浏览代码

fix(settings): repair legacy path settings that block every settings save

A subJsonPath (or subPath/subClashPath/webBasePath) stored without its
leading/trailing slash — written before the slash rules existed, or
restored from an old backup — fails the frontend's whole-form validation,
so every save on the Settings page is rejected client-side. The backend's
CheckValid would normalize the value, but a save request never reaches it,
leaving the panel wedged until someone edits the database by hand.

Normalize the stored path rows at startup, mirroring CheckValid's slash
rules. The pass is idempotent and not seeder-gated, since a restored
backup can reintroduce bad values at any time.

Also add the missing pages.settings.validation.pathLeadingSlash key to
all 13 locales — the validation error used to render as its raw key.

Closes #5726
MHSanaei 1 天之前
父节点
当前提交
a335456cd3

+ 35 - 1
internal/database/db.go

@@ -664,7 +664,10 @@ func runSeeders(isUsersEmpty bool) error {
 	if err := seedWireguardPeersToClients(); err != nil {
 		return err
 	}
-	return nil
+
+	// Idempotent, not seeder-gated: bad values can re-enter via a restored
+	// backup, so re-check on every start.
+	return normalizeSettingPaths()
 }
 
 // resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where
@@ -769,6 +772,37 @@ func clearLegacyProxySettings() error {
 	})
 }
 
+// normalizeSettingPaths repairs URI-path settings persisted before the
+// leading/trailing-slash rules existed (or restored from an old backup),
+// mirroring entity.AllSetting.CheckValid. CheckValid self-heals these on save,
+// but the frontend rejects the whole Settings form on the bad stored value
+// before a save can ever reach it (#5726), so the stored rows themselves must
+// be fixed. Idempotent; runs on every start.
+func normalizeSettingPaths() error {
+	pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
+	var rows []model.Setting
+	if err := db.Where("key IN ?", pathKeys).Find(&rows).Error; err != nil {
+		return err
+	}
+	for _, row := range rows {
+		fixed := row.Value
+		if !strings.HasPrefix(fixed, "/") {
+			fixed = "/" + fixed
+		}
+		if !strings.HasSuffix(fixed, "/") {
+			fixed += "/"
+		}
+		if fixed == row.Value {
+			continue
+		}
+		if err := db.Model(&model.Setting{}).Where("id = ?", row.Id).
+			Update("value", fixed).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func normalizeInboundClientTgId() error {
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {

+ 41 - 0
internal/database/db_seed_test.go

@@ -153,3 +153,44 @@ func TestNormalizeInboundClientSubId_FillsMissingAndPreservesExisting(t *testing
 		t.Fatalf("expected one InboundClientSubIdFix history row, got %d", historyCount)
 	}
 }
+
+func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	seed := []model.Setting{
+		{Key: "subJsonPath", Value: "YIrCXJOOOL"},
+		{Key: "subPath", Value: "/sub"},
+		{Key: "subClashPath", Value: "clash/"},
+		{Key: "webBasePath", Value: "/panel/"},
+	}
+	for i := range seed {
+		if err := db.Create(&seed[i]).Error; err != nil {
+			t.Fatalf("seed setting %s: %v", seed[i].Key, err)
+		}
+	}
+
+	if err := normalizeSettingPaths(); err != nil {
+		t.Fatalf("normalizeSettingPaths: %v", err)
+	}
+
+	want := map[string]string{
+		"subJsonPath":  "/YIrCXJOOOL/",
+		"subPath":      "/sub/",
+		"subClashPath": "/clash/",
+		"webBasePath":  "/panel/",
+	}
+	for key, expected := range want {
+		var row model.Setting
+		if err := db.Where("key = ?", key).First(&row).Error; err != nil {
+			t.Fatalf("read %s: %v", key, err)
+		}
+		if row.Value != expected {
+			t.Errorf("%s = %q, want %q", key, row.Value, expected)
+		}
+	}
+}

+ 4 - 1
internal/web/translation/ar-EG.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
       "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)",
       "remarkTemplate": "قالب الملاحظة",
-      "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه."
+      "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
+      "validation": {
+        "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
+      }
     },
     "xray": {
       "title": "إعدادات Xray",

+ 4 - 1
internal/web/translation/en-US.json

@@ -1512,7 +1512,10 @@
       "smtpErrorRelay": "Server rejects sending from this address",
       "smtpErrorEof": "Connection closed by server",
       "smtpErrorUnknown": "SMTP error: {{ .Error }}",
-      "eventMemoryHigh": "Memory high (%)"
+      "eventMemoryHigh": "Memory high (%)",
+      "validation": {
+        "pathLeadingSlash": "Path must start with /"
+      }
     },
     "xray": {
       "title": "Xray Configs",

+ 4 - 1
internal/web/translation/es-ES.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
       "eventMemoryHigh": "Uso de memoria alto (%)",
       "remarkTemplate": "Plantilla de notas",
-      "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior."
+      "remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
+      "validation": {
+        "pathLeadingSlash": "La ruta debe comenzar con /"
+      }
     },
     "xray": {
       "title": "Xray Configuración",

+ 4 - 1
internal/web/translation/fa-IR.json

@@ -1396,7 +1396,10 @@
       "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
       "smtpErrorEof": "اتصال توسط سرور بسته شد",
       "smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
-      "eventMemoryHigh": "مصرف حافظه بالا (%)"
+      "eventMemoryHigh": "مصرف حافظه بالا (%)",
+      "validation": {
+        "pathLeadingSlash": "مسیر باید با / شروع شود"
+      }
     },
     "xray": {
       "title": "پیکربندی ایکس‌ری",

+ 4 - 1
internal/web/translation/id-ID.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
       "eventMemoryHigh": "Penggunaan memori tinggi (%)",
       "remarkTemplate": "Templat Catatan",
-      "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas."
+      "remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
+      "validation": {
+        "pathLeadingSlash": "Path harus diawali dengan /"
+      }
     },
     "xray": {
       "title": "Konfigurasi Xray",

+ 4 - 1
internal/web/translation/ja-JP.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
       "eventMemoryHigh": "メモリ使用率が高い (%)",
       "remarkTemplate": "備考テンプレート",
-      "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。"
+      "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
+      "validation": {
+        "pathLeadingSlash": "パスは / で始まる必要があります"
+      }
     },
     "xray": {
       "title": "Xray 設定",

+ 4 - 1
internal/web/translation/pt-BR.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
       "eventMemoryHigh": "Uso de memória alto (%)",
       "remarkTemplate": "Modelo de Observação",
-      "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima."
+      "remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
+      "validation": {
+        "pathLeadingSlash": "O caminho deve começar com /"
+      }
     },
     "xray": {
       "title": "Configurações Xray",

+ 4 - 1
internal/web/translation/ru-RU.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
       "eventMemoryHigh": "Превышение порога памяти (%)",
       "remarkTemplate": "Шаблон примечания",
-      "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше."
+      "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
+      "validation": {
+        "pathLeadingSlash": "Путь должен начинаться с /"
+      }
     },
     "xray": {
       "importRules": "Импорт правил",

+ 4 - 1
internal/web/translation/tr-TR.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
       "eventMemoryHigh": "Bellek kullanımı yüksek (%)",
       "remarkTemplate": "Açıklama Şablonu",
-      "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın."
+      "remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
+      "validation": {
+        "pathLeadingSlash": "Yol / ile başlamalıdır"
+      }
     },
     "xray": {
       "title": "Xray Yapılandırmaları",

+ 4 - 1
internal/web/translation/uk-UA.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
       "eventMemoryHigh": "Високе використання пам'яті (%)",
       "remarkTemplate": "Шаблон примітки",
-      "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище."
+      "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
+      "validation": {
+        "pathLeadingSlash": "Шлях має починатися з /"
+      }
     },
     "xray": {
       "title": "Xray конфігурації",

+ 4 - 1
internal/web/translation/vi-VN.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
       "eventMemoryHigh": "Sử dụng bộ nhớ cao (%)",
       "remarkTemplate": "Mẫu ghi chú",
-      "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên."
+      "remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
+      "validation": {
+        "pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
+      }
     },
     "xray": {
       "title": "Cài đặt Xray",

+ 4 - 1
internal/web/translation/zh-CN.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
       "eventMemoryHigh": "内存使用率高 (%)",
       "remarkTemplate": "备注模板",
-      "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。"
+      "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
+      "validation": {
+        "pathLeadingSlash": "路径必须以 / 开头"
+      }
     },
     "xray": {
       "importRules": "导入规则",

+ 4 - 1
internal/web/translation/zh-TW.json

@@ -1396,7 +1396,10 @@
       "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
       "eventMemoryHigh": "記憶體使用率高 (%)",
       "remarkTemplate": "備註範本",
-      "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。"
+      "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
+      "validation": {
+        "pathLeadingSlash": "路徑必須以 / 開頭"
+      }
     },
     "xray": {
       "title": "Xray 配置",