editor-api —— 只做「在线编辑文章」的轻量后端
给 api.200181.xyz/admin 的「文章编辑」tab 当后端。零 npm 依赖(只用 node 内置模块),
所以没有 npm ci、没有锁文件、没有供应链风险,镜像也小。
它做什么 / 不做什么
只做这一件事:读写 content/posts/<YYYY>/<目录>/index.md,外加把图片放进文章同级目录,
以及把改动 commit + push 触发上线。
不做:评论、AI 摘要、部署编排、用户体系、订阅……那些要么在 Worker 里,要么已经不需要了。
三层结构
浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上)
/api/v2/editor/* 127.0.0.1:8017
只做「登录鉴权 + 注入 X-Editor-Token 转发」 真正读写文件 / git
- 浏览器永远接触不到
EDITOR_TOKEN:它只存在 Worker 的 secret 里。 - 容器端口只绑宿主机
127.0.0.1,公网扫不到;外面那层是 nginx 的/editor-api/。 - 鉴权是复用后台已有的管理员登录(
isAdminRequest),不用再造一套账号。
环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
EDITOR_TOKEN |
必填 | 编辑器令牌。没设直接拒绝启动。openssl rand -hex 32 生成 |
BLOG_ROOT |
/blog |
博客仓库(git 工作区)路径 |
PORT |
8017 |
监听端口 |
BIND_HOST |
0.0.0.0 |
容器内监听地址;靠 compose 的端口映射限成 127.0.0.1 |
TRASH_DIR |
/app/trash |
删除文章的回收目录(在仓库外,不进 git) |
GIT_BRANCH |
main |
工作分支 |
PUSH_REMOTES |
origin,gh |
依次推送;主仓失败才算发布失败,备份仓失败只警告 |
GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL |
blog-editor |
自动提交的作者 |
DEFAULT_AUTHOR |
空 | 新建文章时 front matter author 的默认值 |
MAX_UPLOAD_MB |
20 |
单张图片上限 |
GIT_PATHS |
content,static |
git add 的范围(不会把别的东西误提交) |
BLOG_BASE |
空 | 博客对外地址(如 https://usj.cc)。配了列表/编辑页才返回绝对链接,「预览」按钮才能直接开新窗口 |
slug 生成规则(与 write-server 一致)
新文章不填 slug 时自动生成 YYYYMMDDHHMMSS(本地时间 14 位),
目录名 <YYYY-MM-DD>-<标题段>-<slug>——与 write-server 的 makeSlug(src/lib/bot/helpers.ts)
和 computeDirPath(src/lib/bot/sessions.ts)一字不差。仓库里 2026-06 之后
的文章全是这个风格。不要用标题当 slug。
接口
除 GET /health 外一律要 X-Editor-Token,没有就 401。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /health |
健康检查(免鉴权) |
| GET | /posts?q=&page=&perPage= |
列表(含 slugConflict 撞名标记) |
| POST | /posts |
新建,body {frontMatter:{title,...}, content} |
| GET | /posts/:id |
读单篇(id = 目录名,见下) |
| PUT | /posts/:id |
保存,body {content, frontMatter} |
| DELETE | /posts/:id |
移到回收目录 |
| POST | /upload?name=x.png&key=:id |
原始二进制直传,落到文章同级目录 |
| GET | /git/status |
分支 / 改动文件 / 最近提交 |
| POST | /git/publish |
{message} → add + commit + pull --rebase + push |
| POST | /git/sync |
pull --rebase --autostash |
为什么用目录名当 id,不用 slug
仓库里真实存在 5 组 slug 撞名的文章(20210901、20211122、20211128、20211223、20240602)。
Hugo 的 permalink 是 /:slug,撞名时线上必有一篇被另一篇覆盖 —— 也就是说这 10 篇里有 5 篇
线上本来就打不开。如果按 slug 定位,编辑器会静默地打开/保存到另一篇文件上,直接毁数据。
所以:id 用目录名(文件系统保证唯一、单段路径),slug 降级为展示字段 + slugConflict 告警;
拿撞名的 slug 来查会返回 409 并列出候选篇目,绝不猜。
三条不会写坏老文章的底线
- 没动 front matter → 原文一个字节都不重写。
判断方式是「把前端传来的字段合并进已解析对象,再和已解析对象深比较」;相等就原样照抄
frontMatterRaw。这样解析器对冷门语法理解有偏差也无所谓。前端也配合:只回传真正改了的字段。 - 换行风格原样保留(CRLF/LF)。仓库
.gitattributes是* text=auto,仓库存 LF、 Windows 工作区是 CRLF,统一化会产生整文件 diff。 - front matter 与正文间的空行原样保留。语料里 111 篇有空行、19 篇没有; 正文剥掉前导空行给编辑框,回写时按原文件的风格还原。
测试
# ① 纯函数往返:split/join/parse/stringify 对全部真实文章逐字节还原
node test/frontmatter-roundtrip.mjs
# ② 保存往返(最重要):每篇都「读出来原样存回去」,断言 sha256 不变;测完自动还原
BLOG_ROOT=E:/GitHub/blog node test/save-roundtrip.mjs
# ③ 接口端到端(对真实仓库跑,测完自动还原)
BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端
node test/api-e2e.mjs http://127.0.0.1:8017 devtoken
本地联调
# 1) 后端
EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs
# 2) Worker(blog-admin 目录)
npx wrangler dev --port 8799
# .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken
部署见 部署清单.md(根目录)的「文章编辑后端」一节。