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
48a2fd697f
commit
c217d20c30
24 files changed
+3597
-221
No files matched your search
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in new issue
Block a user