Files
blog/blog-admin/src/routes/editor.ts
T
zqlit c217d20c30 feat(editor): 在线编辑文章(Worker 前端 + 轻量 docker 后端)
后端(新增 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
2026-10-04 21:16:06 +08:00

150 lines
5.4 KiB
TypeScript
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.
/**
* 文章编辑 —— Worker 侧只做「鉴权 + 反代」,真正的读写在后端的 editor-api 容器里。
*
* 为什么要绕这一跳,而不是让浏览器直连后端:
* 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到;
* 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口;
* 3. 复用后台已有的登录会话 —— 这里只需要 isAdminRequest,不用再造一套账号体系。
*
* 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`,
* 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。
* 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。
*/
import type { Ctx } from '../router';
import { fail, json } from '../lib/util';
import { isAdminRequest } from '../lib/session';
async function guard(ctx: Ctx): Promise<Response | null> {
if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) {
return fail(403, '需要管理员权限');
}
if (!ctx.env.EDITOR_API_BASE) {
return fail(503, '后端未配置:缺少 EDITOR_API_BASE');
}
if (!ctx.env.EDITOR_TOKEN) {
return fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)');
}
return null;
}
function target(ctx: Ctx, path: string, withQuery = false): string {
const root = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
return root + path + (withQuery && ctx.url.search ? ctx.url.search : '');
}
/** 发到后端并原样把 JSON 与状态码带回来 */
async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise<Response> {
const deny = await guard(ctx);
if (deny) return deny;
let res: Response;
try {
res = await fetch(target(ctx, path, withQuery), {
...init,
headers: {
'X-Editor-Token': String(ctx.env.EDITOR_TOKEN),
...(init.headers || {}),
},
});
} catch (e) {
// 后端容器挂了 / DNS 不通 / 被墙,都要说人话,别让前端看到 Failed to fetch
return fail(502, '编辑后端不可达:' + (e instanceof Error ? e.message : String(e)));
}
const text = await res.text();
let data: unknown = text;
try {
data = text ? JSON.parse(text) : null;
} catch {
/* 后端没返回 JSON 就原样透传 */
}
return json(data, { status: res.status });
}
/** 把浏览器发来的 JSON 原样转发(体积上限交给后端把关) */
async function relayJson(ctx: Ctx, path: string, method: string): Promise<Response> {
let body: string;
try {
body = await ctx.req.text();
} catch {
return fail(400, '读取请求体失败');
}
return relay(ctx, path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body || undefined,
});
}
// ---------------------------------------------------------------- 文章
export const listPosts = (ctx: Ctx) => relay(ctx, '/posts', { method: 'GET' }, true);
export const getPost = (ctx: Ctx) =>
relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'GET' });
export const createPost = (ctx: Ctx) => relayJson(ctx, '/posts', 'POST');
export const savePost = (ctx: Ctx) =>
relayJson(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), 'PUT');
export const deletePost = (ctx: Ctx) =>
relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'DELETE' });
// ---------------------------------------------------------------- 图片
/**
* 图片走**原始二进制**直传(不分片、不组 multipart):
* POST /api/v2/editor/upload?name=xxx.png&slug=<文章 slug>
* Content-Type: image/png
* Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。
*/
export async function upload(ctx: Ctx): Promise<Response> {
const deny = await guard(ctx);
if (deny) return deny;
const ct = ctx.req.headers.get('Content-Type') || '';
if (!ct.startsWith('image/')) return fail(400, '只接受图片');
try {
const res = await fetch(target(ctx, '/upload', true), {
method: 'POST',
headers: {
'X-Editor-Token': String(ctx.env.EDITOR_TOKEN),
'Content-Type': ct,
},
body: ctx.req.body,
});
const text = await res.text();
let data: unknown = text;
try {
data = text ? JSON.parse(text) : null;
} catch {
/* 透传 */
}
return json(data, { status: res.status });
} catch (e) {
return fail(502, '上传失败:' + (e instanceof Error ? e.message : String(e)));
}
}
// ---------------------------------------------------------------- git
export const gitStatus = (ctx: Ctx) => relay(ctx, '/git/status', { method: 'GET' });
export const publish = (ctx: Ctx) => relayJson(ctx, '/git/publish', 'POST');
export const sync = (ctx: Ctx) => relay(ctx, '/git/sync', { method: 'POST' });
/** 健康检查:让后台能显示「编辑后端是否在线」 */
export async function health(ctx: Ctx): Promise<Response> {
if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) return fail(403, '需要管理员权限');
if (!ctx.env.EDITOR_API_BASE) return json({ ok: false, reason: '缺少 EDITOR_API_BASE' });
try {
const res = await fetch(target(ctx, '/health'), { method: 'GET' });
return json({ ...(await res.json() as object), reachable: true });
} catch (e) {
return json({ ok: false, reachable: false, reason: e instanceof Error ? e.message : String(e) });
}
}