Files
blog/editor-api/README.md
T
zqlit b39dc29753 chore(远端): 移除 GitHub,改为「CNB 主仓 + Gitee 辅仓」
起因:GitHub 账号被平台标记,随后仓库被按 AUP 合规条款清空 ——
远端只剩一条孤立提交(无父提交、无内容):

    c21d5669  2026-10-06 20:08:32 +0800  zqlit
    chore: remove repository content (AUP compliance)

同时远端 main 的历史与本地**完全分叉**:两边只共享 2024-07-11 的 Initial commit,
之后每个提交 SHA 都不同(远端那份 989 提交、剥离了 .env/私钥/大二进制)。
所以 `git push gh main` 无法快进,也不可能再当备份用。

改动:

- `git remote remove gh`(改动前配置存 .workbuddy-backup/git-remotes.20261006-201354.txt)
- `pushall` 别名 → `git push origin main; git push gitee main`
- `scripts/setup-cnb-remotes.sh`:参数化第二个远端为 Gitee,
  顺带把「若残留 gh remote 就清掉」做进脚本;Gitee 凭据走 wincred
- `架构总览.md`:§2 发布旅程图、§5.2 远端表、§6 遗留待办全部改指向;
  新增 §5.2 的 GitHub 退役记录(含那条孤立提交的原文)
  与 §5.3「Gitee 辅仓的两条硬约束」实测核算
- `CNB构建落地方案.md`、`README.md`:远端与 pushall 说明同步
- **发布链路一起去 GitHub 化**(这几处原先都写死了 gh):
    · `deploy/editor-api/bootstrap.sh`  GH_URL/GH_SSH_KEY → GITEE_URL/GITEE_SSH_KEY,
      remote `gh` → `gitee`,known_hosts/私钥文件名跟着改;
      保留原设计:**没给私钥就自动降级成只推 origin**,不会让每次发布都报错
    · `docker-compose.editor.yml`   PUSH_REMOTES → origin,gitee
    · `editor-api/server.mjs`       默认值 → 'origin,gitee'
    · `editor-api/README.md`        变量表同步

★ 尚未解决 / 需要决策的两条 Gitee 硬约束(详见 架构总览.md §5.3):

1. **单文件 ≤ 50MB,而 `bin/linux/hugo` 是 83.1MB**
   —— 不管怎么瘦身历史,只要它还跟踪在 HEAD 里,Gitee 一律拒收。
2. 单仓库 ≤ 500MB,而 `.git` 是 620MB
   —— 移出那个 83MB 后 HEAD ≈ 374MB,才有余量。

另:线上 editor-api 容器的 .env 仍是 `PUSH_REMOTES=origin,gh`,
且仓库里还有一个 `gh` remote。因为推送逻辑对辅仓失败只警告、不阻断发布
(`git.mjs`:主仓成即算成),所以**线上发布没有坏**;
但要真正切到 Gitee,需要等 Gitee 仓库与私钥就位后再部署一次。
2026-10-06 20:18:29 +08:00

156 lines
8.9 KiB
Markdown
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 —— 只做「在线编辑文章」的轻量后端
给 `api.200181.xyz/admin` 的「文章编辑」tab 当后端。**零 npm 依赖**(只用 node 内置模块),
所以没有 `npm ci`、没有锁文件、没有供应链风险,镜像也小。
## 它做什么 / 不做什么
只做这一件事:读写 `content/posts/<YYYY>/<目录>/index.md`,外加把图片放进文章同级目录,
以及把改动 commit + push 触发上线。
**不做**:评论、AI 摘要、部署编排、订阅……那些在 Worker 里。
账号体系也**不在这里**(见下节),本服务只认 Worker 转发时声明的身份。
## 角色:管理员 / 编辑(2026-10-05 加)
后台有两种角色,账号体系的唯一事实源是 **Cloudflare 侧 D1 的 `users.role` 列**
(`''` 普通评论用户 / `'editor'` 编辑 / `'admin'` 管理员,与 `is_admin` 同步):
| | 管理员 | 编辑 |
|---|---|---|
| 文章列表 | 全部 | **只有自己写的** |
| 读 / 改 / 删 / 传图 | 任意文章 | **只能碰自己的**(越权一律 403) |
| 发布 | 全量 `git add content static` | **只 add 自己名下的文章目录**(不会顺走别人未发布的改动) |
| 新建文章 | 可指定作者 | 归属与署名由服务端钉死(伪造 `author_id` 无效) |
| 评论/用户/设置/订阅 | ✓ | ✗(Worker 和本服务两层都拒) |
**归属怎么记**:文章 front matter 里的 `author_id`(= 后台用户 id,数字)。
- 用 id 不用昵称 —— 管理员随时可能改昵称,按昵称比对会「丢文章」。
- 老文章(改造前写的)没有这个字段 → 回退成按 `author` 昵称认领;
编辑本人第一次保存时会自动补上 `author_id`,正式划到自己名下。
- 服务端在保存/新建时**忽略**前端传来的 `author` / `author_id`,编辑永远改不了署名。
**身份怎么传进来**(两条通道,本服务不存任何账号):
1. **Worker 反代通道**:请求带共享令牌 `X-Editor-Token`(浏览器拿不到),
Worker 鉴权通过后注入 `X-Editor-Uid` / `X-Editor-User`(encodeURIComponent 过的昵称)/
`X-Editor-Role`。令牌对得上,这组头就是可信的。
只有令牌、没有身份头 → 按管理员处理(兼容 curl 调试和旧版 Worker)。
2. **国内机直连通道**(浏览器直接开 writeapi.usj.cc/admin/):`POST /admin/login` 把
账号密码**转发给 Cloudflare 的 `/api/v2/user/access_token`** 校验,通过后签发本机
HttpOnly Cookie 会话,会话里存 `{uid, name, role}`。写文章那条链路依然不出境。
- Cloudflare 连不上时:本机管理员账号(`ADMIN_USER`/`ADMIN_PASS`)仍能登录(应急通道);
其它账号会收到 503「校验不了」,而不是误导性的 401。
- 「国内线路」一键跳转(`/admin/handoff`)的 HMAC 签名覆盖 `时间戳+uid+名字+角色`
—— **编辑跳过去还是编辑**,不会变成管理员。
## 三层结构
```
浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上)
/api/v2/editor/* 127.0.0.1:8017
只做「登录鉴权 + 注入令牌与身份转发」 真正读写文件 / git
```
- 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。
- 容器端口只绑宿主机 `127.0.0.1`,公网扫不到;外面那层是 nginx 的 `/editor-api/`。
- 鉴权复用后台登录会话:管理员和编辑都放行(`/editor/*` 只有文章相关接口),
评论/用户/设置等其它后台模块仍然只认管理员。
## 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `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,gitee` | 依次推送;主仓失败才算发布失败,辅仓失败只警告 |
| `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`(Worker 通道,附带身份头)
或本机会话 Cookie(国内直连通道),没有就 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` | 原始二进制直传,落到文章同级目录(编辑必须带 key) |
| GET | `/git/status` | 分支 / 改动文件 / 最近提交(编辑只看到自己目录的改动,返回里 `scoped:true`) |
| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push(编辑只 add 自己的目录) |
| POST | `/git/sync` | pull --rebase --autostash |
| GET/POST | `/admin/session` `/admin/login` `/admin/logout` | 国内直连后台的登录会话 |
| GET | `/admin/handoff?ts=&u=&n=&r=&t=` | 「国内线路」免登录跳转(签名覆盖身份) |
### 为什么用目录名当 id,不用 slug
仓库里**真实存在 5 组 slug 撞名**的文章(`20210901`、`20211122`、`20211128`、`20211223`、`20240602`)。
Hugo 的 permalink 是 `/:slug`,撞名时线上必有一篇被另一篇覆盖 —— 也就是说这 10 篇里有 5 篇
**线上本来就打不开**。如果按 slug 定位,编辑器会静默地打开/保存到另一篇文件上,直接毁数据。
所以:id 用目录名(文件系统保证唯一、单段路径),slug 降级为展示字段 + `slugConflict` 告警;
拿撞名的 slug 来查会返回 **409 并列出候选篇目**,绝不猜。
## 三条不会写坏老文章的底线
1. **没动 front matter → 原文一个字节都不重写。**
判断方式是「把前端传来的字段合并进已解析对象,再和已解析对象深比较」;相等就原样照抄
`frontMatterRaw`。这样解析器对冷门语法理解有偏差也无所谓。前端也配合:只回传**真正改了**的字段。
2. **换行风格原样保留**(CRLF/LF)。仓库 `.gitattributes` 是 `* text=auto`,仓库存 LF、
Windows 工作区是 CRLF,统一化会产生整文件 diff。
3. **front matter 与正文间的空行原样保留**。语料里 111 篇有空行、19 篇没有;
正文剥掉前导空行给编辑框,回写时按原文件的风格还原。
## 测试
```bash
# ① 纯函数往返: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
# ④ 角色权限矩阵(自带一次性 git 仓库,不碰真仓库)
node test/role-perm.mjs
# ⑤ date 时分秒(自带一次性临时仓库):新建补时刻、同一天不再撞、保存不抹时刻
node test/date-time.mjs
```
## 本地联调
```bash
# 1) 后端(角色测试可以直接指到 .editor-tmp/uitest/blog 那个一次性仓库)
EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs
# 2) Worker(blog-admin 目录)—— ★ 本机要先清代理环境变量,否则 wrangler dev 卡死在启动
env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY npx wrangler dev --port 8799
# .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken
# 本地 D1 要先给 users 加 role 列(线上同理,见部署清单)
```
部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。