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

+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);