diff --git a/conf/configuration.go b/conf/configuration.go index 67f43294d..64a4e6a75 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -112,6 +112,7 @@ type configOptions struct { DevActivityPanelUpdateRate time.Duration DevSidebarPlaylists bool DevShowArtistPage bool + DevUIShowConfig bool DevOffsetOptimize int DevArtworkMaxRequests int DevArtworkThrottleBacklogLimit int @@ -553,6 +554,7 @@ func setViperDefaults() { viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond) viper.SetDefault("devsidebarplaylists", true) viper.SetDefault("devshowartistpage", true) + viper.SetDefault("devuishowconfig", true) viper.SetDefault("devoffsetoptimize", 50000) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index febdcf769..cc771e8fa 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -496,6 +496,21 @@ "disabled": "Desligado", "waiting": "Aguardando" } + }, + "tabs": { + "about": "Sobre", + "config": "Configuração" + }, + "config": { + "configName": "Nome da Configuração", + "environmentVariable": "Variável de Ambiente", + "currentValue": "Valor Atual", + "configurationFile": "Arquivo de Configuração", + "exportToml": "Exportar Configuração (TOML)", + "exportSuccess": "Configuração exportada para o clipboard em formato TOML", + "exportFailed": "Falha ao copiar configuração", + "devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)", + "devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras" } }, "activity": { @@ -523,4 +538,4 @@ "current_song": "Vai para música atual" } } -} \ No newline at end of file +} diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go new file mode 100644 index 000000000..500e9098f --- /dev/null +++ b/server/nativeapi/config.go @@ -0,0 +1,133 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" +) + +// sensitiveFieldsPartialMask contains configuration field names that should be redacted +// using partial masking (first and last character visible, middle replaced with *). +// For values with 7+ characters: "secretvalue123" becomes "s***********3" +// For values with <7 characters: "short" becomes "****" +// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret") +var sensitiveFieldsPartialMask = []string{ + "LastFM.ApiKey", + "LastFM.Secret", + "Prometheus.MetricsPath", + "Spotify.ID", + "Spotify.Secret", + "DevAutoLoginUsername", +} + +// sensitiveFieldsFullMask contains configuration field names that should always be +// completely masked with "****" regardless of their length. +// Add field paths using dot notation for any fields that should never show any content. +var sensitiveFieldsFullMask = []string{ + "DevAutoCreateAdminPassword", + "PasswordEncryptionKey", + "Prometheus.Password", +} + +type configEntry struct { + Key string `json:"key"` + EnvVar string `json:"envVar"` + Value interface{} `json:"value"` +} + +type configResponse struct { + ID string `json:"id"` + ConfigFile string `json:"configFile"` + Config []configEntry `json:"config"` +} + +func redactValue(key string, value string) string { + // Return empty values as-is + if len(value) == 0 { + return value + } + + // Check if this field should be fully masked + for _, field := range sensitiveFieldsFullMask { + if field == key { + return "****" + } + } + + // Check if this field should be partially masked + for _, field := range sensitiveFieldsPartialMask { + if field == key { + if len(value) < 7 { + return "****" + } + // Show first and last character with * in between + return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1]) + } + } + + // Return original value if not sensitive + return value +} + +func flatten(ctx context.Context, entries *[]configEntry, prefix string, v reflect.Value) { + if v.Kind() == reflect.Struct && v.Type().PkgPath() != "time" { + t := v.Type() + for i := 0; i < v.NumField(); i++ { + if !t.Field(i).IsExported() { + continue + } + flatten(ctx, entries, prefix+"."+t.Field(i).Name, v.Field(i)) + } + return + } + + key := strings.TrimPrefix(prefix, ".") + envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) + var val interface{} + switch v.Kind() { + case reflect.Map, reflect.Slice, reflect.Array: + b, err := json.Marshal(v.Interface()) + if err != nil { + log.Error(ctx, "Error marshalling config value", "key", key, err) + val = "error marshalling value" + } else { + val = string(b) + } + default: + originalValue := fmt.Sprint(v.Interface()) + val = redactValue(key, originalValue) + } + + *entries = append(*entries, configEntry{Key: key, EnvVar: envVar, Value: val}) +} + +func getConfig(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(ctx) + if !user.IsAdmin { + http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized) + return + } + + entries := make([]configEntry, 0) + v := reflect.ValueOf(*conf.Server) + t := reflect.TypeOf(*conf.Server) + for i := 0; i < v.NumField(); i++ { + fieldVal := v.Field(i) + fieldType := t.Field(i) + flatten(ctx, &entries, fieldType.Name, fieldVal) + } + + resp := configResponse{ID: "config", ConfigFile: conf.Server.ConfigFile, Config: entries} + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error(ctx, "Error encoding config response", err) + } +} diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go new file mode 100644 index 000000000..eef8a81a2 --- /dev/null +++ b/server/nativeapi/config_test.go @@ -0,0 +1,268 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("config endpoint", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("rejects non admin users", func() { + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: false}) + getConfig(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("returns configuration entries", func() { + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.ID).To(Equal("config")) + + // Verify that we have both Dev and non-Dev fields + var hasDevFields = false + var hasNonDevFields = false + for _, e := range resp.Config { + if strings.HasPrefix(e.Key, "Dev") { + hasDevFields = true + } else { + hasNonDevFields = true + } + } + + Expect(hasDevFields).To(BeTrue(), "Should have Dev* configuration fields") + Expect(hasNonDevFields).To(BeTrue(), "Should have non-Dev configuration fields") + Expect(len(resp.Config)).To(BeNumerically(">", 0), "Should return configuration entries") + }) + + It("includes flattened struct fields", func() { + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + values := map[string]string{} + for _, e := range resp.Config { + if s, ok := e.Value.(string); ok { + values[e.Key] = s + } + } + Expect(values).To(HaveKeyWithValue("Inspect.MaxRequests", "1")) + Expect(values).To(HaveKeyWithValue("HTTPSecurityHeaders.CustomFrameOptionsValue", "DENY")) + }) + + It("includes the config file path", func() { + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.ConfigFile).To(Not(BeEmpty())) + }) + + It("includes environment variable names", func() { + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + // Create a map to check specific env var mappings + envVars := map[string]string{} + for _, e := range resp.Config { + envVars[e.Key] = e.EnvVar + } + + Expect(envVars).To(HaveKeyWithValue("MusicFolder", "ND_MUSICFOLDER")) + Expect(envVars).To(HaveKeyWithValue("Scanner.Enabled", "ND_SCANNER_ENABLED")) + Expect(envVars).To(HaveKeyWithValue("HTTPSecurityHeaders.CustomFrameOptionsValue", "ND_HTTPSECURITYHEADERS_CUSTOMFRAMEOPTIONSVALUE")) + }) + + Context("redaction functionality", func() { + It("redacts sensitive values with partial masking for long values", func() { + // Set up test values + conf.Server.LastFM.ApiKey = "ba46f0e84a123456" + conf.Server.Spotify.Secret = "verylongsecret123" + + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + values := map[string]string{} + for _, e := range resp.Config { + if s, ok := e.Value.(string); ok { + values[e.Key] = s + } + } + + Expect(values).To(HaveKeyWithValue("LastFM.ApiKey", "b**************6")) + Expect(values).To(HaveKeyWithValue("Spotify.Secret", "v***************3")) + }) + + It("redacts sensitive values with full masking for short values", func() { + // Set up test values with short secrets + conf.Server.LastFM.Secret = "short" + conf.Server.Spotify.ID = "abc123" + + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + values := map[string]string{} + for _, e := range resp.Config { + if s, ok := e.Value.(string); ok { + values[e.Key] = s + } + } + + Expect(values).To(HaveKeyWithValue("LastFM.Secret", "****")) + Expect(values).To(HaveKeyWithValue("Spotify.ID", "****")) + }) + + It("fully masks password fields", func() { + // Set up test values for password fields + conf.Server.DevAutoCreateAdminPassword = "adminpass123" + conf.Server.Prometheus.Password = "prometheuspass" + + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + values := map[string]string{} + for _, e := range resp.Config { + if s, ok := e.Value.(string); ok { + values[e.Key] = s + } + } + + Expect(values).To(HaveKeyWithValue("DevAutoCreateAdminPassword", "****")) + Expect(values).To(HaveKeyWithValue("Prometheus.Password", "****")) + }) + + It("does not redact non-sensitive values", func() { + conf.Server.MusicFolder = "/path/to/music" + conf.Server.Port = 4533 + + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + values := map[string]string{} + for _, e := range resp.Config { + if s, ok := e.Value.(string); ok { + values[e.Key] = s + } + } + + Expect(values).To(HaveKeyWithValue("MusicFolder", "/path/to/music")) + Expect(values).To(HaveKeyWithValue("Port", "4533")) + }) + + It("handles empty sensitive values", func() { + conf.Server.LastFM.ApiKey = "" + conf.Server.PasswordEncryptionKey = "" + + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + values := map[string]string{} + for _, e := range resp.Config { + if s, ok := e.Value.(string); ok { + values[e.Key] = s + } + } + + // Empty sensitive values should remain empty + Expect(values["LastFM.ApiKey"]).To(Equal("")) + Expect(values["PasswordEncryptionKey"]).To(Equal("")) + }) + }) +}) + +var _ = Describe("redactValue function", func() { + It("partially masks long sensitive values", func() { + Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a")) + Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3")) + }) + + It("fully masks long sensitive values that should be completely hidden", func() { + Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****")) + Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****")) + Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****")) + }) + + It("fully masks short sensitive values", func() { + Expect(redactValue("LastFM.Secret", "short")).To(Equal("****")) + Expect(redactValue("Spotify.ID", "abc")).To(Equal("****")) + Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****")) + Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****")) + Expect(redactValue("Prometheus.Password", "short")).To(Equal("****")) + }) + + It("does not mask non-sensitive values", func() { + Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music")) + Expect(redactValue("Port", "4533")).To(Equal("4533")) + Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue")) + }) + + It("handles empty values", func() { + Expect(redactValue("LastFM.ApiKey", "")).To(Equal("")) + Expect(redactValue("NonSensitive", "")).To(Equal("")) + }) + + It("handles edge case values", func() { + Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****")) + Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****")) + Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g")) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index ddf5df1c3..f2c13fa3a 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -61,21 +61,9 @@ func (n *Router) routes() http.Handler { n.addPlaylistTrackRoute(r) n.addMissingFilesRoute(r) n.addInspectRoute(r) - - // Keepalive endpoint to be used to keep the session valid (ex: while playing songs) - r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) - }) - - // Insights status endpoint - r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { - last, success := n.insights.LastRun(r.Context()) - if conf.Server.EnableInsightsCollector { - _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) - } else { - _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`)) - } - }) + n.addConfigRoute(r) + n.addKeepAliveRoute(r) + n.addInsightsRoute(r) }) return r @@ -196,3 +184,26 @@ func (n *Router) addInspectRoute(r chi.Router) { }) } } + +func (n *Router) addConfigRoute(r chi.Router) { + if conf.Server.DevUIShowConfig { + r.Get("/config/*", getConfig) + } +} + +func (n *Router) addKeepAliveRoute(r chi.Router) { + r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) + }) +} + +func (n *Router) addInsightsRoute(r chi.Router) { + r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { + last, success := n.insights.LastRun(r.Context()) + if conf.Server.EnableInsightsCollector { + _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) + } else { + _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`)) + } + }) +} diff --git a/server/serve_index.go b/server/serve_index.go index 9a457ac20..1e55743f0 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -65,6 +65,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "devSidebarPlaylists": conf.Server.DevSidebarPlaylists, "lastFMEnabled": conf.Server.LastFM.Enabled, "devShowArtistPage": conf.Server.DevShowArtistPage, + "devUIShowConfig": conf.Server.DevUIShowConfig, "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, "enableExternalServices": conf.Server.EnableExternalServices, "enableReplayGain": conf.Server.EnableReplayGain, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 0f02153fd..fd0d42193 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -304,6 +304,17 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("devShowArtistPage", true)) }) + It("sets the devUIShowConfig", func() { + conf.Server.DevUIShowConfig = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("devUIShowConfig", true)) + }) + It("sets the listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true r := httptest.NewRequest("GET", "/index.html", nil) diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 1b89f7b8c..4a38051b4 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -137,6 +137,9 @@ const Admin = (props) => { , , , + permissions === 'admin' && config.devUIShowConfig ? ( + + ) : null, , ]} diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx index 77e91b653..9b9ca18cd 100644 --- a/ui/src/common/SongInfo.jsx +++ b/ui/src/common/SongInfo.jsx @@ -138,7 +138,7 @@ export const SongInfo = (props) => { )}