Files
blog/editor-api/README.md
T
zqlit 5e9d328841 fix(排序): 同日文章 date 补全时分秒 + EdgeOne 部署重试增强
现象:小乖同一天发的第二篇排到了第一篇后面。

根因:front matter 的 date 只写到年月日(写入侧只收得到日期),
同一天多篇的时间戳完全并列 → 排序退化成比标题,前台/后台都错。

改动:
- 数据(6 篇):用目录名/slug 里的时间戳把 date 补全为
  'YYYY-MM-DDTHH:MM:SS+08:00',slug 即写作时刻。每篇仅 1 行 diff。
  2026-10-05 两篇(必须)、2026-06-02 / 06-03 四篇(同一 bug 的历史遗留)。
- 写入侧根治
  - editor-api/src/posts.mjs:新建时「只到天」补北京时间此刻;
    保存时「同日保真」——没换日子则原有时分秒一字符不动。
  - write-server/src/lib/posts.ts:parseDate 按 Asia/Shanghai 归日,
    列表同天排序不再受 UTC 时区漂移影响。
- 回归测试:新增 editor-api/test/date-time.mjs(8 项,自带一次性临时仓库)。
- .cnb.yml:EdgeOne stage 重试 3 次/20s → 5 次/30s。
  构建节点到 api.edgeone.ai 偶发 15s 超时会被 CLI 误报
  「Invalid EDGEONE_PAGES_API_TOKEN」,纯网络抖动,令牌没坏。
- .gitignore:忽略 *.zip 与 .edgeone/(误留的 blog.zip 达 361MB)。
2026-10-05 21:54:03 +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,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`(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`(根目录)的「文章编辑后端」一节。