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 || ''));
// ============================================================ 角色
//
// 后台两档角色(服务端 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 = {
@@ -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() ? ' <span class="tag" style="font-size:10px">编辑</span>' : '');
$('#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) =>
'<button class="tab' + (state.tab === id ? ' on' : '') + '" data-act="tab" data-id="' + id + '">' +
(ICONS[id] || '') + '<span>' + label + '</span></button>';
// 编辑角色:订阅源/友链在国内机侧已经被拒(server.mjs 只放管理员走 rssProxy),
// 而且它也没有 Cloudflare 的完整会话 —— 干脆不给它画
$('#tabs').innerHTML =
t('posts', '文章编辑') +
'<div class="nav-sep"></div>' +
'<div class="nav-group">订阅中心</div>' +
t('feeds', '订阅源') + t('links', '友链') +
'<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>';
(isEditor()
? ''
: '<div class="nav-sep"></div>' +
'<div class="nav-group">订阅中心</div>' +
t('feeds', '订阅源') + t('links', '友链') +
'<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;
}
// 分组渲染: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 += '<div class="nav-sep"></div>';
@@ -348,6 +382,7 @@ function renderTabs() {
}
// 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台
// (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie)
// 编辑也能用 —— 跳过去还是编辑,拿不到管理员权限(签名覆盖了身份)
html += '<div class="nav-sep"></div>' +
'<div class="nav-group">线路</div>' +
'<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;
if (state.tab !== 'users') return;
$('#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 id="ulist"><div class="loading">加载中…</div></div>';
@@ -681,7 +718,7 @@ async function loadUsers() {
'</tr></thead><tbody>' +
rows.map((r) =>
'<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="num">' + nfmt(r.comment_count) + '</td>' +
'<td>' + (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) =>
'<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 () {
const u = (state.u.rows || []).filter((x) => x.id === id)[0];
@@ -710,9 +799,7 @@ function editUser(id) {
'</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="euPw" type="password" placeholder="••••••••"></label>' +
'<label style="display:flex;align-items:center;gap:8px;margin-bottom:18px">' +
'<input type="checkbox" id="euAdm"' + (u.is_admin ? ' checked' : '') + ' style="width:auto">' +
'<span class="px" style="color:var(--muted)">管理员权限</span></label>' +
roleSelectHtml(u, 'euRole') +
'<button class="btn primary block" id="euSave"><span>保存</span></button>');
$('#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) =>
'<label class="field ' + (cls || '') + '"><span class="field-k">' + esc(label) + '</span>' +
'<input data-fm="' + k + '" autocomplete="off" spellcheck="false" placeholder="' + esc(ph || '') + '" value="' + esc(orig[k] == null ? '' : orig[k]) + '"></label>';
// 编辑角色:作者字段只读。后端本来就不采纳它传的 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 '<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 =
'<div class="po-frow">' +
@@ -1352,7 +1447,7 @@ function renderEditor() {
'</div>' +
'<div class="po-frow4">' +
field('date', '日期', 'po-f-date') +
field('author', '作者', 'po-f-author') +
field('author', '作者', 'po-f-author', '', roAuthor) +
field('layout', '布局', 'po-f-layout') +
'<label class="field po-f-cats"><span class="field-k">分类(逗号分隔)</span>' +
'<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 class="lk" style="font-size:12px">' + esc(st.lastCommit || '—') + '</dd>' +
'</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>' +
'<label class="field" style="margin-top:16px"><span class="field-k">提交说明(留空自动生成)</span>' +
'<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">' +
'<button class="btn" id="poSync"><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 === '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() ? ' <span class="tag" style="font-size:10px">编辑</span>' : '');
$('#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) {
+1
View File
@@ -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 失效(改密码后踢下线)
+2
View File
@@ -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,
};
}
+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 { 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) };
}
/**
+34 -6
View File
@@ -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<Response> {
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<Response> {
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();
+84 -38
View File
@@ -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<EditorIdentity | null> {
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<Response | null> {
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<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 {
@@ -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<Response> {
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<Response> {
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<Response> {
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<string> {
const enc = new TextEncoder();
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('');
}
/**
* 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 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> {
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<Response> {
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 });
+2 -1
View File
@@ -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<Response> {
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 });
+13
View File
@@ -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;
}