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

+132 -26
View File
@@ -44,6 +44,30 @@ function qs(obj) {
const truncate = (s, n) => (String(s || '').length > n ? String(s).slice(0, n) + '…' : String(s || '')); 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 ' <span class="tag acc">管理员</span>';
if (r === 'editor') return ' <span class="tag">编辑</span>';
return '';
}
// ============================================================ 状态 // ============================================================ 状态
const state = { const state = {
@@ -251,10 +275,13 @@ function showGate() {
function showApp() { function showApp() {
$('#gate').hidden = true; $('#gate').hidden = true;
$('#app').hidden = false; $('#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() ? ' <span class="tag" style="font-size:10px">编辑</span>' : '');
$('#footR').textContent = 'API v2 · 站点:' + (state.site || '—'); $('#footR').textContent = 'API v2 · 站点:' + (state.site || '—');
renderTabs(); renderTabs();
go('dash'); // 编辑进来直接落在文章列表:仪表盘/评论这些它没有权限,默认页只会是 403
go(isEditor() ? 'posts' : 'dash');
} }
function logout(msg) { function logout(msg) {
@@ -304,7 +331,7 @@ async function directLogin(user, password) {
if (!res.ok || !d || !d.ok) { if (!res.ok || !d || !d.ok) {
throw new Error((d && (d.error || d.msg)) || ('登录失败(HTTP ' + res.status + ')')); 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; return state.me;
} }
@@ -318,20 +345,27 @@ function renderTabs() {
const t = (id, label) => const t = (id, label) =>
'<button class="tab' + (state.tab === id ? ' on' : '') + '" data-act="tab" data-id="' + id + '">' + '<button class="tab' + (state.tab === id ? ' on' : '') + '" data-act="tab" data-id="' + id + '">' +
(ICONS[id] || '') + '<span>' + label + '</span></button>'; (ICONS[id] || '') + '<span>' + label + '</span></button>';
// 编辑角色:订阅源/友链在国内机侧已经被拒(server.mjs 只放管理员走 rssProxy),
// 而且它也没有 Cloudflare 的完整会话 —— 干脆不给它画
$('#tabs').innerHTML = $('#tabs').innerHTML =
t('posts', '文章编辑') + t('posts', '文章编辑') +
'<div class="nav-sep"></div>' + (isEditor()
'<div class="nav-group">订阅中心</div>' + ? ''
t('feeds', '订阅源') + t('links', '友链') + : '<div class="nav-sep"></div>' +
'<div class="nav-sep"></div>' + '<div class="nav-group">订阅中心</div>' +
'<div class="nav-group">需要 Cloudflare</div>' + t('feeds', '订阅源') + t('links', '友链') +
'<button class="tab" data-act="relogin">' + I('<path d="M12 3v9"/><path d="M7 7.5a7 7 0 1 0 10 0"/>') + '<span>登录完整后台</span></button>'; '<div class="nav-sep"></div>' +
'<div class="nav-group">需要 Cloudflare</div>' +
'<button class="tab" data-act="relogin">' + I('<path d="M12 3v9"/><path d="M7 7.5a7 7 0 1 0 10 0"/>') + '<span>登录完整后台</span></button>');
return; return;
} }
// 分组渲染:group 为空的(仪表盘)单独在顶部;组间加分隔线 // 分组渲染:group 为空的(仪表盘)单独在顶部;组间加分隔线
// ★ 编辑角色只画「文章编辑」一项 —— 其余模块服务端本来就是 403
// (见 routes/admin.ts 的 requireAdmin),画出来只会让人点进一片报错。
const tabs = isEditor() ? TABS.filter((t) => t.id === 'posts') : TABS;
let html = ''; let html = '';
let lastGroup = null; let lastGroup = null;
for (const t of TABS) { for (const t of tabs) {
if (t.group !== lastGroup) { if (t.group !== lastGroup) {
if (t.group) { if (t.group) {
if (lastGroup !== null && lastGroup !== '') html += '<div class="nav-sep"></div>'; if (lastGroup !== null && lastGroup !== '') html += '<div class="nav-sep"></div>';
@@ -348,6 +382,7 @@ function renderTabs() {
} }
// 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台 // 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台
// (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie) // (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie)
// 编辑也能用 —— 跳过去还是编辑,拿不到管理员权限(签名覆盖了身份)
html += '<div class="nav-sep"></div>' + html += '<div class="nav-sep"></div>' +
'<div class="nav-group">线路</div>' + '<div class="nav-group">线路</div>' +
'<button class="tab" data-act="cn-line">' + I('<path d="M3 12h4l3 -7 4 14 3 -7h4"/>') + '<button class="tab" data-act="cn-line">' + I('<path d="M3 12h4l3 -7 4 14 3 -7h4"/>') +
@@ -645,7 +680,9 @@ async function viewUsers() {
const u = state.u; const u = state.u;
if (state.tab !== 'users') return; if (state.tab !== 'users') return;
$('#view').innerHTML = $('#view').innerHTML =
head('用户', 'USERS', '', '<button class="btn tiny" data-act="u-reload">刷新</button>') + head('用户', 'USERS', '',
'<button class="btn tiny primary" data-act="u-new">+ 新建用户</button>' +
'<button class="btn tiny" data-act="u-reload">刷新</button>') +
'<div class="ctool"><div class="grow"><input id="uq" type="search" placeholder="按昵称 / 邮箱筛选…" value="' + esc(u.q) + '"></div></div>' + '<div class="ctool"><div class="grow"><input id="uq" type="search" placeholder="按昵称 / 邮箱筛选…" value="' + esc(u.q) + '"></div></div>' +
'<div id="ulist"><div class="loading">加载中…</div></div>'; '<div id="ulist"><div class="loading">加载中…</div></div>';
@@ -681,7 +718,7 @@ async function loadUsers() {
'</tr></thead><tbody>' + '</tr></thead><tbody>' +
rows.map((r) => rows.map((r) =>
'<tr>' + '<tr>' +
'<td><b>' + esc(r.name) + '</b>' + (r.is_admin ? ' <span class="tag acc">管理员</span>' : '') + '</td>' + '<td><b>' + esc(r.name) + '</b>' + roleTag(r) + '</td>' +
'<td class="lk">' + esc(r.email) + '</td>' + '<td class="lk">' + esc(r.email) + '</td>' +
'<td class="num">' + nfmt(r.comment_count) + '</td>' + '<td class="num">' + nfmt(r.comment_count) + '</td>' +
'<td>' + (r.badge_name '<td>' + (r.badge_name
@@ -696,6 +733,58 @@ async function loadUsers() {
box.innerHTML = html; 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) =>
'<option value="' + v + '"' + (cur === v ? ' selected' : '') + '>' + esc(label) + '</option>';
return '<label class="field"><span class="field-k">角色</span>' +
'<select id="' + id + '"' + (self ? ' disabled' : '') + '>' +
opt('user', '普通用户(只能评论,进不了后台)') +
opt('editor', '编辑(只能写 / 发布自己的文章)') +
opt('admin', '管理员(全部权限)') +
'</select>' +
(self
? '<span class="px" style="color:var(--muted);font-size:12px">不能修改自己的角色 —— 免得把自己锁在后台外面</span>'
: '') +
'</label>';
}
/** 新建用户:体量上跟「编辑用户」不是一回事,单独一个弹窗 */
function newUser() {
modal('新建用户',
'<label class="field"><span class="field-k">昵称(会显示在文章署名上)</span><input id="nuName" autocomplete="off" placeholder="如:小美"></label>' +
'<label class="field"><span class="field-k">登录账号(邮箱或用户名都行)</span><input id="nuMail" autocomplete="off" placeholder="登录时填这个"></label>' +
'<label class="field"><span class="field-k">密码</span><input id="nuPw" type="password" autocomplete="new-password" placeholder="给 TA 一个初始密码"></label>' +
roleSelectHtml({ role: 'editor' }, 'nuRole') +
'<label class="field"><span class="field-k">头衔徽章(可选)</span><input id="nuTitle" autocomplete="off" placeholder="如:专栏作者"></label>' +
'<div class="alert" style="margin-bottom:0">编辑角色登录后只会看到「文章编辑」,列表里只有自己写的文章,' +
'发布时也只提交自己名下那几篇。</div>' +
'<button class="btn primary block" id="nuSave" style="margin-top:16px"><span>创建</span></button>');
$('#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 editUser(id) {
(function () { (function () {
const u = (state.u.rows || []).filter((x) => x.id === id)[0]; const u = (state.u.rows || []).filter((x) => x.id === id)[0];
@@ -710,9 +799,7 @@ function editUser(id) {
'</div>' + '</div>' +
'<label class="field"><span class="field-k">头衔徽章(金色,显示在系统徽章后、友链/友圈/官职前)</span><input id="euTitle" value="' + esc(u.title_name || '') + '" placeholder="如:荣誉站长 / 合作伙伴"></label>' + '<label class="field"><span class="field-k">头衔徽章(金色,显示在系统徽章后、友链/友圈/官职前)</span><input id="euTitle" value="' + esc(u.title_name || '') + '" placeholder="如:荣誉站长 / 合作伙伴"></label>' +
'<label class="field"><span class="field-k">重设密码(留空不变)</span><input id="euPw" type="password" placeholder="••••••••"></label>' + '<label class="field"><span class="field-k">重设密码(留空不变)</span><input id="euPw" type="password" placeholder="••••••••"></label>' +
'<label style="display:flex;align-items:center;gap:8px;margin-bottom:18px">' + roleSelectHtml(u, 'euRole') +
'<input type="checkbox" id="euAdm"' + (u.is_admin ? ' checked' : '') + ' style="width:auto">' +
'<span class="px" style="color:var(--muted)">管理员权限</span></label>' +
'<button class="btn primary block" id="euSave"><span>保存</span></button>'); '<button class="btn primary block" id="euSave"><span>保存</span></button>');
$('#euSave').onclick = async () => { $('#euSave').onclick = async () => {
@@ -723,7 +810,7 @@ function editUser(id) {
badge_name: $('#euBn').value.trim(), badge_name: $('#euBn').value.trim(),
badge_color: $('#euBc').value.trim(), badge_color: $('#euBc').value.trim(),
title_name: $('#euTitle').value.trim(), title_name: $('#euTitle').value.trim(),
is_admin: $('#euAdm').checked, role: $('#euRole').value,
}; };
const pw = $('#euPw').value; const pw = $('#euPw').value;
if (pw) body.password = pw; if (pw) body.password = pw;
@@ -1341,9 +1428,17 @@ function renderEditor() {
const orig = p.frontMatter || {}; const orig = p.frontMatter || {};
const fmBroken = !ed.isNew && (!p.frontMatter || !!p.frontMatterError); const fmBroken = !ed.isNew && (!p.frontMatter || !!p.frontMatterError);
const field = (k, label, cls, ph) => // 编辑角色:作者字段只读。后端本来就不采纳它传的 author/author_id
'<label class="field ' + (cls || '') + '"><span class="field-k">' + esc(label) + '</span>' + // (见 editor-api/src/posts.mjs),这里显示自己的名字只是为了别让人白填。
'<input data-fm="' + k + '" autocomplete="off" spellcheck="false" placeholder="' + esc(ph || '') + '" value="' + esc(orig[k] == null ? '' : orig[k]) + '"></label>'; 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 '<label class="field ' + (cls || '') + '"><span class="field-k">' + esc(label) + '</span>' +
'<input data-fm="' + k + '" autocomplete="off" spellcheck="false" placeholder="' + esc(ph || '') + '"' +
(readonly ? ' readonly title="编辑角色只能署自己的名字"' : '') +
' value="' + esc(v) + '"></label>';
};
const metaForm = const metaForm =
'<div class="po-frow">' + '<div class="po-frow">' +
@@ -1352,7 +1447,7 @@ function renderEditor() {
'</div>' + '</div>' +
'<div class="po-frow4">' + '<div class="po-frow4">' +
field('date', '日期', 'po-f-date') + field('date', '日期', 'po-f-date') +
field('author', '作者', 'po-f-author') + field('author', '作者', 'po-f-author', '', roAuthor) +
field('layout', '布局', 'po-f-layout') + field('layout', '布局', 'po-f-layout') +
'<label class="field po-f-cats"><span class="field-k">分类(逗号分隔)</span>' + '<label class="field po-f-cats"><span class="field-k">分类(逗号分隔)</span>' +
'<input id="poCats" autocomplete="off" value="' + esc(arrText(orig.categories)) + '"></label>' + '<input id="poCats" autocomplete="off" value="' + esc(arrText(orig.categories)) + '"></label>' +
@@ -1726,11 +1821,16 @@ async function openGitModal() {
'<dt>改动</dt><dd>' + (st.dirty ? '<span style="color:var(--pend-fg)">' + nfmt(st.changed) + ' 个文件待发布</span>' : '工作区干净') + '</dd>' + '<dt>改动</dt><dd>' + (st.dirty ? '<span style="color:var(--pend-fg)">' + nfmt(st.changed) + ' 个文件待发布</span>' : '工作区干净') + '</dd>' +
'<dt>最近提交</dt><dd class="lk" style="font-size:12px">' + esc(st.lastCommit || '—') + '</dd>' + '<dt>最近提交</dt><dd class="lk" style="font-size:12px">' + esc(st.lastCommit || '—') + '</dd>' +
'</dl>' + '</dl>' +
'<div style="margin:14px 0 6px;font-size:12px;color:var(--muted)">待发布文件</div>' + '<div style="margin:14px 0 6px;font-size:12px;color:var(--muted)">待发布文件' +
(st.scoped ? '(只列你自己名下的文章)' : '') + '</div>' +
'<div class="po-ro" style="max-height:200px;overflow:auto">' + files + '</div>' + '<div class="po-ro" style="max-height:200px;overflow:auto">' + files + '</div>' +
'<label class="field" style="margin-top:16px"><span class="field-k">提交说明(留空自动生成)</span>' + '<label class="field" style="margin-top:16px"><span class="field-k">提交说明(留空自动生成)</span>' +
'<input id="poMsg" autocomplete="off" placeholder="例如:修几处错别字"></label>' + '<input id="poMsg" autocomplete="off" placeholder="例如:修几处错别字"></label>' +
'<div class="alert" style="margin-bottom:0">发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。</div>' + '<div class="alert" style="margin-bottom:0">' +
(st.scoped
? '发布 = 只提交**你自己名下的文章目录**(别人还没发布的改动不会被顺走),再 commit + push 到主仓触发线上构建。'
: '发布 = commit + push 到主仓,会触发线上构建。这一步是真的对外可见,想清楚再点。') +
'</div>' +
'<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px">' + '<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px">' +
'<button class="btn" id="poSync"><span>先同步远程</span></button>' + '<button class="btn" id="poSync"><span>先同步远程</span></button>' +
'<button class="btn primary" id="poPub"><span>发布到线上</span></button>' + '<button class="btn primary" id="poPub"><span>发布到线上</span></button>' +
@@ -1935,6 +2035,7 @@ document.addEventListener('click', async (e) => {
if (act === 'p-next') { state.p.offset += state.p.limit; go('pages'); return; } 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-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-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; } 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); await doLogin($('#lgUser').value.trim(), $('#lgPass').value);
state.me = state.me || {}; state.me = state.me || {};
if (state.me.is_admin === false) throw new Error('该账号不是管理员'); if (!canEnterBackend()) throw new Error('该账号不是管理员或编辑,没有写作后台权限');
await bootstrap(); await bootstrap();
} catch (ex) { } catch (ex) {
@@ -2017,6 +2118,10 @@ $('#loginForm').addEventListener('submit', async (e) => {
}); });
async function bootstrap() { async function bootstrap() {
// 编辑角色没有仪表盘/评论/用户那些模块的权限(服务端 requireAdmin 一律 403),
// 所以不能跟着管理员那套预取走 —— 否则第一句 /sites 就把登录打成「会话已失效」。
if (isEditor()) { showApp(); return; }
// 站点列表 -> 默认站点 // 站点列表 -> 默认站点
const sites = await api('/sites'); const sites = await api('/sites');
state.sites = sites || []; state.sites = sites || [];
@@ -2071,10 +2176,11 @@ async function probeSession() {
/** 国内机模式:本机会话已就绪,直接进文章编辑(不经过 Cloudflare) */ /** 国内机模式:本机会话已就绪,直接进文章编辑(不经过 Cloudflare) */
function enterDirect() { function enterDirect() {
state.direct = true; 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; $('#gate').hidden = true;
$('#app').hidden = false; $('#app').hidden = false;
$('#who').textContent = state.me.name || 'admin'; $('#who').innerHTML = esc(state.me.name || 'admin') +
(isEditor() ? ' <span class="tag" style="font-size:10px">编辑</span>' : '');
$('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare'; $('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare';
go('posts'); go('posts');
} }
@@ -2100,7 +2206,7 @@ function enterDirect() {
const me = await api('/user'); const me = await api('/user');
if (!me || !me.user || !me.is_login) throw Object.assign(new Error('会话已失效'), { status: 401 }); if (!me || !me.user || !me.is_login) throw Object.assign(new Error('会话已失效'), { status: 401 });
state.me = me.user; 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)); localStorage.setItem(LS_U, JSON.stringify(state.me));
await bootstrap(); await bootstrap();
} catch (e) { } catch (e) {
+1
View File
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS users (
last_ip TEXT NOT NULL DEFAULT '', last_ip TEXT NOT NULL DEFAULT '',
last_ua TEXT NOT NULL DEFAULT '', last_ua TEXT NOT NULL DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0, 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, receive_email INTEGER NOT NULL DEFAULT 1,
is_in_conf INTEGER NOT NULL DEFAULT 0, is_in_conf INTEGER NOT NULL DEFAULT 0,
token_valid_from INTEGER NOT NULL DEFAULT 0, -- 早于此刻签发的 token 失效(改密码后踢下线) token_valid_from INTEGER NOT NULL DEFAULT 0, -- 早于此刻签发的 token 失效(改密码后踢下线)
+2
View File
@@ -13,6 +13,7 @@ import type {
import { findPagesByKeys, findUsersByIds, siteFirstUrl } from './db'; import { findPagesByKeys, findUsersByIds, siteFirstUrl } from './db';
import { renderMarkdown } from './md'; import { renderMarkdown } from './md';
import { md5Lower } from './md5'; import { md5Lower } from './md5';
import { roleOf } from './role';
import { formatDateCN } from './util'; import { formatDateCN } from './util';
// ============================================================================ // ============================================================================
@@ -126,6 +127,7 @@ export function cookUser(u: UserRow): CookedUser {
badge_color: u.badge_color || '', badge_color: u.badge_color || '',
title_name: u.title_name || '', title_name: u.title_name || '',
is_admin: !!u.is_admin, is_admin: !!u.is_admin,
role: roleOf(u),
receive_email: !!u.receive_email, receive_email: !!u.receive_email,
}; };
} }
+59
View File
@@ -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' ? '编辑' : '';
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Env, SessionUser, UserRow } from '../types'; import type { Env, SessionUser, UserRow } from '../types';
import { findUserByEmail, findUserById, getAdminUsers } from './db'; import { findUserByEmail, findUserById, getAdminUsers } from './db';
import { md5 } from './md5'; import { md5 } from './md5';
import { roleOf } from './role';
import { now } from './util'; 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 { export function sessionOf(user: UserRow | null): SessionUser | null {
if (!user) return 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) };
} }
/** /**
+34 -6
View File
@@ -6,6 +6,7 @@ import {
findOrCreateSite, findOrCreateSite,
findOrCreateUser, findOrCreateUser,
findUserByNameEmail, findUserByNameEmail,
getAdminUsers,
getFrontendConf, getFrontendConf,
isCaptchaEnabled, isCaptchaEnabled,
isIPRegionEnabled, isIPRegionEnabled,
@@ -14,6 +15,7 @@ import {
import { isHumanCheckEnabled, setHumanCheck } from '../lib/human'; import { isHumanCheckEnabled, setHumanCheck } from '../lib/human';
import { cookComments, cookPage, cookSite, cookUser } from '../lib/cook'; import { cookComments, cookPage, cookSite, cookUser } from '../lib/cook';
import { hashPassword, isAdminRequest } from '../lib/session'; 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 { fail, formatDateCN, now, ok, okMsg, qInt, qp, readBody, trimTo } from '../lib/util';
import { parseSimpleYaml, toSimpleYaml } from '../lib/simple-yaml'; import { parseSimpleYaml, toSimpleYaml } from '../lib/simple-yaml';
import { extractForm, formComments, mergeForm, SETTINGS_FORM } from '../lib/settings-form'; import { extractForm, formComments, mergeForm, SETTINGS_FORM } from '../lib/settings-form';
@@ -277,16 +279,18 @@ export async function userCreate(ctx: Ctx): Promise<Response> {
if (existed) return fail(400, 'User already exists'); if (existed) return fail(400, 'User already exists');
const t = now(); const t = now();
const role = normalizeRole(body.role, body.is_admin);
await ctx.env.DB.prepare( await ctx.env.DB.prepare(
`INSERT INTO users (name, email, link, password, is_admin, badge_name, badge_color, title_name, created_at, updated_at) `INSERT INTO users (name, email, link, password, is_admin, role, badge_name, badge_color, title_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.bind( .bind(
name, name,
email, email,
trimTo(body.link || '', 255), trimTo(body.link || '', 255),
password ? await hashPassword(password) : '', password ? await hashPassword(password) : '',
body.is_admin ? 1 : 0, role === 'admin' ? 1 : 0,
role,
trimTo(body.badge_name || '', 60), trimTo(body.badge_name || '', 60),
trimTo(body.badge_color || '', 20), trimTo(body.badge_color || '', 20),
trimTo(body.title_name || '', 30), trimTo(body.title_name || '', 30),
@@ -345,9 +349,33 @@ export async function userUpdateAdmin(ctx: Ctx): Promise<Response> {
sets.push('password = ?', 'token_valid_from = ?'); sets.push('password = ?', 'token_valid_from = ?');
binds.push(await hashPassword(String(body.password)), now()); 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(); if (!sets.length) return okMsg();
+84 -38
View File
@@ -4,16 +4,29 @@
* 为什么要绕这一跳,而不是让浏览器直连后端: * 为什么要绕这一跳,而不是让浏览器直连后端:
* 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到; * 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到;
* 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口; * 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口;
* 3. 复用后台已有的登录会话 —— 这里只需要 isAdminRequest,不用再造一套账号体系。 * 3. 复用后台已有的登录会话 —— 这里只需要一套「谁在写文章」的判定。
* *
* 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`, * 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`,
* 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。 * 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。
* 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。 * 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。
*
* ★★ 本文件是「谁能写博客」的唯一闸门,动它之前先读完下面两段。
*/ */
import type { Ctx } from '../router'; import type { Ctx } from '../router';
import { fail, json } from '../lib/util'; import { fail, json } from '../lib/util';
import { getAdminUsers } from '../lib/db'; import { getAdminUsers } from '../lib/db';
import { userFromToken } from '../lib/session'; 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。 * ★ 鉴权只认「真实登录会话(Bearer token)」,**绝不能**用 isAdminRequest。
@@ -25,31 +38,55 @@ import { userFromToken } from '../lib/session';
* 删文章 + 传图 + 触发 git 发布(真推到 CNB/GitHub)」的能力裸露在公网, * 删文章 + 传图 + 触发 git 发布(真推到 CNB/GitHub)」的能力裸露在公网,
* 任何人拼一个 query 就能删你的博客。这里必须硬性要求会话。 * 任何人拼一个 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<EditorIdentity | null> {
const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization'))); const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization')));
if (!user) return null; 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 标的老账号:邮箱命中配置里的管理员也算 // 已登录但没打 is_admin 标的老账号:邮箱命中配置里的管理员也算
const admins = await getAdminUsers(ctx.env); const admins = await getAdminUsers(ctx.env);
return admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase()) if (admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase())) {
? user return { id: user.id, name: user.name, role: 'admin' };
: null; }
if (role === 'editor') return { id: user.id, name: user.name, role: 'editor' };
return null;
} }
async function guard(ctx: Ctx): Promise<Response | null> { /** 鉴权 + 后端配置检查。返回身份,或一个可以直接返回给浏览器的错误响应 */
if (!(await requireAdminSession(ctx))) { async function auth(ctx: Ctx): Promise<{ ident: EditorIdentity } | { deny: Response }> {
return fail(403, '需要管理员权限'); const ident = await requireEditorSession(ctx);
} if (!ident) return { deny: fail(403, '需要管理员或编辑权限') };
if (!ctx.env.EDITOR_API_BASE) { if (!ctx.env.EDITOR_API_BASE) {
return fail(503, '后端未配置:缺少 EDITOR_API_BASE'); return { deny: fail(503, '后端未配置:缺少 EDITOR_API_BASE') };
} }
if (!ctx.env.EDITOR_TOKEN) { 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<string, string> {
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 { function target(ctx: Ctx, path: string, withQuery = false): string {
@@ -59,15 +96,15 @@ function target(ctx: Ctx, path: string, withQuery = false): string {
/** 发到后端并原样把 JSON 与状态码带回来 */ /** 发到后端并原样把 JSON 与状态码带回来 */
async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise<Response> { async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise<Response> {
const deny = await guard(ctx); const a = await auth(ctx);
if (deny) return deny; if ('deny' in a) return a.deny;
let res: Response; let res: Response;
try { try {
res = await fetch(target(ctx, path, withQuery), { res = await fetch(target(ctx, path, withQuery), {
...init, ...init,
headers: { headers: {
'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), ...editorHeaders(ctx.env, a.ident),
...(init.headers || {}), ...(init.headers || {}),
}, },
}); });
@@ -125,8 +162,8 @@ export const deletePost = (ctx: Ctx) =>
* Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。 * Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。
*/ */
export async function upload(ctx: Ctx): Promise<Response> { export async function upload(ctx: Ctx): Promise<Response> {
const deny = await guard(ctx); const a = await auth(ctx);
if (deny) return deny; if ('deny' in a) return a.deny;
const ct = ctx.req.headers.get('Content-Type') || ''; const ct = ctx.req.headers.get('Content-Type') || '';
if (!ct.startsWith('image/')) return fail(400, '只接受图片'); if (!ct.startsWith('image/')) return fail(400, '只接受图片');
@@ -135,7 +172,7 @@ export async function upload(ctx: Ctx): Promise<Response> {
const res = await fetch(target(ctx, '/upload', true), { const res = await fetch(target(ctx, '/upload', true), {
method: 'POST', method: 'POST',
headers: { headers: {
'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), ...editorHeaders(ctx.env, a.ident),
'Content-Type': ct, 'Content-Type': ct,
}, },
body: ctx.req.body, 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<string> { async function hmacHex(secret: string, msg: string): Promise<string> {
const enc = new TextEncoder(); const enc = new TextEncoder();
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
@@ -187,20 +213,40 @@ async function hmacHex(secret: string, msg: string): Promise<string> {
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join(''); 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<Response> { export async function handoff(ctx: Ctx): Promise<Response> {
const deny = await guard(ctx); const a = await auth(ctx);
if (deny) return deny; if ('deny' in a) return a.deny;
const base = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, ''); const base = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
const ts = Date.now().toString(); const ts = Date.now().toString();
const sig = await hmacHex(String(ctx.env.EDITOR_TOKEN), ts); const uid = String(a.ident.id);
return json({ url: `${base}/api/v2/admin/handoff?ts=${ts}&t=${sig}` }); 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<Response> { export async function health(ctx: Ctx): Promise<Response> {
if (!(await requireAdminSession(ctx))) return fail(403, '需要管理员权限'); const a = await auth(ctx);
if (!ctx.env.EDITOR_API_BASE) return json({ ok: false, reason: '缺少 EDITOR_API_BASE' }); if ('deny' in a) return a.deny;
try { try {
const res = await fetch(target(ctx, '/health'), { method: 'GET' }); const res = await fetch(target(ctx, '/health'), { method: 'GET' });
return json({ ...(await res.json() as object), reachable: true }); return json({ ...(await res.json() as object), reachable: true });
+2 -1
View File
@@ -2,6 +2,7 @@ import type { Env, UserRow } from '../types';
import { findUserByEmail, findUserByName, findUserByNameEmail, rateLimit } from '../lib/db'; import { findUserByEmail, findUserByName, findUserByNameEmail, rateLimit } from '../lib/db';
import { cookNotify, cookUser } from '../lib/cook'; import { cookNotify, cookUser } from '../lib/cook';
import { isAdminByNameEmail, signToken, verifyPassword } from '../lib/session'; 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 { fail, getClientIP, isEmail, now, ok, okMsg, qp, readBody, trimTo } from '../lib/util';
import type { Ctx } from '../router'; import type { Ctx } from '../router';
@@ -97,7 +98,7 @@ export async function userStatus(ctx: Ctx): Promise<Response> {
const email = qp(url, 'email'); const email = qp(url, 'email');
if (user) { 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) { if (name && email) {
return ok({ is_admin: await isAdminByNameEmail(env, name, email), is_login: false }); return ok({ is_admin: await isAdminByNameEmail(env, name, email), is_login: false });
+13
View File
@@ -81,6 +81,14 @@ export interface PageRow {
updated_at: number; updated_at: number;
} }
/**
* 后台角色。
* 'user' —— 普通评论用户(默认,没有后台权限)
* 'editor' —— 编辑:只能写/发布自己的文章,后台只看得到「文章编辑」
* 'admin' —— 管理员:全部权限
*/
export type UserRole = 'admin' | 'editor' | 'user';
export interface UserRow { export interface UserRow {
id: number; id: number;
name: string; name: string;
@@ -93,6 +101,8 @@ export interface UserRow {
last_ip: string; last_ip: string;
last_ua: string; last_ua: string;
is_admin: number; is_admin: number;
/** '' | 'editor' | 'admin'(历史行可能为 '',读取请走 roleOf()) */
role: string;
receive_email: number; receive_email: number;
is_in_conf: number; is_in_conf: number;
token_valid_from: number; token_valid_from: number;
@@ -188,6 +198,8 @@ export interface CookedUser {
badge_color: string; badge_color: string;
title_name?: string; title_name?: string;
is_admin: boolean; is_admin: boolean;
/** 归一化角色:前端据它决定导航能不能看到「评论管理/用户管理」等 */
role: UserRole;
receive_email: boolean; receive_email: boolean;
} }
@@ -213,4 +225,5 @@ export interface SessionUser {
name: string; name: string;
email: string; email: string;
isAdmin: boolean; isAdmin: boolean;
role: UserRole;
} }
+53 -11
View File
@@ -8,19 +8,54 @@
只做这一件事:读写 `content/posts/<YYYY>/<目录>/index.md`,外加把图片放进文章同级目录, 只做这一件事:读写 `content/posts/<YYYY>/<目录>/index.md`,外加把图片放进文章同级目录,
以及把改动 commit + push 触发上线。 以及把改动 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 (这台服务器上) 浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上)
/api/v2/editor/* 127.0.0.1:8017 /api/v2/editor/* 127.0.0.1:8017
只做「登录鉴权 + 注入 X-Editor-Token 转发」 真正读写文件 / git 只做「登录鉴权 + 注入令牌与身份转发」 真正读写文件 / git
``` ```
- 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。 - 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。
- 容器端口只绑宿主机 `127.0.0.1`,公网扫不到;外面那层是 nginx 的 `/editor-api/`。 - 容器端口只绑宿主机 `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 | `/health` | 健康检查(免鉴权) |
| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记) | | GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记;编辑只看到自己的) |
| POST | `/posts` | 新建,body `{frontMatter:{title,...}, content}` | | POST | `/posts` | 新建,body `{frontMatter:{title,...}, content}` |
| GET | `/posts/:id` | 读单篇(**id = 目录名**,见下) | | GET | `/posts/:id` | 读单篇(**id = 目录名**,见下) |
| PUT | `/posts/:id` | 保存,body `{content, frontMatter}` | | PUT | `/posts/:id` | 保存,body `{content, frontMatter}` |
| DELETE | `/posts/:id` | 移到回收目录 | | DELETE | `/posts/:id` | 移到回收目录 |
| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录 | | POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录(编辑必须带 key) |
| GET | `/git/status` | 分支 / 改动文件 / 最近提交 | | GET | `/git/status` | 分支 / 改动文件 / 最近提交(编辑只看到自己目录的改动,返回里 `scoped:true`) |
| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push | | POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push(编辑只 add 自己的目录) |
| POST | `/git/sync` | pull --rebase --autostash | | POST | `/git/sync` | pull --rebase --autostash |
| GET/POST | `/admin/session` `/admin/login` `/admin/logout` | 国内直连后台的登录会话 |
| GET | `/admin/handoff?ts=&u=&n=&r=&t=` | 「国内线路」免登录跳转(签名覆盖身份) |
### 为什么用目录名当 id,不用 slug ### 为什么用目录名当 id,不用 slug
@@ -94,17 +132,21 @@ BLOG_ROOT=E:/GitHub/blog node test/save-roundtrip.mjs
# ③ 接口端到端(对真实仓库跑,测完自动还原) # ③ 接口端到端(对真实仓库跑,测完自动还原)
BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端 BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端
node test/api-e2e.mjs http://127.0.0.1:8017 devtoken node test/api-e2e.mjs http://127.0.0.1:8017 devtoken
# ④ 角色权限矩阵(自带一次性 git 仓库,不碰真仓库)
node test/role-perm.mjs
``` ```
## 本地联调 ## 本地联调
```bash ```bash
# 1) 后端 # 1) 后端(角色测试可以直接指到 .editor-tmp/uitest/blog 那个一次性仓库)
EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs
# 2) Worker(blog-admin 目录) # 2) Worker(blog-admin 目录)—— ★ 本机要先清代理环境变量,否则 wrangler dev 卡死在启动
npx wrangler dev --port 8799 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 # .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken
# 本地 D1 要先给 users 加 role 列(线上同理,见部署清单)
``` ```
部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。 部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。
+171 -34
View File
@@ -119,17 +119,28 @@ const safeName = (s) =>
* 登录态存内存 + 落一个 JSON 文件(重启不掉线)。 * 登录态存内存 + 落一个 JSON 文件(重启不掉线)。
* 会话 ID 是 32 字节随机数,Cookie 带 HttpOnly + SameSite=Lax, * 会话 ID 是 32 字节随机数,Cookie 带 HttpOnly + SameSite=Lax,
* 前端 JS 读不到,也就没法被 XSS 偷走。 * 前端 JS 读不到,也就没法被 XSS 偷走。
*
* ★ 会话里必须存**身份**(uid / name / role),不能只存一个用户名 ——
* 写作后台现在有「管理员 + 编辑」两种角色,后端要靠它判断
* 「这篇文章是不是你的」以及「发布时该提交哪些路径」。
*/ */
const SESSION_FILE = const SESSION_FILE =
cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json'); 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() { function loadSessions() {
try { try {
const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
const now = Date.now(); const now = Date.now();
for (const [sid, v] of Object.entries(raw || {})) { 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 { /* 首次运行没有这个文件,正常 */ } } catch { /* 首次运行没有这个文件,正常 */ }
} }
@@ -184,6 +195,88 @@ function setCookie(res, sid, maxAgeSec) {
res.setHeader('Set-Cookie', parts.join('; ')); 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,供「一键跳转」验签 */ /** HMAC-SHA256(key=共享令牌, msg=时间戳)的 hex,供「一键跳转」验签 */
function hmacHex(key, msg) { function hmacHex(key, msg) {
try { try {
@@ -301,41 +394,41 @@ const health = () => ({
route('GET', '/health', health); route('GET', '/health', health);
route('GET', '/posts', async ({ query }) => posts.listPosts({ route('GET', '/posts', async ({ query, me }) => posts.listPosts({
q: query.get('q') || '', q: query.get('q') || '',
page: Number(query.get('page') || 1), page: Number(query.get('page') || 1),
perPage: Number(query.get('perPage') || 20), perPage: Number(query.get('perPage') || 20),
})); }, me));
route('POST', '/posts', async ({ req }) => { route('POST', '/posts', async ({ req, me }) => {
const body = await readJson(req); const body = await readJson(req);
if (!body.frontMatter || !body.frontMatter.title) { if (!body.frontMatter || !body.frontMatter.title) {
throw Object.assign(new Error('缺少标题'), { status: 400 }); throw Object.assign(new Error('缺少标题'), { status: 400 });
} }
return posts.createPost(body); return posts.createPost(body, me);
}); });
route('GET', `/posts/${R}`, async ({ params }) => { route('GET', `/posts/${R}`, async ({ params, me }) => {
const post = posts.getPost(params[0]); const post = posts.getPost(params[0], me);
if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 }); if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 });
return post; 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 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 }); if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out }; return { ok: true, ...out };
}); });
route('DELETE', `/posts/${R}`, async ({ params }) => { route('DELETE', `/posts/${R}`, async ({ params, me }) => {
const out = posts.deletePost(params[0]); const out = posts.deletePost(params[0], me);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 }); if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out }; return { ok: true, ...out };
}); });
// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单 // 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单
route('POST', '/upload', async ({ req, query }) => { route('POST', '/upload', async ({ req, query, me }) => {
const type = req.headers['content-type'] || ''; const type = req.headers['content-type'] || '';
if (!type.startsWith('image/')) { if (!type.startsWith('image/')) {
throw Object.assign(new Error('只接受图片'), { status: 400 }); 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 base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
const fileName = base + '.' + ext; 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.mkdirSync(target.dir, { recursive: true });
fs.writeFileSync(path.join(target.dir, fileName), buf); 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 body = await readJson(req);
const out = await git.publish(body.message); const out = await git.publish(body.message, scopeOf(me));
if (!out.ok) { if (!out.ok) {
return { ...out, __status: out.conflict ? 409 : 500 }; return { ...out, __status: out.conflict ? 409 : 500 };
} }
@@ -406,23 +507,23 @@ const server = http.createServer(async (req, res) => {
} }
// ---------------------------------------------------------- 后台登录 // ---------------------------------------------------------- 后台登录
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .env,公网只能 // 这几个端点免令牌:浏览器直接访问国内机后台时用。公网只能通过 nginx 的
// 通过 nginx 的 /api/v2/admin/* 转发进来;失败限速防爆破。 // /api/v2/admin/* 转发进来;失败限速防爆破。
if (url.pathname === '/admin/session' && req.method === 'GET') { if (url.pathname === '/admin/session' && req.method === 'GET') {
const s = readSession(req); const s = readSession(req);
if (!s) { if (!s) {
json(res, 401, { ok: false, mode: 'direct', need_login: true }); json(res, 401, { ok: false, mode: 'direct', need_login: true });
return; 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; return;
} }
if (url.pathname === '/admin/login' && req.method === 'POST') { if (url.pathname === '/admin/login' && req.method === 'POST') {
if (!cfg.adminPass) {
json(res, 503, { error: '后端未配置登录密码(ADMIN_PASS),请先在 .env 里设置' });
return;
}
const ip = clientIp(req); const ip = clientIp(req);
const f = failState(ip); const f = failState(ip);
if (f && f.until) { if (f && f.until) {
@@ -430,18 +531,39 @@ const server = http.createServer(async (req, res) => {
return; return;
} }
const body = await readJson(req, 64 * 1024); 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); noteFailure(ip);
if (viaCf.unreachable) {
json(res, 503, {
error: '国内线路连不上 Cloudflare,无法校验账号。请从 api.200181.xyz/admin 进入,或稍后重试。',
});
return;
}
// 不区分「账号错」和「密码错」,别帮对方缩小猜测范围 // 不区分「账号错」和「密码错」,别帮对方缩小猜测范围
json(res, 401, { error: '账号或密码不正确' }); json(res, 401, { error: '账号或密码不正确' });
return; return;
} }
failures.delete(ip); failures.delete(ip);
const sid = crypto.randomBytes(32).toString('hex'); 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(); saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000)); 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; return;
} }
@@ -454,25 +576,34 @@ const server = http.createServer(async (req, res) => {
} }
// ------------------------------------------------------ 一键跳转(免登录) // ------------------------------------------------------ 一键跳转(免登录)
// Cloudflare 后台点「国内线路」时走这里:Worker 用共享令牌对时间戳做 // Cloudflare 后台点「国内线路」时走这里:Worker 用共享令牌对「时间戳 + 操作者
// HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。 // 身份」做 HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。
//
// ★ 签名必须覆盖 uid/名字/角色三样。只签时间戳的话,编辑点一下这个按钮
// 就会在本机拿到一个管理员会话 —— 角色直接越权了。
//
// 三重防线:签名只有两边知道 / 时间窗 2 分钟 / 同一签名只用一次。 // 三重防线:签名只有两边知道 / 时间窗 2 分钟 / 同一签名只用一次。
if (url.pathname === '/admin/handoff' && req.method === 'GET') { if (url.pathname === '/admin/handoff' && req.method === 'GET') {
const ts = String(url.searchParams.get('ts') || ''); 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 sig = String(url.searchParams.get('t') || '');
const bad = () => { const bad = () => {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }); res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('handoff rejected'); res.end('handoff rejected');
}; };
if (!/^\d{13}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 120_000) return bad(); 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 (!expect || !safeEqual(sig, expect)) return bad();
if (usedHandoff.has(sig)) return bad(); // 防重放:一个签名只能换一次会话 if (usedHandoff.has(sig)) return bad(); // 防重放:一个签名只能换一次会话
usedHandoff.add(sig); usedHandoff.add(sig);
if (usedHandoff.size > 500) usedHandoff.clear(); // 简单兜底,防集合无限涨 if (usedHandoff.size > 500) usedHandoff.clear(); // 简单兜底,防集合无限涨
const sid = crypto.randomBytes(32).toString('hex'); 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(); saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000)); setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
res.writeHead(302, { Location: '/admin/' }); res.writeHead(302, { Location: '/admin/' });
@@ -483,15 +614,21 @@ const server = http.createServer(async (req, res) => {
// ---------------------------------------------------------- 鉴权 // ---------------------------------------------------------- 鉴权
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到), // 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
// 或本机签发的浏览器会话 Cookie。满足其一即放行。 // 或本机签发的浏览器会话 Cookie。满足其一即放行。
const byToken = safeEqual(req.headers['x-editor-token'] || '', cfg.token); // me = 操作者身份(uid/name/role),下面所有路由都靠它判归属与发布范围。
if (!byToken && !readSession(req)) { const me = identify(req);
if (!me) {
json(res, 401, { error: '未授权', need_login: true }); json(res, 401, { error: '未授权', need_login: true });
return; return;
} }
// 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。 // 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。 // 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
// ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。
if (RSS_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) { 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); await rssProxy(req, res, url);
return; return;
} }
@@ -502,7 +639,7 @@ const server = http.createServer(async (req, res) => {
return; 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; const status = data && data.__status ? data.__status : 200;
if (data && data.__status) delete data.__status; if (data && data.__status) delete data.__status;
json(res, status, data); json(res, status, data);
+41 -8
View File
@@ -41,27 +41,59 @@ export function makeGit(cfg) {
'-c', 'commit.gpgsign=false', '-c', 'commit.gpgsign=false',
]; ];
async function status() { /**
// core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义, * scope 语义(编辑角色 = 只提交自己的文章):
// 否则发布弹层里满屏都是看不懂的编码 * undefined → 全量 cfg.paths(content / static),管理员用
const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...paths]); * 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 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 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); const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean);
return { return {
branch: br.out.trim(), ...base,
dirty: files.length > 0, dirty: files.length > 0,
changed: files.length, changed: files.length,
files: files.slice(0, 50), files: files.slice(0, 50),
lastCommit: head.out.trim(),
}; };
} }
async function publish(message) { async function publish(message, scope) {
return serial(async () => { return serial(async () => {
const log = []; 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()); log.push(add.out.trim());
// 先提交(没有暂存内容就跳过) // 先提交(没有暂存内容就跳过)
@@ -100,6 +132,7 @@ export function makeGit(cfg) {
return { return {
ok: true, ok: true,
pushed, pushed,
scoped,
// 备份远端推失败不算发布失败(主仓成了就算成),但如实说明 // 备份远端推失败不算发布失败(主仓成了就算成),但如实说明
warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null, warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null,
log: log.join('\n'), log: log.join('\n'),
+112 -14
View File
@@ -101,6 +101,13 @@ export function makePosts(cfg) {
tags: Array.isArray(fm.tags) ? fm.tags : [], tags: Array.isArray(fm.tags) ? fm.tags : [],
categories: Array.isArray(fm.categories) ? fm.categories : [], categories: Array.isArray(fm.categories) ? fm.categories : [],
author: fm.author || '', 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, dirName: name,
relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'), relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'),
parseable: !info.fmError, 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); let rows = findPostDirs().map(toListItem);
// 编辑角色:列表只显示自己写的(连「别人有几篇」都不让它知道)
if (viewer && viewer.role === 'editor') rows = rows.filter((r) => owns(r, viewer));
// slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉 // slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉
const seen = new Map(); const seen = new Map();
for (const r of rows) seen.set(r.slug, (seen.get(r.slug) || 0) + 1); 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; return null;
} }
function getPost(key) { /** 内部读取:不带归属校验,只给本文件的其它函数用(**别直接暴露给路由**) */
function readPost(key) {
const dir = locate(key); const dir = locate(key);
if (!dir) return null; if (!dir) return null;
const item = toListItem(dir); 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 一律忽略(避免覆盖成空) */ /** 只把前端真正传上来的字段合并进去,undefined 一律忽略(避免覆盖成空) */
function pickDefined(obj) { function pickDefined(obj) {
const out = {}; const out = {};
@@ -198,17 +250,36 @@ export function makePosts(cfg) {
return out; return out;
} }
function savePost(key, patch = {}) { function savePost(key, patch = {}, viewer) {
const post = getPost(key); const post = readPost(key);
if (!post) return null; if (!post) return null;
assertOwned(post, viewer);
// 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格 // 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格
if (typeof post.filePath !== 'string' || !post.eol) { if (typeof post.filePath !== 'string' || !post.eol) {
throw Object.assign(new Error('内部错误:文章记录缺少 filePath/eol'), { status: 500 }); 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 incoming = pickDefined(patch.frontMatter);
const merged = { ...(post.frontMatter || {}), ...incoming }; 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; let fmText;
if (post.frontMatter && deepEqual(merged, post.frontMatter)) { if (post.frontMatter && deepEqual(merged, post.frontMatter)) {
// 没动 front matter → 原文照抄,零风险 // 没动 front matter → 原文照抄,零风险
@@ -227,7 +298,7 @@ export function makePosts(cfg) {
fs.writeFileSync(post.filePath, text, 'utf8'); fs.writeFileSync(post.filePath, text, 'utf8');
// 回读用 dirName 定位(用 slug 有撞名风险,可能读到别的文章) // 回读用 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 }; 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(); const title = String(frontMatter.title || '').trim();
if (!title) throw bad('缺少标题'); if (!title) throw bad('缺少标题');
const slug = String(frontMatter.slug || '').trim() || makeSlug(); const slug = String(frontMatter.slug || '').trim() || makeSlug();
@@ -268,16 +339,34 @@ export function makePosts(cfg) {
const ymd = date.slice(0, 10); const ymd = date.slice(0, 10);
const year = ymd.slice(0, 4); 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 = { const fm = {
title, title,
date, date,
slug, 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'), layout: String(frontMatter.layout || 'post'),
categories: Array.isArray(frontMatter.categories) ? frontMatter.categories : [], categories: Array.isArray(frontMatter.categories) ? frontMatter.categories : [],
tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : [], tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : [],
draft: frontMatter.draft === true, draft: frontMatter.draft === true,
}; });
// 目录名与 write-server 的 computeDirPath 一致:<日期>-<标题段>-<slug> // 目录名与 write-server 的 computeDirPath 一致:<日期>-<标题段>-<slug>
const dirName = ymd + '-' + titlePartOf(title) + '-' + slug; const dirName = ymd + '-' + titlePartOf(title) + '-' + slug;
@@ -335,9 +424,10 @@ export function makePosts(cfg) {
} }
} }
function deletePost(key) { function deletePost(key, viewer) {
const post = getPost(key); const post = readPost(key);
if (!post) return null; if (!post) return null;
assertOwned(post, viewer);
const dir = path.dirname(post.filePath); const dir = path.dirname(post.filePath);
const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const dest = path.join(trashDir, stamp + '-' + path.basename(dir)); 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) { if (key) {
const post = getPost(key); const post = readPost(key);
if (post) return { dir: path.dirname(post.filePath), bare: true }; if (post) {
assertOwned(post, viewer);
return { dir: path.dirname(post.filePath), bare: true };
}
throw bad('图片要挂到哪篇文章?找不到: ' + key, 404); throw bad('图片要挂到哪篇文章?找不到: ' + key, 404);
} }
// 不带 key = 传进 static/image/<年>/ 这个公共图库。编辑角色不走这条路:
// 那只会在共享目录里留下没人认领的文件,而且发布时也没法精确提交。
if (viewer && viewer.role === 'editor') {
throw bad('请先打开一篇文章再粘图(编辑角色只支持传到自己文章的目录里)', 403);
}
const year = new Date().getFullYear().toString(); const year = new Date().getFullYear().toString();
return { dir: path.join(contentDir, '..', '..', 'static', 'image', year), bare: false, urlPrefix: '/image/' + year + '/' }; 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 };
} }
+466
View File
@@ -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);
}