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:
1 parent
4795a6c1fd
commit
cac64f18c7
24 files changed
+3597
-221
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user