Deluan Quintão 6dd98e0bed
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>
2025-05-30 21:07:08 -04:00

134 lines
3.7 KiB
Go

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)
}
}