后端(新增 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
150 lines
5.4 KiB
TypeScript
150 lines
5.4 KiB
TypeScript
/**
|
||
* 文章编辑 —— 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) });
|
||
}
|
||
}
|