/** * editor-api —— 只做「在线编辑文章」的轻量后端。 * * 设计要点: * · 零 npm 依赖,只用 node 内置模块 —— 镜像小、没有供应链风险、也不用 npm ci * · 所有接口(除 /health)强制 X-Editor-Token,没有令牌一律 401。 * 前端永远拿不到这个令牌 —— 它只存在于 Cloudflare Worker 的环境变量里, * 浏览器只跟 Worker 说话,Worker 鉴权通过后再加上令牌转发过来。 * · 端口只绑到宿主机的 127.0.0.1,由 nginx 反代出去,公网扫不到。 * * 环境变量见 README.md。 */ import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { makePosts } from './src/posts.mjs'; import { makeGit } from './src/git.mjs'; // ------------------------------------------------------------------ 配置 const env = process.env; const cfg = { port: Number(env.PORT || 8017), host: env.BIND_HOST || '0.0.0.0', token: env.EDITOR_TOKEN || '', repoRoot: path.resolve(env.BLOG_ROOT || '/blog'), contentDir: path.resolve(env.BLOG_ROOT || '/blog', 'content', 'posts'), trashDir: path.resolve(env.TRASH_DIR || '/app/trash'), branch: env.GIT_BRANCH || 'main', pushRemotes: (env.PUSH_REMOTES || 'origin,gh').split(',').map((s) => s.trim()).filter(Boolean), authorName: env.GIT_AUTHOR_NAME || 'blog-editor', authorEmail: env.GIT_AUTHOR_EMAIL || 'editor@localhost', defaultAuthor: env.DEFAULT_AUTHOR || '', maxUpload: Number(env.MAX_UPLOAD_MB || 20) * 1024 * 1024, gitPaths: (env.GIT_PATHS || 'content,static').split(',').map((s) => s.trim()).filter(Boolean), // 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。 blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''), // 订阅服务所在(Cloudflare 上的 rss-api)。国内机后台的订阅/友链请求由本服务 // 反代过去,并带上共享令牌 —— 这样后台前端不必再持有「订阅口令」。 rssBase: (env.RSS_API_BASE || 'https://api.200181.xyz').replace(/\/+$/, ''), // --- 后台登录(浏览器直连本机时用,与 Cloudflare 会话是两套)--------------- // 背景:后台静态页部署在国内机,nginx 的原生 basic auth 弹窗既丑又没法做品牌, // 所以改成「自建登录页 + HttpOnly Cookie 会话」。令牌通道(X-Editor-Token) // 保持不变,供 Cloudflare Worker 侧继续使用。 adminUser: env.ADMIN_USER || 'admin', adminPass: env.ADMIN_PASS || '', sessionFile: env.SESSION_FILE || '', sessionTtlMs: Number(env.SESSION_TTL_HOURS || 168) * 3600 * 1000, }; if (!cfg.token) { console.error('[editor-api] 致命:没有设置 EDITOR_TOKEN。拒绝以无鉴权状态启动。'); process.exit(1); } const posts = makePosts(cfg); const git = makeGit({ ...cfg, repoRoot: cfg.repoRoot, paths: cfg.gitPaths }); // ------------------------------------------------------------------ HTTP 小工具 function json(res, status, data) { const body = JSON.stringify(data); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Length': Buffer.byteLength(body), }); res.end(body); } function readBody(req, limit) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > limit) { reject(Object.assign(new Error('请求体超过上限 ' + Math.round(limit / 1048576) + 'MB'), { status: 413 })); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } async function readJson(req, limit = 2 * 1024 * 1024) { const buf = await readBody(req, limit); if (!buf.length) return {}; try { return JSON.parse(buf.toString('utf8')); } catch { throw Object.assign(new Error('请求体不是合法 JSON'), { status: 400 }); } } /** 定长比较,避免令牌被逐字节试探 */ function safeEqual(a, b) { const ba = Buffer.from(String(a)); const bb = Buffer.from(String(b)); if (ba.length !== bb.length) return false; return crypto.timingSafeEqual(ba, bb); } const safeName = (s) => String(s || '') .replace(/[\\/]/g, '') .replace(/[^\w\u4e00-\u9fff.-]+/g, '_') .slice(0, 120); // ------------------------------------------------------------------ 后台会话 /** * 登录态存内存 + 落一个 JSON 文件(重启不掉线)。 * 会话 ID 是 32 字节随机数,Cookie 带 HttpOnly + SameSite=Lax, * 前端 JS 读不到,也就没法被 XSS 偷走。 * * ★ 会话里必须存**身份**(uid / name / role),不能只存一个用户名 —— * 写作后台现在有「管理员 + 编辑」两种角色,后端要靠它判断 * 「这篇文章是不是你的」以及「发布时该提交哪些路径」。 */ const SESSION_FILE = cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json'); const sessions = new Map(); // sid -> { uid, name, role, exp } function loadSessions() { try { const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); const now = Date.now(); for (const [sid, v] of Object.entries(raw || {})) { if (!v || !(v.exp > now)) continue; // 兼容改造前存的 { user, exp }:那时只有一个本机管理员账号 sessions.set(sid, { uid: String(v.uid || ''), name: String(v.name || v.user || cfg.adminUser), role: v.role === 'editor' ? 'editor' : 'admin', exp: v.exp, }); } } catch { /* 首次运行没有这个文件,正常 */ } } function saveSessions() { try { fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true }); const now = Date.now(); const obj = {}; for (const [sid, v] of sessions) if (v.exp > now) obj[sid] = v; fs.writeFileSync(SESSION_FILE, JSON.stringify(obj), { mode: 0o600 }); } catch (e) { console.error('[editor-api] 会话落盘失败(不影响运行):', e.message); } } function parseCookies(req) { const out = {}; for (const part of String(req.headers.cookie || '').split(';')) { const i = part.indexOf('='); if (i < 1) continue; out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; } const COOKIE = 'ed_sid'; /** 返回会话对象或 null(顺带清掉过期项) */ function readSession(req) { const sid = parseCookies(req)[COOKIE]; if (!sid) return null; const s = sessions.get(sid); if (!s) return null; if (s.exp <= Date.now()) { sessions.delete(sid); saveSessions(); return null; } return s; } function setCookie(res, sid, maxAgeSec) { const parts = [ COOKIE + '=' + sid, 'Path=/', 'HttpOnly', 'Secure', 'SameSite=Lax', 'Max-Age=' + maxAgeSec, ]; res.setHeader('Set-Cookie', parts.join('; ')); } /** Worker 侧对用户名做了 encodeURIComponent(HTTP 头是 byte-string,中文塞不进去) */ function decodeHeader(v) { const s = String(v || '').trim(); if (!s) return ''; try { return decodeURIComponent(s); } catch { return s; } } /** * 谁在操作 —— 返回 { uid, name, role } 或 null。role 只有 'admin' / 'editor' * 两种(普通评论用户进不来写作后台)。 * * 两条通道: * ① Cloudflare Worker 反代:带了共享令牌 X-Editor-Token(浏览器永远拿不到), * 身份在 Worker 注入的 X-Editor-Uid / X-Editor-User / X-Editor-Role 里。 * 令牌对得上 → 这组头就是可信的。 * 缺头(老版 Worker / 直接 curl 带令牌调试)按管理员处理,与改造前行为一致。 * ② 浏览器直连本机后台:身份就在自己的 HttpOnly Cookie 会话里。 */ function identify(req) { // ★ 顺序很重要:会话(具体某个人的登录态)优先于共享令牌(服务身份)。 // 共享令牌分支的默认角色是 admin —— 国内线路的 nginx 目前不注入令牌, // 但一旦配置漂移回「nginx 注入令牌」的旧版本,令牌优先会让编辑直接拿到 // 管理员权限(看到全部文章、能改别人的、能全量发布),而且是静默的。 // 靠配置保证安全太脆弱,这里用「人优先于机器」把边界写死在代码里。 const s = readSession(req); if (s) { return { uid: String(s.uid || ''), name: String(s.name || cfg.adminUser), role: s.role === 'editor' ? 'editor' : 'admin', }; } // Worker 反代通道:由 Cloudflare 发出,不带浏览器 Cookie,只有令牌 + 身份头 if (safeEqual(req.headers['x-editor-token'] || '', cfg.token)) { return { uid: String(req.headers['x-editor-uid'] || '').trim(), name: decodeHeader(req.headers['x-editor-user']), role: String(req.headers['x-editor-role'] || '').trim() === 'editor' ? 'editor' : 'admin', }; } return null; } /** * 直连后台登录时,把账号密码交给 Cloudflare 上的用户表验证。 * * 为什么不自己存一份账号:账号体系只该有一个(D1 users + role 列)。 * 国内机只做「登录这一跳」的转发,之后全靠本机 Cookie 会话 —— * 写文章那条链路依然不出境(这正是国内线路存在的意义)。 * * 返回 { ident } / { denied } / { unreachable },三态分开, * 调用处才能决定「要不要回退到本机管理员」以及给什么错误提示。 */ async function loginViaCf(identifier, password) { if (!cfg.rssBase || !identifier || !password) return { unreachable: true }; let r; try { r = await fetch(cfg.rssBase + '/api/v2/user/access_token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: identifier, password }), signal: AbortSignal.timeout(10_000), }); } catch (e) { console.error('[editor-api] 登录转发失败(CF 不可达):', e.message); return { unreachable: true }; } if (r.status === 401 || r.status === 403) return { denied: true }; if (!r.ok) { console.error('[editor-api] 登录转发异常状态:', r.status); return { unreachable: true }; } let d; try { d = await r.json(); } catch { return { unreachable: true }; } const u = d && d.user; if (!u) return { denied: true }; const role = u.role === 'editor' ? 'editor' : u.is_admin || u.role === 'admin' ? 'admin' : ''; if (!role) return { denied: true }; // 普通评论用户没有写作后台权限 return { ident: { uid: String(u.id), name: String(u.name || identifier), role } }; } /** HMAC-SHA256(key=共享令牌, msg=时间戳)的 hex,供「一键跳转」验签 */ function hmacHex(key, msg) { try { return crypto.createHmac('sha256', key).update(msg).digest('hex'); } catch { return ''; } } /** 已消费的一键跳转签名(防重放):同一签名只允许换一次会话 */ const usedHandoff = new Set(); // 暴力破解防护:同一 IP 连续失败到阈值就冷却一段时间 const FAIL_MAX = 5; const FAIL_COOL_MS = 5 * 60 * 1000; const failures = new Map(); // ip -> { n, until } function clientIp(req) { return ( String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || '?' ); } function failState(ip) { const f = failures.get(ip); if (!f) return null; if (f.until && f.until > Date.now()) return f; if (!f.until && f.n < FAIL_MAX) return f; failures.delete(ip); return null; } function noteFailure(ip) { const f = failures.get(ip) || { n: 0, until: 0 }; f.n += 1; if (f.n >= FAIL_MAX) { f.until = Date.now() + FAIL_COOL_MS; f.n = 0; } failures.set(ip, f); } // ------------------------------------------------------------------ 订阅反代 /** 需要转给订阅服务的路径前缀(后台的订阅源 / 友链模块用) */ const RSS_PATH_PREFIXES = (env.RSS_PATHS || '/api/feeds,/api/links,/api/link-apply') .split(',') .map((s) => s.trim()) .filter(Boolean); /** * 把订阅/友链请求原样转发到订阅服务(Cloudflare 上的 rss-api),带上共享令牌。 * 用途:国内机后台的订阅源 / 友链模块 —— 请求由本机出去,前端不需要知道 * 订阅口令,也不必为了这两个模块去登录 Cloudflare 后台。 * * 鉴权在调用处完成(会话或令牌),这里只管转发。 */ async function rssProxy(req, res, url) { const target = cfg.rssBase + url.pathname + url.search; const method = (req.method || 'GET').toUpperCase(); const headers = { 'X-Editor-Token': cfg.token, Accept: 'application/json' }; const ct = req.headers['content-type']; if (ct) headers['Content-Type'] = ct; let body; if (method !== 'GET' && method !== 'HEAD') { const buf = await readBody(req, 4 * 1024 * 1024); if (buf.length) body = buf; } let r; // 跨境那一跳会偶发握手失败(实测直连 CF 有时 0.3s 就断)。GET 是幂等的, // 重试一次基本就过去了 —— 不能让用户看到「订阅源加载失败」。 const tries = method === 'GET' || method === 'HEAD' ? 2 : 1; for (let i = 0; i < tries; i++) { if (i) await new Promise((s) => setTimeout(s, 400)); try { r = await fetch(target, { method, headers, body }); break; } catch (e) { if (i === tries - 1) { throw Object.assign(new Error('订阅服务连不上:' + e.message), { status: 502 }); } } } const buf = Buffer.from(await r.arrayBuffer()); res.writeHead(r.status, { 'Content-Type': r.headers.get('content-type') || 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Length': buf.length, }); res.end(buf); } // ------------------------------------------------------------------ 路由 const routes = []; const route = (method, pattern, handler) => { routes.push({ method, segs: pattern.split('/').filter(Boolean), handler }); }; const R = ''; const health = () => ({ ok: true, repo: cfg.repoRoot, branch: cfg.branch, remotes: cfg.pushRemotes, time: new Date().toISOString(), }); route('GET', '/health', health); route('GET', '/posts', async ({ query, me }) => posts.listPosts({ q: query.get('q') || '', page: Number(query.get('page') || 1), perPage: Number(query.get('perPage') || 20), }, me)); route('POST', '/posts', async ({ req, me }) => { const body = await readJson(req); if (!body.frontMatter || !body.frontMatter.title) { throw Object.assign(new Error('缺少标题'), { status: 400 }); } return posts.createPost(body, me); }); route('GET', `/posts/${R}`, async ({ params, me }) => { const post = posts.getPost(params[0], me); if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 }); return post; }); route('PUT', `/posts/${R}`, async ({ req, params, me }) => { const body = await readJson(req, 4 * 1024 * 1024); const out = posts.savePost(params[0], body, me); if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 }); return { ok: true, ...out }; }); route('DELETE', `/posts/${R}`, async ({ params, me }) => { const out = posts.deletePost(params[0], me); if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 }); return { ok: true, ...out }; }); // 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单 route('POST', '/upload', async ({ req, query, me }) => { const type = req.headers['content-type'] || ''; if (!type.startsWith('image/')) { throw Object.assign(new Error('只接受图片'), { status: 400 }); } const buf = await readBody(req, cfg.maxUpload); if (!buf.length) throw Object.assign(new Error('空文件'), { status: 400 }); const ext = (safeName(query.get('name')).split('.').pop() || 'png').toLowerCase().slice(0, 6); const base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex'); const fileName = base + '.' + ext; const target = posts.imageTargetDir(query.get('key') || '', me); fs.mkdirSync(target.dir, { recursive: true }); fs.writeFileSync(path.join(target.dir, fileName), buf); return { ok: true, fileName, url: target.bare ? fileName : (target.urlPrefix || '/') + fileName, size: buf.length, dir: path.relative(cfg.repoRoot, target.dir).split(path.sep).join('/'), }; }); /** * 发布范围:编辑角色只提交**自己文章的目录**。 * · admin → null(后端按 GIT_PATHS 全量 add content/static,行为与改造前一致) * · editor → 自己名下的文章目录列表(可能是空数组 → 什么都不提交) * 这一步必须在服务端算,不能信前端传的路径 —— 否则编辑能往 pathspec 里塞别人的目录。 */ const scopeOf = (me) => (me && me.role === 'editor' ? posts.ownedPaths(me) : null); route('GET', '/git/status', async ({ me }) => git.status(scopeOf(me))); route('POST', '/git/publish', async ({ req, me }) => { const body = await readJson(req); const out = await git.publish(body.message, scopeOf(me)); if (!out.ok) { return { ...out, __status: out.conflict ? 409 : 500 }; } return out; }); route('POST', '/git/sync', async () => { const out = await git.sync(); return out.ok ? out : { ...out, __status: out.conflict ? 409 : 500 }; }); // ------------------------------------------------------------------ 分发 function match(method, pathname) { const segs = pathname.split('/').filter(Boolean).map(decodeURIComponent); for (const r of routes) { if (r.method !== method) continue; if (r.segs.length !== segs.length) continue; const params = []; let ok = true; for (let i = 0; i < r.segs.length; i++) { if (r.segs[i] === R) params.push(segs[i]); else if (r.segs[i] !== segs[i]) { ok = false; break; } } if (ok) return { handler: r.handler, params }; } return null; } const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost'); try { // /health 免鉴权,供健康检查用;其余一律先验令牌 if (url.pathname === '/health' && req.method === 'GET') { json(res, 200, health()); return; } // ---------------------------------------------------------- 后台登录 // 这几个端点免令牌:浏览器直接访问国内机后台时用。公网只能通过 nginx 的 // /api/v2/admin/* 转发进来;失败限速防爆破。 if (url.pathname === '/admin/session' && req.method === 'GET') { const s = readSession(req); if (!s) { json(res, 401, { ok: false, mode: 'direct', need_login: true }); return; } json(res, 200, { ok: true, mode: 'direct', user: { id: s.uid || null, name: s.name || cfg.adminUser, role: s.role === 'editor' ? 'editor' : 'admin' }, }); return; } if (url.pathname === '/admin/login' && req.method === 'POST') { const ip = clientIp(req); const f = failState(ip); if (f && f.until) { json(res, 429, { error: '尝试过于频繁,请 ' + Math.ceil((f.until - Date.now()) / 1000) + ' 秒后再试' }); return; } const body = await readJson(req, 64 * 1024); const user = String(body.user || '').trim(); const password = String(body.password || ''); // ① 先交给 Cloudflare 的用户表验证 —— 账号体系只有一个(D1 users + role), // 编辑账号也才能从国内线路登录进来。 const viaCf = await loginViaCf(user, password); let ident = viaCf.ident || null; // ② CF 不可达 / 密码不对时,回退到本机管理员账号(.env 的 ADMIN_USER/ADMIN_PASS)。 // 这是「Cloudflare 挂了还要能进去改文章」的应急通道,只对管理员生效。 if (!ident && cfg.adminPass && safeEqual(user, cfg.adminUser) && safeEqual(password, cfg.adminPass)) { ident = { uid: '', name: cfg.adminUser, role: 'admin' }; } if (!ident) { noteFailure(ip); if (viaCf.unreachable) { json(res, 503, { error: '国内线路连不上 Cloudflare,无法校验账号。请从 api.200181.xyz/admin 进入,或稍后重试。', }); return; } // 不区分「账号错」和「密码错」,别帮对方缩小猜测范围 json(res, 401, { error: '账号或密码不正确' }); return; } failures.delete(ip); const sid = crypto.randomBytes(32).toString('hex'); sessions.set(sid, { uid: ident.uid, name: ident.name, role: ident.role, exp: Date.now() + cfg.sessionTtlMs }); saveSessions(); setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000)); json(res, 200, { ok: true, user: { id: ident.uid || null, name: ident.name, role: ident.role } }); return; } if (url.pathname === '/admin/logout' && req.method === 'POST') { const sid = parseCookies(req)[COOKIE]; if (sid && sessions.delete(sid)) saveSessions(); setCookie(res, '', 0); json(res, 200, { ok: true }); return; } // ------------------------------------------------------ 一键跳转(免登录) // Cloudflare 后台点「国内线路」时走这里:Worker 用共享令牌对「时间戳 + 操作者 // 身份」做 HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。 // // ★ 签名必须覆盖 uid/名字/角色三样。只签时间戳的话,编辑点一下这个按钮 // 就会在本机拿到一个管理员会话 —— 角色直接越权了。 // // 三重防线:签名只有两边知道 / 时间窗 2 分钟 / 同一签名只用一次。 if (url.pathname === '/admin/handoff' && req.method === 'GET') { const ts = String(url.searchParams.get('ts') || ''); const uid = String(url.searchParams.get('u') || ''); const name = String(url.searchParams.get('n') || ''); const role = String(url.searchParams.get('r') || ''); const sig = String(url.searchParams.get('t') || ''); const bad = () => { res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('handoff rejected'); }; if (!/^\d{13}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 120_000) return bad(); if (role !== 'admin' && role !== 'editor') return bad(); if (!name) return bad(); const expect = hmacHex(cfg.token, `${ts}\n${uid}\n${name}\n${role}`); if (!expect || !safeEqual(sig, expect)) return bad(); if (usedHandoff.has(sig)) return bad(); // 防重放:一个签名只能换一次会话 usedHandoff.add(sig); if (usedHandoff.size > 500) usedHandoff.clear(); // 简单兜底,防集合无限涨 const sid = crypto.randomBytes(32).toString('hex'); sessions.set(sid, { uid, name, role, exp: Date.now() + cfg.sessionTtlMs }); saveSessions(); setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000)); res.writeHead(302, { Location: '/admin/' }); res.end(); return; } // ---------------------------------------------------------- 鉴权 // 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到), // 或本机签发的浏览器会话 Cookie。满足其一即放行。 // me = 操作者身份(uid/name/role),下面所有路由都靠它判归属与发布范围。 const me = identify(req); if (!me) { json(res, 401, { error: '未授权', need_login: true }); return; } // 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。 // 这样国内机后台也能管订阅,且前端不需要持有订阅口令。 // ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。 if (RSS_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) { if (me.role !== 'admin') { json(res, 403, { error: '编辑角色只能管理自己的文章' }); return; } await rssProxy(req, res, url); return; } const hit = match(req.method, url.pathname); if (!hit) { json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname }); return; } const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params, me }); const status = data && data.__status ? data.__status : 200; if (data && data.__status) delete data.__status; json(res, status, data); } catch (e) { const status = e.status || 500; if (status >= 500) console.error('[editor-api]', req.method, url.pathname, e); json(res, status, { error: e.message || '服务端错误' }); } }); server.headersTimeout = 30_000; server.requestTimeout = 120_000; loadSessions(); server.listen(cfg.port, cfg.host, () => { console.log('[editor-api] 已启动 http://' + cfg.host + ':' + cfg.port); console.log('[editor-api] 仓库 ' + cfg.repoRoot); console.log('[editor-api] 分支 ' + cfg.branch + ' 推送远端 ' + cfg.pushRemotes.join(', ')); console.log('[editor-api] 回收目录 ' + cfg.trashDir); console.log( '[editor-api] 后台登录 ' + (cfg.adminPass ? '已启用(账号 ' + cfg.adminUser + ',会话 ' + Math.round(cfg.sessionTtlMs / 3600000) + ' 小时,' + sessions.size + ' 个有效会话)' : '未配置 ADMIN_PASS —— 只能走 X-Editor-Token') ); });