2026-10-04 21:16:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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(/\/+$/, ''),
|
2026-10-04 23:40:23 +08:00
|
|
|
|
|
|
|
|
|
|
// --- 后台登录(浏览器直连本机时用,与 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,
|
2026-10-04 21:16:06 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2026-10-04 23:40:23 +08:00
|
|
|
|
// ------------------------------------------------------------------ 后台会话
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 登录态存内存 + 落一个 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 21:16:06 +08:00
|
|
|
|
// ------------------------------------------------------------------ 路由
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 23:40:23 +08:00
|
|
|
|
// ---------------------------------------------------------- 后台登录
|
|
|
|
|
|
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .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 });
|
2026-10-04 21:16:06 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-04 23:40:23 +08:00
|
|
|
|
loadSessions();
|
|
|
|
|
|
|
2026-10-04 21:16:06 +08:00
|
|
|
|
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);
|
2026-10-04 23:40:23 +08:00
|
|
|
|
console.log(
|
|
|
|
|
|
'[editor-api] 后台登录 ' +
|
|
|
|
|
|
(cfg.adminPass
|
|
|
|
|
|
? '已启用(账号 ' + cfg.adminUser + ',会话 ' + Math.round(cfg.sessionTtlMs / 3600000) + ' 小时,' + sessions.size + ' 个有效会话)'
|
|
|
|
|
|
: '未配置 ADMIN_PASS —— 只能走 X-Editor-Token')
|
|
|
|
|
|
);
|
2026-10-04 21:16:06 +08:00
|
|
|
|
});
|