diff --git a/config/config.go b/config/config.go index 64844f68..f17f81b5 100644 --- a/config/config.go +++ b/config/config.go @@ -118,6 +118,39 @@ func GetLogFolder() string { return "/var/log/x-ui" } +var settingGroupAliases = map[string][]string{ + "dbType": {"databaseConnection", "other"}, + "dbHost": {"databaseConnection", "other"}, + "dbPort": {"databaseConnection", "other"}, + "dbUser": {"databaseConnection", "other"}, + "dbPassword": {"databaseConnection", "other"}, + "dbName": {"databaseConnection", "other"}, +} + +func readGroupedString(settings map[string]any, key string) string { + if groups, ok := settingGroupAliases[key]; ok { + for _, groupName := range groups { + if group, ok := settings[groupName].(map[string]any); ok { + if value, ok := group[key].(string); ok && value != "" { + return value + } + } + } + } + if value, ok := settings[key].(string); ok && value != "" { + return value + } + return "" +} + +func settingsLayoutMeta() map[string]any { + return map[string]any{ + "layout": "按模块-用途来归类", + "schema": "module-purpose-v1", + "description": "Top-level groups are organized by module and purpose for easier maintenance and development.", + } +} + func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { @@ -173,15 +206,7 @@ func GetDBTypeFromJSON() string { return "sqlite" } - // Check nested format: "other" group contains "dbType" - if other, ok := settings["other"].(map[string]any); ok { - if dbType, ok := other["dbType"].(string); ok && dbType != "" { - return dbType - } - } - - // Check flat format: top-level "dbType" - if dbType, ok := settings["dbType"].(string); ok && dbType != "" { + if dbType := readGroupedString(settings, "dbType"); dbType != "" { return dbType } @@ -210,36 +235,18 @@ func GetDBConfigFromJSON() DBConfig { return DBConfig{Type: "sqlite", Host: "127.0.0.1", Port: "3306", Name: "3xui"} } - // readString extracts a value from either nested (group.key) or flat format - readString := func(nestedGroup, flatKey string) string { - if group, ok := settings[nestedGroup].(map[string]any); ok { - if v, ok := group[flatKey].(string); ok { - return v - } - } - if v, ok := settings[flatKey].(string); ok { - return v - } - return "" - } - - // Read dbType from the same parsed settings dbType := "sqlite" - if other, ok := settings["other"].(map[string]any); ok { - if t, ok := other["dbType"].(string); ok && t != "" { - dbType = t - } - } else if t, ok := settings["dbType"].(string); ok && t != "" { + if t := readGroupedString(settings, "dbType"); t != "" { dbType = t } return DBConfig{ Type: dbType, - Host: readString("other", "dbHost"), - Port: readString("other", "dbPort"), - User: readString("other", "dbUser"), - Password: readString("other", "dbPassword"), - Name: readString("other", "dbName"), + Host: readGroupedString(settings, "dbHost"), + Port: readGroupedString(settings, "dbPort"), + User: readGroupedString(settings, "dbUser"), + Password: readGroupedString(settings, "dbPassword"), + Name: readGroupedString(settings, "dbName"), } } @@ -256,14 +263,13 @@ func WriteSettingToJSON(key, value string) error { if err := json.Unmarshal(data, &settings); err != nil { return err } - - // Check if the key lives in a nested group - groupMap := map[string]string{ - "dbType": "other", "dbHost": "other", "dbPort": "other", - "dbUser": "other", "dbPassword": "other", "dbName": "other", + if _, exists := settings["_meta"]; !exists { + settings["_meta"] = settingsLayoutMeta() } - if group, ok := groupMap[key]; ok { + // Check if the key lives in a nested group + if groups, ok := settingGroupAliases[key]; ok && len(groups) > 0 { + group := groups[0] if _, exists := settings[group]; !exists { settings[group] = make(map[string]any) } diff --git a/config/config_test.go b/config/config_test.go index d911c7ac..2cc8ce8e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1,6 +1,8 @@ package config import ( + "encoding/json" + "os" "strings" "testing" ) @@ -129,3 +131,73 @@ func TestGetLogFolderCustom(t *testing.T) { t.Errorf("log folder should be '/custom/logs', got %s", GetLogFolder()) } } + +func TestGetDBConfigFromJSONSupportsModulePurposeLayout(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + + settings := map[string]any{ + "_meta": map[string]any{ + "layout": "按模块-用途来归类", + }, + "databaseConnection": map[string]any{ + "dbType": "mariadb", + "dbHost": "10.0.0.12", + "dbPort": "3307", + "dbUser": "panel", + "dbPassword": "secret", + "dbName": "paneldb", + }, + } + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatalf("MarshalIndent error: %v", err) + } + if err := os.WriteFile(GetSettingPath(), data, 0644); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + cfg := GetDBConfigFromJSON() + if cfg.Type != "mariadb" || cfg.Host != "10.0.0.12" || cfg.Port != "3307" || cfg.User != "panel" || cfg.Password != "secret" || cfg.Name != "paneldb" { + t.Fatalf("unexpected DB config: %+v", cfg) + } +} + +func TestWriteSettingToJSONUsesModulePurposeGroup(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", tmpDir) + + initial := map[string]any{ + "_meta": map[string]any{ + "layout": "按模块-用途来归类", + }, + "databaseConnection": map[string]any{}, + } + data, err := json.MarshalIndent(initial, "", " ") + if err != nil { + t.Fatalf("MarshalIndent error: %v", err) + } + if err := os.WriteFile(GetSettingPath(), data, 0644); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + if err := WriteSettingToJSON("dbHost", "127.0.0.2"); err != nil { + t.Fatalf("WriteSettingToJSON error: %v", err) + } + + updated, err := os.ReadFile(GetSettingPath()) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(updated, &parsed); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + group, ok := parsed["databaseConnection"].(map[string]any) + if !ok { + t.Fatalf("expected databaseConnection group, got %T", parsed["databaseConnection"]) + } + if got, ok := group["dbHost"].(string); !ok || got != "127.0.0.2" { + t.Fatalf("expected databaseConnection.dbHost to be updated, got %v", group["dbHost"]) + } +} diff --git a/web/service/setting.go b/web/service/setting.go index c9cc99ae..472d1fe9 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -121,6 +121,115 @@ var defaultValueMap = map[string]string{ // settingGroups defines the nested JSON structure for on-disk settings. // Each group maps nested keys to their flat equivalents in defaultValueMap. var settingGroups = map[string]map[string]string{ + "panelNetwork": { + "listen": "webListen", + "domain": "webDomain", + "port": "webPort", + "basePath": "webBasePath", + }, + "panelTLS": { + "certFile": "webCertFile", + "keyFile": "webKeyFile", + }, + "panelSecurity": { + "sessionMaxAge": "sessionMaxAge", + "twoFactorEnable": "twoFactorEnable", + "twoFactorToken": "twoFactorToken", + "turnstileSiteKey": "turnstileSiteKey", + "turnstileSecretKey": "turnstileSecretKey", + "secret": "secret", + }, + "panelUX": { + "timeLocation": "timeLocation", + "datepicker": "datepicker", + "pageSize": "pageSize", + "expireDiff": "expireDiff", + "trafficDiff": "trafficDiff", + "remarkModel": "remarkModel", + }, + "telegramBot": { + "enable": "tgBotEnable", + "token": "tgBotToken", + "proxy": "tgBotProxy", + "apiServer": "tgBotAPIServer", + "chatId": "tgBotChatId", + "runTime": "tgRunTime", + "backup": "tgBotBackup", + "loginNotify": "tgBotLoginNotify", + "cpu": "tgCpu", + "lang": "tgLang", + }, + "subscriptionNetwork": { + "enable": "subEnable", + "jsonEnable": "subJsonEnable", + "listen": "subListen", + "port": "subPort", + "path": "subPath", + "domain": "subDomain", + "certFile": "subCertFile", + "keyFile": "subKeyFile", + "updates": "subUpdates", + "encrypt": "subEncrypt", + "showInfo": "subShowInfo", + "uri": "subURI", + "jsonPath": "subJsonPath", + "jsonURI": "subJsonURI", + }, + "subscriptionBranding": { + "title": "subTitle", + "supportUrl": "subSupportUrl", + "profileUrl": "subProfileUrl", + "announce": "subAnnounce", + }, + "subscriptionRouting": { + "enableRouting": "subEnableRouting", + "routingRules": "subRoutingRules", + "jsonFragment": "subJsonFragment", + "jsonNoises": "subJsonNoises", + "jsonMux": "subJsonMux", + "jsonRules": "subJsonRules", + }, + "ldapConnection": { + "enable": "ldapEnable", + "host": "ldapHost", + "port": "ldapPort", + "useTLS": "ldapUseTLS", + "bindDN": "ldapBindDN", + "password": "ldapPassword", + "baseDN": "ldapBaseDN", + "userFilter": "ldapUserFilter", + "userAttr": "ldapUserAttr", + "vlessField": "ldapVlessField", + }, + "ldapSync": { + "syncCron": "ldapSyncCron", + "flagField": "ldapFlagField", + "truthyValues": "ldapTruthyValues", + "invertFlag": "ldapInvertFlag", + "inboundTags": "ldapInboundTags", + "autoCreate": "ldapAutoCreate", + "autoDelete": "ldapAutoDelete", + "defaultTotalGB": "ldapDefaultTotalGB", + "defaultExpiryDays": "ldapDefaultExpiryDays", + "defaultLimitIP": "ldapDefaultLimitIP", + }, + "systemIntegration": { + "externalTrafficInformEnable": "externalTrafficInformEnable", + "externalTrafficInformURI": "externalTrafficInformURI", + "warp": "warp", + "xrayOutboundTestUrl": "xrayOutboundTestUrl", + }, + "databaseConnection": { + "dbType": "dbType", + "dbHost": "dbHost", + "dbPort": "dbPort", + "dbUser": "dbUser", + "dbPassword": "dbPassword", + "dbName": "dbName", + }, +} + +var legacySettingGroups = map[string]map[string]string{ "web": { "listen": "webListen", "domain": "webDomain", @@ -215,6 +324,14 @@ var settingGroups = map[string]map[string]string{ }, } +func settingsLayoutMeta() map[string]string { + return map[string]string{ + "layout": "按模块-用途来归类", + "schema": "module-purpose-v1", + "description": "Top-level groups are organized by module and purpose for easier maintenance and development.", + } +} + // flatToNestedKey maps flat keys to their [group, nestedKey] pair. var flatToNestedKey map[string][2]string @@ -231,6 +348,7 @@ func init() { // using the settingGroups mapping. Keys not in any group are placed at the top level. func expandToNested(flat map[string]string) map[string]any { result := make(map[string]any) + result["_meta"] = settingsLayoutMeta() // Initialize all groups for group := range settingGroups { @@ -273,6 +391,12 @@ func flattenNested(nested map[string]any) map[string]string { result[flatKey] = fmt.Sprint(strVal) } } + } else if groupKeys, ok := legacySettingGroups[key]; ok { + for nestedKey, flatKey := range groupKeys { + if strVal, exists := v[nestedKey]; exists { + result[flatKey] = fmt.Sprint(strVal) + } + } } default: // Top-level value (ungrouped key) diff --git a/web/service/setting_test.go b/web/service/setting_test.go index 45d574f7..2a656840 100644 --- a/web/service/setting_test.go +++ b/web/service/setting_test.go @@ -158,8 +158,15 @@ func TestSettingsFileFormat(t *testing.T) { t.Fatalf("settings file is not valid JSON: %v", err) } - // Verify nested format: should contain group objects - for _, group := range []string{"web", "tgBot", "sub", "ldap", "other"} { + // Verify nested format: should contain group objects and metadata. + for _, group := range []string{ + "_meta", + "panelNetwork", "panelTLS", "panelSecurity", "panelUX", + "telegramBot", + "subscriptionNetwork", "subscriptionBranding", "subscriptionRouting", + "ldapConnection", "ldapSync", + "systemIntegration", "databaseConnection", + } { val, exists := parsed[group] if !exists { t.Errorf("expected group %q in nested JSON", group) @@ -169,6 +176,13 @@ func TestSettingsFileFormat(t *testing.T) { t.Errorf("expected group %q to be an object, got %T", group, val) } } + if meta, ok := parsed["_meta"].(map[string]any); ok { + if layout, ok := meta["layout"].(string); !ok || layout != "按模块-用途来归类" { + t.Errorf("expected _meta.layout to explain grouping, got %v", meta["layout"]) + } + } else { + t.Error("expected _meta group in nested JSON") + } // Verify pretty-printed (has newlines) hasNewline := false @@ -240,6 +254,52 @@ func TestLegacyFlatFormatBackwardCompat(t *testing.T) { } } +func TestLegacyNestedFormatBackwardCompat(t *testing.T) { + setupTestSettings(t) + + legacy := map[string]any{ + "web": map[string]any{ + "port": "8088", + "basePath": "/legacy/", + }, + "tgBot": map[string]any{ + "enable": "true", + }, + "other": map[string]any{ + "dbType": "mariadb", + "dbHost": "192.168.1.10", + }, + } + data, err := json.MarshalIndent(legacy, "", " ") + if err != nil { + t.Fatalf("MarshalIndent error: %v", err) + } + path := config.GetSettingPath() + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("WriteFile error: %v", err) + } + + loaded, err := loadSettings() + if err != nil { + t.Fatalf("loadSettings error: %v", err) + } + if loaded["webPort"] != "8088" { + t.Errorf("expected webPort=8088, got %s", loaded["webPort"]) + } + if loaded["webBasePath"] != "/legacy/" { + t.Errorf("expected webBasePath=/legacy/, got %s", loaded["webBasePath"]) + } + if loaded["tgBotEnable"] != "true" { + t.Errorf("expected tgBotEnable=true, got %s", loaded["tgBotEnable"]) + } + if loaded["dbType"] != "mariadb" { + t.Errorf("expected dbType=mariadb, got %s", loaded["dbType"]) + } + if loaded["dbHost"] != "192.168.1.10" { + t.Errorf("expected dbHost=192.168.1.10, got %s", loaded["dbHost"]) + } +} + func TestRoundTripNestedFormat(t *testing.T) { setupTestSettings(t) @@ -280,19 +340,19 @@ func TestRoundTripNestedFormat(t *testing.T) { if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("settings file is not valid JSON: %v", err) } - if webGroup, ok := parsed["web"].(map[string]any); ok { + if webGroup, ok := parsed["panelNetwork"].(map[string]any); ok { if port, ok := webGroup["port"].(string); !ok || port != "9090" { - t.Errorf("expected web.port=9090 in nested JSON, got %v", webGroup["port"]) + t.Errorf("expected panelNetwork.port=9090 in nested JSON, got %v", webGroup["port"]) } } else { - t.Error("expected 'web' group in nested JSON") + t.Error("expected 'panelNetwork' group in nested JSON") } - if tgGroup, ok := parsed["tgBot"].(map[string]any); ok { + if tgGroup, ok := parsed["telegramBot"].(map[string]any); ok { if enable, ok := tgGroup["enable"].(string); !ok || enable != "true" { - t.Errorf("expected tgBot.enable=true in nested JSON, got %v", tgGroup["enable"]) + t.Errorf("expected telegramBot.enable=true in nested JSON, got %v", tgGroup["enable"]) } } else { - t.Error("expected 'tgBot' group in nested JSON") + t.Error("expected 'telegramBot' group in nested JSON") } } diff --git a/x-ui.sh b/x-ui.sh index f5f46f88..65fbf129 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -2220,9 +2220,9 @@ db_show_status() { if [ "$current_type" = "mariadb" ]; then local json_path="/etc/x-ui/x-ui.json" if command -v jq >/dev/null 2>&1; then - local host=$(jq -r '.other.dbHost // "127.0.0.1"' "$json_path" 2>/dev/null) - local port=$(jq -r '.other.dbPort // "3306"' "$json_path" 2>/dev/null) - local dbname=$(jq -r '.other.dbName // "3xui"' "$json_path" 2>/dev/null) + local host=$(jq -r '.databaseConnection.dbHost // .other.dbHost // "127.0.0.1"' "$json_path" 2>/dev/null) + local port=$(jq -r '.databaseConnection.dbPort // .other.dbPort // "3306"' "$json_path" 2>/dev/null) + local dbname=$(jq -r '.databaseConnection.dbName // .other.dbName // "3xui"' "$json_path" 2>/dev/null) else local host=$(grep -o '"dbHost"[[:space:]]*:[[:space:]]*"[^"]*"' "$json_path" 2>/dev/null | tail -1 | sed 's/.*"\([^"]*\)"$/\1/') local port=$(grep -o '"dbPort"[[:space:]]*:[[:space:]]*"[^"]*"' "$json_path" 2>/dev/null | tail -1 | sed 's/.*"\([^"]*\)"$/\1/')