feat(编辑角色): editor 只能写并发布自己的文章

- D1 users 加 role 列(''/editor/admin),与 is_admin 成对写入
- Worker routes/editor.ts 放行 admin+editor,反代注入 X-Editor-Uid/User/Role(昵称 encodeURIComponent)
- editor-api 引入 identify():身份取自注入头或本机会话;文章归属记 frontmatter author_id
- 编辑发布改精确 pathspec(只提交自己文章目录),管理员保持全量;空 pathspec 显式拦截
- 国内机直连登录改为转发 CF /user/access_token 校验,CF 不可达回退本机管理员
- handoff 签名覆盖身份(ts/uid/name/role),防编辑一键跳转变管理员
- admin.js:编辑只渲染「文章编辑」tab、作者框只读;用户管理加角色下拉 + 新建用户
This commit is contained in:
zqlit committed 2026-10-05 14:34:56 +08:00
1 parent 66c27c7b62
commit d899cd793b
14 files changed
+1172 -139

No files matched your search

+171 -34
View File
@@ -119,17 +119,28 @@ const safeName = (s) =>
* 登录态存内存 + 落一个 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 -> { user, exp }
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) sessions.set(sid, v);
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 { /* 首次运行没有这个文件,正常 */ }
}
@@ -184,6 +195,88 @@ function setCookie(res, sid, 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) {
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',
};
}
const s = readSession(req);
if (!s) return null;
return {
uid: String(s.uid || ''),
name: String(s.name || cfg.adminUser),
role: s.role === 'editor' ? 'editor' : 'admin',
};
}
/**
* 直连后台登录时,把账号密码交给 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 {
@@ -301,41 +394,41 @@ const health = () => ({
route('GET', '/health', health);
route('GET', '/posts', async ({ query }) => posts.listPosts({
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 }) => {
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);
return posts.createPost(body, me);
});
route('GET', `/posts/${R}`, async ({ params }) => {
const post = posts.getPost(params[0]);
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 }) => {
route('PUT', `/posts/${R}`, async ({ req, params, me }) => {
const body = await readJson(req, 4 * 1024 * 1024);
const out = posts.savePost(params[0], body);
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 }) => {
const out = posts.deletePost(params[0]);
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 }) => {
route('POST', '/upload', async ({ req, query, me }) => {
const type = req.headers['content-type'] || '';
if (!type.startsWith('image/')) {
throw Object.assign(new Error('只接受图片'), { status: 400 });
@@ -347,7 +440,7 @@ route('POST', '/upload', async ({ req, query }) => {
const base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
const fileName = base + '.' + ext;
const target = posts.imageTargetDir(query.get('key') || '');
const target = posts.imageTargetDir(query.get('key') || '', me);
fs.mkdirSync(target.dir, { recursive: true });
fs.writeFileSync(path.join(target.dir, fileName), buf);
@@ -360,10 +453,18 @@ route('POST', '/upload', async ({ req, query }) => {
};
});
route('GET', '/git/status', async () => git.status());
route('POST', '/git/publish', async ({ req }) => {
/**
* 发布范围:编辑角色只提交**自己文章的目录**。
* · 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);
const out = await git.publish(body.message, scopeOf(me));
if (!out.ok) {
return { ...out, __status: out.conflict ? 409 : 500 };
}
@@ -406,23 +507,23 @@ const server = http.createServer(async (req, res) => {
}
// ---------------------------------------------------------- 后台登录
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .env,公网只能
// 通过 nginx 的 /api/v2/admin/* 转发进来;失败限速防爆破。
// 这几个端点免令牌:浏览器直接访问国内机后台时用。公网只能通过 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 } });
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') {
if (!cfg.adminPass) {
json(res, 503, { error: '后端未配置登录密码(ADMIN_PASS),请先在 .env 里设置' });
return;
}
const ip = clientIp(req);
const f = failState(ip);
if (f && f.until) {
@@ -430,18 +531,39 @@ const server = http.createServer(async (req, res) => {
return;
}
const body = await readJson(req, 64 * 1024);
if (!safeEqual(body.user || '', cfg.adminUser) || !safeEqual(body.password || '', cfg.adminPass)) {
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, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
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: { name: cfg.adminUser } });
json(res, 200, { ok: true, user: { id: ident.uid || null, name: ident.name, role: ident.role } });
return;
}
@@ -454,25 +576,34 @@ const server = http.createServer(async (req, res) => {
}
// ------------------------------------------------------ 一键跳转(免登录)
// Cloudflare 后台点「国内线路」时走这里:Worker 用共享令牌对时间戳做
// HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。
// 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();
const expect = hmacHex(cfg.token, ts);
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, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
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/' });
@@ -483,15 +614,21 @@ const server = http.createServer(async (req, res) => {
// ---------------------------------------------------------- 鉴权
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
// 或本机签发的浏览器会话 Cookie。满足其一即放行。
const byToken = safeEqual(req.headers['x-editor-token'] || '', cfg.token);
if (!byToken && !readSession(req)) {
// 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;
}
@@ -502,7 +639,7 @@ const server = http.createServer(async (req, res) => {
return;
}
const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params });
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);