用户诉求:管理端与前端完全分离,本地探针只跑 /admin,前端部署到 GitHub Pages 并硬编码管理地址,探测仍在本地常驻。用户自己搭隧道给本地套 HTTPS。 硬限制(务必先看):GitHub Pages 强制 HTTPS,而浏览器「混合内容」策略禁止 HTTPS 页面 fetch HTTP 资源 —— 这与 CORS 无关,改设置也没用。 因此 settings.dataUrl 必须与面板页面同为 HTTPS。 服务端 - 只读接口(/api/status、/api/monitors、/api/events、/api/v2/getMonitors、 /api/refresh)按 settings.corsOrigins 白名单回显 Access-Control-Allow-Origin - /api/admin/* 一律不放行跨域,预检直接 403 —— 外部页面既读不到也改不了 - 绝不使用 '*':白名单里写 '*' 不放行任何来源 前端 - settings.dataUrl 存在时,所有 api/ 相对路径自动改写到该地址 - 数据源标记为「远程探针(跨域)」,页脚可区分 - 管理界面新增两项配置:跨域数据源地址、允许跨域的页面地址(改完立即生效) 关键设计:origin 匹配必须考虑端口 origin 是「协议 + 主机 + 端口」的完整组合。最初白名单存 http://localhost(无端口), 导致本地前端跑在 localhost:5173 时被拒 —— 浏览器实测 Failed to fetch 而 curl 能过。 修法:回环主机(localhost / 127.0.0.1)忽略端口放行,其余要求完整 origin 精确匹配。 测试:verify-security 25→40 项,新增 [D3] CORS 白名单 14 条 + [D4] '*' 写法 1 条。 全项目 8 套 334 项通过。 附记一个测试上的教训:跨域 fetch 时 JS 读不到 Access-Control-Allow-Origin (CORS 规范规定只有同源能读),所以断言「是否放行」要看 fetch 有没有拿到数据, 不能看响应头 —— 我一开始用 headers.get() 判断,拿到 null 误以为失败。
625 lines
25 KiB
JavaScript
625 lines
25 KiB
JavaScript
/**
|
||
* 管理后台前端:绑定 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 = [
|
||
`<div class="admin-stat"><b>${s.total ?? '–'}</b><label>监控数</label></div>`,
|
||
`<div class="admin-stat up"><b>${s.up ?? '–'}</b><label>在线</label></div>`,
|
||
`<div class="admin-stat down"><b>${s.down ?? '–'}</b><label>离线</label></div>`,
|
||
`<div class="admin-stat"><b>${s.avgLatency != null ? `${s.avgLatency} ms` : '–'}</b><label>平均延迟</label></div>`,
|
||
`<div class="admin-stat"><b>${s.avgUptime90 != null ? `${s.avgUptime90}%` : '–'}</b><label>90天可用率</label></div>`,
|
||
].join('');
|
||
const st = d.status ?? {};
|
||
const channels = d.notify?.channels ?? [];
|
||
$('updated-at').innerHTML =
|
||
`数据更新于 ${esc(new Date(st.dataUpdatedAt ?? Date.now()).toLocaleString('zh-CN', { hour12: false }))} · ` +
|
||
`自建目标 ${st.localCount ?? 0} 个 / 配置 ${st.configCount ?? 0} 个 / UptimeRobot ${st.urCount ?? 0} 个` +
|
||
(st.urEnabled ? (st.urOk === false ? `(<b>同步异常:${esc(st.urError ?? '')}</b>)` : '(已启用)') : '(未启用)') +
|
||
(channels.length ? ` · 通知渠道 ${esc(channels.join(' + '))}` : '');
|
||
|
||
$('probe-scope').value = d.settings?.probeScope ?? 'all';
|
||
$('data-filter').value = d.settings?.dataFilter ?? 'both';
|
||
$('admin-url').value = d.settings?.adminUrl ?? '';
|
||
$('admin-url').dataset.saved = $('admin-url').value;
|
||
$('data-url').value = d.settings?.dataUrl ?? '';
|
||
$('cors-origins').value = (d.settings?.corsOrigins ?? []).join(', ');
|
||
|
||
// 维护窗口目标下拉
|
||
const sel = $('mw-id');
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">全部目标</option>' + d.monitors.map((m) => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
|
||
if (cur) sel.value = cur;
|
||
|
||
const now = Date.now();
|
||
$('mw-list').innerHTML = d.maintenance.length
|
||
? d.maintenance.map((w, i) => {
|
||
const active = Date.parse(w.from) <= now && now <= Date.parse(w.to);
|
||
const scope = w.id ? (d.monitors.find((m) => m.id === w.id)?.name ?? w.id) : '全部目标';
|
||
return `<div class="mw-item${active ? ' active' : ''}">
|
||
<span class="mw-tag ${active ? 'active' : 'idle'}">${active ? '进行中' : '待生效'}</span>
|
||
<span class="mw-time">${esc(String(w.from).replace('T', ' ').slice(0, 16))} → ${esc(String(w.to).replace('T', ' ').slice(0, 16))}</span>
|
||
<span class="mw-note">${esc(scope)}${w.note ? ` · ${esc(w.note)}` : ''}</span>
|
||
<button type="button" class="admin-btn admin-btn-sm admin-btn-danger" data-del="${i}">删除</button>
|
||
</div>`;
|
||
}).join('')
|
||
: '<p class="admin-muted">暂无维护窗口。</p>';
|
||
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) => `<tr>
|
||
<td title="${esc(m.name)}">${esc(m.name)}</td>
|
||
<td>${esc(m.group)}</td>
|
||
<td>${esc(m.type)}</td>
|
||
<td class="mono" title="${esc(m.target)}">${esc(m.target)}</td>
|
||
<td>${m.interval != null ? `${m.interval}s` : '–'}</td>
|
||
</tr>`).join('');
|
||
|
||
const labels = { down: '故障', up: '恢复', tls: '证书', maintenance: '维护' };
|
||
const statusOf = { down: 'down', up: 'up', tls: 'pending', maintenance: 'up' };
|
||
$('event-list').innerHTML = d.events.length
|
||
? d.events.map((e) => `<tr data-status="${esc(statusOf[e.type] ?? 'up')}">
|
||
<td title="${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}">${esc(new Date(e.t).toLocaleString('zh-CN', { hour12: false }))}</td>
|
||
<td><span class="ev-badge">${esc(labels[e.type] ?? e.type)}</span></td>
|
||
<td title="${esc(e.name)}">${esc(e.name)}</td>
|
||
<td class="mono" title="${esc(e.detail ?? '')}">${esc(e.detail ?? '')}</td>
|
||
</tr>`).join('')
|
||
: '<tr><td colspan="4">暂无事件</td></tr>';
|
||
|
||
const tf = d.twofa ?? {};
|
||
$('twofa-summary').innerHTML = `已绑定于 ${esc(tf.boundAt ?? '–')} · 剩余恢复码 ${tf.recoveryLeft ?? 0} 枚 · 当前会话 ${tf.sessions ?? 0} 个`
|
||
+ (tf.recoveryLow ? ' · <b style="color:var(--pending-text)">恢复码不足 3 枚,请重新绑定以获取新的一批</b>' : '');
|
||
}
|
||
|
||
async function refresh() {
|
||
const r = await api('/overview');
|
||
if (r.ok) renderConsole(r.data);
|
||
else if (r.status === 401) { $('console-panel').hidden = true; $('login-panel').hidden = false; }
|
||
}
|
||
|
||
/* ---------- 绑定 ---------- */
|
||
|
||
$('setup-start').addEventListener('click', async () => {
|
||
const key = $('setup-key').value;
|
||
msg($('setup-key-msg'), '');
|
||
const r = await api('/2fa/setup', { method: 'POST', body: { key } });
|
||
if (!r.ok) { msg($('setup-key-msg'), errText(r.data?.error, r.data?.hint), 'err'); return; }
|
||
setupDraft = { setupToken: r.data.setupToken, secret: r.data.secret, uri: r.data.uri };
|
||
$('setup-key').value = '';
|
||
$('step-key').hidden = true;
|
||
$('step-secret').hidden = false;
|
||
$('secret-text').textContent = r.data.secret.replace(/(.{4})/g, '$1 ').trim();
|
||
$('uri-text').textContent = r.data.uri;
|
||
try { renderQr(r.data.uri, $('qr')); } catch (err) { msg($('confirm-msg'), `二维码生成失败,可手动输入密钥:${err.message}`, 'err'); }
|
||
$('confirm-code').focus();
|
||
});
|
||
|
||
$('copy-secret').addEventListener('click', () => copy(setupDraft?.secret ?? ''));
|
||
$('copy-uri').addEventListener('click', () => copy(setupDraft?.uri ?? ''));
|
||
|
||
async function copy(text) {
|
||
if (!text) return;
|
||
try { await navigator.clipboard.writeText(text); } catch { /* 非安全上下文下 clipboard 不可用 */ }
|
||
}
|
||
|
||
$('confirm-btn').addEventListener('click', async () => {
|
||
const code = $('confirm-code').value.trim();
|
||
msg($('confirm-msg'), '');
|
||
const r = await api('/2fa/confirm', { method: 'POST', body: { setupToken: setupDraft?.setupToken, code } });
|
||
if (!r.ok) { msg($('confirm-msg'), errText(r.data?.error), 'err'); return; }
|
||
$('step-secret').hidden = true;
|
||
$('step-recovery').hidden = false;
|
||
setupDraft.confirmCode = r.data.confirmCode;
|
||
$('recovery-list').innerHTML = r.data.recoveryCodes.map((c) => `<code>${esc(c)}</code>`).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');
|
||
});
|
||
|
||
$('admin-url').addEventListener('change', async (e) => {
|
||
const v = e.target.value.trim();
|
||
const r = await api('/admin-url', { method: 'POST', body: { adminUrl: v } });
|
||
if (r.ok) {
|
||
msg($('action-msg'), v ? `面板页脚将显示指向 ${v} 的「管理」入口` : '已清空,面板不再显示管理入口', 'ok');
|
||
refresh();
|
||
} else {
|
||
msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
|
||
e.target.value = $('admin-url').dataset.saved ?? '';
|
||
}
|
||
});
|
||
|
||
async function saveRemoteSettings() {
|
||
const dataUrl = $('data-url').value.trim();
|
||
const corsOrigins = $('cors-origins').value.split(',').map((s) => s.trim()).filter(Boolean);
|
||
const r = await api('/remote', { method: 'POST', body: { dataUrl, corsOrigins } });
|
||
if (r.ok) {
|
||
msg($('action-msg'), dataUrl ? `跨域数据源:${r.data.dataUrl};已放行 ${r.data.corsOrigins.length} 个来源` : '已清空跨域数据源', 'ok');
|
||
refresh();
|
||
} else {
|
||
msg($('action-msg'), errText(r.data?.error, r.data?.hint), 'err');
|
||
}
|
||
}
|
||
|
||
$('data-url').addEventListener('change', saveRemoteSettings);
|
||
$('cors-origins').addEventListener('change', saveRemoteSettings);
|
||
|
||
$('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(); |