diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js
index 66652778..cae11195 100644
--- a/frontend/src/api/axios-init.js
+++ b/frontend/src/api/axios-init.js
@@ -22,7 +22,7 @@ function readMetaToken() {
// recurse through this same interceptor.
async function fetchCsrfToken() {
try {
- const basePath = window.__X_UI_BASE_PATH__;
+ const basePath = window.X_UI_BASE_PATH;
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
: CSRF_TOKEN_PATH);
@@ -59,7 +59,7 @@ 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__;
+ const basePath = window.X_UI_BASE_PATH;
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath;
}
@@ -98,7 +98,7 @@ export function setupAxios() {
// the user right back on the dashboard and the interceptor
// would loop. Navigate to the dev login entry instead.
if (import.meta.env.DEV) {
- const basePath = window.__X_UI_BASE_PATH__ || '/';
+ const basePath = window.X_UI_BASE_PATH || '/';
window.location.href = `${basePath}login.html`;
} else {
window.location.reload();
diff --git a/frontend/src/api/websocket.js b/frontend/src/api/websocket.js
index 5076e53e..b45eed92 100644
--- a/frontend/src/api/websocket.js
+++ b/frontend/src/api/websocket.js
@@ -140,7 +140,7 @@ export class WebSocketClient {
#buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- // basePath comes from window.__X_UI_BASE_PATH__ which is only injected
+ // basePath comes from window.X_UI_BASE_PATH which is only injected
// by the Go binary in production. In dev (Vite serves directly) the
// global is missing and basePath would be '' — without the fallback to
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
diff --git a/frontend/src/composables/useWebSocket.js b/frontend/src/composables/useWebSocket.js
index 04107afb..27a4eff1 100644
--- a/frontend/src/composables/useWebSocket.js
+++ b/frontend/src/composables/useWebSocket.js
@@ -9,7 +9,7 @@ let sharedClient = null;
function getSharedClient() {
if (sharedClient) return sharedClient;
- const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || '';
+ const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
sharedClient = new WebSocketClient(basePath);
return sharedClient;
}
diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue
index fd4fce8b..084f8135 100644
--- a/frontend/src/pages/inbounds/InboundsPage.vue
+++ b/frontend/src/pages/inbounds/InboundsPage.vue
@@ -67,7 +67,7 @@ const { isMobile } = useMediaQuery();
// the id→node map for the new "Node" column. Fetched once on mount.
const { byId: nodesById } = useNodeList();
-const basePath = window.__X_UI_BASE_PATH__ || '';
+const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
onMounted(async () => {
@@ -631,7 +631,7 @@ function onRowAction({ key, dbInbound }) {
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
+ :node-address="qrNodeAddress" :sub-settings="subSettings" />
-import { ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Protocols } from '@/models/inbound.js';
import QrPanel from './QrPanel.vue';
const { t } = useI18n();
-
-// Light QR-only modal — used for the "qrcode" row action on
-// single-user Shadowsocks and WireGuard inbounds. The big info modal
-// (InboundInfoModal) is too detailed when the user just wants the
-// share link as a QR.
-
const props = defineProps({
open: { type: Boolean, default: false },
dbInbound: { type: Object, default: null },
client: { type: Object, default: null },
remarkModel: { type: String, default: '-ieo' },
- // Address of the node hosting this inbound (empty string for local).
- // When set, share/QR links use it as the host instead of the panel's
- // origin — node-managed inbounds proxy from the node, not the panel.
nodeAddress: { type: String, default: '' },
+ subSettings: {
+ type: Object,
+ default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
+ },
});
const emit = defineEmits(['update:open']);
@@ -28,6 +23,50 @@ const emit = defineEmits(['update:open']);
const links = ref([]);
const wireguardConfigs = ref([]);
const wireguardLinks = ref([]);
+const subLink = ref('');
+const subJsonLink = ref('');
+const activeKeys = ref([]);
+
+const qrItems = computed(() => {
+ const items = [];
+ if (subLink.value) {
+ items.push({
+ key: 'sub',
+ header: t('subscription.title'),
+ value: subLink.value,
+ });
+ }
+ if (subJsonLink.value) {
+ items.push({
+ key: 'sub-json',
+ header: `${t('subscription.title')} (JSON)`,
+ value: subJsonLink.value,
+ });
+ }
+ links.value.forEach((link, idx) => {
+ items.push({
+ key: `l${idx}`,
+ header: link.remark || `Link ${idx + 1}`,
+ value: link.link,
+ });
+ });
+ wireguardConfigs.value.forEach((cfg, idx) => {
+ items.push({
+ key: `wc${idx}`,
+ header: `Peer ${idx + 1} config`,
+ value: cfg,
+ downloadName: `peer-${idx + 1}.conf`,
+ });
+ if (wireguardLinks.value[idx]) {
+ items.push({
+ key: `wl${idx}`,
+ header: `Peer ${idx + 1} link`,
+ value: wireguardLinks.value[idx],
+ });
+ }
+ });
+ return items;
+});
watch(() => props.open, (next) => {
if (!next || !props.dbInbound) return;
@@ -46,6 +85,21 @@ watch(() => props.open, (next) => {
wireguardConfigs.value = [];
wireguardLinks.value = [];
}
+
+ const subId = props.client?.subId;
+ if (props.subSettings?.enable && subId) {
+ subLink.value = (props.subSettings.subURI || '') + subId;
+ subJsonLink.value = props.subSettings.subJsonEnable
+ ? (props.subSettings.subJsonURI || '') + subId
+ : '';
+ } else {
+ subLink.value = '';
+ subJsonLink.value = '';
+ }
+ const open = [];
+ if (subLink.value) open.push('sub');
+ if (subJsonLink.value) open.push('sub-json');
+ activeKeys.value = open;
});
function close() {
@@ -56,12 +110,17 @@ function close() {
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/index/BackupModal.vue b/frontend/src/pages/index/BackupModal.vue
index 4acfaafa..469c3999 100644
--- a/frontend/src/pages/index/BackupModal.vue
+++ b/frontend/src/pages/index/BackupModal.vue
@@ -20,7 +20,7 @@ function exportDb() {
// The Go endpoint streams x-ui.db as a download. Setting
// window.location triggers a browser download without leaving
// the page (the Go side responds with Content-Disposition: attachment).
- window.location = window.__X_UI_BASE_PATH__+'panel/api/server/getDb';
+ window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
}
function importDb() {
diff --git a/frontend/src/pages/index/IndexPage.vue b/frontend/src/pages/index/IndexPage.vue
index 5f527e18..1c9e28b5 100644
--- a/frontend/src/pages/index/IndexPage.vue
+++ b/frontend/src/pages/index/IndexPage.vue
@@ -53,14 +53,14 @@ onMounted(() => {
});
});
-const basePath = window.__X_UI_BASE_PATH__ || '';
+const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
-// In production, dist.go injects window.__X_UI_CUR_VER__ at serve time.
+// In production, dist.go injects window.X_UI_CUR_VER at serve time.
// In dev, Vite serves the HTML directly so the global is missing — fall
// back to currentVersion from the panel-update API once it answers.
const displayVersion = computed(
- () => panelUpdateInfo.value?.currentVersion || window.__X_UI_CUR_VER__ || '?',
+ () => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?',
);
// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
diff --git a/frontend/src/pages/login/LoginPage.vue b/frontend/src/pages/login/LoginPage.vue
index d24965b5..4954cf1f 100644
--- a/frontend/src/pages/login/LoginPage.vue
+++ b/frontend/src/pages/login/LoginPage.vue
@@ -38,7 +38,7 @@ const user = reactive({
twoFactorCode: '',
});
-const basePath = window.__X_UI_BASE_PATH__ || '';
+const basePath = window.X_UI_BASE_PATH || '';
onMounted(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable');
diff --git a/frontend/src/pages/nodes/NodesPage.vue b/frontend/src/pages/nodes/NodesPage.vue
index bc8d9fd0..4968d01f 100644
--- a/frontend/src/pages/nodes/NodesPage.vue
+++ b/frontend/src/pages/nodes/NodesPage.vue
@@ -39,7 +39,7 @@ useWebSocket({ nodes: applyNodesEvent });
const { isMobile } = useMediaQuery();
-const basePath = window.__X_UI_BASE_PATH__ || '';
+const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// === Form modal state =================================================
diff --git a/frontend/src/pages/settings/SecurityTab.vue b/frontend/src/pages/settings/SecurityTab.vue
index 9e77c773..d841c787 100644
--- a/frontend/src/pages/settings/SecurityTab.vue
+++ b/frontend/src/pages/settings/SecurityTab.vue
@@ -54,7 +54,7 @@ async function sendUpdateUser() {
if (msg?.success) {
// Force re-login at the standard logout path; basePath is handled
// by the Go router so a relative redirect is correct here.
- const basePath = window.__X_UI_BASE_PATH__ || '';
+ const basePath = window.X_UI_BASE_PATH || '';
window.location.replace(`${basePath}logout`);
}
} finally {
diff --git a/frontend/src/pages/settings/SettingsPage.vue b/frontend/src/pages/settings/SettingsPage.vue
index 9c0a7e3a..223a609e 100644
--- a/frontend/src/pages/settings/SettingsPage.vue
+++ b/frontend/src/pages/settings/SettingsPage.vue
@@ -26,7 +26,7 @@ const { t } = useI18n();
const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
const { isMobile } = useMediaQuery();
-const basePath = window.__X_UI_BASE_PATH__ || '';
+const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// AD-Vue 4's calls `target()` after mount to find the
diff --git a/frontend/src/pages/xray/XrayPage.vue b/frontend/src/pages/xray/XrayPage.vue
index 674f7937..c1a3ee46 100644
--- a/frontend/src/pages/xray/XrayPage.vue
+++ b/frontend/src/pages/xray/XrayPage.vue
@@ -186,7 +186,7 @@ function onRemoveRoutingRules({ prefix }) {
void message;
const { isMobile } = useMediaQuery();
-const basePath = window.__X_UI_BASE_PATH__ || '';
+const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname;
// See SettingsPage scrollTarget — wrap so `document` is in scope.
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index f1099d38..afc288d8 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -57,7 +57,7 @@ function refreshBasePath() {
}
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
-// already injects __X_UI_BASE_PATH__ at runtime in production.
+// already injects webBasePath at runtime in production.
function injectBasePathPlugin() {
return {
name: 'xui-inject-base-path',
@@ -65,7 +65,7 @@ function injectBasePathPlugin() {
transformIndexHtml(html) {
const basePath = refreshBasePath();
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
- const tag = ``;
+ const tag = ``;
return html.replace('', `${tag}`);
},
};
diff --git a/sub/subController.go b/sub/subController.go
index 693b5871..5024e61c 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -150,7 +150,7 @@ func (a *SUBController) subs(c *gin.Context) {
// serveSubPage renders web/dist/subpage.html for the current subscription
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
-// we inject that here, along with window.__X_UI_BASE_PATH__ so the
+// we inject that here, along with window.X_UI_BASE_PATH so the
// page's static asset references resolve correctly when the panel runs
// behind a URL prefix.
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) {
@@ -219,7 +219,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
)
escapedBase := jsEscape.Replace(basePath)
- inject := []byte(``)
out := bytes.Replace(body, []byte(""), inject, 1)
diff --git a/web/controller/dist.go b/web/controller/dist.go
index b53e7233..8bcfb84a 100644
--- a/web/controller/dist.go
+++ b/web/controller/dist.go
@@ -15,41 +15,14 @@ import (
"github.com/mhsanaei/3x-ui/v3/web/session"
)
-// distFS is filled in once at startup by the web package via SetDistFS.
-// It holds the Vite-built frontend (the `dist/.html` files) so
-// the panel's HTML routes can serve them in production.
-//
-// We can't `go:embed` the dist directory directly from this package
-// because embed.FS only accepts paths relative to the source file —
-// dist/ lives one directory up. The web package owns the embed and
-// hands the FS to us through this setter.
var distFS embed.FS
-// SetDistFS is called once during server bootstrap by the web package
-// to hand off the embedded `dist/` filesystem.
func SetDistFS(fs embed.FS) {
distFS = fs
}
-// distPageBuildTime is captured at startup so every served HTML page
-// reports a stable Last-Modified header and the browser's conditional
-// GETs can hit the 304 path on repeat loads.
var distPageBuildTime = time.Now()
-// serveDistPage reads `dist/` from the embedded FS and writes it
-// to the response. Two transforms run before send:
-//
-// 1. `` is injected
-// just before so the AppSidebar's link generator sees the
-// right prefix.
-// 2. Absolute Vite-emitted asset URLs (`/assets/...`) are rewritten
-// to include the panel's basePath, so installs running under a
-// custom URL prefix (e.g. `/myprefix/`) load the bundle from
-// `/myprefix/assets/...` where the static handler actually lives.
-//
-// The HTML responses are served with no-cache so a panel update
-// reaches users on the next reload; the long-hashed JS/CSS files
-// under /assets/ stay cacheable indefinitely.
func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name)
if err != nil {
@@ -62,21 +35,11 @@ func serveDistPage(c *gin.Context, name string) {
basePath = "/"
}
- // Rewrite asset URLs only when basePath isn't the root — for the
- // default `/` install, Vite's `/assets/...` already resolves
- // correctly and we save the byte churn.
if basePath != "/" {
- // Vite emits these three attribute shapes for every entry's
- // JS / CSS / modulepreload reference. Anchoring the search to
- // the leading attribute name avoids matching unrelated /assets
- // substrings inside any inlined script.
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
}
- // Escape just enough that a hostile basePath setting can't break
- // out of the JS string literal. The setting is admin-controlled
- // but defense-in-depth costs nothing here.
jsEscape := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
@@ -88,13 +51,6 @@ func serveDistPage(c *gin.Context, name string) {
)
escapedBase := jsEscape.Replace(basePath)
escapedVer := jsEscape.Replace(config.GetVersion())
-
- // Embed a CSRF token in the served HTML the same way the legacy
- // templates did via ``. Without this the
- // SPA login page has no way to acquire a token (the existing
- // /panel/csrf-token endpoint sits behind checkLogin), and POST
- // /login is rejected by CSRFMiddleware. EnsureCSRFToken creates
- // a session token on first call even for anonymous visitors.
csrfToken, err := session.EnsureCSRFToken(c)
if err != nil {
logger.Warning("Unable to mint CSRF token for", name+":", err)
@@ -102,8 +58,8 @@ func serveDistPage(c *gin.Context, name string) {
}
csrfMeta := []byte(``)
- inject := []byte(``)
+ inject := []byte(``)
inject = append(inject, csrfMeta...)
inject = append(inject, []byte(``)...)
out := bytes.Replace(body, []byte(""), inject, 1)