feat(editor): 在线编辑文章(Worker 前端 + 轻量 docker 后端)

后端(新增 editor-api/,零 npm 依赖,只用 node 内置模块):
- 只做文章相关:列表/读取/新建/保存/删除/图片上传/git 状态·发布·同步
- front matter 往返保真:未改动的块按字节照抄,CRLF/块标量/引号写法都不动
- 列表用目录名当 id,slug 撞名不再静默改错文件(返回 409 列候选)
- 鉴权只有一条路:X-Editor-Token(只存在 Worker 侧,浏览器拿不到)
- 端口只绑 127.0.0.1,由宿主机 nginx 反代出去

部署(新增 deploy/editor-api/bootstrap.sh):
- 一条命令在新机器上完成 克隆仓库→写 .env→起容器→健康检查
- 状态全在两个目录(/srv/blog 仓库工作区 + /srv/editor-api 配置),
  迁移 = 复制目录或在新机重跑本脚本,容器本身无状态

Cloudflare Worker 侧(blog-admin):
- src/routes/editor.ts:鉴权 + 反代,浏览器只跟 Worker 说话
- 管理面板新增「文章编辑」:两栏布局 + 快捷插入面板(13 项短代码,
  与 write-server 的 ShortcutPanel 一致)+ 底部 草稿/保存/发布
- 系统设置改为 schema 驱动的表单,且**以运行时实际生效的配置为准**
  (frontend_conf/captcha/moderator/ip_region/site_default + KV human_check),
  修复「表单显示一套、评论系统跑另一套」的脱节问题

验证:tsc 0 错;front matter 往返 130/130;保存往返 130/130;
API e2e 44/44;无头 Chrome UI e2e 14/14
This commit is contained in:
zqlit committed 2026-10-04 21:16:06 +08:00
1 parent 48a2fd697f
commit c217d20c30
24 files changed
+3597 -221

No files matched your search

+238
View File
@@ -0,0 +1,238 @@
/**
* editor-api 端到端回归测试(对真实仓库跑,测完自动还原)。
*
* node test/api-e2e.mjs [baseUrl] [token]
*
* 重点验证「不写坏老文章」这条底线:
* ① 只改正文 → front matter 区块必须**逐字节不变**(含 pid / ai_comment 等冷门键)
* ② 原样回存 → 整个文件必须**逐字节不变**
* ③ 改 front matter 字段 → 其余字段必须原样保留
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
const BASE = process.argv[2] || 'http://127.0.0.1:8017';
const TOKEN = process.argv[3] || 'devtoken';
const ROOT = path.resolve(import.meta.dirname, '../..');
const TARGET_SLUG = '20251121';
const TARGET_FILE = path.join(ROOT, 'content/posts/2025/2025-11-21-除草日记/index.md');
// ★ API 的定位键是**目录名**(全仓唯一),不是 slug —— 仓库里有 5 组 slug 撞名,
// 按 slug 取会静默地操作到另一篇文章上。这里刻意用带中文的目录名走一遍,
// 顺带验证 URL 编码链路。
const TARGET_ID = path.basename(path.dirname(TARGET_FILE));
const P = (id) => '/posts/' + encodeURIComponent(id);
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const read = (p) => fs.readFileSync(p, 'utf8');
let pass = 0;
const fails = [];
function check(name, cond, extra = '') {
if (cond) {
pass++;
console.log(' ✅ ' + name);
} else {
fails.push(name + (extra ? ' → ' + extra : ''));
console.log(' ❌ ' + name + (extra ? ' → ' + extra : ''));
}
}
async function call(method, p, body, raw) {
const res = await fetch(BASE + p, {
method,
headers: {
'X-Editor-Token': TOKEN,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined,
});
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
return { status: res.status, data };
}
const original = read(TARGET_FILE);
const originalSha = sha(TARGET_FILE);
console.log('\n== ① 鉴权 ==');
{
const noToken = await fetch(BASE + '/posts');
check('无令牌 → 401', noToken.status === 401, '实际 ' + noToken.status);
const health = await fetch(BASE + '/health');
check('/health 免鉴权', health.status === 200, '实际 ' + health.status);
const bad = await call('GET', '/posts');
check('带令牌 → 200', bad.status === 200, '实际 ' + bad.status);
}
console.log('\n== ② 读取 ==');
let post;
{
const r = await call('GET', P(TARGET_ID));
post = r.data;
check('能用目录名(含中文)定位', r.status === 200 && post.slug === TARGET_SLUG, r.status + ' / ' + post?.slug);
check('返回 id = 目录名', post.id === TARGET_ID, String(post.id));
check('front matter 解析成功', post.frontMatter && !post.frontMatterError, String(post.frontMatterError || ''));
check('冷门键 pid 已解析', post.frontMatter && post.frontMatter.pid === 133, JSON.stringify(post.frontMatter?.pid));
check('title 正确', post.title === '除草日记', String(post.title));
}
console.log('\n== ③ 原样回存 → 必须逐字节不变 ==');
{
const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
check('文件 sha256 未变', sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16) + ' vs ' + originalSha.slice(0, 16));
}
console.log('\n== ④ 只改正文 → front matter 区块必须逐字节不变 ==');
{
const body = post.content + '\n\n(测试追加的一行,稍后还原)\n';
const r = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: body });
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
const now = read(TARGET_FILE);
const fmOf = (t) => t.replace(/\r\n/g, '\n').split('\n').slice(0, t.split('\n').findIndex((l, i) => i > 0 && l.trim() === '---') + 1).join('\n');
check('front matter 区块逐字节不变', fmOf(now) === fmOf(original));
check('正文确实写进去了', now.includes('测试追加的一行'));
check('冷门键 ai_comment 仍在', now.includes('ai_comment:'));
check('pid 仍在', now.includes('pid: 133'));
// 还原
const back = await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
check('还原成功', back.status === 200 && sha(TARGET_FILE) === originalSha, sha(TARGET_FILE).slice(0, 16));
}
console.log('\n== ⑤ 改 front matter 字段 → 其余字段保留 ==');
{
const fm = { ...post.frontMatter, title: '除草日记(测试改名)' };
const r = await call('PUT', P(TARGET_ID), { frontMatter: fm, content: post.content });
check('PUT 成功', r.status === 200, JSON.stringify(r.data).slice(0, 120));
const after = (await call('GET', P(TARGET_ID))).data;
check('title 已更新', after.title === '除草日记(测试改名)', String(after.title));
check('pid 未丢', after.frontMatter.pid === 133, JSON.stringify(after.frontMatter.pid));
check('ai_comment 未丢', 'ai_comment' in after.frontMatter);
check('ai_comment 值未变(含结尾换行)', after.frontMatter.ai_comment === post.frontMatter.ai_comment);
check('categories 未丢', Array.isArray(after.frontMatter.categories) && after.frontMatter.categories.length === 1);
// 还原。注意:一旦真的改过 front matter,整块会被按规范重排
// (例如 >- 折叠块变成 | 字面块),所以这里校验的是**值**而不是字节。
// 「字节不变」的保证只适用于「没碰 front matter」的场景,见 ③ / ④。
await call('PUT', P(TARGET_ID), { frontMatter: post.frontMatter, content: post.content });
const back = (await call('GET', P(TARGET_ID))).data;
check('还原后 front matter 值与原值一致',
JSON.stringify(back.frontMatter) === JSON.stringify(post.frontMatter),
JSON.stringify(back.frontMatter).slice(0, 160));
check('还原后正文与原文一致', back.content === post.content);
check('还原后换行风格未变(CRLF/LF 保持)', (read(TARGET_FILE).includes('\r\n')) === original.includes('\r\n'));
}
console.log('\n== ⑥ 新建 → 删除(走回收站)==');
{
const slug = 'e2e-tmp-' + Date.now().toString(36);
const created = await call('POST', '/posts', {
frontMatter: { title: 'E2E 临时文章', slug, date: '2026-10-04', categories: ['测试'], tags: ['a', 'b'], draft: true },
content: '# 标题\n\n正文。\n',
});
check('新建成功', created.status === 200, JSON.stringify(created.data).slice(0, 160));
const dir = created.data?.dirPath;
check('目录已创建', !!dir && fs.existsSync(path.join(dir, 'index.md')), String(dir));
const got = await call('GET', '/posts/' + slug);
check('能读回', got.status === 200 && got.data.title === 'E2E 临时文章');
const dup = await call('POST', '/posts', { frontMatter: { title: '重名', slug }, content: '' });
check('重复 slug 被拒绝(400/409)', dup.status === 400 || dup.status === 409, '实际 ' + dup.status);
const del = await call('DELETE', '/posts/' + slug);
check('删除成功', del.status === 200, JSON.stringify(del.data).slice(0, 120));
check('目录已移走', !!dir && !fs.existsSync(dir));
if (del.data?.movedTo && fs.existsSync(del.data.movedTo)) fs.rmSync(del.data.movedTo, { recursive: true, force: true });
}
console.log('\n== ⑦ 图片上传 ==');
{
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const res = await fetch(BASE + '/upload?name=t.png&key=' + encodeURIComponent(TARGET_ID), {
method: 'POST',
headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'image/png' },
body: png,
});
const data = await res.json();
check('上传成功', res.status === 200 && data.ok, JSON.stringify(data).slice(0, 140));
if (data.fileName) {
const f = path.join(path.dirname(TARGET_FILE), data.fileName);
check('文件落盘', fs.existsSync(f));
check('返回裸文件名(仓库现行写法)', data.url === data.fileName, String(data.url));
if (fs.existsSync(f)) fs.unlinkSync(f);
}
const bad = await fetch(BASE + '/upload?name=x.txt', {
method: 'POST',
headers: { 'X-Editor-Token': TOKEN, 'Content-Type': 'text/plain' },
body: 'hi',
});
check('非图片被拒绝', bad.status === 400, String(bad.status));
}
console.log('\n== ⑧ git 状态 ==');
{
const r = await call('GET', '/git/status');
check('能取到状态', r.status === 200 && typeof r.data.branch === 'string', JSON.stringify(r.data).slice(0, 140));
console.log(' 分支 ' + r.data.branch + ' | 改动 ' + r.data.changed + ' 个文件 | 最近提交 ' + String(r.data.lastCommit).slice(0, 60));
}
console.log('\n== ⑨ slug 撞名 → 必须拒绝,不能猜 ==');
{
// 仓库真实存在:2021-09-01-Twitter… 与 2021-08-31-无悔 都用 slug 20210901
const r = await call('GET', '/posts/20210901');
check('撞名 slug → 409(而不是随便挑一篇)', r.status === 409, '实际 ' + r.status);
check('错误信息里点出候选篇目', String(r.data?.error || '').includes('无悔'), String(r.data?.error || '').slice(0, 120));
check('单篇 slug 仍可定位', (await call('GET', '/posts/20251121')).status === 200);
check('不存在的 id → 404', (await call('GET', '/posts/__不存在__')).status === 404);
}
console.log('\n== ⑩ front matter 后没有空行的文章 → 不能平白多插空行 ==');
{
// 语料里 111 篇有空行、19 篇没有;这类写法差异最容易在回存时产生脏 diff
const list = (await call('GET', '/posts?perPage=200')).data.posts;
let tested = 0;
for (const item of list) {
const r = await call('GET', P(item.id));
if (r.status !== 200 || !r.data?.filePath) continue;
const file = r.data.filePath;
const before = read(file);
// 只挑「--- 之后紧接正文」的那种
if (/\n---\r?\n[^\r\n]/.test(before) === false) continue;
await call('PUT', P(item.id), { frontMatter: r.data.frontMatter, content: r.data.content });
check('无空行文章原样回存字节不变:' + item.id,
sha(file) === crypto.createHash('sha256').update(before).digest('hex'));
fs.writeFileSync(file, before);
if (++tested >= 3) break;
}
if (!tested) console.log(' (语料里没找到这种写法,跳过)');
}
// 收尾:确保目标文件恢复原样
if (sha(TARGET_FILE) !== originalSha) {
fs.writeFileSync(TARGET_FILE, original);
check('测试后强制还原目标文件', true);
}
console.log('\n──────────────────────────────');
console.log('通过 ' + pass + ' 项,失败 ' + fails.length + ' 项');
if (fails.length) {
console.log('失败明细:');
for (const f of fails) console.log(' · ' + f);
}
process.exit(fails.length ? 1 : 0);
+93
View File
@@ -0,0 +1,93 @@
/**
* front matter 解析器回归测试:拿仓库里全部真实文章跑无损往返。
*
* node test/frontmatter-roundtrip.mjs [contentDir]
*
* 断言三件事:
* 1. split → join 能**逐字节**还原原文件(说明切分没吃掉任何字符)
* 2. parse → stringify → parse 后对象与首次解析**深相等**(说明丢了不信息)
* 3. 记录所有解析失败的样本,便于判断是否需要补语法
*/
import fs from 'node:fs';
import path from 'node:path';
import { splitFrontMatter, joinFrontMatter, parse, stringify, deepEqual } from '../src/frontmatter.mjs';
const root = process.argv[2] || path.resolve(import.meta.dirname, '../../content/posts');
function walk(dir, out = []) {
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = fs.statSync(p);
if (st.isDirectory()) walk(p, out);
else if (name.endsWith('.md')) out.push(p);
}
return out;
}
const files = walk(root);
let ok = 0;
const byteFail = [];
const parseFail = [];
const roundFail = [];
for (const f of files) {
const text = fs.readFileSync(f, 'utf8');
const rel = path.relative(root, f);
const { raw, body, eol } = splitFrontMatter(text);
if (raw == null) {
parseFail.push([rel, '没有 front matter']);
continue;
}
// 1. 逐字节还原(含原始换行风格)
if (joinFrontMatter(raw, body, eol) !== text) byteFail.push(rel);
// 2. 解析 + 往返
let first;
try {
first = parse(raw);
} catch (e) {
parseFail.push([rel, e.message]);
continue;
}
let second;
try {
second = parse(stringify(first));
} catch (e) {
roundFail.push([rel, 'stringify/parse 失败: ' + e.message]);
continue;
}
if (!deepEqual(first, second)) {
const diff = Object.keys(first).concat(Object.keys(second)).filter(
(k, i, a) => a.indexOf(k) === i && !deepEqual(first[k], second[k]),
);
roundFail.push([rel, '字段不一致: ' + diff.join(', ')]);
continue;
}
ok++;
}
const total = files.length;
console.log('样本总数 :', total);
console.log('解析 + 往返通过 :', ok);
console.log('切分不还原 :', byteFail.length);
console.log('解析失败 :', parseFail.length);
console.log('往返不一致 :', roundFail.length);
const show = (title, arr) => {
if (!arr.length) return;
console.log('\n--- ' + title + ' ---');
for (const [f, why] of arr.slice(0, 12)) console.log(' ' + f + ' || ' + why);
if (arr.length > 12) console.log(' … 另有 ' + (arr.length - 12) + ' 条');
};
show('切分不还原(必须为 0)', byteFail.map((f) => [f, 'join != 原文']));
show('解析失败', parseFail);
show('往返不一致', roundFail);
const bad = byteFail.length + parseFail.length + roundFail.length;
process.exit(bad === 0 ? 0 : 1);
+106
View File
@@ -0,0 +1,106 @@
/**
* savePost 全量往返测试 —— 对仓库里**每一篇**真实文章跑一遍「读出来原样存回去」。
*
* 为什么要有这个:frontmatter-roundtrip 只测 split/join 这两个纯函数,
* 但真正会写坏文章的是 getPost → savePost 这条链路(中间的 content 剥了前导换行、
* 回写时又要还原,任何一处不对称都会在部分文章上产生脏 diff)。
* 所以这里必须用真文件、真篇数跑,不能只挑一篇(19/130 的写法差异就藏在里面)。
*
* 测试期间会**真实改写文件**,但每篇测完都立刻按原字节还原;
* 结尾再逐篇核对 sha256,任何一篇没还原都会让脚本失败并指名道姓。
*
* 跑法:node test/save-roundtrip.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { makePosts } from '../src/posts.mjs';
import { splitFrontMatter } from '../src/frontmatter.mjs';
const repoRoot = path.resolve(process.env.BLOG_ROOT || path.join(import.meta.dirname, '..', '..'));
const contentDir = path.join(repoRoot, 'content', 'posts');
const trashDir = path.join(repoRoot, '.editor-tmp', 'trash');
const posts = makePosts({ contentDir, trashDir, repoRoot, defaultAuthor: '' });
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const fmBlockOf = (text) => {
const { raw } = splitFrontMatter(text);
if (raw == null) return null;
return '---\n' + raw + '\n---';
};
const list = posts.listPosts({ perPage: 500 });
console.log('待测文章: ' + list.total + ' 篇 (仓库 ' + repoRoot + ')\n');
let pass = 0;
let fail = 0;
const failures = [];
/** 记录每篇的原始字节,最后统一核对还原情况 */
const originals = new Map();
for (const item of list.posts) {
// 用 id(目录名)而不是 slug —— 仓库里有 slug 撞名,按 slug 会拿到 409 或别的文章
const post = posts.getPost(item.id);
if (!post || typeof post.filePath !== 'string') {
fail++;
failures.push(item.id + ' → getPost 拿不到 filePath');
continue;
}
const file = post.filePath;
const before = fs.readFileSync(file);
originals.set(file, before);
const beforeHash = crypto.createHash('sha256').update(before).digest('hex');
let mark = '✓';
// ---- ① 原样回存:必须逐字节不变(这是最重要的一条)----
posts.savePost(item.id, { content: post.content, frontMatter: post.frontMatter });
const afterHash = sha(file);
if (afterHash !== beforeHash) {
mark = '✗';
failures.push(item.id + ' → 「原样回存」字节变了 ' + beforeHash.slice(0, 12) + ' ≠ ' + afterHash.slice(0, 12));
}
// ---- ② 只改正文:front matter 区块必须逐字节不变 ----
const midText = fs.readFileSync(file, 'utf8');
const fmBefore = fmBlockOf(midText);
posts.savePost(item.id, { content: post.content + '\n\n<!-- probe -->\n' });
const editedText = fs.readFileSync(file, 'utf8');
if (fmBefore !== fmBlockOf(editedText)) {
mark = '✗';
failures.push(item.id + ' → 改正文时 front matter 被改动');
}
if (!editedText.includes('<!-- probe -->')) {
mark = '✗';
failures.push(item.id + ' → 正文没写进去');
}
// ---- ③ 立刻还原成原字节 ----
fs.writeFileSync(file, before);
if (mark === '✓') pass++;
else fail++;
if (mark === '✗') console.log(mark + ' ' + item.slug);
}
// ---- ④ 总核对:全仓库不允许残留任何被改动的文章 ----
console.log('\n--- 还原核对 ---');
let notRestored = 0;
for (const [file, buf] of originals) {
const now = fs.readFileSync(file);
if (!now.equals(buf)) {
notRestored++;
console.log(' ✗ 未还原: ' + file);
}
}
if (notRestored === 0) console.log(' ✓ ' + originals.size + ' 篇全部还原为原始字节');
console.log('\n================================');
console.log('通过 ' + pass + ' / 失败 ' + fail + ' 未还原 ' + notRestored);
if (failures.length) {
console.log('\n失败明细:');
for (const f of failures) console.log(' · ' + f);
}
process.exit(fail || notRestored ? 1 : 0);