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:
1 parent
66c27c7b62
commit
d899cd793b
14 files changed
+1172
-139
No files matched your search
@@ -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) {
|
||||||
|
|||||||
@@ -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 失效(改密码后踢下线)
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' ? '编辑' : '';
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 });
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user