/** * 管理后台前端:绑定 2FA → 登录 → 管理面板 * * 零依赖,QR 码用内置的最小实现生成(只支持 byte 模式、ECC L、版本自动 1–10), * 覆盖 otpauth 链接(通常 60–120 字节)的长度。 */ const $ = (id) => document.getElementById(id); const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ESC[c]); const API = '/api/admin'; let setupDraft = null; // 绑定过程中的临时状态:{ setupToken, secret, uri } async function api(path, { method = 'GET', body } = {}) { const res = await fetch(`${API}${path}`, { method, credentials: 'same-origin', cache: 'no-store', headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30000), }); let data = null; try { data = await res.json(); } catch { /* 空响应体 */ } return { ok: res.ok, status: res.status, data, retryAfter: res.headers.get('retry-after') }; } function msg(el, text, kind = '') { el.textContent = text; el.className = `admin-msg${kind ? ` ${kind}` : ''}`; } const ERROR_TEXT = { 'bad-setup-key': '绑定口令不正确', 'setup-disabled': '未设置 SHIROKO_ADMIN_SETUP_KEY 环境变量,管理端未开放绑定', 'already-bound': '已完成绑定,不能重复绑定', 'no-setup': '绑定流程已失效,请重新开始', 'setup-expired': '绑定流程超过 10 分钟已失效,请重新开始', 'invalid-code': '验证码不正确', 'invalid-format': '格式不正确', replayed: '该验证码已被使用过,请等下一个时间窗', locked: '失败次数过多,请稍后再试', 'not-bound': '尚未绑定 2FA', unauthorized: '会话已失效,请重新登录', 'invalid-window': '时间无效,或结束早于开始', 'invalid-index': '窗口序号无效', 'invalid-scope': '探测范围取值无效', 'invalid-filter': '展示口径取值无效', 'notify-disabled': '未启用任何通知渠道', }; const errText = (code, hint) => ERROR_TEXT[code] ?? hint ?? '操作失败'; /* ============================================================ * QR 码生成(byte 模式 / ECC L / 版本 1–10) * 参考 ISO/IEC 18004;这里只保留生成 otpauth 链接所需的最小子集。 * ============================================================ */ // GF(256) 对数/指数表,本原多项式 0x11D const GF_EXP = new Uint8Array(512); const GF_LOG = new Uint8Array(256); (function initGf() { let x = 1; for (let i = 0; i < 255; i++) { GF_EXP[i] = x; GF_LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255]; })(); const gfMul = (a, b) => (a === 0 || b === 0 ? 0 : GF_EXP[GF_LOG[a] + GF_LOG[b]]); function rsGeneratorPoly(degree) { let poly = [1]; for (let i = 0; i < degree; i++) { const next = new Array(poly.length + 1).fill(0); for (let j = 0; j < poly.length; j++) { next[j] ^= poly[j]; next[j + 1] ^= gfMul(poly[j], GF_EXP[i]); } poly = next; } return poly; } function rsEncode(data, ecLen) { const gen = rsGeneratorPoly(ecLen); const res = new Uint8Array(data.length + ecLen); res.set(data); for (let i = 0; i < data.length; i++) { const factor = res[i]; if (factor === 0) continue; for (let j = 0; j < gen.length; j++) res[i + j] ^= gfMul(gen[j], factor); } return res.slice(data.length); } // 版本 1–10 在 ECC L 下的参数表:[总码字数, EC 每块码字数, 组1块数, 组1数据码字, 组2块数, 组2数据码字] // 索引 0 = 版本 1 const VERSIONS = [ null, [26, 7, 1, 19, 0, 0], [44, 10, 1, 34, 0, 0], [70, 15, 1, 55, 0, 0], [100, 20, 1, 80, 0, 0], [134, 26, 1, 108, 0, 0], [172, 18, 2, 68, 0, 0], [196, 20, 2, 78, 0, 0], [242, 24, 2, 97, 0, 0], [292, 30, 2, 116, 0, 0], [346, 18, 2, 68, 2, 69], ]; const ALIGN_POS = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50], }; function pickVersion(byteLen) { for (let v = 1; v <= 10; v++) { const [, ecLen, b1, d1, b2, d2] = VERSIONS[v]; const capacityBits = b1 * d1 * 8 + b2 * d2 * 8; const headerBits = 4 + (v < 10 ? 8 : 16); if (headerBits + byteLen * 8 <= capacityBits) return v; } throw new Error('内容过长,无法生成二维码'); } function buildCodewords(bytes, version) { const [, ecLen, b1, d1, b2, d2] = VERSIONS[version]; const totalData = b1 * d1 + b2 * d2; const bits = []; const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); }; put(0b0100, 4); // byte 模式 put(bytes.length, version < 10 ? 8 : 16); for (const b of bytes) put(b, 8); // 终止符最多 4 个 0,再补齐到字节边界 for (let i = 0; i < 4 && bits.length < totalData * 8; i++) bits.push(0); while (bits.length % 8 !== 0) bits.push(0); const codewords = new Uint8Array(totalData); for (let i = 0; i < bits.length; i += 8) { let byte = 0; for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j]; codewords[i / 8] = byte; } // 填充字节 0xEC / 0x11 交替 const PAD = [0xec, 0x11]; for (let i = bits.length / 8, k = 0; i < totalData; i++, k++) codewords[i] = PAD[k % 2]; // 分块 + 纠错 + 交织 const blocks = []; let off = 0; for (let i = 0; i < b1; i++) { blocks.push(codewords.slice(off, off + d1)); off += d1; } for (let i = 0; i < b2; i++) { blocks.push(codewords.slice(off, off + d2)); off += d2; } const ecBlocks = blocks.map((b) => rsEncode(b, ecLen)); const out = []; const maxData = Math.max(d1, d2); for (let i = 0; i < maxData; i++) for (const b of blocks) if (i < b.length) out.push(b[i]); for (let i = 0; i < ecLen; i++) for (const b of ecBlocks) out.push(b[i]); return out; } function makeMatrix(version, codewords) { const size = version * 4 + 17; const m = Array.from({ length: size }, () => new Array(size).fill(null)); const setFn = (r, c, v) => { if (r >= 0 && r < size && c >= 0 && c < size) m[r][c] = v; }; const finder = (r0, c0) => { for (let r = -1; r <= 7; r++) { for (let c = -1; c <= 7; c++) { const inRing = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6)); const inCore = r >= 2 && r <= 4 && c >= 2 && c <= 4; setFn(r0 + r, c0 + c, inRing || inCore ? 1 : 0); } } }; finder(0, 0); finder(0, size - 7); finder(size - 7, 0); // 校正图形(跳过与 finder 图案重叠的位置) for (const r of ALIGN_POS[version] ?? []) { for (const c of ALIGN_POS[version] ?? []) { if ((r <= 8 && c <= 8) || (r <= 8 && c >= size - 9) || (r >= size - 9 && c <= 8)) continue; for (let dr = -2; dr <= 2; dr++) { for (let dc = -2; dc <= 2; dc++) { setFn(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1 ? 1 : 0); } } } } // 定时图形 for (let i = 8; i < size - 8; i++) { setFn(6, i, i % 2 === 0 ? 1 : 0); setFn(i, 6, i % 2 === 0 ? 1 : 0); } // 预留格式信息位(15 位)与版本信息位(版本 ≥ 7) for (let i = 0; i <= 8; i++) { if (m[8][i] === null) setFn(8, i, 0); if (m[i][8] === null) setFn(i, 8, 0); } for (let i = 0; i < 8; i++) { if (m[8][size - 1 - i] === null) setFn(8, size - 1 - i, 0); if (m[size - 1 - i][8] === null) setFn(size - 1 - i, 8, 0); } if (version >= 7) { for (let i = 0; i < 18; i++) { setFn(Math.floor(i / 3), size - 11 + (i % 3), 0); setFn(size - 11 + (i % 3), Math.floor(i / 3), 0); } } const reserved = m.map((row) => row.map((v) => v !== null)); // 数据按之字形填入(从右下角起,每两列一组,方向交替) let bitIndex = 0; const dataBits = []; for (const cw of codewords) for (let i = 7; i >= 0; i--) dataBits.push((cw >> i) & 1); let upward = true; for (let col = size - 1; col > 0; col -= 2) { if (col === 6) col--; // 跳过垂直定时图形所在列 for (let k = 0; k < size; k++) { const row = upward ? size - 1 - k : k; for (const c of [col, col - 1]) { if (reserved[row][c]) continue; m[row][c] = dataBits[bitIndex] ? 1 : 0; bitIndex++; } } upward = !upward; } // 8 种掩码,按 ISO 惩罚分选最低 const maskFns = [ (r, c) => (r + c) % 2 === 0, (r) => r % 2 === 0, (r, c) => c % 3 === 0, (r, c) => (r + c) % 3 === 0, (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0, (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0, ]; const applyMask = (base, fn) => base.map((row, r) => row.map((v, c) => (reserved[r][c] ? v : (v ^ (fn(r, c) ? 1 : 0))))); const penalty = (g) => { let score = 0; // 规则 1:同色连续 ≥5 for (const line of [...g, ...g[0].map((_, c) => g.map((row) => row[c]))]) { let run = 1; for (let i = 1; i < line.length; i++) { if (line[i] === line[i - 1]) run++; else { if (run >= 5) score += run - 2; run = 1; } } if (run >= 5) score += run - 2; } // 规则 2:2×2 同色块 for (let r = 0; r < g.length - 1; r++) { for (let c = 0; c < g.length - 1; c++) { const v = g[r][c]; if (v === g[r][c + 1] && v === g[r + 1][c] && v === g[r + 1][c + 1]) score += 3; } } // 规则 4:黑白比例偏离 50% let dark = 0; for (const row of g) for (const v of row) dark += v; const pct = (dark * 100) / (g.length * g.length); score += Math.floor(Math.abs(pct - 50) / 5) * 10; return score; }; let best = null; for (let i = 0; i < 8; i++) { const cand = applyMask(m, maskFns[i]); const sc = penalty(cand); if (!best || sc < best.score) best = { score: sc, grid: cand, mask: i }; } // 写回格式信息:ECC L 的指示位为 0b01,拼接 3 位掩码编号后按 BCH(15,5) 计算校验并与 0x5412 掩码异或 const fmtData = (0b01 << 3) | best.mask; let bch = fmtData << 10; for (let i = 4; i >= 0; i--) { if (bch & (1 << (i + 10))) bch ^= 0b10100110111 << i; } const format = ((fmtData << 10) | (bch & 0x3ff)) ^ 0b101010000010010; const f = bitsOf(format, 15); for (let i = 0; i <= 5; i++) best.grid[8][i] = f[i]; best.grid[8][7] = f[6]; best.grid[8][8] = f[7]; best.grid[7][8] = f[8]; for (let i = 9; i <= 14; i++) best.grid[14 - i][8] = f[i]; for (let i = 0; i <= 7; i++) best.grid[size - 1 - i][8] = f[i]; for (let i = 8; i <= 14; i++) best.grid[8][size - 15 + i] = f[i]; best.grid[size - 8][8] = 1; // 固定的黑点 return best.grid; } function bitsOf(val, len) { const out = []; for (let i = len - 1; i >= 0; i--) out.push((val >> i) & 1); return out; } /** 把文本渲染成 canvas 上的二维码 */ function renderQr(text, canvas) { const bytes = [...new TextEncoder().encode(text)]; const version = pickVersion(bytes.length); const grid = makeMatrix(version, buildCodewords(bytes, version)); const n = grid.length; const scale = Math.floor(canvas.width / (n + 2)); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000000'; const offset = Math.floor((canvas.width - n * scale) / 2); for (let r = 0; r < n; r++) { for (let c = 0; c < n; c++) { if (grid[r][c]) ctx.fillRect(offset + c * scale, offset + r * scale, scale, scale); } } } /* ============================================================ * 流程控制 * ============================================================ */ async function boot() { const { ok, data } = await api('/status'); if (!ok) { msg($('loading'), '无法连接服务端'); return; } $('loading').hidden = true; if (!data.bound) { if (!data.setupKeyConfigured) { $('bind-panel').hidden = false; msg($('setup-key-msg'), '服务端未设置 SHIROKO_ADMIN_SETUP_KEY,绑定未开放。请在启动探针的环境里设置后重启。', 'err'); $('setup-start').disabled = true; return; } $('bind-panel').hidden = false; return; } // 已绑定:直接试拉概览,能拉到说明会话还有效 const ov = await api('/overview'); if (ov.ok) return showConsole(ov.data); $('login-panel').hidden = false; $('login-code').focus(); } function showConsole(data) { $('login-panel').hidden = true; $('bind-panel').hidden = true; $('console-panel').hidden = false; $('logout-btn').hidden = false; $('admin-ver').textContent = `shiroko-monitor v${data.version}`; renderConsole(data); } function renderConsole(d) { const s = d.stats ?? {}; $('admin-stats').innerHTML = [ `
暂无维护窗口。
'; for (const btn of $('mw-list').querySelectorAll('[data-del]')) { btn.addEventListener('click', async () => { const r = await api('/maintenance', { method: 'DELETE', body: { index: Number(btn.dataset.del) } }); if (r.ok) refresh(); else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err'); }); } $('monitor-list').innerHTML = d.monitors.map((m) => `${esc(c)}`).join('');
$('copy-recovery').onclick = () => copy(r.data.recoveryCodes.join('\n'));
});
// 绑定完成即视为本次操作已通过 2FA(刚用 App 的验证码证明了持有),
// 直接用绑定时那枚验证码换会话,不再要求用户再输一次
$('recovery-done').addEventListener('click', async () => {
$('recovery-done').disabled = true;
const r = await api('/login', { method: 'POST', body: { code: setupDraft?.confirmCode } });
$('recovery-done').disabled = false;
if (r.ok) {
const ov = await api('/overview');
if (ov.ok) return showConsole(ov.data);
}
// 兜底:让用户手动登录(同一时间窗内的码已被防重放消费,也属正常)
$('bind-panel').hidden = true;
$('login-panel').hidden = false;
$('login-code').focus();
});
/* ---------- 登录 ---------- */
$('login-btn').addEventListener('click', doLogin);
$('login-code').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
async function doLogin() {
const code = $('login-code').value.trim();
if (!code) return;
msg($('login-msg'), '');
$('login-btn').disabled = true;
try {
const r = await api('/login', { method: 'POST', body: { code } });
if (!r.ok) {
const extra = r.data?.fails ? `(已失败 ${r.data.fails} 次)` : (r.retryAfter ? `(约 ${r.retryAfter} 秒后可试)` : '');
msg($('login-msg'), errText(r.data?.error) + extra, 'err');
return;
}
$('login-code').value = '';
const ov = await api('/overview');
if (ov.ok) showConsole(ov.data);
else msg($('login-msg'), '登录成功但读取概览失败,请刷新页面', 'err');
} finally {
$('login-btn').disabled = false;
}
}
$('reset-btn').addEventListener('click', async () => {
const key = $('reset-key').value;
if (!key) return;
if (!confirm('重置 2FA 会清空已绑定的密钥与全部恢复码,需重新绑定。确定继续?')) return;
const r = await api('/2fa/reset', { method: 'POST', body: { key } });
if (r.ok) location.reload();
else msg($('login-msg'), errText(r.data?.error), 'err');
});
$('logout-btn').addEventListener('click', async () => {
await api('/logout', { method: 'POST' });
location.reload();
});
/* ---------- 管理操作 ---------- */
$('probe-scope').addEventListener('change', async (e) => {
const r = await api('/probe-scope', { method: 'POST', body: { probeScope: e.target.value } });
if (r.ok) { msg($('action-msg'), `探测范围已改为 ${e.target.value}`, 'ok'); refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
$('data-filter').addEventListener('change', async (e) => {
const r = await api('/data-filter', { method: 'POST', body: { dataFilter: e.target.value } });
if (r.ok) { msg($('action-msg'), `展示口径已改为 ${e.target.value}`, 'ok'); refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
$('probe-now').addEventListener('click', async () => {
const btn = $('probe-now');
btn.disabled = true;
msg($('action-msg'), '正在探测…');
try {
const r = await api('/probe-now', { method: 'POST' });
if (r.ok) { msg($('action-msg'), `探测完成:在线 ${r.data.stats.up},离线 ${r.data.stats.down}`, 'ok'); refresh(); }
else msg($('action-msg'), '探测失败', 'err');
} finally { btn.disabled = false; }
});
$('notify-test').addEventListener('click', async () => {
const btn = $('notify-test');
btn.disabled = true;
msg($('action-msg'), '正在发送…');
try {
const r = await api('/notify-test', { method: 'POST' });
if (!r.ok) { msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
const detail = (r.data.results ?? []).map((x) => `${x.channel} ${x.ok ? '成功' : `失败:${x.error}`}`).join(';');
msg($('action-msg'), detail || '无渠道', r.data.ok ? 'ok' : 'err');
} finally { btn.disabled = false; }
});
// 维护窗口默认填「现在起 1 小时」
(function initDefaults() {
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const now = new Date();
$('mw-from').value = fmt(now);
$('mw-to').value = fmt(new Date(now.getTime() + 60 * 60 * 1000));
})();
$('mw-add').addEventListener('click', async () => {
const from = $('mw-from').value;
const to = $('mw-to').value;
if (!from || !to) { msg($('action-msg'), '请填写开始与结束时间', 'err'); return; }
const body = { from: new Date(from).toISOString(), to: new Date(to).toISOString() };
const note = $('mw-note').value.trim();
const id = $('mw-id').value;
if (note) body.note = note;
if (id) body.id = id;
const r = await api('/maintenance', { method: 'POST', body });
if (r.ok) { msg($('action-msg'), '维护窗口已新增', 'ok'); $('mw-note').value = ''; refresh(); }
else msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
});
boot();