refactor: simplify configuration endpoint with JSON serialization (#4159)

* refactor(config): reorganize configuration handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(aboutUtils): improve array formatting and handling in TOML conversion

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(aboutUtils): add escapeTomlKey function to handle special characters in TOML keys

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(test): remove unused getNestedValue function

* fix(ui): apply prettier formatting

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-05-31 19:37:23 -04:00
committed by GitHub
parent 8e32eeae93
commit 36ed2f2f58
6 changed files with 598 additions and 248 deletions
+20 -21
View File
@@ -66,6 +66,7 @@ type configOptions struct {
CoverArtPriority string CoverArtPriority string
CoverJpegQuality int CoverJpegQuality int
ArtistArtPriority string ArtistArtPriority string
LyricsPriority string
EnableGravatar bool EnableGravatar bool
EnableFavourites bool EnableFavourites bool
EnableStarRating bool EnableStarRating bool
@@ -86,25 +87,23 @@ type configOptions struct {
PasswordEncryptionKey string PasswordEncryptionKey string
ReverseProxyUserHeader string ReverseProxyUserHeader string
ReverseProxyWhitelist string ReverseProxyWhitelist string
HTTPSecurityHeaders secureOptions HTTPSecurityHeaders secureOptions `json:",omitzero"`
Prometheus prometheusOptions Prometheus prometheusOptions `json:",omitzero"`
Scanner scannerOptions Scanner scannerOptions `json:",omitzero"`
Jukebox jukeboxOptions Jukebox jukeboxOptions `json:",omitzero"`
Backup backupOptions Backup backupOptions `json:",omitzero"`
PID pidOptions PID pidOptions `json:",omitzero"`
Inspect inspectOptions Inspect inspectOptions `json:",omitzero"`
Subsonic subsonicOptions Subsonic subsonicOptions `json:",omitzero"`
LyricsPriority string LastFM lastfmOptions `json:",omitzero"`
Spotify spotifyOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
Tags map[string]TagConf `json:",omitempty"`
Agents string Agents string
LastFM lastfmOptions
Spotify spotifyOptions
ListenBrainz listenBrainzOptions
Tags map[string]TagConf
// DevFlags. These are used to enable/disable debugging and incomplete features // DevFlags. These are used to enable/disable debugging and incomplete features
DevLogLevels map[string]string `json:",omitempty"`
DevLogSourceLine bool DevLogSourceLine bool
DevLogLevels map[string]string
DevEnableProfiler bool DevEnableProfiler bool
DevAutoCreateAdminPassword string DevAutoCreateAdminPassword string
DevAutoLoginUsername string DevAutoLoginUsername string
@@ -146,12 +145,12 @@ type subsonicOptions struct {
} }
type TagConf struct { type TagConf struct {
Ignore bool `yaml:"ignore"` Ignore bool `yaml:"ignore" json:",omitempty"`
Aliases []string `yaml:"aliases"` Aliases []string `yaml:"aliases" json:",omitempty"`
Type string `yaml:"type"` Type string `yaml:"type" json:",omitempty"`
MaxLength int `yaml:"maxLength"` MaxLength int `yaml:"maxLength" json:",omitempty"`
Split []string `yaml:"split"` Split []string `yaml:"split" json:",omitempty"`
Album bool `yaml:"album"` Album bool `yaml:"album" json:",omitempty"`
} }
type lastfmOptions struct { type lastfmOptions struct {
+47 -42
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"reflect"
"strings" "strings"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
@@ -36,16 +35,10 @@ var sensitiveFieldsFullMask = []string{
"Prometheus.Password", "Prometheus.Password",
} }
type configEntry struct {
Key string `json:"key"`
EnvVar string `json:"envVar"`
Value interface{} `json:"value"`
}
type configResponse struct { type configResponse struct {
ID string `json:"id"` ID string `json:"id"`
ConfigFile string `json:"configFile"` ConfigFile string `json:"configFile"`
Config []configEntry `json:"config"` Config map[string]interface{} `json:"config"`
} }
func redactValue(key string, value string) string { func redactValue(key string, value string) string {
@@ -76,36 +69,32 @@ func redactValue(key string, value string) string {
return value return value
} }
func flatten(ctx context.Context, entries *[]configEntry, prefix string, v reflect.Value) { // applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map
if v.Kind() == reflect.Struct && v.Type().PkgPath() != "time" { func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) {
t := v.Type() for key, value := range config {
for i := 0; i < v.NumField(); i++ { fullKey := key
if !t.Field(i).IsExported() { if prefix != "" {
continue fullKey = prefix + "." + key
}
flatten(ctx, entries, prefix+"."+t.Field(i).Name, v.Field(i))
}
return
} }
key := strings.TrimPrefix(prefix, ".") switch v := value.(type) {
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) case map[string]interface{}:
var val interface{} // Recursively process nested maps
switch v.Kind() { applySensitiveFieldMasking(ctx, v, fullKey)
case reflect.Map, reflect.Slice, reflect.Array: case string:
b, err := json.Marshal(v.Interface()) // Apply masking to string values
if err != nil { config[key] = redactValue(fullKey, v)
log.Error(ctx, "Error marshalling config value", "key", key, err)
val = "error marshalling value"
} else {
val = string(b)
}
default: default:
originalValue := fmt.Sprint(v.Interface()) // For other types (numbers, booleans, etc.), convert to string and check for masking
val = redactValue(key, originalValue) if str := fmt.Sprint(v); str != "" {
masked := redactValue(fullKey, str)
if masked != str {
// Only replace if masking was applied
config[key] = masked
}
}
}
} }
*entries = append(*entries, configEntry{Key: key, EnvVar: envVar, Value: val})
} }
func getConfig(w http.ResponseWriter, r *http.Request) { func getConfig(w http.ResponseWriter, r *http.Request) {
@@ -116,16 +105,32 @@ func getConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
entries := make([]configEntry, 0) // Marshal the actual configuration struct to preserve original field names
v := reflect.ValueOf(*conf.Server) configBytes, err := json.Marshal(*conf.Server)
t := reflect.TypeOf(*conf.Server) if err != nil {
for i := 0; i < v.NumField(); i++ { log.Error(ctx, "Error marshaling config", err)
fieldVal := v.Field(i) http.Error(w, "Internal server error", http.StatusInternalServerError)
fieldType := t.Field(i) return
flatten(ctx, &entries, fieldType.Name, fieldVal) }
// Unmarshal back to map to get the structure with proper field names
var configMap map[string]interface{}
err = json.Unmarshal(configBytes, &configMap)
if err != nil {
log.Error(ctx, "Error unmarshaling config to map", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Apply sensitive field masking
applySensitiveFieldMasking(ctx, configMap, "")
resp := configResponse{
ID: "config",
ConfigFile: conf.Server.ConfigFile,
Config: configMap,
} }
resp := configResponse{ID: "config", ConfigFile: conf.Server.ConfigFile, Config: entries}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Error(ctx, "Error encoding config response", err) log.Error(ctx, "Error encoding config response", err)
+39 -160
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/conf/configtest"
@@ -14,148 +13,44 @@ import (
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
) )
var _ = Describe("config endpoint", func() { var _ = Describe("getConfig", func() {
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig()) DeferCleanup(configtest.SetupConfig())
}) })
It("rejects non admin users", func() { Context("when user is not admin", func() {
It("returns unauthorized", func() {
req := httptest.NewRequest("GET", "/config", nil) req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: false}) ctx := request.WithUser(req.Context(), model.User{IsAdmin: false})
getConfig(w, req.WithContext(ctx)) getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusUnauthorized)) Expect(w.Code).To(Equal(http.StatusUnauthorized))
}) })
})
It("returns configuration entries", func() { Context("when user is admin", func() {
It("returns config successfully", func() {
req := httptest.NewRequest("GET", "/config", nil) req := httptest.NewRequest("GET", "/config", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx)) getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Code).To(Equal(http.StatusOK))
var resp configResponse var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
Expect(resp.ID).To(Equal("config")) Expect(resp.ID).To(Equal("config"))
Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile))
// Verify that we have both Dev and non-Dev fields Expect(resp.Config).ToNot(BeEmpty())
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() { It("redacts sensitive fields", func() {
req := httptest.NewRequest("GET", "/config", nil) conf.Server.LastFM.ApiKey = "secretapikey123"
w := httptest.NewRecorder() conf.Server.Spotify.Secret = "spotifysecret456"
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) conf.Server.PasswordEncryptionKey = "encryptionkey789"
getConfig(w, req.WithContext(ctx)) conf.Server.DevAutoCreateAdminPassword = "adminpassword123"
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" conf.Server.Prometheus.Password = "prometheuspass"
req := httptest.NewRequest("GET", "/config", nil) req := httptest.NewRequest("GET", "/config", nil)
@@ -167,39 +62,26 @@ var _ = Describe("config endpoint", func() {
var resp configResponse var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{} // Check LastFM.ApiKey (partially masked)
for _, e := range resp.Config { lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
if s, ok := e.Value.(string); ok { Expect(ok).To(BeTrue())
values[e.Key] = s Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
}
}
Expect(values).To(HaveKeyWithValue("DevAutoCreateAdminPassword", "****")) // Check Spotify.Secret (partially masked)
Expect(values).To(HaveKeyWithValue("Prometheus.Password", "****")) spotify, ok := resp.Config["Spotify"].(map[string]interface{})
}) Expect(ok).To(BeTrue())
Expect(spotify["Secret"]).To(Equal("s**************6"))
It("does not redact non-sensitive values", func() { // Check PasswordEncryptionKey (fully masked)
conf.Server.MusicFolder = "/path/to/music" Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****"))
conf.Server.Port = 4533
req := httptest.NewRequest("GET", "/config", nil) // Check DevAutoCreateAdminPassword (fully masked)
w := httptest.NewRecorder() Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
ctx := request.WithUser(req.Context(), model.User{IsAdmin: true})
getConfig(w, req.WithContext(ctx))
Expect(w.Code).To(Equal(http.StatusOK)) // Check Prometheus.Password (fully masked)
var resp configResponse prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) Expect(ok).To(BeTrue())
Expect(prometheus["Password"]).To(Equal("****"))
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() { It("handles empty sensitive values", func() {
@@ -215,16 +97,13 @@ var _ = Describe("config endpoint", func() {
var resp configResponse var resp configResponse
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
values := map[string]string{} // Check LastFM.ApiKey - should be preserved because it's sensitive
for _, e := range resp.Config { lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
if s, ok := e.Value.(string); ok { Expect(ok).To(BeTrue())
values[e.Key] = s Expect(lastfm["ApiKey"]).To(Equal(""))
}
}
// Empty sensitive values should remain empty // Empty sensitive values should remain empty - should be preserved because it's sensitive
Expect(values["LastFM.ApiKey"]).To(Equal("")) Expect(resp.Config["PasswordEncryptionKey"]).To(Equal(""))
Expect(values["PasswordEncryptionKey"]).To(Equal(""))
}) })
}) })
}) })
+1 -1
View File
@@ -23,7 +23,7 @@ import { INSIGHTS_DOC_URL } from '../consts.js'
import subsonic from '../subsonic/index.js' import subsonic from '../subsonic/index.js'
import { Typography } from '@material-ui/core' import { Typography } from '@material-ui/core'
import TableHead from '@material-ui/core/TableHead' import TableHead from '@material-ui/core/TableHead'
import { configToToml, separateAndSortConfigs } from '../utils/toml' import { configToToml, separateAndSortConfigs } from './aboutUtils'
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
configNameColumn: { configNameColumn: {
@@ -2,16 +2,72 @@
* TOML utility functions for configuration export * TOML utility functions for configuration export
*/ */
/**
* Flattens nested configuration object and generates environment variable names
* @param {Object} config - The nested configuration object from the backend
* @param {string} prefix - The current prefix for nested keys
* @returns {Array} - Array of config objects with key, envVar, and value properties
*/
export const flattenConfig = (config, prefix = '') => {
const result = []
if (!config || typeof config !== 'object') {
return result
}
Object.keys(config).forEach((key) => {
const value = config[key]
const currentKey = prefix ? `${prefix}.${key}` : key
if (value && typeof value === 'object' && !Array.isArray(value)) {
// Recursively flatten nested objects
result.push(...flattenConfig(value, currentKey))
} else {
// Generate environment variable name: ND_ + uppercase with dots replaced by underscores
const envVar = 'ND_' + currentKey.toUpperCase().replace(/\./g, '_')
// Convert value to string for display
let displayValue = value
if (
Array.isArray(value) ||
(typeof value === 'object' && value !== null)
) {
displayValue = JSON.stringify(value)
} else {
displayValue = String(value)
}
result.push({
key: currentKey,
envVar: envVar,
value: displayValue,
})
}
})
return result
}
/** /**
* Separates and sorts configuration entries into regular and dev configs * Separates and sorts configuration entries into regular and dev configs
* @param {Array} configEntries - Array of config objects with key and value * @param {Array|Object} configEntries - Array of config objects with key and value, or nested config object
* @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted * @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted
*/ */
export const separateAndSortConfigs = (configEntries) => { export const separateAndSortConfigs = (configEntries) => {
const regularConfigs = [] const regularConfigs = []
const devConfigs = [] const devConfigs = []
configEntries?.forEach((config) => { // Handle both the old array format and new nested object format
let flattenedConfigs
if (Array.isArray(configEntries)) {
// Old format - already flattened
flattenedConfigs = configEntries
} else {
// New format - need to flatten
flattenedConfigs = flattenConfig(configEntries)
}
flattenedConfigs?.forEach((config) => {
// Skip configFile as it's displayed separately // Skip configFile as it's displayed separately
if (config.key === 'ConfigFile') { if (config.key === 'ConfigFile') {
return return
@@ -31,6 +87,30 @@ export const separateAndSortConfigs = (configEntries) => {
return { regularConfigs, devConfigs } return { regularConfigs, devConfigs }
} }
/**
* Escapes TOML keys that contain special characters
* @param {string} key - The key to potentially escape
* @returns {string} - The escaped key if needed, or the original key
*/
export const escapeTomlKey = (key) => {
// Convert to string first to handle null/undefined
const keyStr = String(key)
// Empty strings always need quotes
if (keyStr === '') {
return '""'
}
// TOML bare keys can only contain letters, numbers, underscores, and hyphens
// If the key contains other characters, it needs to be quoted
if (/^[a-zA-Z0-9_-]+$/.test(keyStr)) {
return keyStr
}
// Escape quotes in the key and wrap in quotes
return `"${keyStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/** /**
* Converts a value to proper TOML format * Converts a value to proper TOML format
* @param {*} value - The value to format * @param {*} value - The value to format
@@ -61,10 +141,30 @@ export const formatTomlValue = (value) => {
return `"${str}"` return `"${str}"`
} }
// Arrays/JSON objects // Handle arrays and objects
if (str.startsWith('[') || str.startsWith('{')) { if (str.startsWith('[') || str.startsWith('{')) {
try { try {
JSON.parse(str) const parsed = JSON.parse(str)
// If it's an array, format as TOML array
if (Array.isArray(parsed)) {
const formattedItems = parsed.map((item) => {
if (typeof item === 'string') {
return `"${item.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
} else if (typeof item === 'number' || typeof item === 'boolean') {
return String(item)
} else {
return `"${String(item).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
})
if (formattedItems.length === 0) {
return '[ ]'
}
return `[ ${formattedItems.join(', ')} ]`
}
// For objects, keep the JSON string format with triple quotes
return `"""${str}"""` return `"""${str}"""`
} catch { } catch {
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
@@ -111,9 +211,17 @@ export const buildTomlSections = (configs) => {
export const configToToml = (configData, translate = (key) => key) => { export const configToToml = (configData, translate = (key) => key) => {
let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n` let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n`
const { regularConfigs, devConfigs } = separateAndSortConfigs( // Handle both old array format (configData.config is array) and new nested format (configData.config is object)
configData.config, let configs
) if (Array.isArray(configData.config)) {
// Old format - already flattened
configs = configData.config
} else {
// New format - need to flatten
configs = flattenConfig(configData.config)
}
const { regularConfigs, devConfigs } = separateAndSortConfigs(configs)
// Process regular configs // Process regular configs
const { sections: regularSections, rootKeys: regularRootKeys } = const { sections: regularSections, rootKeys: regularRootKeys } =
@@ -149,7 +257,7 @@ export const configToToml = (configData, translate = (key) => key) => {
.forEach((sectionName) => { .forEach((sectionName) => {
tomlContent += `[${sectionName}]\n` tomlContent += `[${sectionName}]\n`
devSections[sectionName].forEach(({ key, value }) => { devSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n` tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
}) })
tomlContent += '\n' tomlContent += '\n'
}) })
@@ -161,7 +269,7 @@ export const configToToml = (configData, translate = (key) => key) => {
.forEach((sectionName) => { .forEach((sectionName) => {
tomlContent += `[${sectionName}]\n` tomlContent += `[${sectionName}]\n`
regularSections[sectionName].forEach(({ key, value }) => { regularSections[sectionName].forEach(({ key, value }) => {
tomlContent += `${key} = ${formatTomlValue(value)}\n` tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n`
}) })
tomlContent += '\n' tomlContent += '\n'
}) })
@@ -4,7 +4,9 @@ import {
buildTomlSections, buildTomlSections,
configToToml, configToToml,
separateAndSortConfigs, separateAndSortConfigs,
} from './toml' flattenConfig,
escapeTomlKey,
} from './aboutUtils'
describe('formatTomlValue', () => { describe('formatTomlValue', () => {
it('handles null and undefined values', () => { it('handles null and undefined values', () => {
@@ -42,12 +44,25 @@ describe('formatTomlValue', () => {
}) })
it('handles JSON arrays and objects', () => { it('handles JSON arrays and objects', () => {
expect(formatTomlValue('["item1", "item2"]')).toBe( expect(formatTomlValue('["item1", "item2"]')).toBe('[ "item1", "item2" ]')
'"""["item1", "item2"]"""',
)
expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""') expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""')
}) })
it('formats different types of arrays correctly', () => {
// String array
expect(formatTomlValue('["genre", "tcon", "©gen"]')).toBe(
'[ "genre", "tcon", "©gen" ]',
)
// Mixed array with numbers and strings
expect(formatTomlValue('[42, "test", true]')).toBe('[ 42, "test", true ]')
// Empty array
expect(formatTomlValue('[]')).toBe('[ ]')
// Array with special characters in strings
expect(
formatTomlValue('["item with spaces", "item\\"with\\"quotes"]'),
).toBe('[ "item with spaces", "item\\"with\\"quotes" ]')
})
it('handles invalid JSON as regular strings', () => { it('handles invalid JSON as regular strings', () => {
expect(formatTomlValue('[invalid json')).toBe('"[invalid json"') expect(formatTomlValue('[invalid json')).toBe('"[invalid json"')
expect(formatTomlValue('{broken')).toBe('"{broken"') expect(formatTomlValue('{broken')).toBe('"{broken"')
@@ -302,12 +317,259 @@ describe('configToToml', () => {
expect(result).toContain('IntegerValue = 42') expect(result).toContain('IntegerValue = 42')
expect(result).toContain('FloatValue = 3.14') expect(result).toContain('FloatValue = 3.14')
expect(result).toContain('DurationValue = "5s"') expect(result).toContain('DurationValue = "5s"')
expect(result).toContain('ArrayValue = """["item1", "item2"]"""') expect(result).toContain('ArrayValue = [ "item1", "item2" ]')
})
it('handles nested config object format correctly', () => {
const configData = {
config: {
Address: '127.0.0.1',
Port: 4533,
EnableDownloads: true,
DevLogSourceLine: false,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
Scanner: {
Schedule: 'daily',
Enabled: true,
},
},
}
const result = configToToml(configData, mockTranslate)
// Should contain regular configs
expect(result).toContain('Address = "127.0.0.1"')
expect(result).toContain('Port = 4533')
expect(result).toContain('EnableDownloads = true')
// Should contain dev configs with header
expect(result).toContain('# Development Flags (subject to change/removal)')
expect(result).toContain('DevLogSourceLine = false')
// Should contain sections
expect(result).toContain('[LastFM]')
expect(result).toContain('Enabled = true')
expect(result).toContain('ApiKey = "secret123"')
expect(result).toContain('Language = "en"')
expect(result).toContain('[Scanner]')
expect(result).toContain('Schedule = "daily"')
})
it('handles mixed nested and flat structure', () => {
const configData = {
config: {
MusicFolder: '/music',
DevAutoLoginUsername: 'testuser',
Jukebox: {
Enabled: false,
AdminOnly: true,
},
},
}
const result = configToToml(configData, mockTranslate)
expect(result).toContain('MusicFolder = "/music"')
expect(result).toContain('DevAutoLoginUsername = "testuser"')
expect(result).toContain('[Jukebox]')
expect(result).toContain('Enabled = false')
expect(result).toContain('AdminOnly = true')
})
it('properly escapes keys with special characters in sections', () => {
const configData = {
config: [
{ key: 'DevLogLevels.persistence/sql_base_repository', value: 'trace' },
{ key: 'DevLogLevels.core/scanner', value: 'debug' },
{ key: 'DevLogLevels.regular_key', value: 'info' },
{ key: 'Tags.genre.Aliases', value: '["tcon","genre","©gen"]' },
],
}
const result = configToToml(configData, mockTranslate)
// Keys with forward slashes should be quoted
expect(result).toContain('"persistence/sql_base_repository" = "trace"')
expect(result).toContain('"core/scanner" = "debug"')
// Regular keys should not be quoted
expect(result).toContain('regular_key = "info"')
// Arrays should be formatted correctly
expect(result).toContain('"genre.Aliases" = [ "tcon", "genre", "©gen" ]')
// Should contain proper sections
expect(result).toContain('[DevLogLevels]')
expect(result).toContain('[Tags]')
})
})
describe('flattenConfig', () => {
it('flattens simple nested objects correctly', () => {
const config = {
Address: '0.0.0.0',
Port: 4533,
EnableDownloads: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
Language: 'en',
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Address',
envVar: 'ND_ADDRESS',
value: '0.0.0.0',
})
expect(result).toContainEqual({
key: 'Port',
envVar: 'ND_PORT',
value: '4533',
})
expect(result).toContainEqual({
key: 'EnableDownloads',
envVar: 'ND_ENABLEDOWNLOADS',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.Enabled',
envVar: 'ND_LASTFM_ENABLED',
value: 'true',
})
expect(result).toContainEqual({
key: 'LastFM.ApiKey',
envVar: 'ND_LASTFM_APIKEY',
value: 'secret123',
})
expect(result).toContainEqual({
key: 'LastFM.Language',
envVar: 'ND_LASTFM_LANGUAGE',
value: 'en',
})
})
it('handles deeply nested objects', () => {
const config = {
Scanner: {
Schedule: 'daily',
Options: {
ExtractorType: 'taglib',
ArtworkPriority: 'cover.jpg',
},
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'Scanner.Schedule',
envVar: 'ND_SCANNER_SCHEDULE',
value: 'daily',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ExtractorType',
envVar: 'ND_SCANNER_OPTIONS_EXTRACTORTYPE',
value: 'taglib',
})
expect(result).toContainEqual({
key: 'Scanner.Options.ArtworkPriority',
envVar: 'ND_SCANNER_OPTIONS_ARTWORKPRIORITY',
value: 'cover.jpg',
})
})
it('handles arrays correctly', () => {
const config = {
DeviceList: ['device1', 'device2'],
Settings: {
EnabledFormats: ['mp3', 'flac', 'ogg'],
},
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'DeviceList',
envVar: 'ND_DEVICELIST',
value: '["device1","device2"]',
})
expect(result).toContainEqual({
key: 'Settings.EnabledFormats',
envVar: 'ND_SETTINGS_ENABLEDFORMATS',
value: '["mp3","flac","ogg"]',
})
})
it('handles null and undefined values', () => {
const config = {
NullValue: null,
UndefinedValue: undefined,
EmptyString: '',
ZeroValue: 0,
}
const result = flattenConfig(config)
expect(result).toContainEqual({
key: 'NullValue',
envVar: 'ND_NULLVALUE',
value: 'null',
})
expect(result).toContainEqual({
key: 'UndefinedValue',
envVar: 'ND_UNDEFINEDVALUE',
value: 'undefined',
})
expect(result).toContainEqual({
key: 'EmptyString',
envVar: 'ND_EMPTYSTRING',
value: '',
})
expect(result).toContainEqual({
key: 'ZeroValue',
envVar: 'ND_ZEROVALUE',
value: '0',
})
})
it('handles empty object', () => {
const result = flattenConfig({})
expect(result).toEqual([])
})
it('handles null/undefined input', () => {
expect(flattenConfig(null)).toEqual([])
expect(flattenConfig(undefined)).toEqual([])
})
it('handles non-object input', () => {
expect(flattenConfig('string')).toEqual([])
expect(flattenConfig(123)).toEqual([])
expect(flattenConfig(true)).toEqual([])
}) })
}) })
describe('separateAndSortConfigs', () => { describe('separateAndSortConfigs', () => {
it('separates regular and dev configs correctly', () => { it('separates regular and dev configs correctly with array input', () => {
const configs = [ const configs = [
{ key: 'RegularKey1', value: 'value1' }, { key: 'RegularKey1', value: 'value1' },
{ key: 'DevTestFlag', value: 'true' }, { key: 'DevTestFlag', value: 'true' },
@@ -328,6 +590,37 @@ describe('separateAndSortConfigs', () => {
]) ])
}) })
it('separates regular and dev configs correctly with nested object input', () => {
const config = {
Address: '127.0.0.1',
Port: 4533,
DevAutoLoginUsername: 'testuser',
DevLogSourceLine: true,
LastFM: {
Enabled: true,
ApiKey: 'secret123',
},
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'Address', envVar: 'ND_ADDRESS', value: '127.0.0.1' },
{ key: 'LastFM.ApiKey', envVar: 'ND_LASTFM_APIKEY', value: 'secret123' },
{ key: 'LastFM.Enabled', envVar: 'ND_LASTFM_ENABLED', value: 'true' },
{ key: 'Port', envVar: 'ND_PORT', value: '4533' },
])
expect(result.devConfigs).toEqual([
{
key: 'DevAutoLoginUsername',
envVar: 'ND_DEVAUTOLOGINUSERNAME',
value: 'testuser',
},
{ key: 'DevLogSourceLine', envVar: 'ND_DEVLOGSOURCELINE', value: 'true' },
])
})
it('skips ConfigFile entries', () => { it('skips ConfigFile entries', () => {
const configs = [ const configs = [
{ key: 'ConfigFile', value: '/path/to/config.toml' }, { key: 'ConfigFile', value: '/path/to/config.toml' },
@@ -343,6 +636,23 @@ describe('separateAndSortConfigs', () => {
expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }]) expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }])
}) })
it('skips ConfigFile entries with nested object input', () => {
const config = {
ConfigFile: '/path/to/config.toml',
RegularKey: 'value',
DevFlag: true,
}
const result = separateAndSortConfigs(config)
expect(result.regularConfigs).toEqual([
{ key: 'RegularKey', envVar: 'ND_REGULARKEY', value: 'value' },
])
expect(result.devConfigs).toEqual([
{ key: 'DevFlag', envVar: 'ND_DEVFLAG', value: 'true' },
])
})
it('handles empty input', () => { it('handles empty input', () => {
const result = separateAndSortConfigs([]) const result = separateAndSortConfigs([])
@@ -376,3 +686,52 @@ describe('separateAndSortConfigs', () => {
expect(result.devConfigs[1].key).toBe('DevZ') expect(result.devConfigs[1].key).toBe('DevZ')
}) })
}) })
describe('escapeTomlKey', () => {
it('does not escape valid bare keys', () => {
expect(escapeTomlKey('RegularKey')).toBe('RegularKey')
expect(escapeTomlKey('regular_key')).toBe('regular_key')
expect(escapeTomlKey('regular-key')).toBe('regular-key')
expect(escapeTomlKey('key123')).toBe('key123')
expect(escapeTomlKey('Key_with_underscores')).toBe('Key_with_underscores')
expect(escapeTomlKey('Key-with-hyphens')).toBe('Key-with-hyphens')
})
it('escapes keys with special characters', () => {
// Keys with forward slashes (like DevLogLevels keys)
expect(escapeTomlKey('persistence/sql_base_repository')).toBe(
'"persistence/sql_base_repository"',
)
expect(escapeTomlKey('core/scanner')).toBe('"core/scanner"')
// Keys with dots
expect(escapeTomlKey('Section.NestedKey')).toBe('"Section.NestedKey"')
// Keys with spaces
expect(escapeTomlKey('key with spaces')).toBe('"key with spaces"')
// Keys with other special characters
expect(escapeTomlKey('key@with@symbols')).toBe('"key@with@symbols"')
expect(escapeTomlKey('key+with+plus')).toBe('"key+with+plus"')
})
it('escapes quotes in keys', () => {
expect(escapeTomlKey('key"with"quotes')).toBe('"key\\"with\\"quotes"')
expect(escapeTomlKey('key with "quotes" inside')).toBe(
'"key with \\"quotes\\" inside"',
)
})
it('escapes backslashes in keys', () => {
expect(escapeTomlKey('key\\with\\backslashes')).toBe(
'"key\\\\with\\\\backslashes"',
)
expect(escapeTomlKey('path\\to\\file')).toBe('"path\\\\to\\\\file"')
})
it('handles empty and null keys', () => {
expect(escapeTomlKey('')).toBe('""')
expect(escapeTomlKey(null)).toBe('null')
expect(escapeTomlKey(undefined)).toBe('undefined')
})
})