diff --git a/public/tools/token_lab/auth_config.json b/public/tools/token_lab/auth_config.json
new file mode 100644
index 0000000..33aba31
--- /dev/null
+++ b/public/tools/token_lab/auth_config.json
@@ -0,0 +1,5 @@
+{
+ "_comment": "令牌工坊默认走导航登录,不对公网开放",
+ "enable_auth": true,
+ "max_age_days": 30
+}
diff --git a/public/tools/token_lab/index.html b/public/tools/token_lab/index.html
new file mode 100644
index 0000000..1ee774f
--- /dev/null
+++ b/public/tools/token_lab/index.html
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+ 令牌工坊
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
验证首页导航令牌
+
+ 可以粘贴完整链接或纯 token,快速看签名、过期时间、剩余时长和载荷字段。
+
+
+
+
+
+
+ 详细输出
+
+
+
+
+
+
+
+
+
+
+
+
+
验证 Tool 令牌
+
+ 粘贴旧链接或旧 token,马上就能看出 alias、到期时间、剩余时长,以及当前密钥是否验得过。
+
+
+
+
+
+
+ 详细输出
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/token_lab/token_lab.css b/public/tools/token_lab/token_lab.css
new file mode 100644
index 0000000..e6dace4
--- /dev/null
+++ b/public/tools/token_lab/token_lab.css
@@ -0,0 +1,356 @@
+* {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ min-height: 100%;
+ background: linear-gradient(180deg, #091121 0%, #111a2f 100%);
+ color: #eef2ff;
+ font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
+}
+
+body {
+ padding: 20px 14px 32px;
+}
+
+.page-shell {
+ width: min(1240px, 100%);
+ margin: 0 auto;
+}
+
+.hero {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 24px;
+ background: rgba(10, 17, 34, 0.78);
+ backdrop-filter: blur(18px);
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
+}
+
+.hero-side {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 12px;
+}
+
+.eyebrow {
+ color: #8ea4ff;
+ font-size: 13px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.hero h1 {
+ margin: 8px 0 10px;
+ font-size: 34px;
+}
+
+.hero p {
+ margin: 0;
+ line-height: 1.7;
+ color: #b8c2de;
+}
+
+.notice {
+ min-width: 240px;
+ padding: 14px 16px;
+ border-radius: 16px;
+ background: rgba(67, 56, 202, 0.18);
+ border: 1px solid rgba(129, 140, 248, 0.35);
+ color: #dbe4ff;
+ line-height: 1.6;
+}
+
+.notice.error {
+ background: rgba(127, 29, 29, 0.24);
+ border-color: rgba(248, 113, 113, 0.38);
+ color: #fee2e2;
+}
+
+.notice.success {
+ background: rgba(20, 83, 45, 0.24);
+ border-color: rgba(74, 222, 128, 0.35);
+ color: #dcfce7;
+}
+
+.tab-nav {
+ display: flex;
+ gap: 10px;
+ margin-top: 16px;
+}
+
+.auth-screen {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ min-height: calc(100vh - 40px);
+}
+
+.auth-card {
+ width: min(320px, calc(100vw - 28px));
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 0;
+}
+
+.auth-hint {
+ min-height: 20px;
+ padding-left: 2px;
+ color: #c7d2fe;
+ line-height: 1.5;
+ font-size: 13px;
+}
+
+.auth-hint.error {
+ color: #fecaca;
+}
+
+.auth-hint.success {
+ color: #bbf7d0;
+}
+
+.tab-btn {
+ border: 0;
+ border-radius: 999px;
+ padding: 12px 18px;
+ background: rgba(255, 255, 255, 0.08);
+ color: #d8def5;
+ font-size: 15px;
+ cursor: pointer;
+}
+
+.tab-btn.active {
+ background: linear-gradient(135deg, #6f7bf7 0%, #9b71f6 100%);
+ color: #fff;
+ box-shadow: 0 10px 24px rgba(111, 123, 247, 0.32);
+}
+
+.tab-panel {
+ display: none;
+ margin-top: 16px;
+}
+
+.tab-panel.active {
+ display: block;
+}
+
+.layout-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.card {
+ padding: 22px;
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(8, 13, 27, 0.8);
+ box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22);
+}
+
+.card-head h2 {
+ margin: 0;
+ font-size: 22px;
+}
+
+.card-desc {
+ margin: 10px 0 18px;
+ color: #b2bfdc;
+ line-height: 1.7;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+
+.field-span-2 {
+ grid-column: span 2;
+}
+
+.field > span {
+ font-size: 14px;
+ color: #c9d3ef;
+}
+
+.input, .textarea, .result-box, select.input {
+ width: 100%;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 16px;
+ background: rgba(16, 22, 40, 0.92);
+ color: #eff3ff;
+ padding: 12px 14px;
+ font: inherit;
+}
+
+.textarea, .result-box {
+ resize: vertical;
+}
+
+.textarea.mono {
+ white-space: pre;
+ word-break: normal;
+ overflow-wrap: normal;
+ overflow-x: auto;
+}
+
+.result-box {
+ min-height: 120px;
+ max-height: 280px;
+ overflow: auto;
+ margin: 0;
+ white-space: pre;
+ word-break: normal;
+ overflow-wrap: normal;
+ line-height: 1.7;
+}
+
+.raw-details {
+ margin-top: 6px;
+}
+
+.raw-details > summary {
+ cursor: pointer;
+ color: #c9d3ef;
+ font-size: 14px;
+ list-style: none;
+ user-select: none;
+}
+
+.raw-details > summary::-webkit-details-marker {
+ display: none;
+}
+
+.raw-details > summary::before {
+ content: '展开';
+ display: inline-block;
+ margin-right: 8px;
+ color: #8ea4ff;
+}
+
+.raw-details[open] > summary::before {
+ content: '收起';
+}
+
+.raw-details .result-box {
+ margin-top: 10px;
+}
+
+.mono {
+ font-family: "Cascadia Mono", Consolas, monospace;
+ font-size: 13px;
+}
+
+.actions {
+ display: flex;
+ gap: 10px;
+ margin: 6px 0 14px;
+}
+
+.actions.wrap {
+ flex-wrap: wrap;
+}
+
+.btn {
+ border: 0;
+ border-radius: 14px;
+ padding: 11px 16px;
+ background: rgba(255, 255, 255, 0.08);
+ color: #eef2ff;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.btn:hover {
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #6f7bf7 0%, #7c55f2 100%);
+}
+
+.auth-submit {
+ width: 100%;
+}
+
+.btn-primary:hover {
+ background: linear-gradient(135deg, #7d88fb 0%, #8c62f8 100%);
+}
+
+.result-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.kv {
+ padding: 12px 14px;
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.kv .k {
+ display: block;
+ font-size: 12px;
+ color: #96a5ca;
+ margin-bottom: 6px;
+}
+
+.kv .v {
+ display: block;
+ line-height: 1.5;
+ white-space: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ word-break: normal;
+}
+
+.kv.good .v {
+ color: #86efac;
+}
+
+.kv.bad .v {
+ color: #fca5a5;
+}
+
+@media (max-width: 980px) {
+ .layout-grid,
+ .form-grid,
+ .result-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .field-span-2 {
+ grid-column: span 1;
+ }
+
+ .hero {
+ flex-direction: column;
+ }
+
+ .hero-side {
+ align-items: stretch;
+ }
+}
diff --git a/public/tools/token_lab/token_lab.js b/public/tools/token_lab/token_lab.js
new file mode 100644
index 0000000..61bf5bd
--- /dev/null
+++ b/public/tools/token_lab/token_lab.js
@@ -0,0 +1,814 @@
+(() => {
+ const $ = (sel) => document.querySelector(sel)
+ const $$ = (sel) => Array.from(document.querySelectorAll(sel))
+
+ const STORAGE_PRESETS = 'token_lab.tool_presets.v1'
+ const STORAGE_CURRENT_PRESET = 'token_lab.tool_current_preset.v1'
+
+ const state = {
+ toolPresets: [],
+ currentPresetId: '',
+ toolKeys: null,
+ authenticated: false
+ }
+
+ const el = {
+ workshopShell: $('#workshopShell'),
+ notice: $('#globalNotice'),
+ tabButtons: $$('.tab-btn'),
+ tabPanels: $$('.tab-panel'),
+ authScreen: $('#authScreen'),
+ toolTabs: $('#toolTabs'),
+ appMain: $('#appMain'),
+ authUsername: $('#authUsername'),
+ authPassword: $('#authPassword'),
+ authHint: $('#authHint'),
+ btnAuthLogout: $('#btnAuthLogout'),
+
+ navBaseUrl: $('#navBaseUrl'),
+ navExpiry: $('#navExpiry'),
+ navLinkOutput: $('#navLinkOutput'),
+ navGenerateMeta: $('#navGenerateMeta'),
+ navVerifyInput: $('#navVerifyInput'),
+ navVerifyMeta: $('#navVerifyMeta'),
+ navVerifyRaw: $('#navVerifyRaw'),
+
+ toolPresetSelect: $('#toolPresetSelect'),
+ toolPresetName: $('#toolPresetName'),
+ toolBaseUrl: $('#toolBaseUrl'),
+ toolAlias: $('#toolAlias'),
+ toolExpiry: $('#toolExpiry'),
+ toolPassword: $('#toolPassword'),
+ toolXmlFile: $('#toolXmlFile'),
+ toolPublicJwk: $('#toolPublicJwk'),
+ toolLinkOutput: $('#toolLinkOutput'),
+ toolKeyMeta: $('#toolKeyMeta'),
+ toolGenerateMeta: $('#toolGenerateMeta'),
+ toolVerifyInput: $('#toolVerifyInput'),
+ toolVerifyMeta: $('#toolVerifyMeta'),
+ toolVerifyRaw: $('#toolVerifyRaw')
+ }
+
+ function setNotice(message, type = '') {
+ if (!message) {
+ el.notice.hidden = true
+ el.notice.className = 'notice'
+ el.notice.textContent = ''
+ return
+ }
+ el.notice.hidden = false
+ el.notice.className = `notice ${type}`.trim()
+ el.notice.textContent = message
+ if (type === 'success') {
+ window.clearTimeout(setNotice._timer)
+ setNotice._timer = window.setTimeout(() => {
+ if (el.notice.textContent === message) setNotice('')
+ }, 1800)
+ }
+ }
+
+ function setAuthHint(message, type = '') {
+ el.authHint.textContent = String(message || '')
+ el.authHint.className = `auth-hint ${type}`.trim()
+ }
+
+ function applyAuthUi() {
+ el.authScreen.hidden = state.authenticated
+ el.workshopShell.hidden = !state.authenticated
+ el.toolTabs.hidden = !state.authenticated
+ el.appMain.hidden = !state.authenticated
+ el.btnAuthLogout.hidden = !state.authenticated
+ if (!state.authenticated) {
+ el.authPassword.value = ''
+ setNotice('')
+ }
+ }
+
+ async function fetchJson(url, options) {
+ const resp = await fetch(url, options)
+ let data = null
+ try {
+ data = await resp.json()
+ } catch {
+ data = null
+ }
+ if (resp.status === 401) {
+ state.authenticated = false
+ applyAuthUi()
+ }
+ if (!resp.ok || !data || data.ok === false) {
+ const error = data && data.error ? data.error : `http_${resp.status}`
+ throw new Error(error)
+ }
+ return data
+ }
+
+ async function refreshAuthStatus() {
+ try {
+ const data = await fetchJson('/api/token_lab/auth/status')
+ state.authenticated = !!data.authenticated
+ applyAuthUi()
+ } catch (err) {
+ state.authenticated = false
+ applyAuthUi()
+ }
+ }
+
+ async function handleAuthLogin() {
+ try {
+ const username = String(el.authUsername.value || '').trim()
+ const password = String(el.authPassword.value || '')
+ if (!username || !password) {
+ setAuthHint('请输入账号和密码', 'error')
+ return
+ }
+ await fetchJson('/api/token_lab/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ })
+ state.authenticated = true
+ el.authUsername.value = ''
+ el.authPassword.value = ''
+ setAuthHint('')
+ applyAuthUi()
+ setNotice('令牌工坊登录成功。', 'success')
+ } catch (err) {
+ state.authenticated = false
+ applyAuthUi()
+ setAuthHint('登录失败', 'error')
+ }
+ }
+
+ async function handleAuthLogout() {
+ try {
+ await fetchJson('/api/token_lab/auth/logout', { method: 'POST' })
+ state.authenticated = false
+ el.authUsername.value = ''
+ el.authPassword.value = ''
+ applyAuthUi()
+ setAuthHint('')
+ setNotice('令牌工坊已退出登录。', 'success')
+ } catch (err) {
+ setNotice(`退出登录失败:${err.message || err}`, 'error')
+ }
+ }
+
+ function safeJsonParse(text, fallback) {
+ try {
+ return JSON.parse(text)
+ } catch {
+ return fallback
+ }
+ }
+
+ function toDatetimeLocalValue(date) {
+ const pad = (n) => String(n).padStart(2, '0')
+ const d = new Date(date)
+ const yyyy = d.getFullYear()
+ const mm = pad(d.getMonth() + 1)
+ const dd = pad(d.getDate())
+ const hh = pad(d.getHours())
+ const mi = pad(d.getMinutes())
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}`
+ }
+
+ function epochFromDatetimeLocal(value) {
+ if (!value) return 0
+ const ms = new Date(value).getTime()
+ if (!Number.isFinite(ms)) return 0
+ return Math.floor(ms / 1000)
+ }
+
+ function formatEpoch(epochSeconds) {
+ if (!epochSeconds) return '-'
+ try {
+ return new Intl.DateTimeFormat('zh-CN', {
+ timeZone: 'Asia/Shanghai',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ }).format(new Date(epochSeconds * 1000))
+ } catch {
+ return new Date(epochSeconds * 1000).toLocaleString()
+ }
+ }
+
+ function formatRelative(exp) {
+ if (!exp) return '-'
+ const diff = exp - Math.floor(Date.now() / 1000)
+ const abs = Math.abs(diff)
+ const days = Math.floor(abs / 86400)
+ const hours = Math.floor((abs % 86400) / 3600)
+ const mins = Math.floor((abs % 3600) / 60)
+ const text = `${days}天 ${hours}小时 ${mins}分钟`
+ return diff >= 0 ? `剩余 ${text}` : `已过期 ${text}`
+ }
+
+ function base64UrlFromBytes(bytes) {
+ let binary = ''
+ const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
+ for (let i = 0; i < arr.length; i += 1) binary += String.fromCharCode(arr[i])
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
+ }
+
+ function bytesFromBase64Url(str) {
+ const normalized = String(str || '').replace(/-/g, '+').replace(/_/g, '/')
+ const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
+ const binary = atob(padded)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
+ return bytes
+ }
+
+ function bytesFromBase64(str) {
+ const binary = atob(String(str || ''))
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
+ return bytes
+ }
+
+ function base64FromBytes(bytes) {
+ let binary = ''
+ const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
+ for (let i = 0; i < arr.length; i += 1) binary += String.fromCharCode(arr[i])
+ return btoa(binary)
+ }
+
+ function encodeUtf8(text) {
+ return new TextEncoder().encode(String(text || ''))
+ }
+
+ function decodeJwtPart(part) {
+ try {
+ return JSON.parse(new TextDecoder().decode(bytesFromBase64Url(part)))
+ } catch {
+ return null
+ }
+ }
+
+ function extractToken(value) {
+ const text = String(value || '').trim()
+ if (!text) return ''
+ if (/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(text)) return text
+ try {
+ const url = new URL(text)
+ const token = String(url.searchParams.get('token') || '').trim()
+ if (token) return token
+ } catch {}
+ const match = text.match(/(?:^|[?&])token=([^\s]+)/)
+ return match && match[1] ? decodeURIComponent(match[1]) : ''
+ }
+
+ function appendTokenToUrl(baseUrl, token) {
+ const base = String(baseUrl || '').trim()
+ if (!base) return `?token=${token}`
+ return base.includes('?') ? `${base}&token=${token}` : `${base}?token=${token}`
+ }
+
+ function createKvHtml(items) {
+ return items.map((item) => {
+ const status = item.status ? ` ${item.status}` : ''
+ return `${escapeHtml(item.label)}${escapeHtml(item.value)}
`
+ }).join('')
+ }
+
+ function createVerifyDebugJson(payload) {
+ return JSON.stringify(payload, null, 2)
+ }
+
+ function deriveToolAlias() {
+ const preset = currentPreset()
+ const fromName = String(el.toolPresetName.value || '').trim()
+ if (fromName) return fromName
+ if (preset && preset.name) return String(preset.name).trim()
+ return 'tool-token'
+ }
+
+ function scrollToolGenerateIntoView() {
+ try {
+ el.toolLinkOutput.scrollIntoView({ block: 'center', behavior: 'smooth' })
+ } catch {}
+ }
+
+ function escapeHtml(value) {
+ return String(value == null ? '' : value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ }
+
+ function switchTab(name) {
+ el.tabButtons.forEach((btn) => btn.classList.toggle('active', btn.dataset.tab === name))
+ el.tabPanels.forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === name))
+ }
+
+ function defaultPresets() {
+ const origin = window.location.origin || ''
+ return [
+ {
+ id: 'expense',
+ name: 'expense',
+ baseUrl: `${origin}/tools/expense`,
+ privateXml: ''
+ },
+ {
+ id: 'ai-lib',
+ name: 'ai-lib',
+ baseUrl: `${origin}/tools/ai-lib`,
+ privateXml: ''
+ }
+ ]
+ }
+
+ function loadPresets() {
+ const stored = safeJsonParse(localStorage.getItem(STORAGE_PRESETS) || '', null)
+ const presets = Array.isArray(stored) && stored.length ? stored : defaultPresets()
+ state.toolPresets = presets.map((item, index) => ({
+ id: String(item.id || item.name || `preset-${index + 1}`),
+ name: String(item.name || `preset-${index + 1}`),
+ baseUrl: String(item.baseUrl || ''),
+ privateXml: String(item.privateXml || '')
+ }))
+ state.currentPresetId = localStorage.getItem(STORAGE_CURRENT_PRESET) || state.toolPresets[0].id
+ }
+
+ function savePresets() {
+ localStorage.setItem(STORAGE_PRESETS, JSON.stringify(state.toolPresets))
+ localStorage.setItem(STORAGE_CURRENT_PRESET, state.currentPresetId || '')
+ }
+
+ function currentPreset() {
+ return state.toolPresets.find((item) => item.id === state.currentPresetId) || null
+ }
+
+ function renderPresetSelect() {
+ el.toolPresetSelect.innerHTML = state.toolPresets.map((item) => (
+ ``
+ )).join('')
+ el.toolPresetSelect.value = state.currentPresetId || ''
+ }
+
+ async function applyPreset(presetId) {
+ state.currentPresetId = presetId
+ savePresets()
+ renderPresetSelect()
+ const preset = currentPreset()
+ if (!preset) return
+ el.toolPresetName.value = preset.name
+ el.toolBaseUrl.value = preset.baseUrl
+ if (!String(el.toolAlias.value || '').trim()) el.toolAlias.value = preset.name
+ if (preset.privateXml) {
+ try {
+ state.toolKeys = await importKeyPairFromXml(preset.privateXml)
+ } catch {
+ state.toolKeys = null
+ setNotice(`预设「${preset.name}」里的私钥 XML 载入失败,请重新导入。`, 'error')
+ }
+ } else {
+ state.toolKeys = null
+ }
+ await refreshToolKeyUi()
+ }
+
+ async function saveCurrentPreset() {
+ let preset = currentPreset()
+ const name = String(el.toolPresetName.value || '').trim()
+ const baseUrl = String(el.toolBaseUrl.value || '').trim()
+ if (!name) {
+ setNotice('请先填写预设名称。', 'error')
+ return
+ }
+ let privateXml = ''
+ if (state.toolKeys && state.toolKeys.privateKey) privateXml = await exportPrivateXml(state.toolKeys.privateKey)
+ if (!preset) {
+ let id = name
+ let seq = 2
+ while (state.toolPresets.some((item) => item.id === id)) {
+ id = `${name}-${seq}`
+ seq += 1
+ }
+ preset = { id, name, baseUrl, privateXml }
+ state.toolPresets.push(preset)
+ state.currentPresetId = preset.id
+ } else {
+ preset.id = preset.id || name
+ preset.name = name
+ preset.baseUrl = baseUrl
+ preset.privateXml = privateXml
+ state.currentPresetId = preset.id
+ }
+ savePresets()
+ renderPresetSelect()
+ el.toolPresetSelect.value = state.currentPresetId
+ setNotice('当前预设已保存到本浏览器。', 'success')
+ }
+
+ async function startNewPreset() {
+ state.currentPresetId = ''
+ savePresets()
+ renderPresetSelect()
+ el.toolPresetSelect.value = ''
+ el.toolPresetName.value = ''
+ el.toolBaseUrl.value = ''
+ el.toolAlias.value = ''
+ el.toolPassword.value = ''
+ el.toolLinkOutput.value = ''
+ el.toolGenerateMeta.innerHTML = ''
+ el.toolVerifyInput.value = ''
+ el.toolVerifyMeta.innerHTML = ''
+ el.toolVerifyRaw.textContent = ''
+ setNotice('已切到新预设草稿。填好名称后保存,就会新增一个站点预设。', 'success')
+ }
+
+ function deleteCurrentPreset() {
+ const preset = currentPreset()
+ if (!preset) return
+ if (!window.confirm(`确定删除预设「${preset.name}」吗?`)) return
+ state.toolPresets = state.toolPresets.filter((item) => item.id !== preset.id)
+ if (!state.toolPresets.length) state.toolPresets = defaultPresets()
+ state.currentPresetId = state.toolPresets[0].id
+ savePresets()
+ applyPreset(state.currentPresetId)
+ setNotice('预设已删除。', 'success')
+ }
+
+ function xmlTagValue(doc, tag) {
+ const node = doc.querySelector(tag)
+ return node ? String(node.textContent || '').trim() : ''
+ }
+
+ function xmlToPrivateJwk(xmlText) {
+ const doc = new DOMParser().parseFromString(xmlText, 'application/xml')
+ const modulus = xmlTagValue(doc, 'Modulus')
+ const exponent = xmlTagValue(doc, 'Exponent')
+ const d = xmlTagValue(doc, 'D')
+ if (!modulus || !exponent || !d) throw new Error('私钥 XML 内容不完整')
+ const jwk = {
+ kty: 'RSA',
+ n: base64UrlFromBytes(bytesFromBase64(modulus)),
+ e: base64UrlFromBytes(bytesFromBase64(exponent)),
+ d: base64UrlFromBytes(bytesFromBase64(d)),
+ p: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'P'))),
+ q: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'Q'))),
+ dp: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'DP'))),
+ dq: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'DQ'))),
+ qi: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'InverseQ'))),
+ ext: true
+ }
+ return jwk
+ }
+
+ function privateJwkToXml(jwk) {
+ const read = (name) => base64FromBytes(bytesFromBase64Url(jwk[name] || ''))
+ return [
+ '',
+ `${read('n')}`,
+ `${read('e')}`,
+ `${read('p')}
`,
+ `${read('q')}
`,
+ `${read('dp')}`,
+ `${read('dq')}`,
+ `${read('qi')}`,
+ `${read('d')}`,
+ ''
+ ].join('')
+ }
+
+ async function importKeyPairFromXml(xmlText) {
+ const privateJwk = xmlToPrivateJwk(xmlText)
+ const publicJwk = { kty: 'RSA', n: privateJwk.n, e: privateJwk.e, ext: true }
+ const algorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
+ const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algorithm, true, ['sign'])
+ const publicKey = await crypto.subtle.importKey('jwk', publicJwk, algorithm, true, ['verify'])
+ return { privateKey, publicKey }
+ }
+
+ async function generateKeyPair() {
+ return crypto.subtle.generateKey({
+ name: 'RSASSA-PKCS1-v1_5',
+ modulusLength: 2048,
+ publicExponent: new Uint8Array([1, 0, 1]),
+ hash: 'SHA-256'
+ }, true, ['sign', 'verify'])
+ }
+
+ async function exportPrivateXml(privateKey) {
+ const jwk = await crypto.subtle.exportKey('jwk', privateKey)
+ return privateJwkToXml(jwk)
+ }
+
+ async function exportPublicJwk(publicKey) {
+ return crypto.subtle.exportKey('jwk', publicKey)
+ }
+
+ async function fingerprintFromPublicKey(publicKey) {
+ const jwk = await exportPublicJwk(publicKey)
+ const digest = await crypto.subtle.digest('SHA-256', bytesFromBase64Url(jwk.n))
+ return Array.from(new Uint8Array(digest)).slice(0, 6).map((n) => n.toString(16).padStart(2, '0')).join('')
+ }
+
+ async function refreshToolKeyUi() {
+ if (!state.toolKeys || !state.toolKeys.publicKey) {
+ el.toolPublicJwk.value = ''
+ el.toolKeyMeta.innerHTML = createKvHtml([
+ { label: '当前状态', value: '还没有载入私钥', status: 'bad' },
+ { label: '说明', value: '先生成或导入 XML 私钥后,才能生成和验签 Tool 令牌。' }
+ ])
+ return
+ }
+ const publicJwk = await exportPublicJwk(state.toolKeys.publicKey)
+ const fingerprint = await fingerprintFromPublicKey(state.toolKeys.publicKey)
+ el.toolPublicJwk.value = JSON.stringify(publicJwk, null, 2)
+ el.toolKeyMeta.innerHTML = createKvHtml([
+ { label: '当前状态', value: '私钥已载入', status: 'good' },
+ { label: '密钥指纹', value: fingerprint },
+ { label: '公钥 kty', value: publicJwk.kty || '-' },
+ { label: '说明', value: '保存预设后,这把私钥会只保存在当前浏览器本地。' }
+ ])
+ }
+
+ async function copyText(value, successMessage) {
+ const text = String(value || '').trim()
+ if (!text) {
+ setNotice('没有可复制的内容。', 'error')
+ return
+ }
+ try {
+ await navigator.clipboard.writeText(text)
+ setNotice(successMessage || '已复制。', 'success')
+ } catch {
+ try {
+ const ta = document.createElement('textarea')
+ ta.value = text
+ ta.setAttribute('readonly', 'readonly')
+ ta.style.position = 'fixed'
+ ta.style.left = '-9999px'
+ document.body.appendChild(ta)
+ ta.select()
+ ta.setSelectionRange(0, ta.value.length)
+ const ok = document.execCommand('copy')
+ document.body.removeChild(ta)
+ if (!ok) throw new Error('copy_failed')
+ setNotice(successMessage || '已复制。', 'success')
+ } catch {
+ setNotice('复制失败,请手动选中复制。', 'error')
+ }
+ }
+ }
+
+ async function handleNavGenerate() {
+ try {
+ const exp = epochFromDatetimeLocal(el.navExpiry.value)
+ const baseUrl = String(el.navBaseUrl.value || '').trim()
+ const data = await fetchJson('/api/token_lab/nav/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ baseUrl, exp })
+ })
+ el.navLinkOutput.value = data.link || ''
+ el.navGenerateMeta.innerHTML = createKvHtml([
+ { label: 'iss', value: data.payload.iss || '-' },
+ { label: 'iat(北京时间)', value: formatEpoch(data.payload.iat) },
+ { label: 'exp(北京时间)', value: formatEpoch(data.payload.exp) },
+ { label: '剩余时长', value: formatRelative(data.payload.exp), status: 'good' },
+ { label: 'jti', value: data.payload.jti || '-' },
+ { label: '基础地址', value: baseUrl || '/' }
+ ])
+ setNotice('首页导航令牌已生成。', 'success')
+ } catch (err) {
+ setNotice(`生成首页令牌失败:${err.message || err}`, 'error')
+ }
+ }
+
+ async function handleNavVerify() {
+ try {
+ const value = String(el.navVerifyInput.value || '').trim()
+ const data = await fetchJson('/api/token_lab/nav/verify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ value })
+ })
+ const payload = data.payload || {}
+ const result = data.result || {}
+ el.navVerifyMeta.innerHTML = createKvHtml([
+ { label: '类型', value: '首页导航 token' },
+ { label: '签名验证', value: result.sig_ok ? '通过' : '未通过', status: result.sig_ok ? 'good' : 'bad' },
+ { label: '整体状态', value: result.ok ? '可用' : `不可用(${result.reason || '校验失败'})`, status: result.ok ? 'good' : 'bad' },
+ { label: 'iss', value: payload.iss || '-' },
+ { label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
+ { label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
+ { label: '剩余时长', value: formatRelative(payload.exp), status: result.ok ? 'good' : 'bad' },
+ { label: 'jti', value: payload.jti || '-' }
+ ])
+ el.navVerifyRaw.textContent = createVerifyDebugJson({
+ header: data.header || null,
+ payload,
+ result: {
+ ok: !!result.ok,
+ sig_ok: !!result.sig_ok,
+ reason: result.reason || '',
+ matched_source: result.matched_source || '',
+ checked_keys: result.checked_keys || 0,
+ keys_sources: Array.isArray(result.keys_sources) ? result.keys_sources : []
+ }
+ })
+ setNotice('首页令牌验证完成。', 'success')
+ } catch (err) {
+ setNotice(`验证首页令牌失败:${err.message || err}`, 'error')
+ }
+ }
+
+ async function sha256Base64Url(text) {
+ const digest = await crypto.subtle.digest('SHA-256', encodeUtf8(text))
+ return base64UrlFromBytes(new Uint8Array(digest))
+ }
+
+ async function handleToolGenerate() {
+ try {
+ if (!state.toolKeys || !state.toolKeys.privateKey) throw new Error('请先生成或导入私钥 XML')
+ const alias = String(el.toolAlias.value || '').trim() || deriveToolAlias()
+ const baseUrl = String(el.toolBaseUrl.value || '').trim()
+ const exp = epochFromDatetimeLocal(el.toolExpiry.value)
+ el.toolAlias.value = alias
+ if (!baseUrl) {
+ el.toolBaseUrl.focus()
+ throw new Error('请先填写基础地址')
+ }
+ if (!exp) {
+ el.toolExpiry.focus()
+ throw new Error('请先填写过期时间')
+ }
+ const now = Math.floor(Date.now() / 1000)
+ if (exp <= now) throw new Error('过期时间必须晚于当前时间')
+ const payload = {
+ alias,
+ iat: now,
+ exp,
+ jti: crypto.randomUUID().replace(/-/g, ''),
+ pwd: await sha256Base64Url(String(el.toolPassword.value || ''))
+ }
+ const header = { alg: 'RS256', typ: 'JWT' }
+ const data = `${base64UrlFromBytes(encodeUtf8(JSON.stringify(header)))}.${base64UrlFromBytes(encodeUtf8(JSON.stringify(payload)))}`
+ const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', state.toolKeys.privateKey, encodeUtf8(data))
+ const token = `${data}.${base64UrlFromBytes(new Uint8Array(signature))}`
+ const link = appendTokenToUrl(baseUrl, token)
+ el.toolLinkOutput.value = link
+ el.toolGenerateMeta.innerHTML = createKvHtml([
+ { label: 'alias', value: payload.alias },
+ { label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
+ { label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
+ { label: '剩余时长', value: formatRelative(payload.exp), status: 'good' },
+ { label: 'jti', value: payload.jti },
+ { label: 'pwd 字段', value: payload.pwd ? '已生成' : '空' }
+ ])
+ scrollToolGenerateIntoView()
+ setNotice('Tool 令牌已在浏览器本地生成。', 'success')
+ } catch (err) {
+ el.toolGenerateMeta.innerHTML = createKvHtml([
+ { label: '生成失败', value: String(err.message || err), status: 'bad' }
+ ])
+ scrollToolGenerateIntoView()
+ setNotice(`生成 Tool 令牌失败:${err.message || err}`, 'error')
+ }
+ }
+
+ async function handleToolVerify() {
+ try {
+ const token = extractToken(el.toolVerifyInput.value)
+ if (!token) throw new Error('没有识别到 token')
+ const parts = token.split('.')
+ if (parts.length !== 3) throw new Error('token 格式不正确')
+ const header = decodeJwtPart(parts[0]) || {}
+ const payload = decodeJwtPart(parts[1]) || {}
+ let signatureLabel = '未载入当前密钥'
+ let signatureStatus = ''
+ let signatureOk = null
+ if (state.toolKeys && state.toolKeys.publicKey) {
+ signatureOk = await crypto.subtle.verify(
+ 'RSASSA-PKCS1-v1_5',
+ state.toolKeys.publicKey,
+ bytesFromBase64Url(parts[2]),
+ encodeUtf8(`${parts[0]}.${parts[1]}`)
+ )
+ signatureLabel = signatureOk ? '通过' : '未通过'
+ signatureStatus = signatureOk ? 'good' : 'bad'
+ }
+ el.toolVerifyMeta.innerHTML = createKvHtml([
+ { label: '类型', value: payload.alias || payload.pwd ? 'Tool token' : '未知格式' },
+ { label: '签名验证', value: signatureLabel, status: signatureStatus },
+ { label: 'alias', value: payload.alias || '-' },
+ { label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
+ { label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
+ { label: '剩余时长', value: formatRelative(payload.exp), status: payload.exp && payload.exp >= Math.floor(Date.now() / 1000) ? 'good' : 'bad' },
+ { label: 'jti', value: payload.jti || '-' },
+ { label: 'pwd 字段', value: payload.pwd ? '存在' : '不存在' }
+ ])
+ el.toolVerifyRaw.textContent = createVerifyDebugJson({
+ header,
+ payload,
+ result: {
+ signature_ok: signatureOk,
+ key_loaded: !!(state.toolKeys && state.toolKeys.publicKey)
+ }
+ })
+ setNotice('Tool 令牌验证完成。', 'success')
+ } catch (err) {
+ setNotice(`验证 Tool 令牌失败:${err.message || err}`, 'error')
+ }
+ }
+
+ async function handleImportXmlFile(file) {
+ if (!file) return
+ const text = await file.text()
+ state.toolKeys = await importKeyPairFromXml(text)
+ await refreshToolKeyUi()
+ setNotice('XML 私钥已导入。记得按一下“保存当前预设”。', 'success')
+ }
+
+ function downloadText(filename, content) {
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }
+
+ function bindEvents() {
+ el.tabButtons.forEach((btn) => {
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab))
+ })
+
+ $('#btnNavGenerate').addEventListener('click', handleNavGenerate)
+ $('#btnNavCopy').addEventListener('click', () => copyText(el.navLinkOutput.value, '首页链接已复制。'))
+ $('#btnNavVerify').addEventListener('click', handleNavVerify)
+ $('#btnAuthLogin').addEventListener('click', handleAuthLogin)
+ el.authUsername.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') handleAuthLogin()
+ })
+ el.authPassword.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') handleAuthLogin()
+ })
+ el.btnAuthLogout.addEventListener('click', handleAuthLogout)
+
+ el.toolPresetSelect.addEventListener('change', (e) => applyPreset(e.target.value))
+ $('#btnToolSavePreset').addEventListener('click', saveCurrentPreset)
+ $('#btnToolDeletePreset').addEventListener('click', deleteCurrentPreset)
+ $('#btnToolGenerateKey').addEventListener('click', async () => {
+ state.toolKeys = await generateKeyPair()
+ await refreshToolKeyUi()
+ setNotice('新的 RSA 密钥已生成。记得按一下“保存当前预设”。', 'success')
+ })
+ $('#btnToolImportXml').addEventListener('click', () => el.toolXmlFile.click())
+ el.toolXmlFile.addEventListener('change', async (e) => {
+ try {
+ await handleImportXmlFile(e.target.files && e.target.files[0])
+ } catch (err) {
+ setNotice(`导入 XML 失败:${err.message || err}`, 'error')
+ } finally {
+ e.target.value = ''
+ }
+ })
+ $('#btnToolExportXml').addEventListener('click', async () => {
+ try {
+ if (!state.toolKeys || !state.toolKeys.privateKey) throw new Error('当前没有可导出的私钥')
+ const xml = await exportPrivateXml(state.toolKeys.privateKey)
+ const filename = `${String(el.toolPresetName.value || 'tool-token').trim() || 'tool-token'}_private_key.xml`
+ downloadText(filename, xml)
+ setNotice('私钥 XML 已导出。', 'success')
+ } catch (err) {
+ setNotice(`导出 XML 失败:${err.message || err}`, 'error')
+ }
+ })
+ $('#btnToolNewPreset').addEventListener('click', startNewPreset)
+ $('#btnToolCopyJwk').addEventListener('click', () => copyText(el.toolPublicJwk.value, '公钥 JWK 已复制。'))
+ $('#btnToolGenerateToken').addEventListener('click', handleToolGenerate)
+ $('#btnToolCopyLink').addEventListener('click', () => copyText(el.toolLinkOutput.value, 'Tool 链接已复制。'))
+ $('#btnToolVerify').addEventListener('click', handleToolVerify)
+ }
+
+ async function init() {
+ loadPresets()
+ renderPresetSelect()
+ bindEvents()
+ el.navBaseUrl.value = `${window.location.origin}/`
+ el.navExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 30)
+ el.toolExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 7)
+ await applyPreset(state.currentPresetId)
+ await refreshAuthStatus()
+ }
+
+ init().catch((err) => {
+ setNotice(`初始化失败:${err.message || err}`, 'error')
+ })
+})()
diff --git a/src/server/index.js b/src/server/index.js
index b5499f9..cb4f06e 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -1573,6 +1573,24 @@ const signJwtRS256 = (payload, jwk) => {
const sig = crypto.sign('RSA-SHA256', Buffer.from(data), priv).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')
return data + '.' + sig
}
+const extractJwtFromInput = (raw) => {
+ const text = String(raw || '').trim()
+ if (!text) return ''
+ if (/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(text)) return text
+ try {
+ const u = new URL(text)
+ const fromQuery = String(u.searchParams.get('token') || '').trim()
+ if (fromQuery) return fromQuery
+ } catch {}
+ const match = text.match(/(?:^|[?&])token=([^\s]+)/)
+ if (match && match[1]) return decodeURIComponent(match[1])
+ return ''
+}
+const appendTokenToUrl = (baseUrl, token) => {
+ const base = String(baseUrl || '').trim()
+ if (!base) return `/?token=${token}`
+ return base.includes('?') ? `${base}&token=${token}` : `${base}?token=${token}`
+}
const readNavTargets = () => {
try {
const s = String(process.env.NAV_TARGETS_JSON || '')
@@ -4145,6 +4163,98 @@ app.get('/api/debug/nav/private', (req, res) => {
}
})
+app.get('/api/token_lab/auth/status', (req, res) => {
+ try {
+ if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
+ return res.json({
+ ok: true,
+ authenticated: hasTokenLabAuth(req)
+ })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+})
+
+app.post('/api/token_lab/auth/login', (req, res) => {
+ try {
+ if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
+ const username = String(req.body?.username || '').trim()
+ const password = String(req.body?.password || '')
+ const expectedUser = String(process.env.TOKEN_LAB_USERNAME || '').trim()
+ const expectedPass = String(process.env.TOKEN_LAB_PASSWORD || '')
+ if (!expectedUser || !expectedPass) return res.status(500).json({ ok: false, error: 'token_lab_creds_not_configured' })
+ if (username !== expectedUser || password !== expectedPass) {
+ return res.status(401).json({ ok: false, error: '账号或密码错误' })
+ }
+ res.cookie(TOKEN_LAB_AUTH_COOKIE, '1', { httpOnly: true, sameSite: 'lax', maxAge: getTokenLabAuthMaxAge(), path: '/' })
+ return res.json({ ok: true })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+})
+
+app.post('/api/token_lab/auth/logout', (req, res) => {
+ try {
+ if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
+ res.clearCookie(TOKEN_LAB_AUTH_COOKIE, { httpOnly: true, sameSite: 'lax', path: '/' })
+ return res.json({ ok: true })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+})
+
+app.use('/api/token_lab', (req, res, next) => {
+ try {
+ if (!hasNavAuthRoot(req)) return res.status(401).json({ ok: false, error: 'unauthorized' })
+ if (req.path === '/auth/status' || req.path === '/auth/login' || req.path === '/auth/logout') return next()
+ if (hasTokenLabAuth(req)) return next()
+ return res.status(401).json({ ok: false, error: 'token_lab_login_required' })
+ } catch {
+ return res.status(401).json({ ok: false, error: 'unauthorized' })
+ }
+})
+
+app.post('/api/token_lab/nav/generate', (req, res) => {
+ try {
+ const baseUrl = String(req.body?.baseUrl || '').trim()
+ const exp = parseInt(String(req.body?.exp || ''), 10)
+ const now = Math.floor(Date.now() / 1000)
+ if (!Number.isFinite(exp) || exp <= now) return res.status(400).json({ ok: false, error: 'bad_exp' })
+ if (exp > now + 3600 * 24 * 365 * 2) return res.status(400).json({ ok: false, error: 'exp_too_far' })
+ const priv = readNavPrivateJwk()
+ if (!priv) return res.status(500).json({ ok: false, error: 'nav_private_key_missing' })
+ const payload = {
+ iss: getNavIssFromFlags(),
+ iat: now,
+ exp,
+ jti: crypto.randomUUID()
+ }
+ const token = signJwtRS256(payload, priv)
+ const link = appendTokenToUrl(baseUrl || '/', token)
+ return res.json({ ok: true, token, link, payload })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+})
+
+app.post('/api/token_lab/nav/verify', (req, res) => {
+ try {
+ const raw = String(req.body?.value || '').trim()
+ const token = extractJwtFromInput(raw)
+ if (!token) return res.status(400).json({ ok: false, error: 'missing_token' })
+ const result = debugVerifyJwt(token, [], { issStrict: computeNavIssStrict() })
+ return res.json({
+ ok: true,
+ token,
+ result,
+ payload: result.payload || null,
+ header: result.header || null
+ })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: String(e.message || e) })
+ }
+})
+
app.get('/api/flags', (req, res) => {
const enabled = computeDebugEnabled()
res.json({ ok: true, debugToolEnabled: enabled, debug: { weibo: computeToolDebug('weibo'), markets: computeToolDebug('markets'), wall: computeToolDebug('wall'), funds_guoxin: computeToolDebug('funds_guoxin'), funds_huatai: computeToolDebug('funds_huatai'), weekly: computeToolDebug('weekly') } })
@@ -5545,12 +5655,20 @@ app.get('/api/archives/get', (req, res) => {
})
const NAV_AUTH_COOKIE = 'nav_gate'
+const TOKEN_LAB_AUTH_COOKIE = 'token_lab_gate'
const hasNavAuthRoot = (req) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
return cookies[NAV_AUTH_COOKIE] === '1'
} catch { return false }
}
+const hasTokenLabAuth = (req) => {
+ try {
+ const cookies = parseCookie(req.headers.cookie || '')
+ return cookies[TOKEN_LAB_AUTH_COOKIE] === '1'
+ } catch { return false }
+}
+const getTokenLabAuthMaxAge = () => 1000 * 60 * 60 * 24
app.get('/index.html', (req, res) => {
try {
@@ -6195,6 +6313,35 @@ app.use('/tools/web_order_box', (req, res, next) => {
next()
})
app.use('/tools/web_order_box', express.static(path.join(process.cwd(), 'public', 'tools', 'web_order_box')))
+app.get('/tools/token_lab', (req, res) => {
+ try {
+ if (hasNavAuthRoot(req)) return res.redirect('/tools/token_lab/index.html')
+ const token = String(req.query.token || '').trim()
+ if (!token) return res.status(401).send('未授权')
+ const payload = verifyJwtWithKeys(token, [])
+ if (!payload) return res.status(401).send('未授权')
+ const iss = String(payload.iss || '')
+ const navIss = getNavIssFromFlags()
+ if (iss === navIss && !audMatch(payload, 'Tools-token_lab')) return res.status(401).send('未授权')
+ res.cookie(NAV_AUTH_COOKIE, '1', { httpOnly: true, sameSite: 'lax' })
+ return res.redirect('/tools/token_lab/index.html')
+ } catch {
+ return res.status(401).send('未授权')
+ }
+})
+app.use('/tools/token_lab', (req, res, next) => {
+ try {
+ if (hasNavAuthRoot(req)) return next()
+ return res.status(401).send('未授权')
+ } catch {
+ return res.status(401).send('未授权')
+ }
+})
+app.use('/tools/token_lab', (req, res, next) => {
+ res.set('X-Frame-Options', 'SAMEORIGIN')
+ next()
+})
+app.use('/tools/token_lab', express.static(path.join(process.cwd(), 'public', 'tools', 'token_lab')))
app.use('/tools/investment_ledger', (req, res, next) => {
try {
if (hasNavAuthRoot(req)) return next()