From 435ed976c07825e2a565b0eb4b8eeb126f316e69 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Sun, 13 Sep 2026 23:09:15 +0330 Subject: [PATCH] fix(web): restart panel after ImportDB so subPath routes match (#6446) (#6456) * fix(web): restart panel after ImportDB so subPath routes match (#6446) ImportDB only restarted Xray, leaving the subscription HTTP server on startup-registered paths. Schedule the same in-process restart hook used by restartPanel so restored subPath (and related) routes take effect without relying on a browser follow-up that can fail after session invalidation. * fix(web): schedule the post-import panel restart once, via PanelService ImportDB grew a private copy of PanelService.RestartPanel (same hook check, same Windows bail-out, same SIGHUP fallback, already diverging in log severity) while BackupModal kept POSTing restartPanel after a successful import, so one restore bounced the panel and the public sub server twice back to back. The service package cannot reuse PanelService (panel imports service), so the importDB controller now calls the existing RestartPanel(3s) after ImportDB succeeds, the duplicated helper is dropped, and the browser follow-up is removed; it waits out the restart and reloads. Test drives the importDB handler against a stub xray binary and fails when no restart is scheduled through the global restart hook. --------- Co-authored-by: mrchatam Co-authored-by: Sanaei --- frontend/src/pages/index/BackupModal.tsx | 10 +- .../web/controller/import_db_restart_test.go | 91 +++++++++++++++++++ internal/web/controller/server.go | 3 + 3 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 internal/web/controller/import_db_restart_test.go diff --git a/frontend/src/pages/index/BackupModal.tsx b/frontend/src/pages/index/BackupModal.tsx index b0f62bf2d..a77478923 100644 --- a/frontend/src/pages/index/BackupModal.tsx +++ b/frontend/src/pages/index/BackupModal.tsx @@ -59,14 +59,10 @@ export default function BackupModal({ return; } + // importDB schedules the panel restart server-side; wait it out, then reload. onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` }); - const restart = await HttpUtil.post('/panel/api/setting/restartPanel'); - if (restart?.success) { - await PromiseUtil.sleep(5000); - window.location.reload(); - } else { - onBusy({ busy: false }); - } + await PromiseUtil.sleep(5000); + window.location.reload(); }); fileInput.click(); } diff --git a/internal/web/controller/import_db_restart_test.go b/internal/web/controller/import_db_restart_test.go new file mode 100644 index 000000000..9b9392797 --- /dev/null +++ b/internal/web/controller/import_db_restart_test.go @@ -0,0 +1,91 @@ +package controller + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/web/global" + "github.com/mhsanaei/3x-ui/v3/internal/xray" + + "github.com/gin-gonic/gin" +) + +// A successful import must schedule the panel restart itself: the browser's +// restartPanel follow-up can 401 once the imported users table lands (#6446). +func TestImportDBSchedulesPanelRestart(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the stub xray binary is a shell script") + } + uploadPath := filepath.Join(t.TempDir(), "x-ui.db") + if err := database.InitDB(uploadPath); err != nil { + t.Fatalf("InitDB(upload): %v", err) + } + if err := database.CloseDB(); err != nil { + t.Fatalf("CloseDB(upload): %v", err) + } + upload, err := os.ReadFile(uploadPath) + if err != nil { + t.Fatalf("read upload: %v", err) + } + + newHostTestDB(t) + binDir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", binDir) + t.Setenv("XUI_LOG_FOLDER", t.TempDir()) + if err := os.WriteFile(filepath.Join(binDir, xray.GetBinaryName()), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write stub xray: %v", err) + } + + restarts := make(chan struct{}, 1) + global.SetRestartHook(func() { + select { + case restarts <- struct{}{}: + default: + } + }) + t.Cleanup(func() { global.SetRestartHook(func() {}) }) + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, err := mw.CreateFormFile("db", "x-ui.db") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := part.Write(upload); err != nil { + t.Fatalf("write part: %v", err) + } + if err := mw.Close(); err != nil { + t.Fatalf("close multipart: %v", err) + } + + a := &ServerController{} + engine := gin.New() + engine.POST("/panel/api/server/importDB", a.importDB) + req := httptest.NewRequest(http.MethodPost, "/panel/api/server/importDB", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + var env hostEnvelope + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v body=%s", err, w.Body.String()) + } + if !env.Success { + t.Fatalf("importDB failed: %s", env.Msg) + } + + select { + case <-restarts: + case <-time.After(6 * time.Second): + t.Fatal("importDB succeeded but no panel restart was scheduled within 6s") + } +} diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 581fb71f2..22e029936 100644 --- a/internal/web/controller/server.go +++ b/internal/web/controller/server.go @@ -391,6 +391,9 @@ func (a *ServerController) importDB(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err) return } + // Startup-registered routes (subPath) must match the restored DB, and the + // browser's restartPanel follow-up can 401 once the imported users land (#6446). + _ = a.panelService.RestartPanel(3 * time.Second) jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil) }