From 6dd98e0bede6ef258d59f7336fcab870daf9166e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 30 May 2025 21:07:08 -0400 Subject: [PATCH] feat(ui): add configuration tab in About dialog (#4142) * Flatten config endpoint and improve About dialog * add config resource Signed-off-by: Deluan * fix(ui): replace `==` with `===` Signed-off-by: Deluan * feat(ui): add environment variables Signed-off-by: Deluan * feat(ui): add sensitive value redaction Signed-off-by: Deluan * feat(ui): more translations Signed-off-by: Deluan * address PR comments Signed-off-by: Deluan * feat(ui): add configuration export feature in About dialog Signed-off-by: Deluan * feat(ui): translate development flags section header Signed-off-by: Deluan * refactor Signed-off-by: Deluan * feat(api): refactor routes for keepalive and insights endpoints Signed-off-by: Deluan * lint Signed-off-by: Deluan * fix(ui): enhance string escaping in formatTomlValue function Updated the formatTomlValue function to properly escape backslashes in addition to quotes. Added new test cases to ensure correct handling of strings containing both backslashes and quotes. Signed-off-by: Deluan * feat(ui): adjust dialog size Signed-off-by: Deluan --------- Signed-off-by: Deluan --- conf/configuration.go | 2 + resources/i18n/pt-br.json | 17 +- server/nativeapi/config.go | 133 ++++++++++ server/nativeapi/config_test.go | 268 ++++++++++++++++++++ server/nativeapi/native_api.go | 41 +-- server/serve_index.go | 1 + server/serve_index_test.go | 11 + ui/src/App.jsx | 3 + ui/src/common/SongInfo.jsx | 2 +- ui/src/config.js | 1 + ui/src/dialogs/AboutDialog.jsx | 424 ++++++++++++++++++++++++++------ ui/src/i18n/en.json | 15 ++ ui/src/utils/toml.js | 170 +++++++++++++ ui/src/utils/toml.test.js | 363 +++++++++++++++++++++++++++ 14 files changed, 1356 insertions(+), 95 deletions(-) create mode 100644 server/nativeapi/config.go create mode 100644 server/nativeapi/config_test.go create mode 100644 ui/src/utils/toml.js create mode 100644 ui/src/utils/toml.test.js 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) => { )}