diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0d08e261..e9b37e82 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,11 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3185ec05 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + go-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Stub web/dist for go:embed + run: mkdir -p web/dist && touch web/dist/.gitkeep + - name: Test + run: | + go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt + go test $(cat /tmp/go-packages.txt) + + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Stub web/dist for go:embed + run: mkdir -p web/dist && touch web/dist/.gitkeep + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run govulncheck + run: govulncheck ./... + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install + run: npm ci + working-directory: frontend + - name: Lint + run: npm run lint + working-directory: frontend + - name: Build + run: npm run build + working-directory: frontend + - name: Audit + run: npm audit --audit-level=high + working-directory: frontend diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/database/model/model.go b/database/model/model.go index 56a76b6e..2d44d104 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -21,12 +21,8 @@ const ( Shadowsocks Protocol = "shadowsocks" Mixed Protocol = "mixed" WireGuard Protocol = "wireguard" - // UI stores Hysteria v1 and v2 both as "hysteria" and uses - // settings.version to discriminate. Imports from outside the panel - // can carry the literal "hysteria2" string, so IsHysteria below - // accepts both. - Hysteria Protocol = "hysteria" - Hysteria2 Protocol = "hysteria2" + Hysteria Protocol = "hysteria" + Hysteria2 Protocol = "hysteria2" ) // IsHysteria returns true for both "hysteria" and "hysteria2". @@ -38,9 +34,10 @@ func IsHysteria(p Protocol) bool { // User represents a user account in the 3x-ui panel. type User struct { - Id int `json:"id" gorm:"primaryKey;autoIncrement"` - Username string `json:"username"` - Password string `json:"password"` + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Username string `json:"username"` + Password string `json:"password"` + LoginEpoch int64 `json:"-" gorm:"default:0"` } // Inbound represents an Xray inbound configuration with traffic statistics and settings. @@ -66,12 +63,7 @@ type Inbound struct { StreamSettings string `json:"streamSettings" form:"streamSettings"` Tag string `json:"tag" form:"tag" gorm:"unique"` Sniffing string `json:"sniffing" form:"sniffing"` - - // NodeID points at the remote panel (Node) where this inbound's xray - // actually runs. NULL means the inbound runs on the local xray (the - // pre-multi-node behaviour). Existing rows migrate to NULL with no - // backfill. - NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` + NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"` } // OutboundTraffics tracks traffic statistics for Xray outbound connections. @@ -128,15 +120,16 @@ type Setting struct { // endpoint over HTTP using the per-node ApiToken to populate the runtime // status fields below. type Node struct { - Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" form:"name" gorm:"uniqueIndex"` - Remark string `json:"remark" form:"remark"` - Scheme string `json:"scheme" form:"scheme"` - Address string `json:"address" form:"address"` - Port int `json:"port" form:"port"` - BasePath string `json:"basePath" form:"basePath"` - ApiToken string `json:"apiToken" form:"apiToken"` - Enable bool `json:"enable" form:"enable" gorm:"default:true"` + Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" form:"name" gorm:"uniqueIndex"` + Remark string `json:"remark" form:"remark"` + Scheme string `json:"scheme" form:"scheme"` + Address string `json:"address" form:"address"` + Port int `json:"port" form:"port"` + BasePath string `json:"basePath" form:"basePath"` + ApiToken string `json:"apiToken" form:"apiToken"` + Enable bool `json:"enable" form:"enable" gorm:"default:true"` + AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` // Heartbeat-updated fields. UpdatedAt advances on every probe even when // the row is otherwise unchanged so the UI's "last seen" tooltip is diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bfbce76..365a2324 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "3x-ui-frontend", "version": "0.0.2", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + }, "dependencies": { "@ant-design/icons-vue": "^7.0.1", "ant-design-vue": "^4.2.6", diff --git a/frontend/package.json b/frontend/package.json index bf0ca9c1..03c381f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,10 @@ "version": "0.0.2", "type": "module", "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index cae11195..95f5c689 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -2,24 +2,16 @@ import axios from 'axios'; import qs from 'qs'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); -// Public CSRF endpoint — works pre-login (the panel-scoped -// /panel/csrf-token sits behind checkLogin and would 401 a fresh -// login page that hasn't authenticated yet). const CSRF_TOKEN_PATH = '/csrf-token'; -// Cached session CSRF token. The legacy panel injects it via a -// tag rendered by Go; the new SPA pages -// fetch it once from /panel/csrf-token instead. Module-level so -// every axios POST sees the latest value. let csrfToken = null; let csrfFetchPromise = null; +let sessionExpired = false; function readMetaToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; } -// Fetch the token via a bare fetch() (not axios) so the call doesn't -// recurse through this same interceptor. async function fetchCsrfToken() { try { const basePath = window.X_UI_BASE_PATH; @@ -91,19 +83,16 @@ export function setupAxios() { async (error) => { const status = error.response?.status; if (status === 401) { - // 401 → session is gone. In production, the panel routes - // are gated by Go's checkLogin which redirects to base_path - // serving the login page; a reload is enough. In dev, Vite - // serves /index.html directly at "/", so a reload would put - // the user right back on the dashboard and the interceptor - // would loop. Navigate to the dev login entry instead. - if (import.meta.env.DEV) { - const basePath = window.X_UI_BASE_PATH || '/'; - window.location.href = `${basePath}login.html`; - } else { - window.location.reload(); + if (!sessionExpired) { + sessionExpired = true; + if (import.meta.env.DEV) { + const basePath = window.X_UI_BASE_PATH || '/'; + window.location.href = `${basePath}login.html`; + } else { + window.location.reload(); + } } - return Promise.reject(error); + return new Promise(() => { }); } // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. const cfg = error.config; diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 7763b715..bf625f65 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -14,6 +14,7 @@ import { } from '@ant-design/icons-vue'; import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js'; +import { HttpUtil } from '@/utils'; const { t } = useI18n(); @@ -45,7 +46,7 @@ const tabs = computed(() => [ { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') }, - { key: `${prefix}logout`, icon: 'logout', title: t('logout') }, + { key: 'logout', icon: 'logout', title: t('logout') }, ]); const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout')); @@ -55,7 +56,12 @@ const drawerOpen = ref(false); const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false')); const drawerWidth = 'min(82vw, 320px)'; -function openLink(key) { +async function openLink(key) { + if (key === 'logout') { + await HttpUtil.post('/logout'); + window.location.href = props.basePath || '/'; + return; + } if (key.startsWith('http')) { window.open(key); } else { diff --git a/frontend/src/models/setting.js b/frontend/src/models/setting.js index 7efc9e7d..1f465036 100644 --- a/frontend/src/models/setting.js +++ b/frontend/src/models/setting.js @@ -15,6 +15,7 @@ export class AllSetting { this.webKeyFile = ""; this.webBasePath = "/"; this.sessionMaxAge = 360; + this.trustedProxyCIDRs = "127.0.0.1/32,::1/128"; this.pageSize = 25; this.expireDiff = 0; this.trafficDiff = 0; @@ -87,6 +88,12 @@ export class AllSetting { this.ldapDefaultTotalGB = 0; this.ldapDefaultExpiryDays = 0; this.ldapDefaultLimitIP = 0; + this.hasTgBotToken = false; + this.hasTwoFactorToken = false; + this.hasLdapPassword = false; + this.hasApiToken = false; + this.hasWarpSecret = false; + this.hasNordSecret = false; if (data == null) { return @@ -97,4 +104,4 @@ export class AllSetting { equals(other) { return ObjectUtil.equals(this, other); } -} \ No newline at end of file +} diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 07476cf0..c92ee389 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -46,9 +46,10 @@ export const sections = [ '{\n "success": false,\n "msg": "Wrong username or password"\n}', }, { - method: 'GET', + method: 'POST', path: '/logout', - summary: 'Clear the session cookie. Redirects back to the login page; not useful from non-browser clients.', + summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.', + response: '{\n "success": true\n}', }, { method: 'GET', @@ -70,7 +71,7 @@ export const sections = [ id: 'inbounds', title: 'Inbounds API', description: - 'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.', + 'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.', endpoints: [ { method: 'GET', @@ -627,7 +628,7 @@ export const sections = [ description: 'Operations that interact with the configured Telegram bot.', endpoints: [ { - method: 'GET', + method: 'POST', path: '/panel/api/backuptotgbot', summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.', }, diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index 38465818..cface361 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -67,9 +67,29 @@ const emit = defineEmits([ ]); // ============ Toolbar / search & filter ============================= -const enableFilter = ref(false); -const searchKey = ref(''); -const filterBy = ref(''); +const FILTER_STATE_KEY = 'inboundsFilterState'; +const savedFilterState = (() => { + try { + return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}'); + } catch (_e) { + return {}; + } +})(); +const enableFilter = ref(!!savedFilterState.enableFilter); +const searchKey = ref(savedFilterState.searchKey || ''); +const filterBy = ref(savedFilterState.filterBy || ''); +const protocolFilter = ref(savedFilterState.protocolFilter || ''); +const nodeFilter = ref(savedFilterState.nodeFilter || ''); + +watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => { + localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ + enableFilter: enableFilter.value, + searchKey: searchKey.value, + filterBy: filterBy.value, + protocolFilter: protocolFilter.value, + nodeFilter: nodeFilter.value, + })); +}); // Toggle the filter mode — flip cleans the other input. function onToggleFilter() { @@ -77,6 +97,35 @@ function onToggleFilter() { else filterBy.value = ''; } +const protocolOptions = computed(() => { + const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean)); + return [...values].sort(); +}); + +const nodeOptions = computed(() => { + const values = new Map(); + if (props.dbInbounds.some((i) => i.nodeId == null)) { + values.set('local', t('pages.inbounds.localPanel')); + } + for (const dbInbound of props.dbInbounds) { + if (dbInbound.nodeId == null) continue; + const node = props.nodesById.get(dbInbound.nodeId); + values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`); + } + return [...values.entries()].map(([value, label]) => ({ value, label })); +}); + +function applySecondaryFilters(rows) { + return rows.filter((dbInbound) => { + if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false; + if (nodeFilter.value) { + const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId); + if (nodeValue !== nodeFilter.value) return false; + } + return true; + }); +} + // ============ Search / filter projection ============================= // Mirrors the legacy logic: when searching, keep inbounds that match // anywhere (deep search); when filtering, keep inbounds that have at @@ -99,7 +148,7 @@ function projectInbound(dbInbound, predicate) { const visibleInbounds = computed(() => { if (enableFilter.value) { - if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds]; + if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]); const out = []; for (const dbInbound of props.dbInbounds) { const c = props.clientCount[dbInbound.id]; @@ -107,15 +156,15 @@ const visibleInbounds = computed(() => { const list = c[filterBy.value]; out.push(projectInbound(dbInbound, (client) => list.includes(client.email))); } - return out; + return applySecondaryFilters(out); } - if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds]; + if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]); const out = []; for (const dbInbound of props.dbInbounds) { if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue; out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value))); } - return out; + return applySecondaryFilters(out); }); // ============ Sorting ================================================= @@ -319,6 +368,18 @@ function showQrCodeMenu(dbInbound) { {{ t('depletingSoon') }} {{ t('online') }} + + + {{ protocol }} + + + + + {{ node.label }} + + diff --git a/frontend/src/pages/nodes/NodeFormModal.vue b/frontend/src/pages/nodes/NodeFormModal.vue index 2a585e19..720f4e8b 100644 --- a/frontend/src/pages/nodes/NodeFormModal.vue +++ b/frontend/src/pages/nodes/NodeFormModal.vue @@ -28,6 +28,7 @@ function defaultForm() { basePath: '/', apiToken: '', enable: true, + allowPrivateAddress: false, }; } @@ -69,6 +70,7 @@ function buildPayload() { basePath: form.basePath?.trim() || '/', apiToken: form.apiToken?.trim() || '', enable: !!form.enable, + allowPrivateAddress: !!form.allowPrivateAddress, }; } @@ -161,6 +163,11 @@ async function onSave() { + + + Enable only for nodes on a private network or VPN. + + {{ t('pages.nodes.apiTokenHint') }} diff --git a/frontend/src/pages/settings/GeneralTab.vue b/frontend/src/pages/settings/GeneralTab.vue index 2d85312b..75957277 100644 --- a/frontend/src/pages/settings/GeneralTab.vue +++ b/frontend/src/pages/settings/GeneralTab.vue @@ -153,6 +153,14 @@ onMounted(loadInboundTags); + + Trusted proxy CIDRs + Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers. + + + + + {{ t('pages.settings.pageSize') }} {{ t('pages.settings.pageSizeDesc') }} @@ -298,8 +306,12 @@ onMounted(loadInboundTags); {{ t('password') }} + + {{ allSetting.hasLdapPassword ? 'Configured; leave blank to keep current password.' : 'Not configured.' }} + - + diff --git a/frontend/src/pages/settings/SecurityTab.vue b/frontend/src/pages/settings/SecurityTab.vue index d841c787..bb9a2f73 100644 --- a/frontend/src/pages/settings/SecurityTab.vue +++ b/frontend/src/pages/settings/SecurityTab.vue @@ -52,10 +52,9 @@ async function sendUpdateUser() { try { const msg = await HttpUtil.post('/panel/setting/updateUser', user); if (msg?.success) { - // Force re-login at the standard logout path; basePath is handled - // by the Go router so a relative redirect is correct here. - const basePath = window.X_UI_BASE_PATH || ''; - window.location.replace(`${basePath}logout`); + await HttpUtil.post('/logout'); + const basePath = window.X_UI_BASE_PATH || '/'; + window.location.replace(basePath); } } finally { updating.value = false; diff --git a/frontend/src/pages/settings/TelegramTab.vue b/frontend/src/pages/settings/TelegramTab.vue index ce82350f..15111307 100644 --- a/frontend/src/pages/settings/TelegramTab.vue +++ b/frontend/src/pages/settings/TelegramTab.vue @@ -23,9 +23,12 @@ defineProps({ {{ t('pages.settings.telegramToken') }} - {{ t('pages.settings.telegramTokenDesc') }} + + {{ allSetting.hasTgBotToken ? 'Configured; leave blank to keep current token.' : t('pages.settings.telegramTokenDesc') }} + - + diff --git a/frontend/src/pages/settings/TwoFactorModal.vue b/frontend/src/pages/settings/TwoFactorModal.vue index 51a0afea..d0b5819a 100644 --- a/frontend/src/pages/settings/TwoFactorModal.vue +++ b/frontend/src/pages/settings/TwoFactorModal.vue @@ -38,18 +38,24 @@ function buildTotp() { watch(() => props.open, (next) => { if (!next) return; enteredCode.value = ''; + totp = null; + qrValue.value = ''; if (props.token) { buildTotp(); } }); -function close(success) { - emit('confirm', success); +function close(success, code = '') { + emit('confirm', success, code); emit('update:open', false); enteredCode.value = ''; } function onOk() { + if (props.type === 'confirm' && !props.token) { + close(true, enteredCode.value); + return; + } if (!totp) return; if (totp.generate() === enteredCode.value) { close(true); diff --git a/frontend/src/pages/sub/SubPage.vue b/frontend/src/pages/sub/SubPage.vue index e09fe63e..33df819a 100644 --- a/frontend/src/pages/sub/SubPage.vue +++ b/frontend/src/pages/sub/SubPage.vue @@ -40,7 +40,6 @@ const subUrl = subData.subUrl || ''; const subJsonUrl = subData.subJsonUrl || ''; const subClashUrl = subData.subClashUrl || ''; const subTitle = subData.subTitle || ''; -const subSupportUrl = subData.subSupportUrl || ''; const links = Array.isArray(subData.links) ? subData.links : []; // Panel's "Calendar Type" setting; controls whether expiry / lastOnline // render in Gregorian or Jalali on this standalone subscription page. diff --git a/frontend/src/pages/xray/DnsPresetsModal.vue b/frontend/src/pages/xray/DnsPresetsModal.vue index 666e02fd..6067d0cd 100644 --- a/frontend/src/pages/xray/DnsPresetsModal.vue +++ b/frontend/src/pages/xray/DnsPresetsModal.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'; const { t } = useI18n(); -const props = defineProps({ +defineProps({ open: { type: Boolean, default: false }, }); diff --git a/web/controller/api.go b/web/controller/api.go index 219632d5..9344541a 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -29,25 +29,11 @@ func NewAPIController(g *gin.RouterGroup, customGeo *service.CustomGeoService) * return a } -// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests -// to hide the existence of API endpoints from unauthorized users. -// -// Two auth paths are accepted: -// 1. Authorization: Bearer — used by remote central panels -// polling this instance as a node. Matches via constant-time compare. -// Sets c.Set("api_authed", true) so CSRFMiddleware can short-circuit. -// 2. Existing session cookie — used by browsers logged into the panel UI. -// -// Anything else falls through to a 404 so the API endpoints remain hidden. func (a *APIController) checkAPIAuth(c *gin.Context) { auth := c.GetHeader("Authorization") if strings.HasPrefix(auth, "Bearer ") { tok := strings.TrimPrefix(auth, "Bearer ") if a.settingService.MatchApiToken(tok) { - // Handlers like InboundController.addInbound assume a logged-in - // user (inbound.UserId = user.Id). Bearer callers have no - // session, so attach the first user as a fallback. Single-user - // panels are the norm here. if u, err := a.userService.GetFirstUser(); err == nil { session.SetAPIAuthUser(c, u) } @@ -57,7 +43,11 @@ func (a *APIController) checkAPIAuth(c *gin.Context) { } } if !session.IsLogin(c) { - c.AbortWithStatus(http.StatusNotFound) + if c.GetHeader("X-Requested-With") == "XMLHttpRequest" { + c.AbortWithStatus(http.StatusUnauthorized) + } else { + c.AbortWithStatus(http.StatusNotFound) + } return } c.Next() @@ -85,7 +75,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom NewCustomGeoController(api.Group("/custom-geo"), customGeo) // Extra routes - api.GET("/backuptotgbot", a.BackuptoTgbot) + api.POST("/backuptotgbot", a.BackuptoTgbot) } // BackuptoTgbot sends a backup of the panel data to Telegram bot admins. diff --git a/web/controller/dist.go b/web/controller/dist.go index 51bd3574..fd1b35a9 100644 --- a/web/controller/dist.go +++ b/web/controller/dist.go @@ -57,7 +57,11 @@ func serveDistPage(c *gin.Context, name string) { } csrfMeta := []byte(``) - script := `