mirror of
https://github.com/navidrome/navidrome.git
synced 2025-08-10 00:52:20 +00:00
feat(ui): add configuration tab in About dialog (#4142)
* Flatten config endpoint and improve About dialog * add config resource Signed-off-by: Deluan <deluan@navidrome.org> * fix(ui): replace `==` with `===` Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add environment variables Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add sensitive value redaction Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): more translations Signed-off-by: Deluan <deluan@navidrome.org> * address PR comments Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): add configuration export feature in About dialog Signed-off-by: Deluan <deluan@navidrome.org> * feat(ui): translate development flags section header Signed-off-by: Deluan <deluan@navidrome.org> * refactor Signed-off-by: Deluan <deluan@navidrome.org> * feat(api): refactor routes for keepalive and insights endpoints Signed-off-by: Deluan <deluan@navidrome.org> * lint Signed-off-by: Deluan <deluan@navidrome.org> * 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 <deluan@navidrome.org> * feat(ui): adjust dialog size Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
server/nativeapi/config.go
Normal file
133
server/nativeapi/config.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
268
server/nativeapi/config_test.go
Normal file
268
server/nativeapi/config_test.go
Normal file
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -137,6 +137,9 @@ const Admin = (props) => {
|
||||
<Resource name="playlistTrack" />,
|
||||
<Resource name="keepalive" />,
|
||||
<Resource name="insights" />,
|
||||
permissions === 'admin' && config.devUIShowConfig ? (
|
||||
<Resource name="config" />
|
||||
) : null,
|
||||
<Player />,
|
||||
]}
|
||||
</RAAdmin>
|
||||
|
||||
@@ -138,7 +138,7 @@ export const SongInfo = (props) => {
|
||||
</Tabs>
|
||||
)}
|
||||
<div
|
||||
hidden={tab == 1}
|
||||
hidden={tab === 1}
|
||||
id="mapped-tags-body"
|
||||
aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined}
|
||||
>
|
||||
|
||||
@@ -30,6 +30,7 @@ const defaultConfig = {
|
||||
enableExternalServices: true,
|
||||
enableCoverAnimation: true,
|
||||
devShowArtistPage: true,
|
||||
devUIShowConfig: true,
|
||||
enableReplayGain: true,
|
||||
defaultDownsamplingFormat: 'opus',
|
||||
publicBaseUrl: '/share',
|
||||
|
||||
@@ -10,14 +10,63 @@ import TableRow from '@material-ui/core/TableRow'
|
||||
import TableCell from '@material-ui/core/TableCell'
|
||||
import Paper from '@material-ui/core/Paper'
|
||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||
import FileCopyIcon from '@material-ui/icons/FileCopy'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { humanize, underscore } from 'inflection'
|
||||
import { useGetOne, usePermissions, useTranslate } from 'react-admin'
|
||||
import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin'
|
||||
import { Tabs, Tab } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import config from '../config'
|
||||
import { DialogTitle } from './DialogTitle'
|
||||
import { DialogContent } from './DialogContent'
|
||||
import { INSIGHTS_DOC_URL } from '../consts.js'
|
||||
import subsonic from '../subsonic/index.js'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import TableHead from '@material-ui/core/TableHead'
|
||||
import { configToToml, separateAndSortConfigs } from '../utils/toml'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
configNameColumn: {
|
||||
maxWidth: '200px',
|
||||
width: '200px',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
envVarColumn: {
|
||||
maxWidth: '200px',
|
||||
width: '200px',
|
||||
fontFamily: 'monospace',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
},
|
||||
configFileValue: {
|
||||
maxWidth: '300px',
|
||||
width: '300px',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
},
|
||||
copyButton: {
|
||||
marginBottom: theme.spacing(2),
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
devSectionHeader: {
|
||||
'& td': {
|
||||
paddingTop: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(2),
|
||||
borderTop: `2px solid ${theme.palette.divider}`,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
configContainer: {
|
||||
paddingTop: theme.spacing(1),
|
||||
},
|
||||
tableContainer: {
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
},
|
||||
}))
|
||||
|
||||
const links = {
|
||||
homepage: 'navidrome.org',
|
||||
@@ -54,7 +103,6 @@ const LinkToVersion = ({ version }) => {
|
||||
|
||||
const ShowVersion = ({ uiVersion, serverVersion }) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
const showRefresh = uiVersion !== serverVersion
|
||||
|
||||
return (
|
||||
@@ -90,11 +138,286 @@ const ShowVersion = ({ uiVersion, serverVersion }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const AboutDialog = ({ open, onClose }) => {
|
||||
const AboutTabContent = ({
|
||||
uiVersion,
|
||||
serverVersion,
|
||||
insightsData,
|
||||
loading,
|
||||
permissions,
|
||||
}) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
const lastRun = !loading && insightsData?.lastRun
|
||||
let insightsStatus = 'N/A'
|
||||
if (lastRun === 'disabled') {
|
||||
insightsStatus = translate('about.links.insights.disabled')
|
||||
} else if (lastRun && lastRun?.startsWith('1969-12-31')) {
|
||||
insightsStatus = translate('about.links.insights.waiting')
|
||||
} else if (lastRun) {
|
||||
insightsStatus = lastRun
|
||||
}
|
||||
|
||||
return (
|
||||
<Table aria-label={translate('menu.about')} size="small">
|
||||
<TableBody>
|
||||
<ShowVersion uiVersion={uiVersion} serverVersion={serverVersion} />
|
||||
{Object.keys(links).map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.${key}`, {
|
||||
_: humanize(underscore(key)),
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={`https://${links[key]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{links[key]}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{permissions === 'admin' ? (
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.lastInsightsCollection`)}:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
<Link
|
||||
href={'https://github.com/sponsors/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconButton size={'small'}>
|
||||
<FavoriteBorderIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={'https://ko-fi.com/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ko-fi.com/deluan
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const ConfigTabContent = ({ configData }) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
|
||||
if (!configData || !configData.config) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the shared separation and sorting logic
|
||||
const { regularConfigs, devConfigs } = separateAndSortConfigs(
|
||||
configData.config,
|
||||
)
|
||||
|
||||
const handleCopyToml = async () => {
|
||||
try {
|
||||
const tomlContent = configToToml(configData, translate)
|
||||
await navigator.clipboard.writeText(tomlContent)
|
||||
notify(translate('about.config.exportSuccess'), 'info')
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to copy TOML:', err)
|
||||
notify(translate('about.config.exportFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.configContainer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileCopyIcon />}
|
||||
onClick={handleCopyToml}
|
||||
className={classes.copyButton}
|
||||
disabled={!configData}
|
||||
size="small"
|
||||
>
|
||||
{translate('about.config.exportToml')}
|
||||
</Button>
|
||||
<TableContainer className={classes.tableContainer}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="col"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{translate('about.config.configName')}
|
||||
</TableCell>
|
||||
<TableCell align="left" component="th" scope="col">
|
||||
{translate('about.config.environmentVariable')}
|
||||
</TableCell>
|
||||
<TableCell align="left" component="th" scope="col">
|
||||
{translate('about.config.currentValue')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{configData?.configFile && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{translate('about.config.configurationFile')}
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.envVarColumn}>
|
||||
ND_CONFIGFILE
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.configFileValue}>
|
||||
{configData.configFile}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{regularConfigs.map(({ key, envVar, value }) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{key}
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.envVarColumn}>
|
||||
{envVar}
|
||||
</TableCell>
|
||||
<TableCell align="left">{String(value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{devConfigs.length > 0 && (
|
||||
<TableRow className={classes.devSectionHeader}>
|
||||
<TableCell colSpan={3}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
style={{ fontWeight: 600 }}
|
||||
>
|
||||
🚧 {translate('about.config.devFlagsHeader')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{devConfigs.map(({ key, envVar, value }) => (
|
||||
<TableRow key={key}>
|
||||
<TableCell
|
||||
align="left"
|
||||
component="th"
|
||||
scope="row"
|
||||
className={classes.configNameColumn}
|
||||
>
|
||||
{key}
|
||||
</TableCell>
|
||||
<TableCell align="left" className={classes.envVarColumn}>
|
||||
{envVar}
|
||||
</TableCell>
|
||||
<TableCell align="left">{String(value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TabContent = ({
|
||||
tab,
|
||||
setTab,
|
||||
showConfigTab,
|
||||
uiVersion,
|
||||
serverVersion,
|
||||
insightsData,
|
||||
loading,
|
||||
permissions,
|
||||
configData,
|
||||
}) => {
|
||||
const translate = useTranslate()
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
{showConfigTab && (
|
||||
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
|
||||
<Tab
|
||||
label={translate('about.tabs.about')}
|
||||
id="about-tab"
|
||||
aria-controls="about-panel"
|
||||
/>
|
||||
<Tab
|
||||
label={translate('about.tabs.config')}
|
||||
id="config-tab"
|
||||
aria-controls="config-panel"
|
||||
/>
|
||||
</Tabs>
|
||||
)}
|
||||
<div
|
||||
id="about-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="about-tab"
|
||||
hidden={showConfigTab && tab === 1}
|
||||
>
|
||||
<AboutTabContent
|
||||
uiVersion={uiVersion}
|
||||
serverVersion={serverVersion}
|
||||
insightsData={insightsData}
|
||||
loading={loading}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</div>
|
||||
{showConfigTab && (
|
||||
<div
|
||||
id="config-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="config-tab"
|
||||
hidden={tab === 0}
|
||||
>
|
||||
<ConfigTabContent configData={configData} />
|
||||
</div>
|
||||
)}
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const AboutDialog = ({ open, onClose }) => {
|
||||
const { permissions } = usePermissions()
|
||||
const { data, loading } = useGetOne('insights', 'insights_status')
|
||||
const { data: insightsData, loading } = useGetOne(
|
||||
'insights',
|
||||
'insights_status',
|
||||
)
|
||||
const [serverVersion, setServerVersion] = useState('')
|
||||
const showConfigTab = permissions === 'admin' && config.devUIShowConfig
|
||||
const [tab, setTab] = useState(0)
|
||||
const { data: configData } = useGetOne('config', 'config', {
|
||||
enabled: showConfigTab,
|
||||
})
|
||||
const expanded = showConfigTab && tab === 1
|
||||
const uiVersion = config.version
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,85 +435,30 @@ const AboutDialog = ({ open, onClose }) => {
|
||||
})
|
||||
}, [setServerVersion])
|
||||
|
||||
const lastRun = !loading && data?.lastRun
|
||||
let insightsStatus = 'N/A'
|
||||
if (lastRun === 'disabled') {
|
||||
insightsStatus = translate('about.links.insights.disabled')
|
||||
} else if (lastRun && lastRun?.startsWith('1969-12-31')) {
|
||||
insightsStatus = translate('about.links.insights.waiting')
|
||||
} else if (lastRun) {
|
||||
insightsStatus = lastRun
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}>
|
||||
<Dialog
|
||||
onClose={onClose}
|
||||
aria-labelledby="about-dialog-title"
|
||||
open={open}
|
||||
fullWidth={true}
|
||||
maxWidth={expanded ? 'lg' : 'sm'}
|
||||
style={{ transition: 'max-width 300ms ease' }}
|
||||
>
|
||||
<DialogTitle id="about-dialog-title" onClose={onClose}>
|
||||
Navidrome Music Server
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TableContainer component={Paper}>
|
||||
<Table aria-label={translate('menu.about')} size="small">
|
||||
<TableBody>
|
||||
<ShowVersion
|
||||
uiVersion={uiVersion}
|
||||
serverVersion={serverVersion}
|
||||
/>
|
||||
{Object.keys(links).map((key) => {
|
||||
return (
|
||||
<TableRow key={key}>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.${key}`, {
|
||||
_: humanize(underscore(key)),
|
||||
})}
|
||||
:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={`https://${links[key]}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{links[key]}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{permissions === 'admin' ? (
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
{translate(`about.links.lastInsightsCollection`)}:
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
<TableRow>
|
||||
<TableCell align="right" component="th" scope="row">
|
||||
<Link
|
||||
href={'https://github.com/sponsors/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconButton size={'small'}>
|
||||
<FavoriteBorderIcon fontSize={'small'} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell align="left">
|
||||
<Link
|
||||
href={'https://ko-fi.com/deluan'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ko-fi.com/deluan
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TabContent
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
showConfigTab={showConfigTab}
|
||||
uiVersion={uiVersion}
|
||||
serverVersion={serverVersion}
|
||||
insightsData={insightsData}
|
||||
loading={loading}
|
||||
permissions={permissions}
|
||||
configData={configData}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -498,6 +498,21 @@
|
||||
"disabled": "Disabled",
|
||||
"waiting": "Waiting"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "About",
|
||||
"config": "Configuration"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Config Name",
|
||||
"environmentVariable": "Environment Variable",
|
||||
"currentValue": "Current Value",
|
||||
"configurationFile": "Configuration File",
|
||||
"exportToml": "Export Configuration (TOML)",
|
||||
"exportSuccess": "Configuration exported to clipboard in TOML format",
|
||||
"exportFailed": "Failed to copy configuration",
|
||||
"devFlagsHeader": "Development Flags (subject to change/removal)",
|
||||
"devFlagsComment": "These are experimental settings and may be removed in future versions"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
|
||||
170
ui/src/utils/toml.js
Normal file
170
ui/src/utils/toml.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* TOML utility functions for configuration export
|
||||
*/
|
||||
|
||||
/**
|
||||
* Separates and sorts configuration entries into regular and dev configs
|
||||
* @param {Array} configEntries - Array of config objects with key and value
|
||||
* @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted
|
||||
*/
|
||||
export const separateAndSortConfigs = (configEntries) => {
|
||||
const regularConfigs = []
|
||||
const devConfigs = []
|
||||
|
||||
configEntries?.forEach((config) => {
|
||||
// Skip configFile as it's displayed separately
|
||||
if (config.key === 'ConfigFile') {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.key.startsWith('Dev')) {
|
||||
devConfigs.push(config)
|
||||
} else {
|
||||
regularConfigs.push(config)
|
||||
}
|
||||
})
|
||||
|
||||
// Sort configurations alphabetically
|
||||
regularConfigs.sort((a, b) => a.key.localeCompare(b.key))
|
||||
devConfigs.sort((a, b) => a.key.localeCompare(b.key))
|
||||
|
||||
return { regularConfigs, devConfigs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a value to proper TOML format
|
||||
* @param {*} value - The value to format
|
||||
* @returns {string} - The TOML-formatted value
|
||||
*/
|
||||
export const formatTomlValue = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '""'
|
||||
}
|
||||
|
||||
const str = String(value)
|
||||
|
||||
// Boolean values
|
||||
if (str === 'true' || str === 'false') {
|
||||
return str
|
||||
}
|
||||
|
||||
// Numbers (integers and floats)
|
||||
if (/^-?\d+$/.test(str)) {
|
||||
return str // Integer
|
||||
}
|
||||
if (/^-?\d*\.\d+$/.test(str)) {
|
||||
return str // Float
|
||||
}
|
||||
|
||||
// Duration values (like "300ms", "1s", "5m")
|
||||
if (/^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)$/.test(str)) {
|
||||
return `"${str}"`
|
||||
}
|
||||
|
||||
// Arrays/JSON objects
|
||||
if (str.startsWith('[') || str.startsWith('{')) {
|
||||
try {
|
||||
JSON.parse(str)
|
||||
return `"""${str}"""`
|
||||
} catch {
|
||||
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
}
|
||||
|
||||
// String values (escape backslashes and quotes)
|
||||
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts nested keys to TOML sections
|
||||
* @param {Array} configs - Array of config objects with key and value
|
||||
* @returns {Object} - Object with sections and rootKeys
|
||||
*/
|
||||
export const buildTomlSections = (configs) => {
|
||||
const sections = {}
|
||||
const rootKeys = []
|
||||
|
||||
configs.forEach(({ key, value }) => {
|
||||
if (key.includes('.')) {
|
||||
const parts = key.split('.')
|
||||
const sectionName = parts[0]
|
||||
const keyName = parts.slice(1).join('.')
|
||||
|
||||
if (!sections[sectionName]) {
|
||||
sections[sectionName] = []
|
||||
}
|
||||
sections[sectionName].push({ key: keyName, value })
|
||||
} else {
|
||||
rootKeys.push({ key, value })
|
||||
}
|
||||
})
|
||||
|
||||
return { sections, rootKeys }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts configuration data to TOML format
|
||||
* @param {Object} configData - The configuration data object
|
||||
* @param {Function} translate - Translation function for internationalization
|
||||
* @returns {string} - The TOML-formatted configuration
|
||||
*/
|
||||
export const configToToml = (configData, translate = (key) => key) => {
|
||||
let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n`
|
||||
|
||||
const { regularConfigs, devConfigs } = separateAndSortConfigs(
|
||||
configData.config,
|
||||
)
|
||||
|
||||
// Process regular configs
|
||||
const { sections: regularSections, rootKeys: regularRootKeys } =
|
||||
buildTomlSections(regularConfigs)
|
||||
|
||||
// Add root-level keys first
|
||||
if (regularRootKeys.length > 0) {
|
||||
regularRootKeys.forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
tomlContent += '\n'
|
||||
}
|
||||
|
||||
// Add sections
|
||||
Object.keys(regularSections)
|
||||
.sort()
|
||||
.forEach((sectionName) => {
|
||||
tomlContent += `[${sectionName}]\n`
|
||||
regularSections[sectionName].forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
tomlContent += '\n'
|
||||
})
|
||||
|
||||
// Add dev configs if any
|
||||
if (devConfigs.length > 0) {
|
||||
tomlContent += `# ${translate('about.config.devFlagsHeader')}\n`
|
||||
tomlContent += `# ${translate('about.config.devFlagsComment')}\n\n`
|
||||
|
||||
const { sections: devSections, rootKeys: devRootKeys } =
|
||||
buildTomlSections(devConfigs)
|
||||
|
||||
// Add dev root-level keys
|
||||
devRootKeys.forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
if (devRootKeys.length > 0) {
|
||||
tomlContent += '\n'
|
||||
}
|
||||
|
||||
// Add dev sections
|
||||
Object.keys(devSections)
|
||||
.sort()
|
||||
.forEach((sectionName) => {
|
||||
tomlContent += `[${sectionName}]\n`
|
||||
devSections[sectionName].forEach(({ key, value }) => {
|
||||
tomlContent += `${key} = ${formatTomlValue(value)}\n`
|
||||
})
|
||||
tomlContent += '\n'
|
||||
})
|
||||
}
|
||||
|
||||
return tomlContent
|
||||
}
|
||||
363
ui/src/utils/toml.test.js
Normal file
363
ui/src/utils/toml.test.js
Normal file
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
formatTomlValue,
|
||||
buildTomlSections,
|
||||
configToToml,
|
||||
separateAndSortConfigs,
|
||||
} from './toml'
|
||||
|
||||
describe('formatTomlValue', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
expect(formatTomlValue(null)).toBe('""')
|
||||
expect(formatTomlValue(undefined)).toBe('""')
|
||||
})
|
||||
|
||||
it('handles boolean values', () => {
|
||||
expect(formatTomlValue('true')).toBe('true')
|
||||
expect(formatTomlValue('false')).toBe('false')
|
||||
expect(formatTomlValue(true)).toBe('true')
|
||||
expect(formatTomlValue(false)).toBe('false')
|
||||
})
|
||||
|
||||
it('handles integer values', () => {
|
||||
expect(formatTomlValue('123')).toBe('123')
|
||||
expect(formatTomlValue('-456')).toBe('-456')
|
||||
expect(formatTomlValue('0')).toBe('0')
|
||||
expect(formatTomlValue(789)).toBe('789')
|
||||
})
|
||||
|
||||
it('handles float values', () => {
|
||||
expect(formatTomlValue('123.45')).toBe('123.45')
|
||||
expect(formatTomlValue('-67.89')).toBe('-67.89')
|
||||
expect(formatTomlValue('0.0')).toBe('0.0')
|
||||
expect(formatTomlValue(12.34)).toBe('12.34')
|
||||
})
|
||||
|
||||
it('handles duration values', () => {
|
||||
expect(formatTomlValue('300ms')).toBe('"300ms"')
|
||||
expect(formatTomlValue('5s')).toBe('"5s"')
|
||||
expect(formatTomlValue('10m')).toBe('"10m"')
|
||||
expect(formatTomlValue('2h')).toBe('"2h"')
|
||||
expect(formatTomlValue('1.5s')).toBe('"1.5s"')
|
||||
})
|
||||
|
||||
it('handles JSON arrays and objects', () => {
|
||||
expect(formatTomlValue('["item1", "item2"]')).toBe(
|
||||
'"""["item1", "item2"]"""',
|
||||
)
|
||||
expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""')
|
||||
})
|
||||
|
||||
it('handles invalid JSON as regular strings', () => {
|
||||
expect(formatTomlValue('[invalid json')).toBe('"[invalid json"')
|
||||
expect(formatTomlValue('{broken')).toBe('"{broken"')
|
||||
})
|
||||
|
||||
it('handles regular strings with quote escaping', () => {
|
||||
expect(formatTomlValue('simple string')).toBe('"simple string"')
|
||||
expect(formatTomlValue('string with "quotes"')).toBe(
|
||||
'"string with \\"quotes\\""',
|
||||
)
|
||||
expect(formatTomlValue('/path/to/file')).toBe('"/path/to/file"')
|
||||
})
|
||||
|
||||
it('handles strings with backslashes and quotes', () => {
|
||||
expect(formatTomlValue('C:\\Program Files\\app')).toBe(
|
||||
'"C:\\\\Program Files\\\\app"',
|
||||
)
|
||||
expect(formatTomlValue('path\\to"file')).toBe('"path\\\\to\\"file"')
|
||||
expect(formatTomlValue('backslash\\ and "quote"')).toBe(
|
||||
'"backslash\\\\ and \\"quote\\""',
|
||||
)
|
||||
expect(formatTomlValue('single\\backslash')).toBe('"single\\\\backslash"')
|
||||
})
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(formatTomlValue('')).toBe('""')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTomlSections', () => {
|
||||
it('separates root keys from nested keys', () => {
|
||||
const configs = [
|
||||
{ key: 'RootKey1', value: 'value1' },
|
||||
{ key: 'Section.NestedKey', value: 'value2' },
|
||||
{ key: 'RootKey2', value: 'value3' },
|
||||
{ key: 'Section.AnotherKey', value: 'value4' },
|
||||
{ key: 'AnotherSection.Key', value: 'value5' },
|
||||
]
|
||||
|
||||
const result = buildTomlSections(configs)
|
||||
|
||||
expect(result.rootKeys).toEqual([
|
||||
{ key: 'RootKey1', value: 'value1' },
|
||||
{ key: 'RootKey2', value: 'value3' },
|
||||
])
|
||||
|
||||
expect(result.sections).toEqual({
|
||||
Section: [
|
||||
{ key: 'NestedKey', value: 'value2' },
|
||||
{ key: 'AnotherKey', value: 'value4' },
|
||||
],
|
||||
AnotherSection: [{ key: 'Key', value: 'value5' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('handles deeply nested keys', () => {
|
||||
const configs = [{ key: 'Section.SubSection.DeepKey', value: 'deepValue' }]
|
||||
|
||||
const result = buildTomlSections(configs)
|
||||
|
||||
expect(result.rootKeys).toEqual([])
|
||||
expect(result.sections).toEqual({
|
||||
Section: [{ key: 'SubSection.DeepKey', value: 'deepValue' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
const result = buildTomlSections([])
|
||||
|
||||
expect(result.rootKeys).toEqual([])
|
||||
expect(result.sections).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('configToToml', () => {
|
||||
const mockTranslate = (key) => {
|
||||
const translations = {
|
||||
'about.config.devFlagsHeader':
|
||||
'Development Flags (subject to change/removal)',
|
||||
'about.config.devFlagsComment':
|
||||
'These are experimental settings and may be removed in future versions',
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
|
||||
it('generates TOML with header and timestamp', () => {
|
||||
const configData = {
|
||||
config: [{ key: 'TestKey', value: 'testValue' }],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Navidrome Configuration')
|
||||
expect(result).toContain('# Generated on')
|
||||
expect(result).toContain('TestKey = "testValue"')
|
||||
})
|
||||
|
||||
it('separates and sorts regular and dev configs', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'ZRegularKey', value: 'regularValue' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
{ key: 'ARegularKey', value: 'anotherValue' },
|
||||
{ key: 'DevAnotherFlag', value: 'false' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
// Check that regular configs come first and are sorted
|
||||
const lines = result.split('\n')
|
||||
const aRegularIndex = lines.findIndex((line) =>
|
||||
line.includes('ARegularKey'),
|
||||
)
|
||||
const zRegularIndex = lines.findIndex((line) =>
|
||||
line.includes('ZRegularKey'),
|
||||
)
|
||||
const devHeaderIndex = lines.findIndex((line) =>
|
||||
line.includes('Development Flags'),
|
||||
)
|
||||
const devAnotherIndex = lines.findIndex((line) =>
|
||||
line.includes('DevAnotherFlag'),
|
||||
)
|
||||
const devTestIndex = lines.findIndex((line) => line.includes('DevTestFlag'))
|
||||
|
||||
expect(aRegularIndex).toBeLessThan(zRegularIndex)
|
||||
expect(zRegularIndex).toBeLessThan(devHeaderIndex)
|
||||
expect(devHeaderIndex).toBeLessThan(devAnotherIndex)
|
||||
expect(devAnotherIndex).toBeLessThan(devTestIndex)
|
||||
})
|
||||
|
||||
it('skips ConfigFile entries', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'ConfigFile', value: '/path/to/config.toml' },
|
||||
{ key: 'TestKey', value: 'testValue' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).not.toContain('ConfigFile =')
|
||||
expect(result).toContain('TestKey = "testValue"')
|
||||
})
|
||||
|
||||
it('handles sections correctly', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'RootKey', value: 'rootValue' },
|
||||
{ key: 'Section.NestedKey', value: 'nestedValue' },
|
||||
{ key: 'Section.AnotherKey', value: 'anotherValue' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('RootKey = "rootValue"')
|
||||
expect(result).toContain('[Section]')
|
||||
expect(result).toContain('NestedKey = "nestedValue"')
|
||||
expect(result).toContain('AnotherKey = "anotherValue"')
|
||||
})
|
||||
|
||||
it('includes dev flags header when dev configs exist', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'RegularKey', value: 'regularValue' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Development Flags (subject to change/removal)')
|
||||
expect(result).toContain(
|
||||
'# These are experimental settings and may be removed in future versions',
|
||||
)
|
||||
expect(result).toContain('DevTestFlag = true')
|
||||
})
|
||||
|
||||
it('does not include dev flags header when no dev configs exist', () => {
|
||||
const configData = {
|
||||
config: [{ key: 'RegularKey', value: 'regularValue' }],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).not.toContain('Development Flags')
|
||||
expect(result).toContain('RegularKey = "regularValue"')
|
||||
})
|
||||
|
||||
it('handles empty config data', () => {
|
||||
const configData = { config: [] }
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Navidrome Configuration')
|
||||
expect(result).not.toContain('Development Flags')
|
||||
})
|
||||
|
||||
it('handles missing config array', () => {
|
||||
const configData = {}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('# Navidrome Configuration')
|
||||
expect(result).not.toContain('Development Flags')
|
||||
})
|
||||
|
||||
it('works without translate function', () => {
|
||||
const configData = {
|
||||
config: [{ key: 'DevTestFlag', value: 'true' }],
|
||||
}
|
||||
|
||||
const result = configToToml(configData)
|
||||
|
||||
expect(result).toContain('# about.config.devFlagsHeader')
|
||||
expect(result).toContain('# about.config.devFlagsComment')
|
||||
expect(result).toContain('DevTestFlag = true')
|
||||
})
|
||||
|
||||
it('handles various data types correctly', () => {
|
||||
const configData = {
|
||||
config: [
|
||||
{ key: 'StringValue', value: 'test string' },
|
||||
{ key: 'BooleanValue', value: 'true' },
|
||||
{ key: 'IntegerValue', value: '42' },
|
||||
{ key: 'FloatValue', value: '3.14' },
|
||||
{ key: 'DurationValue', value: '5s' },
|
||||
{ key: 'ArrayValue', value: '["item1", "item2"]' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = configToToml(configData, mockTranslate)
|
||||
|
||||
expect(result).toContain('StringValue = "test string"')
|
||||
expect(result).toContain('BooleanValue = true')
|
||||
expect(result).toContain('IntegerValue = 42')
|
||||
expect(result).toContain('FloatValue = 3.14')
|
||||
expect(result).toContain('DurationValue = "5s"')
|
||||
expect(result).toContain('ArrayValue = """["item1", "item2"]"""')
|
||||
})
|
||||
})
|
||||
|
||||
describe('separateAndSortConfigs', () => {
|
||||
it('separates regular and dev configs correctly', () => {
|
||||
const configs = [
|
||||
{ key: 'RegularKey1', value: 'value1' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
{ key: 'AnotherRegular', value: 'value2' },
|
||||
{ key: 'DevAnotherFlag', value: 'false' },
|
||||
]
|
||||
|
||||
const result = separateAndSortConfigs(configs)
|
||||
|
||||
expect(result.regularConfigs).toEqual([
|
||||
{ key: 'AnotherRegular', value: 'value2' },
|
||||
{ key: 'RegularKey1', value: 'value1' },
|
||||
])
|
||||
|
||||
expect(result.devConfigs).toEqual([
|
||||
{ key: 'DevAnotherFlag', value: 'false' },
|
||||
{ key: 'DevTestFlag', value: 'true' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips ConfigFile entries', () => {
|
||||
const configs = [
|
||||
{ key: 'ConfigFile', value: '/path/to/config.toml' },
|
||||
{ key: 'RegularKey', value: 'value' },
|
||||
{ key: 'DevFlag', value: 'true' },
|
||||
]
|
||||
|
||||
const result = separateAndSortConfigs(configs)
|
||||
|
||||
expect(result.regularConfigs).toEqual([
|
||||
{ key: 'RegularKey', value: 'value' },
|
||||
])
|
||||
expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }])
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
const result = separateAndSortConfigs([])
|
||||
|
||||
expect(result.regularConfigs).toEqual([])
|
||||
expect(result.devConfigs).toEqual([])
|
||||
})
|
||||
|
||||
it('handles null/undefined input', () => {
|
||||
const result1 = separateAndSortConfigs(null)
|
||||
const result2 = separateAndSortConfigs(undefined)
|
||||
|
||||
expect(result1.regularConfigs).toEqual([])
|
||||
expect(result1.devConfigs).toEqual([])
|
||||
expect(result2.regularConfigs).toEqual([])
|
||||
expect(result2.devConfigs).toEqual([])
|
||||
})
|
||||
|
||||
it('sorts configs alphabetically', () => {
|
||||
const configs = [
|
||||
{ key: 'ZRegular', value: 'z' },
|
||||
{ key: 'ARegular', value: 'a' },
|
||||
{ key: 'DevZ', value: 'z' },
|
||||
{ key: 'DevA', value: 'a' },
|
||||
]
|
||||
|
||||
const result = separateAndSortConfigs(configs)
|
||||
|
||||
expect(result.regularConfigs[0].key).toBe('ARegular')
|
||||
expect(result.regularConfigs[1].key).toBe('ZRegular')
|
||||
expect(result.devConfigs[0].key).toBe('DevA')
|
||||
expect(result.devConfigs[1].key).toBe('DevZ')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user