Skip to content

Commit 61c84e8

Browse files
MHSanaeiclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 72d8ebd commit 61c84e8

6 files changed

Lines changed: 162 additions & 135 deletions

File tree

frontend/src/api/axios-init.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ function readMetaToken() {
2222
// recurse through this same interceptor.
2323
async function fetchCsrfToken() {
2424
try {
25-
const res = await fetch(CSRF_TOKEN_PATH, {
25+
const basePath = window.__X_UI_BASE_PATH__;
26+
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
27+
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
28+
: CSRF_TOKEN_PATH);
29+
const res = await fetch(url, {
2630
method: 'GET',
2731
credentials: 'same-origin',
2832
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -55,6 +59,11 @@ export function setupAxios() {
5559
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
5660
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
5761

62+
const basePath = window.__X_UI_BASE_PATH__;
63+
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
64+
axios.defaults.baseURL = basePath;
65+
}
66+
5867
// Seed the cache from the meta tag if a server-rendered page injected
5968
// one — saves a round trip on legacy templates that still embed it.
6069
csrfToken = readMetaToken();

frontend/vite.config.js

Lines changed: 125 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,136 @@
11
import { defineConfig } from 'vite';
22
import vue from '@vitejs/plugin-vue';
3+
import fs from 'node:fs';
34
import path from 'node:path';
5+
import { DatabaseSync } from 'node:sqlite';
46

5-
// Output goes to web/dist/ at the repo root so the Go binary can embed it
6-
// via embed.FS without reaching outside the web/ tree.
77
const outDir = path.resolve(__dirname, '../web/dist');
8+
const BACKEND_TARGET = 'http://localhost:2053';
89

9-
// In production the Go binary serves /panel/<route> from web/dist/<route>.html.
10-
// In dev the Vue app lives at /index.html, /settings.html, ... while AppSidebar
11-
// links use the production-style /panel/<route> URLs. Map each migrated route
12-
// to its Vite entry so the sidebar works without relying on the Go backend
13-
// for already-ported pages.
14-
const MIGRATED_ROUTES = {
15-
'/panel': '/index.html',
16-
'/panel/': '/index.html',
17-
'/panel/settings': '/settings.html',
18-
'/panel/settings/': '/settings.html',
19-
'/panel/inbounds': '/inbounds.html',
20-
'/panel/inbounds/': '/inbounds.html',
21-
'/panel/xray': '/xray.html',
22-
'/panel/xray/': '/xray.html',
23-
'/panel/nodes': '/nodes.html',
24-
'/panel/nodes/': '/nodes.html',
10+
function resolveDBPath() {
11+
const envFolder = process.env.XUI_DB_FOLDER;
12+
if (envFolder) return path.join(envFolder, 'x-ui.db');
13+
const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
14+
if (fs.existsSync(repoDB)) return repoDB;
15+
return '/etc/x-ui/x-ui.db';
16+
}
17+
18+
const BASE_MIGRATED_ROUTES = {
19+
'panel': '/index.html',
20+
'panel/': '/index.html',
21+
'panel/settings': '/settings.html',
22+
'panel/settings/': '/settings.html',
23+
'panel/inbounds': '/inbounds.html',
24+
'panel/inbounds/': '/inbounds.html',
25+
'panel/xray': '/xray.html',
26+
'panel/xray/': '/xray.html',
27+
'panel/nodes': '/nodes.html',
28+
'panel/nodes/': '/nodes.html',
2529
};
2630

27-
// Build a proxy config that suppresses ECONNREFUSED noise when the Go
28-
// backend isn't running locally. Real errors (timeouts, 5xx, etc.) still
29-
// surface in the Vite log.
30-
function makeBackendProxy(target, patterns) {
31-
const config = {};
32-
for (const pattern of patterns) {
33-
config[pattern] = {
34-
target,
35-
changeOrigin: true,
36-
// Returning a path from bypass tells Vite to serve that file from
37-
// its own dev server instead of forwarding the request — used here
38-
// to short-circuit /panel/<route> for pages we've already migrated.
39-
//
40-
// Only GETs get bypassed: the xray page reuses its page URL
41-
// (`POST /panel/xray/`) for data, so a method-blind bypass would
42-
// hand HTML back to fetch calls and break the page in dev.
43-
bypass(req) {
44-
if (req.method !== 'GET') return undefined;
45-
const url = req.url.split('?')[0];
46-
if (Object.prototype.hasOwnProperty.call(MIGRATED_ROUTES, url)) {
47-
return MIGRATED_ROUTES[url];
48-
}
49-
return undefined;
50-
},
51-
configure(proxy) {
52-
let warned = false;
53-
proxy.on('error', (err, req) => {
54-
// Node wraps connection failures in an AggregateError when DNS
55-
// returns multiple addresses (e.g. ::1 + 127.0.0.1) and all
56-
// refuse — the code lands on the inner errors, not the outer.
57-
const codes = new Set();
58-
if (err && err.code) codes.add(err.code);
59-
if (err && Array.isArray(err.errors)) {
60-
for (const inner of err.errors) {
61-
if (inner && inner.code) codes.add(inner.code);
62-
}
31+
let cachedBasePath = '/';
32+
33+
function readBasePathFromDB() {
34+
const dbPath = resolveDBPath();
35+
let db;
36+
try {
37+
db = new DatabaseSync(dbPath, { readOnly: true });
38+
} catch (_e) {
39+
return '/';
40+
}
41+
try {
42+
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('webBasePath');
43+
let value = row && typeof row.value === 'string' ? row.value : '/';
44+
if (!value.startsWith('/')) value = '/' + value;
45+
if (!value.endsWith('/')) value += '/';
46+
return value;
47+
} catch (_e) {
48+
return '/';
49+
} finally {
50+
db.close();
51+
}
52+
}
53+
54+
function refreshBasePath() {
55+
cachedBasePath = readBasePathFromDB();
56+
return cachedBasePath;
57+
}
58+
59+
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
60+
// already injects __X_UI_BASE_PATH__ at runtime in production.
61+
function injectBasePathPlugin() {
62+
return {
63+
name: 'xui-inject-base-path',
64+
apply: 'serve',
65+
transformIndexHtml(html) {
66+
const basePath = refreshBasePath();
67+
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
68+
const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
69+
return html.replace('</head>', `${tag}</head>`);
70+
},
71+
};
72+
}
73+
74+
function bypassMigratedRoute(req) {
75+
if (req.method !== 'GET') return undefined;
76+
const url = req.url.split('?')[0];
77+
78+
for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
79+
if (url === '/' + key) return value;
80+
}
81+
82+
const m = url.match(/^\/[^/]+\/(.+)$/);
83+
if (m) {
84+
const stripped = m[1];
85+
if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
86+
}
87+
88+
if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
89+
90+
return undefined;
91+
}
92+
93+
function rewriteToBackend(p) {
94+
if (cachedBasePath === '/' || p.startsWith(cachedBasePath)) return p;
95+
return cachedBasePath + p.replace(/^\//, '');
96+
}
97+
98+
function makeBackendProxy(target) {
99+
return {
100+
target,
101+
changeOrigin: true,
102+
rewrite: rewriteToBackend,
103+
bypass: bypassMigratedRoute,
104+
configure(proxy) {
105+
let warned = false;
106+
proxy.on('error', (err, req) => {
107+
const codes = new Set();
108+
if (err && err.code) codes.add(err.code);
109+
if (err && Array.isArray(err.errors)) {
110+
for (const inner of err.errors) {
111+
if (inner && inner.code) codes.add(inner.code);
63112
}
64-
const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
65-
if (offline) {
66-
// Print a single friendly hint the first time, then stay quiet.
67-
if (!warned) {
68-
warned = true;
69-
// eslint-disable-next-line no-console
70-
console.warn(
71-
`[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
72-
);
73-
}
74-
return;
113+
}
114+
const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
115+
if (offline) {
116+
if (!warned) {
117+
warned = true;
118+
// eslint-disable-next-line no-console
119+
console.warn(
120+
`[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
121+
);
75122
}
76-
// eslint-disable-next-line no-console
77-
console.error('[proxy]', err);
78-
});
79-
},
80-
};
81-
}
82-
return config;
123+
return;
124+
}
125+
// eslint-disable-next-line no-console
126+
console.error('[proxy]', err);
127+
});
128+
},
129+
};
83130
}
84131

85132
export default defineConfig({
86-
plugins: [vue()],
133+
plugins: [vue(), injectBasePathPlugin()],
87134
resolve: {
88135
alias: {
89136
'@': path.resolve(__dirname, 'src'),
@@ -94,14 +141,7 @@ export default defineConfig({
94141
emptyOutDir: true,
95142
sourcemap: true,
96143
target: 'es2020',
97-
// ant-design-vue is intentionally bundled as one chunk (its
98-
// components share internals — splitting it breaks Modal/Form/
99-
// Select interop). Minified it lands ~1.4MB but gzips to ~410kB,
100-
// so the actual transfer is fine and caches across every page.
101-
// Bump the warning past that ceiling so the build stays quiet.
102144
chunkSizeWarningLimit: 1500,
103-
// Multiple HTML entries — one per legacy page we migrate.
104-
// As pages get ported in later phases, add their entrypoints here.
105145
rollupOptions: {
106146
input: {
107147
index: path.resolve(__dirname, 'index.html'),
@@ -113,10 +153,6 @@ export default defineConfig({
113153
subpage: path.resolve(__dirname, 'subpage.html'),
114154
},
115155
output: {
116-
// Split vendor deps into stable chunks so each page only pulls
117-
// what it needs and the browser caches them across versions.
118-
// Without this, ant-design-vue + vue + icons all end up in one
119-
// 1.6MB blob attached to whichever page consumed them first.
120156
manualChunks(id) {
121157
if (!id.includes('node_modules')) return undefined;
122158
if (id.includes('ant-design-vue')) return 'vendor-antd';
@@ -129,8 +165,6 @@ export default defineConfig({
129165
if (id.includes('dayjs')) return 'vendor-dayjs';
130166
if (id.includes('qrious')) return 'vendor-qrious';
131167
if (id.includes('axios')) return 'vendor-axios';
132-
// The persian datepicker pulls in moment + moment-jalaali; bundle
133-
// the trio together so unrelated pages don't pay the cost.
134168
if (
135169
id.includes('vue3-persian-datetime-picker')
136170
|| id.includes('moment-jalaali')
@@ -146,21 +180,14 @@ export default defineConfig({
146180
port: 5173,
147181
strictPort: true,
148182
proxy: {
149-
...makeBackendProxy('http://localhost:2053', [
150-
// Patterns are anchored regex so /login.html and /index.html
151-
// (which Vite serves itself) are NOT forwarded — only the bare
152-
// backend paths and their sub-routes.
153-
'^/(login|logout|getTwoFactorEnable|csrf-token)$',
154-
'^/(panel|server)(/|$)',
155-
]),
156-
// The panel mounts the live-update WebSocket at /ws (basePath +
157-
// "/ws"). Vite needs `ws: true` to forward the HTTP Upgrade to the
158-
// Go backend; without it the dev server would 404 the upgrade and
159-
// the page falls back to the no-data state.
160-
'/ws': {
183+
'^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
184+
'^/$': makeBackendProxy(BACKEND_TARGET),
185+
'^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
186+
'^/(?:[^/]+/)?ws$': {
161187
target: 'ws://localhost:2053',
162188
ws: true,
163189
changeOrigin: true,
190+
rewrite: rewriteToBackend,
164191
},
165192
},
166193
},

web/controller/base.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func (a *BaseController) checkLogin(c *gin.Context) {
2121
if isAjax(c) {
2222
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
2323
} else {
24+
c.Header("Cache-Control", "no-store")
2425
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
2526
}
2627
c.Abort()

web/controller/index.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
5454
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
5555
func (a *IndexController) index(c *gin.Context) {
5656
if session.IsLogin(c) {
57-
c.Redirect(http.StatusTemporaryRedirect, "panel/")
57+
c.Header("Cache-Control", "no-store")
58+
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/")
5859
return
5960
}
6061
serveDistPage(c, "login.html")
@@ -148,6 +149,7 @@ func (a *IndexController) logout(c *gin.Context) {
148149
if err := session.ClearSession(c); err != nil {
149150
logger.Warning("Unable to clear session on logout:", err)
150151
}
152+
c.Header("Cache-Control", "no-store")
151153
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
152154
}
153155

web/controller/server.go

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,11 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
164164
}
165165

166166
// getPanelUpdateInfo retrieves the current and latest panel version.
167-
// Network failures (e.g. no internet, GitHub blocked) are logged at debug
168-
// level only — the panel keeps working offline and we don't want to spam
169-
// WARN every time a user opens the page.
170167
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
171168
info, err := a.panelService.GetUpdateInfo()
172169
if err != nil {
173170
logger.Debug("panel update check failed:", err)
174-
c.JSON(http.StatusOK, entity.Msg{
175-
Success: false,
176-
Msg: I18nWeb(c, "pages.index.panelUpdateCheckPopover"),
177-
})
171+
c.JSON(http.StatusOK, entity.Msg{Success: false})
178172
return
179173
}
180174
jsonObj(c, info, nil)

0 commit comments

Comments
 (0)