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 48a2fd697f
commit c217d20c30
24 files changed
+3597 -221

No files matched your search

+17
View File
@@ -10,6 +10,7 @@ import * as P from './routes/public';
import * as U from './routes/user';
import * as A from './routes/admin';
import * as H from './routes/human';
import * as E from './routes/editor';
const router = new Router();
@@ -31,6 +32,20 @@ router.get('/human/challenge', H.humanChallenge);
router.post('/human/verify', H.humanVerify);
router.get('/human/status', H.humanStatus);
// ---------------------------------------------------------------- 文章编辑
// 本 Worker 不碰仓库:只是鉴权后用 X-Editor-Token 反代到 editor-api 容器。
// 必须挂在 /api/v2 下 —— /api/*(非 v2)已被上面的 RSS 模块整个接走。
router.get('/editor/health', E.health);
router.get('/editor/posts', E.listPosts);
router.post('/editor/posts', E.createPost);
router.get('/editor/posts/:slug', E.getPost);
router.put('/editor/posts/:slug', E.savePost);
router.del('/editor/posts/:slug', E.deletePost);
router.post('/editor/upload', E.upload);
router.get('/editor/git/status', E.gitStatus);
router.post('/editor/git/publish', E.publish);
router.post('/editor/git/sync', E.sync);
// ---------------------------------------------------------------- 评论
router.get('/comments', C.listComments);
router.post('/comments', C.createComment);
@@ -64,6 +79,8 @@ router.post('/sso/exchange', U.ssoExchange);
// ---------------------------------------------------------------- 后台:设置
router.get('/settings', A.settingGet);
router.put('/settings', A.settingApply);
router.get('/settings/form', A.settingFormGet);
router.put('/settings/form', A.settingFormApply);
router.get('/settings/template/:locale', A.settingTemplate);
// ---------------------------------------------------------------- 后台:站点
+204
View File
@@ -0,0 +1,204 @@
/**
* 设置页的表单化:schema 驱动。
*
* 后端是唯一事实源 —— schema、取值、合并、序列化都在这里做,
* 前端只负责把 schema 画成表单、把用户改的值原样传回来,完全不碰 YAML。
*
* 数据流:
* GET /settings/form → parseSimpleYaml(config_yaml 或模板) → { schema, values, extra }
* PUT /settings/form → mergeForm(values, extra) → toSimpleYaml → 走 settingApply 的
* 全部回填逻辑(frontend_conf / captcha / human_check /
* moderator / ip_region / site_default),保证行为和手改 YAML 一致。
*
* ★ `extra` 是 schema 之外的键(admin_users、admin_notify、auth……),
* 前端不渲染但会原样传回 —— 这样表单化永远不会静默丢配置。
*/
export interface FieldDef {
/** 配置路径,如 'frontend.pagination.pageSize' */
path: string;
label: string;
type?: 'text' | 'bool' | 'number' | 'select';
options?: { v: string; t: string }[];
/** 解析结果里没有这个键时的表单默认值 */
def?: unknown;
/** 生成 YAML 时写在该键上方的注释 */
desc?: string;
/** 表单里的辅助说明 */
hint?: string;
}
export interface GroupDef {
title: string;
desc?: string;
fields: FieldDef[];
}
export const SETTINGS_FORM: GroupDef[] = [
{
title: '站点身份',
desc: '评论归属与站点身份',
fields: [
{ path: 'site_default', label: '默认站点名', type: 'text', desc: '默认站点名', hint: '没有匹配到 page 的 site 时用它' },
],
},
{
title: '审核与风控',
desc: '什么评论需要人来看一眼',
fields: [
{ path: 'moderator.pending_default', label: '新评论默认待审', type: 'bool', def: false, desc: '默认待审', hint: '开了之后所有新评论都要后台审核才显示' },
{ path: 'human_check.enabled', label: '人机验证(一键验证)', type: 'bool', def: false, desc: 'human_check 开关(存 KV,评论提交时读)', hint: '读者无感,可疑请求才出验证' },
{ path: 'captcha.enabled', label: '验证码', type: 'bool', def: false, desc: '启用验证码' },
{ path: 'ip_region.enabled', label: '显示 IP 归属地', type: 'bool', def: false, desc: '启用 IP 属地展示', hint: '如 CN / US' },
],
},
{
title: '评论列表',
desc: '前台评论区的排列与分页',
fields: [
{ path: 'frontend.pagination.pageSize', label: '每页评论数', type: 'number', def: 20, desc: '每页评论数' },
{ path: 'frontend.countOfCommentsOnPageInit', label: '初始加载条数', type: 'number', def: 20 },
{ path: 'frontend.nestMax', label: '最大嵌套层数', type: 'number', def: 2, desc: '最大嵌套层数' },
{
path: 'frontend.nestSort', label: '嵌套排序', type: 'select', def: 'DATE_ASC', desc: '嵌套评论排序规则',
options: [
{ v: 'DATE_ASC', t: '最早优先' },
{ v: 'DATE_DESC', t: '最新优先' },
{ v: 'VOTE_UP_DESC', t: '点赞优先' },
],
},
{
path: 'frontend.darkMode', label: '夜间模式', type: 'select', def: 'inherit', desc: '夜间模式',
options: [
{ v: 'inherit', t: '跟随页面' },
{ v: 'auto', t: '自动' },
],
},
{ path: 'frontend.heightLimit.content', label: '评论内容限高 (px)', type: 'number', def: 300 },
{ path: 'frontend.heightLimit.children', label: '子评论区限高 (px)', type: 'number', def: 400 },
],
},
{
title: '评论框',
desc: '输入区与按钮的文字、开关',
fields: [
{ path: 'frontend.placeholder', label: '占位文字', type: 'text', desc: '评论框占位文字' },
{ path: 'frontend.noComment', label: '无评论提示文字', type: 'text', desc: '无评论显示文字' },
{ path: 'frontend.sendBtn', label: '发送按钮文字', type: 'text', desc: '发送按钮文字' },
{ path: 'frontend.emoticons', label: '表情包地址', type: 'text', desc: '表情包', hint: 'JSON 配置地址,留空关闭' },
{ path: 'frontend.editorTravel', label: '评论框穿梭', type: 'bool', def: true, desc: '评论框穿梭', hint: '点「回复」时输入框跟过去' },
{ path: 'frontend.preview', label: '实时预览', type: 'bool', def: true, desc: '编辑器实时预览功能' },
{ path: 'frontend.vote', label: '投票按钮(赞)', type: 'bool', def: true, desc: '投票按钮' },
{ path: 'frontend.voteDown', label: '反对按钮(踩)', type: 'bool', def: false, desc: '反对按钮' },
{ path: 'frontend.listSort', label: '评论排序切换', type: 'bool', def: true, desc: '评论排序功能' },
{ path: 'frontend.uaBadge', label: 'UA 徽标', type: 'bool', def: false, desc: '用户 UA 徽标', hint: '显示读者的操作系统 / 浏览器' },
],
},
{
title: '头像与其他',
fields: [
{ path: 'frontend.gravatar.mirror', label: '头像源地址', type: 'text', desc: '头像 Gravatar API 地址' },
{ path: 'frontend.gravatar.params', label: '头像参数', type: 'text', desc: '头像 API 参数', hint: '如 sha256=1&d=mp&s=240' },
{ path: 'frontend.imgLazyLoad', label: '图片懒加载', type: 'select', def: 'false', desc: '图片懒加载',
options: [
{ v: 'false', t: '关闭' },
{ v: 'native', t: 'native' },
{ v: 'data-src', t: 'data-src' },
] },
{ path: 'frontend.reqTimeout', label: '请求超时 (ms)', type: 'number', def: 15000, desc: '请求超时(毫秒)' },
{ path: 'frontend.versionCheck', label: '版本检测', type: 'bool', def: true, desc: '版本检测' },
],
},
];
// ------------------------------------------------------------------ 工具
function getByPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const k of path.split('.')) {
if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return undefined;
cur = (cur as Record<string, unknown>)[k];
}
return cur;
}
function setByPath(obj: Record<string, unknown>, path: string, value: unknown): void {
const parts = path.split('.');
let cur: Record<string, unknown> = obj;
for (let i = 0; i < parts.length - 1; i++) {
const k = parts[i];
if (cur[k] === null || typeof cur[k] !== 'object' || Array.isArray(cur[k])) cur[k] = {};
cur = cur[k] as Record<string, unknown>;
}
cur[parts[parts.length - 1]] = value;
}
function deleteByPath(obj: Record<string, unknown>, path: string): void {
const parts = path.split('.');
let cur: unknown = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) return;
cur = (cur as Record<string, unknown>)[parts[i]];
}
if (cur !== null && typeof cur === 'object' && !Array.isArray(cur)) {
delete (cur as Record<string, unknown>)[parts[parts.length - 1]];
}
}
function defaultFor(f: FieldDef): unknown {
if (f.def !== undefined) return f.def;
if (f.type === 'bool') return false;
if (f.type === 'number') return '';
return '';
}
/** 把解析好的配置拆成「表单值」+「schema 外的剩余键」 */
export function extractForm(parsed: Record<string, unknown>): {
values: Record<string, unknown>;
extra: Record<string, unknown>;
} {
const extra: Record<string, unknown> = JSON.parse(JSON.stringify(parsed ?? {}));
const values: Record<string, unknown> = {};
for (const g of SETTINGS_FORM) {
for (const f of g.fields) {
const v = getByPath(extra, f.path);
values[f.path] = v === undefined ? defaultFor(f) : v;
deleteByPath(extra, f.path);
}
}
return { values, extra };
}
/** 表单值 + 剩余键 → 完整配置对象(types 走一层宽松校正,避免写出非法 YAML) */
export function mergeForm(
values: Record<string, unknown>,
extra: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = JSON.parse(JSON.stringify(extra ?? {}));
for (const g of SETTINGS_FORM) {
for (const f of g.fields) {
const raw = values ? values[f.path] : undefined;
if (raw === undefined) continue;
if (f.type === 'bool') {
setByPath(out, f.path, raw === true || raw === 'true');
} else if (f.type === 'number') {
const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
setByPath(out, f.path, String(raw).trim() === '' ? (typeof f.def === 'number' ? f.def : 0) : Number.isFinite(n) ? n : f.def ?? 0);
} else {
setByPath(out, f.path, String(raw));
}
}
}
return out;
}
/** schema 里声明的注释(生成 YAML 时带回去,配置文件不至于变成天书) */
export function formComments(): Map<string, string> {
const m = new Map<string, string>();
for (const g of SETTINGS_FORM) {
for (const f of g.fields) {
if (f.desc) m.set(f.path, f.desc);
}
}
return m;
}
+180
View File
@@ -0,0 +1,180 @@
/**
* 极简 YAML 读写 —— 只认「嵌套 map + 标量 + 列表(块式与内联)」,与设置页的需求严格同构。
*
* ★ 为什么不用真正的 YAML 库:设置页只需要读写 artalk.yml 的一个子集,
* 引一整个 yaml 依赖(几十 KB)只为读几个键不值;而且自己写可以保证
* 「自己写出去的字符串自己一定读得回来」(toSimpleYaml ↔ parseSimpleYaml 互为逆运算)。
*
* ★ 必须支持**块式列表**(`- item`):artalk.yml 里 admin_notify.telegram.receivers、
* moderator.keywords.files 都是这种写法。老解析器把它们读成空对象,再序列化就丢数据
* —— 表单化保存走的是「解析→重序列化」,这个丢法会静默改配置,不能忍。
*/
export function parseSimpleYaml(text: string): Record<string, unknown> {
const root: Record<string, unknown> = {};
type Frame = {
indent: number;
node: Record<string, unknown> | unknown[];
/** 裸键先建空对象占位;遇到列表项时升级成数组 */
pendingList: boolean;
parent?: Record<string, unknown>;
key?: string;
};
const stack: Frame[] = [{ indent: -1, node: root, pendingList: false }];
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
if (!rawLine.trim() || /^\s*#/.test(rawLine)) continue;
const indent = rawLine.match(/^\s*/)![0].length;
const line = rawLine.trim();
// ---- 列表项:`- 值` / `- 键: 值` / `-` ----
if (/^(-\s+|$)/.test(line)) {
const top = stack[stack.length - 1];
// 裸键占位 → 升级为数组
if (top.pendingList && top.parent && top.key) {
const arr: unknown[] = [];
top.parent[top.key] = arr;
top.node = arr;
top.pendingList = false;
}
if (Array.isArray(top.node)) {
const rest = line === '-' ? '' : line.slice(2).trim();
if (!rest) {
(top.node as unknown[]).push('');
} else {
const kv = rest.match(/^([A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
if (kv) {
const item: Record<string, unknown> = {};
item[kv[1]] = kv[2] ? coerce(kv[2]) : {};
(top.node as unknown[]).push(item);
} else {
(top.node as unknown[]).push(coerce(rest));
}
}
}
continue; // 列表项不参与键栈
}
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 (Array.isArray(parent) || typeof parent !== 'object') continue; // 键不能挂在数组上
if (!value) {
const child: Record<string, unknown> = {};
parent[key] = child;
stack.push({ indent, node: child, pendingList: true, parent, key });
continue;
}
(parent as Record<string, unknown>)[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 (v === 'null' || v === '~') return null;
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;
}
// ------------------------------------------------------------------ 序列化
/** comments:路径(如 "frontend.placeholder")→ 写在该键上方的注释行 */
export function toSimpleYaml(
data: Record<string, unknown>,
comments?: Map<string, string>,
): string {
return writeMap(data ?? {}, 0, '', comments ?? new Map());
}
function writeMap(
obj: Record<string, unknown>,
indent: number,
prefix: string,
comments: Map<string, string>,
): string {
const pad = ' '.repeat(indent);
let out = '';
for (const [k, v] of Object.entries(obj)) {
const p = prefix ? prefix + '.' + k : k;
const c = comments.get(p) ? '# ' + comments.get(p) + '\n' : '';
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
const keys = Object.keys(v as Record<string, unknown>);
if (keys.length === 0) {
out += c + pad + k + ': {}\n';
} else {
out += c + pad + k + ':\n' + writeMap(v as Record<string, unknown>, indent + 1, p, comments);
}
} else if (Array.isArray(v)) {
if (v.length === 0) {
out += c + pad + k + ': []\n';
} else {
// 块式列表(与 artalk.yml 模板的写法一致,解析器已支持)
out += c + pad + k + ':\n' + v.map((x) => writeItem(x, indent + 1)).join('');
}
} else {
out += c + pad + k + ': ' + scalarYaml(v) + '\n';
}
}
return out;
}
function writeItem(v: unknown, indent: number): string {
const pad = ' '.repeat(indent);
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
const o = v as Record<string, unknown>;
const keys = Object.keys(o);
if (keys.length === 1 && (o[keys[0]] === null || typeof o[keys[0]] !== 'object')) {
return pad + '- ' + keys[0] + ': ' + scalarYaml(o[keys[0]]) + '\n';
}
return pad + '- ' + JSON.stringify(v) + '\n'; // 复杂条目(当前模板里没有)
}
return pad + '- ' + scalarYaml(v) + '\n';
}
function scalarYaml(v: unknown): string {
if (v === null || v === undefined) return 'null';
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'null';
const s = String(v);
return needsQuote(s) ? '"' + s.replace(/"/g, "'") + '"' : s;
}
function needsQuote(s: string): boolean {
if (s === '') return true;
if (/^\s|\s$/.test(s)) return true; // 首尾空白
if (/^(true|false|yes|no|null|~)$/i.test(s)) return true; // 会被解析成布尔/null
if (/^-?\d+(\.\d+)?$/.test(s)) return true; // 会被解析成数字
if (/^[[{]/.test(s)) return true; // 会被解析成数组/对象
if (/\s#/.test(s)) return true; // 会被当成行内注释截断
if (/\\/.test(s)) return true; // 反斜杠加引号保护(如 "\n" 分隔符)
return false;
}
+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) });
}
}
+13
View File
@@ -27,6 +27,19 @@ export interface Env {
PENDING_DEFAULT: string;
IMG_UPLOAD_ENABLED?: string;
// ---- 文章编辑(反代到自建的 editor-api 容器)----
/**
* 后端基址,例如 https://post.usj.cc/editor-api。
* 留空 = 该模块自动禁用(所有 /editor/* 接口返 503)。
* 走 [vars] 即可,不是密钥。
*/
EDITOR_API_BASE?: string;
/**
* 与 editor-api 容器约定的共享令牌。
* ★ 必须走 `wrangler secret put EDITOR_TOKEN`,不要写进 wrangler.toml。
*/
EDITOR_TOKEN?: string;
// ---- 邮件通知(可选,走 Resend 的 HTTP API;Workers 没有 TCP,发不了 SMTP)
// 两个都走 `wrangler secret put`,不要写进 wrangler.toml 的 [vars]
RESEND_API_KEY?: string;