Files
blog/editor-api/server.mjs
T
zqlit bcfa229105 邮件模板微信化 + 评论老友自动填充 + CF 后台国内线路一键跳
- 邮件模板重做成微信聊天式:灰底、自己评论绿色气泡靠右、对方白色气泡靠左、
  40px 圆角方头像(weavatar,邮箱 md5 不外泄)、时间居中、#07C160 主按钮;
  布局全嵌套表格(Outlook 稳),表情内联 max-height:28px(修掉「表情太大」)、
  配图限宽 100%;管理员主题从「新的评论待审」改成「您有一个新的评论」
- 老友自动填充:新增 /comments/lookup(只回昵称+网址,绝不含邮箱;IP 限速
  30 次/10 分钟)+ 主题 comment-autofill 模块(填昵称弹提示、填邮箱自动带出
  上次的昵称/网址,事件委托不怕 Artalk 重建编辑器)
- CF 后台新增「国内线路」一键跳:/editor/handoff 用共享令牌对时间戳 HMAC
  签名 → editor-api /admin/handoff 验签发会话 Cookie(2 分钟有效、防重放),
  晚高峰丢包时直达国内机后台免重新登录
2026-10-05 07:36:26 +08:00

533 lines
19 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.
/**
* 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 偷走。
*/
const SESSION_FILE =
cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json');
const sessions = new Map(); // sid -> { user, 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) sessions.set(sid, v);
}
} 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('; '));
}
/** 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 = '<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 }) => posts.listPosts({
q: query.get('q') || '',
page: Number(query.get('page') || 1),
perPage: Number(query.get('perPage') || 20),
}));
route('POST', '/posts', async ({ req }) => {
const body = await readJson(req);
if (!body.frontMatter || !body.frontMatter.title) {
throw Object.assign(new Error('缺少标题'), { status: 400 });
}
return posts.createPost(body);
});
route('GET', `/posts/${R}`, async ({ params }) => {
const post = posts.getPost(params[0]);
if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 });
return post;
});
route('PUT', `/posts/${R}`, async ({ req, params }) => {
const body = await readJson(req, 4 * 1024 * 1024);
const out = posts.savePost(params[0], body);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
route('DELETE', `/posts/${R}`, async ({ params }) => {
const out = posts.deletePost(params[0]);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单
route('POST', '/upload', async ({ req, query }) => {
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') || '');
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('/'),
};
});
route('GET', '/git/status', async () => git.status());
route('POST', '/git/publish', async ({ req }) => {
const body = await readJson(req);
const out = await git.publish(body.message);
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;
}
// ---------------------------------------------------------- 后台登录
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .env,公网只能
// 通过 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: { name: s.user } });
return;
}
if (url.pathname === '/admin/login' && req.method === 'POST') {
if (!cfg.adminPass) {
json(res, 503, { error: '后端未配置登录密码(ADMIN_PASS),请先在 .env 里设置' });
return;
}
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);
if (!safeEqual(body.user || '', cfg.adminUser) || !safeEqual(body.password || '', cfg.adminPass)) {
noteFailure(ip);
// 不区分「账号错」和「密码错」,别帮对方缩小猜测范围
json(res, 401, { error: '账号或密码不正确' });
return;
}
failures.delete(ip);
const sid = crypto.randomBytes(32).toString('hex');
sessions.set(sid, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
json(res, 200, { ok: true, user: { name: cfg.adminUser } });
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/。
// 三重防线:签名只有两边知道 / 时间窗 2 分钟 / 同一签名只用一次。
if (url.pathname === '/admin/handoff' && req.method === 'GET') {
const ts = String(url.searchParams.get('ts') || '');
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();
const expect = hmacHex(cfg.token, ts);
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, { user: cfg.adminUser, 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。满足其一即放行。
const byToken = safeEqual(req.headers['x-editor-token'] || '', cfg.token);
if (!byToken && !readSession(req)) {
json(res, 401, { error: '未授权', need_login: true });
return;
}
// 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
if (RSS_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) {
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 });
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')
);
});