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
This commit is contained in:
zqlit committed 2026-10-04 21:16:06 +08:00
1 parent 4795a6c1fd
commit cac64f18c7
24 files changed
+3597 -221

No files matched your search

+72 -73
View File
@@ -6,11 +6,17 @@ import {
findOrCreateSite,
findOrCreateUser,
findUserByNameEmail,
getFrontendConf,
isCaptchaEnabled,
isIPRegionEnabled,
isPendingDefault,
} from '../lib/db';
import { setHumanCheck } from '../lib/human';
import { isHumanCheckEnabled, setHumanCheck } from '../lib/human';
import { cookComments, cookPage, cookSite, cookUser } from '../lib/cook';
import { hashPassword, isAdminRequest } from '../lib/session';
import { fail, formatDateCN, now, ok, okMsg, qInt, qp, readBody, trimTo } from '../lib/util';
import { parseSimpleYaml, toSimpleYaml } from '../lib/simple-yaml';
import { extractForm, formComments, mergeForm, SETTINGS_FORM } from '../lib/settings-form';
import { SERVER_API_VERSION } from './public';
import type { Ctx } from '../router';
@@ -1049,7 +1055,63 @@ export async function settingApply(ctx: Ctx): Promise<Response> {
if (!yaml.trim()) return fail(400, 'yaml is required');
const parsed = parseSimpleYaml(yaml);
await persistSettings(ctx, parsed, yaml);
return ok({ msg: 'Success' });
}
/**
* 设置页表单化:GET 返回 schema + 解析好的表单值 + schema 之外的剩余键。
* 前端只画表单,完全不碰 YAML;剩余键原样传回,保证表单化不丢配置。
*/
export async function settingFormGet(ctx: Ctx): Promise<Response> {
const deny = await requireAdmin(ctx);
if (deny) return deny;
const stored = await ctx.env.DB.prepare('SELECT value FROM settings WHERE key = ?')
.bind('config_yaml')
.first<{ value: string }>();
const yaml = stored?.value || (await loadTemplate(ctx, 'zh-CN'));
const parsed = parseSimpleYaml(yaml);
// ★ 表单值必须以「运行时实际生效的配置」为准:
// config_yaml 只是落地文本,可能从没保存过、也可能早就过期;
// 评论区真正读的是 D1 的 frontend_conf/captcha/moderator/ip_region/site_default
// 和 KV 的 human_check。不覆盖的话表单显示一套、评论系统跑另一套。
await overlayRuntimeConf(ctx.env, parsed);
const { values, extra } = extractForm(parsed);
return ok({ schema: SETTINGS_FORM, values, extra, custom: Boolean(stored?.value) });
}
/** 把运行时实际生效的配置覆盖到解析结果上(只影响表单展示;保存时又会写回 config_yaml,两边收敛) */
async function overlayRuntimeConf(env: Env, parsed: Record<string, unknown>): Promise<void> {
const asObj = (v: unknown): Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
// frontend.*:/conf 实际下发的就是 DEFAULT_FRONTEND_CONF ⊕ frontend_conf
parsed.frontend = { ...asObj(parsed.frontend), ...(await getFrontendConf(env)) };
parsed.captcha = { ...asObj(parsed.captcha), enabled: await isCaptchaEnabled(env) };
parsed.moderator = { ...asObj(parsed.moderator), pending_default: await isPendingDefault(env) };
parsed.ip_region = { ...asObj(parsed.ip_region), enabled: await isIPRegionEnabled(env) };
parsed.site_default = await defaultSiteName(env);
parsed.human_check = { ...asObj(parsed.human_check), enabled: await isHumanCheckEnabled(env) };
}
/** 表单值 → YAML → 与手改 YAML 完全相同的持久化路径 */
export async function settingFormApply(ctx: Ctx): Promise<Response> {
const deny = await requireAdmin(ctx);
if (deny) return deny;
const body = await readBody(ctx.req);
const values = body.values && typeof body.values === 'object' ? body.values : {};
const extra = body.extra && typeof body.extra === 'object' ? body.extra : {};
const merged = mergeForm(values, extra);
const yaml = toSimpleYaml(merged, formComments());
const parsed = parseSimpleYaml(yaml); // 自己写的必须自己能读回来
await persistSettings(ctx, parsed, yaml);
return ok({ msg: 'Success' });
}
/** settingApply / settingFormApply 共用的落库与回填逻辑 */
async function persistSettings(ctx: Ctx, parsed: Record<string, unknown>, yaml: string): Promise<void> {
await ctx.env.DB.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES ('config_yaml', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
@@ -1058,13 +1120,14 @@ export async function settingApply(ctx: Ctx): Promise<Response> {
.run();
// 回填前端配置
if (parsed.frontend && typeof parsed.frontend === 'object') {
await writeSetting(ctx, 'frontend_conf', parsed.frontend);
const frontend = parsed.frontend;
if (frontend && typeof frontend === 'object' && !Array.isArray(frontend)) {
await writeSetting(ctx, 'frontend_conf', frontend);
}
// 验证码
const captchaMode =
parsed?.captcha?.mode ?? parsed?.captcha?.captcha_mode ?? parsed?.captcha?.enabled;
const cap = parsed.captcha as Record<string, unknown> | undefined;
const captchaMode = cap?.mode ?? cap?.captcha_mode ?? cap?.enabled;
const captchaOn =
captchaMode === true || captchaMode === 'always' || captchaMode === 'once' || captchaMode === 'on';
await writeSetting(ctx, 'captcha', { enabled: captchaOn, mode: captchaMode ?? 'off' });
@@ -1080,16 +1143,15 @@ export async function settingApply(ctx: Ctx): Promise<Response> {
}
// 审核
const pendingDefault =
parsed?.moderator?.pending_default ??
parsed?.moderator?.pendingDefault ??
parsed?.moderator?.pending;
const mod = parsed.moderator as Record<string, unknown> | undefined;
const pendingDefault = mod?.pending_default ?? mod?.pendingDefault ?? mod?.pending;
if (pendingDefault !== undefined) {
await writeSetting(ctx, 'moderator', { pendingDefault: toBool(pendingDefault) });
}
// IP 归属地
const ipRegion = parsed?.ip_region?.enabled ?? parsed?.ip_region?.ip_region_enabled;
const ipr = parsed.ip_region as Record<string, unknown> | undefined;
const ipRegion = ipr?.enabled ?? ipr?.ip_region_enabled;
if (ipRegion !== undefined) {
await writeSetting(ctx, 'ip_region', { enabled: toBool(ipRegion) });
}
@@ -1100,8 +1162,6 @@ export async function settingApply(ctx: Ctx): Promise<Response> {
await writeSetting(ctx, 'site_default', siteDefault.trim());
await findOrCreateSite(ctx.env, siteDefault.trim(), ctx.env.SITE_URL);
}
return ok({ msg: 'Success' });
}
export async function settingTemplate(ctx: Ctx): Promise<Response> {
@@ -1134,67 +1194,6 @@ function toBool(v: unknown): boolean {
return s === 'true' || s === '1' || s === 'on' || s === 'yes' || s === 'always';
}
/**
* 极简 YAML 解析:只认「嵌套 map + 标量 + 标量列表」,
* 足够读出设置页里我们关心的那几个键;其余内容原样保留在 config_yaml 里。
*/
export function parseSimpleYaml(text: string): Record<string, any> {
const root: Record<string, any> = {};
const stack: { indent: number; node: Record<string, any> }[] = [{ indent: -1, node: root }];
for (const rawLine of text.split(/\r?\n/)) {
if (!rawLine.trim() || /^\s*#/.test(rawLine)) continue;
const indent = rawLine.match(/^\s*/)![0].length;
const line = rawLine.trim();
const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
if (!m) continue;
const key = m[1];
let value = m[2];
// 去掉行内注释(只处理 " #" 且不在引号里的情况)
if (value && !/^['"]/.test(value)) {
const hash = value.search(/\s+#/);
if (hash >= 0) value = value.slice(0, hash).trim();
}
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
const parent = stack[stack.length - 1].node;
if (!value) {
const child: Record<string, any> = {};
parent[key] = child;
stack.push({ indent, node: child });
continue;
}
parent[key] = coerce(value);
}
return root;
}
function coerce(value: string): unknown {
let v = value.trim();
if (
(v.startsWith('"') && v.endsWith('"') && v.length > 1) ||
(v.startsWith("'") && v.endsWith("'") && v.length > 1)
) {
v = v.slice(1, -1);
}
if (v === 'true' || v === 'True' || v === 'yes') return true;
if (v === 'false' || v === 'False' || v === 'no') return false;
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
if (v.startsWith('[') && v.endsWith(']')) {
return v
.slice(1, -1)
.split(',')
.map((s) => coerce(s.trim()))
.filter((s) => s !== '');
}
return v;
}
// ==================================================================== 版本
export async function version(): Promise<Response> {
+149
View File
@@ -0,0 +1,149 @@
/**
* 文章编辑 —— 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) });
}
}