From 18e8e787d2eec7665e5efb5f672ba5d1ce8ec959 Mon Sep 17 00:00:00 2001 From: yangxiangyuan Date: Sun, 2 Aug 2026 10:18:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(token=5Flab):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E5=B7=A5=E5=9D=8A=E5=B7=A5=E5=85=B7=E5=8F=8A?= =?UTF-8?q?=E9=85=8D=E5=A5=97=E5=90=8E=E7=AB=AF=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增完整的令牌工坊工具链: - 添加前端静态资源:登录界面、导航与Tool令牌管理页面,配套样式与脚本文件 - 添加后端接口与中间件:认证相关接口、路由权限控制,以及导航令牌的生成验证API - 添加默认配置文件,配置令牌工坊默认走导航登录,不对外公网开放 --- public/tools/token_lab/auth_config.json | 5 + public/tools/token_lab/index.html | 177 ++++++ public/tools/token_lab/token_lab.css | 356 +++++++++++ public/tools/token_lab/token_lab.js | 814 ++++++++++++++++++++++++ src/server/index.js | 147 +++++ 5 files changed, 1499 insertions(+) create mode 100644 public/tools/token_lab/auth_config.json create mode 100644 public/tools/token_lab/index.html create mode 100644 public/tools/token_lab/token_lab.css create mode 100644 public/tools/token_lab/token_lab.js 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 @@ + + + + + + + 令牌工坊 + + + +
+
+ + + +
+
+
+ + + + + + 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()