Files
blog/editor-api/server.mjs
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

257 lines
8.5 KiB
JavaScript

/**
* editor-api —— 只做「在线编辑文章」的轻量后端。
*
* 设计要点:
* · 零 npm 依赖,只用 node 内置模块 —— 镜像小、没有供应链风险、也不用 npm ci
* · 所有接口(除 /health)强制 X-Editor-Token,没有令牌一律 401。
* 前端永远拿不到这个令牌 —— 它只存在于 Cloudflare Worker 的环境变量里,
* 浏览器只跟 Worker 说话,Worker 鉴权通过后再加上令牌转发过来。
* · 端口只绑到宿主机的 127.0.0.1,由 nginx 反代出去,公网扫不到。
*
* 环境变量见 README.md。
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { makePosts } from './src/posts.mjs';
import { makeGit } from './src/git.mjs';
// ------------------------------------------------------------------ 配置
const env = process.env;
const cfg = {
port: Number(env.PORT || 8017),
host: env.BIND_HOST || '0.0.0.0',
token: env.EDITOR_TOKEN || '',
repoRoot: path.resolve(env.BLOG_ROOT || '/blog'),
contentDir: path.resolve(env.BLOG_ROOT || '/blog', 'content', 'posts'),
trashDir: path.resolve(env.TRASH_DIR || '/app/trash'),
branch: env.GIT_BRANCH || 'main',
pushRemotes: (env.PUSH_REMOTES || 'origin,gh').split(',').map((s) => s.trim()).filter(Boolean),
authorName: env.GIT_AUTHOR_NAME || 'blog-editor',
authorEmail: env.GIT_AUTHOR_EMAIL || 'editor@localhost',
defaultAuthor: env.DEFAULT_AUTHOR || '',
maxUpload: Number(env.MAX_UPLOAD_MB || 20) * 1024 * 1024,
gitPaths: (env.GIT_PATHS || 'content,static').split(',').map((s) => s.trim()).filter(Boolean),
// 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。
blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''),
};
if (!cfg.token) {
console.error('[editor-api] 致命:没有设置 EDITOR_TOKEN。拒绝以无鉴权状态启动。');
process.exit(1);
}
const posts = makePosts(cfg);
const git = makeGit({ ...cfg, repoRoot: cfg.repoRoot, paths: cfg.gitPaths });
// ------------------------------------------------------------------ HTTP 小工具
function json(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function readBody(req, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > limit) {
reject(Object.assign(new Error('请求体超过上限 ' + Math.round(limit / 1048576) + 'MB'), { status: 413 }));
req.destroy();
return;
}
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
async function readJson(req, limit = 2 * 1024 * 1024) {
const buf = await readBody(req, limit);
if (!buf.length) return {};
try {
return JSON.parse(buf.toString('utf8'));
} catch {
throw Object.assign(new Error('请求体不是合法 JSON'), { status: 400 });
}
}
/** 定长比较,避免令牌被逐字节试探 */
function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
if (ba.length !== bb.length) return false;
return crypto.timingSafeEqual(ba, bb);
}
const safeName = (s) =>
String(s || '')
.replace(/[\\/]/g, '')
.replace(/[^\w\u4e00-\u9fff.-]+/g, '_')
.slice(0, 120);
// ------------------------------------------------------------------ 路由
const routes = [];
const route = (method, pattern, handler) => {
routes.push({ method, segs: pattern.split('/').filter(Boolean), handler });
};
const R = '<param>';
const health = () => ({
ok: true,
repo: cfg.repoRoot,
branch: cfg.branch,
remotes: cfg.pushRemotes,
time: new Date().toISOString(),
});
route('GET', '/health', health);
route('GET', '/posts', async ({ query }) => posts.listPosts({
q: query.get('q') || '',
page: Number(query.get('page') || 1),
perPage: Number(query.get('perPage') || 20),
}));
route('POST', '/posts', async ({ req }) => {
const body = await readJson(req);
if (!body.frontMatter || !body.frontMatter.title) {
throw Object.assign(new Error('缺少标题'), { status: 400 });
}
return posts.createPost(body);
});
route('GET', `/posts/${R}`, async ({ params }) => {
const post = posts.getPost(params[0]);
if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 });
return post;
});
route('PUT', `/posts/${R}`, async ({ req, params }) => {
const body = await readJson(req, 4 * 1024 * 1024);
const out = posts.savePost(params[0], body);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
route('DELETE', `/posts/${R}`, async ({ params }) => {
const out = posts.deletePost(params[0]);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单
route('POST', '/upload', async ({ req, query }) => {
const type = req.headers['content-type'] || '';
if (!type.startsWith('image/')) {
throw Object.assign(new Error('只接受图片'), { status: 400 });
}
const buf = await readBody(req, cfg.maxUpload);
if (!buf.length) throw Object.assign(new Error('空文件'), { status: 400 });
const ext = (safeName(query.get('name')).split('.').pop() || 'png').toLowerCase().slice(0, 6);
const base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
const fileName = base + '.' + ext;
const target = posts.imageTargetDir(query.get('key') || '');
fs.mkdirSync(target.dir, { recursive: true });
fs.writeFileSync(path.join(target.dir, fileName), buf);
return {
ok: true,
fileName,
url: target.bare ? fileName : (target.urlPrefix || '/') + fileName,
size: buf.length,
dir: path.relative(cfg.repoRoot, target.dir).split(path.sep).join('/'),
};
});
route('GET', '/git/status', async () => git.status());
route('POST', '/git/publish', async ({ req }) => {
const body = await readJson(req);
const out = await git.publish(body.message);
if (!out.ok) {
return { ...out, __status: out.conflict ? 409 : 500 };
}
return out;
});
route('POST', '/git/sync', async () => {
const out = await git.sync();
return out.ok ? out : { ...out, __status: out.conflict ? 409 : 500 };
});
// ------------------------------------------------------------------ 分发
function match(method, pathname) {
const segs = pathname.split('/').filter(Boolean).map(decodeURIComponent);
for (const r of routes) {
if (r.method !== method) continue;
if (r.segs.length !== segs.length) continue;
const params = [];
let ok = true;
for (let i = 0; i < r.segs.length; i++) {
if (r.segs[i] === R) params.push(segs[i]);
else if (r.segs[i] !== segs[i]) {
ok = false;
break;
}
}
if (ok) return { handler: r.handler, params };
}
return null;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
try {
// /health 免鉴权,供健康检查用;其余一律先验令牌
if (url.pathname === '/health' && req.method === 'GET') {
json(res, 200, health());
return;
}
if (!safeEqual(req.headers['x-editor-token'] || '', cfg.token)) {
json(res, 401, { error: '未授权' });
return;
}
const hit = match(req.method, url.pathname);
if (!hit) {
json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname });
return;
}
const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params });
const status = data && data.__status ? data.__status : 200;
if (data && data.__status) delete data.__status;
json(res, status, data);
} catch (e) {
const status = e.status || 500;
if (status >= 500) console.error('[editor-api]', req.method, url.pathname, e);
json(res, status, { error: e.message || '服务端错误' });
}
});
server.headersTimeout = 30_000;
server.requestTimeout = 120_000;
server.listen(cfg.port, cfg.host, () => {
console.log('[editor-api] 已启动 http://' + cfg.host + ':' + cfg.port);
console.log('[editor-api] 仓库 ' + cfg.repoRoot);
console.log('[editor-api] 分支 ' + cfg.branch + ' 推送远端 ' + cfg.pushRemotes.join(', '));
console.log('[editor-api] 回收目录 ' + cfg.trashDir);
});