From d899cd793b028970098160553c979e56285f5c89 Mon Sep 17 00:00:00 2001 From: zqlit Date: Mon, 5 Oct 2026 14:34:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=A7=92=E8=89=B2):=20e?= =?UTF-8?q?ditor=20=E5=8F=AA=E8=83=BD=E5=86=99=E5=B9=B6=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E6=96=87=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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、作者框只读;用户管理加角色下拉 + 新建用户 --- blog-admin/public/admin/admin.js | 158 +++++++++-- blog-admin/schema.sql | 1 + blog-admin/src/lib/cook.ts | 2 + blog-admin/src/lib/role.ts | 59 ++++ blog-admin/src/lib/session.ts | 3 +- blog-admin/src/routes/admin.ts | 40 ++- blog-admin/src/routes/editor.ts | 122 +++++--- blog-admin/src/routes/user.ts | 3 +- blog-admin/src/types.ts | 13 + editor-api/README.md | 64 ++++- editor-api/server.mjs | 205 +++++++++++--- editor-api/src/git.mjs | 49 +++- editor-api/src/posts.mjs | 126 ++++++++- editor-api/test/role-perm.mjs | 466 +++++++++++++++++++++++++++++++ 14 files changed, 1172 insertions(+), 139 deletions(-) create mode 100644 blog-admin/src/lib/role.ts create mode 100644 editor-api/test/role-perm.mjs diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index 0e58569b..c8affca5 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -44,6 +44,30 @@ function qs(obj) { const truncate = (s, n) => (String(s || '').length > n ? String(s).slice(0, n) + '…' : String(s || '')); +// ============================================================ 角色 +// +// 后台两档角色(服务端 users.role,权威实现在 blog-admin/src/lib/role.ts): +// admin 管理员 —— 全部模块 +// editor 编辑 —— 只有「文章编辑」,而且只能写 / 发布自己的文章 +// 服务端已经归一化过一次(is_admin 优先),这里再兜一次, +// 兼容缓存里的老 state.me(localStorage 可能还留着改造前的结构)。 +function roleOf(u) { + if (!u) return 'user'; + if (u.is_admin) return 'admin'; + return u.role === 'editor' ? 'editor' : u.role === 'admin' ? 'admin' : 'user'; +} +/** 当前登录者是不是编辑(决定导航能画几项、作者能不能改) */ +function isEditor() { return roleOf(state.me) === 'editor'; } +/** 能进写作后台的角色 */ +function canEnterBackend() { const r = roleOf(state.me); return r === 'admin' || r === 'editor'; } +/** 用户列表里的角色标签 */ +function roleTag(u) { + const r = roleOf(u); + if (r === 'admin') return ' 管理员'; + if (r === 'editor') return ' 编辑'; + return ''; +} + // ============================================================ 状态 const state = { @@ -251,10 +275,13 @@ function showGate() { function showApp() { $('#gate').hidden = true; $('#app').hidden = false; - $('#who').textContent = (state.me && (state.me.name || state.me.email)) || 'admin'; + // 侧栏显示身份:编辑角色标一下,免得 TA 以为自己权限跟管理员一样 + $('#who').innerHTML = esc((state.me && (state.me.name || state.me.email)) || 'admin') + + (isEditor() ? ' 编辑' : ''); $('#footR').textContent = 'API v2 · 站点:' + (state.site || '—'); renderTabs(); - go('dash'); + // 编辑进来直接落在文章列表:仪表盘/评论这些它没有权限,默认页只会是 403 + go(isEditor() ? 'posts' : 'dash'); } function logout(msg) { @@ -304,7 +331,7 @@ async function directLogin(user, password) { if (!res.ok || !d || !d.ok) { throw new Error((d && (d.error || d.msg)) || ('登录失败(HTTP ' + res.status + ')')); } - state.me = d.user || { name: user, is_admin: true }; + state.me = d.user || { name: user, is_admin: true, role: 'admin' }; return state.me; } @@ -318,20 +345,27 @@ function renderTabs() { const t = (id, label) => ''; + // 编辑角色:订阅源/友链在国内机侧已经被拒(server.mjs 只放管理员走 rssProxy), + // 而且它也没有 Cloudflare 的完整会话 —— 干脆不给它画 $('#tabs').innerHTML = t('posts', '文章编辑') + - '' + - '' + - t('feeds', '订阅源') + t('links', '友链') + - '' + - '' + - ''; + (isEditor() + ? '' + : '' + + '' + + t('feeds', '订阅源') + t('links', '友链') + + '' + + '' + + ''); return; } // 分组渲染:group 为空的(仪表盘)单独在顶部;组间加分隔线 + // ★ 编辑角色只画「文章编辑」一项 —— 其余模块服务端本来就是 403 + // (见 routes/admin.ts 的 requireAdmin),画出来只会让人点进一片报错。 + const tabs = isEditor() ? TABS.filter((t) => t.id === 'posts') : TABS; let html = ''; let lastGroup = null; - for (const t of TABS) { + for (const t of tabs) { if (t.group !== lastGroup) { if (t.group) { if (lastGroup !== null && lastGroup !== '') html += ''; @@ -348,6 +382,7 @@ function renderTabs() { } // 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台 // (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie) + // 编辑也能用 —— 跳过去还是编辑,拿不到管理员权限(签名覆盖了身份) html += '' + '' + '') + + head('用户', 'USERS', '', + '' + + '') + '
' + '
加载中…
'; @@ -681,7 +718,7 @@ async function loadUsers() { '' + rows.map((r) => '' + - '' + esc(r.name) + '' + (r.is_admin ? ' 管理员' : '') + '' + + '' + esc(r.name) + '' + roleTag(r) + '' + '' + esc(r.email) + '' + '' + nfmt(r.comment_count) + '' + '' + (r.badge_name @@ -696,6 +733,58 @@ async function loadUsers() { box.innerHTML = html; } +/** + * 角色下拉 —— 一个控件同时表达三档。 + * 拆成「管理员 checkbox + 编辑 checkbox」会有四种组合,其中两种是废的。 + */ +function roleSelectHtml(u, id) { + const cur = roleOf(u); + const self = state.me && state.me.id != null && String(u.id) === String(state.me.id); + const opt = (v, label) => + ''; + return ''; +} + +/** 新建用户:体量上跟「编辑用户」不是一回事,单独一个弹窗 */ +function newUser() { + modal('新建用户', + '' + + '' + + '' + + roleSelectHtml({ role: 'editor' }, 'nuRole') + + '' + + '
编辑角色登录后只会看到「文章编辑」,列表里只有自己写的文章,' + + '发布时也只提交自己名下那几篇。
' + + ''); + + $('#nuSave').onclick = async () => { + const body = { + name: $('#nuName').value.trim(), + email: $('#nuMail').value.trim(), + password: $('#nuPw').value, + role: $('#nuRole').value, + title_name: $('#nuTitle').value.trim(), + }; + if (!body.name || !body.email) { toast('✕ 昵称和登录账号都要填', true); return; } + if (!body.password) { toast('✕ 要设一个初始密码,否则 TA 登不进来', true); return; } + const b = $('#nuSave'); b.dataset.busy = '1'; + try { + await api('/users', { method: 'POST', body: body }); + closeModal(); toast('✓ 已创建'); + await loadUsers(); + } catch (e) { toast('✕ ' + e.message, true); delete b.dataset.busy; } + }; +} + function editUser(id) { (function () { const u = (state.u.rows || []).filter((x) => x.id === id)[0]; @@ -710,9 +799,7 @@ function editUser(id) { '' + '' + '' + - '' + + roleSelectHtml(u, 'euRole') + ''); $('#euSave').onclick = async () => { @@ -723,7 +810,7 @@ function editUser(id) { badge_name: $('#euBn').value.trim(), badge_color: $('#euBc').value.trim(), title_name: $('#euTitle').value.trim(), - is_admin: $('#euAdm').checked, + role: $('#euRole').value, }; const pw = $('#euPw').value; if (pw) body.password = pw; @@ -1341,9 +1428,17 @@ function renderEditor() { const orig = p.frontMatter || {}; const fmBroken = !ed.isNew && (!p.frontMatter || !!p.frontMatterError); - const field = (k, label, cls, ph) => - ''; + // 编辑角色:作者字段只读。后端本来就不采纳它传的 author/author_id + // (见 editor-api/src/posts.mjs),这里显示自己的名字只是为了别让人白填。 + const roAuthor = isEditor(); + const field = (k, label, cls, ph, readonly) => { + let v = orig[k] == null ? '' : orig[k]; + if (readonly && !v) v = (state.me && state.me.name) || ''; + return ''; + }; const metaForm = '
' + @@ -1352,7 +1447,7 @@ function renderEditor() { '
' + '
' + field('date', '日期', 'po-f-date') + - field('author', '作者', 'po-f-author') + + field('author', '作者', 'po-f-author', '', roAuthor) + field('layout', '布局', 'po-f-layout') + '' + @@ -1726,11 +1821,16 @@ async function openGitModal() { '
改动
' + (st.dirty ? '' + nfmt(st.changed) + ' 个文件待发布' : '工作区干净') + '
' + '
最近提交
' + esc(st.lastCommit || '—') + '
' + '' + - '
待发布文件
' + + '
待发布文件' + + (st.scoped ? '(只列你自己名下的文章)' : '') + '
' + '
' + files + '
' + '' + - '
发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。
' + + '
' + + (st.scoped + ? '发布 = 只提交**你自己名下的文章目录**(别人还没发布的改动不会被顺走),再 commit + push 到主仓触发线上构建。' + : '发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。') + + '
' + '
' + '' + '' + @@ -1935,6 +2035,7 @@ document.addEventListener('click', async (e) => { if (act === 'p-next') { state.p.offset += state.p.limit; go('pages'); return; } // --- 用户 + if (act === 'u-new') { newUser(); return; } if (act === 'u-reload') { el.dataset.busy = '1'; try { await loadUsers(); } finally { delete el.dataset.busy; } return; } if (act === 'u-prev') { state.u.offset = Math.max(0, state.u.offset - state.u.limit); await loadUsers(); return; } if (act === 'u-next') { state.u.offset += state.u.limit; await loadUsers(); return; } @@ -2003,7 +2104,7 @@ $('#loginForm').addEventListener('submit', async (e) => { await doLogin($('#lgUser').value.trim(), $('#lgPass').value); state.me = state.me || {}; - if (state.me.is_admin === false) throw new Error('该账号不是管理员'); + if (!canEnterBackend()) throw new Error('该账号不是管理员或编辑,没有写作后台权限'); await bootstrap(); } catch (ex) { @@ -2017,6 +2118,10 @@ $('#loginForm').addEventListener('submit', async (e) => { }); async function bootstrap() { + // 编辑角色没有仪表盘/评论/用户那些模块的权限(服务端 requireAdmin 一律 403), + // 所以不能跟着管理员那套预取走 —— 否则第一句 /sites 就把登录打成「会话已失效」。 + if (isEditor()) { showApp(); return; } + // 站点列表 -> 默认站点 const sites = await api('/sites'); state.sites = sites || []; @@ -2071,10 +2176,11 @@ async function probeSession() { /** 国内机模式:本机会话已就绪,直接进文章编辑(不经过 Cloudflare) */ function enterDirect() { state.direct = true; - if (!state.me) state.me = { name: 'admin', is_admin: true }; + if (!state.me) state.me = { name: 'admin', is_admin: true, role: 'admin' }; $('#gate').hidden = true; $('#app').hidden = false; - $('#who').textContent = state.me.name || 'admin'; + $('#who').innerHTML = esc(state.me.name || 'admin') + + (isEditor() ? ' 编辑' : ''); $('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare'; go('posts'); } @@ -2100,7 +2206,7 @@ function enterDirect() { const me = await api('/user'); if (!me || !me.user || !me.is_login) throw Object.assign(new Error('会话已失效'), { status: 401 }); state.me = me.user; - if (state.me.is_admin === false) throw Object.assign(new Error('该账号不是管理员'), { status: 403 }); + if (!canEnterBackend()) throw Object.assign(new Error('该账号不是管理员或编辑,没有写作后台权限'), { status: 403 }); localStorage.setItem(LS_U, JSON.stringify(state.me)); await bootstrap(); } catch (e) { diff --git a/blog-admin/schema.sql b/blog-admin/schema.sql index 5ce41bb0..1a4ef50f 100644 --- a/blog-admin/schema.sql +++ b/blog-admin/schema.sql @@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS users ( last_ip TEXT NOT NULL DEFAULT '', last_ua TEXT NOT NULL DEFAULT '', is_admin INTEGER NOT NULL DEFAULT 0, + role TEXT NOT NULL DEFAULT '', -- 后台角色:'' 普通评论用户 | 'editor' 编辑 | 'admin' 管理员(2026-10-05 追加,存量库需 ALTER) receive_email INTEGER NOT NULL DEFAULT 1, is_in_conf INTEGER NOT NULL DEFAULT 0, token_valid_from INTEGER NOT NULL DEFAULT 0, -- 早于此刻签发的 token 失效(改密码后踢下线) diff --git a/blog-admin/src/lib/cook.ts b/blog-admin/src/lib/cook.ts index e4bc6ba5..5cf0bf38 100644 --- a/blog-admin/src/lib/cook.ts +++ b/blog-admin/src/lib/cook.ts @@ -13,6 +13,7 @@ import type { import { findPagesByKeys, findUsersByIds, siteFirstUrl } from './db'; import { renderMarkdown } from './md'; import { md5Lower } from './md5'; +import { roleOf } from './role'; import { formatDateCN } from './util'; // ============================================================================ @@ -126,6 +127,7 @@ export function cookUser(u: UserRow): CookedUser { badge_color: u.badge_color || '', title_name: u.title_name || '', is_admin: !!u.is_admin, + role: roleOf(u), receive_email: !!u.receive_email, }; } diff --git a/blog-admin/src/lib/role.ts b/blog-admin/src/lib/role.ts new file mode 100644 index 00000000..35522bbb --- /dev/null +++ b/blog-admin/src/lib/role.ts @@ -0,0 +1,59 @@ +/** + * 后台角色。 + * + * 为什么单独一个小模块:`cook.ts`(输出用户结构)、`session.ts`(会话)、 + * `routes/editor.ts`(编辑器鉴权)、`routes/admin.ts`(用户管理)都要判角色, + * 谁 import 谁都不合适 —— 放这里谁都能用,也不会绕出循环依赖。 + * + * 两处事实源的关系: + * · `users.is_admin` 是历史字段,评论/通知/设置等一大堆既有逻辑都在用它 —— 不动。 + * · `users.role` 是这次新增的('' 普通评论用户 | 'editor' 编辑 | 'admin' 管理员), + * 只有它才能表达「编辑」这一级。 + * · 两者必须同步:role='admin' ⇔ is_admin=1。写入走 `normalizeRole()`, + * 读取走 `roleOf()`(以 is_admin 优先,兜住没同步过的历史行)。 + */ +import type { UserRole } from '../types'; + +/** 判角色只需要这两个字段,别把整个 UserRow 拖进来 */ +export interface RoleCarrier { + is_admin?: number | boolean | null; + role?: string | null; +} + +/** + * 归一化读取:只认三种结果。 + * ★ is_admin 优先 —— 历史行可能没有 role 列的值(ALTER 之前建的账号), + * 但 is_admin=1 是确定的,不能因为 role 是空串就把老管理员降级。 + */ +export function roleOf(u: RoleCarrier | null | undefined): UserRole { + if (!u) return 'user'; + if (u.is_admin) return 'admin'; + return String(u.role || '') === 'editor' ? 'editor' : 'user'; +} + +/** 写入方向:把前端传来的一对(role / is_admin)折叠成一个规范值 */ +export function normalizeRole(role: unknown, isAdmin: unknown): 'admin' | 'editor' | '' { + const r = String(role ?? '').trim().toLowerCase(); + if (r === 'admin' || r === 'editor') return r; + if (r === '' && isAdmin === undefined) return ''; + // 没给 role(老前端只给 is_admin)时按 is_admin 推 + if (role === undefined || role === null) return isAdmin ? 'admin' : ''; + // 给了个不认识的值:不猜,退回 is_admin 的语义 + return isAdmin ? 'admin' : ''; +} + +/** 能进写作后台的角色(管理员 + 编辑) */ +export function canWritePosts(u: RoleCarrier | null | undefined): boolean { + const r = roleOf(u); + return r === 'admin' || r === 'editor'; +} + +/** 编辑角色在文章维度上只能碰自己的(管理员不受限) */ +export function isScopedEditor(u: RoleCarrier | null | undefined): boolean { + return roleOf(u) === 'editor'; +} + +/** 给前端展示的角色名(后台用户管理列表用) */ +export function roleLabel(role: UserRole): string { + return role === 'admin' ? '管理员' : role === 'editor' ? '编辑' : ''; +} diff --git a/blog-admin/src/lib/session.ts b/blog-admin/src/lib/session.ts index fc88fcaa..b1401b2b 100644 --- a/blog-admin/src/lib/session.ts +++ b/blog-admin/src/lib/session.ts @@ -1,6 +1,7 @@ import type { Env, SessionUser, UserRow } from '../types'; import { findUserByEmail, findUserById, getAdminUsers } from './db'; import { md5 } from './md5'; +import { roleOf } from './role'; import { now } from './util'; // ================================================================= 编解码 @@ -166,7 +167,7 @@ export async function userFromToken(env: Env, authHeader: string | null): Promis export function sessionOf(user: UserRow | null): SessionUser | null { if (!user) return null; - return { id: user.id, name: user.name, email: user.email, isAdmin: !!user.is_admin }; + return { id: user.id, name: user.name, email: user.email, isAdmin: !!user.is_admin, role: roleOf(user) }; } /** diff --git a/blog-admin/src/routes/admin.ts b/blog-admin/src/routes/admin.ts index 3a8f40ec..96ae6c93 100644 --- a/blog-admin/src/routes/admin.ts +++ b/blog-admin/src/routes/admin.ts @@ -6,6 +6,7 @@ import { findOrCreateSite, findOrCreateUser, findUserByNameEmail, + getAdminUsers, getFrontendConf, isCaptchaEnabled, isIPRegionEnabled, @@ -14,6 +15,7 @@ import { import { isHumanCheckEnabled, setHumanCheck } from '../lib/human'; import { cookComments, cookPage, cookSite, cookUser } from '../lib/cook'; import { hashPassword, isAdminRequest } from '../lib/session'; +import { normalizeRole } from '../lib/role'; import { fail, formatDateCN, now, ok, okMsg, qInt, qp, readBody, trimTo } from '../lib/util'; import { parseSimpleYaml, toSimpleYaml } from '../lib/simple-yaml'; import { extractForm, formComments, mergeForm, SETTINGS_FORM } from '../lib/settings-form'; @@ -277,16 +279,18 @@ export async function userCreate(ctx: Ctx): Promise { if (existed) return fail(400, 'User already exists'); const t = now(); + const role = normalizeRole(body.role, body.is_admin); await ctx.env.DB.prepare( - `INSERT INTO users (name, email, link, password, is_admin, badge_name, badge_color, title_name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO users (name, email, link, password, is_admin, role, badge_name, badge_color, title_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( name, email, trimTo(body.link || '', 255), password ? await hashPassword(password) : '', - body.is_admin ? 1 : 0, + role === 'admin' ? 1 : 0, + role, trimTo(body.badge_name || '', 60), trimTo(body.badge_color || '', 20), trimTo(body.title_name || '', 30), @@ -345,9 +349,33 @@ export async function userUpdateAdmin(ctx: Ctx): Promise { sets.push('password = ?', 'token_valid_from = ?'); binds.push(await hashPassword(String(body.password)), now()); } - if (body.is_admin !== undefined) { - sets.push('is_admin = ?'); - binds.push(body.is_admin ? 1 : 0); + + // ---------------------------------------------------------------- 角色 + // role 与 is_admin 是一对(见 lib/role.ts),必须一起写,不能只写一个 —— + // 否则会出现「role='editor' 但 is_admin=1」这种自相矛盾的行, + // 而 is_admin 在评论/通知/设置那几十处旧逻辑里是权威。 + if (body.role !== undefined || body.is_admin !== undefined) { + const next = normalizeRole(body.role, body.is_admin); + + // ① 不能把自己降下去 —— 否则当场失去用户管理入口,只能去改数据库 + if (ctx.user && ctx.user.id === id && next !== 'admin') { + return fail(400, '不能修改自己的角色:请让另一位管理员来操作,避免把自己锁在门外'); + } + // ② 也不能把最后一个管理员降下去(全站就没人能管用户了) + if (next !== 'admin') { + const admins = await ctx.env.DB.prepare( + 'SELECT COUNT(*) AS n FROM users WHERE is_admin = 1 AND id != ?', + ) + .bind(id) + .first<{ n: number }>(); + const confAdmins = await getAdminUsers(ctx.env); + if ((admins?.n ?? 0) === 0 && !confAdmins.length) { + return fail(400, '这是最后一个管理员,不能取消它的管理员身份'); + } + } + + sets.push('role = ?', 'is_admin = ?'); + binds.push(next, next === 'admin' ? 1 : 0); } if (!sets.length) return okMsg(); diff --git a/blog-admin/src/routes/editor.ts b/blog-admin/src/routes/editor.ts index 8ca59739..3ea5b29d 100644 --- a/blog-admin/src/routes/editor.ts +++ b/blog-admin/src/routes/editor.ts @@ -4,16 +4,29 @@ * 为什么要绕这一跳,而不是让浏览器直连后端: * 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到; * 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口; - * 3. 复用后台已有的登录会话 —— 这里只需要 isAdminRequest,不用再造一套账号体系。 + * 3. 复用后台已有的登录会话 —— 这里只需要一套「谁在写文章」的判定。 * * 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`, * 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。 * 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。 + * + * ★★ 本文件是「谁能写博客」的唯一闸门,动它之前先读完下面两段。 */ import type { Ctx } from '../router'; import { fail, json } from '../lib/util'; import { getAdminUsers } from '../lib/db'; import { userFromToken } from '../lib/session'; +import { roleOf } from '../lib/role'; + +/** + * 操作者身份:后端 editor-api 靠它判断「这篇文章是不是你的」。 + * role 只有两档 —— admin(不受限)和 editor(只能碰自己的文章)。 + */ +interface EditorIdentity { + id: number; + name: string; + role: 'admin' | 'editor'; +} /** * ★ 鉴权只认「真实登录会话(Bearer token)」,**绝不能**用 isAdminRequest。 @@ -25,31 +38,55 @@ import { userFromToken } from '../lib/session'; * 删文章 + 传图 + 触发 git 发布(真推到 CNB/GitHub)」的能力裸露在公网, * 任何人拼一个 query 就能删你的博客。这里必须硬性要求会话。 * - * 已登录用户的判定与 isAdminRequest 一致(is_admin 或邮箱命中配置管理员), - * 只是砍掉了「匿名 + query 参数」那一条。 + * 放行两种人(与「用户管理」里的角色一一对应): + * · is_admin=1,或邮箱命中配置里的管理员 → admin + * · users.role = 'editor' → editor + * 普通评论用户(role='')没有登录过的话根本走不到这里;走得到也一律 403。 */ -async function requireAdminSession(ctx: Ctx) { +async function requireEditorSession(ctx: Ctx): Promise { const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization'))); if (!user) return null; - if (user.is_admin) return user; + + const role = roleOf(user); + if (role === 'admin') return { id: user.id, name: user.name, role: 'admin' }; + // 已登录但没打 is_admin 标的老账号:邮箱命中配置里的管理员也算 const admins = await getAdminUsers(ctx.env); - return admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase()) - ? user - : null; + if (admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase())) { + return { id: user.id, name: user.name, role: 'admin' }; + } + + if (role === 'editor') return { id: user.id, name: user.name, role: 'editor' }; + return null; } -async function guard(ctx: Ctx): Promise { - if (!(await requireAdminSession(ctx))) { - return fail(403, '需要管理员权限'); - } +/** 鉴权 + 后端配置检查。返回身份,或一个可以直接返回给浏览器的错误响应 */ +async function auth(ctx: Ctx): Promise<{ ident: EditorIdentity } | { deny: Response }> { + const ident = await requireEditorSession(ctx); + if (!ident) return { deny: fail(403, '需要管理员或编辑权限') }; if (!ctx.env.EDITOR_API_BASE) { - return fail(503, '后端未配置:缺少 EDITOR_API_BASE'); + return { deny: fail(503, '后端未配置:缺少 EDITOR_API_BASE') }; } if (!ctx.env.EDITOR_TOKEN) { - return fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)'); + return { deny: fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)') }; } - return null; + return { ident }; +} + +/** + * 把身份放进请求头传给后端。 + * + * ★ 名字必须 encodeURIComponent —— HTTP 头是 byte-string, + * 中文昵称("群林")直接塞进去会被丢字符甚至整条请求报错。 + * 后端拿到后 decode,所以那边看到的是原文。 + */ +function editorHeaders(env: Ctx['env'], ident: EditorIdentity): Record { + return { + 'X-Editor-Token': String(env.EDITOR_TOKEN), + 'X-Editor-Uid': String(ident.id), + 'X-Editor-User': encodeURIComponent(ident.name), + 'X-Editor-Role': ident.role, + }; } function target(ctx: Ctx, path: string, withQuery = false): string { @@ -59,15 +96,15 @@ function target(ctx: Ctx, path: string, withQuery = false): string { /** 发到后端并原样把 JSON 与状态码带回来 */ async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise { - const deny = await guard(ctx); - if (deny) return deny; + const a = await auth(ctx); + if ('deny' in a) return a.deny; let res: Response; try { res = await fetch(target(ctx, path, withQuery), { ...init, headers: { - 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), + ...editorHeaders(ctx.env, a.ident), ...(init.headers || {}), }, }); @@ -125,8 +162,8 @@ export const deletePost = (ctx: Ctx) => * Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。 */ export async function upload(ctx: Ctx): Promise { - const deny = await guard(ctx); - if (deny) return deny; + const a = await auth(ctx); + if ('deny' in a) return a.deny; const ct = ctx.req.headers.get('Content-Type') || ''; if (!ct.startsWith('image/')) return fail(400, '只接受图片'); @@ -135,7 +172,7 @@ export async function upload(ctx: Ctx): Promise { const res = await fetch(target(ctx, '/upload', true), { method: 'POST', headers: { - 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), + ...editorHeaders(ctx.env, a.ident), 'Content-Type': ct, }, body: ctx.req.body, @@ -163,17 +200,6 @@ export const sync = (ctx: Ctx) => relay(ctx, '/git/sync', { method: 'POST' }); // ---------------------------------------------------------------- 一键跳转 -/** - * 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 URL。 - * - * 怎么做到免登录:浏览器直接开 writeapi.usj.cc/admin/ 是要重新输账号密码的 - * (那边是独立的 Cookie 会话)。这里让 Worker 用共享令牌对当前时间戳做 - * HMAC 签名,前端拿着签名去 editor-api 的 /admin/handoff 换会话 Cookie —— - * 两边本来就共享 EDITOR_TOKEN,等于用已登录的 CF 会话给国内机做了一次担保。 - * - * 安全边界:签名 2 分钟内有效、同一签名只能换一次会话、且只有已登录管理员 - * 能从 Worker 拿到签名。URL 全程 HTTPS,不含任何账号密码。 - */ async function hmacHex(secret: string, msg: string): Promise { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( @@ -187,20 +213,40 @@ async function hmacHex(secret: string, msg: string): Promise { return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join(''); } +/** + * 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 URL。 + * + * 怎么做到免登录:浏览器直接开 writeapi.usj.cc/admin/ 是要重新输账号密码的 + * (那边是独立的 Cookie 会话)。这里让 Worker 用共享令牌对当前时间戳做 + * HMAC 签名,前端拿着签名去 editor-api 的 /admin/handoff 换会话 Cookie —— + * 两边本来就共享 EDITOR_TOKEN,等于用已登录的 CF 会话给国内机做了一次担保。 + * + * ★ 签名载荷里必须带**身份**(uid/名字/角色)。否则编辑点一下这个按钮, + * 到了国内机就变成那个唯一的管理员账号(cfg.adminUser)—— 角色直接越权。 + * 后端按载荷建会话,编辑过去还是编辑。 + * + * 安全边界:签名 2 分钟内有效、同一签名只能换一次会话、且只有已登录的 + * 管理员/编辑能从 Worker 拿到签名。URL 全程 HTTPS,不含任何账号密码。 + */ export async function handoff(ctx: Ctx): Promise { - const deny = await guard(ctx); - if (deny) return deny; + const a = await auth(ctx); + if ('deny' in a) return a.deny; const base = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, ''); const ts = Date.now().toString(); - const sig = await hmacHex(String(ctx.env.EDITOR_TOKEN), ts); - return json({ url: `${base}/api/v2/admin/handoff?ts=${ts}&t=${sig}` }); + const uid = String(a.ident.id); + const name = a.ident.name; + const role = a.ident.role; + const sig = await hmacHex(String(ctx.env.EDITOR_TOKEN), `${ts}\n${uid}\n${name}\n${role}`); + + const qs = new URLSearchParams({ ts, u: uid, n: name, r: role, t: sig }); + return json({ url: `${base}/api/v2/admin/handoff?${qs.toString()}` }); } /** 健康检查:让后台能显示「编辑后端是否在线」(同样只认真实会话) */ export async function health(ctx: Ctx): Promise { - if (!(await requireAdminSession(ctx))) return fail(403, '需要管理员权限'); - if (!ctx.env.EDITOR_API_BASE) return json({ ok: false, reason: '缺少 EDITOR_API_BASE' }); + const a = await auth(ctx); + if ('deny' in a) return a.deny; try { const res = await fetch(target(ctx, '/health'), { method: 'GET' }); return json({ ...(await res.json() as object), reachable: true }); diff --git a/blog-admin/src/routes/user.ts b/blog-admin/src/routes/user.ts index 6bdfe3f4..20c3a4f2 100644 --- a/blog-admin/src/routes/user.ts +++ b/blog-admin/src/routes/user.ts @@ -2,6 +2,7 @@ import type { Env, UserRow } from '../types'; import { findUserByEmail, findUserByName, findUserByNameEmail, rateLimit } from '../lib/db'; import { cookNotify, cookUser } from '../lib/cook'; import { isAdminByNameEmail, signToken, verifyPassword } from '../lib/session'; +import { roleOf } from '../lib/role'; import { fail, getClientIP, isEmail, now, ok, okMsg, qp, readBody, trimTo } from '../lib/util'; import type { Ctx } from '../router'; @@ -97,7 +98,7 @@ export async function userStatus(ctx: Ctx): Promise { const email = qp(url, 'email'); if (user) { - return ok({ is_admin: !!user.is_admin, is_login: true }); + return ok({ is_admin: !!user.is_admin, role: roleOf(user), is_login: true }); } if (name && email) { return ok({ is_admin: await isAdminByNameEmail(env, name, email), is_login: false }); diff --git a/blog-admin/src/types.ts b/blog-admin/src/types.ts index a58144a7..fa99d164 100644 --- a/blog-admin/src/types.ts +++ b/blog-admin/src/types.ts @@ -81,6 +81,14 @@ export interface PageRow { updated_at: number; } +/** + * 后台角色。 + * 'user' —— 普通评论用户(默认,没有后台权限) + * 'editor' —— 编辑:只能写/发布自己的文章,后台只看得到「文章编辑」 + * 'admin' —— 管理员:全部权限 + */ +export type UserRole = 'admin' | 'editor' | 'user'; + export interface UserRow { id: number; name: string; @@ -93,6 +101,8 @@ export interface UserRow { last_ip: string; last_ua: string; is_admin: number; + /** '' | 'editor' | 'admin'(历史行可能为 '',读取请走 roleOf()) */ + role: string; receive_email: number; is_in_conf: number; token_valid_from: number; @@ -188,6 +198,8 @@ export interface CookedUser { badge_color: string; title_name?: string; is_admin: boolean; + /** 归一化角色:前端据它决定导航能不能看到「评论管理/用户管理」等 */ + role: UserRole; receive_email: boolean; } @@ -213,4 +225,5 @@ export interface SessionUser { name: string; email: string; isAdmin: boolean; + role: UserRole; } diff --git a/editor-api/README.md b/editor-api/README.md index b9d63bed..cceede50 100644 --- a/editor-api/README.md +++ b/editor-api/README.md @@ -8,19 +8,54 @@ 只做这一件事:读写 `content/posts//<目录>/index.md`,外加把图片放进文章同级目录, 以及把改动 commit + push 触发上线。 -**不做**:评论、AI 摘要、部署编排、用户体系、订阅……那些要么在 Worker 里,要么已经不需要了。 +**不做**:评论、AI 摘要、部署编排、订阅……那些在 Worker 里。 +账号体系也**不在这里**(见下节),本服务只认 Worker 转发时声明的身份。 + +## 角色:管理员 / 编辑(2026-10-05 加) + +后台有两种角色,账号体系的唯一事实源是 **Cloudflare 侧 D1 的 `users.role` 列** +(`''` 普通评论用户 / `'editor'` 编辑 / `'admin'` 管理员,与 `is_admin` 同步): + +| | 管理员 | 编辑 | +|---|---|---| +| 文章列表 | 全部 | **只有自己写的** | +| 读 / 改 / 删 / 传图 | 任意文章 | **只能碰自己的**(越权一律 403) | +| 发布 | 全量 `git add content static` | **只 add 自己名下的文章目录**(不会顺走别人未发布的改动) | +| 新建文章 | 可指定作者 | 归属与署名由服务端钉死(伪造 `author_id` 无效) | +| 评论/用户/设置/订阅 | ✓ | ✗(Worker 和本服务两层都拒) | + +**归属怎么记**:文章 front matter 里的 `author_id`(= 后台用户 id,数字)。 +- 用 id 不用昵称 —— 管理员随时可能改昵称,按昵称比对会「丢文章」。 +- 老文章(改造前写的)没有这个字段 → 回退成按 `author` 昵称认领; + 编辑本人第一次保存时会自动补上 `author_id`,正式划到自己名下。 +- 服务端在保存/新建时**忽略**前端传来的 `author` / `author_id`,编辑永远改不了署名。 + +**身份怎么传进来**(两条通道,本服务不存任何账号): + +1. **Worker 反代通道**:请求带共享令牌 `X-Editor-Token`(浏览器拿不到), + Worker 鉴权通过后注入 `X-Editor-Uid` / `X-Editor-User`(encodeURIComponent 过的昵称)/ + `X-Editor-Role`。令牌对得上,这组头就是可信的。 + 只有令牌、没有身份头 → 按管理员处理(兼容 curl 调试和旧版 Worker)。 +2. **国内机直连通道**(浏览器直接开 writeapi.usj.cc/admin/):`POST /admin/login` 把 + 账号密码**转发给 Cloudflare 的 `/api/v2/user/access_token`** 校验,通过后签发本机 + HttpOnly Cookie 会话,会话里存 `{uid, name, role}`。写文章那条链路依然不出境。 + - Cloudflare 连不上时:本机管理员账号(`ADMIN_USER`/`ADMIN_PASS`)仍能登录(应急通道); + 其它账号会收到 503「校验不了」,而不是误导性的 401。 + - 「国内线路」一键跳转(`/admin/handoff`)的 HMAC 签名覆盖 `时间戳+uid+名字+角色` + —— **编辑跳过去还是编辑**,不会变成管理员。 ## 三层结构 ``` 浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上) /api/v2/editor/* 127.0.0.1:8017 - 只做「登录鉴权 + 注入 X-Editor-Token 转发」 真正读写文件 / git + 只做「登录鉴权 + 注入令牌与身份转发」 真正读写文件 / git ``` - 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。 - 容器端口只绑宿主机 `127.0.0.1`,公网扫不到;外面那层是 nginx 的 `/editor-api/`。 -- 鉴权是复用后台已有的管理员登录(`isAdminRequest`),不用再造一套账号。 +- 鉴权复用后台登录会话:管理员和编辑都放行(`/editor/*` 只有文章相关接口), + 评论/用户/设置等其它后台模块仍然只认管理员。 ## 环境变量 @@ -48,20 +83,23 @@ ## 接口 -除 `GET /health` 外一律要 `X-Editor-Token`,没有就 401。 +除 `GET /health` 外一律要凭据:`X-Editor-Token`(Worker 通道,附带身份头) +或本机会话 Cookie(国内直连通道),没有就 401。 | 方法 | 路径 | 说明 | |---|---|---| | GET | `/health` | 健康检查(免鉴权) | -| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记) | +| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记;编辑只看到自己的) | | POST | `/posts` | 新建,body `{frontMatter:{title,...}, content}` | | GET | `/posts/:id` | 读单篇(**id = 目录名**,见下) | | PUT | `/posts/:id` | 保存,body `{content, frontMatter}` | | DELETE | `/posts/:id` | 移到回收目录 | -| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录 | -| GET | `/git/status` | 分支 / 改动文件 / 最近提交 | -| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push | +| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录(编辑必须带 key) | +| GET | `/git/status` | 分支 / 改动文件 / 最近提交(编辑只看到自己目录的改动,返回里 `scoped:true`) | +| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push(编辑只 add 自己的目录) | | POST | `/git/sync` | pull --rebase --autostash | +| GET/POST | `/admin/session` `/admin/login` `/admin/logout` | 国内直连后台的登录会话 | +| GET | `/admin/handoff?ts=&u=&n=&r=&t=` | 「国内线路」免登录跳转(签名覆盖身份) | ### 为什么用目录名当 id,不用 slug @@ -94,17 +132,21 @@ BLOG_ROOT=E:/GitHub/blog node test/save-roundtrip.mjs # ③ 接口端到端(对真实仓库跑,测完自动还原) BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端 node test/api-e2e.mjs http://127.0.0.1:8017 devtoken + +# ④ 角色权限矩阵(自带一次性 git 仓库,不碰真仓库) +node test/role-perm.mjs ``` ## 本地联调 ```bash -# 1) 后端 +# 1) 后端(角色测试可以直接指到 .editor-tmp/uitest/blog 那个一次性仓库) EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs -# 2) Worker(blog-admin 目录) -npx wrangler dev --port 8799 +# 2) Worker(blog-admin 目录)—— ★ 本机要先清代理环境变量,否则 wrangler dev 卡死在启动 +env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY npx wrangler dev --port 8799 # .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken +# 本地 D1 要先给 users 加 role 列(线上同理,见部署清单) ``` 部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。 diff --git a/editor-api/server.mjs b/editor-api/server.mjs index 5ca4e909..91c77156 100644 --- a/editor-api/server.mjs +++ b/editor-api/server.mjs @@ -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); diff --git a/editor-api/src/git.mjs b/editor-api/src/git.mjs index ec680362..2fae6179 100644 --- a/editor-api/src/git.mjs +++ b/editor-api/src/git.mjs @@ -41,27 +41,59 @@ export function makeGit(cfg) { '-c', 'commit.gpgsign=false', ]; - async function status() { - // core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义, - // 否则发布弹层里满屏都是看不懂的编码 - const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...paths]); + /** + * scope 语义(编辑角色 = 只提交自己的文章): + * undefined → 全量 cfg.paths(content / static),管理员用 + * string[] → 只用这些 pathspec(编辑自己的文章目录) + * [] → 编辑名下还没有文章目录:什么都不做(不能退化成全量!) + * + * ★ 空数组必须显式拦掉。`git add -- ` 后面不跟 pathspec 就等于 `git add -A`, + * 会把仓库里所有人未提交的改动一起提交进去 —— 那正是要避免的事。 + */ + function resolvePaths(scope) { + if (Array.isArray(scope)) return scope; + return paths; + } + + async function status(scope) { + const usePaths = resolvePaths(scope); + const scoped = Array.isArray(scope); + const head = await run(repoRoot, ['log', '-1', '--pretty=%h%x09%ad%x09%s', '--date=format:%Y-%m-%d %H:%M']); const br = await run(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']); + const base = { + branch: br.out.trim(), + scoped, + lastCommit: head.out.trim(), + }; + + if (scoped && !usePaths.length) { + return { ...base, dirty: false, changed: 0, files: [] }; + } + + // core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义, + // 否则发布弹层里满屏都是看不懂的编码 + const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...usePaths]); const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean); return { - branch: br.out.trim(), + ...base, dirty: files.length > 0, changed: files.length, files: files.slice(0, 50), - lastCommit: head.out.trim(), }; } - async function publish(message) { + async function publish(message, scope) { return serial(async () => { const log = []; + const usePaths = resolvePaths(scope); + const scoped = Array.isArray(scope); - const add = await run(repoRoot, ['add', '--', ...paths]); + if (scoped && !usePaths.length) { + return { ok: true, pushed: [], scoped: true, warning: null, log: '(你名下还没有文章目录,没有需要发布的内容)' }; + } + + const add = await run(repoRoot, ['add', '--', ...usePaths]); log.push(add.out.trim()); // 先提交(没有暂存内容就跳过) @@ -100,6 +132,7 @@ export function makeGit(cfg) { return { ok: true, pushed, + scoped, // 备份远端推失败不算发布失败(主仓成了就算成),但如实说明 warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null, log: log.join('\n'), diff --git a/editor-api/src/posts.mjs b/editor-api/src/posts.mjs index 2239279a..51e4df8e 100644 --- a/editor-api/src/posts.mjs +++ b/editor-api/src/posts.mjs @@ -101,6 +101,13 @@ export function makePosts(cfg) { tags: Array.isArray(fm.tags) ? fm.tags : [], categories: Array.isArray(fm.categories) ? fm.categories : [], author: fm.author || '', + /** + * 归属键:front matter 里的 author_id(= 后台用户的数字 id)。 + * 用 id 而不是昵称 —— 管理员随时可能在「用户管理」里改昵称, + * 一改昵称,按名字比对的文章就全「丢了」。 + * 老文章(改造前写的)没有这个字段,回退成比昵称,见 owns()。 + */ + authorId: fm.author_id === undefined || fm.author_id === null ? '' : String(fm.author_id), dirName: name, relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'), parseable: !info.fmError, @@ -108,9 +115,45 @@ export function makePosts(cfg) { }; } - function listPosts({ q = '', page = 1, perPage = 20 } = {}) { + /** 仓库相对路径(发布时当 git 的 pathspec 用) */ + function relOf(dir) { + return path.relative(repoRoot || path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'); + } + + // ------------------------------------------------------------ 归属判定 + // + // viewer = { uid, name, role } —— 由 server.mjs 从请求头/会话里解出来。 + // admin 不受限;editor 只能碰自己的。 + // viewer 为 null/undefined = 内部调用(不做限制),只用于本文件内部互相调用。 + + function owns(item, viewer) { + if (!item || !viewer) return true; + if (viewer.role !== 'editor') return true; // 管理员(或未知角色走不到这儿) + const id = String(item.authorId || ''); + if (id) return id === String(viewer.uid); + // 老文章的兜底:没有 author_id 时按昵称认领(改造前 write-server 写的那些) + return !!viewer.name && item.author === viewer.name; + } + + function assertOwned(item, viewer) { + if (owns(item, viewer)) return; + throw bad('这篇文章不是你的,编辑角色只能改动自己写的文章(作者:' + (item.author || '未署名') + ')', 403); + } + + /** 编辑自己在仓库里的文章目录(git pathspec),管理员返回 null 表示「全量」 */ + function ownedPaths(viewer) { + if (!viewer || viewer.role !== 'editor') return null; + return findPostDirs() + .filter((dir) => owns(toListItem(dir), viewer)) + .map(relOf); + } + + function listPosts({ q = '', page = 1, perPage = 20 } = {}, viewer) { let rows = findPostDirs().map(toListItem); + // 编辑角色:列表只显示自己写的(连「别人有几篇」都不让它知道) + if (viewer && viewer.role === 'editor') rows = rows.filter((r) => owns(r, viewer)); + // slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉 const seen = new Map(); for (const r of rows) seen.set(r.slug, (seen.get(r.slug) || 0) + 1); @@ -169,7 +212,8 @@ export function makePosts(cfg) { return null; } - function getPost(key) { + /** 内部读取:不带归属校验,只给本文件的其它函数用(**别直接暴露给路由**) */ + function readPost(key) { const dir = locate(key); if (!dir) return null; const item = toListItem(dir); @@ -188,6 +232,14 @@ export function makePosts(cfg) { }; } + /** 对外读取:编辑角色只能打开自己的文章 */ + function getPost(key, viewer) { + const post = readPost(key); + if (!post) return null; + assertOwned(post, viewer); + return post; + } + /** 只把前端真正传上来的字段合并进去,undefined 一律忽略(避免覆盖成空) */ function pickDefined(obj) { const out = {}; @@ -198,17 +250,36 @@ export function makePosts(cfg) { return out; } - function savePost(key, patch = {}) { - const post = getPost(key); + function savePost(key, patch = {}, viewer) { + const post = readPost(key); if (!post) return null; + assertOwned(post, viewer); + // 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格 if (typeof post.filePath !== 'string' || !post.eol) { throw Object.assign(new Error('内部错误:文章记录缺少 filePath/eol'), { status: 500 }); } + // 编辑角色:署名与归属由服务端钉死,前端传什么都不采纳 + if (viewer && viewer.role === 'editor') { + if (!post.frontMatter) throw bad('这篇的 front matter 无法解析,为避免写坏,请先修好再来改'); + if (patch.frontMatter) { + delete patch.frontMatter.author_id; + delete patch.frontMatter.author; + } + } + const incoming = pickDefined(patch.frontMatter); const merged = { ...(post.frontMatter || {}), ...incoming }; + // 老文章(改造前没有 author_id):第一次由编辑本人保存时补上,正式划到自己名下。 + // 已有则不覆写 —— 避免把别人名下的文章顺手改成自己的。 + if (viewer && viewer.role === 'editor') { + const cur = post.frontMatter.author_id; + merged.author_id = cur === undefined || cur === null ? String(viewer.uid) : cur; + if (!merged.author) merged.author = viewer.name; + } + let fmText; if (post.frontMatter && deepEqual(merged, post.frontMatter)) { // 没动 front matter → 原文照抄,零风险 @@ -227,7 +298,7 @@ export function makePosts(cfg) { fs.writeFileSync(post.filePath, text, 'utf8'); // 回读用 dirName 定位(用 slug 有撞名风险,可能读到别的文章) - const after = getPost(post.dirName); + const after = readPost(post.dirName); return { id: post.dirName, slug: post.slug, dirPath: path.dirname(post.filePath), url: after ? after.url : post.url }; } @@ -254,7 +325,7 @@ export function makePosts(cfg) { ); } - function createPost({ frontMatter = {}, content = '' } = {}) { + function createPost({ frontMatter = {}, content = '' } = {}, viewer) { const title = String(frontMatter.title || '').trim(); if (!title) throw bad('缺少标题'); const slug = String(frontMatter.slug || '').trim() || makeSlug(); @@ -268,16 +339,34 @@ export function makePosts(cfg) { const ymd = date.slice(0, 10); const year = ymd.slice(0, 4); + // 署名:编辑只能署自己;管理员可指定(默认取配置里的 DEFAULT_AUTHOR) + const author = + viewer && viewer.role === 'editor' + ? viewer.name + : String(frontMatter.author || defaultAuthor || (viewer ? viewer.name : '') || ''); + const fm = { title, date, slug, - author: String(frontMatter.author || defaultAuthor || ''), + author, + }; + + // 归属:由服务端钉死。前端传什么都不算 —— 否则编辑可以伪造别人的 author_id, + // 把文章塞到别人名下(或者反过来,把锅甩给别人)。 + if (viewer) { + fm.author_id = + viewer.role === 'admin' && frontMatter.author_id != null + ? String(frontMatter.author_id) + : String(viewer.uid); + } + + Object.assign(fm, { layout: String(frontMatter.layout || 'post'), categories: Array.isArray(frontMatter.categories) ? frontMatter.categories : [], tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : [], draft: frontMatter.draft === true, - }; + }); // 目录名与 write-server 的 computeDirPath 一致:<日期>-<标题段>- const dirName = ymd + '-' + titlePartOf(title) + '-' + slug; @@ -335,9 +424,10 @@ export function makePosts(cfg) { } } - function deletePost(key) { - const post = getPost(key); + function deletePost(key, viewer) { + const post = readPost(key); if (!post) return null; + assertOwned(post, viewer); const dir = path.dirname(post.filePath); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const dest = path.join(trashDir, stamp + '-' + path.basename(dir)); @@ -347,15 +437,23 @@ export function makePosts(cfg) { } /** 图片落到文章同级目录,正文用裸文件名引用(仓库现行写法) */ - function imageTargetDir(key) { + function imageTargetDir(key, viewer) { if (key) { - const post = getPost(key); - if (post) return { dir: path.dirname(post.filePath), bare: true }; + const post = readPost(key); + if (post) { + assertOwned(post, viewer); + return { dir: path.dirname(post.filePath), bare: true }; + } throw bad('图片要挂到哪篇文章?找不到: ' + key, 404); } + // 不带 key = 传进 static/image/<年>/ 这个公共图库。编辑角色不走这条路: + // 那只会在共享目录里留下没人认领的文件,而且发布时也没法精确提交。 + if (viewer && viewer.role === 'editor') { + throw bad('请先打开一篇文章再粘图(编辑角色只支持传到自己文章的目录里)', 403); + } const year = new Date().getFullYear().toString(); return { dir: path.join(contentDir, '..', '..', 'static', 'image', year), bare: false, urlPrefix: '/image/' + year + '/' }; } - return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, contentDir }; + return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, ownedPaths, contentDir }; } diff --git a/editor-api/test/role-perm.mjs b/editor-api/test/role-perm.mjs new file mode 100644 index 00000000..f908dd0d --- /dev/null +++ b/editor-api/test/role-perm.mjs @@ -0,0 +1,466 @@ +/** + * editor-api「管理员 / 编辑」角色权限矩阵 —— 端到端测试。 + * + * node test/role-perm.mjs + * + * 自己搭一个一次性 git 仓库(含 3 篇不同归属的文章)跑真实 HTTP 请求, + * 测完连临时目录一起删掉 —— 不碰真仓库、不碰线上。 + * + * 覆盖的是这次改造的核心承诺: + * · 编辑只能看/改/删/传图到自己的文章(含老文章按昵称认领的兜底) + * · 编辑建文章时归属与署名由服务端钉死,前端伪造 author_id 无效 + * · 编辑发布只提交自己的文章目录,不会把别人未提交的改动一起带走 + * · 「国内线路」一键跳转不能把编辑变成管理员(签名覆盖身份) + * · 管理员能力与改造前一致 + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFile, spawn } from 'node:child_process'; + +const PORT = 8099; +const BASE = `http://127.0.0.1:${PORT}`; +const TOKEN = 'test-token-' + crypto.randomBytes(4).toString('hex'); +const ADMIN_PASS = 'local-admin-pass'; + +const ADMIN = { uid: '1', name: '群林', role: 'admin' }; +const EDITOR = { uid: '7', name: '小美', role: 'editor' }; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'editor-role-')); +const BLOG = path.join(TMP, 'blog'); +const TRASH = path.join(TMP, 'trash'); +const ORIGIN = path.join(TMP, 'origin.git'); + +let pass = 0; +const fails = []; +function check(name, cond, extra = '') { + if (cond) { + pass++; + console.log(' ✅ ' + name); + } else { + fails.push(name + (extra ? ' → ' + extra : '')); + console.log(' ❌ ' + name + (extra ? ' → ' + extra : '')); + } +} +function section(t) { + console.log('\n' + t); +} + +// ------------------------------------------------------------------ 搭仓库 + +// 用异步 execFile,不用 execFileSync —— +// 本机(Windows + 沙箱)同步版 spawn 一律 EBUSY,异步版正常。 +// editor-api 自己的 git.mjs 用的也是异步,所以线上没这个问题。 +function sh(cwd, args) { + return new Promise((resolve, reject) => { + execFile('git', args, { cwd, encoding: 'utf8' }, (err, stdout, stderr) => { + if (err) reject(Object.assign(err, { stdout, stderr })); + else resolve(stdout); + }); + }); +} + +function writePost(dirName, fmLines, body) { + const dir = path.join(BLOG, 'content', 'posts', '2026', dirName); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.md'), ['---', ...fmLines, '---', '', body, ''].join('\n'), 'utf8'); + return dir; +} + +async function setupRepo() { + fs.mkdirSync(BLOG, { recursive: true }); + await sh(TMP, ['init', '--bare', '-b', 'main', ORIGIN]); + await sh(BLOG, ['init', '-b', 'main']); + await sh(BLOG, ['config', 'user.name', 'tester']); + await sh(BLOG, ['config', 'user.email', 'tester@local']); + + // ① 小美(id=7)的文章 + writePost('2026-10-01-妈妈的菜-20261001093000', [ + 'title: 妈妈的菜', 'date: 2026-10-01', 'slug: 20261001093000', + 'author: 小美', 'author_id: 7', 'layout: post', 'tags: []', 'categories: []', 'draft: false', + ], '今天做了红烧肉。'); + + // ② 群林(id=1)的文章 + writePost('2026-10-02-我的随笔-20261002120000', [ + 'title: 我的随笔', 'date: 2026-10-02', 'slug: 20261002120000', + 'author: 群林', 'author_id: 1', 'layout: post', 'tags: []', 'categories: []', 'draft: false', + ], '随便写点。'); + + // ③ 老文章:没有 author_id,只能按昵称认领(改造前 write-server 写的那些) + writePost('2026-10-03-老文章-20261003150000', [ + 'title: 老文章', 'date: 2026-10-03', 'slug: 20261003150000', + 'author: 小美', 'layout: post', 'tags: []', 'categories: []', 'draft: false', + ], '很久以前写的。'); + + await sh(BLOG, ['add', '-A']); + await sh(BLOG, ['commit', '-m', 'init']); + await sh(BLOG, ['remote', 'add', 'origin', ORIGIN]); + await sh(BLOG, ['push', '-u', 'origin', 'main']); +} + +const DIR_MINE = '2026-10-01-妈妈的菜-20261001093000'; +const DIR_ADMIN = '2026-10-02-我的随笔-20261002120000'; +const DIR_LEGACY = '2026-10-03-老文章-20261003150000'; + +// ------------------------------------------------------------------ 起服务 + +const child = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], { + env: { + ...process.env, + PORT: String(PORT), + BIND_HOST: '127.0.0.1', + BLOG_ROOT: BLOG, + TRASH_DIR: TRASH, + SESSION_FILE: path.join(TMP, 'sessions.json'), + EDITOR_TOKEN: TOKEN, + ADMIN_USER: 'admin', + ADMIN_PASS, + GIT_BRANCH: 'main', + PUSH_REMOTES: 'origin', + GIT_PATHS: 'content,static', + // 故意指向一个连不上的地址:验证「CF 不可达时回退本机管理员」这条应急通道 + RSS_API_BASE: 'http://127.0.0.1:9', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}); +let serverLog = ''; +child.stdout.on('data', (d) => (serverLog += d)); +child.stderr.on('data', (d) => (serverLog += d)); + +async function waitUp() { + for (let i = 0; i < 50; i++) { + try { + const r = await fetch(BASE + '/health'); + if (r.ok) return true; + } catch {} + await new Promise((s) => setTimeout(s, 120)); + } + return false; +} + +async function call(method, p, { body, ident, headers = {}, cookie, raw } = {}) { + const h = { ...headers }; + if (ident) { + h['X-Editor-Token'] = TOKEN; + h['X-Editor-Uid'] = ident.uid; + h['X-Editor-User'] = encodeURIComponent(ident.name); + h['X-Editor-Role'] = ident.role; + } + if (cookie) h.Cookie = cookie; + if (body !== undefined && !raw) h['Content-Type'] = 'application/json'; + const res = await fetch(BASE + p, { + method, + headers: h, + body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined, + redirect: 'manual', + }); + const text = await res.text(); + let data; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + return { status: res.status, data, cookie: res.headers.get('set-cookie') }; +} + +const P = (id) => '/posts/' + encodeURIComponent(id); +const readFile = (dirName) => + fs.readFileSync(path.join(BLOG, 'content', 'posts', '2026', dirName, 'index.md'), 'utf8'); + +/** + * ⑧ 直连登录的**主路径**:国内机把账号密码转发给 Cloudflare 的用户表校验。 + * 用一个假的 CF(只实现 /api/v2/user/access_token)来测 —— + * 不依赖真线上账号,也能覆盖「编辑能不能从国内线路登录」这条。 + */ +async function sectionCfLogin() { + section('⑧ 直连登录走 Cloudflare 用户表(主路径)'); + + const CF_PORT = 8098; + const PORT2 = 8097; + const http = await import('node:http'); + + const stub = http.createServer((req, res) => { + let buf = ''; + req.on('data', (c) => (buf += c)); + req.on('end', () => { + if (!req.url.startsWith('/api/v2/user/access_token')) { + res.writeHead(404).end('{}'); + return; + } + const body = JSON.parse(buf || '{}'); + const okPair = + (body.email === 'xiaomei' && body.password === 'good-pass') || + (body.email === 'someone' && body.password === 'plain-pass') || + (body.email === 'admin' && body.password === 'admin-pass'); + if (!okPair) { + res.writeHead(401, { 'Content-Type': 'application/json' }).end('{"msg":"Unauthorized"}'); + return; + } + const users = { + xiaomei: { id: 7, name: '小美', role: 'editor', is_admin: false }, + admin: { id: 1, name: '群林', role: 'admin', is_admin: true }, + // 普通评论用户:有账号,但没写作后台权限 + someone: { id: 42, name: '路人', role: 'user', is_admin: false }, + }; + res.writeHead(200, { 'Content-Type': 'application/json' }).end( + JSON.stringify({ token: 'tok', user: users[body.email] }), + ); + }); + }); + await new Promise((s) => stub.listen(CF_PORT, '127.0.0.1', s)); + + const srv2 = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], { + env: { + ...process.env, + PORT: String(PORT2), + BIND_HOST: '127.0.0.1', + BLOG_ROOT: BLOG, + TRASH_DIR: TRASH, + SESSION_FILE: path.join(TMP, 'sessions2.json'), + EDITOR_TOKEN: TOKEN, + ADMIN_USER: 'admin', + ADMIN_PASS, + GIT_BRANCH: 'main', + PUSH_REMOTES: 'origin', + RSS_API_BASE: `http://127.0.0.1:${CF_PORT}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let log2 = ''; + srv2.stdout.on('data', (d) => (log2 += d)); + srv2.stderr.on('data', (d) => (log2 += d)); + + const B2 = `http://127.0.0.1:${PORT2}`; + for (let i = 0; i < 50; i++) { + try { + if ((await fetch(B2 + '/health')).ok) break; + } catch {} + await new Promise((s) => setTimeout(s, 120)); + } + + const call2 = async (p, body) => { + const res = await fetch(B2 + p, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + redirect: 'manual', + }); + const text = await res.text(); + let data; + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + return { status: res.status, data, cookie: res.headers.get('set-cookie') }; + }; + + const post = async (p, body, cookie) => { + const res = await fetch(B2 + p, { + method: 'GET', + headers: cookie ? { Cookie: cookie } : {}, + }); + return { status: res.status, data: await res.json().catch(() => null) }; + }; + + let r = await call2('/admin/login', { user: 'xiaomei', password: 'good-pass' }); + check('编辑账号从国内线路登录 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140)); + check('会话角色是 editor(不是 admin)', r.data.user && r.data.user.role === 'editor' && r.data.user.name === '小美', JSON.stringify(r.data.user)); + + const ck = String(r.cookie).split(';')[0]; + r = await post('/posts', null, ck); + check('编辑登录后只看到自己的文章', r.data && r.data.total === 3, 'total=' + (r.data && r.data.total)); + + r = await call2('/admin/login', { user: 'someone', password: 'plain-pass' }); + check('普通评论用户 → 401(没有写作后台权限)', r.status === 401, 'got ' + r.status); + + r = await call2('/admin/login', { user: 'xiaomei', password: 'bad-pass' }); + check('密码错(CF 可达)→ 401', r.status === 401, 'got ' + r.status); + + r = await call2('/admin/login', { user: 'admin', password: 'admin-pass' }); + check('管理员账号 → 200 且角色 admin', r.status === 200 && r.data.user.role === 'admin', JSON.stringify(r.data.user)); + + srv2.kill(); + stub.close(); +} + +// ------------------------------------------------------------------ 跑 + +async function main() { + await setupRepo(); + if (!(await waitUp())) { + console.error('服务没起来:\n' + serverLog); + process.exit(1); + } + + section('① 身份门禁'); + let r = await call('GET', '/posts'); + check('不带凭据 → 401', r.status === 401, 'got ' + r.status); + + r = await call('GET', '/posts', { headers: { 'X-Editor-Token': TOKEN } }); + check('只带令牌、无身份头 → 视为管理员(兼容 curl / 旧 Worker)', r.status === 200 && r.data.total === 3, JSON.stringify(r.data).slice(0, 120)); + + r = await call('GET', '/posts', { headers: { 'X-Editor-Token': 'wrong', 'X-Editor-Uid': '7', 'X-Editor-User': '%E5%B0%8F%E7%BE%8E', 'X-Editor-Role': 'editor' } }); + check('令牌不对 → 401(伪造身份头没用)', r.status === 401, 'got ' + r.status); + + r = await call('GET', '/posts', { ident: EDITOR }); + check('编辑看到 2 篇(自己 + 老的昵称认领),看不到群林那篇', r.status === 200 && r.data.total === 2 && + !r.data.posts.some((p) => p.id === DIR_ADMIN), JSON.stringify(r.data.posts?.map((p) => p.id))); + + r = await call('GET', '/posts', { ident: ADMIN }); + check('管理员看到全部 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total); + + section('② 编辑不能碰别人的文章'); + r = await call('GET', P(DIR_ADMIN), { ident: EDITOR }); + check('打开别人的文章 → 403', r.status === 403, 'got ' + r.status); + + r = await call('PUT', P(DIR_ADMIN), { ident: EDITOR, body: { content: '我改' } }); + check('改别人的文章 → 403', r.status === 403, 'got ' + r.status); + check('别人的文章内容没被动', readFile(DIR_ADMIN).includes('随便写点。')); + + r = await call('DELETE', P(DIR_ADMIN), { ident: EDITOR }); + check('删别人的文章 → 403', r.status === 403, 'got ' + r.status); + check('别人的文章还在原处', fs.existsSync(path.join(BLOG, 'content/posts/2026', DIR_ADMIN, 'index.md'))); + + r = await call('POST', '/upload?key=' + encodeURIComponent(DIR_ADMIN), { + ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'), + headers: { 'Content-Type': 'image/png' }, + }); + check('往别人文章目录传图 → 403', r.status === 403, 'got ' + r.status); + + r = await call('POST', '/upload', { + ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'), + headers: { 'Content-Type': 'image/png' }, + }); + check('不带 key 传图(公共图库)→ 403', r.status === 403, 'got ' + r.status); + + section('③ 编辑自己的文章:正常能力 + 老文章认领'); + r = await call('PUT', P(DIR_LEGACY), { ident: EDITOR, body: { content: '很久以前写的。改一个字。' } }); + check('改自己(老文章按昵称认领)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140)); + const legacyText = readFile(DIR_LEGACY); + check('老文章保存后补上了 author_id: 7(正式划到自己名下)', /author_id:\s*7\b/.test(legacyText), legacyText.slice(0, 200)); + check('老文章正文改到了', legacyText.includes('改一个字')); + + r = await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '今天做了红烧肉,很好吃。' } }); + check('改自己(有 author_id)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140)); + + r = await call('POST', '/upload?name=a.png&key=' + encodeURIComponent(DIR_MINE), { + ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'), + headers: { 'Content-Type': 'image/png' }, + }); + check('往自己文章目录传图 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140)); + check('图片确实落在自己文章目录里', r.data.dir === 'content/posts/2026/' + DIR_MINE, String(r.data.dir)); + + section('④ 新建文章的归属由服务端钉死'); + r = await call('POST', '/posts', { + ident: EDITOR, + body: { + frontMatter: { title: '伪造归属测试', slug: 'fakeowner' + Date.now(), author: '群林', author_id: 1 }, + content: '正文', + }, + }); + check('新建 → 200', r.status === 200 && r.data.id, JSON.stringify(r.data).slice(0, 140)); + const created = readFile(r.data.id); + check('author_id 被强制成 7(前端传的 1 无效)', /author_id:\s*7\b/.test(created), created.slice(0, 240)); + check('author 被强制成「小美」(不能替别人署名)', /author:\s*小美/.test(created), created.slice(0, 240)); + + section('⑤ 发布范围:编辑只提交自己的目录'); + // 两边各改一处:编辑改自己的,管理员改自己的 —— 编辑发布时不能顺走管理员那笔 + await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '准备发布' } }); + await call('PUT', P(DIR_ADMIN), { ident: ADMIN, body: { content: '管理员自己的未提交改动' } }); + + r = await call('GET', '/git/status', { ident: EDITOR }); + check('编辑看到的状态是「限定范围」的', r.status === 200 && r.data.scoped === true, JSON.stringify(r.data).slice(0, 160)); + check('编辑只看到自己目录的改动', (r.data.files || []).every((f) => f.includes(DIR_MINE) || f.includes(DIR_LEGACY) || f.includes('fakeowner')), + JSON.stringify(r.data.files)); + + r = await call('GET', '/git/status', { ident: ADMIN }); + check('管理员看到全量改动(含编辑的和自己的)', r.data.scoped === false && (r.data.files || []).some((f) => f.includes(DIR_MINE)), + JSON.stringify(r.data.files)); + + r = await call('POST', '/git/publish', { ident: EDITOR, body: { message: '编辑:发布我的' } }); + check('编辑发布 → 成功', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 200)); + check('返回里标明是限定范围发布', r.data.scoped === true, String(r.data.scoped)); + + // core.quotepath=false:中文目录名默认会被 git 转成 \346\210\221 八进制转义, + // 拿它跟原始目录名比字符串必然对不上(编辑器里已经踩过一次同样的坑) + const committed = await sh(BLOG, ['-c', 'core.quotepath=false', 'show', '--stat', '--oneline', 'HEAD']); + check('提交里没有管理员的文章', !committed.includes(DIR_ADMIN), committed.slice(0, 300)); + check('提交里有编辑自己的文章', committed.includes(DIR_MINE), committed.slice(0, 300)); + + const stillDirty = await sh(BLOG, ['-c', 'core.quotepath=false', 'status', '--porcelain']); + check('管理员那篇仍是未提交状态(没被顺走)', stillDirty.includes(DIR_ADMIN), stillDirty); + + section('⑥ 国内线路一键跳转不能越权'); + const ts = String(Date.now()); + const sigFor = (uid, name, role) => + crypto.createHmac('sha256', TOKEN).update(`${ts}\n${uid}\n${name}\n${role}`).digest('hex'); + + r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=deadbeef`); + check('签名错 → 403', r.status === 403, 'got ' + r.status); + + r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=${sigFor('7', '小美', 'editor')}`); + check('编辑的正确签名 → 302 + 发 Cookie', r.status === 302 && !!r.cookie, 'got ' + r.status); + + // 拿这枚 Cookie 冒充管理员操作 + const edCookie = String(r.cookie).split(';')[0]; + r = await call('GET', P(DIR_ADMIN), { cookie: edCookie }); + check('用这枚 Cookie 打开管理员那篇 → 仍 403(跳转没有把编辑变成管理员)', r.status === 403, 'got ' + r.status); + + r = await call('GET', '/posts', { cookie: edCookie }); + // 到这一步编辑名下有 3 篇了:自己的、按昵称认领的老文章、以及第 ④ 节新建的那篇 + check('用这枚 Cookie 列文章 → 只看到自己的 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total); + + // 把签名载荷里的 r 改成 admin(签名仍是按 editor 算的)→ 必须拒 + r = await call('GET', `/admin/handoff?ts=${String(Date.now())}&u=7&n=%E5%B0%8F%E7%BE%8E&r=admin&t=${sigFor('7', '小美', 'editor')}`); + check('把角色改成 admin 但签名不匹配 → 403', r.status === 403, 'got ' + r.status); + + section('⑦ 直连登录(CF 不可达时的应急通道)'); + // 本测试故意把 RSS_API_BASE 指到连不上的地址,模拟「国内机连不上 Cloudflare」。 + // 此时账号密码**无法校验**,如实回 503 比谎报 401「密码错」有用得多 + // (线上 CF 正常时,密码错会由 CF 回 401 → 这里回 401,见下一条)。 + r = await call('POST', '/admin/login', { body: { user: 'admin', password: 'wrong' } }); + check('CF 不可达 + 不是本机管理员 → 503(如实说「校验不了」)', r.status === 503, 'got ' + r.status); + + r = await call('POST', '/admin/login', { body: { user: 'admin', password: ADMIN_PASS } }); + check('本机管理员账号 → 200(CF 连不上也能进)', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140)); + const admCookie = String(r.cookie).split(';')[0]; + check('会话带上角色 admin', r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data.user)); + + r = await call('GET', '/posts', { cookie: admCookie }); + check('这枚 Cookie 能看到全部 3+1 篇', r.status === 200 && r.data.total >= 4, 'total=' + r.data.total); + + r = await call('GET', '/admin/session', { cookie: admCookie }); + check('会话探测返回身份(含 role)', r.status === 200 && r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data)); + + r = await call('GET', '/admin/session'); + check('没会话时 → 401 且 mode=direct', r.status === 401 && r.data.mode === 'direct', JSON.stringify(r.data)); + + await sectionCfLogin(); + + console.log('\n' + '─'.repeat(60)); + console.log(`通过 ${pass} / ${pass + fails.length}`); + if (fails.length) { + console.log('\n失败项:'); + for (const f of fails) console.log(' · ' + f); + } + return fails.length ? 1 : 0; +} + +let code = 1; +try { + code = await main(); +} catch (e) { + console.error('测试异常崩溃:', e); +} finally { + child.kill(); + await new Promise((s) => setTimeout(s, 250)); + try { + fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 3 }); + } catch (e) { + console.error('(临时目录没删干净,可手动删:' + TMP + ')'); + } + process.exit(code); +}