From 3e1b6ed76f1fb83e9ec8ecb182012298867ac342 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Mon, 6 Apr 2026 21:00:02 +0800 Subject: [PATCH 01/17] fix frontend loading and client modal bugs --- web/html/inbounds.html | 126 +++++++++++++---------- web/html/index.html | 30 +++--- web/html/login.html | 26 +++-- web/html/modals/client_bulk_modal.html | 21 +++- web/html/modals/client_modal.html | 19 +++- web/html/modals/warp_modal.html | 41 ++++---- web/html/modals/xray_outbound_modal.html | 14 +-- web/html/settings.html | 10 +- web/html/xray.html | 16 +-- 9 files changed, 183 insertions(+), 120 deletions(-) diff --git a/web/html/inbounds.html b/web/html/inbounds.html index fda4aca2..397a510c 100644 --- a/web/html/inbounds.html +++ b/web/html/inbounds.html @@ -845,9 +845,9 @@ }, getClientCounts(dbInbound, inbound) { let clientCount = 0, active = [], deactive = [], depleted = [], expiring = [], online = [], comments = new Map(); - clients = inbound.clients; - clientStats = dbInbound.clientStats - now = new Date().getTime() + const clients = inbound.clients; + const clientStats = dbInbound.clientStats; + const now = new Date().getTime(); if (clients) { clientCount = clients.length; if (dbInbound.enable) { @@ -862,17 +862,19 @@ deactive.push(client.email); } }); - clientStats.forEach(stats => { - const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total; - const expired = stats.expiryTime > 0 && stats.expiryTime <= now; - if (expired || exhausted) { - depleted.push(stats.email); - } else { - const expiringSoon = (stats.expiryTime > 0 && (stats.expiryTime - now < this.expireDiff)) || - (stats.total > 0 && (stats.total - (stats.up + stats.down) < this.trafficDiff)); - if (expiringSoon) expiring.push(stats.email); - } - }); + if (Array.isArray(clientStats)) { + clientStats.forEach(stats => { + const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total; + const expired = stats.expiryTime > 0 && stats.expiryTime <= now; + if (expired || exhausted) { + depleted.push(stats.email); + } else { + const expiringSoon = (stats.expiryTime > 0 && (stats.expiryTime - now < this.expireDiff)) || + (stats.total > 0 && (stats.total - (stats.up + stats.down) < this.trafficDiff)); + if (expiringSoon) expiring.push(stats.email); + } + }); + } } else { clients.forEach(client => { deactive.push(client.email); @@ -1060,7 +1062,8 @@ }); }, openEditInbound(dbInboundId) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; const inbound = dbInbound.toInbound(); inModal.show({ title: '{{ i18n "pages.inbounds.modifyInbound"}}', @@ -1127,7 +1130,8 @@ await this.submit(`/panel/api/inbounds/update/${dbInbound.id}`, data, inModal); }, openAddClient(dbInboundId) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; clientModal.show({ title: '{{ i18n "pages.client.add"}}', okText: '{{ i18n "pages.client.submitAdd"}}', @@ -1139,7 +1143,8 @@ }); }, openAddBulkClient(dbInboundId) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; clientsBulkModal.show({ title: '{{ i18n "pages.client.bulk"}} ' + dbInbound.remark, okText: '{{ i18n "pages.client.bulk"}}', @@ -1150,11 +1155,11 @@ }); }, openEditClient(dbInboundId, client) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); if (!dbInbound) return; - clients = this.getInboundClients(dbInbound); + const clients = this.getInboundClients(dbInbound); if (!clients || !Array.isArray(clients)) return; - index = this.findIndexOfClient(dbInbound.protocol, clients, client); + const index = this.findIndexOfClient(dbInbound.protocol, clients, client); if (index < 0) return; clientModal.show({ title: '{{ i18n "pages.client.edit"}}', @@ -1195,7 +1200,8 @@ await this.submit(`/panel/api/inbounds/updateClient/${clientId}`, data, clientModal); }, resetTraffic(dbInboundId) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; this.$confirm({ title: '{{ i18n "pages.inbounds.resetTraffic"}}' + ' #' + dbInboundId, content: '{{ i18n "pages.inbounds.resetTrafficContent"}}', @@ -1211,6 +1217,8 @@ }); }, delInbound(dbInboundId) { + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; this.$confirm({ title: '{{ i18n "pages.inbounds.deleteInbound"}}' + ' #' + dbInboundId, content: '{{ i18n "pages.inbounds.deleteInboundContent"}}', @@ -1221,8 +1229,9 @@ }); }, delClient(dbInboundId, client, confirmation = true) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); - clientId = this.getClientId(dbInbound.protocol, client); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; + const clientId = this.getClientId(dbInbound.protocol, client); if (confirmation) { this.$confirm({ title: '{{ i18n "pages.inbounds.deleteClient"}}' + ' ' + client.email, @@ -1274,9 +1283,9 @@ } }, checkFallback(dbInbound) { - newDbInbound = new DBInbound(dbInbound); + const newDbInbound = new DBInbound(dbInbound); if (dbInbound.listen.startsWith("@")) { - rootInbound = this.inbounds.find((i) => + const rootInbound = this.inbounds.find((i) => i.isTcp && ['trojan', 'vless'].includes(i.protocol) && i.settings.fallbacks.find(f => f.dest === dbInbound.listen) @@ -1294,43 +1303,48 @@ return newDbInbound; }, showQrcode(dbInboundId, client) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); - newDbInbound = this.checkFallback(dbInbound); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; + const newDbInbound = this.checkFallback(dbInbound); qrModal.show('{{ i18n "qrCode"}}', newDbInbound, client); }, showInfo(dbInboundId, client) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); if (!dbInbound) return; - index = 0; + let index = 0; if (dbInbound.isMultiUser()) { - inbound = dbInbound.toInbound(); - clients = inbound && inbound.clients ? inbound.clients : null; + const inbound = dbInbound.toInbound(); + const clients = inbound && inbound.clients ? inbound.clients : null; if (clients && Array.isArray(clients)) { index = this.findIndexOfClient(dbInbound.protocol, clients, client); if (index < 0) index = 0; } } - newDbInbound = this.checkFallback(dbInbound); + const newDbInbound = this.checkFallback(dbInbound); infoModal.show(newDbInbound, index); }, switchEnable(dbInboundId, state) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; dbInbound.enable = state; this.submit(`/panel/api/inbounds/update/${dbInboundId}`, dbInbound); }, async switchEnableClient(dbInboundId, client) { - this.loading() - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); - if (!dbInbound) return; - inbound = dbInbound.toInbound(); - clients = inbound && inbound.clients ? inbound.clients : null; - if (!clients || !Array.isArray(clients)) return; - index = this.findIndexOfClient(dbInbound.protocol, clients, client); - if (index < 0 || !clients[index]) return; - clients[index].enable = !clients[index].enable; - clientId = this.getClientId(dbInbound.protocol, clients[index]); - await this.updateClient(clients[index], dbInboundId, clientId); - this.loading(false); + this.loading(); + try { + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; + const inbound = dbInbound.toInbound(); + const clients = inbound && inbound.clients ? inbound.clients : null; + if (!clients || !Array.isArray(clients)) return; + const index = this.findIndexOfClient(dbInbound.protocol, clients, client); + if (index < 0 || !clients[index]) return; + clients[index].enable = !clients[index].enable; + const clientId = this.getClientId(dbInbound.protocol, clients[index]); + await this.updateClient(clients[index], dbInboundId, clientId); + } finally { + this.loading(false); + } }, async submit(url, data, modal) { const msg = await HttpUtil.postWithModal(url, data, modal); @@ -1489,15 +1503,18 @@ return new Date(ts).toLocaleString() }, isRemovable(dbInboundId) { - return this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)).length > 1; + const clients = this.getInboundClients(this.dbInbounds.find(row => row.id === dbInboundId)); + return Array.isArray(clients) && clients.length > 1; }, inboundLinks(dbInboundId) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); - newDbInbound = this.checkFallback(dbInbound); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; + const newDbInbound = this.checkFallback(dbInbound); txtModal.show('{{ i18n "pages.inbounds.export"}}', newDbInbound.genInboundLinks(this.remarkModel), newDbInbound.remark); }, exportSubs(dbInboundId) { const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; const clients = this.getInboundClients(dbInbound); let subLinks = [] if (clients != null) { @@ -1548,7 +1565,8 @@ txtModal.show('{{ i18n "pages.inbounds.export"}}', copyText.join('\r\n'), 'All-Inbounds'); }, copy(dbInboundId) { - dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + const dbInbound = this.dbInbounds.find(row => row.id === dbInboundId); + if (!dbInbound) return; txtModal.show('{{ i18n "pages.inbounds.inboundData" }}', JSON.stringify(dbInbound, null, 2)); }, async startDataRefreshLoop() { @@ -1613,9 +1631,13 @@ this.getClientEmailOptions(); // Initial data fetch - this.getDBInbounds().then(() => { - this.loading(false); - }); + this.getDBInbounds() + .catch((e) => { + console.error(e); + }) + .finally(() => { + this.loading(false); + }); // Setup WebSocket for real-time updates if (window.wsClient) { diff --git a/web/html/index.html b/web/html/index.html index e442a022..4eb4077e 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -1018,23 +1018,29 @@ }, async openLogs() { logModal.loading = true; - const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, { level: logModal.level, syslog: logModal.syslog }); - if (!msg.success) { - return; + try { + const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, { level: logModal.level, syslog: logModal.syslog }); + if (!msg.success) { + return; + } + logModal.show(msg.obj); + await PromiseUtil.sleep(500); + } finally { + logModal.loading = false; } - logModal.show(msg.obj); - await PromiseUtil.sleep(500); - logModal.loading = false; }, async openXrayLogs() { xraylogModal.loading = true; - const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy }); - if (!msg.success) { - return; + try { + const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy }); + if (!msg.success) { + return; + } + xraylogModal.show(msg.obj); + await PromiseUtil.sleep(500); + } finally { + xraylogModal.loading = false; } - xraylogModal.show(msg.obj); - await PromiseUtil.sleep(500); - xraylogModal.loading = false; }, downloadXrayLogs() { if (!Array.isArray(this.xraylogModal.logs) || this.xraylogModal.logs.length === 0) { diff --git a/web/html/login.html b/web/html/login.html index a4ceb831..9ef14cb5 100644 --- a/web/html/login.html +++ b/web/html/login.html @@ -167,10 +167,20 @@ }, async mounted() { this.lang = LanguageManager.getLanguage(); - this.twoFactorEnable = await this.getTwoFactorEnable(); - this.turnstileSiteKey = await this.getTurnstileSiteKey(); - if (this.turnstileSiteKey) { - this.$nextTick(() => this.ensureTurnstileRendered()); + try { + this.twoFactorEnable = await this.getTwoFactorEnable(); + this.turnstileSiteKey = await this.getTurnstileSiteKey(); + } finally { + this.loadingStates.fetched = true; + this.$nextTick(() => { + if (!this.animationStarted) { + this.animationStarted = true; + this.initHeadline(); + } + if (this.turnstileSiteKey) { + this.ensureTurnstileRendered(); + } + }); } }, computed: { @@ -248,15 +258,9 @@ const msg = await HttpUtil.post('/getTwoFactorEnable'); if (msg.success) { this.twoFactorEnable = msg.obj; - this.loadingStates.fetched = true; - this.$nextTick(() => { - if (!this.animationStarted) { - this.animationStarted = true; - this.initHeadline(); - } - }); return msg.obj; } + return false; }, initHeadline() { const animationDelay = 2000; diff --git a/web/html/modals/client_bulk_modal.html b/web/html/modals/client_bulk_modal.html index ac0fa011..1726771c 100644 --- a/web/html/modals/client_bulk_modal.html +++ b/web/html/modals/client_bulk_modal.html @@ -150,8 +150,13 @@ delayedStart: false, reset: 0, ok() { - clients = []; - method = clientsBulkModal.emailMethod; + const clients = []; + const method = clientsBulkModal.emailMethod; + let start; + let end; + let prefix; + let useNum; + let postfix; if (method > 1) { start = clientsBulkModal.firstNum; end = clientsBulkModal.lastNum + 1; @@ -163,7 +168,7 @@ useNum = (method > 1); postfix = (method > 2 && clientsBulkModal.emailPostfix.length > 0) ? clientsBulkModal.emailPostfix : ""; for (let i = start; i < end; i++) { - newClient = clientsBulkModal.newClient(clientsBulkModal.dbInbound.protocol); + const newClient = clientsBulkModal.newClient(clientsBulkModal.dbInbound.protocol); if (method == 4) newClient.email = ""; newClient.email += useNum ? prefix + i.toString() + postfix : prefix + postfix; if (clientsBulkModal.subId.length > 0) newClient.subId = clientsBulkModal.subId; @@ -186,6 +191,9 @@ dbInbound = null, confirm = (inbound, dbInbound) => { } }) { + if (!dbInbound) { + return; + } this.visible = true; this.title = title; this.okText = okText; @@ -213,7 +221,10 @@ case Protocols.VMESS: return new Inbound.VmessSettings.VMESS(); case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS(); case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan(); - case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings.Shadowsocks(clientsBulkModal.inbound.settings.shadowsockses[0].method); + case Protocols.SHADOWSOCKS: { + const method = clientsBulkModal.inbound?.settings?.method || ''; + return new Inbound.ShadowsocksSettings.Shadowsocks(method, RandomUtil.randomShadowsocksPassword(method)); + } default: return null; } }, @@ -247,4 +258,4 @@ }); -{{end}} \ No newline at end of file +{{end}} diff --git a/web/html/modals/client_modal.html b/web/html/modals/client_modal.html index 27364d96..b9a58d1e 100644 --- a/web/html/modals/client_modal.html +++ b/web/html/modals/client_modal.html @@ -32,6 +32,9 @@ } }, show({ title = '', okText = '{{ i18n "sure" }}', index = null, dbInbound = null, confirm = () => { }, isEdit = false }) { + if (!dbInbound) { + return; + } this.visible = true; this.title = title; this.okText = okText; @@ -42,14 +45,22 @@ this.index = index === null ? this.clients.length : index; this.delayedStart = false; if (isEdit) { - if (this.clients[index].expiryTime < 0) { + const currentClient = this.clients[index]; + if (!currentClient) { + this.visible = false; + return; + } + if (currentClient.expiryTime < 0) { this.delayedStart = true; } - this.oldClientId = this.getClientId(dbInbound.protocol, clients[index]); + this.oldClientId = this.getClientId(dbInbound.protocol, currentClient); } else { this.addClient(this.inbound, this.clients); } - this.clientStats = this.dbInbound.clientStats.find(row => row.email === this.clients[this.index].email); + const activeClient = this.clients[this.index]; + this.clientStats = Array.isArray(this.dbInbound.clientStats) + ? this.dbInbound.clientStats.find(row => row.email === activeClient?.email) + : null; this.confirm = confirm; }, getClientId(protocol, client) { @@ -64,7 +75,7 @@ case Protocols.VMESS: return clients.push(new Inbound.VmessSettings.VMESS()); case Protocols.VLESS: return clients.push(new Inbound.VLESSSettings.VLESS()); case Protocols.TROJAN: return clients.push(new Inbound.TrojanSettings.Trojan()); - case Protocols.SHADOWSOCKS: return clients.push(new Inbound.ShadowsocksSettings.Shadowsocks(clients[0].method, RandomUtil.randomShadowsocksPassword(inbound.settings.method))); + case Protocols.SHADOWSOCKS: return clients.push(new Inbound.ShadowsocksSettings.Shadowsocks(inbound.settings.method, RandomUtil.randomShadowsocksPassword(inbound.settings.method))); default: return null; } }, diff --git a/web/html/modals/warp_modal.html b/web/html/modals/warp_modal.html index 4bfb7ca1..8d9d7a0b 100644 --- a/web/html/modals/warp_modal.html +++ b/web/html/modals/warp_modal.html @@ -131,26 +131,27 @@ }, methods: { collectConfig() { - config = warpModal.warpConfig.config; - peer = config.peers[0]; - if (config) { - warpModal.warpOutbound = Outbound.fromJson({ - tag: 'warp', - protocol: Protocols.Wireguard, - settings: { - mtu: 1420, - secretKey: warpModal.warpData.private_key, - address: this.getAddresses(config.interface.addresses), - reserved: this.getResolved(config.client_id), - domainStrategy: 'ForceIP', - peers: [{ - publicKey: peer.public_key, - endpoint: peer.endpoint.host, - }], - noKernelTun: false, - } - }); + const config = warpModal.warpConfig?.config; + if (!config || !Array.isArray(config.peers) || config.peers.length === 0) { + return; } + const peer = config.peers[0]; + warpModal.warpOutbound = Outbound.fromJson({ + tag: 'warp', + protocol: Protocols.Wireguard, + settings: { + mtu: 1420, + secretKey: warpModal.warpData.private_key, + address: this.getAddresses(config.interface.addresses), + reserved: this.getResolved(config.client_id), + domainStrategy: 'ForceIP', + peers: [{ + publicKey: peer.public_key, + endpoint: peer.endpoint.host, + }], + noKernelTun: false, + } + }); }, getAddresses(addrs) { let addresses = []; @@ -243,4 +244,4 @@ }); -{{end}} \ No newline at end of file +{{end}} diff --git a/web/html/modals/xray_outbound_modal.html b/web/html/modals/xray_outbound_modal.html index 2edb5fc0..ce70bc6d 100644 --- a/web/html/modals/xray_outbound_modal.html +++ b/web/html/modals/xray_outbound_modal.html @@ -55,7 +55,7 @@ } }, toggleJson(jsonTab) { - textAreaObj = document.getElementById('outboundJson'); + const textAreaObj = document.getElementById('outboundJson'); if(jsonTab){ if(this.cm != null) { this.cm.toTextArea(); @@ -64,7 +64,7 @@ textAreaObj.value = JSON.stringify(this.outbound.toJson(), null, 2); this.cm = CodeMirror.fromTextArea(textAreaObj, app.cmOptions); this.cm.on('change',editor => { - value = editor.getValue(); + const value = editor.getValue(); if(this.isJsonString(value)){ this.outbound = Outbound.fromJson(JSON.parse(value)); this.check(); @@ -107,11 +107,11 @@ canEnableTls() { return this.outModal.outbound.canEnableTls(); }, - convertLink(){ - newOutbound = Outbound.fromLink(outModal.link); - if(newOutbound){ - this.outModal.outbound = newOutbound; - this.outModal.toggleJson(true); + convertLink(){ + const newOutbound = Outbound.fromLink(outModal.link); + if(newOutbound){ + this.outModal.outbound = newOutbound; + this.outModal.toggleJson(true); this.outModal.check(); this.$message.success('Link imported successfully...'); outModal.link = ''; diff --git a/web/html/settings.html b/web/html/settings.html index 21294da7..fd86657c 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -626,8 +626,12 @@ this.entryPort = window.location.port; this.entryProtocol = window.location.protocol; this.entryIsIP = this._isIp(this.entryHost); - await this.getAllSetting(); - await this.loadInboundTags(); + try { + await this.getAllSetting(); + await this.loadInboundTags(); + } finally { + this.loadingStates.fetched = true; + } while (true) { await PromiseUtil.sleep(1000); this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting); @@ -635,4 +639,4 @@ } }); -{{ template "page/body_end" .}} \ No newline at end of file +{{ template "page/body_end" .}} diff --git a/web/html/xray.html b/web/html/xray.html index ebe31f48..5a04910b 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -398,8 +398,8 @@ this.loadingStates.fetched = true } - result = JSON.parse(msg.obj); - xs = JSON.stringify(result.xraySetting, null, 2); + const result = JSON.parse(msg.obj); + const xs = JSON.stringify(result.xraySetting, null, 2); this.oldXraySetting = xs; this.xraySetting = xs; this.inboundTags = result.inboundTags; @@ -1063,9 +1063,13 @@ if (window.location.protocol !== "https:") { this.showAlert = true; } - await this.getXraySetting(); - await this.getXrayResult(); - await this.getOutboundsTraffic(); + try { + await this.getXraySetting(); + await this.getXrayResult(); + await this.getOutboundsTraffic(); + } finally { + this.loadingStates.fetched = true; + } if (window.wsClient) { window.wsClient.connect(); @@ -1562,4 +1566,4 @@ }, }); -{{ template "page/body_end" .}} \ No newline at end of file +{{ template "page/body_end" .}} From 6564cf8202d61f3b382b85c9a06c743ca399a9fa Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Mon, 6 Apr 2026 21:02:58 +0800 Subject: [PATCH 02/17] fix settings and xray page state leaks --- web/html/settings.html | 32 +++++----- web/html/xray.html | 139 +++++++++++++++++++++-------------------- 2 files changed, 84 insertions(+), 87 deletions(-) diff --git a/web/html/settings.html b/web/html/settings.html index fd86657c..4560d96f 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -206,11 +206,11 @@ { label: 'Google', value: 'geosite:google' }, ], get remarkModel() { - rm = this.allSetting.remarkModel; + const rm = this.allSetting.remarkModel; return rm.length > 1 ? rm.substring(1).split('') : []; }, set remarkModel(value) { - rs = this.allSetting.remarkModel[0]; + const rs = this.allSetting.remarkModel[0]; this.allSetting.remarkModel = rs + value.join(''); this.changeRemarkSample(); }, @@ -228,7 +228,7 @@ this.allSetting.datepicker = value; }, changeRemarkSample() { - sample = [] + const sample = []; this.remarkModel.forEach(r => sample.push(this.remarkModels[r])); this.remarkSample = sample.length == 0 ? '' : sample.join(this.remarkSeparator); } @@ -355,13 +355,9 @@ return; } - let finalHost = this.entryHost; + const finalHost = webDomain && this._isIp(webDomain) ? webDomain : this.entryHost; let finalPort = this.entryPort || ""; - if (webDomain && this._isIp(webDomain)) { - finalHost = webDomain; - } - if (webPort && Number(webPort) !== Number(this.entryPort)) { finalPort = String(webPort); } @@ -457,7 +453,7 @@ get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.packets : ""; }, set: function (v) { if (v != "") { - newFragment = JSON.parse(this.allSetting.subJsonFragment); + const newFragment = JSON.parse(this.allSetting.subJsonFragment); newFragment.settings.fragment.packets = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } @@ -467,7 +463,7 @@ get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.length : ""; }, set: function (v) { if (v != "") { - newFragment = JSON.parse(this.allSetting.subJsonFragment); + const newFragment = JSON.parse(this.allSetting.subJsonFragment); newFragment.settings.fragment.length = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } @@ -477,7 +473,7 @@ get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.interval : ""; }, set: function (v) { if (v != "") { - newFragment = JSON.parse(this.allSetting.subJsonFragment); + const newFragment = JSON.parse(this.allSetting.subJsonFragment); newFragment.settings.fragment.interval = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } @@ -487,7 +483,7 @@ get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.maxSplit : ""; }, set: function (v) { if (v != "") { - newFragment = JSON.parse(this.allSetting.subJsonFragment); + const newFragment = JSON.parse(this.allSetting.subJsonFragment); newFragment.settings.fragment.maxSplit = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } @@ -526,7 +522,7 @@ muxConcurrency: { get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).concurrency : -1; }, set: function (v) { - newMux = JSON.parse(this.allSetting.subJsonMux); + const newMux = JSON.parse(this.allSetting.subJsonMux); newMux.concurrency = v; this.allSetting.subJsonMux = JSON.stringify(newMux); } @@ -534,7 +530,7 @@ muxXudpConcurrency: { get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpConcurrency : -1; }, set: function (v) { - newMux = JSON.parse(this.allSetting.subJsonMux); + const newMux = JSON.parse(this.allSetting.subJsonMux); newMux.xudpConcurrency = v; this.allSetting.subJsonMux = JSON.stringify(newMux); } @@ -542,7 +538,7 @@ muxXudpProxyUDP443: { get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpProxyUDP443 : "reject"; }, set: function (v) { - newMux = JSON.parse(this.allSetting.subJsonMux); + const newMux = JSON.parse(this.allSetting.subJsonMux); newMux.xudpProxyUDP443 = v; this.allSetting.subJsonMux = JSON.stringify(newMux); } @@ -607,14 +603,14 @@ var alerts = [] if (window.location.protocol !== "https:") alerts.push('{{ i18n "secAlertSSL" }}'); if (this.allSetting.webPort === 2053) alerts.push('{{ i18n "secAlertPanelPort" }}'); - panelPath = window.location.pathname.split('/').length < 4 + const panelPath = window.location.pathname.split('/').length < 4 if (panelPath && this.allSetting.webBasePath == '/') alerts.push('{{ i18n "secAlertPanelURI" }}'); if (this.allSetting.subEnable) { - subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath; + const subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath; if (subPath == '/sub/') alerts.push('{{ i18n "secAlertSubURI" }}'); } if (this.allSetting.subJsonEnable) { - subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath; + const subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath; if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}'); } return alerts diff --git a/web/html/xray.html b/web/html/xray.html index 5a04910b..721c2edc 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -483,7 +483,7 @@ const { data, property, outboundTag } = routeSettings; const oldTemplateSettings = this.templateSettings; const newTemplateSettings = oldTemplateSettings; - currentProperty = this.templateRuleGetter({ outboundTag, property }) + const currentProperty = this.templateRuleGetter({ outboundTag, property }) if (currentProperty.length == 0) { const propertyRule = { type: "field", @@ -494,7 +494,7 @@ } else { const newRules = []; - insertedOnce = false; + let insertedOnce = false; newTemplateSettings.routing.rules.forEach( (routingRule) => { if ( @@ -521,11 +521,11 @@ if (this.cm != null) { this.cm.toTextArea(); } - textAreaObj = document.getElementById('xraySetting'); + const textAreaObj = document.getElementById('xraySetting'); textAreaObj.value = this[this.advSettings]; this.cm = CodeMirror.fromTextArea(textAreaObj, this.cmOptions); this.cm.on('change', editor => { - value = editor.getValue(); + const value = editor.getValue(); if (this.isJsonString(value)) { this[this.advSettings] = value; } @@ -539,11 +539,11 @@ this.cm = null; return } - textAreaObj = document.getElementById('obsSetting'); + const textAreaObj = document.getElementById('obsSetting'); textAreaObj.value = this[this.obsSettings]; this.cm = CodeMirror.fromTextArea(textAreaObj, this.cmOptions); this.cm.on('change', editor => { - value = editor.getValue(); + const value = editor.getValue(); if (this.isJsonString(value)) { this[this.obsSettings] = value; } @@ -566,7 +566,7 @@ return `${SizeFormatter.sizeFormat(0)} / ${SizeFormatter.sizeFormat(0)}` }, findOutboundAddress(o) { - serverObj = null; + let serverObj = null; switch (o.protocol) { case Protocols.VMess: serverObj = o.settings.vnext; @@ -619,12 +619,12 @@ }); }, deleteOutbound(index) { - outbounds = this.templateSettings.outbounds; + const outbounds = this.templateSettings.outbounds; outbounds.splice(index, 1); this.outboundSettings = JSON.stringify(outbounds); }, setFirstOutbound(index) { - outbounds = this.templateSettings.outbounds; + const outbounds = this.templateSettings.outbounds; outbounds.splice(0, 0, outbounds.splice(index, 1)[0]); this.outboundSettings = JSON.stringify(outbounds); }, @@ -700,7 +700,7 @@ confirm: (reverse, rules) => { reverseModal.loading(); if (reverse.tag.length > 0) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newTemplateSettings.reverse == undefined) newTemplateSettings.reverse = {}; if (newTemplateSettings.reverse[reverse.type + 's'] == undefined) newTemplateSettings.reverse[reverse.type + 's'] = []; newTemplateSettings.reverse[reverse.type + 's'].push({ tag: reverse.tag, domain: reverse.domain }); @@ -716,6 +716,7 @@ }); }, editReverse(index) { + let oldRules; if (this.reverseData[index].type == "bridge") { oldRules = this.templateSettings.routing.rules.filter(r => r.inboundTag && r.inboundTag[0] == this.reverseData[index].tag); } else { @@ -728,11 +729,11 @@ confirm: (reverse, rules) => { reverseModal.loading(); if (reverse.tag.length > 0) { - oldData = this.reverseData[index]; - newTemplateSettings = this.templateSettings; - oldReverseIndex = newTemplateSettings.reverse[oldData.type + 's'].findIndex(rs => rs.tag == oldData.tag); - oldRuleIndex0 = oldRules.length > 0 ? newTemplateSettings.routing.rules.findIndex(r => JSON.stringify(r) == JSON.stringify(oldRules[0])) : -1; - oldRuleIndex1 = oldRules.length == 2 ? newTemplateSettings.routing.rules.findIndex(r => JSON.stringify(r) == JSON.stringify(oldRules[1])) : -1; + const oldData = this.reverseData[index]; + const newTemplateSettings = this.templateSettings; + const oldReverseIndex = newTemplateSettings.reverse[oldData.type + 's'].findIndex(rs => rs.tag == oldData.tag); + const oldRuleIndex0 = oldRules.length > 0 ? newTemplateSettings.routing.rules.findIndex(r => JSON.stringify(r) == JSON.stringify(oldRules[0])) : -1; + const oldRuleIndex1 = oldRules.length == 2 ? newTemplateSettings.routing.rules.findIndex(r => JSON.stringify(r) == JSON.stringify(oldRules[1])) : -1; if (oldData.type == reverse.type) { newTemplateSettings.reverse[oldData.type + 's'][oldReverseIndex] = { tag: reverse.tag, domain: reverse.domain }; } else { @@ -746,7 +747,7 @@ this.templateSettings = newTemplateSettings; // Adjust Rules - newRules = this.templateSettings.routing.rules; + const newRules = this.templateSettings.routing.rules; oldRuleIndex0 != -1 ? newRules[oldRuleIndex0] = rules[0] : newRules.push(rules[0]); oldRuleIndex1 != -1 ? newRules[oldRuleIndex1] = rules[1] : newRules.push(rules[1]); this.routingRuleSettings = JSON.stringify(newRules); @@ -757,10 +758,10 @@ }); }, deleteReverse(index) { - oldData = this.reverseData[index]; - newTemplateSettings = this.templateSettings; - reverseTypeObj = newTemplateSettings.reverse[oldData.type + 's']; - realIndex = reverseTypeObj.findIndex(r => r.tag == oldData.tag && r.domain == oldData.domain); + const oldData = this.reverseData[index]; + const newTemplateSettings = this.templateSettings; + const reverseTypeObj = newTemplateSettings.reverse[oldData.type + 's']; + const realIndex = reverseTypeObj.findIndex(r => r.tag == oldData.tag && r.domain == oldData.domain); newTemplateSettings.reverse[oldData.type + 's'].splice(realIndex, 1); // delete empty objects @@ -768,7 +769,7 @@ if (Object.keys(newTemplateSettings.reverse).length === 0) Reflect.deleteProperty(newTemplateSettings, 'reverse'); // delete related routing rules - newRules = newTemplateSettings.routing.rules; + let newRules = newTemplateSettings.routing.rules; if (oldData.type == "bridge") { newRules = newTemplateSettings.routing.rules.filter(r => !(r.inboundTag && r.inboundTag.length == 1 && r.inboundTag[0] == oldData.tag)); } else if (oldData.type == "portal") { @@ -783,7 +784,7 @@ this.refreshing = true; await this.getOutboundsTraffic(); - data = [] + const data = [] if (this.templateSettings != null) { this.templateSettings.outbounds.forEach((o, index) => { data.push({ 'key': index, ...o }); @@ -817,11 +818,11 @@ }, confirm: (balancer) => { balancerModal.loading(); - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newTemplateSettings.routing.balancers == undefined) { newTemplateSettings.routing.balancers = []; } - let tmpBalancer = { + const tmpBalancer = { 'tag': balancer.tag, 'selector': balancer.selector, 'fallbackTag': balancer.fallbackTag @@ -849,9 +850,9 @@ balancer: this.balancersData[index], confirm: (balancer) => { balancerModal.loading(); - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; - let tmpBalancer = { + const tmpBalancer = { 'tag': balancer.tag, 'selector': balancer.selector, 'fallbackTag': balancer.fallbackTag @@ -889,7 +890,7 @@ }); }, updateObservatorySelectors() { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; const leastPings = this.balancersData.filter((b) => b.strategy == 'leastPing'); const leastLoads = this.balancersData.filter((b) => b.strategy === 'leastLoad' || @@ -926,7 +927,7 @@ this.changeObsCode(); }, deleteBalancer(index) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; // Remove from balancers const removedBalancer = this.balancersData.splice(index, 1)[0]; @@ -958,7 +959,7 @@ dnsModal.show({ title: '{{ i18n "pages.xray.dns.add" }}', confirm: (dnsServer) => { - dnsServers = this.dnsServers; + const dnsServers = this.dnsServers; dnsServers.push(dnsServer); this.dnsServers = dnsServers; dnsModal.close(); @@ -971,7 +972,7 @@ title: '{{ i18n "pages.xray.dns.edit" }} #' + (index + 1), dnsServer: this.dnsServers[index], confirm: (dnsServer) => { - dnsServers = this.dnsServers; + const dnsServers = this.dnsServers; dnsServers[index] = dnsServer; this.dnsServers = dnsServers; dnsModal.close(); @@ -980,7 +981,7 @@ }); }, deleteDNSServer(index) { - newDnsServers = this.dnsServers; + const newDnsServers = this.dnsServers; newDnsServers.splice(index, 1); this.dnsServers = newDnsServers; }, @@ -988,7 +989,7 @@ fakednsModal.show({ title: '{{ i18n "pages.xray.fakedns.add" }}', confirm: (item) => { - fakeDns = this.fakeDns ?? []; + const fakeDns = this.fakeDns ?? []; fakeDns.push(item); this.fakeDns = fakeDns; fakednsModal.close(); @@ -1001,7 +1002,7 @@ title: '{{ i18n "pages.xray.fakedns.edit" }} #' + (index + 1), fakeDns: this.fakeDns[index], confirm: (item) => { - fakeDns = this.fakeDns; + const fakeDns = this.fakeDns; fakeDns[index] = item; this.fakeDns = fakeDns; fakednsModal.close(); @@ -1010,7 +1011,7 @@ }); }, deleteFakedns(index) { - fakeDns = this.fakeDns; + const fakeDns = this.fakeDns; fakeDns.splice(index, 1); this.fakeDns = fakeDns; }, @@ -1045,13 +1046,13 @@ }); }, replaceRule(old_index, new_index) { - rules = this.templateSettings.routing.rules; + const rules = this.templateSettings.routing.rules; if (new_index >= rules.length) rules.push(undefined); rules.splice(new_index, 0, rules.splice(old_index, 1)[0]); this.routingRuleSettings = JSON.stringify(rules); }, deleteRule(index) { - rules = this.templateSettings.routing.rules; + const rules = this.templateSettings.routing.rules; rules.splice(index, 1); this.routingRuleSettings = JSON.stringify(rules); }, @@ -1101,7 +1102,7 @@ inboundSettings: { get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.inbounds, null, 2) : null; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.inbounds = JSON.parse(newValue); this.templateSettings = newTemplateSettings; }, @@ -1109,14 +1110,14 @@ outboundSettings: { get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.outbounds, null, 2) : null; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.outbounds = JSON.parse(newValue); this.templateSettings = newTemplateSettings; }, }, outboundData: { get: function () { - data = [] + const data = []; if (this.templateSettings != null) { this.templateSettings.outbounds.forEach((o, index) => { data.push({ 'key': index, ...o }); @@ -1127,7 +1128,7 @@ }, reverseData: { get: function () { - data = [] + const data = []; if (this.templateSettings != null && this.templateSettings.reverse != null) { if (this.templateSettings.reverse.bridges) { this.templateSettings.reverse.bridges.forEach((o, index) => { @@ -1146,14 +1147,14 @@ routingRuleSettings: { get: function () { return this.templateSettings ? JSON.stringify(this.templateSettings.routing.rules, null, 2) : null; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.routing.rules = JSON.parse(newValue); this.templateSettings = newTemplateSettings; }, }, routingRuleData: { get: function () { - data = []; + const data = []; if (this.templateSettings != null) { this.templateSettings.routing.rules.forEach((r, index) => { data.push({ 'key': index, ...r }); @@ -1174,7 +1175,7 @@ }, balancersData: { get: function () { - data = [] + const data = []; if (this.templateSettings != null && this.templateSettings.routing != null && this.templateSettings.routing.balancers != null) { this.templateSettings.routing.balancers.forEach((o, index) => { data.push({ @@ -1194,7 +1195,7 @@ return this.templateSettings?.observatory ? JSON.stringify(this.templateSettings.observatory, null, 2) : null; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.observatory = JSON.parse(newValue); this.templateSettings = newTemplateSettings; }, @@ -1204,7 +1205,7 @@ return this.templateSettings?.burstObservatory ? JSON.stringify(this.templateSettings.burstObservatory, null, 2) : null; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.burstObservatory = JSON.parse(newValue); this.templateSettings = newTemplateSettings; }, @@ -1214,14 +1215,14 @@ freedomStrategy: { get: function () { if (!this.templateSettings) return "AsIs"; - freedomOutbound = this.templateSettings.outbounds.find((o) => o.protocol === "freedom" && o.tag == "direct"); + const freedomOutbound = this.templateSettings.outbounds.find((o) => o.protocol === "freedom" && o.tag == "direct"); if (!freedomOutbound) return "AsIs"; if (!freedomOutbound.settings || !freedomOutbound.settings.domainStrategy) return "AsIs"; return freedomOutbound.settings.domainStrategy; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; - freedomOutboundIndex = newTemplateSettings.outbounds.findIndex((o) => o.protocol === "freedom" && o.tag == "direct"); + const newTemplateSettings = this.templateSettings; + const freedomOutboundIndex = newTemplateSettings.outbounds.findIndex((o) => o.protocol === "freedom" && o.tag == "direct"); if (freedomOutboundIndex == -1) { newTemplateSettings.outbounds.push({ protocol: "freedom", tag: "direct", settings: { "domainStrategy": newValue } }); } else if (!newTemplateSettings.outbounds[freedomOutboundIndex].settings) { @@ -1238,7 +1239,7 @@ return this.templateSettings.routing.domainStrategy; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.routing.domainStrategy = newValue; this.templateSettings = newTemplateSettings; } @@ -1249,7 +1250,7 @@ return this.templateSettings.log.loglevel; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.log.loglevel = newValue; this.templateSettings = newTemplateSettings; } @@ -1260,7 +1261,7 @@ return this.templateSettings.log.access; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.log.access = newValue; this.templateSettings = newTemplateSettings; } @@ -1271,7 +1272,7 @@ return this.templateSettings.log.error; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.log.error = newValue; this.templateSettings = newTemplateSettings; } @@ -1282,7 +1283,7 @@ return this.templateSettings.log.dnsLog; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.log.dnsLog = newValue; this.templateSettings = newTemplateSettings; } @@ -1293,7 +1294,7 @@ return this.templateSettings.policy.system.statsInboundUplink; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.policy.system.statsInboundUplink = newValue; this.templateSettings = newTemplateSettings; } @@ -1304,7 +1305,7 @@ return this.templateSettings.policy.system.statsInboundDownlink; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.policy.system.statsInboundDownlink = newValue; this.templateSettings = newTemplateSettings; } @@ -1315,7 +1316,7 @@ return this.templateSettings.policy.system.statsOutboundUplink; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.policy.system.statsOutboundUplink = newValue; this.templateSettings = newTemplateSettings; } @@ -1326,7 +1327,7 @@ return this.templateSettings.policy.system.statsOutboundDownlink; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.policy.system.statsOutboundDownlink = newValue; this.templateSettings = newTemplateSettings; } @@ -1337,7 +1338,7 @@ return this.templateSettings.log.maskAddress; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.log.maskAddress = newValue; this.templateSettings = newTemplateSettings; } @@ -1423,7 +1424,7 @@ return this.templateSettings ? this.templateSettings.dns != null : false; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newValue) { newTemplateSettings.dns = { servers: [], @@ -1444,7 +1445,7 @@ return this.enableDNS ? this.templateSettings.dns.tag : ""; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.dns.tag = newValue; this.templateSettings = newTemplateSettings; } @@ -1454,7 +1455,7 @@ return this.enableDNS ? this.templateSettings.dns.clientIp : null; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newValue) { newTemplateSettings.dns.clientIp = newValue; } else { @@ -1468,7 +1469,7 @@ return this.enableDNS ? this.templateSettings.dns.disableCache : false; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newValue) { newTemplateSettings.dns.disableCache = newValue; } else { @@ -1482,7 +1483,7 @@ return this.enableDNS ? this.templateSettings.dns.disableFallback : false; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newValue) { newTemplateSettings.dns.disableFallback = newValue; } else { @@ -1496,7 +1497,7 @@ return this.enableDNS ? this.templateSettings.dns.disableFallbackIfMatch : false; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newValue) { newTemplateSettings.dns.disableFallbackIfMatch = newValue; } else { @@ -1510,7 +1511,7 @@ return this.enableDNS ? (this.templateSettings.dns.enableParallelQuery || false) : false; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newValue) { newTemplateSettings.dns.enableParallelQuery = newValue; } else { @@ -1524,7 +1525,7 @@ return this.enableDNS ? this.templateSettings.dns.useSystemHosts : false; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (newValue) { newTemplateSettings.dns.useSystemHosts = newValue; } else { @@ -1538,7 +1539,7 @@ return this.enableDNS ? this.templateSettings.dns.queryStrategy : null; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.dns.queryStrategy = newValue; this.templateSettings = newTemplateSettings; } @@ -1546,7 +1547,7 @@ dnsServers: { get: function () { return this.enableDNS ? this.templateSettings.dns.servers : []; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; newTemplateSettings.dns.servers = newValue; this.templateSettings = newTemplateSettings; } @@ -1554,7 +1555,7 @@ fakeDns: { get: function () { return this.templateSettings && this.templateSettings.fakedns ? this.templateSettings.fakedns : []; }, set: function (newValue) { - newTemplateSettings = this.templateSettings; + const newTemplateSettings = this.templateSettings; if (this.enableDNS) { newTemplateSettings.fakedns = newValue.length > 0 ? newValue : null; } else { From 266f368b078b46907a388906b59c4fb945fa576a Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Mon, 6 Apr 2026 21:03:50 +0800 Subject: [PATCH 03/17] fix remaining modal state leaks --- web/html/modals/inbound_info_modal.html | 12 ++++++++++-- web/html/modals/xray_reverse_modal.html | 5 ++--- web/html/modals/xray_rule_modal.html | 8 ++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/web/html/modals/inbound_info_modal.html b/web/html/modals/inbound_info_modal.html index 37f8248a..4fee720e 100644 --- a/web/html/modals/inbound_info_modal.html +++ b/web/html/modals/inbound_info_modal.html @@ -650,7 +650,9 @@ this.dbInbound = new DBInbound(dbInbound); this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null; this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry; - this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null; + this.clientStats = (this.inbound.clients && this.clientSettings) + ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) + : null; if ( [ @@ -774,10 +776,13 @@ return ColorUtils.usageColor(stats.up + stats.down, app.trafficDiff, stats.total); }, getRemStats() { - remained = this.infoModal.clientStats.total - this.infoModal.clientStats.up - this.infoModal.clientStats.down; + const remained = this.infoModal.clientStats.total - this.infoModal.clientStats.up - this.infoModal.clientStats.down; return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-'; }, refreshIPs() { + if (!this.infoModal.clientStats) { + return; + } this.refreshing = true; refreshIPs(this.infoModal.clientStats.email) .then((result) => { @@ -789,6 +794,9 @@ }); }, clearClientIps() { + if (!this.infoModal.clientStats) { + return; + } HttpUtil.post(`/panel/api/inbounds/clearClientIps/${this.infoModal.clientStats.email}`) .then((msg) => { if (!msg.success) { diff --git a/web/html/modals/xray_reverse_modal.html b/web/html/modals/xray_reverse_modal.html index 22f04317..588b305b 100644 --- a/web/html/modals/xray_reverse_modal.html +++ b/web/html/modals/xray_reverse_modal.html @@ -84,10 +84,9 @@ type: reverse.type, domain: reverse.domain, }; - reverse; - rules0 = rules.filter(r => r.domain != null); + let rules0 = rules.filter(r => r.domain != null); if(rules0.length == 0) rules0 = [{ outboundTag: '', domain: ["full:" + this.reverse.domain], inboundTag: []}]; - rules1 = rules.filter(r => r.domain == null); + let rules1 = rules.filter(r => r.domain == null); if(rules1.length == 0) rules1 = [{ outboundTag: '', inboundTag: []}]; this.rules = []; this.rules.push({ diff --git a/web/html/modals/xray_rule_modal.html b/web/html/modals/xray_rule_modal.html index e6a8bf46..7c1c7725 100644 --- a/web/html/modals/xray_rule_modal.html +++ b/web/html/modals/xray_rule_modal.html @@ -147,7 +147,7 @@ users: [], balancerTags: [], ok() { - newRule = ruleModal.getResult(); + const newRule = ruleModal.getResult(); ObjectUtil.execute(ruleModal.confirm, newRule); }, show({ @@ -215,9 +215,9 @@ ruleModal.confirmLoading = loading; }, getResult() { - value = ruleModal.rule; - rule = {}; - newRule = {}; + const value = ruleModal.rule; + const rule = {}; + const newRule = {}; rule.type = "field"; rule.domain = value.domain.length > 0 ? value.domain.split(',').map(s => s.trim()) : []; rule.ip = value.ip.length > 0 ? value.ip.split(',').map(s => s.trim()) : []; From 537c73c1b252690976931e5fde9e921409997d55 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Mon, 6 Apr 2026 21:22:33 +0800 Subject: [PATCH 04/17] fix settings and xray load failure regressions --- web/html/modals/inbound_info_modal.html | 2 +- web/html/settings.html | 24 +++++++++++++++++++----- web/html/xray.html | 24 +++++++++++++++++++----- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/web/html/modals/inbound_info_modal.html b/web/html/modals/inbound_info_modal.html index 4fee720e..d67dfc53 100644 --- a/web/html/modals/inbound_info_modal.html +++ b/web/html/modals/inbound_info_modal.html @@ -650,7 +650,7 @@ this.dbInbound = new DBInbound(dbInbound); this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null; this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry; - this.clientStats = (this.inbound.clients && this.clientSettings) + this.clientStats = (this.inbound.clients && this.clientSettings && Array.isArray(this.dbInbound.clientStats)) ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null; diff --git a/web/html/settings.html b/web/html/settings.html index 4560d96f..ce3f1fbe 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -26,6 +26,15 @@ + + + + + + @@ -119,6 +128,7 @@ }, oldAllSetting: new AllSetting(), allSetting: new AllSetting(), + loadFailed: false, saveBtnDisable: true, entryHost: null, entryPort: null, @@ -266,15 +276,15 @@ const msg = await HttpUtil.post("/panel/setting/all"); if (msg.success) { - if (!this.loadingStates.fetched) { - this.loadingStates.fetched = true - } - this.oldAllSetting = new AllSetting(msg.obj); this.allSetting = new AllSetting(msg.obj); app.changeRemarkSample(); this.saveBtnDisable = true; + this.loadFailed = false; + return true; } + this.loadFailed = true; + return false; }, async loadInboundTags() { const msg = await HttpUtil.get("/panel/api/inbounds/list"); @@ -622,12 +632,16 @@ this.entryPort = window.location.port; this.entryProtocol = window.location.protocol; this.entryIsIP = this._isIp(this.entryHost); + let settingsLoaded = false; try { - await this.getAllSetting(); + settingsLoaded = await this.getAllSetting(); await this.loadInboundTags(); } finally { this.loadingStates.fetched = true; } + if (!settingsLoaded) { + return; + } while (true) { await PromiseUtil.sleep(1000); this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting); diff --git a/web/html/xray.html b/web/html/xray.html index 721c2edc..d9881ce6 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -29,6 +29,15 @@ + + + + + + @@ -253,6 +262,7 @@ }, oldXraySetting: '', xraySetting: '', + loadFailed: false, outboundTestUrl: 'https://www.google.com/generate_204', oldOutboundTestUrl: 'https://www.google.com/generate_204', inboundTags: [], @@ -394,10 +404,6 @@ const msg = await HttpUtil.post("/panel/xray/"); if (msg.success) { - if (!this.loadingStates.fetched) { - this.loadingStates.fetched = true - } - const result = JSON.parse(msg.obj); const xs = JSON.stringify(result.xraySetting, null, 2); this.oldXraySetting = xs; @@ -406,7 +412,11 @@ this.outboundTestUrl = result.outboundTestUrl || 'https://www.google.com/generate_204'; this.oldOutboundTestUrl = this.outboundTestUrl; this.saveBtnDisable = true; + this.loadFailed = false; + return true; } + this.loadFailed = true; + return false; }, async updateXraySetting() { this.loading(true); @@ -1064,13 +1074,17 @@ if (window.location.protocol !== "https:") { this.showAlert = true; } + let settingsLoaded = false; try { - await this.getXraySetting(); + settingsLoaded = await this.getXraySetting(); await this.getXrayResult(); await this.getOutboundsTraffic(); } finally { this.loadingStates.fetched = true; } + if (!settingsLoaded) { + return; + } if (window.wsClient) { window.wsClient.connect(); From 6131c5588221e2ab3182d0d5873e61c4c2f643ed Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Mon, 6 Apr 2026 21:51:33 +0800 Subject: [PATCH 05/17] fix dashboard and inbounds load failure states --- web/html/inbounds.html | 17 ++++++++++++++++- web/html/index.html | 23 ++++++++++++++++++----- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/web/html/inbounds.html b/web/html/inbounds.html index 397a510c..1f37577d 100644 --- a/web/html/inbounds.html +++ b/web/html/inbounds.html @@ -19,6 +19,15 @@ + + + + + + @@ -749,6 +758,7 @@ isAdmin: {{if .is_admin}}true{{else}}false{{end}}, currentUsername: {{ printf "%q" .current_username }}, clientEmailOptions: [], + loadFailed: false, }, methods: { loading(spinning = true) { @@ -759,16 +769,19 @@ const msg = await HttpUtil.get('/panel/api/inbounds/list'); if (!msg.success) { this.refreshing = false; - return; + this.loadFailed = true; + return false; } await this.getLastOnlineMap(); await this.getOnlineUsers(); + this.loadFailed = false; this.setInbounds(msg.obj); setTimeout(() => { this.refreshing = false; }, 500); + return true; }, async getOnlineUsers() { const msg = await HttpUtil.post('/panel/api/inbounds/onlines'); @@ -1633,9 +1646,11 @@ // Initial data fetch this.getDBInbounds() .catch((e) => { + this.loadFailed = true; console.error(e); }) .finally(() => { + this.loadingStates.fetched = true; this.loading(false); }); diff --git a/web/html/index.html b/web/html/index.html index 4eb4077e..53c97acb 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -19,6 +19,14 @@ + + + + + + @@ -882,6 +890,7 @@ fetched: false, spinning: false }, + loadFailed: false, status: new Status(), cpuHistory: [], // small live widget history cpuHistoryLong: [], // aggregated points from backend @@ -905,15 +914,15 @@ try { const msg = await HttpUtil.get('/panel/api/server/status'); if (msg.success) { - if (!this.loadingStates.fetched) { - this.loadingStates.fetched = true; - } - + this.loadFailed = false; this.setStatus(msg.obj, true); + return true; } } catch (e) { console.error("Failed to get status:", e); } + this.loadFailed = true; + return false; }, setStatus(data) { this.status = new Status(data); @@ -1134,7 +1143,11 @@ } // Initial status fetch - await this.getStatus(); + try { + await this.getStatus(); + } finally { + this.loadingStates.fetched = true; + } // Setup WebSocket for real-time updates if (window.wsClient) { From e298996d77d95498f29bed0f07477b440acd20ae Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Mon, 6 Apr 2026 22:12:38 +0800 Subject: [PATCH 06/17] Harden admin access for panel APIs --- web/controller/access_control_test.go | 155 ++++++++++++++++++++++++++ web/controller/api.go | 8 +- web/controller/inbound.go | 39 ++++--- web/controller/setting.go | 2 + web/controller/xui.go | 6 +- web/service/inbound.go | 79 +++++++++++++ web/service/inbound_access_test.go | 63 +++++++++++ 7 files changed, 334 insertions(+), 18 deletions(-) create mode 100644 web/controller/access_control_test.go create mode 100644 web/service/inbound_access_test.go diff --git a/web/controller/access_control_test.go b/web/controller/access_control_test.go new file mode 100644 index 00000000..4744f29f --- /dev/null +++ b/web/controller/access_control_test.go @@ -0,0 +1,155 @@ +package controller + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/web/global" + "github.com/mhsanaei/3x-ui/v2/web/session" + "github.com/mhsanaei/3x-ui/v2/xray" + "github.com/robfig/cron/v3" +) + +type testWebServer struct { + cron *cron.Cron +} + +func (s *testWebServer) GetCron() *cron.Cron { return s.cron } +func (s *testWebServer) GetCtx() context.Context { return context.Background() } +func (s *testWebServer) GetWSHub() any { return nil } + +func setupControllerTestDB(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + t.Setenv("XUI_DEBUG", "") + t.Setenv("XUI_DB_FOLDER", tmpDir) + dbPath := filepath.Join(tmpDir, "controller-test.db") + if err := database.InitDBWithPath(dbPath); err != nil { + t.Fatalf("InitDB failed: %v", err) + } + t.Cleanup(func() { + database.CloseDB() + }) +} + +func newTestRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + + r := gin.New() + store := cookie.NewStore([]byte("test-secret")) + r.Use(sessions.Sessions("3x-ui", store)) + r.Use(func(c *gin.Context) { + c.Set("base_path", "/") + role := c.GetHeader("X-Test-Role") + if role == "" { + return + } + user := &model.User{ + Id: 1, + Username: c.GetHeader("X-Test-Username"), + Role: role, + } + if user.Username == "" { + user.Username = "tester@example.com" + } + session.SetLoginUser(c, user) + }) + return r +} + +func TestXUIController_SettingsPageRequiresAdmin(t *testing.T) { + r := newTestRouter(t) + NewXUIController(r.Group("/")) + + req := httptest.NewRequest(http.MethodGet, "/panel/settings", nil) + req.Header.Set("X-Test-Role", "user") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusTemporaryRedirect { + t.Fatalf("expected %d, got %d", http.StatusTemporaryRedirect, w.Code) + } + if got := w.Header().Get("Location"); got != "/panel/user" { + t.Fatalf("expected redirect to /panel/user, got %q", got) + } +} + +func TestAPIController_AdminEndpointsRequireAdmin(t *testing.T) { + global.SetWebServer(&testWebServer{cron: cron.New()}) + + r := newTestRouter(t) + NewAPIController(r.Group("/")) + + for _, path := range []string{ + "/panel/api/inbounds/list", + "/panel/api/server/status", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-Test-Role", "user") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("%s: expected %d, got %d", path, http.StatusForbidden, w.Code) + } + } +} + +func TestAPIController_UserInfoRemainsAvailableToLoggedInUser(t *testing.T) { + setupControllerTestDB(t) + global.SetWebServer(&testWebServer{cron: cron.New()}) + + inboundSettings, err := json.Marshal(map[string]any{ + "clients": []map[string]any{ + {"id": "client-1", "email": "tester@example.com", "enable": true, "subId": "sub-1"}, + }, + }) + if err != nil { + t.Fatalf("marshal inbound settings failed: %v", err) + } + + inbound := &model.Inbound{ + UserId: 1, + Port: 12001, + Protocol: model.VLESS, + Tag: "controller-user-info", + Settings: string(inboundSettings), + } + if err := database.GetDB().Create(inbound).Error; err != nil { + t.Fatalf("create inbound failed: %v", err) + } + if err := database.GetDB().Create(&xray.ClientTraffic{ + InboundId: inbound.Id, + Email: "tester@example.com", + Enable: true, + }).Error; err != nil { + t.Fatalf("create client traffic failed: %v", err) + } + + r := newTestRouter(t) + NewAPIController(r.Group("/")) + + req := httptest.NewRequest(http.MethodGet, "/panel/api/inbounds/userInfo", nil) + req.Header.Set("X-Test-Role", "user") + req.Header.Set("X-Test-Username", "tester@example.com") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, w.Code) + } +} diff --git a/web/controller/api.go b/web/controller/api.go index 74a6d301..a9ffc369 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -43,10 +43,14 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { // Inbounds API inbounds := api.Group("/inbounds") - a.inboundController = NewInboundController(inbounds) + a.inboundController = &InboundController{} + inbounds.GET("/userInfo", a.inboundController.getUserInfo) + inbounds.Use(a.checkAdmin) + a.inboundController.initRouter(inbounds) // Server API server := api.Group("/server") + server.Use(a.checkAdmin) a.serverController = NewServerController(server) // Users API @@ -55,7 +59,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup) { a.userController = NewUserController(users) // Extra routes - api.GET("/backuptotgbot", a.BackuptoTgbot) + api.GET("/backuptotgbot", a.checkAdmin, a.BackuptoTgbot) } // BackuptoTgbot sends a backup of the panel data to Telegram bot admins. diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 069ca188..fbd915f6 100644 --- a/web/controller/inbound.go +++ b/web/controller/inbound.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "errors" "fmt" "strconv" "time" @@ -12,6 +13,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/web/websocket" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // InboundController handles HTTP requests related to Xray inbounds management. @@ -48,7 +50,6 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics) g.POST("/delDepletedClients/:id", a.delDepletedClients) g.POST("/import", a.importInbound) - g.GET("/userInfo", a.getUserInfo) g.POST("/onlines", a.onlines) g.POST("/lastOnline", a.lastOnline) g.POST("/updateClientTraffic/:email", a.updateClientTraffic) @@ -73,8 +74,13 @@ func (a *InboundController) getInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "get"), err) return } - inbound, err := a.inboundService.GetInbound(id) + user := session.GetLoginUser(c) + inbound, err := a.inboundService.GetInboundForUser(user.Id, user.Role == "admin", id) if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), errors.New("inbound not found")) + return + } jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } @@ -140,7 +146,8 @@ func (a *InboundController) delInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err) return } - needRestart, err := a.inboundService.DelInbound(id) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.DelInboundForUser(user.Id, user.Role == "admin", id) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -150,7 +157,6 @@ func (a *InboundController) delInbound(c *gin.Context) { a.xrayService.SetToNeedRestart() } // Broadcast inbounds update via WebSocket - user := session.GetLoginUser(c) inbounds, _ := a.inboundService.GetInbounds(user.Id) websocket.BroadcastInbounds(inbounds) } @@ -170,7 +176,8 @@ func (a *InboundController) updateInbound(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } - inbound, needRestart, err := a.inboundService.UpdateInbound(inbound) + user := session.GetLoginUser(c) + inbound, needRestart, err := a.inboundService.UpdateInboundForUser(user.Id, user.Role == "admin", inbound) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -180,7 +187,6 @@ func (a *InboundController) updateInbound(c *gin.Context) { a.xrayService.SetToNeedRestart() } // Broadcast inbounds update via WebSocket - user := session.GetLoginUser(c) inbounds, _ := a.inboundService.GetInbounds(user.Id) websocket.BroadcastInbounds(inbounds) } @@ -250,7 +256,8 @@ func (a *InboundController) addInboundClient(c *gin.Context) { return } - needRestart, err := a.inboundService.AddInboundClient(data) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.AddInboundClientForUser(user.Id, user.Role == "admin", data) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -270,7 +277,8 @@ func (a *InboundController) delInboundClient(c *gin.Context) { } clientId := c.Param("clientId") - needRestart, err := a.inboundService.DelInboundClient(id, clientId) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.DelInboundClientForUser(user.Id, user.Role == "admin", id, clientId) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -292,7 +300,8 @@ func (a *InboundController) updateInboundClient(c *gin.Context) { return } - needRestart, err := a.inboundService.UpdateInboundClient(inbound, clientId) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.UpdateInboundClientForUser(user.Id, user.Role == "admin", inbound, clientId) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -312,7 +321,8 @@ func (a *InboundController) resetClientTraffic(c *gin.Context) { } email := c.Param("email") - needRestart, err := a.inboundService.ResetClientTraffic(id, email) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.ResetClientTrafficForUser(user.Id, user.Role == "admin", id, email) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -343,7 +353,8 @@ func (a *InboundController) resetAllClientTraffics(c *gin.Context) { return } - err = a.inboundService.ResetAllClientTraffics(id) + user := session.GetLoginUser(c) + err = a.inboundService.ResetAllClientTrafficsForUser(user.Id, user.Role == "admin", id) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -390,7 +401,8 @@ func (a *InboundController) delDepletedClients(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err) return } - err = a.inboundService.DelDepletedClients(id) + user := session.GetLoginUser(c) + err = a.inboundService.DelDepletedClientsForUser(user.Id, user.Role == "admin", id) if err != nil { jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) return @@ -444,7 +456,8 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) { } email := c.Param("email") - needRestart, err := a.inboundService.DelInboundClientByEmail(inboundId, email) + user := session.GetLoginUser(c) + needRestart, err := a.inboundService.DelInboundClientByEmailForUser(user.Id, user.Role == "admin", inboundId, email) if err != nil { jsonMsg(c, "Failed to delete client by email", err) return diff --git a/web/controller/setting.go b/web/controller/setting.go index be0629ba..92bc7b84 100644 --- a/web/controller/setting.go +++ b/web/controller/setting.go @@ -24,6 +24,7 @@ type updateUserForm struct { // SettingController handles settings and user management operations. type SettingController struct { + BaseController settingService service.SettingService userService service.UserService panelService service.PanelService @@ -39,6 +40,7 @@ func NewSettingController(g *gin.RouterGroup) *SettingController { // initRouter sets up the routes for settings management. func (a *SettingController) initRouter(g *gin.RouterGroup) { g = g.Group("/setting") + g.Use(a.checkAdmin) g.POST("/all", a.getAllSetting) g.POST("/defaultSettings", a.getDefaultSettings) diff --git a/web/controller/xui.go b/web/controller/xui.go index 44ff5e1e..607359e5 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -30,9 +30,9 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/", a.index) g.GET("/user", a.user) - g.GET("/inbounds", a.inbounds) - g.GET("/settings", a.settings) - g.GET("/xray", a.xraySettings) + g.GET("/inbounds", a.checkAdmin, a.inbounds) + g.GET("/settings", a.checkAdmin, a.settings) + g.GET("/xray", a.checkAdmin, a.xraySettings) g.GET("/users", a.checkAdmin, a.users) a.settingController = NewSettingController(g) diff --git a/web/service/inbound.go b/web/service/inbound.go index e0daa47f..83362259 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -389,6 +389,85 @@ func (s *InboundService) GetInbound(id int) (*model.Inbound, error) { return inbound, nil } +func (s *InboundService) getInboundQueryForUser(userID int, isAdmin bool) *gorm.DB { + db := database.GetDB().Model(model.Inbound{}) + if !isAdmin { + db = db.Where("user_id = ?", userID) + } + return db +} + +func (s *InboundService) GetInboundForUser(userID int, isAdmin bool, id int) (*model.Inbound, error) { + inbound := &model.Inbound{} + if err := s.getInboundQueryForUser(userID, isAdmin).First(inbound, id).Error; err != nil { + return nil, err + } + return inbound, nil +} + +func (s *InboundService) UpdateInboundForUser(userID int, isAdmin bool, inbound *model.Inbound) (*model.Inbound, bool, error) { + if _, err := s.GetInboundForUser(userID, isAdmin, inbound.Id); err != nil { + return inbound, false, err + } + return s.UpdateInbound(inbound) +} + +func (s *InboundService) DelInboundForUser(userID int, isAdmin bool, id int) (bool, error) { + if _, err := s.GetInboundForUser(userID, isAdmin, id); err != nil { + return false, err + } + return s.DelInbound(id) +} + +func (s *InboundService) AddInboundClientForUser(userID int, isAdmin bool, data *model.Inbound) (bool, error) { + if _, err := s.GetInboundForUser(userID, isAdmin, data.Id); err != nil { + return false, err + } + return s.AddInboundClient(data) +} + +func (s *InboundService) DelInboundClientForUser(userID int, isAdmin bool, inboundID int, clientID string) (bool, error) { + if _, err := s.GetInboundForUser(userID, isAdmin, inboundID); err != nil { + return false, err + } + return s.DelInboundClient(inboundID, clientID) +} + +func (s *InboundService) UpdateInboundClientForUser(userID int, isAdmin bool, data *model.Inbound, clientID string) (bool, error) { + if _, err := s.GetInboundForUser(userID, isAdmin, data.Id); err != nil { + return false, err + } + return s.UpdateInboundClient(data, clientID) +} + +func (s *InboundService) ResetClientTrafficForUser(userID int, isAdmin bool, inboundID int, clientEmail string) (bool, error) { + if _, err := s.GetInboundForUser(userID, isAdmin, inboundID); err != nil { + return false, err + } + return s.ResetClientTraffic(inboundID, clientEmail) +} + +func (s *InboundService) ResetAllClientTrafficsForUser(userID int, isAdmin bool, id int) error { + if _, err := s.GetInboundForUser(userID, isAdmin, id); err != nil { + return err + } + return s.ResetAllClientTraffics(id) +} + +func (s *InboundService) DelDepletedClientsForUser(userID int, isAdmin bool, id int) error { + if _, err := s.GetInboundForUser(userID, isAdmin, id); err != nil { + return err + } + return s.DelDepletedClients(id) +} + +func (s *InboundService) DelInboundClientByEmailForUser(userID int, isAdmin bool, inboundID int, email string) (bool, error) { + if _, err := s.GetInboundForUser(userID, isAdmin, inboundID); err != nil { + return false, err + } + return s.DelInboundClientByEmail(inboundID, email) +} + // UpdateInbound modifies an existing inbound configuration. // It validates changes, updates the database, and syncs with the running Xray instance. // Returns the updated inbound, whether Xray needs restart, and any error. diff --git a/web/service/inbound_access_test.go b/web/service/inbound_access_test.go new file mode 100644 index 00000000..ba1c8739 --- /dev/null +++ b/web/service/inbound_access_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "errors" + "testing" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "gorm.io/gorm" +) + +func TestGetInboundForUser_DeniesOtherUsers(t *testing.T) { + setupTestDB(t) + + svc := &InboundService{} + inbound := mustCreateInboundWithClients(t, svc, model.Inbound{ + UserId: 2, + Port: 13001, + Protocol: model.VLESS, + Tag: "owned-by-user-2", + }, model.Client{ + ID: "client-1", + Email: "user2@example.com", + Enable: false, + }) + + _, err := svc.GetInboundForUser(1, false, inbound.Id) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected ErrRecordNotFound, got %v", err) + } + + got, err := svc.GetInboundForUser(2, false, inbound.Id) + if err != nil { + t.Fatalf("expected owner to fetch inbound: %v", err) + } + if got.Id != inbound.Id { + t.Fatalf("expected inbound %d, got %d", inbound.Id, got.Id) + } +} + +func TestDelInboundForUser_DeniesOtherUsers(t *testing.T) { + setupTestDB(t) + + svc := &InboundService{} + inbound := mustCreateInboundWithClients(t, svc, model.Inbound{ + UserId: 2, + Port: 13002, + Protocol: model.VLESS, + Tag: "delete-owned-by-user-2", + }, model.Client{ + ID: "client-1", + Email: "user2@example.com", + Enable: false, + }) + + _, err := svc.DelInboundForUser(1, false, inbound.Id) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected ErrRecordNotFound, got %v", err) + } + + if _, err := svc.GetInbound(inbound.Id); err != nil { + t.Fatalf("expected inbound to remain after denied delete: %v", err) + } +} From cc6d3daa3a3a6e951c1db855c8808ecb102a12c9 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 02:12:02 +0800 Subject: [PATCH 07/17] fix: harden migration and setting writes --- database/db.go | 40 ++++-- database/db_test.go | 44 ++++++ database/model/model.go | 2 +- database/model/model_test.go | 4 +- main.go | 4 +- web/service/inbound.go | 257 ++++++++++++++++++----------------- web/service/inbound_test.go | 58 ++++++++ web/service/server.go | 4 +- web/service/setting.go | 21 ++- web/service/setting_test.go | 27 ++++ 10 files changed, 301 insertions(+), 160 deletions(-) diff --git a/database/db.go b/database/db.go index fd1da3fa..e0c50551 100644 --- a/database/db.go +++ b/database/db.go @@ -91,18 +91,24 @@ func runSeeders(isUsersEmpty bool) error { return err } - if empty && isUsersEmpty { - hashSeeder := &model.HistoryOfSeeders{ - SeederName: "UserPasswordHash", + return db.Transaction(func(tx *gorm.DB) error { + if empty && isUsersEmpty { + hashSeeder := &model.HistoryOfSeeders{ + SeederName: "UserPasswordHash", + } + return tx.Create(hashSeeder).Error } - return db.Create(hashSeeder).Error - } else { + var seedersHistory []string - db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory) + if err := tx.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil { + return err + } if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty { var users []model.User - db.Find(&users) + if err := tx.Find(&users).Error; err != nil { + return err + } for _, user := range users { hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password) @@ -110,13 +116,15 @@ func runSeeders(isUsersEmpty bool) error { log.Printf("Error hashing password for user '%s': %v", user.Username, err) return err } - db.Model(&user).Update("password", hashedPassword) + if err := tx.Model(&user).Update("password", hashedPassword).Error; err != nil { + return err + } } hashSeeder := &model.HistoryOfSeeders{ SeederName: "UserPasswordHash", } - if err := db.Create(hashSeeder).Error; err != nil { + if err := tx.Create(hashSeeder).Error; err != nil { return err } } @@ -125,21 +133,25 @@ func runSeeders(isUsersEmpty bool) error { // Drop the old unique index on client_traffics.email to allow // the same email across multiple inbounds dbType := config.GetDBTypeFromJSON() + var execErr error if dbType == "mariadb" { - db.Exec("DROP INDEX IF EXISTS idx_client_traffics_email ON client_traffics") + execErr = tx.Exec("DROP INDEX IF EXISTS idx_client_traffics_email ON client_traffics").Error } else { - db.Exec("DROP INDEX IF EXISTS idx_client_traffics_email") + execErr = tx.Exec("DROP INDEX IF EXISTS idx_client_traffics_email").Error + } + if execErr != nil { + return execErr } uniqueSeeder := &model.HistoryOfSeeders{ SeederName: "RemoveClientTrafficEmailUnique", } - if err := db.Create(uniqueSeeder).Error; err != nil { + if err := tx.Create(uniqueSeeder).Error; err != nil { return err } } - } - return nil + return nil + }) } // isTableEmpty returns true if the named table contains zero rows. diff --git a/database/db_test.go b/database/db_test.go index 9cebdd20..a51b5c64 100644 --- a/database/db_test.go +++ b/database/db_test.go @@ -212,3 +212,47 @@ func TestInitUser_OnlyOnce(t *testing.T) { t.Errorf("expected 1 user, got %d", count) } } + +func TestRunSeeders_DoesNotRecordHistoryWhenPasswordUpdateFails(t *testing.T) { + setupTestDB(t) + + if err := db.Exec("DELETE FROM history_of_seeders").Error; err != nil { + t.Fatalf("clear seeders history failed: %v", err) + } + + if err := db.Exec(` + CREATE TRIGGER fail_user_password_update + BEFORE UPDATE OF password ON users + BEGIN + SELECT RAISE(FAIL, 'boom'); + END; + `).Error; err != nil { + t.Fatalf("create trigger failed: %v", err) + } + + err := runSeeders(false) + if err == nil { + t.Fatalf("expected runSeeders to fail when user password update fails") + } + + var count int64 + if err := db.Model(&model.HistoryOfSeeders{}). + Where("seeder_name = ?", "UserPasswordHash"). + Count(&count).Error; err != nil { + t.Fatalf("count seeder history failed: %v", err) + } + if count != 0 { + t.Fatalf("expected no UserPasswordHash history row after failed seeder, got %d", count) + } +} + +func TestSettingKey_IsUnique(t *testing.T) { + setupTestDB(t) + + if err := db.Create(&model.Setting{Key: "dup", Value: "one"}).Error; err != nil { + t.Fatalf("first insert failed: %v", err) + } + if err := db.Create(&model.Setting{Key: "dup", Value: "two"}).Error; err == nil { + t.Fatal("expected duplicate setting key insert to fail") + } +} diff --git a/database/model/model.go b/database/model/model.go index 98252f62..36ba1b0f 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -102,7 +102,7 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig { // Setting stores key-value configuration settings for the 3x-ui panel. type Setting struct { Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` - Key string `json:"key" form:"key"` + Key string `json:"key" form:"key" gorm:"uniqueIndex"` Value string `json:"value" form:"value"` } diff --git a/database/model/model_test.go b/database/model/model_test.go index 17c9839c..e0d0a2ab 100644 --- a/database/model/model_test.go +++ b/database/model/model_test.go @@ -1,8 +1,6 @@ package model -import ( - "testing" -) +import "testing" func TestGenXrayInboundConfig_EmptyListen(t *testing.T) { in := &Inbound{ diff --git a/main.go b/main.go index 329c5130..ec47b742 100644 --- a/main.go +++ b/main.go @@ -411,7 +411,9 @@ func migrateDb() { log.Fatal(err) } fmt.Println("Start migrating database...") - inboundService.MigrateDB() + if err := inboundService.MigrateDB(); err != nil { + log.Fatal(err) + } fmt.Println("Migration done!") } diff --git a/web/service/inbound.go b/web/service/inbound.go index 83362259..68c1cc42 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -2333,167 +2333,170 @@ func (s *InboundService) SearchInbounds(query string) ([]*model.Inbound, error) return inbounds, nil } -func (s *InboundService) MigrationRequirements() { +func (s *InboundService) MigrationRequirements() error { db := database.GetDB() - tx := db.Begin() - var err error - defer func() { - if err == nil { - tx.Commit() - if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil { - logger.Warningf("VACUUM failed: %v", dbErr) - } - } else { - tx.Rollback() + if err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(` + UPDATE inbounds + SET all_time = IFNULL(up, 0) + IFNULL(down, 0) + WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 + `).Error; err != nil { + return err + } + if err := tx.Exec(` + UPDATE client_traffics + SET all_time = IFNULL(up, 0) + IFNULL(down, 0) + WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 + `).Error; err != nil { + return err } - }() - // Calculate and backfill all_time from up+down for inbounds and clients - err = tx.Exec(` - UPDATE inbounds - SET all_time = IFNULL(up, 0) + IFNULL(down, 0) - WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 - `).Error - if err != nil { - return - } - err = tx.Exec(` - UPDATE client_traffics - SET all_time = IFNULL(up, 0) + IFNULL(down, 0) - WHERE IFNULL(all_time, 0) = 0 AND (IFNULL(up, 0) + IFNULL(down, 0)) > 0 - `).Error + var inbounds []*model.Inbound + err := tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error + if err != nil && err != gorm.ErrRecordNotFound { + return err + } + for inboundIndex := range inbounds { + settings := map[string]any{} + if err := json.Unmarshal([]byte(inbounds[inboundIndex].Settings), &settings); err != nil { + return err + } + clients, ok := settings["clients"].([]any) + if ok { + var newClients []any + for clientIndex := range clients { + c, ok := clients[clientIndex].(map[string]any) + if !ok { + return fmt.Errorf("invalid client settings format for inbound %d", inbounds[inboundIndex].Id) + } - if err != nil { - return - } - - // Fix inbounds based problems - var inbounds []*model.Inbound - err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error - if err != nil && err != gorm.ErrRecordNotFound { - return - } - for inbound_index := range inbounds { - settings := map[string]any{} - json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings) - clients, ok := settings["clients"].([]any) - if ok { - // Fix Client configuration problems - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - - // Add email='' if it is not exists - if _, ok := c["email"]; !ok { - c["email"] = "" - } - - // Convert string tgId to int64 - if _, ok := c["tgId"]; ok { - var tgId any = c["tgId"] - if tgIdStr, ok2 := tgId.(string); ok2 { - tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64) - if err == nil { - c["tgId"] = tgIdInt64 + if _, ok := c["email"]; !ok { + c["email"] = "" + } + if _, ok := c["tgId"]; ok { + tgId := c["tgId"] + if tgIdStr, ok2 := tgId.(string); ok2 { + tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64) + if err == nil { + c["tgId"] = tgIdInt64 + } } } - } - - // Remove "flow": "xtls-rprx-direct" - if _, ok := c["flow"]; ok { - if c["flow"] == "xtls-rprx-direct" { + if _, ok := c["flow"]; ok && c["flow"] == "xtls-rprx-direct" { c["flow"] = "" } + if _, ok := c["created_at"]; !ok { + c["created_at"] = time.Now().Unix() * 1000 + } + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, c) } - // Backfill created_at and updated_at - if _, ok := c["created_at"]; !ok { - c["created_at"] = time.Now().Unix() * 1000 + settings["clients"] = newClients + modifiedSettings, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return err } - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) + inbounds[inboundIndex].Settings = string(modifiedSettings) } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") + + modelClients, err := s.GetClients(inbounds[inboundIndex]) if err != nil { - return + return err } - - inbounds[inbound_index].Settings = string(modifiedSettings) - } - - // Add client traffic row for all clients which has email - modelClients, err := s.GetClients(inbounds[inbound_index]) - if err != nil { - return - } - for _, modelClient := range modelClients { - if len(modelClient.Email) > 0 { + for _, modelClient := range modelClients { + if len(modelClient.Email) == 0 { + continue + } var count int64 - tx.Model(xray.ClientTraffic{}). - Where("inbound_id = ? AND email = ?", inbounds[inbound_index].Id, modelClient.Email). - Count(&count) + if err := tx.Model(xray.ClientTraffic{}). + Where("inbound_id = ? AND email = ?", inbounds[inboundIndex].Id, modelClient.Email). + Count(&count).Error; err != nil { + return err + } if count == 0 { - s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient) + if err := s.AddClientStat(tx, inbounds[inboundIndex].Id, &modelClient); err != nil { + return err + } } } } - } - tx.Save(inbounds) + if err := tx.Save(inbounds).Error; err != nil { + return err + } + if err := tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{}).Error; err != nil { + return err + } - // Remove orphaned traffics - tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{}) + var externalProxy []struct { + Id int + Port int + StreamSettings []byte + } + if err := tx.Raw(`select id, port, stream_settings + from inbounds + WHERE protocol in ('vmess','vless','trojan') + AND json_extract(stream_settings, '$.security') = 'tls' + AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`).Scan(&externalProxy).Error; err != nil { + return err + } - // Migrate old MultiDomain to External Proxy - var externalProxy []struct { - Id int - Port int - StreamSettings []byte - } - err = tx.Raw(`select id, port, stream_settings - from inbounds - WHERE protocol in ('vmess','vless','trojan') - AND json_extract(stream_settings, '$.security') = 'tls' - AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`).Scan(&externalProxy).Error - if err != nil || len(externalProxy) == 0 { - return - } - - for _, ep := range externalProxy { - var reverses any - var stream map[string]any - json.Unmarshal(ep.StreamSettings, &stream) - if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok { - if settings, ok := tlsSettings["settings"].(map[string]any); ok { - if domains, ok := settings["domains"].([]any); ok { - for _, domain := range domains { - if domainMap, ok := domain.(map[string]any); ok { + for _, ep := range externalProxy { + var reverses any + var stream map[string]any + if err := json.Unmarshal(ep.StreamSettings, &stream); err != nil { + return err + } + if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok { + if settings, ok := tlsSettings["settings"].(map[string]any); ok { + if domains, ok := settings["domains"].([]any); ok { + for _, domain := range domains { + domainMap, ok := domain.(map[string]any) + if !ok { + return fmt.Errorf("invalid tls domain settings format for inbound %d", ep.Id) + } + domainName, ok := domainMap["domain"].(string) + if !ok { + return fmt.Errorf("invalid tls domain name for inbound %d", ep.Id) + } domainMap["forceTls"] = "same" domainMap["port"] = ep.Port - domainMap["dest"] = domainMap["domain"].(string) + domainMap["dest"] = domainName delete(domainMap, "domain") } } + reverses = settings["domains"] + delete(settings, "domains") } - reverses = settings["domains"] - delete(settings, "domains") + } + stream["externalProxy"] = reverses + newStream, err := json.MarshalIndent(stream, " ", " ") + if err != nil { + return err + } + if err := tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream).Error; err != nil { + return err } } - stream["externalProxy"] = reverses - newStream, _ := json.MarshalIndent(stream, " ", " ") - tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream) + + return tx.Raw(`UPDATE inbounds + SET tag = REPLACE(tag, '0.0.0.0:', '') + WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error + }); err != nil { + return err } - err = tx.Raw(`UPDATE inbounds - SET tag = REPLACE(tag, '0.0.0.0:', '') - WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error - if err != nil { - return + if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil { + logger.Warningf("VACUUM failed: %v", dbErr) } + return nil } -func (s *InboundService) MigrateDB() { - s.MigrationRequirements() +func (s *InboundService) MigrateDB() error { + if err := s.MigrationRequirements(); err != nil { + return err + } s.MigrationRemoveOrphanedTraffics() + return nil } func (s *InboundService) GetOnlineClients() []string { diff --git a/web/service/inbound_test.go b/web/service/inbound_test.go index b78a0f7a..20dee7b5 100644 --- a/web/service/inbound_test.go +++ b/web/service/inbound_test.go @@ -194,3 +194,61 @@ func TestUpdateInboundClient_DoesNotUpdateOtherInboundTraffic(t *testing.T) { t.Fatalf("expected renamed email to stay isolated to inbound1, got %d rows in inbound2", got) } } + +func TestMigrationRequirements_RollsBackOnAddClientStatFailure(t *testing.T) { + setupTestDB(t) + + svc := &InboundService{} + inbound := model.Inbound{ + UserId: 1, + Port: 12001, + Protocol: model.VLESS, + Tag: "rollback-test", + Up: 10, + Down: 20, + Settings: mustMarshalInboundSettings(t, model.Client{ + ID: "client-rollback", + Email: "rollback@example.com", + Enable: true, + TotalGB: 100, + ExpiryTime: 200, + }), + } + if err := database.GetDB().Create(&inbound).Error; err != nil { + t.Fatalf("create inbound failed: %v", err) + } + + if err := database.GetDB().Exec(` + CREATE TRIGGER fail_client_traffic_insert + BEFORE INSERT ON client_traffics + BEGIN + SELECT RAISE(FAIL, 'boom'); + END; + `).Error; err != nil { + t.Fatalf("create trigger failed: %v", err) + } + + err := svc.MigrationRequirements() + if err == nil { + t.Fatalf("expected migration requirements to return an error when client traffic insert fails") + } + + var refreshed model.Inbound + if err := database.GetDB().First(&refreshed, inbound.Id).Error; err != nil { + t.Fatalf("reload inbound failed: %v", err) + } + if refreshed.AllTime != 0 { + t.Fatalf("expected inbound all_time rollback to keep 0, got %d", refreshed.AllTime) + } + + var traffic xray.ClientTraffic + err = database.GetDB(). + Where("inbound_id = ? AND email = ?", inbound.Id, "rollback@example.com"). + First(&traffic).Error + if err == nil { + t.Fatalf("expected client traffic insert to roll back, but row exists: %+v", traffic) + } + if !database.IsNotFound(err) { + t.Fatalf("reload client traffic failed: %v", err) + } +} diff --git a/web/service/server.go b/web/service/server.go index 96c4174f..8cb895a3 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -1022,7 +1022,9 @@ func (s *ServerService) ImportDB(file multipart.File) error { return common.NewErrorf("Error migrating db: %v", err) } - s.inboundService.MigrateDB() + if err := s.inboundService.MigrateDB(); err != nil { + return common.NewErrorf("Error finalizing imported db: %v", err) + } // Start Xray if err = s.RestartXrayService(); err != nil { diff --git a/web/service/setting.go b/web/service/setting.go index ce855791..e5a3a3d5 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -21,6 +21,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/util/reflect_util" "github.com/mhsanaei/3x-ui/v2/web/entity" "github.com/mhsanaei/3x-ui/v2/xray" + "gorm.io/gorm/clause" ) //go:embed config.json @@ -497,19 +498,13 @@ func getXrayTemplateConfigFromDB() (string, error) { // saveXrayTemplateConfigToDB writes xrayTemplateConfig directly to the database. func saveXrayTemplateConfigToDB(value string) error { db := database.GetDB() - setting := &model.Setting{} - err := db.Model(model.Setting{}).Where("`key` = ?", "xrayTemplateConfig").First(setting).Error - if database.IsNotFound(err) { - return db.Create(&model.Setting{ - Key: "xrayTemplateConfig", - Value: value, - }).Error - } - if err != nil { - return err - } - setting.Value = value - return db.Save(setting).Error + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "key"}}, + DoUpdates: clause.Assignments(map[string]any{"value": value}), + }).Create(&model.Setting{ + Key: "xrayTemplateConfig", + Value: value, + }).Error } // SettingService provides business logic for application settings management. diff --git a/web/service/setting_test.go b/web/service/setting_test.go index 2a656840..93ed9d03 100644 --- a/web/service/setting_test.go +++ b/web/service/setting_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v2/config" + "github.com/mhsanaei/3x-ui/v2/database" + "github.com/mhsanaei/3x-ui/v2/database/model" ) func setupTestSettings(t *testing.T) func() { @@ -102,6 +104,31 @@ func TestSettingServiceSetAndGetString(t *testing.T) { } } +func TestSaveXrayTemplateConfigToDB_UpdatesSingleRow(t *testing.T) { + setupTestSettings(t) + setupTestDB(t) + + if err := saveXrayTemplateConfigToDB(`{"version":1}`); err != nil { + t.Fatalf("first save failed: %v", err) + } + if err := saveXrayTemplateConfigToDB(`{"version":2}`); err != nil { + t.Fatalf("second save failed: %v", err) + } + + var settings []model.Setting + if err := database.GetDB(). + Where("key = ?", "xrayTemplateConfig"). + Find(&settings).Error; err != nil { + t.Fatalf("query settings failed: %v", err) + } + if len(settings) != 1 { + t.Fatalf("expected exactly one xrayTemplateConfig row, got %d", len(settings)) + } + if settings[0].Value != `{"version":2}` { + t.Fatalf("expected latest config value to be persisted, got %s", settings[0].Value) + } +} + func TestResetSettingsDeletesFile(t *testing.T) { setupTestSettings(t) From 019603d55f288f0f3eb792b54dbce44d9bf90c73 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 11:32:30 +0800 Subject: [PATCH 08/17] docs: add cloudflare asset optimization design --- ...2026-04-07-cloudflare-cdn-assets-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-07-cloudflare-cdn-assets-design.md diff --git a/docs/superpowers/specs/2026-04-07-cloudflare-cdn-assets-design.md b/docs/superpowers/specs/2026-04-07-cloudflare-cdn-assets-design.md new file mode 100644 index 00000000..6c48aee1 --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-cloudflare-cdn-assets-design.md @@ -0,0 +1,281 @@ +# Cloudflare CDN Frontend Asset Optimization Design + +## Context + +The panel currently serves frontend assets from embedded files under `web/assets` and references them directly from HTML templates. A subset of assets uses `?{{ .cur_ver }}` query strings for cache busting, while some third-party files have no version token at all. The server sets `Cache-Control: max-age=31536000` for requests under `/assets/`, and enables gzip at the Gin layer. + +This works for basic browser caching, but it is not a strong fit for Cloudflare edge caching: + +- Query-string cache busting is weaker than content-addressed filenames. +- Some assets are not versioned at all. +- HTML and static assets are not explicitly separated into short-cache vs long-cache behavior. +- The current embedded asset flow does not provide a manifest-based way to map logical asset names to hashed output names. + +The deployment model is Go binary compilation with `go:embed`, so the design must preserve compile-time embedding and avoid runtime dependence on the local filesystem. + +## Goals + +- Keep all frontend assets self-hosted. +- Optimize asset delivery for Cloudflare CDN edge caching. +- Replace query-string cache busting with content-hashed filenames. +- Preserve the current HTML templates, base path support, and embedded deployment model. +- Keep API routes, session behavior, and WebSocket endpoints unchanged. + +## Non-Goals + +- No migration to third-party script or stylesheet CDNs. +- No change to business logic, Vue component behavior, or page structure. +- No runtime asset compilation in production. +- No broad frontend bundler migration in this change. + +## Recommended Approach + +Adopt a build-time asset fingerprinting pipeline that generates a new embedded asset output tree and a manifest file. Templates will resolve logical asset paths through the manifest, and the server will serve only fingerprinted asset URLs with long-lived immutable caching headers. + +This is the recommended approach because it is the most compatible with Cloudflare's cache model and the current Go binary deployment flow. + +## Alternatives Considered + +### 1. Build-time fingerprinted assets and manifest + +This is the recommended option. + +Pros: + +- Best Cloudflare cache efficiency and invalidation behavior. +- Safe long-lived caching with `immutable`. +- Explicit and debuggable asset mapping. +- Compatible with `go:embed`. + +Cons: + +- Adds a pre-build asset generation step. +- Requires template updates to use a manifest helper. + +### 2. Runtime virtual hashed routes backed by embedded assets + +Pros: + +- No extra pre-build step. + +Cons: + +- Adds runtime complexity to compute or maintain mappings. +- Less transparent than generated files. +- Harder to reason about and test than build-time outputs. + +### 3. Keep filenames and use per-file hash query strings + +Pros: + +- Smallest code change. + +Cons: + +- Weaker fit for Cloudflare edge caching. +- Less operationally clear than immutable fingerprinted paths. +- Leaves ambiguity around caches that normalize or vary on query strings. + +## Design + +### Asset Source and Output Layout + +Keep `web/assets` as the source tree checked into the repository. + +Add a generated output tree for embedded production assets: + +- `web/public/assets/...` for fingerprinted files +- `web/public/assets-manifest.json` for logical-to-fingerprinted path mapping + +`web/public` is generated content. `go:embed` in production should target the generated tree rather than the source tree. + +Example mapping: + +- logical: `css/custom.min.css` +- output: `css/custom.min.4f3c2a1b.css` + +- logical: `js/websocket.js` +- output: `js/websocket.a9c88d71.js` + +### Build Pipeline + +Add a build-time generator command or script that: + +1. Walks `web/assets` +2. Computes a deterministic content hash for each file +3. Writes the file into `web/public/assets` with the hash inserted before the extension +4. Emits `web/public/assets-manifest.json` + +Hash requirements: + +- Deterministic for identical file content +- Stable across platforms +- Short enough for readable filenames + +An 8 to 12 character hex digest from SHA-256 is sufficient here. + +The generator must preserve subdirectories so current logical organization remains intact. + +### Manifest Format + +Use a flat JSON object keyed by logical asset path relative to `web/assets`. + +Example: + +```json +{ + "ant-design-vue/antd.min.css": "ant-design-vue/antd.min.4f3c2a1b.css", + "css/custom.min.css": "css/custom.min.182d7e0a.css", + "js/axios-init.js": "js/axios-init.bf4d1d4e.js", + "js/websocket.js": "js/websocket.a9c88d71.js", + "Vazirmatn-UI-NL-Regular.woff2": "Vazirmatn-UI-NL-Regular.4c2a16f1.woff2" +} +``` + +This keeps template lookup simple and avoids path reconstruction logic. + +### Embed Strategy + +Replace the production asset embed source in `web/web.go` so that production serving reads from generated output, not raw source assets. + +Development mode can keep serving from `web/assets` directly to avoid slowing local iteration. + +Production mode behavior: + +- embed `web/public/assets` +- load `web/public/assets-manifest.json` +- serve only the generated fingerprinted files + +### Template Asset Resolution + +Add a template function, for example `asset`, that accepts a logical asset path and returns the final URL under the current `basePath`. + +Example usage in templates: + +```gotemplate + + +``` + +This replaces direct `{{ .base_path }}assets/...` references and removes `?{{ .cur_ver }}` from static asset URLs. + +The helper behavior should be: + +- resolve the logical path through the manifest in production +- prefix with `{{ .base_path }}assets/` +- fail loudly during server init if a required manifest entry is missing + +For debug mode, the helper can return the original non-fingerprinted path so templates work unchanged during local development. + +### Cache-Control Policy + +Separate HTML caching from static asset caching. + +HTML responses: + +- `Cache-Control: no-cache, must-revalidate` + +Fingerprint asset responses: + +- `Cache-Control: public, max-age=31536000, immutable` + +This allows Cloudflare and browsers to retain asset files for a year while ensuring HTML revalidates and can reference new asset filenames after deployment. + +### ETag and Last-Modified + +This design does not require ETag for fingerprinted assets because filename changes already provide cache invalidation. ETag may still be present if provided by the underlying file serving behavior, but it is not required for correctness. + +`Last-Modified` is also non-critical for fingerprinted assets. The current `ModTime` override tied to process start is not a reliable version signal, and should not be treated as part of cache invalidation. The fingerprinted filename is the source of truth. + +### Cloudflare Behavior + +Expected Cloudflare policy after this design: + +- Cache `/assets/*` aggressively at the edge +- Do not cache HTML application pages for long durations +- Avoid purge-heavy workflows because asset invalidation is filename-based + +This design keeps Cloudflare configuration simple. New deployments produce new asset URLs; old assets remain safely cacheable until naturally evicted. + +### Backward Compatibility + +Preserve: + +- `basePath` support +- current routes outside static asset delivery +- current debug mode serving behavior + +Change: + +- production asset references move from raw names plus optional query strings to fingerprinted filenames +- production asset embed source moves to generated output + +Existing un-fingerprinted `/assets/...` paths should not remain part of the production template output. If any route continues to expose them, that should be treated as compatibility-only behavior, not a primary path. + +## Implementation Outline + +1. Add an asset generation tool under the repository, preferably Go-based for portability with the existing build stack. +2. Generate `web/public/assets` and `web/public/assets-manifest.json` from `web/assets`. +3. Update `go:embed` usage in production to embed the generated asset tree and manifest. +4. Add manifest loading during server initialization. +5. Add the `asset` template helper. +6. Replace direct static asset references in HTML templates with `asset(...)`. +7. Update asset response headers to use immutable long-lived caching for fingerprinted assets. +8. Keep HTML responses on short-cache or revalidation semantics. +9. Document the new build prerequisite in developer and release documentation. + +## Error Handling + +Server startup should fail if: + +- the manifest file is missing in production +- a manifest entry is malformed +- a template references an asset key that is absent from the manifest + +Fail-fast is preferable here because silent fallback would hide release integrity problems and produce broken pages under CDN caching. + +## Testing Strategy + +### Automated + +- Unit test the asset generator: + - stable hash naming + - preserved directory structure + - correct manifest output +- Unit test manifest loading: + - valid manifest parses + - missing or malformed entries fail +- Unit test template helper: + - returns base-path-prefixed fingerprinted URLs in production + - returns raw asset URLs in debug mode +- Integration test asset responses: + - fingerprinted asset path returns `Cache-Control: public, max-age=31536000, immutable` + - HTML response returns `Cache-Control: no-cache, must-revalidate` + +### Manual + +- Build a production binary and open the panel in a browser +- Inspect HTML and verify asset URLs contain hashes in filenames +- Confirm page reload after deployment references new filenames when a source asset changes +- Confirm Cloudflare can cache asset responses without manual purge + +## Operational Notes + +- Release workflows must run the asset generation step before `go build`. +- Developers should have a single documented command to regenerate embedded assets. +- Generated assets should either be committed consistently or regenerated in CI/build scripts. This decision should be made once and documented to avoid drift. + +## Open Decision + +One repository policy still needs to be chosen during implementation: + +- Commit generated `web/public` outputs to git +- Or treat them as build artifacts generated before release and excluded from source control + +Recommendation: + +Do not commit generated fingerprinted assets if the release pipeline reliably runs the generator before building. Committing generated outputs increases churn and review noise. If the project's release flow is manual and local builds are common, committing generated outputs may be acceptable for simplicity. + +## Summary + +Use a build-time fingerprinting pipeline to generate embedded static assets and a manifest. Resolve template asset URLs through the manifest, serve fingerprinted asset files with one-year immutable caching, and keep HTML on revalidation semantics. This gives Cloudflare a clean, robust cache model without changing the panel's runtime behavior or introducing third-party CDNs. From 05ece0bd8eb8753d3c78bef397203dd7645b4e05 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 11:59:25 +0800 Subject: [PATCH 09/17] feat: add fingerprinted asset generator --- web/assetsgen/generator.go | 114 ++++++++++++++++++++++ web/assetsgen/generator_test.go | 165 ++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 web/assetsgen/generator.go create mode 100644 web/assetsgen/generator_test.go diff --git a/web/assetsgen/generator.go b/web/assetsgen/generator.go new file mode 100644 index 00000000..0630ecaf --- /dev/null +++ b/web/assetsgen/generator.go @@ -0,0 +1,114 @@ +package assetsgen + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type Manifest map[string]string + +type Options struct { + SourceDir string + OutputDir string + HashLen int +} + +func Generate(opts Options) (Manifest, error) { + if opts.HashLen <= 0 { + opts.HashLen = 8 + } + if opts.HashLen > sha256.Size*2 { + opts.HashLen = sha256.Size * 2 + } + + manifest := make(Manifest) + if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { + return nil, err + } + + err := filepath.WalkDir(opts.SourceDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + rel, err := filepath.Rel(opts.SourceDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + + raw, err := os.ReadFile(path) + if err != nil { + return err + } + + sum := sha256.Sum256(raw) + hash := hex.EncodeToString(sum[:])[:opts.HashLen] + target := fingerprint(rel, hash) + targetPath := filepath.Join(opts.OutputDir, filepath.FromSlash(target)) + + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(targetPath, raw, 0o644); err != nil { + return err + } + + manifest[rel] = target + return nil + }) + if err != nil { + return nil, err + } + + return manifest, nil +} + +func WriteManifest(path string, manifest Manifest) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + return os.WriteFile(path, raw, 0o644) +} + +func fingerprint(rel, hash string) string { + name := filepath.Base(rel) + if strings.HasPrefix(name, ".") && strings.Count(name, ".") == 1 { + return rel + "." + hash + } + + ext := filepath.Ext(rel) + base := strings.TrimSuffix(rel, ext) + if ext == "" { + return rel + "." + hash + } + return base + "." + hash + ext +} + +func CopyFile(dst string, src io.Reader) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, src) + return err +} diff --git a/web/assetsgen/generator_test.go b/web/assetsgen/generator_test.go new file mode 100644 index 00000000..4133cf13 --- /dev/null +++ b/web/assetsgen/generator_test.go @@ -0,0 +1,165 @@ +package assetsgen + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestGenerateProducesFingerprintManifestAndFiles(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "js"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "js", "app.js"), []byte("console.log('v1')\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + got, ok := manifest["js/app.js"] + if !ok { + t.Fatalf("manifest missing logical path: %#v", manifest) + } + + sum := sha256.Sum256([]byte("console.log('v1')\n")) + wantHash := hex.EncodeToString(sum[:])[:8] + want := "js/app." + wantHash + ".js" + if got != want { + t.Fatalf("unexpected hashed filename: got %q want %q", got, want) + } + + if _, err := os.Stat(filepath.Join(dst, "assets", got)); err != nil { + t.Fatalf("hashed output missing: %v", err) + } + + defaultManifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "default-assets"), + }) + if err != nil { + t.Fatalf("Generate with default hash length returned error: %v", err) + } + + if gotDefault := defaultManifest["js/app.js"]; gotDefault != want { + t.Fatalf("default HashLen mismatch: got %q want %q", gotDefault, want) + } + + if _, err := os.Stat(filepath.Join(dst, "default-assets", want)); err != nil { + t.Fatalf("default hashed output missing: %v", err) + } +} + +func TestGenerateClampsHashLenToSha256HexLength(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.WriteFile(filepath.Join(src, "main.css"), []byte("body{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 65, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + sum := sha256.Sum256([]byte("body{}\n")) + wantHash := hex.EncodeToString(sum[:]) + want := "main." + wantHash + ".css" + if got := manifest["main.css"]; got != want { + t.Fatalf("unexpected hashed filename: got %q want %q", got, want) + } + + if _, err := os.Stat(filepath.Join(dst, "assets", want)); err != nil { + t.Fatalf("clamped hashed output missing: %v", err) + } +} + +func TestGenerateFingerprintsDotfilesWithoutLeadingExtension(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "dir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, ".env"), []byte("ROOT=1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "dir", ".env"), []byte("NESTED=1\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + rootSum := sha256.Sum256([]byte("ROOT=1\n")) + rootWant := ".env." + hex.EncodeToString(rootSum[:])[:8] + if got := manifest[".env"]; got != rootWant { + t.Fatalf("unexpected root dotfile fingerprint: got %q want %q", got, rootWant) + } + if _, err := os.Stat(filepath.Join(dst, "assets", rootWant)); err != nil { + t.Fatalf("root dotfile output missing: %v", err) + } + + nestedSum := sha256.Sum256([]byte("NESTED=1\n")) + nestedWant := filepath.ToSlash(filepath.Join("dir", ".env.")) + hex.EncodeToString(nestedSum[:])[:8] + if got := manifest["dir/.env"]; got != nestedWant { + t.Fatalf("unexpected nested dotfile fingerprint: got %q want %q", got, nestedWant) + } + if _, err := os.Stat(filepath.Join(dst, "assets", filepath.FromSlash(nestedWant))); err != nil { + t.Fatalf("nested dotfile output missing: %v", err) + } +} + +func TestWriteManifestSerializesStableJson(t *testing.T) { + dst := t.TempDir() + path := filepath.Join(dst, "assets-manifest.json") + manifest := Manifest{ + "css/a.css": "css/a.11111111.css", + "js/b.js": "js/b.22222222.js", + } + + if err := WriteManifest(path, manifest); err != nil { + t.Fatalf("WriteManifest returned error: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + want := "{\n \"css/a.css\": \"css/a.11111111.css\",\n \"js/b.js\": \"js/b.22222222.js\"\n}\n" + if string(raw) != want { + t.Fatalf("unexpected manifest json:\n got: %q\nwant: %q", string(raw), want) + } + + var decoded map[string]string + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("manifest json invalid: %v", err) + } + if decoded["js/b.js"] != "js/b.22222222.js" { + t.Fatalf("unexpected manifest entry: %#v", decoded) + } +} From faeb8dd2449bd65bb3a3eb0a336014ba8c7ec3dd Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 12:08:10 +0800 Subject: [PATCH 10/17] feat: add asset generation command --- cmd/genassets/main.go | 37 +++++++++++++++++++++++++++++++++ web/assetsgen/generator_test.go | 32 ++++++++++++++++++++++++++++ web/public/.gitkeep | 1 + web/public/README.md | 12 +++++++++++ 4 files changed, 82 insertions(+) create mode 100644 cmd/genassets/main.go create mode 100644 web/public/.gitkeep create mode 100644 web/public/README.md diff --git a/cmd/genassets/main.go b/cmd/genassets/main.go new file mode 100644 index 00000000..3e52ca8c --- /dev/null +++ b/cmd/genassets/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "log" + "os" + "path/filepath" + + "github.com/mhsanaei/3x-ui/v2/web/assetsgen" +) + +func main() { + const ( + sourceDir = "web/assets" + outputDir = "web/public/assets" + manifestPath = "web/public/assets-manifest.json" + ) + + if err := os.RemoveAll(outputDir); err != nil { + log.Fatalf("remove stale asset output: %v", err) + } + if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) { + log.Fatalf("remove stale asset manifest: %v", err) + } + + manifest, err := assetsgen.Generate(assetsgen.Options{ + SourceDir: sourceDir, + OutputDir: outputDir, + HashLen: 8, + }) + if err != nil { + log.Fatalf("generate fingerprinted assets: %v", err) + } + + if err := assetsgen.WriteManifest(filepath.Clean(manifestPath), manifest); err != nil { + log.Fatalf("write asset manifest: %v", err) + } +} diff --git a/web/assetsgen/generator_test.go b/web/assetsgen/generator_test.go index 4133cf13..b6b43fea 100644 --- a/web/assetsgen/generator_test.go +++ b/web/assetsgen/generator_test.go @@ -91,6 +91,38 @@ func TestGenerateClampsHashLenToSha256HexLength(t *testing.T) { } } +func TestGeneratePreservesNestedDirectories(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "css"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "css", "custom.min.css"), []byte("body{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + got := manifest["css/custom.min.css"] + if got == "" { + t.Fatalf("missing css/custom.min.css entry: %#v", manifest) + } + if filepath.Dir(got) != "css" { + t.Fatalf("expected nested output directory, got %q", got) + } + if filepath.Ext(got) != ".css" { + t.Fatalf("expected css extension, got %q", got) + } +} + func TestGenerateFingerprintsDotfilesWithoutLeadingExtension(t *testing.T) { src := t.TempDir() dst := t.TempDir() diff --git a/web/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/web/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/web/public/README.md b/web/public/README.md new file mode 100644 index 00000000..7bb8646b --- /dev/null +++ b/web/public/README.md @@ -0,0 +1,12 @@ +# Generated frontend assets + +This directory is generated from `web/assets` by: + +- `go run ./cmd/genassets` + +Contents: + +- `assets/`: fingerprinted files for production embedding +- `assets-manifest.json`: logical-to-fingerprinted path mapping + +Do not edit generated files by hand. From e6752e04db84adca70e2a24d714359d9a64f551c Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 12:24:33 +0800 Subject: [PATCH 11/17] feat: load fingerprinted asset manifest --- web/asset_manifest.go | 102 +++++++++++++++++++++++++++++++++++++ web/asset_manifest_test.go | 75 +++++++++++++++++++++++++++ web/web.go | 38 +++++++++++--- 3 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 web/asset_manifest.go create mode 100644 web/asset_manifest_test.go diff --git a/web/asset_manifest.go b/web/asset_manifest.go new file mode 100644 index 00000000..54726523 --- /dev/null +++ b/web/asset_manifest.go @@ -0,0 +1,102 @@ +package web + +import ( + "encoding/json" + "fmt" + "io/fs" + "path" + "strings" +) + +type assetManifest map[string]string + +type assetResolver struct { + basePath string + debug bool + manifest assetManifest +} + +func newAssetResolver(basePath string, debug bool, manifest assetManifest) assetResolver { + return assetResolver{ + basePath: basePath, + debug: debug, + manifest: manifest, + } +} + +func (r assetResolver) URL(logical string) string { + target := logical + if !r.debug { + hashed, ok := r.manifest[logical] + if !ok { + panic(fmt.Sprintf("missing asset manifest entry for %q", logical)) + } + target = hashed + } + return path.Join(r.basePath, "assets", target) +} + +func loadAssetManifest(raw []byte) (assetManifest, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("asset manifest is empty") + } + var manifest assetManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, err + } + if len(manifest) == 0 { + return nil, fmt.Errorf("asset manifest has no entries") + } + return manifest, nil +} + +func assetCacheControl(requestPath string) string { + if hasFingerprint(requestPath) { + return "public, max-age=31536000, immutable" + } + return "public, max-age=300" +} + +func assetRequestCacheControl(requestPath string, exists bool) string { + if exists { + return assetCacheControl(requestPath) + } + return "public, max-age=300" +} + +func assetExists(assetsFS fs.FS, assetPath string) bool { + if assetPath == "" { + return false + } + if _, err := fs.Stat(assetsFS, assetPath); err != nil { + return false + } + return true +} + +func hasFingerprint(requestPath string) bool { + base := path.Base(requestPath) + parts := strings.Split(base, ".") + if len(parts) < 2 { + return false + } + if isFingerprintHash(parts[len(parts)-1]) { + return true + } + if len(parts) >= 3 && isFingerprintHash(parts[len(parts)-2]) { + return true + } + return false +} + +func isFingerprintHash(hash string) bool { + if len(hash) < 6 || len(hash) > 64 { + return false + } + for _, ch := range hash { + if !strings.ContainsRune("0123456789abcdef", ch) { + return false + } + } + return true +} diff --git a/web/asset_manifest_test.go b/web/asset_manifest_test.go new file mode 100644 index 00000000..20817553 --- /dev/null +++ b/web/asset_manifest_test.go @@ -0,0 +1,75 @@ +package web + +import ( + "strings" + "testing" +) + +func TestAssetResolverReturnsFingerprintedPathInProduction(t *testing.T) { + resolver := newAssetResolver("/panel/", false, assetManifest{ + "js/websocket.js": "js/websocket.12345678.js", + }) + + got := resolver.URL("js/websocket.js") + want := "/panel/assets/js/websocket.12345678.js" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAssetResolverReturnsLogicalPathInDebug(t *testing.T) { + resolver := newAssetResolver("/panel/", true, nil) + + got := resolver.URL("js/websocket.js") + want := "/panel/assets/js/websocket.js" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) { + resolver := newAssetResolver("/", false, assetManifest{}) + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for missing manifest key") + } + }() + + resolver.URL("missing.js") +} + +func TestFingerprintCacheHeaderIncludesImmutable(t *testing.T) { + got := assetCacheControl("js/websocket.12345678.js") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control, got %q", got) + } +} + +func TestFingerprintCacheHeaderIncludesImmutableForDotfile(t *testing.T) { + got := assetCacheControl(".env.12345678") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control for dotfile, got %q", got) + } +} + +func TestFingerprintCacheHeaderSupportsVariableHexLength(t *testing.T) { + got := assetCacheControl("js/websocket.123456789abc.js") + if !strings.Contains(got, "immutable") { + t.Fatalf("expected immutable cache-control for variable-length hash, got %q", got) + } +} + +func TestFingerprintCacheHeaderRejectsObviousNonFingerprint(t *testing.T) { + got := assetCacheControl("js/websocket.nothex123.js") + if strings.Contains(got, "immutable") { + t.Fatalf("expected short-lived cache-control for non-fingerprint, got %q", got) + } +} + +func TestAssetRequestCacheControlDoesNotMarkMissingFingerprintPathImmutable(t *testing.T) { + got := assetRequestCacheControl("js/missing.123456789abc.js", false) + if strings.Contains(got, "immutable") { + t.Fatalf("expected missing asset path to avoid immutable cache-control, got %q", got) + } +} diff --git a/web/web.go b/web/web.go index 9e8eef91..84fae9e8 100644 --- a/web/web.go +++ b/web/web.go @@ -12,6 +12,7 @@ import ( "net" "net/http" "os" + "path" "strconv" "strings" "time" @@ -37,6 +38,12 @@ import ( //go:embed assets var assetsFS embed.FS +//go:embed public/assets +var publicAssetsFS embed.FS + +//go:embed public/assets-manifest.json +var assetsManifestRaw []byte + //go:embed html/* var htmlFS embed.FS @@ -44,13 +51,15 @@ var htmlFS embed.FS var i18nFS embed.FS var startTime = time.Now() +var productionAssetManifest assetManifest type wrapAssetsFS struct { embed.FS + root string } func (f *wrapAssetsFS) Open(name string) (fs.File, error) { - file, err := f.FS.Open("assets/" + name) + file, err := f.FS.Open(path.Join(f.root, name)) if err != nil { return nil, err } @@ -81,6 +90,17 @@ func (f *wrapAssetsFileInfo) ModTime() time.Time { return startTime } +func init() { + if config.IsDebug() { + return + } + manifest, err := loadAssetManifest(assetsManifestRaw) + if err != nil { + panic(err) + } + productionAssetManifest = manifest +} + // EmbeddedHTML returns the embedded HTML templates filesystem for reuse by other servers. func EmbeddedHTML() embed.FS { return htmlFS @@ -202,6 +222,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { } engine.Use(gzip.Gzip(gzip.DefaultCompression)) assetsBasePath := basePath + "assets/" + assetResolver := newAssetResolver(basePath, config.IsDebug(), productionAssetManifest) + var staticAssetsFS fs.FS store := cookie.NewStore(secret) // Configure default session cookie options, including expiration (MaxAge) @@ -218,9 +240,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { c.Set("base_path", basePath) }) engine.Use(func(c *gin.Context) { - uri := c.Request.RequestURI + uri := c.Request.URL.Path if strings.HasPrefix(uri, assetsBasePath) { - c.Header("Cache-Control", "max-age=31536000") + assetPath := strings.TrimPrefix(uri, assetsBasePath) + c.Header("Cache-Control", assetRequestCacheControl(uri, assetExists(staticAssetsFS, assetPath))) } }) @@ -236,7 +259,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { } // Register template functions before loading templates funcMap := template.FuncMap{ - "i18n": i18nWebFunc, + "i18n": i18nWebFunc, + "asset": assetResolver.URL, } engine.SetFuncMap(funcMap) engine.Use(locale.LocalizerMiddleware()) @@ -250,7 +274,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { } // Use the registered func map with the loaded templates engine.LoadHTMLFiles(files...) - engine.StaticFS(basePath+"assets", http.FS(os.DirFS("web/assets"))) + staticAssetsFS = os.DirFS("web/assets") + engine.StaticFS(basePath+"assets", http.FS(staticAssetsFS)) } else { // for production template, err := s.getHtmlTemplate(funcMap) @@ -258,7 +283,8 @@ func (s *Server) initRouter() (*gin.Engine, error) { return nil, err } engine.SetHTMLTemplate(template) - engine.StaticFS(basePath+"assets", http.FS(&wrapAssetsFS{FS: assetsFS})) + staticAssetsFS = &wrapAssetsFS{FS: publicAssetsFS, root: "public/assets"} + engine.StaticFS(basePath+"assets", http.FS(staticAssetsFS)) } // Apply the redirect middleware (`/xui` to `/panel`) From cfb169d2fb26d4e8d5d3adff254f425e81d9a86d Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 16:41:55 +0800 Subject: [PATCH 12/17] refactor: resolve template assets through manifest helper --- sub/sub.go | 66 ++++++++++++-- sub/sub_test.go | 89 +++++++++++++++++++ web/asset_manifest_test.go | 12 +++ web/html/common/page.html | 24 ++--- web/html/component/aPersianDatepicker.html | 8 +- web/html/inbounds.html | 10 +-- web/html/settings.html | 6 +- .../settings/panel/subscription/subpage.html | 16 ++-- web/html/xray.html | 30 +++---- web/web.go | 10 +++ 10 files changed, 216 insertions(+), 55 deletions(-) create mode 100644 sub/sub_test.go diff --git a/sub/sub.go b/sub/sub.go index 1dcd9601..7d1fde9f 100644 --- a/sub/sub.go +++ b/sub/sub.go @@ -5,12 +5,15 @@ package sub import ( "context" "crypto/tls" + "encoding/json" + "fmt" "html/template" "io" "io/fs" "net" "net/http" "os" + "path" "path/filepath" "strconv" "strings" @@ -26,6 +29,8 @@ import ( "github.com/gin-gonic/gin" ) +type subscriptionAssetManifest map[string]string + // setEmbeddedTemplates parses and sets embedded templates on the engine func setEmbeddedTemplates(engine *gin.Engine) error { t, err := template.New("").Funcs(engine.FuncMap).ParseFS( @@ -41,6 +46,50 @@ func setEmbeddedTemplates(engine *gin.Engine) error { return nil } +func subscriptionTemplateFuncMap(basePath string, manifest subscriptionAssetManifest) template.FuncMap { + i18nWebFunc := func(key string, params ...string) string { + return locale.I18n(locale.Web, key, params...) + } + assetFunc := func(logical string) string { + target := logical + if manifest != nil { + if hashed, ok := manifest[logical]; ok { + target = hashed + } + } + return path.Join(basePath, "assets", target) + } + return template.FuncMap{ + "i18n": i18nWebFunc, + "asset": assetFunc, + } +} + +func loadSubscriptionAssetManifest() (subscriptionAssetManifest, error) { + if raw, err := os.ReadFile("web/public/assets-manifest.json"); err == nil { + return decodeSubscriptionAssetManifest(raw) + } else if !os.IsNotExist(err) { + return nil, err + } + + return decodeSubscriptionAssetManifest(webpkg.EmbeddedAssetsManifest()) +} + +func decodeSubscriptionAssetManifest(raw []byte) (subscriptionAssetManifest, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("subscription asset manifest is empty") + } + + var manifest subscriptionAssetManifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, err + } + if len(manifest) == 0 { + return nil, fmt.Errorf("subscription asset manifest has no entries") + } + return manifest, nil +} + // Server represents the subscription server that serves subscription links and JSON configurations. type Server struct { httpServer *http.Server @@ -181,11 +230,12 @@ func (s *Server) initRouter() (*gin.Engine, error) { // set per-request localizer from headers/cookies engine.Use(locale.LocalizerMiddleware()) - // register i18n function similar to web server - i18nWebFunc := func(key string, params ...string) string { - return locale.I18n(locale.Web, key, params...) + assetManifest, err := loadSubscriptionAssetManifest() + if err != nil { + return nil, err } - engine.SetFuncMap(map[string]any{"i18n": i18nWebFunc}) + + engine.SetFuncMap(subscriptionTemplateFuncMap(basePath, assetManifest)) // Templates: prefer embedded; fallback to disk if necessary if err := setEmbeddedTemplates(engine); err != nil { @@ -212,10 +262,10 @@ func (s *Server) initRouter() (*gin.Engine, error) { // Mount assets in multiple paths to handle different URL patterns var assetsFS http.FileSystem - if _, err := os.Stat("web/assets"); err == nil { - assetsFS = http.FS(os.DirFS("web/assets")) + if _, err := os.Stat("web/public/assets"); err == nil { + assetsFS = http.FS(os.DirFS("web/public/assets")) } else { - if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { + if subFS, err := fs.Sub(webpkg.EmbeddedPublicAssets(), "public/assets"); err == nil { assetsFS = http.FS(subFS) } else { logger.Error("sub: failed to mount embedded assets:", err) @@ -277,7 +327,7 @@ func (s *Server) getHtmlFiles() ([]string, error) { files = append(files, theme) } // page itself - page := filepath.Join(dir, "web", "html", "subpage.html") + page := filepath.Join(dir, "web", "html", "settings", "panel", "subscription", "subpage.html") if _, err := os.Stat(page); err == nil { files = append(files, page) } else { diff --git a/sub/sub_test.go b/sub/sub_test.go new file mode 100644 index 00000000..c4df2edc --- /dev/null +++ b/sub/sub_test.go @@ -0,0 +1,89 @@ +package sub + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestSubscriptionTemplatesUseAssetHelper(t *testing.T) { + engine := gin.New() + engine.SetFuncMap(subscriptionTemplateFuncMap("/sub/", subscriptionAssetManifest{ + "moment/moment.min.js": "moment/moment.min.12345678.js", + })) + + if err := setEmbeddedTemplates(engine); err != nil { + t.Fatalf("setEmbeddedTemplates() error = %v", err) + } + + recorder := httptest.NewRecorder() + rendered := engine.HTMLRender.Instance("subpage.html", gin.H{ + "title": "subscription.title", + "host": "example.com", + "base_path": "/sub/test-subid/", + }) + + if err := rendered.Render(recorder); err != nil { + t.Fatalf("rendered.Render() error = %v", err) + } + + body := recorder.Body.String() + if !strings.Contains(body, `/sub/assets/moment/moment.min.12345678.js`) { + t.Fatalf("rendered body missing subscription asset path: %s", body) + } +} + +func TestGetHtmlFilesReturnsCurrentSubscriptionTemplatePath(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd() error = %v", err) + } + + tempDir := t.TempDir() + writeTestFile(t, filepath.Join(tempDir, "web", "html", "common", "page.html")) + writeTestFile(t, filepath.Join(tempDir, "web", "html", "component", "aThemeSwitch.html")) + currentTemplatePath := filepath.Join(tempDir, "web", "html", "settings", "panel", "subscription", "subpage.html") + writeTestFile(t, currentTemplatePath) + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("os.Chdir() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(wd); err != nil { + t.Fatalf("restore working directory: %v", err) + } + }) + + server := NewServer() + files, err := server.getHtmlFiles() + if err != nil { + t.Fatalf("getHtmlFiles() error = %v", err) + } + + if !containsPath(files, currentTemplatePath) { + t.Fatalf("getHtmlFiles() missing current subscription template path %q in %v", currentTemplatePath, files) + } +} + +func writeTestFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", path, err) + } + if err := os.WriteFile(path, []byte("test"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } +} + +func containsPath(paths []string, want string) bool { + for _, path := range paths { + if path == want { + return true + } + } + return false +} diff --git a/web/asset_manifest_test.go b/web/asset_manifest_test.go index 20817553..1d06532c 100644 --- a/web/asset_manifest_test.go +++ b/web/asset_manifest_test.go @@ -27,6 +27,18 @@ func TestAssetResolverReturnsLogicalPathInDebug(t *testing.T) { } } +func TestAssetResolverPreservesBasePathWithoutDoubleSlash(t *testing.T) { + resolver := newAssetResolver("/xui/", false, assetManifest{ + "css/custom.min.css": "css/custom.min.11111111.css", + }) + + got := resolver.URL("css/custom.min.css") + want := "/xui/assets/css/custom.min.11111111.css" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + func TestAssetResolverPanicsOnMissingProductionAsset(t *testing.T) { resolver := newAssetResolver("/", false, assetManifest{}) diff --git a/web/html/common/page.html b/web/html/common/page.html index 058682d5..24a0cf03 100644 --- a/web/html/common/page.html +++ b/web/html/common/page.html @@ -6,8 +6,8 @@ - - + + \n '+t+'\n \n \n
\n \n '+n+'\n \n
\n \n \n '},initIframeSrc:function(){this.domain&&(this.getIframeNode().src="javascript:void((function(){\n var d = document;\n d.open();\n d.domain='"+this.domain+"';\n d.write('');\n d.close();\n })())")},initIframe:function(){var e=this.getIframeNode(),t=e.contentWindow,n=void 0;this.domain=this.domain||"",this.initIframeSrc();try{n=t.document}catch(i){this.domain=document.domain,this.initIframeSrc(),n=(t=e.contentWindow).document}n.open("text/html","replace"),n.write(this.getIframeHTML(this.domain)),n.close(),this.getFormInputNode().onchange=this.onChange},endUpload:function(){this.uploading&&(this.file={},this.uploading=!1,this.setState({uploading:!1}),this.initIframe())},startUpload:function(){this.uploading||(this.uploading=!0,this.setState({uploading:!0}))},updateIframeWH:function(){var e=this.$el,t=this.getIframeNode();t.style.height=e.offsetHeight+"px",t.style.width=e.offsetWidth+"px"},abort:function(e){if(e){var t=e;e&&e.uid&&(t=e.uid),t===this.file.uid&&this.endUpload()}else this.endUpload()},post:function(e){var t=this,n=this.getFormNode(),i=this.getFormDataNode(),r=this.$props.data;"function"==typeof r&&(r=r(e));var o=document.createDocumentFragment();for(var a in r)if(r.hasOwnProperty(a)){var s=document.createElement("input");s.setAttribute("name",a),s.value=r[a],o.appendChild(s)}i.appendChild(o),new Promise((function(n){var i=t.action;if("function"==typeof i)return n(i(e));n(i)})).then((function(r){n.setAttribute("action",r),n.submit(),i.innerHTML="",t.$emit("start",e)}))}},mounted:function(){var e=this;this.$nextTick((function(){e.updateIframeWH(),e.initIframe()}))},updated:function(){var e=this;this.$nextTick((function(){e.updateIframeWH()}))},render:function(){var e,t=arguments[0],n=this.$props,i=n.componentTag,o=n.disabled,a=n.prefixCls,s=r()({},O,{display:this.uploading||o?"none":""}),c=f()((e={},l()(e,a,!0),l()(e,a+"-disabled",o),e));return t(i,{attrs:{className:c},style:{position:"relative",zIndex:0}},[t("iframe",{ref:"iframeRef",on:{load:this.onLoad},style:s}),this.$slots.default])}};var M={componentTag:o.a.string,prefixCls:o.a.string,action:o.a.oneOfType([o.a.string,o.a.func]),name:o.a.string,multipart:o.a.bool,directory:o.a.bool,data:o.a.oneOfType([o.a.object,o.a.func]),headers:o.a.object,accept:o.a.string,multiple:o.a.bool,disabled:o.a.bool,beforeUpload:o.a.func,customRequest:o.a.func,method:o.a.string,withCredentials:o.a.bool,supportServerRender:o.a.bool,openFileDialogOnClick:o.a.bool,transformFile:o.a.func},k={name:"Upload",mixins:[s.a],inheritAttrs:!1,props:Object(a.t)(M,{componentTag:"span",prefixCls:"rc-upload",data:{},headers:{},name:"file",multipart:!1,supportServerRender:!1,multiple:!1,beforeUpload:function(){},withCredentials:!1,openFileDialogOnClick:!0}),data:function(){return{Component:null}},mounted:function(){var e=this;this.$nextTick((function(){e.supportServerRender&&e.setState({Component:e.getComponent()},(function(){e.$emit("ready")}))}))},methods:{getComponent:function(){return"undefined"!=typeof File?z:S},abort:function(e){this.$refs.uploaderRef.abort(e)}},render:function(){var e=arguments[0],t={props:r()({},this.$props),on:Object(a.k)(this),ref:"uploaderRef",attrs:this.$attrs};if(this.supportServerRender){var n=this.Component;return n?e(n,t,[this.$slots.default]):null}var i=this.getComponent();return e(i,t,[this.$slots.default])}};t.a=k},function(e,t,n){"use strict";n.r(t);n(20),n(448)},function(e,t,n){"use strict";n.r(t);n(20),n(465)},function(e,t,n){"use strict";n.r(t);n(20),n(445),n(119)},function(e,t,n){"use strict";n.r(t);n(20),n(447),n(160)},function(e,t,n){"use strict";n.r(t);n(20),n(457)},function(e,t,n){e.exports={default:n(242),__esModule:!0}},function(e,t,n){var i=n(244);e.exports=function(e,t,n){if(i(e),void 0===t)return e;switch(n){case 1:return function(n){return e.call(t,n)};case 2:return function(n,i){return e.call(t,n,i)};case 3:return function(n,i,r){return e.call(t,n,i,r)}}return function(){return e.apply(t,arguments)}}},function(e,t,n){e.exports=!n(50)&&!n(85)((function(){return 7!=Object.defineProperty(n(168)("div"),"a",{get:function(){return 7}}).a}))},function(e,t,n){var i=n(84),r=n(48).document,o=i(r)&&i(r.createElement);e.exports=function(e){return o?r.createElement(e):{}}},function(e,t,n){var i=n(60),r=n(69),o=n(249)(!1),a=n(126)("IE_PROTO");e.exports=function(e,t){var n,s=r(e),c=0,l=[];for(n in s)n!=a&&i(s,n)&&l.push(n);for(;t.length>c;)i(s,n=t[c++])&&(~o(l,n)||l.push(n));return l}},function(e,t,n){var i=n(123);e.exports=Object("z").propertyIsEnumerable(0)?Object:function(e){return"String"==i(e)?e.split(""):Object(e)}},function(e,t,n){var i=n(125),r=Math.min;e.exports=function(e){return e>0?r(i(e),9007199254740991):0}},function(e,t,n){"use strict";var i=n(92),r=n(83),o=n(173),a=n(67),s=n(70),c=n(254),l=n(130),u=n(257),h=n(38)("iterator"),d=!([].keys&&"next"in[].keys()),f=function(){return this};e.exports=function(e,t,n,p,v,m,g){c(n,t,p);var b,y,C,x=function(e){if(!d&&e in S)return S[e];switch(e){case"keys":case"values":return function(){return new n(this,e)}}return function(){return new n(this,e)}},z=t+" Iterator",w="values"==v,O=!1,S=e.prototype,M=S[h]||S["@@iterator"]||v&&S[v],k=M||x(v),V=v?w?x("entries"):k:void 0,T="Array"==t&&S.entries||M;if(T&&(C=u(T.call(new e)))!==Object.prototype&&C.next&&(l(C,z,!0),i||"function"==typeof C[h]||a(C,h,f)),w&&M&&"values"!==M.name&&(O=!0,k=function(){return M.call(this)}),i&&!g||!d&&!O&&S[h]||a(S,h,k),s[t]=k,s[z]=f,v)if(b={values:w?k:x("values"),keys:m?k:x("keys"),entries:V},g)for(y in b)y in S||o(S,y,b[y]);else r(r.P+r.F*(d||O),t,b);return b}},function(e,t,n){e.exports=n(67)},function(e,t,n){var i=n(68),r=n(255),o=n(128),a=n(126)("IE_PROTO"),s=function(){},c=function(){var e,t=n(168)("iframe"),i=o.length;for(t.style.display="none",n(256).appendChild(t),t.src="javascript:",(e=t.contentWindow.document).open(),e.write("