Files
blog/editor-api/server.mjs
T

888 lines
34 KiB
JavaScript
Raw Normal View History

/**
* 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 tls from 'node:tls';
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: normalizeRole(v.role),
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'(只有自己的文章)/ 'ssl'(只有证书管家)。
*
* 两条通道:
* ① 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: normalizeRole(s.role),
};
}
// 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: normalizeRole(req.headers['x-editor-role']),
};
}
return null;
}
/** 角色归一化:只有这三个值合法;空/未知一律按最高权限处理(保持改造前语义) */
function normalizeRole(v) {
const r = String(v || '').trim();
if (r === 'editor' || r === 'ssl') return r;
return 'admin';
}
/**
* 直连后台登录时,把账号密码交给 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 };
// ★ 三种角色都要认。早期版本只认 editor/admin,SSL 管理员登进来会被当成
// 普通评论用户直接拒掉 —— 那它就没法从国内线路用证书管家了。
const role = u.is_admin
? 'admin'
: u.role === 'editor'
? 'editor'
: u.role === 'ssl'
? 'ssl'
: '';
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 而不在本机:
* · 它握着 AES-GCM 保险箱的密钥(TOKEN_SECRET)和全部云厂商凭据,
* 再多一处副本就多一个失守点;
* · 逻辑(探测、ACME、部署)与 artalk-cf 共用同一套 KV 与后台会话。
* 本机只做一跳转发,语义与 rssProxy 完全一致。
*
* ★ 与 RSS 的区别:**SSL 管理员也要能走**(证件管家就是给它用的),
* 所以这条路径的放行判定是 role ∈ {admin, ssl},不是 admin-only。
*/
const SSL_PATH_PREFIXES = (env.SSL_PATHS || '/api/v2/ssl')
.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);
}
// ------------------------------------------------------------------ 证书探针
/**
* 对 `host:443` 做一次真实 TLS 握手,把对端**证书正文**读出来。
*
* ★ 为什么这件事必须在本机做:
* Cloudflare Workers 上拿不到对端证书 —— `cloudflare:sockets` 的 Socket 没有
* `getPeerCertificate`,而 `node:tls` 的该方法在 Workers 里是个**桩函数**
* (一调用就 `Error: getPeerCertificate is not implemented`)。本机是真 Node,
* 方法是真实现的,所以证书管家把这些探测请求派发到这里来跑。
*
* ★ 安全边界:这个端点**只读公开的 TLS 握手结果**(到期日 / SAN / 签发方),
* 不含任何密钥、不会带回证书私钥,也不会因为我们配了哪家 DNS 而泄露什么 ——
* 任何人 `openssl s_client -connect <host>:443` 都能拿到同样的信息。
* 它仍然排在鉴权之后(见下方调用处),不开放给匿名访问。
*
* ★ 字段名注意:Node 的 `getPeerCertificate()` 返回的是 **OpenSSL 风格**
* `valid_from` / `valid_to`(形如 `Dec 7 06:59:59 2026 GMT`,日号前两个空格),
* **不是** `notBefore` / `notAfter`。
*/
function sslProbe(host, timeoutMs = 8000, connectTo) {
return new Promise((resolve) => {
const h = String(host || '').trim().toLowerCase();
if (!h || !/^[a-z0-9.-]+$/.test(h)) {
return resolve({ ok: false, error: '域名不合法' });
}
// ★ connectTo:连到**指定地址**(源站 IP),SNI 仍用 h。
// 用途:域名挂在 CDN / 边缘加速(如腾讯云 EO)后面时,公网握手拿到的是
// 边缘证书;只有直连源站 IP 才能量到「我们真正要部署的那张证书」。
const dial = String(connectTo || '').trim();
if (dial && !/^[a-z0-9.:-]+$/.test(dial)) {
return resolve({ ok: false, error: 'connect 参数不合法(只允许域名或 IP)' });
}
const dialHost = dial || h;
let settled = false;
let socket = null;
const finish = (r) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
socket?.destroy();
} catch {
/* 已经关了 */
}
resolve(r);
};
const timer = setTimeout(
() => finish({ ok: false, handshakeFailed: true, error: '握手超时(' + timeoutMs + 'ms)' }),
timeoutMs,
);
try {
socket = tls.connect(
{
host: dialHost,
port: 443,
servername: h, // SNI:同 IP 多站时必须带,否则拿到的是默认证书
// 证书过期 / 自签也要能把正文读出来 —— 探针的任务是「报告」,不是「拒绝」
rejectUnauthorized: false,
},
() => {
let cert = null;
try {
cert = socket.getPeerCertificate(false);
} catch {
/* 理论上不会发生在真 Node 上 */
}
if (!cert || !cert.valid_to) {
return finish({ ok: false, error: '对端没有返回证书' });
}
let proto = null;
try {
proto = socket.getProtocol();
} catch {
/* 忽略 */
}
finish({
ok: true,
handshakeFailed: false,
tlsVersion: proto || undefined,
notAfter: toMs(cert.valid_to),
notBefore: toMs(cert.valid_from),
subject: (cert.subject && cert.subject.CN) || '',
issuer: (cert.issuer && (cert.issuer.O || cert.issuer.CN)) || '',
altNames: parseAltNames(cert.subjectaltname || ''),
});
},
);
socket.on('error', (e) => {
const msg = (e && e.message) || String(e);
// 握手失败(SNI 不匹配 / 协议不支持)与 DNS/连接失败要分开说,
// 前者修证书,后者修网络,运维动作完全不同。
finish({
ok: false,
error: msg,
handshakeFailed: /handshake|tls|ssl|certificate|alert/i.test(msg),
});
});
} catch (e) {
finish({ ok: false, error: (e && e.message) || String(e) });
}
});
}
/** OpenSSL 时间串 → 毫秒时间戳(`Dec 7 06:59:59 2026 GMT`) */
function toMs(v) {
if (v == null) return undefined;
if (typeof v === 'number') return v > 1e12 ? v : v * 1000;
const t = Date.parse(v);
return Number.isFinite(t) ? t : undefined;
}
/** `DNS:usj.cc, DNS:*.usj.cc` → `['usj.cc','*.usj.cc']` */
function parseAltNames(s) {
return String(s || '')
.split(',')
.map((x) => x.trim().replace(/^DNS:/i, ''))
.filter(Boolean);
}
// ------------------------------------------------------------------ 路由
const routes = [];
const route = (method, pattern, handler) => {
routes.push({ method, segs: pattern.split('/').filter(Boolean), handler });
};
const R = '<param>';
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: normalizeRole(s.role) },
});
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();
// ★ 三种角色都要收 —— 早期只认 admin/editor,SSL 管理员点「国内线路」
// 会被这里静默拒掉(403 handoff rejected),看起来像「按钮坏了」。
if (role !== 'admin' && role !== 'editor' && role !== 'ssl') 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;
}
// SSL 管理员只该碰证书管家。白名单式放行:除 /ssl 相关的转发路径外一律拒。
// ★ 放在所有业务分支之前,且用「允许清单」而不是「排除清单」——
// 将来新增文章相关路由时,SS​L 角色默认是**被拒**的,不会悄悄放行。
if (me.role === 'ssl') {
const allow =
url.pathname === '/health' ||
url.pathname === '/admin/session' ||
url.pathname === '/admin/logout' ||
url.pathname === '/ssl-probe' ||
SSL_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'));
if (!allow) {
json(res, 403, { error: 'SSL 管理员只能使用证书管家' });
return;
}
}
// ---------------------------------------------------------- 证书探针(本地实现)
// ★ 放在 SSL 反代分支**之前**:这条路径不反代去 Cloudflare,而是在本机跑。
// 原因见 sslProbe() 注释 —— Workers 拿不到对端证书正文,本机才行。
// 鉴权已经在上面的 identify() 完成(Cookie 会话或 X-Editor-Token 二选一)。
// 角色判定与证书管家一致:admin / ssl 放行,editor 拒绝。
if (url.pathname === '/ssl-probe') {
if (me.role !== 'admin' && me.role !== 'ssl') {
json(res, 403, { error: '需要管理员或 SSL 管理员权限' });
return;
}
const host = String(url.searchParams.get('host') || '').trim();
if (!host) {
json(res, 400, { ok: false, error: '缺少 host 参数' });
return;
}
// ★ connect=<IP>:直连源站(SNI 仍是 host)。用于量「源站证书」而不是
// CDN/边缘节点那张 —— 否则续期判定会永远「还很新」。
const connectTo = String(url.searchParams.get('connect') || '').trim();
const t0 = Date.now();
const info = await sslProbe(host, 8000, connectTo || undefined);
json(res, 200, { ...info, host, connect: connectTo || null, ms: Date.now() - t0 });
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;
}
// 证书管家:同样反代到 Cloudflare(实现和密钥都在那边)。
// ★ 放行 admin + ssl —— 这条路径本来就是为了让 SSL 管理员用的。
// 注意判定是**枚举放行**,不是 `!== 'editor'`:以后加角色不会静默放权。
if (SSL_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) {
if (me.role !== 'admin' && me.role !== 'ssl') {
json(res, 403, { error: '需要管理员或 SSL 管理员权限' });
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')
);
});