Files
blog/editor-api/server.mjs
T
zqlit 68416366eb fix(editor-api): 身份判定改为会话优先,防止配置漂移静默提权
identify() 原来「令牌优先」:令牌对得上就用 X-Editor-* 头的身份,没有头时默认 admin。
国内线路的 nginx 目前不注入令牌,但一旦配置回滚到「注入令牌」的旧版本,编辑的请求
会命中令牌分支 → 静默获得管理员权限(看到全部文章、能改别人的、能全量发布)。

改为「会话(真实的人)优先于共享令牌(服务身份)」,把边界写死在代码里而不是靠配置。
新增断言:编辑会话 + 管理员令牌头 → 仍按编辑身份。role-perm.mjs 47/47
2026-10-05 14:41:02 +08:00

678 lines
25 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 上的 rss-api)。国内机后台的订阅/友链请求由本服务
// 反代过去,并带上共享令牌 —— 这样后台前端不必再持有「订阅口令」。
rssBase: (env.RSS_API_BASE || 'https://api.200181.xyz').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 偷走。
*
* ★ 会话里必须存**身份**(uid / name / role),不能只存一个用户名 ——
* 写作后台现在有「管理员 + 编辑」两种角色,后端要靠它判断
* 「这篇文章是不是你的」以及「发布时该提交哪些路径」。
*/
const SESSION_FILE =
cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json');
const sessions = new Map(); // sid -> { uid, name, role, 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)) continue;
// 兼容改造前存的 { user, exp }:那时只有一个本机管理员账号
sessions.set(sid, {
uid: String(v.uid || ''),
name: String(v.name || v.user || cfg.adminUser),
role: v.role === 'editor' ? 'editor' : 'admin',
exp: v.exp,
});
}
} 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('; '));
}
/** Worker 侧对用户名做了 encodeURIComponent(HTTP 头是 byte-string,中文塞不进去) */
function decodeHeader(v) {
const s = String(v || '').trim();
if (!s) return '';
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
/**
* 谁在操作 —— 返回 { uid, name, role } 或 null。role 只有 'admin' / 'editor'
* 两种(普通评论用户进不来写作后台)。
*
* 两条通道:
* ① Cloudflare Worker 反代:带了共享令牌 X-Editor-Token(浏览器永远拿不到),
* 身份在 Worker 注入的 X-Editor-Uid / X-Editor-User / X-Editor-Role 里。
* 令牌对得上 → 这组头就是可信的。
* 缺头(老版 Worker / 直接 curl 带令牌调试)按管理员处理,与改造前行为一致。
* ② 浏览器直连本机后台:身份就在自己的 HttpOnly Cookie 会话里。
*/
function identify(req) {
// ★ 顺序很重要:会话(具体某个人的登录态)优先于共享令牌(服务身份)。
// 共享令牌分支的默认角色是 admin —— 国内线路的 nginx 目前不注入令牌,
// 但一旦配置漂移回「nginx 注入令牌」的旧版本,令牌优先会让编辑直接拿到
// 管理员权限(看到全部文章、能改别人的、能全量发布),而且是静默的。
// 靠配置保证安全太脆弱,这里用「人优先于机器」把边界写死在代码里。
const s = readSession(req);
if (s) {
return {
uid: String(s.uid || ''),
name: String(s.name || cfg.adminUser),
role: s.role === 'editor' ? 'editor' : 'admin',
};
}
// Worker 反代通道:由 Cloudflare 发出,不带浏览器 Cookie,只有令牌 + 身份头
if (safeEqual(req.headers['x-editor-token'] || '', cfg.token)) {
return {
uid: String(req.headers['x-editor-uid'] || '').trim(),
name: decodeHeader(req.headers['x-editor-user']),
role: String(req.headers['x-editor-role'] || '').trim() === 'editor' ? 'editor' : 'admin',
};
}
return null;
}
/**
* 直连后台登录时,把账号密码交给 Cloudflare 上的用户表验证。
*
* 为什么不自己存一份账号:账号体系只该有一个(D1 users + role 列)。
* 国内机只做「登录这一跳」的转发,之后全靠本机 Cookie 会话 ——
* 写文章那条链路依然不出境(这正是国内线路存在的意义)。
*
* 返回 { ident } / { denied } / { unreachable },三态分开,
* 调用处才能决定「要不要回退到本机管理员」以及给什么错误提示。
*/
async function loginViaCf(identifier, password) {
if (!cfg.rssBase || !identifier || !password) return { unreachable: true };
let r;
try {
r = await fetch(cfg.rssBase + '/api/v2/user/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: identifier, password }),
signal: AbortSignal.timeout(10_000),
});
} catch (e) {
console.error('[editor-api] 登录转发失败(CF 不可达):', e.message);
return { unreachable: true };
}
if (r.status === 401 || r.status === 403) return { denied: true };
if (!r.ok) {
console.error('[editor-api] 登录转发异常状态:', r.status);
return { unreachable: true };
}
let d;
try {
d = await r.json();
} catch {
return { unreachable: true };
}
const u = d && d.user;
if (!u) return { denied: true };
const role = u.role === 'editor' ? 'editor' : u.is_admin || u.role === 'admin' ? 'admin' : '';
if (!role) return { denied: true }; // 普通评论用户没有写作后台权限
return { ident: { uid: String(u.id), name: String(u.name || identifier), role } };
}
/** HMAC-SHA256(key=共享令牌, msg=时间戳)的 hex,供「一键跳转」验签 */
function hmacHex(key, msg) {
try {
return crypto.createHmac('sha256', key).update(msg).digest('hex');
} catch {
return '';
}
}
/** 已消费的一键跳转签名(防重放):同一签名只允许换一次会话 */
const usedHandoff = new Set();
// 暴力破解防护:同一 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 RSS_PATH_PREFIXES = (env.RSS_PATHS || '/api/feeds,/api/links,/api/link-apply')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
/**
* 把订阅/友链请求原样转发到订阅服务(Cloudflare 上的 rss-api),带上共享令牌。
* 用途:国内机后台的订阅源 / 友链模块 —— 请求由本机出去,前端不需要知道
* 订阅口令,也不必为了这两个模块去登录 Cloudflare 后台。
*
* 鉴权在调用处完成(会话或令牌),这里只管转发。
*/
async function rssProxy(req, res, url) {
const target = cfg.rssBase + url.pathname + url.search;
const method = (req.method || 'GET').toUpperCase();
const headers = { 'X-Editor-Token': cfg.token, Accept: 'application/json' };
const ct = req.headers['content-type'];
if (ct) headers['Content-Type'] = ct;
let body;
if (method !== 'GET' && method !== 'HEAD') {
const buf = await readBody(req, 4 * 1024 * 1024);
if (buf.length) body = buf;
}
let r;
// 跨境那一跳会偶发握手失败(实测直连 CF 有时 0.3s 就断)。GET 是幂等的,
// 重试一次基本就过去了 —— 不能让用户看到「订阅源加载失败」。
const tries = method === 'GET' || method === 'HEAD' ? 2 : 1;
for (let i = 0; i < tries; i++) {
if (i) await new Promise((s) => setTimeout(s, 400));
try {
r = await fetch(target, { method, headers, body });
break;
} catch (e) {
if (i === tries - 1) {
throw Object.assign(new Error('订阅服务连不上:' + e.message), { status: 502 });
}
}
}
const buf = Buffer.from(await r.arrayBuffer());
res.writeHead(r.status, {
'Content-Type': r.headers.get('content-type') || 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': buf.length,
});
res.end(buf);
}
// ------------------------------------------------------------------ 路由
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, me }) => posts.listPosts({
q: query.get('q') || '',
page: Number(query.get('page') || 1),
perPage: Number(query.get('perPage') || 20),
}, me));
route('POST', '/posts', async ({ req, me }) => {
const body = await readJson(req);
if (!body.frontMatter || !body.frontMatter.title) {
throw Object.assign(new Error('缺少标题'), { status: 400 });
}
return posts.createPost(body, me);
});
route('GET', `/posts/${R}`, async ({ params, me }) => {
const post = posts.getPost(params[0], me);
if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 });
return post;
});
route('PUT', `/posts/${R}`, async ({ req, params, me }) => {
const body = await readJson(req, 4 * 1024 * 1024);
const out = posts.savePost(params[0], body, me);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
route('DELETE', `/posts/${R}`, async ({ params, me }) => {
const out = posts.deletePost(params[0], me);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单
route('POST', '/upload', async ({ req, query, me }) => {
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') || '', me);
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('/'),
};
});
/**
* 发布范围:编辑角色只提交**自己文章的目录**。
* · admin → null(后端按 GIT_PATHS 全量 add content/static,行为与改造前一致)
* · editor → 自己名下的文章目录列表(可能是空数组 → 什么都不提交)
* 这一步必须在服务端算,不能信前端传的路径 —— 否则编辑能往 pathspec 里塞别人的目录。
*/
const scopeOf = (me) => (me && me.role === 'editor' ? posts.ownedPaths(me) : null);
route('GET', '/git/status', async ({ me }) => git.status(scopeOf(me)));
route('POST', '/git/publish', async ({ req, me }) => {
const body = await readJson(req);
const out = await git.publish(body.message, scopeOf(me));
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;
}
// ---------------------------------------------------------- 后台登录
// 这几个端点免令牌:浏览器直接访问国内机后台时用。公网只能通过 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: { id: s.uid || null, name: s.name || cfg.adminUser, role: s.role === 'editor' ? 'editor' : 'admin' },
});
return;
}
if (url.pathname === '/admin/login' && req.method === 'POST') {
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);
const user = String(body.user || '').trim();
const password = String(body.password || '');
// ① 先交给 Cloudflare 的用户表验证 —— 账号体系只有一个(D1 users + role),
// 编辑账号也才能从国内线路登录进来。
const viaCf = await loginViaCf(user, password);
let ident = viaCf.ident || null;
// ② CF 不可达 / 密码不对时,回退到本机管理员账号(.env 的 ADMIN_USER/ADMIN_PASS)。
// 这是「Cloudflare 挂了还要能进去改文章」的应急通道,只对管理员生效。
if (!ident && cfg.adminPass && safeEqual(user, cfg.adminUser) && safeEqual(password, cfg.adminPass)) {
ident = { uid: '', name: cfg.adminUser, role: 'admin' };
}
if (!ident) {
noteFailure(ip);
if (viaCf.unreachable) {
json(res, 503, {
error: '国内线路连不上 Cloudflare,无法校验账号。请从 api.200181.xyz/admin 进入,或稍后重试。',
});
return;
}
// 不区分「账号错」和「密码错」,别帮对方缩小猜测范围
json(res, 401, { error: '账号或密码不正确' });
return;
}
failures.delete(ip);
const sid = crypto.randomBytes(32).toString('hex');
sessions.set(sid, { uid: ident.uid, name: ident.name, role: ident.role, exp: Date.now() + cfg.sessionTtlMs });
saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
json(res, 200, { ok: true, user: { id: ident.uid || null, name: ident.name, role: ident.role } });
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 用共享令牌对「时间戳 + 操作者
// 身份」做 HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。
//
// ★ 签名必须覆盖 uid/名字/角色三样。只签时间戳的话,编辑点一下这个按钮
// 就会在本机拿到一个管理员会话 —— 角色直接越权了。
//
// 三重防线:签名只有两边知道 / 时间窗 2 分钟 / 同一签名只用一次。
if (url.pathname === '/admin/handoff' && req.method === 'GET') {
const ts = String(url.searchParams.get('ts') || '');
const uid = String(url.searchParams.get('u') || '');
const name = String(url.searchParams.get('n') || '');
const role = String(url.searchParams.get('r') || '');
const sig = String(url.searchParams.get('t') || '');
const bad = () => {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('handoff rejected');
};
if (!/^\d{13}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 120_000) return bad();
if (role !== 'admin' && role !== 'editor') return bad();
if (!name) return bad();
const expect = hmacHex(cfg.token, `${ts}\n${uid}\n${name}\n${role}`);
if (!expect || !safeEqual(sig, expect)) return bad();
if (usedHandoff.has(sig)) return bad(); // 防重放:一个签名只能换一次会话
usedHandoff.add(sig);
if (usedHandoff.size > 500) usedHandoff.clear(); // 简单兜底,防集合无限涨
const sid = crypto.randomBytes(32).toString('hex');
sessions.set(sid, { uid, name, role, exp: Date.now() + cfg.sessionTtlMs });
saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
res.writeHead(302, { Location: '/admin/' });
res.end();
return;
}
// ---------------------------------------------------------- 鉴权
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
// 或本机签发的浏览器会话 Cookie。满足其一即放行。
// me = 操作者身份(uid/name/role),下面所有路由都靠它判归属与发布范围。
const me = identify(req);
if (!me) {
json(res, 401, { error: '未授权', need_login: true });
return;
}
// 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
// ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。
if (RSS_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) {
if (me.role !== 'admin') {
json(res, 403, { error: '编辑角色只能管理自己的文章' });
return;
}
await rssProxy(req, res, url);
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, me });
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')
);
});