Files
blog/editor-api/server.mjs
T
zqlit 9f586d625b 后台登录改造:自建登录页 + Cookie 会话,弃用 basic auth 弹窗
- editor-api 新增 /admin/login|session|logout:HttpOnly Cookie 会话(落盘持久化)、
  登录失败限速(5 次锁 5 分钟)、鉴权改为「X-Editor-Token 或 会话」双通道
- 登录页重做:品牌标识、图标输入框、密码可见切换、柔光背景,深浅色适配
- 前端以 /admin/session 探测部署模式:国内机走本机会话,CF 版走原令牌流程
- nginx:/admin/ 与 /api/v2/editor/ 去掉 basic auth 与令牌注入,登录凭据只存 Cookie
- bootstrap.sh 同步 ADMIN_USER/ADMIN_PASS,保持一键迁移能力
2026-10-04 23:40:23 +08:00

429 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(/\/+$/, ''),
// --- 后台登录(浏览器直连本机时用,与 Cloudflare 会话是两套)---------------
// 背景:后台静态页部署在国内机,nginx 的原生 basic auth 弹窗既丑又没法做品牌,
// 所以改成「自建登录页 + HttpOnly Cookie 会话」。令牌通道(X-Editor-Token)
// 保持不变,供 Cloudflare Worker 侧继续使用。
adminUser: env.ADMIN_USER || 'admin',
adminPass: env.ADMIN_PASS || '',
sessionFile: env.SESSION_FILE || '',
sessionTtlMs: Number(env.SESSION_TTL_HOURS || 168) * 3600 * 1000,
};
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);
// ------------------------------------------------------------------ 后台会话
/**
* 登录态存内存 + 落一个 JSON 文件(重启不掉线)。
* 会话 ID 是 32 字节随机数,Cookie 带 HttpOnly + SameSite=Lax,
* 前端 JS 读不到,也就没法被 XSS 偷走。
*/
const SESSION_FILE =
cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json');
const sessions = new Map(); // sid -> { user, exp }
function loadSessions() {
try {
const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
const now = Date.now();
for (const [sid, v] of Object.entries(raw || {})) {
if (v && v.exp > now) sessions.set(sid, v);
}
} catch { /* 首次运行没有这个文件,正常 */ }
}
function saveSessions() {
try {
fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true });
const now = Date.now();
const obj = {};
for (const [sid, v] of sessions) if (v.exp > now) obj[sid] = v;
fs.writeFileSync(SESSION_FILE, JSON.stringify(obj), { mode: 0o600 });
} catch (e) {
console.error('[editor-api] 会话落盘失败(不影响运行):', e.message);
}
}
function parseCookies(req) {
const out = {};
for (const part of String(req.headers.cookie || '').split(';')) {
const i = part.indexOf('=');
if (i < 1) continue;
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
const COOKIE = 'ed_sid';
/** 返回会话对象或 null(顺带清掉过期项) */
function readSession(req) {
const sid = parseCookies(req)[COOKIE];
if (!sid) return null;
const s = sessions.get(sid);
if (!s) return null;
if (s.exp <= Date.now()) {
sessions.delete(sid);
saveSessions();
return null;
}
return s;
}
function setCookie(res, sid, maxAgeSec) {
const parts = [
COOKIE + '=' + sid,
'Path=/',
'HttpOnly',
'Secure',
'SameSite=Lax',
'Max-Age=' + maxAgeSec,
];
res.setHeader('Set-Cookie', parts.join('; '));
}
// 暴力破解防护:同一 IP 连续失败到阈值就冷却一段时间
const FAIL_MAX = 5;
const FAIL_COOL_MS = 5 * 60 * 1000;
const failures = new Map(); // ip -> { n, until }
function clientIp(req) {
return (
String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
(req.socket && req.socket.remoteAddress) ||
'?'
);
}
function failState(ip) {
const f = failures.get(ip);
if (!f) return null;
if (f.until && f.until > Date.now()) return f;
if (!f.until && f.n < FAIL_MAX) return f;
failures.delete(ip);
return null;
}
function noteFailure(ip) {
const f = failures.get(ip) || { n: 0, until: 0 };
f.n += 1;
if (f.n >= FAIL_MAX) {
f.until = Date.now() + FAIL_COOL_MS;
f.n = 0;
}
failures.set(ip, f);
}
// ------------------------------------------------------------------ 路由
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;
}
// ---------------------------------------------------------- 后台登录
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .env,公网只能
// 通过 nginx 的 /api/v2/admin/* 转发进来;失败限速防爆破。
if (url.pathname === '/admin/session' && req.method === 'GET') {
const s = readSession(req);
if (!s) {
json(res, 401, { ok: false, mode: 'direct', need_login: true });
return;
}
json(res, 200, { ok: true, mode: 'direct', user: { name: s.user } });
return;
}
if (url.pathname === '/admin/login' && req.method === 'POST') {
if (!cfg.adminPass) {
json(res, 503, { error: '后端未配置登录密码(ADMIN_PASS),请先在 .env 里设置' });
return;
}
const ip = clientIp(req);
const f = failState(ip);
if (f && f.until) {
json(res, 429, { error: '尝试过于频繁,请 ' + Math.ceil((f.until - Date.now()) / 1000) + ' 秒后再试' });
return;
}
const body = await readJson(req, 64 * 1024);
if (!safeEqual(body.user || '', cfg.adminUser) || !safeEqual(body.password || '', cfg.adminPass)) {
noteFailure(ip);
// 不区分「账号错」和「密码错」,别帮对方缩小猜测范围
json(res, 401, { error: '账号或密码不正确' });
return;
}
failures.delete(ip);
const sid = crypto.randomBytes(32).toString('hex');
sessions.set(sid, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
json(res, 200, { ok: true, user: { name: cfg.adminUser } });
return;
}
if (url.pathname === '/admin/logout' && req.method === 'POST') {
const sid = parseCookies(req)[COOKIE];
if (sid && sessions.delete(sid)) saveSessions();
setCookie(res, '', 0);
json(res, 200, { ok: true });
return;
}
// ---------------------------------------------------------- 鉴权
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
// 或本机签发的浏览器会话 Cookie。满足其一即放行。
const byToken = safeEqual(req.headers['x-editor-token'] || '', cfg.token);
if (!byToken && !readSession(req)) {
json(res, 401, { error: '未授权', need_login: true });
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;
loadSessions();
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);
console.log(
'[editor-api] 后台登录 ' +
(cfg.adminPass
? '已启用(账号 ' + cfg.adminUser + ',会话 ' + Math.round(cfg.sessionTtlMs / 3600000) + ' 小时,' + sessions.size + ' 个有效会话)'
: '未配置 ADMIN_PASS —— 只能走 X-Editor-Token')
);
});