Files
blog/editor-api/server.mjs
T
zqlit 5f27ad321d chore(备份): 异地备份定案为 OpenList 离线 bundle,撤销全部 git 辅仓
用户定案:「辅助仓就用 openlist,其他不再考虑」。
据此把前一轮为 Gitee 铺的路全部收回,git 远端只剩 CNB 一个。

一、git 配置收口
- `pushall` 别名 `origin + gitee` → `!git push origin main`(只推唯一远端)
- 移除 `gitea` remote(自建 23.254.236.47:3001)—— 远端仓库本身没删,
  需要时可 `git remote add` 恢复;改动前配置存
  `.workbuddy-backup/git-remotes.20261006-211516.txt`
- 确认无 gitee 相关 credential 残留

二、发布链路去 Gitee 化(6 处)
- `deploy/editor-api/bootstrap.sh`
    · 删掉 GITEE_URL / GITEE_SSH_KEY 两个变量与「没给私钥就降级」的分支
    · PUSH_REMOTES 默认 → origin
    · 原「配 gitee 辅仓远端」一节改为「清理退役远端」循环(gh/gitee/gitea),
      让从旧部署续用的工作区自动恢复干净
- `docker-compose.editor.yml`、`editor-api/server.mjs` → 默认值 origin
- `editor-api/README.md` → 变量表同步
- `editor-api/Dockerfile` → 注释里的「CNB / GitHub」改「CNB」
- `blog-admin/src/routes/rss/tools.ts` → deploy-notify 的注释里
  「与 Gitea Actions 的构建通知配套」改为中性描述
  (该轮询链路 2026-10-04 起已被 CNB 国内节点直传取代)

三、`scripts/setup-cnb-remotes.sh` 重写为单远端模式
- 去掉 GITEE_URL / GITEE_TOKEN 参数、校验、凭据写入与自检提示
- 新增「清理退役远端」步骤
- 凭据处理改为「已存在空的 credential.helper 就不再添加」,
  不再用 --replace-all —— 本仓另有一个从 `$HOME/.workbuddy/secrets/cnb-token`
  读令牌的自定义 helper,那是有效的,不能被脚本抹掉

四、备份升级为「唯一辅仓」的配置
- 保留份数 3 → 7(一周窗口;每份 605.5 MB ≈ 4.2 GB,F50 有 256 GB)
    · `scripts/backup-task.cmd` 默认参数 --keep 7
    · `scripts/backup-bundle.mjs` 的 KEEP 默认值同步为 7
      (原先写的是 2,一直被命令行参数掩盖着)
- 远端目录 `/本地/备份` → `/本地/备份/blog-bundle`:
  根目录是用户自己在用的(放着 github-zqlit-*、local-repos-* 等手工备份),
  实测发现直接放根下的 bundle 已被清掉 —— 改子目录隔离,避免混放与误删

五、新增 `scripts/backup-run.mjs`:备份的推荐入口 + 失败告警
- 读 `.workbuddy-backup/openlist-backup.env`(只补空缺,环境变量优先)
- 跑 backup-bundle.mjs 并实时透传输出,同时留一份日志尾部
- 退出码非 0 → 经 `scripts/send_mail.js` 发告警邮件(附日志尾部与常见原因);
  成功默认不发,`--notify-success` 才发
- 退出用 `process.exitCode` 而非 `process.exit()`,避免截断未排干的 stdout
- 发信失败不改判备份退出码 —— 通知不该掩盖真正的故障
- 理由:这是当前**唯一**的异地备份,而「每天自动跑」的任务最典型的失败模式
  恰恰是静默的(F50 被带出门、换了网段、OpenList 没起来、口令改过……),
  没有告警就要等到真要用备份那天才发现
- `scripts/backup-task.cmd` 改调它

六、文档
- `架构总览.md`
    · §1.2 地址地图:备份行改指 F50/OpenList;通知行补「兼做备份失败告警」
    · §2 旅程图:双推改单推,并说明备份换了介质
    · §5.1 / §5.2 推送与远端:只剩 origin;补「已移除远端」表与恢复命令;
      GitHub 退役记录保留并补上「CI 定义也已删除」
    · §5.3 由「辅仓选型」改为「异地备份的定案」—— 明确不走 git 远端;
      平台对比数据保留备查,并注明 `bin/linux/hugo` 出库不必再做了
    · §5.6 补「唯一备份」定位、专属子目录、失败告警、keep 7、SMTP 配置键,
      实测数据更新为本次复测值
    · §6 待办:#2 定案、#3 不必做、#4 已移除、#8 已更新、#10 定位升级,
      新增 #11(F50 目录使用约定)
- `CNB构建落地方案.md` §4.0:双远端改单远端,脚本示例去掉 Gitee 参数
- `README.md`:推送说明改单推;脚本表补 backup-run.mjs

实测(2026-10-06,本轮复测):
  bundle 7.4s / AES-256-GCM 加密 1.3s / 上传 19.2s(31.6 MB/s)
  / 读回 sha256 一致 → 端到端 60.6s,退出码 0
  告警邮件链路已实测(发出一封「备份成功」验证信)

★ 一处过程记录,供以后避免重复踩坑:
  中途我把「本机沙箱里 `env -u ... cmd > file` 会让输出整个消失」
  误判成 process.exit 截断 stdout,并据此改了日志实现;
  随后用 `env -u FOO echo hi > file`(同样零输出)证伪 ——
  那是沙箱文件重定向的伪影,与脚本无关。相关改动已回滚,
  只留下本身无害的 process.exitCode 写法。
2026-10-06 21:27:28 +08:00

889 lines
34 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 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').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')
);
});