Просмотр исходного кода

fix(panel): make webBasePath work end-to-end in dev and prod

- Vite dev server reads webBasePath from x-ui.db via node:sqlite and
  injects __X_UI_BASE_PATH__ on every HTML serve, mirroring dist.go.
  Single broad proxy regex catches backend routes whether the URL is
  prefixed or not, and the bypass serves login.html for the bare
  basePath URL so post-logout navigation lands on Vite's own page
  instead of the production dist HTML's hashed asset URLs.
- axios.defaults.baseURL is set from __X_UI_BASE_PATH__ at startup so
  HttpUtil calls reach the backend's basePath group instead of 404ing
  on every prefixed install. fetch() for the public CSRF endpoint
  prepends the prefix manually since it doesn't honor axios defaults.
- Logout/redirect responses set Cache-Control: no-store and the index
  handler's logged-in redirect uses an absolute base_path+panel/ URL,
  preventing browsers from replaying a stale cached 307 that bounced
  the user back to /panel/ after logout.
- ClearSession also issues a Path=/ deletion cookie when basePath is
  not "/", so a legacy cookie from an earlier basePath setting can't
  keep IsLogin returning true after logout.
- getPanelUpdateInfo no longer returns a translated error message on
  GitHub fetch failures, so HttpUtil's auto-popup stays quiet on
  offline / blocked environments.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
MHSanaei 18 часов назад
Родитель
Сommit
61c84e8223

+ 10 - 1
frontend/src/api/axios-init.js

@@ -22,7 +22,11 @@ function readMetaToken() {
 // recurse through this same interceptor.
 async function fetchCsrfToken() {
   try {
-    const res = await fetch(CSRF_TOKEN_PATH, {
+    const basePath = window.__X_UI_BASE_PATH__;
+    const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
+      ? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
+      : CSRF_TOKEN_PATH);
+    const res = await fetch(url, {
       method: 'GET',
       credentials: 'same-origin',
       headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -55,6 +59,11 @@ export function setupAxios() {
   axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
   axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
 
+  const basePath = window.__X_UI_BASE_PATH__;
+  if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
+    axios.defaults.baseURL = basePath;
+  }
+
   // Seed the cache from the meta tag if a server-rendered page injected
   // one — saves a round trip on legacy templates that still embed it.
   csrfToken = readMetaToken();

+ 125 - 98
frontend/vite.config.js

@@ -1,89 +1,136 @@
 import { defineConfig } from 'vite';
 import vue from '@vitejs/plugin-vue';
+import fs from 'node:fs';
 import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
 
-// Output goes to web/dist/ at the repo root so the Go binary can embed it
-// via embed.FS without reaching outside the web/ tree.
 const outDir = path.resolve(__dirname, '../web/dist');
+const BACKEND_TARGET = 'http://localhost:2053';
 
-// In production the Go binary serves /panel/<route> from web/dist/<route>.html.
-// In dev the Vue app lives at /index.html, /settings.html, ... while AppSidebar
-// links use the production-style /panel/<route> URLs. Map each migrated route
-// to its Vite entry so the sidebar works without relying on the Go backend
-// for already-ported pages.
-const MIGRATED_ROUTES = {
-  '/panel': '/index.html',
-  '/panel/': '/index.html',
-  '/panel/settings': '/settings.html',
-  '/panel/settings/': '/settings.html',
-  '/panel/inbounds': '/inbounds.html',
-  '/panel/inbounds/': '/inbounds.html',
-  '/panel/xray': '/xray.html',
-  '/panel/xray/': '/xray.html',
-  '/panel/nodes': '/nodes.html',
-  '/panel/nodes/': '/nodes.html',
+function resolveDBPath() {
+  const envFolder = process.env.XUI_DB_FOLDER;
+  if (envFolder) return path.join(envFolder, 'x-ui.db');
+  const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
+  if (fs.existsSync(repoDB)) return repoDB;
+  return '/etc/x-ui/x-ui.db';
+}
+
+const BASE_MIGRATED_ROUTES = {
+  'panel': '/index.html',
+  'panel/': '/index.html',
+  'panel/settings': '/settings.html',
+  'panel/settings/': '/settings.html',
+  'panel/inbounds': '/inbounds.html',
+  'panel/inbounds/': '/inbounds.html',
+  'panel/xray': '/xray.html',
+  'panel/xray/': '/xray.html',
+  'panel/nodes': '/nodes.html',
+  'panel/nodes/': '/nodes.html',
 };
 
-// Build a proxy config that suppresses ECONNREFUSED noise when the Go
-// backend isn't running locally. Real errors (timeouts, 5xx, etc.) still
-// surface in the Vite log.
-function makeBackendProxy(target, patterns) {
-  const config = {};
-  for (const pattern of patterns) {
-    config[pattern] = {
-      target,
-      changeOrigin: true,
-      // Returning a path from bypass tells Vite to serve that file from
-      // its own dev server instead of forwarding the request — used here
-      // to short-circuit /panel/<route> for pages we've already migrated.
-      //
-      // Only GETs get bypassed: the xray page reuses its page URL
-      // (`POST /panel/xray/`) for data, so a method-blind bypass would
-      // hand HTML back to fetch calls and break the page in dev.
-      bypass(req) {
-        if (req.method !== 'GET') return undefined;
-        const url = req.url.split('?')[0];
-        if (Object.prototype.hasOwnProperty.call(MIGRATED_ROUTES, url)) {
-          return MIGRATED_ROUTES[url];
-        }
-        return undefined;
-      },
-      configure(proxy) {
-        let warned = false;
-        proxy.on('error', (err, req) => {
-          // Node wraps connection failures in an AggregateError when DNS
-          // returns multiple addresses (e.g. ::1 + 127.0.0.1) and all
-          // refuse — the code lands on the inner errors, not the outer.
-          const codes = new Set();
-          if (err && err.code) codes.add(err.code);
-          if (err && Array.isArray(err.errors)) {
-            for (const inner of err.errors) {
-              if (inner && inner.code) codes.add(inner.code);
-            }
+let cachedBasePath = '/';
+
+function readBasePathFromDB() {
+  const dbPath = resolveDBPath();
+  let db;
+  try {
+    db = new DatabaseSync(dbPath, { readOnly: true });
+  } catch (_e) {
+    return '/';
+  }
+  try {
+    const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('webBasePath');
+    let value = row && typeof row.value === 'string' ? row.value : '/';
+    if (!value.startsWith('/')) value = '/' + value;
+    if (!value.endsWith('/')) value += '/';
+    return value;
+  } catch (_e) {
+    return '/';
+  } finally {
+    db.close();
+  }
+}
+
+function refreshBasePath() {
+  cachedBasePath = readBasePathFromDB();
+  return cachedBasePath;
+}
+
+// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
+// already injects __X_UI_BASE_PATH__ at runtime in production.
+function injectBasePathPlugin() {
+  return {
+    name: 'xui-inject-base-path',
+    apply: 'serve',
+    transformIndexHtml(html) {
+      const basePath = refreshBasePath();
+      const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+      const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
+      return html.replace('</head>', `${tag}</head>`);
+    },
+  };
+}
+
+function bypassMigratedRoute(req) {
+  if (req.method !== 'GET') return undefined;
+  const url = req.url.split('?')[0];
+
+  for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
+    if (url === '/' + key) return value;
+  }
+
+  const m = url.match(/^\/[^/]+\/(.+)$/);
+  if (m) {
+    const stripped = m[1];
+    if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
+  }
+
+  if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
+
+  return undefined;
+}
+
+function rewriteToBackend(p) {
+  if (cachedBasePath === '/' || p.startsWith(cachedBasePath)) return p;
+  return cachedBasePath + p.replace(/^\//, '');
+}
+
+function makeBackendProxy(target) {
+  return {
+    target,
+    changeOrigin: true,
+    rewrite: rewriteToBackend,
+    bypass: bypassMigratedRoute,
+    configure(proxy) {
+      let warned = false;
+      proxy.on('error', (err, req) => {
+        const codes = new Set();
+        if (err && err.code) codes.add(err.code);
+        if (err && Array.isArray(err.errors)) {
+          for (const inner of err.errors) {
+            if (inner && inner.code) codes.add(inner.code);
           }
-          const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
-          if (offline) {
-            // Print a single friendly hint the first time, then stay quiet.
-            if (!warned) {
-              warned = true;
-              // eslint-disable-next-line no-console
-              console.warn(
-                `[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
-              );
-            }
-            return;
+        }
+        const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
+        if (offline) {
+          if (!warned) {
+            warned = true;
+            // eslint-disable-next-line no-console
+            console.warn(
+              `[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
+            );
           }
-          // eslint-disable-next-line no-console
-          console.error('[proxy]', err);
-        });
-      },
-    };
-  }
-  return config;
+          return;
+        }
+        // eslint-disable-next-line no-console
+        console.error('[proxy]', err);
+      });
+    },
+  };
 }
 
 export default defineConfig({
-  plugins: [vue()],
+  plugins: [vue(), injectBasePathPlugin()],
   resolve: {
     alias: {
       '@': path.resolve(__dirname, 'src'),
@@ -94,14 +141,7 @@ export default defineConfig({
     emptyOutDir: true,
     sourcemap: true,
     target: 'es2020',
-    // ant-design-vue is intentionally bundled as one chunk (its
-    // components share internals — splitting it breaks Modal/Form/
-    // Select interop). Minified it lands ~1.4MB but gzips to ~410kB,
-    // so the actual transfer is fine and caches across every page.
-    // Bump the warning past that ceiling so the build stays quiet.
     chunkSizeWarningLimit: 1500,
-    // Multiple HTML entries — one per legacy page we migrate.
-    // As pages get ported in later phases, add their entrypoints here.
     rollupOptions: {
       input: {
         index: path.resolve(__dirname, 'index.html'),
@@ -113,10 +153,6 @@ export default defineConfig({
         subpage: path.resolve(__dirname, 'subpage.html'),
       },
       output: {
-        // Split vendor deps into stable chunks so each page only pulls
-        // what it needs and the browser caches them across versions.
-        // Without this, ant-design-vue + vue + icons all end up in one
-        // 1.6MB blob attached to whichever page consumed them first.
         manualChunks(id) {
           if (!id.includes('node_modules')) return undefined;
           if (id.includes('ant-design-vue')) return 'vendor-antd';
@@ -129,8 +165,6 @@ export default defineConfig({
           if (id.includes('dayjs')) return 'vendor-dayjs';
           if (id.includes('qrious')) return 'vendor-qrious';
           if (id.includes('axios')) return 'vendor-axios';
-          // The persian datepicker pulls in moment + moment-jalaali; bundle
-          // the trio together so unrelated pages don't pay the cost.
           if (
             id.includes('vue3-persian-datetime-picker')
             || id.includes('moment-jalaali')
@@ -146,21 +180,14 @@ export default defineConfig({
     port: 5173,
     strictPort: true,
     proxy: {
-      ...makeBackendProxy('http://localhost:2053', [
-        // Patterns are anchored regex so /login.html and /index.html
-        // (which Vite serves itself) are NOT forwarded — only the bare
-        // backend paths and their sub-routes.
-        '^/(login|logout|getTwoFactorEnable|csrf-token)$',
-        '^/(panel|server)(/|$)',
-      ]),
-      // The panel mounts the live-update WebSocket at /ws (basePath +
-      // "/ws"). Vite needs `ws: true` to forward the HTTP Upgrade to the
-      // Go backend; without it the dev server would 404 the upgrade and
-      // the page falls back to the no-data state.
-      '/ws': {
+      '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
+      '^/$': makeBackendProxy(BACKEND_TARGET),
+      '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
+      '^/(?:[^/]+/)?ws$': {
         target: 'ws://localhost:2053',
         ws: true,
         changeOrigin: true,
+        rewrite: rewriteToBackend,
       },
     },
   },

+ 1 - 0
web/controller/base.go

@@ -21,6 +21,7 @@ func (a *BaseController) checkLogin(c *gin.Context) {
 		if isAjax(c) {
 			pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
 		} else {
+			c.Header("Cache-Control", "no-store")
 			c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
 		}
 		c.Abort()

+ 3 - 1
web/controller/index.go

@@ -54,7 +54,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
 // index handles the root route, redirecting logged-in users to the panel or showing the login page.
 func (a *IndexController) index(c *gin.Context) {
 	if session.IsLogin(c) {
-		c.Redirect(http.StatusTemporaryRedirect, "panel/")
+		c.Header("Cache-Control", "no-store")
+		c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/")
 		return
 	}
 	serveDistPage(c, "login.html")
@@ -148,6 +149,7 @@ func (a *IndexController) logout(c *gin.Context) {
 	if err := session.ClearSession(c); err != nil {
 		logger.Warning("Unable to clear session on logout:", err)
 	}
+	c.Header("Cache-Control", "no-store")
 	c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
 }
 

+ 1 - 7
web/controller/server.go

@@ -164,17 +164,11 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
 }
 
 // getPanelUpdateInfo retrieves the current and latest panel version.
-// Network failures (e.g. no internet, GitHub blocked) are logged at debug
-// level only — the panel keeps working offline and we don't want to spam
-// WARN every time a user opens the page.
 func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
 	info, err := a.panelService.GetUpdateInfo()
 	if err != nil {
 		logger.Debug("panel update check failed:", err)
-		c.JSON(http.StatusOK, entity.Msg{
-			Success: false,
-			Msg:     I18nWeb(c, "pages.index.panelUpdateCheckPopover"),
-		})
+		c.JSON(http.StatusOK, entity.Msg{Success: false})
 		return
 	}
 	jsonObj(c, info, nil)

+ 22 - 28
web/session/session.go

@@ -1,10 +1,9 @@
-// Package session provides session management utilities for the 3x-ui web panel.
-// It handles user authentication state, login sessions, and session storage using Gin sessions.
 package session
 
 import (
 	"encoding/gob"
 	"net/http"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v2/database/model"
 	"github.com/mhsanaei/3x-ui/v2/logger"
@@ -14,24 +13,15 @@ import (
 )
 
 const (
-	loginUserKey = "LOGIN_USER"
-	// apiAuthUserKey is the gin-context key under which checkAPIAuth
-	// stashes a fallback user for Bearer-token-authenticated callers.
-	// Bearer requests don't carry a session cookie, so handlers that
-	// scope writes by user.Id (e.g. InboundController.addInbound) would
-	// otherwise nil-deref. Keeping the override in the gin context
-	// (not the cookie session) means the fallback never leaks into a
-	// browser request.
-	apiAuthUserKey = "api_auth_user"
+	loginUserKey      = "LOGIN_USER"
+	apiAuthUserKey    = "api_auth_user"
+	sessionCookieName = "3x-ui"
 )
 
 func init() {
 	gob.Register(model.User{})
 }
 
-// SetLoginUser stores the authenticated user in the session and persists it.
-// gin-contrib/sessions does not auto-save; callers that forget Save() leave
-// the cookie out of sync with server state — this helper avoids that pitfall.
 func SetLoginUser(c *gin.Context, user *model.User) error {
 	if user == nil {
 		return nil
@@ -41,10 +31,6 @@ func SetLoginUser(c *gin.Context, user *model.User) error {
 	return s.Save()
 }
 
-// SetAPIAuthUser stashes a fallback user on the gin context for the
-// lifetime of a single bearer-authed request. checkAPIAuth calls this
-// after a successful token match so downstream handlers that read
-// GetLoginUser don't see nil.
 func SetAPIAuthUser(c *gin.Context, user *model.User) {
 	if user == nil {
 		return
@@ -52,8 +38,6 @@ func SetAPIAuthUser(c *gin.Context, user *model.User) {
 	c.Set(apiAuthUserKey, user)
 }
 
-// GetLoginUser retrieves the authenticated user from the session.
-// Returns nil if no user is logged in or if the session data is invalid.
 func GetLoginUser(c *gin.Context) *model.User {
 	if v, ok := c.Get(apiAuthUserKey); ok {
 		if u, ok2 := v.(*model.User); ok2 {
@@ -67,8 +51,6 @@ func GetLoginUser(c *gin.Context) *model.User {
 	}
 	user, ok := obj.(model.User)
 	if !ok {
-		// Stale or incompatible session payload — wipe and persist immediately
-		// so subsequent requests don't keep hitting the same broken cookie.
 		s.Delete(loginUserKey)
 		if err := s.Save(); err != nil {
 			logger.Warning("session: failed to drop stale user payload:", err)
@@ -78,14 +60,10 @@ func GetLoginUser(c *gin.Context) *model.User {
 	return &user
 }
 
-// IsLogin checks if a user is currently authenticated in the session.
 func IsLogin(c *gin.Context) bool {
 	return GetLoginUser(c) != nil
 }
 
-// ClearSession invalidates the session and tells the browser to drop the cookie.
-// The cookie attributes (Path/HttpOnly/SameSite) must mirror those used when
-// the cookie was created or browsers will keep it.
 func ClearSession(c *gin.Context) error {
 	s := sessions.Default(c)
 	s.Clear()
@@ -93,12 +71,28 @@ func ClearSession(c *gin.Context) error {
 	if cookiePath == "" {
 		cookiePath = "/"
 	}
+	secure := c.Request.TLS != nil
 	s.Options(sessions.Options{
 		Path:     cookiePath,
 		MaxAge:   -1,
 		HttpOnly: true,
-		Secure:   c.Request.TLS != nil,
+		Secure:   secure,
 		SameSite: http.SameSiteLaxMode,
 	})
-	return s.Save()
+	if err := s.Save(); err != nil {
+		return err
+	}
+	if cookiePath != "/" {
+		http.SetCookie(c.Writer, &http.Cookie{
+			Name:     sessionCookieName,
+			Value:    "",
+			Path:     "/",
+			MaxAge:   -1,
+			Expires:  time.Unix(0, 0),
+			HttpOnly: true,
+			Secure:   secure,
+			SameSite: http.SameSiteLaxMode,
+		})
+	}
+	return nil
 }