From a335456cd3bf0f9b91c7e7a3fea6f3099f6671b4 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 2 Jul 2026 13:42:03 +0200 Subject: [PATCH] fix(settings): repair legacy path settings that block every settings save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/database/db.go | 36 ++++++++++++++++++++++++- internal/database/db_seed_test.go | 41 +++++++++++++++++++++++++++++ internal/web/translation/ar-EG.json | 5 +++- internal/web/translation/en-US.json | 5 +++- internal/web/translation/es-ES.json | 5 +++- internal/web/translation/fa-IR.json | 5 +++- internal/web/translation/id-ID.json | 5 +++- internal/web/translation/ja-JP.json | 5 +++- internal/web/translation/pt-BR.json | 5 +++- internal/web/translation/ru-RU.json | 5 +++- internal/web/translation/tr-TR.json | 5 +++- internal/web/translation/uk-UA.json | 5 +++- internal/web/translation/vi-VN.json | 5 +++- internal/web/translation/zh-CN.json | 5 +++- internal/web/translation/zh-TW.json | 5 +++- 15 files changed, 128 insertions(+), 14 deletions(-) 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 配置",