Files
blog/editor-api/README.md
T
zqlit 448b898e40 feat(远端): 恢复自建 Gitea 代码辅仓,OpenList 降为备选备份
用户定案:「自建 gitea 同步一下吧,openlist 也作为备选备份」。
于是把上一轮「撤销全部 git 辅仓」的决定部分回退:在线那一路回到自建 Gitea,
OpenList 上的加密 bundle 从「唯一异地备份」改为「备选异地备份」。

两层不是重复,而是失效模式不同:
  · 自建 Gitea(在线、可增量、可浏览)—— 不受任何第三方平台规则约束
  · 离线 bundle(离线、单一文件、完整历史)—— 平台全挂也能恢复

一、实际同步
- Gitea 位于 23.254.236.47:3001(Gitea 28.0.0),本机直连即可推,
  **不需要广州中转机**(旧结论「沙箱跑不通 git smart HTTP」只针对当时的代理)
- 远端停在 ed38f938(2026-10-04),落后 57 个提交,且是本地 HEAD 的**祖先**
  → 一次 fast-forward 追平,**未强推、未丢历史**
- 同步后 origin 与 gitea 均指向 5f27ad32

二、git 配置
- 恢复 remote `gitea`(凭据编在 URL 里,只落本机 .git/config,不入库)
- `pushall` 回到双推:
    !git push origin main; ec=$?; git remote | grep -qx gitea && git push gitea main; exit $ec
  · 用 `;` 而非 `&&` → 主仓挂了辅仓照样推
  · `exit $ec` → 退出码仍以主仓为准,辅仓成功不掩盖主仓失败
  · gitea 段带存在性前置判断 → 没配辅仓时静默跳过,不白报错

三、发布链路恢复 gitea(前一轮刚去掉,现按新定案加回)
- `deploy/editor-api/bootstrap.sh`
    · 新增 GITEA_URL / GITEA_USER / GITEA_PASS(凭据必须现给,不写死在脚本里)
    · PUSH_REMOTES 默认 → origin,gitea;没给 GITEA_PASS 则降级为 origin
    · 恢复「配 gitea 远端」一节;退役远端名单去掉 gitea(只留 gh / gitee)
      —— 否则新部署会把现行辅仓当成退役远端清掉
    · 结尾补一句「未启用 Gitea 辅仓」的提示
- `docker-compose.editor.yml`、`editor-api/server.mjs` → 默认 origin,gitea
- `editor-api/README.md` → 变量表同步;`editor-api/Dockerfile` → 注释补 http 情形

四、`scripts/setup-cnb-remotes.sh` 改回双远端模式
- 头部沿革、USAGE、凭据段全部重写;GITEA_PASS 走环境变量,不入库
- 退役远端循环只清 gh / gitee;pushall 写同一份定义

五、文档
- `架构总览.md`:§1.2 地址地图加「代码辅仓」行、备份行降为「备选」;
  §2 旅程图改双推;§5.1 推送重写(含别名实际定义与三条取舍);
  §5.2 远端改双行表 + Gitea 接入细节;§5.3 由「不走 git 远端」改为
  「远端与备份的分工」(三层各自的失效模式);§5.6 定位改备选;
  §6 待办 #2/#4/#7/#10 更新
- `CNB构建落地方案.md` §4.0 目标形态与定案块、§4.1 第 4 条
- `README.md` 发布流程图、推送说明、脚本表
- `scripts/backup-run.mjs` 头部注释:不再是「唯一备份」

实测:
  git push gitea main → ed38f938..5f27ad32  main -> main
  git pushall         → origin / gitea 双向同步,退出码 0
  bash -n 校验 bootstrap.sh 与 setup-cnb-remotes.sh 均通过
2026-10-06 21:42:08 +08:00

156 lines
9.2 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,gitea` | 依次推送的远端;**主仓失败才算发布失败**,其余远端失败只警告。`origin`=CNB 主仓(推送即触发构建),`gitea`=自建 Gitea 代码同步辅仓(需给 `GITEA_PASS` 才配得上,见 `bootstrap.sh`)。异地备份不在此列 —— 走 OpenList 上的加密 bundle(`scripts/backup-bundle.mjs`) |
| `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`(根目录)的「文章编辑后端」一节。