Files
Toolbox/public/tools/token_lab/token_lab.js
T
yangxiangyuan a3e8911deb feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
2026-10-09 14:05:36 +08:00

987 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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,
// 防爆紧急替换:第 1 步产出的新 token,供第 2 步绑定短链接使用
emergencyToken: ''
}
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'),
emergencyLog: $('#emergencyLog'),
emergencyMonths: $('#emergencyMonths'),
emergencyTokenOut: $('#emergencyTokenOut'),
emergencyShortUrlOut: $('#emergencyShortUrlOut'),
emergencyMeta: $('#emergencyMeta'),
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)
}
// ===== 首页导航防爆紧急替换 =====
// 两个按钮串行:先轮换密钥+签发新 token,再把新 token 绑到新短链并让旧短链失效。
// 执行过程按后端回传的步骤逐行滚动展示,最后把 token / 短链接单独显示,方便直接复制。
const EMERGENCY_STEP_DELAY = 110
const EMERGENCY_MONTH_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const EMERGENCY_DEFAULT_MONTHS = 1
// 每月按 30 天计算:与后端 days 参数对接,避免月初月末的天数歧义
const EMERGENCY_DAYS_PER_MONTH = 30
/** 初始化月份下拉:1~12 个月,默认 1 个月,并让按钮文字跟着选中值走 */
function initEmergencyMonths() {
el.emergencyMonths.innerHTML = EMERGENCY_MONTH_OPTIONS
.map(m => `<option value="${m}">${m} 个月</option>`)
.join('')
el.emergencyMonths.value = String(EMERGENCY_DEFAULT_MONTHS)
el.emergencyMonths.addEventListener('change', renderEmergencyRotateButtonText)
renderEmergencyRotateButtonText()
}
function currentEmergencyMonths() {
const m = Number(el.emergencyMonths.value)
return EMERGENCY_MONTH_OPTIONS.includes(m) ? m : EMERGENCY_DEFAULT_MONTHS
}
/** 按钮文案随下拉实时变化,避免"固定写一个月、实际却按别的月份执行"的歧义 */
function renderEmergencyRotateButtonText() {
const btn = $('#btnEmergencyRotate')
if (!btn || btn.disabled) return // 执行中不要覆盖"执行中…"的文案
btn.textContent = `自动生成向后 ${currentEmergencyMonths()} 个月的鉴权,且当前鉴权立即失效`
}
/** 按钮异步态:禁用 + 文字切换(项目规则:所有异步按钮都要有 loading) */
function setButtonBusy(btn, busy, busyText) {
if (!btn) return
if (busy) {
if (btn.dataset.originalText === undefined) btn.dataset.originalText = btn.textContent
btn.disabled = true
btn.classList.add('btn-busy')
if (busyText) btn.textContent = busyText
} else {
btn.disabled = false
btn.classList.remove('btn-busy')
if (btn.dataset.originalText !== undefined) {
btn.textContent = btn.dataset.originalText
delete btn.dataset.originalText
}
}
}
function emergencyLogClear() {
el.emergencyLog.value = ''
el.emergencyMeta.innerHTML = ''
}
function emergencyLogAppend(line) {
const stamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
el.emergencyLog.value += `[${stamp}] ${line}\n`
el.emergencyLog.scrollTop = el.emergencyLog.scrollHeight
}
/** 逐行播放后端回传的步骤,营造滚动输出的效果 */
async function emergencyLogPlay(lines) {
for (const line of lines || []) {
emergencyLogAppend(line)
await new Promise(resolve => setTimeout(resolve, EMERGENCY_STEP_DELAY))
}
}
/** 与 fetchJson 的区别:失败时也把后端返回的 logs 取回来展示,便于排障 */
async function emergencyPost(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
})
let data = null
try { data = await resp.json() } catch { data = null }
if (resp.status === 401) {
state.authenticated = false
applyAuthUi()
}
return { ok: !!(resp.ok && data && data.ok !== false), data }
}
async function handleEmergencyRotate() {
const btn = $('#btnEmergencyRotate')
const months = currentEmergencyMonths()
setButtonBusy(btn, true, '执行中…')
try {
emergencyLogClear()
state.emergencyToken = ''
el.emergencyTokenOut.value = ''
el.emergencyShortUrlOut.value = ''
emergencyLogAppend(`开始执行:自动生成向后 ${months} 个月的鉴权,且当前鉴权立即失效`)
const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/rotate', { months })
await emergencyLogPlay(data && data.logs)
if (!ok || !data) {
emergencyLogAppend('✘ 第 1 步未成功,请查看上面的执行过程')
setNotice('第 1 步失败,请查看执行过程。', 'error')
return
}
state.emergencyToken = String(data.token || '')
el.emergencyTokenOut.value = state.emergencyToken
const effectiveDays = Number(data.days) || months * EMERGENCY_DAYS_PER_MONTH
el.emergencyMeta.innerHTML = createKvHtml([
{ label: '有效期', value: `${months} 个月(${effectiveDays} 天)` },
{ label: '公钥指纹', value: data.fingerprint || '-' },
{ label: '过期(北京时间)', value: formatEpoch(data.exp) },
{ label: '剩余时长', value: formatRelative(data.exp), status: 'good' },
{ label: '备份目录', value: data.backupDir || '-' }
])
setNotice(`第 1 步完成:旧鉴权已失效,新的 ${months} 个月 token 已生成。`, 'success')
} catch (err) {
emergencyLogAppend(`✘ 失败:${err.message || err}`)
setNotice(`第 1 步失败:${err.message || err}`, 'error')
} finally {
setButtonBusy(btn, false)
// 按钮文案依赖下拉选中值,恢复时按当前选择重算,而不是回退到旧文案
renderEmergencyRotateButtonText()
}
}
async function handleEmergencyRelink() {
const btn = $('#btnEmergencyRelink')
if (!state.emergencyToken) {
emergencyLogAppend('✘ 还没有可用的新 token,请先点第 1 个按钮生成。')
setNotice('请先执行第 1 步生成新 token。', 'error')
return
}
setButtonBusy(btn, true, '执行中…')
try {
emergencyLogAppend('开始执行:将新 token 绑定到新的短链接,且旧短链接立即失效')
const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/relink', { token: state.emergencyToken })
await emergencyLogPlay(data && data.logs)
if (!ok || !data) {
emergencyLogAppend('✘ 第 2 步未成功,请查看上面的执行过程')
setNotice('第 2 步失败,请查看执行过程。', 'error')
return
}
el.emergencyShortUrlOut.value = String(data.shortUrl || '')
el.emergencyMeta.innerHTML = createKvHtml([
{ label: '新短码', value: data.code || '-' },
{ label: '已停用的旧短码', value: (data.disabledCodes || []).join('、') || '(无)' },
{ label: '跳转自校验', value: data.verified ? '通过' : '未通过(请手工确认)', status: data.verified ? 'good' : 'bad' }
])
setNotice('第 2 步完成:新短链接已生效,旧短链接已失效。', 'success')
} catch (err) {
emergencyLogAppend(`✘ 失败:${err.message || err}`)
setNotice(`第 2 步失败:${err.message || err}`, 'error')
} finally {
setButtonBusy(btn, false)
}
}
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)
// 首页导航防爆紧急替换
$('#btnEmergencyRotate').addEventListener('click', handleEmergencyRotate)
$('#btnEmergencyRelink').addEventListener('click', handleEmergencyRelink)
$('#btnEmergencyCopyToken').addEventListener('click', () => copyText(el.emergencyTokenOut.value, 'token 已复制。'))
$('#btnEmergencyCopyShortUrl').addEventListener('click', () => copyText(el.emergencyShortUrlOut.value, '短链接已复制。'))
$('#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()
initEmergencyMonths()
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')
})
})()