feat(token_lab): 新增令牌工坊工具及配套后端逻辑

新增完整的令牌工坊工具链:
- 添加前端静态资源:登录界面、导航与Tool令牌管理页面,配套样式与脚本文件
- 添加后端接口与中间件:认证相关接口、路由权限控制,以及导航令牌的生成验证API
- 添加默认配置文件,配置令牌工坊默认走导航登录,不对外公网开放
This commit is contained in:
yangxiangyuan
2026-08-02 10:18:44 +08:00
parent 47da1967d3
commit 18e8e787d2
5 changed files with 1499 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"_comment": "令牌工坊默认走导航登录,不对公网开放",
"enable_auth": true,
"max_age_days": 30
}
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#0b1020">
<title>令牌工坊</title>
<link rel="stylesheet" href="/tools/token_lab/token_lab.css">
</head>
<body>
<section id="authScreen" class="auth-screen">
<div class="auth-card">
<input id="authUsername" class="input" type="text" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="账号">
<input id="authPassword" class="input" type="password" autocomplete="off" placeholder="密码">
<button id="btnAuthLogin" class="btn btn-primary auth-submit">登录</button>
<div id="authHint" class="auth-hint"></div>
</div>
</section>
<div id="workshopShell" class="page-shell" hidden>
<header class="hero">
<div>
<div class="eyebrow">Token Lab</div>
<h1>令牌工坊</h1>
<p>首页导航令牌与 Tool 令牌的生成、验证、信息回看,都放在这里统一处理。</p>
</div>
<div class="hero-side">
<div id="globalNotice" class="notice" hidden></div>
<button id="btnAuthLogout" class="btn" hidden>退出工坊登录</button>
</div>
</header>
<nav id="toolTabs" class="tab-nav" hidden>
<button class="tab-btn active" data-tab="nav">首页导航生成器</button>
<button class="tab-btn" data-tab="tool">Tool 令牌生成器</button>
</nav>
<main id="appMain" hidden>
<section class="tab-panel active" data-panel="nav">
<div class="layout-grid">
<section class="card">
<div class="card-head">
<h2>生成首页导航令牌</h2>
</div>
<p class="card-desc">服务端使用当前导航私钥签发首页长链接,适合你自己的首页入口轮换。</p>
<div class="form-grid">
<label class="field">
<span>基础地址</span>
<input id="navBaseUrl" class="input" type="text" placeholder="https://traesite.umersoft.com:8975/">
</label>
<label class="field">
<span>过期时间</span>
<input id="navExpiry" class="input" type="datetime-local">
</label>
</div>
<div class="actions">
<button id="btnNavGenerate" class="btn btn-primary">生成首页令牌</button>
<button id="btnNavCopy" class="btn">复制链接</button>
</div>
<label class="field">
<span>生成结果</span>
<textarea id="navLinkOutput" class="textarea mono" rows="5" readonly wrap="off"></textarea>
</label>
<div id="navGenerateMeta" class="result-grid"></div>
</section>
<section class="card">
<div class="card-head">
<h2>验证首页导航令牌</h2>
</div>
<p class="card-desc">可以粘贴完整链接或纯 token,快速看签名、过期时间、剩余时长和载荷字段。</p>
<label class="field">
<span>待验证内容</span>
<textarea id="navVerifyInput" class="textarea mono" rows="6" placeholder="粘贴 https://.../?token=... 或直接粘贴 token" wrap="off"></textarea>
</label>
<div class="actions">
<button id="btnNavVerify" class="btn btn-primary">验证首页令牌</button>
</div>
<div id="navVerifyMeta" class="result-grid"></div>
<details class="raw-details">
<summary>详细输出</summary>
<pre id="navVerifyRaw" class="result-box"></pre>
</details>
</section>
</div>
</section>
<section class="tab-panel" data-panel="tool">
<div class="layout-grid">
<section class="card">
<div class="card-head">
<h2>生成 Tool 令牌</h2>
</div>
<p class="card-desc">这部分沿用原 WinForm 的思路:浏览器本地持有 RSA 私钥,本地完成签名,不把 tool 私钥交给服务器。</p>
<div class="form-grid">
<label class="field">
<span>站点预设</span>
<select id="toolPresetSelect" class="input"></select>
</label>
<label class="field">
<span>预设名称</span>
<input id="toolPresetName" class="input" type="text" placeholder="expense">
</label>
<label class="field field-span-2">
<span>基础地址</span>
<input id="toolBaseUrl" class="input" type="text" placeholder="https://traesite.umersoft.com:8975/tools/expense">
</label>
<label class="field">
<span>设备别名</span>
<input id="toolAlias" class="input" type="text" placeholder="nav-private">
</label>
<label class="field">
<span>过期时间</span>
<input id="toolExpiry" class="input" type="datetime-local">
</label>
<label class="field field-span-2">
<span>自定义密码</span>
<input id="toolPassword" class="input" type="password" placeholder="可留空">
</label>
</div>
<div class="actions wrap">
<button id="btnToolGenerateKey" class="btn">生成 RSA 密钥</button>
<button id="btnToolImportXml" class="btn">导入私钥 XML</button>
<button id="btnToolExportXml" class="btn">导出私钥 XML</button>
<button id="btnToolCopyJwk" class="btn">复制公钥 JWK</button>
<button id="btnToolNewPreset" class="btn">新建预设</button>
<button id="btnToolSavePreset" class="btn">保存当前预设</button>
<button id="btnToolDeletePreset" class="btn">删除当前预设</button>
</div>
<input id="toolXmlFile" type="file" accept=".xml,text/xml" hidden>
<div id="toolKeyMeta" class="result-grid"></div>
<label class="field">
<span>公钥 JWK</span>
<textarea id="toolPublicJwk" class="textarea mono" rows="5" readonly wrap="off"></textarea>
</label>
<div class="actions">
<button id="btnToolGenerateToken" class="btn btn-primary">生成 Tool 令牌</button>
<button id="btnToolCopyLink" class="btn">复制链接</button>
</div>
<label class="field">
<span>生成结果</span>
<textarea id="toolLinkOutput" class="textarea mono" rows="5" readonly wrap="off"></textarea>
</label>
<div id="toolGenerateMeta" class="result-grid"></div>
</section>
<section class="card">
<div class="card-head">
<h2>验证 Tool 令牌</h2>
</div>
<p class="card-desc">粘贴旧链接或旧 token,马上就能看出 alias、到期时间、剩余时长,以及当前密钥是否验得过。</p>
<label class="field">
<span>待验证内容</span>
<textarea id="toolVerifyInput" class="textarea mono" rows="6" placeholder="粘贴 https://.../?token=... 或直接粘贴 token" wrap="off"></textarea>
</label>
<div class="actions">
<button id="btnToolVerify" class="btn btn-primary">验证 Tool 令牌</button>
</div>
<div id="toolVerifyMeta" class="result-grid"></div>
<details class="raw-details">
<summary>详细输出</summary>
<pre id="toolVerifyRaw" class="result-box"></pre>
</details>
</section>
</div>
</section>
</main>
</div>
<script src="/tools/token_lab/token_lab.js"></script>
</body>
</html>
+356
View File
@@ -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;
}
}
+814
View File
@@ -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 `<div class="kv${status}"><span class="k">${escapeHtml(item.label)}</span><span class="v">${escapeHtml(item.value)}</span></div>`
}).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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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) => (
`<option value="${escapeHtml(item.id)}">${escapeHtml(item.name)}</option>`
)).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 [
'<RSAKeyValue>',
`<Modulus>${read('n')}</Modulus>`,
`<Exponent>${read('e')}</Exponent>`,
`<P>${read('p')}</P>`,
`<Q>${read('q')}</Q>`,
`<DP>${read('dp')}</DP>`,
`<DQ>${read('dq')}</DQ>`,
`<InverseQ>${read('qi')}</InverseQ>`,
`<D>${read('d')}</D>`,
'</RSAKeyValue>'
].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')
})
})()
+147
View File
@@ -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(/=+$/,'') const sig = crypto.sign('RSA-SHA256', Buffer.from(data), priv).toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'')
return data + '.' + sig 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 = () => { const readNavTargets = () => {
try { try {
const s = String(process.env.NAV_TARGETS_JSON || '') 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) => { app.get('/api/flags', (req, res) => {
const enabled = computeDebugEnabled() 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') } }) 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 NAV_AUTH_COOKIE = 'nav_gate'
const TOKEN_LAB_AUTH_COOKIE = 'token_lab_gate'
const hasNavAuthRoot = (req) => { const hasNavAuthRoot = (req) => {
try { try {
const cookies = parseCookie(req.headers.cookie || '') const cookies = parseCookie(req.headers.cookie || '')
return cookies[NAV_AUTH_COOKIE] === '1' return cookies[NAV_AUTH_COOKIE] === '1'
} catch { return false } } 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) => { app.get('/index.html', (req, res) => {
try { try {
@@ -6195,6 +6313,35 @@ app.use('/tools/web_order_box', (req, res, next) => {
next() next()
}) })
app.use('/tools/web_order_box', express.static(path.join(process.cwd(), 'public', 'tools', 'web_order_box'))) 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) => { app.use('/tools/investment_ledger', (req, res, next) => {
try { try {
if (hasNavAuthRoot(req)) return next() if (hasNavAuthRoot(req)) return next()