后端(新增 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
7.7 KiB
在线编辑文章 · 集成到 api.200181.xyz/admin
日期:2026-10-04(定稿) 结论:已实现并本地全链路验证通过。 形态:前端长在现有
api.200181.xyz/admin面板里(新增「文章编辑」tab), 后端是一个零 npm 依赖的轻量 Docker 容器(editor-api/),替代臃肿的write-server。
0. 一句话
把 write-server/ 里只有「文章在线编辑」这一件事剥出来,做成 200 行级的轻量后端
容器;/admin 面板加一个 tab,Worker 只做「登录鉴权 + 注入令牌转发」。
1. 最终架构
浏览器
│ (只用后台已有的管理员登录态,不需要任何新凭据)
▼
Cloudflare Worker api.200181.xyz blog-admin/src/routes/editor.ts
/api/v2/editor/* 只做两件事:
│ ① isAdminRequest() 鉴权
│ X-Editor-Token 只存在这里 ② 注入 X-Editor-Token 反代
▼
nginx post.usj.cc /editor-api/ ──► 127.0.0.1:8017
editor-api 容器(零 npm 依赖)
读写 /srv/blog/content/posts/**,
图片落文章同级目录,
发布 = git add/commit/pull --rebase/push
三层各自的职责被切得很干净:
| 层 | 文件 | 职责 | 故意不做的 |
|---|---|---|---|
| 前端 | blog-admin/public/admin/admin.js(新增 ~450 行) |
列表 / 编辑 / 图片粘贴 / 保存 / 发布 | 不碰令牌、不直连后端 |
| Worker | blog-admin/src/routes/editor.ts(新增,150 行) |
鉴权 + 反代,10 条路由 | 不碰仓库 |
| 后端 | editor-api/(新增目录) |
读写 Markdown + git | 不做账号、评论、AI、部署编排 |
2. 顺带发现的安全问题(write-server 生产环境)
评估过程中实测发现 write-server 线上完全没有鉴权(只读核实,未做任何写操作):
| 现象 | 证据 |
|---|---|
| 后端公网裸奔 | 23.254.236.47:8016 直接返回 200 |
| 文章全量泄露 | https://post.usj.cc/api/posts 无凭据返回全部 133 篇 |
X-Auth-User 无签名 |
只读明文用户名,可任意伪造 |
| 写操作全裸 | PUT / DELETE / upload / deploy / ai 全部无鉴权、无 middleware |
| nginx 未加 auth_basic | — |
这正是这次重写的动机:不是把 write-server 搬个家,而是换成一个「默认安全」的小东西—— 令牌只在 Worker 里、端口只绑回环、没配令牌直接拒绝启动。
3. 改动清单
新增
editor-api/server.mjs+editor-api/src/{frontmatter,posts,git}.mjs—— 零依赖后端editor-api/test/{frontmatter-roundtrip,save-roundtrip,api-e2e}.mjs—— 三个测试editor-api/{Dockerfile,README.md}、docker-compose.editor.ymlblog-admin/src/routes/editor.ts—— Worker 反代blog-admin/.dev.vars.example的编辑器两项
修改
blog-admin/src/index.ts—— 注册 10 条/editor/*路由blog-admin/src/types.ts—— Env 加EDITOR_API_BASE/EDITOR_TOKENblog-admin/wrangler.toml——[vars]加EDITOR_API_BASEblog-admin/public/admin/admin.js—— 新增「文章编辑」tab(+约 450 行)blog-admin/public/admin/admin.css—— 编辑器样式(+约 80 行).gitignore—— 加.editor-tmp//.editor-trash/
4. 测试结论(全部通过)
| 测试 | 结果 | 说明 |
|---|---|---|
frontmatter-roundtrip.mjs |
130/130 | split/join 逐字节还原 + parse/stringify 深相等 |
save-roundtrip.mjs |
130/130 | 每篇「读出来原样存回去」sha256 不变,测完全部还原 |
api-e2e.mjs |
44/44 | 鉴权/读取/回存/只改正文/改字段/新建删除/上传/git/撞名/无空行 |
Worker 反代链路(.editor-tmp/verify-relay.mjs) |
26/26 | 真实走 wrangler dev,含鉴权 403 / 404 / 409 |
| 后台 UI(无头 Chrome 真点) | 全通过 | 登录→列表→打开→改→保存→发布弹层,4 张截图 |
过程中抓到并修掉的真 bug:
- front matter 后的空行:语料里 111 篇有空行、19 篇没有;
getPost把前导空行剥掉后 信息丢了,savePost又无条件补一个 → 那 19 篇一保存就被平白多插一个空行。 修法:readIndex记bodyLead,回写时按原文件风格还原。 - slug 撞名静默改错文件(详见第 6 节):定位键从 slug 换成目录名。
- CRLF/LF、块标量 chomping(
|-/|/|+)、getPost漏filePath/eol、 重复 slug 返回 500 —— 都是早期测试抓到的。
5. 上线步骤
① 服务器侧(23.254.236.47)
# 1. 博客仓库 checkout 到 /srv/blog(main 分支,git 工作区)
# (原 write-server 用的那份仓库可以直接沿用,确认 remote 是 CNB + GitHub 两段)
# 2. 放 compose 与令牌
cd /srv/blog
mkdir -p /srv/editor-trash
cat > .env <<'EOF'
EDITOR_TOKEN=<openssl rand -hex 32 生成的值>
EOF
docker compose -f docker-compose.editor.yml up -d --build
curl -s http://127.0.0.1:8017/health # 应返回 {"ok":true,...}
② nginx(post.usj.cc 的 server 块里加一段)
# —— 文章编辑后端:只给 Cloudflare Worker 反代用 ——
# 令牌本身就是鉴权(X-Editor-Token),所以这里不再叠 auth_basic。
location /editor-api/ {
proxy_pass http://127.0.0.1:8017/; # 末尾的 / 会剥掉 /editor-api 前缀
proxy_http_version 1.1;
proxy_set_header Host $host;
client_max_body_size 25m; # 图片直传
proxy_read_timeout 120s; # git push 可能慢
}
可选加固:
location里再叠allow <Cloudflare IP 段>; deny all;, 让这条路径只有 CF 能碰到。令牌泄露才是真风险,这层属于纵深防御。
③ Cloudflare 侧
cd blog-admin
npx wrangler secret put EDITOR_TOKEN # 粘贴与服务器 .env 相同的值
npx wrangler deploy
EDITOR_API_BASE 已写在 wrangler.toml 的 [vars](https://post.usj.cc/editor-api)。
④ 验证
打开 https://api.200181.xyz/admin → 「内容管理 / 文章编辑」→ 随便开一篇 → 改一个字 →
保存 → 「发布 / 同步」里确认文件出现在待发布列表。
⑤ 下线 write-server(确认新编辑器好用之后再做)
- nginx 里摘掉 write-server 的
location /(先只留location /editor-api/) docker stop write-server && docker update --restart=no write-server- 关掉
8016的公网映射(compose 里删 ports 或改绑 127.0.0.1) - 观察几天没问题,再删镜像与数据卷(删前先备份)
6. 遗留问题:slug 撞名(需要你决定)
仓库里有 5 组文章共用同一个 slug,Hugo 的 permalink 是 /:slug,所以每组里
有一篇在线上是被另一篇覆盖掉的(打不开):
| slug | 两篇 |
|---|---|
20210901 |
Twitter主题加入加载耗时… / 无悔 |
20211122 |
情侣恋爱倒计时小工具… / 这组照片的主题,咱就叫它光吧 |
20211128 |
大学生体测… / 可惜不能一直做小孩子… |
20211223 |
更换掉jsdelivr… / 放假之前最后一次的照片合集… |
20240602 |
parsec远程软件报6023错误 / idea关闭ai自动补全 |
编辑器已经把这件事标出来了(列表里黄色「URL 冲突」标签;拿撞名 slug 去查会返回 409 并列出候选篇目,不会猜)。但改哪一篇的 slug、还是让后写的那篇换个 slug,需要你定。
顺带:改 slug = 改网址,旧链接会 404。如果在意 SEO,得配套做重定向。