diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index f651e82a..66652778 100644 --- a/frontend/src/api/axios-init.js +++ b/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(); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 42e3ffcd..f1099d38 100644 --- a/frontend/vite.config.js +++ b/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/ from web/dist/.html. -// In dev the Vue app lives at /index.html, /settings.html, ... while AppSidebar -// links use the production-style /panel/ 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/ 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); - } - } - 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; - } - // eslint-disable-next-line no-console - console.error('[proxy]', err); - }); - }, - }; +let cachedBasePath = '/'; + +function readBasePathFromDB() { + const dbPath = resolveDBPath(); + let db; + try { + db = new DatabaseSync(dbPath, { readOnly: true }); + } catch (_e) { + return '/'; } - return config; + 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 = ``; + return html.replace('', `${tag}`); + }, + }; +} + +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) { + 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; + } + // 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, }, }, }, diff --git a/web/controller/base.go b/web/controller/base.go index 7bc61b64..2b136103 100644 --- a/web/controller/base.go +++ b/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() diff --git a/web/controller/index.go b/web/controller/index.go index 1a719a98..ac802026 100644 --- a/web/controller/index.go +++ b/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")) } diff --git a/web/controller/server.go b/web/controller/server.go index 031c5318..146c94cd 100644 --- a/web/controller/server.go +++ b/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) diff --git a/web/session/session.go b/web/session/session.go index 6267679a..44684e2e 100644 --- a/web/session/session.go +++ b/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 }