/** * 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 = ''; 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); });