diff --git a/internal/database/db.go b/internal/database/db.go index 2b33997d6..05ff98e5e 100644 --- a/internal/database/db.go +++ b/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 { diff --git a/internal/database/db_seed_test.go b/internal/database/db_seed_test.go index 63d623d33..2464211ad 100644 --- a/internal/database/db_seed_test.go +++ b/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) + } + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 8a92963b8..5e9e7e6ee 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1396,7 +1396,10 @@ "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}", "eventMemoryHigh": "ارتفاع استخدام الذاكرة (%)", "remarkTemplate": "قالب الملاحظة", - "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه." + "remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.", + "validation": { + "pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /" + } }, "xray": { "title": "إعدادات Xray", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 005d1884d..2c65f1719 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 834377d20..dacf31c95 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 9587704cb..bc0718cd4 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1396,7 +1396,10 @@ "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند", "smtpErrorEof": "اتصال توسط سرور بسته شد", "smtpErrorUnknown": "خطای SMTP: {{ .Error }}", - "eventMemoryHigh": "مصرف حافظه بالا (%)" + "eventMemoryHigh": "مصرف حافظه بالا (%)", + "validation": { + "pathLeadingSlash": "مسیر باید با / شروع شود" + } }, "xray": { "title": "پیکربندی ایکس‌ری", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index a5c1679b3..2b9a8e92c 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 9be5a9791..d30b9f2ba 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1396,7 +1396,10 @@ "smtpErrorUnknown": "SMTPエラー: {{ .Error }}", "eventMemoryHigh": "メモリ使用率が高い (%)", "remarkTemplate": "備考テンプレート", - "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。" + "remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。", + "validation": { + "pathLeadingSlash": "パスは / で始まる必要があります" + } }, "xray": { "title": "Xray 設定", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index c4c307e21..a7aebcae7 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 2b402e1ae..1ce3a9b03 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1396,7 +1396,10 @@ "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}", "eventMemoryHigh": "Превышение порога памяти (%)", "remarkTemplate": "Шаблон примечания", - "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше." + "remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.", + "validation": { + "pathLeadingSlash": "Путь должен начинаться с /" + } }, "xray": { "importRules": "Импорт правил", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 54f86a1ea..7ccdd1ba6 100644 --- a/internal/web/translation/tr-TR.json +++ b/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ı", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 70b51eafe..d66259e77 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1396,7 +1396,10 @@ "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}", "eventMemoryHigh": "Високе використання пам'яті (%)", "remarkTemplate": "Шаблон примітки", - "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище." + "remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.", + "validation": { + "pathLeadingSlash": "Шлях має починатися з /" + } }, "xray": { "title": "Xray конфігурації", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index b6b67a3d6..5f037e294 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index d44dc801e..b6fc73d0e 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1396,7 +1396,10 @@ "smtpErrorUnknown": "SMTP 错误:{{ .Error }}", "eventMemoryHigh": "内存使用率高 (%)", "remarkTemplate": "备注模板", - "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。" + "remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。", + "validation": { + "pathLeadingSlash": "路径必须以 / 开头" + } }, "xray": { "importRules": "导入规则", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 7cf10f931..ac61e969f 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1396,7 +1396,10 @@ "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}", "eventMemoryHigh": "記憶體使用率高 (%)", "remarkTemplate": "備註範本", - "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。" + "remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。", + "validation": { + "pathLeadingSlash": "路徑必須以 / 開頭" + } }, "xray": { "title": "Xray 配置",