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) {