feat(编辑角色): editor 只能写并发布自己的文章

- D1 users 加 role 列(''/editor/admin),与 is_admin 成对写入
- Worker routes/editor.ts 放行 admin+editor,反代注入 X-Editor-Uid/User/Role(昵称 encodeURIComponent)
- editor-api 引入 identify():身份取自注入头或本机会话;文章归属记 frontmatter author_id
- 编辑发布改精确 pathspec(只提交自己文章目录),管理员保持全量;空 pathspec 显式拦截
- 国内机直连登录改为转发 CF /user/access_token 校验,CF 不可达回退本机管理员
- handoff 签名覆盖身份(ts/uid/name/role),防编辑一键跳转变管理员
- admin.js:编辑只渲染「文章编辑」tab、作者框只读;用户管理加角色下拉 + 新建用户
This commit is contained in:
zqlit committed 2026-10-05 14:34:56 +08:00
1 parent 66c27c7b62
commit d899cd793b
14 files changed
+1172 -139

No files matched your search

+53 -11
View File
@@ -8,19 +8,54 @@
只做这一件事:读写 `content/posts/<YYYY>/<目录>/index.md`,外加把图片放进文章同级目录,
以及把改动 commit + push 触发上线。
**不做**:评论、AI 摘要、部署编排、用户体系、订阅……那些要么在 Worker 里,要么已经不需要了。
**不做**:评论、AI 摘要、部署编排、订阅……那些在 Worker 里。
账号体系也**不在这里**(见下节),本服务只认 Worker 转发时声明的身份。
## 角色:管理员 / 编辑(2026-10-05 加)
后台有两种角色,账号体系的唯一事实源是 **Cloudflare 侧 D1 的 `users.role` 列**
(`''` 普通评论用户 / `'editor'` 编辑 / `'admin'` 管理员,与 `is_admin` 同步):
| | 管理员 | 编辑 |
|---|---|---|
| 文章列表 | 全部 | **只有自己写的** |
| 读 / 改 / 删 / 传图 | 任意文章 | **只能碰自己的**(越权一律 403) |
| 发布 | 全量 `git add content static` | **只 add 自己名下的文章目录**(不会顺走别人未发布的改动) |
| 新建文章 | 可指定作者 | 归属与署名由服务端钉死(伪造 `author_id` 无效) |
| 评论/用户/设置/订阅 | ✓ | ✗(Worker 和本服务两层都拒) |
**归属怎么记**:文章 front matter 里的 `author_id`(= 后台用户 id,数字)。
- 用 id 不用昵称 —— 管理员随时可能改昵称,按昵称比对会「丢文章」。
- 老文章(改造前写的)没有这个字段 → 回退成按 `author` 昵称认领;
编辑本人第一次保存时会自动补上 `author_id`,正式划到自己名下。
- 服务端在保存/新建时**忽略**前端传来的 `author` / `author_id`,编辑永远改不了署名。
**身份怎么传进来**(两条通道,本服务不存任何账号):
1. **Worker 反代通道**:请求带共享令牌 `X-Editor-Token`(浏览器拿不到),
Worker 鉴权通过后注入 `X-Editor-Uid` / `X-Editor-User`(encodeURIComponent 过的昵称)/
`X-Editor-Role`。令牌对得上,这组头就是可信的。
只有令牌、没有身份头 → 按管理员处理(兼容 curl 调试和旧版 Worker)。
2. **国内机直连通道**(浏览器直接开 writeapi.usj.cc/admin/):`POST /admin/login` 把
账号密码**转发给 Cloudflare 的 `/api/v2/user/access_token`** 校验,通过后签发本机
HttpOnly Cookie 会话,会话里存 `{uid, name, role}`。写文章那条链路依然不出境。
- Cloudflare 连不上时:本机管理员账号(`ADMIN_USER`/`ADMIN_PASS`)仍能登录(应急通道);
其它账号会收到 503「校验不了」,而不是误导性的 401。
- 「国内线路」一键跳转(`/admin/handoff`)的 HMAC 签名覆盖 `时间戳+uid+名字+角色`
—— **编辑跳过去还是编辑**,不会变成管理员。
## 三层结构
```
浏览器 ──► Cloudflare Worker (api.200181.xyz) ──► editor-api (这台服务器上)
/api/v2/editor/* 127.0.0.1:8017
只做「登录鉴权 + 注入 X-Editor-Token 转发」 真正读写文件 / git
只做「登录鉴权 + 注入令牌与身份转发」 真正读写文件 / git
```
- 浏览器**永远接触不到** `EDITOR_TOKEN`:它只存在 Worker 的 secret 里。
- 容器端口只绑宿主机 `127.0.0.1`,公网扫不到;外面那层是 nginx 的 `/editor-api/`。
- 鉴权是复用后台已有的管理员登录(`isAdminRequest`),不用再造一套账号。
- 鉴权复用后台登录会话:管理员和编辑都放行(`/editor/*` 只有文章相关接口),
评论/用户/设置等其它后台模块仍然只认管理员。
## 环境变量
@@ -48,20 +83,23 @@
## 接口
除 `GET /health` 外一律要 `X-Editor-Token`,没有就 401。
除 `GET /health` 外一律要凭据:`X-Editor-Token`(Worker 通道,附带身份头)
或本机会话 Cookie(国内直连通道),没有就 401。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/health` | 健康检查(免鉴权) |
| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记) |
| GET | `/posts?q=&page=&perPage=` | 列表(含 `slugConflict` 撞名标记;编辑只看到自己的) |
| POST | `/posts` | 新建,body `{frontMatter:{title,...}, content}` |
| GET | `/posts/:id` | 读单篇(**id = 目录名**,见下) |
| PUT | `/posts/:id` | 保存,body `{content, frontMatter}` |
| DELETE | `/posts/:id` | 移到回收目录 |
| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录 |
| GET | `/git/status` | 分支 / 改动文件 / 最近提交 |
| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push |
| POST | `/upload?name=x.png&key=:id` | 原始二进制直传,落到文章同级目录(编辑必须带 key) |
| GET | `/git/status` | 分支 / 改动文件 / 最近提交(编辑只看到自己目录的改动,返回里 `scoped:true`) |
| POST | `/git/publish` | `{message}` → add + commit + pull --rebase + push(编辑只 add 自己的目录) |
| POST | `/git/sync` | pull --rebase --autostash |
| GET/POST | `/admin/session` `/admin/login` `/admin/logout` | 国内直连后台的登录会话 |
| GET | `/admin/handoff?ts=&u=&n=&r=&t=` | 「国内线路」免登录跳转(签名覆盖身份) |
### 为什么用目录名当 id,不用 slug
@@ -94,17 +132,21 @@ BLOG_ROOT=E:/GitHub/blog node test/save-roundtrip.mjs
# ③ 接口端到端(对真实仓库跑,测完自动还原)
BLOG_ROOT=E:/GitHub/blog node server.mjs & # 另开终端
node test/api-e2e.mjs http://127.0.0.1:8017 devtoken
# ④ 角色权限矩阵(自带一次性 git 仓库,不碰真仓库)
node test/role-perm.mjs
```
## 本地联调
```bash
# 1) 后端
# 1) 后端(角色测试可以直接指到 .editor-tmp/uitest/blog 那个一次性仓库)
EDITOR_TOKEN=devtoken BLOG_ROOT=E:/GitHub/blog node server.mjs
# 2) Worker(blog-admin 目录)
npx wrangler dev --port 8799
# 2) Worker(blog-admin 目录)—— ★ 本机要先清代理环境变量,否则 wrangler dev 卡死在启动
env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY npx wrangler dev --port 8799
# .dev.vars 里配 EDITOR_API_BASE=http://127.0.0.1:8017 EDITOR_TOKEN=devtoken
# 本地 D1 要先给 users 加 role 列(线上同理,见部署清单)
```
部署见 `部署清单.md`(根目录)的「文章编辑后端」一节。
+171 -34
View File
@@ -119,17 +119,28 @@ const safeName = (s) =>
* 登录态存内存 + 落一个 JSON 文件(重启不掉线)。
* 会话 ID 是 32 字节随机数,Cookie 带 HttpOnly + SameSite=Lax,
* 前端 JS 读不到,也就没法被 XSS 偷走。
*
* ★ 会话里必须存**身份**(uid / name / role),不能只存一个用户名 ——
* 写作后台现在有「管理员 + 编辑」两种角色,后端要靠它判断
* 「这篇文章是不是你的」以及「发布时该提交哪些路径」。
*/
const SESSION_FILE =
cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json');
const sessions = new Map(); // sid -> { user, exp }
const sessions = new Map(); // sid -> { uid, name, role, exp }
function loadSessions() {
try {
const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
const now = Date.now();
for (const [sid, v] of Object.entries(raw || {})) {
if (v && v.exp > now) sessions.set(sid, v);
if (!v || !(v.exp > now)) continue;
// 兼容改造前存的 { user, exp }:那时只有一个本机管理员账号
sessions.set(sid, {
uid: String(v.uid || ''),
name: String(v.name || v.user || cfg.adminUser),
role: v.role === 'editor' ? 'editor' : 'admin',
exp: v.exp,
});
}
} catch { /* 首次运行没有这个文件,正常 */ }
}
@@ -184,6 +195,88 @@ function setCookie(res, sid, maxAgeSec) {
res.setHeader('Set-Cookie', parts.join('; '));
}
/** Worker 侧对用户名做了 encodeURIComponent(HTTP 头是 byte-string,中文塞不进去) */
function decodeHeader(v) {
const s = String(v || '').trim();
if (!s) return '';
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
/**
* 谁在操作 —— 返回 { uid, name, role } 或 null。role 只有 'admin' / 'editor'
* 两种(普通评论用户进不来写作后台)。
*
* 两条通道:
* ① Cloudflare Worker 反代:带了共享令牌 X-Editor-Token(浏览器永远拿不到),
* 身份在 Worker 注入的 X-Editor-Uid / X-Editor-User / X-Editor-Role 里。
* 令牌对得上 → 这组头就是可信的。
* 缺头(老版 Worker / 直接 curl 带令牌调试)按管理员处理,与改造前行为一致。
* ② 浏览器直连本机后台:身份就在自己的 HttpOnly Cookie 会话里。
*/
function identify(req) {
if (safeEqual(req.headers['x-editor-token'] || '', cfg.token)) {
return {
uid: String(req.headers['x-editor-uid'] || '').trim(),
name: decodeHeader(req.headers['x-editor-user']),
role: String(req.headers['x-editor-role'] || '').trim() === 'editor' ? 'editor' : 'admin',
};
}
const s = readSession(req);
if (!s) return null;
return {
uid: String(s.uid || ''),
name: String(s.name || cfg.adminUser),
role: s.role === 'editor' ? 'editor' : 'admin',
};
}
/**
* 直连后台登录时,把账号密码交给 Cloudflare 上的用户表验证。
*
* 为什么不自己存一份账号:账号体系只该有一个(D1 users + role 列)。
* 国内机只做「登录这一跳」的转发,之后全靠本机 Cookie 会话 ——
* 写文章那条链路依然不出境(这正是国内线路存在的意义)。
*
* 返回 { ident } / { denied } / { unreachable },三态分开,
* 调用处才能决定「要不要回退到本机管理员」以及给什么错误提示。
*/
async function loginViaCf(identifier, password) {
if (!cfg.rssBase || !identifier || !password) return { unreachable: true };
let r;
try {
r = await fetch(cfg.rssBase + '/api/v2/user/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: identifier, password }),
signal: AbortSignal.timeout(10_000),
});
} catch (e) {
console.error('[editor-api] 登录转发失败(CF 不可达):', e.message);
return { unreachable: true };
}
if (r.status === 401 || r.status === 403) return { denied: true };
if (!r.ok) {
console.error('[editor-api] 登录转发异常状态:', r.status);
return { unreachable: true };
}
let d;
try {
d = await r.json();
} catch {
return { unreachable: true };
}
const u = d && d.user;
if (!u) return { denied: true };
const role = u.role === 'editor' ? 'editor' : u.is_admin || u.role === 'admin' ? 'admin' : '';
if (!role) return { denied: true }; // 普通评论用户没有写作后台权限
return { ident: { uid: String(u.id), name: String(u.name || identifier), role } };
}
/** HMAC-SHA256(key=共享令牌, msg=时间戳)的 hex,供「一键跳转」验签 */
function hmacHex(key, msg) {
try {
@@ -301,41 +394,41 @@ const health = () => ({
route('GET', '/health', health);
route('GET', '/posts', async ({ query }) => posts.listPosts({
route('GET', '/posts', async ({ query, me }) => posts.listPosts({
q: query.get('q') || '',
page: Number(query.get('page') || 1),
perPage: Number(query.get('perPage') || 20),
}));
}, me));
route('POST', '/posts', async ({ req }) => {
route('POST', '/posts', async ({ req, me }) => {
const body = await readJson(req);
if (!body.frontMatter || !body.frontMatter.title) {
throw Object.assign(new Error('缺少标题'), { status: 400 });
}
return posts.createPost(body);
return posts.createPost(body, me);
});
route('GET', `/posts/${R}`, async ({ params }) => {
const post = posts.getPost(params[0]);
route('GET', `/posts/${R}`, async ({ params, me }) => {
const post = posts.getPost(params[0], me);
if (!post) throw Object.assign(new Error('文章不存在'), { status: 404 });
return post;
});
route('PUT', `/posts/${R}`, async ({ req, params }) => {
route('PUT', `/posts/${R}`, async ({ req, params, me }) => {
const body = await readJson(req, 4 * 1024 * 1024);
const out = posts.savePost(params[0], body);
const out = posts.savePost(params[0], body, me);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
route('DELETE', `/posts/${R}`, async ({ params }) => {
const out = posts.deletePost(params[0]);
route('DELETE', `/posts/${R}`, async ({ params, me }) => {
const out = posts.deletePost(params[0], me);
if (!out) throw Object.assign(new Error('文章不存在'), { status: 404 });
return { ok: true, ...out };
});
// 图片:直接 POST 原始二进制,文件名走 query —— 不解析 multipart,零依赖也简单
route('POST', '/upload', async ({ req, query }) => {
route('POST', '/upload', async ({ req, query, me }) => {
const type = req.headers['content-type'] || '';
if (!type.startsWith('image/')) {
throw Object.assign(new Error('只接受图片'), { status: 400 });
@@ -347,7 +440,7 @@ route('POST', '/upload', async ({ req, query }) => {
const base = Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
const fileName = base + '.' + ext;
const target = posts.imageTargetDir(query.get('key') || '');
const target = posts.imageTargetDir(query.get('key') || '', me);
fs.mkdirSync(target.dir, { recursive: true });
fs.writeFileSync(path.join(target.dir, fileName), buf);
@@ -360,10 +453,18 @@ route('POST', '/upload', async ({ req, query }) => {
};
});
route('GET', '/git/status', async () => git.status());
route('POST', '/git/publish', async ({ req }) => {
/**
* 发布范围:编辑角色只提交**自己文章的目录**。
* · admin → null(后端按 GIT_PATHS 全量 add content/static,行为与改造前一致)
* · editor → 自己名下的文章目录列表(可能是空数组 → 什么都不提交)
* 这一步必须在服务端算,不能信前端传的路径 —— 否则编辑能往 pathspec 里塞别人的目录。
*/
const scopeOf = (me) => (me && me.role === 'editor' ? posts.ownedPaths(me) : null);
route('GET', '/git/status', async ({ me }) => git.status(scopeOf(me)));
route('POST', '/git/publish', async ({ req, me }) => {
const body = await readJson(req);
const out = await git.publish(body.message);
const out = await git.publish(body.message, scopeOf(me));
if (!out.ok) {
return { ...out, __status: out.conflict ? 409 : 500 };
}
@@ -406,23 +507,23 @@ const server = http.createServer(async (req, res) => {
}
// ---------------------------------------------------------- 后台登录
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .env,公网只能
// 通过 nginx 的 /api/v2/admin/* 转发进来;失败限速防爆破。
// 这几个端点免令牌:浏览器直接访问国内机后台时用。公网只能通过 nginx 的
// /api/v2/admin/* 转发进来;失败限速防爆破。
if (url.pathname === '/admin/session' && req.method === 'GET') {
const s = readSession(req);
if (!s) {
json(res, 401, { ok: false, mode: 'direct', need_login: true });
return;
}
json(res, 200, { ok: true, mode: 'direct', user: { name: s.user } });
json(res, 200, {
ok: true,
mode: 'direct',
user: { id: s.uid || null, name: s.name || cfg.adminUser, role: s.role === 'editor' ? 'editor' : 'admin' },
});
return;
}
if (url.pathname === '/admin/login' && req.method === 'POST') {
if (!cfg.adminPass) {
json(res, 503, { error: '后端未配置登录密码(ADMIN_PASS),请先在 .env 里设置' });
return;
}
const ip = clientIp(req);
const f = failState(ip);
if (f && f.until) {
@@ -430,18 +531,39 @@ const server = http.createServer(async (req, res) => {
return;
}
const body = await readJson(req, 64 * 1024);
if (!safeEqual(body.user || '', cfg.adminUser) || !safeEqual(body.password || '', cfg.adminPass)) {
const user = String(body.user || '').trim();
const password = String(body.password || '');
// ① 先交给 Cloudflare 的用户表验证 —— 账号体系只有一个(D1 users + role),
// 编辑账号也才能从国内线路登录进来。
const viaCf = await loginViaCf(user, password);
let ident = viaCf.ident || null;
// ② CF 不可达 / 密码不对时,回退到本机管理员账号(.env 的 ADMIN_USER/ADMIN_PASS)。
// 这是「Cloudflare 挂了还要能进去改文章」的应急通道,只对管理员生效。
if (!ident && cfg.adminPass && safeEqual(user, cfg.adminUser) && safeEqual(password, cfg.adminPass)) {
ident = { uid: '', name: cfg.adminUser, role: 'admin' };
}
if (!ident) {
noteFailure(ip);
if (viaCf.unreachable) {
json(res, 503, {
error: '国内线路连不上 Cloudflare,无法校验账号。请从 api.200181.xyz/admin 进入,或稍后重试。',
});
return;
}
// 不区分「账号错」和「密码错」,别帮对方缩小猜测范围
json(res, 401, { error: '账号或密码不正确' });
return;
}
failures.delete(ip);
const sid = crypto.randomBytes(32).toString('hex');
sessions.set(sid, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
sessions.set(sid, { uid: ident.uid, name: ident.name, role: ident.role, exp: Date.now() + cfg.sessionTtlMs });
saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
json(res, 200, { ok: true, user: { name: cfg.adminUser } });
json(res, 200, { ok: true, user: { id: ident.uid || null, name: ident.name, role: ident.role } });
return;
}
@@ -454,25 +576,34 @@ const server = http.createServer(async (req, res) => {
}
// ------------------------------------------------------ 一键跳转(免登录)
// Cloudflare 后台点「国内线路」时走这里:Worker 用共享令牌对时间戳做
// HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。
// Cloudflare 后台点「国内线路」时走这里:Worker 用共享令牌对「时间戳 + 操作者
// 身份」做 HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。
//
// ★ 签名必须覆盖 uid/名字/角色三样。只签时间戳的话,编辑点一下这个按钮
// 就会在本机拿到一个管理员会话 —— 角色直接越权了。
//
// 三重防线:签名只有两边知道 / 时间窗 2 分钟 / 同一签名只用一次。
if (url.pathname === '/admin/handoff' && req.method === 'GET') {
const ts = String(url.searchParams.get('ts') || '');
const uid = String(url.searchParams.get('u') || '');
const name = String(url.searchParams.get('n') || '');
const role = String(url.searchParams.get('r') || '');
const sig = String(url.searchParams.get('t') || '');
const bad = () => {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('handoff rejected');
};
if (!/^\d{13}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 120_000) return bad();
const expect = hmacHex(cfg.token, ts);
if (role !== 'admin' && role !== 'editor') return bad();
if (!name) return bad();
const expect = hmacHex(cfg.token, `${ts}\n${uid}\n${name}\n${role}`);
if (!expect || !safeEqual(sig, expect)) return bad();
if (usedHandoff.has(sig)) return bad(); // 防重放:一个签名只能换一次会话
usedHandoff.add(sig);
if (usedHandoff.size > 500) usedHandoff.clear(); // 简单兜底,防集合无限涨
const sid = crypto.randomBytes(32).toString('hex');
sessions.set(sid, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
sessions.set(sid, { uid, name, role, exp: Date.now() + cfg.sessionTtlMs });
saveSessions();
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
res.writeHead(302, { Location: '/admin/' });
@@ -483,15 +614,21 @@ const server = http.createServer(async (req, res) => {
// ---------------------------------------------------------- 鉴权
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
// 或本机签发的浏览器会话 Cookie。满足其一即放行。
const byToken = safeEqual(req.headers['x-editor-token'] || '', cfg.token);
if (!byToken && !readSession(req)) {
// me = 操作者身份(uid/name/role),下面所有路由都靠它判归属与发布范围。
const me = identify(req);
if (!me) {
json(res, 401, { error: '未授权', need_login: true });
return;
}
// 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。
// 这样国内机后台也能管订阅,且前端不需要持有订阅口令。
// ★ 只放给管理员:这些是后台的其它模块,编辑角色看不到也不该碰。
if (RSS_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) {
if (me.role !== 'admin') {
json(res, 403, { error: '编辑角色只能管理自己的文章' });
return;
}
await rssProxy(req, res, url);
return;
}
@@ -502,7 +639,7 @@ const server = http.createServer(async (req, res) => {
return;
}
const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params });
const data = await hit.handler({ req, url, query: url.searchParams, params: hit.params, me });
const status = data && data.__status ? data.__status : 200;
if (data && data.__status) delete data.__status;
json(res, status, data);
+41 -8
View File
@@ -41,27 +41,59 @@ export function makeGit(cfg) {
'-c', 'commit.gpgsign=false',
];
async function status() {
// core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义,
// 否则发布弹层里满屏都是看不懂的编码
const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...paths]);
/**
* scope 语义(编辑角色 = 只提交自己的文章):
* undefined → 全量 cfg.paths(content / static),管理员用
* string[] → 只用这些 pathspec(编辑自己的文章目录)
* [] → 编辑名下还没有文章目录:什么都不做(不能退化成全量!)
*
* ★ 空数组必须显式拦掉。`git add -- ` 后面不跟 pathspec 就等于 `git add -A`,
* 会把仓库里所有人未提交的改动一起提交进去 —— 那正是要避免的事。
*/
function resolvePaths(scope) {
if (Array.isArray(scope)) return scope;
return paths;
}
async function status(scope) {
const usePaths = resolvePaths(scope);
const scoped = Array.isArray(scope);
const head = await run(repoRoot, ['log', '-1', '--pretty=%h%x09%ad%x09%s', '--date=format:%Y-%m-%d %H:%M']);
const br = await run(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
const base = {
branch: br.out.trim(),
scoped,
lastCommit: head.out.trim(),
};
if (scoped && !usePaths.length) {
return { ...base, dirty: false, changed: 0, files: [] };
}
// core.quotepath=false:不把中文文件名转成 \346\265\213 那种八进制转义,
// 否则发布弹层里满屏都是看不懂的编码
const st = await run(repoRoot, ['-c', 'core.quotepath=false', 'status', '--porcelain', '--', ...usePaths]);
const files = st.out.split('\n').map((l) => l.trim()).filter(Boolean);
return {
branch: br.out.trim(),
...base,
dirty: files.length > 0,
changed: files.length,
files: files.slice(0, 50),
lastCommit: head.out.trim(),
};
}
async function publish(message) {
async function publish(message, scope) {
return serial(async () => {
const log = [];
const usePaths = resolvePaths(scope);
const scoped = Array.isArray(scope);
const add = await run(repoRoot, ['add', '--', ...paths]);
if (scoped && !usePaths.length) {
return { ok: true, pushed: [], scoped: true, warning: null, log: '(你名下还没有文章目录,没有需要发布的内容)' };
}
const add = await run(repoRoot, ['add', '--', ...usePaths]);
log.push(add.out.trim());
// 先提交(没有暂存内容就跳过)
@@ -100,6 +132,7 @@ export function makeGit(cfg) {
return {
ok: true,
pushed,
scoped,
// 备份远端推失败不算发布失败(主仓成了就算成),但如实说明
warning: failed.length ? '以下远端推送失败(不影响上线): ' + failed.join(' | ') : null,
log: log.join('\n'),
+112 -14
View File
@@ -101,6 +101,13 @@ export function makePosts(cfg) {
tags: Array.isArray(fm.tags) ? fm.tags : [],
categories: Array.isArray(fm.categories) ? fm.categories : [],
author: fm.author || '',
/**
* 归属键:front matter 里的 author_id(= 后台用户的数字 id)。
* 用 id 而不是昵称 —— 管理员随时可能在「用户管理」里改昵称,
* 一改昵称,按名字比对的文章就全「丢了」。
* 老文章(改造前写的)没有这个字段,回退成比昵称,见 owns()。
*/
authorId: fm.author_id === undefined || fm.author_id === null ? '' : String(fm.author_id),
dirName: name,
relPath: path.relative(path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/'),
parseable: !info.fmError,
@@ -108,9 +115,45 @@ export function makePosts(cfg) {
};
}
function listPosts({ q = '', page = 1, perPage = 20 } = {}) {
/** 仓库相对路径(发布时当 git 的 pathspec 用) */
function relOf(dir) {
return path.relative(repoRoot || path.resolve(contentDir, '..', '..'), dir).split(path.sep).join('/');
}
// ------------------------------------------------------------ 归属判定
//
// viewer = { uid, name, role } —— 由 server.mjs 从请求头/会话里解出来。
// admin 不受限;editor 只能碰自己的。
// viewer 为 null/undefined = 内部调用(不做限制),只用于本文件内部互相调用。
function owns(item, viewer) {
if (!item || !viewer) return true;
if (viewer.role !== 'editor') return true; // 管理员(或未知角色走不到这儿)
const id = String(item.authorId || '');
if (id) return id === String(viewer.uid);
// 老文章的兜底:没有 author_id 时按昵称认领(改造前 write-server 写的那些)
return !!viewer.name && item.author === viewer.name;
}
function assertOwned(item, viewer) {
if (owns(item, viewer)) return;
throw bad('这篇文章不是你的,编辑角色只能改动自己写的文章(作者:' + (item.author || '未署名') + ')', 403);
}
/** 编辑自己在仓库里的文章目录(git pathspec),管理员返回 null 表示「全量」 */
function ownedPaths(viewer) {
if (!viewer || viewer.role !== 'editor') return null;
return findPostDirs()
.filter((dir) => owns(toListItem(dir), viewer))
.map(relOf);
}
function listPosts({ q = '', page = 1, perPage = 20 } = {}, viewer) {
let rows = findPostDirs().map(toListItem);
// 编辑角色:列表只显示自己写的(连「别人有几篇」都不让它知道)
if (viewer && viewer.role === 'editor') rows = rows.filter((r) => owns(r, viewer));
// slug 撞名告警:Hugo 是 /:slug,撞名意味着线上必有一篇被覆盖掉
const seen = new Map();
for (const r of rows) seen.set(r.slug, (seen.get(r.slug) || 0) + 1);
@@ -169,7 +212,8 @@ export function makePosts(cfg) {
return null;
}
function getPost(key) {
/** 内部读取:不带归属校验,只给本文件的其它函数用(**别直接暴露给路由**) */
function readPost(key) {
const dir = locate(key);
if (!dir) return null;
const item = toListItem(dir);
@@ -188,6 +232,14 @@ export function makePosts(cfg) {
};
}
/** 对外读取:编辑角色只能打开自己的文章 */
function getPost(key, viewer) {
const post = readPost(key);
if (!post) return null;
assertOwned(post, viewer);
return post;
}
/** 只把前端真正传上来的字段合并进去,undefined 一律忽略(避免覆盖成空) */
function pickDefined(obj) {
const out = {};
@@ -198,17 +250,36 @@ export function makePosts(cfg) {
return out;
}
function savePost(key, patch = {}) {
const post = getPost(key);
function savePost(key, patch = {}, viewer) {
const post = readPost(key);
if (!post) return null;
assertOwned(post, viewer);
// 防呆:这两个字段漏一个就会写出畸形文件或改掉换行风格
if (typeof post.filePath !== 'string' || !post.eol) {
throw Object.assign(new Error('内部错误:文章记录缺少 filePath/eol'), { status: 500 });
}
// 编辑角色:署名与归属由服务端钉死,前端传什么都不采纳
if (viewer && viewer.role === 'editor') {
if (!post.frontMatter) throw bad('这篇的 front matter 无法解析,为避免写坏,请先修好再来改');
if (patch.frontMatter) {
delete patch.frontMatter.author_id;
delete patch.frontMatter.author;
}
}
const incoming = pickDefined(patch.frontMatter);
const merged = { ...(post.frontMatter || {}), ...incoming };
// 老文章(改造前没有 author_id):第一次由编辑本人保存时补上,正式划到自己名下。
// 已有则不覆写 —— 避免把别人名下的文章顺手改成自己的。
if (viewer && viewer.role === 'editor') {
const cur = post.frontMatter.author_id;
merged.author_id = cur === undefined || cur === null ? String(viewer.uid) : cur;
if (!merged.author) merged.author = viewer.name;
}
let fmText;
if (post.frontMatter && deepEqual(merged, post.frontMatter)) {
// 没动 front matter → 原文照抄,零风险
@@ -227,7 +298,7 @@ export function makePosts(cfg) {
fs.writeFileSync(post.filePath, text, 'utf8');
// 回读用 dirName 定位(用 slug 有撞名风险,可能读到别的文章)
const after = getPost(post.dirName);
const after = readPost(post.dirName);
return { id: post.dirName, slug: post.slug, dirPath: path.dirname(post.filePath), url: after ? after.url : post.url };
}
@@ -254,7 +325,7 @@ export function makePosts(cfg) {
);
}
function createPost({ frontMatter = {}, content = '' } = {}) {
function createPost({ frontMatter = {}, content = '' } = {}, viewer) {
const title = String(frontMatter.title || '').trim();
if (!title) throw bad('缺少标题');
const slug = String(frontMatter.slug || '').trim() || makeSlug();
@@ -268,16 +339,34 @@ export function makePosts(cfg) {
const ymd = date.slice(0, 10);
const year = ymd.slice(0, 4);
// 署名:编辑只能署自己;管理员可指定(默认取配置里的 DEFAULT_AUTHOR)
const author =
viewer && viewer.role === 'editor'
? viewer.name
: String(frontMatter.author || defaultAuthor || (viewer ? viewer.name : '') || '');
const fm = {
title,
date,
slug,
author: String(frontMatter.author || defaultAuthor || ''),
author,
};
// 归属:由服务端钉死。前端传什么都不算 —— 否则编辑可以伪造别人的 author_id,
// 把文章塞到别人名下(或者反过来,把锅甩给别人)。
if (viewer) {
fm.author_id =
viewer.role === 'admin' && frontMatter.author_id != null
? String(frontMatter.author_id)
: String(viewer.uid);
}
Object.assign(fm, {
layout: String(frontMatter.layout || 'post'),
categories: Array.isArray(frontMatter.categories) ? frontMatter.categories : [],
tags: Array.isArray(frontMatter.tags) ? frontMatter.tags : [],
draft: frontMatter.draft === true,
};
});
// 目录名与 write-server 的 computeDirPath 一致:<日期>-<标题段>-<slug>
const dirName = ymd + '-' + titlePartOf(title) + '-' + slug;
@@ -335,9 +424,10 @@ export function makePosts(cfg) {
}
}
function deletePost(key) {
const post = getPost(key);
function deletePost(key, viewer) {
const post = readPost(key);
if (!post) return null;
assertOwned(post, viewer);
const dir = path.dirname(post.filePath);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const dest = path.join(trashDir, stamp + '-' + path.basename(dir));
@@ -347,15 +437,23 @@ export function makePosts(cfg) {
}
/** 图片落到文章同级目录,正文用裸文件名引用(仓库现行写法) */
function imageTargetDir(key) {
function imageTargetDir(key, viewer) {
if (key) {
const post = getPost(key);
if (post) return { dir: path.dirname(post.filePath), bare: true };
const post = readPost(key);
if (post) {
assertOwned(post, viewer);
return { dir: path.dirname(post.filePath), bare: true };
}
throw bad('图片要挂到哪篇文章?找不到: ' + key, 404);
}
// 不带 key = 传进 static/image/<年>/ 这个公共图库。编辑角色不走这条路:
// 那只会在共享目录里留下没人认领的文件,而且发布时也没法精确提交。
if (viewer && viewer.role === 'editor') {
throw bad('请先打开一篇文章再粘图(编辑角色只支持传到自己文章的目录里)', 403);
}
const year = new Date().getFullYear().toString();
return { dir: path.join(contentDir, '..', '..', 'static', 'image', year), bare: false, urlPrefix: '/image/' + year + '/' };
}
return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, contentDir };
return { listPosts, getPost, savePost, createPost, deletePost, imageTargetDir, ownedPaths, contentDir };
}
+466
View File
@@ -0,0 +1,466 @@
/**
* editor-api「管理员 / 编辑」角色权限矩阵 —— 端到端测试。
*
* node test/role-perm.mjs
*
* 自己搭一个一次性 git 仓库(含 3 篇不同归属的文章)跑真实 HTTP 请求,
* 测完连临时目录一起删掉 —— 不碰真仓库、不碰线上。
*
* 覆盖的是这次改造的核心承诺:
* · 编辑只能看/改/删/传图到自己的文章(含老文章按昵称认领的兜底)
* · 编辑建文章时归属与署名由服务端钉死,前端伪造 author_id 无效
* · 编辑发布只提交自己的文章目录,不会把别人未提交的改动一起带走
* · 「国内线路」一键跳转不能把编辑变成管理员(签名覆盖身份)
* · 管理员能力与改造前一致
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFile, spawn } from 'node:child_process';
const PORT = 8099;
const BASE = `http://127.0.0.1:${PORT}`;
const TOKEN = 'test-token-' + crypto.randomBytes(4).toString('hex');
const ADMIN_PASS = 'local-admin-pass';
const ADMIN = { uid: '1', name: '群林', role: 'admin' };
const EDITOR = { uid: '7', name: '小美', role: 'editor' };
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'editor-role-'));
const BLOG = path.join(TMP, 'blog');
const TRASH = path.join(TMP, 'trash');
const ORIGIN = path.join(TMP, 'origin.git');
let pass = 0;
const fails = [];
function check(name, cond, extra = '') {
if (cond) {
pass++;
console.log(' ✅ ' + name);
} else {
fails.push(name + (extra ? ' → ' + extra : ''));
console.log(' ❌ ' + name + (extra ? ' → ' + extra : ''));
}
}
function section(t) {
console.log('\n' + t);
}
// ------------------------------------------------------------------ 搭仓库
// 用异步 execFile,不用 execFileSync ——
// 本机(Windows + 沙箱)同步版 spawn 一律 EBUSY,异步版正常。
// editor-api 自己的 git.mjs 用的也是异步,所以线上没这个问题。
function sh(cwd, args) {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd, encoding: 'utf8' }, (err, stdout, stderr) => {
if (err) reject(Object.assign(err, { stdout, stderr }));
else resolve(stdout);
});
});
}
function writePost(dirName, fmLines, body) {
const dir = path.join(BLOG, 'content', 'posts', '2026', dirName);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.md'), ['---', ...fmLines, '---', '', body, ''].join('\n'), 'utf8');
return dir;
}
async function setupRepo() {
fs.mkdirSync(BLOG, { recursive: true });
await sh(TMP, ['init', '--bare', '-b', 'main', ORIGIN]);
await sh(BLOG, ['init', '-b', 'main']);
await sh(BLOG, ['config', 'user.name', 'tester']);
await sh(BLOG, ['config', 'user.email', 'tester@local']);
// ① 小美(id=7)的文章
writePost('2026-10-01-妈妈的菜-20261001093000', [
'title: 妈妈的菜', 'date: 2026-10-01', 'slug: 20261001093000',
'author: 小美', 'author_id: 7', 'layout: post', 'tags: []', 'categories: []', 'draft: false',
], '今天做了红烧肉。');
// ② 群林(id=1)的文章
writePost('2026-10-02-我的随笔-20261002120000', [
'title: 我的随笔', 'date: 2026-10-02', 'slug: 20261002120000',
'author: 群林', 'author_id: 1', 'layout: post', 'tags: []', 'categories: []', 'draft: false',
], '随便写点。');
// ③ 老文章:没有 author_id,只能按昵称认领(改造前 write-server 写的那些)
writePost('2026-10-03-老文章-20261003150000', [
'title: 老文章', 'date: 2026-10-03', 'slug: 20261003150000',
'author: 小美', 'layout: post', 'tags: []', 'categories: []', 'draft: false',
], '很久以前写的。');
await sh(BLOG, ['add', '-A']);
await sh(BLOG, ['commit', '-m', 'init']);
await sh(BLOG, ['remote', 'add', 'origin', ORIGIN]);
await sh(BLOG, ['push', '-u', 'origin', 'main']);
}
const DIR_MINE = '2026-10-01-妈妈的菜-20261001093000';
const DIR_ADMIN = '2026-10-02-我的随笔-20261002120000';
const DIR_LEGACY = '2026-10-03-老文章-20261003150000';
// ------------------------------------------------------------------ 起服务
const child = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], {
env: {
...process.env,
PORT: String(PORT),
BIND_HOST: '127.0.0.1',
BLOG_ROOT: BLOG,
TRASH_DIR: TRASH,
SESSION_FILE: path.join(TMP, 'sessions.json'),
EDITOR_TOKEN: TOKEN,
ADMIN_USER: 'admin',
ADMIN_PASS,
GIT_BRANCH: 'main',
PUSH_REMOTES: 'origin',
GIT_PATHS: 'content,static',
// 故意指向一个连不上的地址:验证「CF 不可达时回退本机管理员」这条应急通道
RSS_API_BASE: 'http://127.0.0.1:9',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLog = '';
child.stdout.on('data', (d) => (serverLog += d));
child.stderr.on('data', (d) => (serverLog += d));
async function waitUp() {
for (let i = 0; i < 50; i++) {
try {
const r = await fetch(BASE + '/health');
if (r.ok) return true;
} catch {}
await new Promise((s) => setTimeout(s, 120));
}
return false;
}
async function call(method, p, { body, ident, headers = {}, cookie, raw } = {}) {
const h = { ...headers };
if (ident) {
h['X-Editor-Token'] = TOKEN;
h['X-Editor-Uid'] = ident.uid;
h['X-Editor-User'] = encodeURIComponent(ident.name);
h['X-Editor-Role'] = ident.role;
}
if (cookie) h.Cookie = cookie;
if (body !== undefined && !raw) h['Content-Type'] = 'application/json';
const res = await fetch(BASE + p, {
method,
headers: h,
body: body !== undefined ? (raw ? body : JSON.stringify(body)) : undefined,
redirect: 'manual',
});
const text = await res.text();
let data;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text };
}
return { status: res.status, data, cookie: res.headers.get('set-cookie') };
}
const P = (id) => '/posts/' + encodeURIComponent(id);
const readFile = (dirName) =>
fs.readFileSync(path.join(BLOG, 'content', 'posts', '2026', dirName, 'index.md'), 'utf8');
/**
* ⑧ 直连登录的**主路径**:国内机把账号密码转发给 Cloudflare 的用户表校验。
* 用一个假的 CF(只实现 /api/v2/user/access_token)来测 ——
* 不依赖真线上账号,也能覆盖「编辑能不能从国内线路登录」这条。
*/
async function sectionCfLogin() {
section('⑧ 直连登录走 Cloudflare 用户表(主路径)');
const CF_PORT = 8098;
const PORT2 = 8097;
const http = await import('node:http');
const stub = http.createServer((req, res) => {
let buf = '';
req.on('data', (c) => (buf += c));
req.on('end', () => {
if (!req.url.startsWith('/api/v2/user/access_token')) {
res.writeHead(404).end('{}');
return;
}
const body = JSON.parse(buf || '{}');
const okPair =
(body.email === 'xiaomei' && body.password === 'good-pass') ||
(body.email === 'someone' && body.password === 'plain-pass') ||
(body.email === 'admin' && body.password === 'admin-pass');
if (!okPair) {
res.writeHead(401, { 'Content-Type': 'application/json' }).end('{"msg":"Unauthorized"}');
return;
}
const users = {
xiaomei: { id: 7, name: '小美', role: 'editor', is_admin: false },
admin: { id: 1, name: '群林', role: 'admin', is_admin: true },
// 普通评论用户:有账号,但没写作后台权限
someone: { id: 42, name: '路人', role: 'user', is_admin: false },
};
res.writeHead(200, { 'Content-Type': 'application/json' }).end(
JSON.stringify({ token: 'tok', user: users[body.email] }),
);
});
});
await new Promise((s) => stub.listen(CF_PORT, '127.0.0.1', s));
const srv2 = spawn(process.execPath, [path.join(import.meta.dirname, '..', 'server.mjs')], {
env: {
...process.env,
PORT: String(PORT2),
BIND_HOST: '127.0.0.1',
BLOG_ROOT: BLOG,
TRASH_DIR: TRASH,
SESSION_FILE: path.join(TMP, 'sessions2.json'),
EDITOR_TOKEN: TOKEN,
ADMIN_USER: 'admin',
ADMIN_PASS,
GIT_BRANCH: 'main',
PUSH_REMOTES: 'origin',
RSS_API_BASE: `http://127.0.0.1:${CF_PORT}`,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let log2 = '';
srv2.stdout.on('data', (d) => (log2 += d));
srv2.stderr.on('data', (d) => (log2 += d));
const B2 = `http://127.0.0.1:${PORT2}`;
for (let i = 0; i < 50; i++) {
try {
if ((await fetch(B2 + '/health')).ok) break;
} catch {}
await new Promise((s) => setTimeout(s, 120));
}
const call2 = async (p, body) => {
const res = await fetch(B2 + p, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
redirect: 'manual',
});
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
return { status: res.status, data, cookie: res.headers.get('set-cookie') };
};
const post = async (p, body, cookie) => {
const res = await fetch(B2 + p, {
method: 'GET',
headers: cookie ? { Cookie: cookie } : {},
});
return { status: res.status, data: await res.json().catch(() => null) };
};
let r = await call2('/admin/login', { user: 'xiaomei', password: 'good-pass' });
check('编辑账号从国内线路登录 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140));
check('会话角色是 editor(不是 admin)', r.data.user && r.data.user.role === 'editor' && r.data.user.name === '小美', JSON.stringify(r.data.user));
const ck = String(r.cookie).split(';')[0];
r = await post('/posts', null, ck);
check('编辑登录后只看到自己的文章', r.data && r.data.total === 3, 'total=' + (r.data && r.data.total));
r = await call2('/admin/login', { user: 'someone', password: 'plain-pass' });
check('普通评论用户 → 401(没有写作后台权限)', r.status === 401, 'got ' + r.status);
r = await call2('/admin/login', { user: 'xiaomei', password: 'bad-pass' });
check('密码错(CF 可达)→ 401', r.status === 401, 'got ' + r.status);
r = await call2('/admin/login', { user: 'admin', password: 'admin-pass' });
check('管理员账号 → 200 且角色 admin', r.status === 200 && r.data.user.role === 'admin', JSON.stringify(r.data.user));
srv2.kill();
stub.close();
}
// ------------------------------------------------------------------ 跑
async function main() {
await setupRepo();
if (!(await waitUp())) {
console.error('服务没起来:\n' + serverLog);
process.exit(1);
}
section('① 身份门禁');
let r = await call('GET', '/posts');
check('不带凭据 → 401', r.status === 401, 'got ' + r.status);
r = await call('GET', '/posts', { headers: { 'X-Editor-Token': TOKEN } });
check('只带令牌、无身份头 → 视为管理员(兼容 curl / 旧 Worker)', r.status === 200 && r.data.total === 3, JSON.stringify(r.data).slice(0, 120));
r = await call('GET', '/posts', { headers: { 'X-Editor-Token': 'wrong', 'X-Editor-Uid': '7', 'X-Editor-User': '%E5%B0%8F%E7%BE%8E', 'X-Editor-Role': 'editor' } });
check('令牌不对 → 401(伪造身份头没用)', r.status === 401, 'got ' + r.status);
r = await call('GET', '/posts', { ident: EDITOR });
check('编辑看到 2 篇(自己 + 老的昵称认领),看不到群林那篇', r.status === 200 && r.data.total === 2 &&
!r.data.posts.some((p) => p.id === DIR_ADMIN), JSON.stringify(r.data.posts?.map((p) => p.id)));
r = await call('GET', '/posts', { ident: ADMIN });
check('管理员看到全部 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total);
section('② 编辑不能碰别人的文章');
r = await call('GET', P(DIR_ADMIN), { ident: EDITOR });
check('打开别人的文章 → 403', r.status === 403, 'got ' + r.status);
r = await call('PUT', P(DIR_ADMIN), { ident: EDITOR, body: { content: '我改' } });
check('改别人的文章 → 403', r.status === 403, 'got ' + r.status);
check('别人的文章内容没被动', readFile(DIR_ADMIN).includes('随便写点。'));
r = await call('DELETE', P(DIR_ADMIN), { ident: EDITOR });
check('删别人的文章 → 403', r.status === 403, 'got ' + r.status);
check('别人的文章还在原处', fs.existsSync(path.join(BLOG, 'content/posts/2026', DIR_ADMIN, 'index.md')));
r = await call('POST', '/upload?key=' + encodeURIComponent(DIR_ADMIN), {
ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'),
headers: { 'Content-Type': 'image/png' },
});
check('往别人文章目录传图 → 403', r.status === 403, 'got ' + r.status);
r = await call('POST', '/upload', {
ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'),
headers: { 'Content-Type': 'image/png' },
});
check('不带 key 传图(公共图库)→ 403', r.status === 403, 'got ' + r.status);
section('③ 编辑自己的文章:正常能力 + 老文章认领');
r = await call('PUT', P(DIR_LEGACY), { ident: EDITOR, body: { content: '很久以前写的。改一个字。' } });
check('改自己(老文章按昵称认领)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140));
const legacyText = readFile(DIR_LEGACY);
check('老文章保存后补上了 author_id: 7(正式划到自己名下)', /author_id:\s*7\b/.test(legacyText), legacyText.slice(0, 200));
check('老文章正文改到了', legacyText.includes('改一个字'));
r = await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '今天做了红烧肉,很好吃。' } });
check('改自己(有 author_id)→ 200', r.status === 200, JSON.stringify(r.data).slice(0, 140));
r = await call('POST', '/upload?name=a.png&key=' + encodeURIComponent(DIR_MINE), {
ident: EDITOR, raw: true, body: Buffer.from('89504e47', 'hex'),
headers: { 'Content-Type': 'image/png' },
});
check('往自己文章目录传图 → 200', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140));
check('图片确实落在自己文章目录里', r.data.dir === 'content/posts/2026/' + DIR_MINE, String(r.data.dir));
section('④ 新建文章的归属由服务端钉死');
r = await call('POST', '/posts', {
ident: EDITOR,
body: {
frontMatter: { title: '伪造归属测试', slug: 'fakeowner' + Date.now(), author: '群林', author_id: 1 },
content: '正文',
},
});
check('新建 → 200', r.status === 200 && r.data.id, JSON.stringify(r.data).slice(0, 140));
const created = readFile(r.data.id);
check('author_id 被强制成 7(前端传的 1 无效)', /author_id:\s*7\b/.test(created), created.slice(0, 240));
check('author 被强制成「小美」(不能替别人署名)', /author:\s*小美/.test(created), created.slice(0, 240));
section('⑤ 发布范围:编辑只提交自己的目录');
// 两边各改一处:编辑改自己的,管理员改自己的 —— 编辑发布时不能顺走管理员那笔
await call('PUT', P(DIR_MINE), { ident: EDITOR, body: { content: '准备发布' } });
await call('PUT', P(DIR_ADMIN), { ident: ADMIN, body: { content: '管理员自己的未提交改动' } });
r = await call('GET', '/git/status', { ident: EDITOR });
check('编辑看到的状态是「限定范围」的', r.status === 200 && r.data.scoped === true, JSON.stringify(r.data).slice(0, 160));
check('编辑只看到自己目录的改动', (r.data.files || []).every((f) => f.includes(DIR_MINE) || f.includes(DIR_LEGACY) || f.includes('fakeowner')),
JSON.stringify(r.data.files));
r = await call('GET', '/git/status', { ident: ADMIN });
check('管理员看到全量改动(含编辑的和自己的)', r.data.scoped === false && (r.data.files || []).some((f) => f.includes(DIR_MINE)),
JSON.stringify(r.data.files));
r = await call('POST', '/git/publish', { ident: EDITOR, body: { message: '编辑:发布我的' } });
check('编辑发布 → 成功', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 200));
check('返回里标明是限定范围发布', r.data.scoped === true, String(r.data.scoped));
// core.quotepath=false:中文目录名默认会被 git 转成 \346\210\221 八进制转义,
// 拿它跟原始目录名比字符串必然对不上(编辑器里已经踩过一次同样的坑)
const committed = await sh(BLOG, ['-c', 'core.quotepath=false', 'show', '--stat', '--oneline', 'HEAD']);
check('提交里没有管理员的文章', !committed.includes(DIR_ADMIN), committed.slice(0, 300));
check('提交里有编辑自己的文章', committed.includes(DIR_MINE), committed.slice(0, 300));
const stillDirty = await sh(BLOG, ['-c', 'core.quotepath=false', 'status', '--porcelain']);
check('管理员那篇仍是未提交状态(没被顺走)', stillDirty.includes(DIR_ADMIN), stillDirty);
section('⑥ 国内线路一键跳转不能越权');
const ts = String(Date.now());
const sigFor = (uid, name, role) =>
crypto.createHmac('sha256', TOKEN).update(`${ts}\n${uid}\n${name}\n${role}`).digest('hex');
r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=deadbeef`);
check('签名错 → 403', r.status === 403, 'got ' + r.status);
r = await call('GET', `/admin/handoff?ts=${ts}&u=7&n=%E5%B0%8F%E7%BE%8E&r=editor&t=${sigFor('7', '小美', 'editor')}`);
check('编辑的正确签名 → 302 + 发 Cookie', r.status === 302 && !!r.cookie, 'got ' + r.status);
// 拿这枚 Cookie 冒充管理员操作
const edCookie = String(r.cookie).split(';')[0];
r = await call('GET', P(DIR_ADMIN), { cookie: edCookie });
check('用这枚 Cookie 打开管理员那篇 → 仍 403(跳转没有把编辑变成管理员)', r.status === 403, 'got ' + r.status);
r = await call('GET', '/posts', { cookie: edCookie });
// 到这一步编辑名下有 3 篇了:自己的、按昵称认领的老文章、以及第 ④ 节新建的那篇
check('用这枚 Cookie 列文章 → 只看到自己的 3 篇', r.status === 200 && r.data.total === 3, 'total=' + r.data.total);
// 把签名载荷里的 r 改成 admin(签名仍是按 editor 算的)→ 必须拒
r = await call('GET', `/admin/handoff?ts=${String(Date.now())}&u=7&n=%E5%B0%8F%E7%BE%8E&r=admin&t=${sigFor('7', '小美', 'editor')}`);
check('把角色改成 admin 但签名不匹配 → 403', r.status === 403, 'got ' + r.status);
section('⑦ 直连登录(CF 不可达时的应急通道)');
// 本测试故意把 RSS_API_BASE 指到连不上的地址,模拟「国内机连不上 Cloudflare」。
// 此时账号密码**无法校验**,如实回 503 比谎报 401「密码错」有用得多
// (线上 CF 正常时,密码错会由 CF 回 401 → 这里回 401,见下一条)。
r = await call('POST', '/admin/login', { body: { user: 'admin', password: 'wrong' } });
check('CF 不可达 + 不是本机管理员 → 503(如实说「校验不了」)', r.status === 503, 'got ' + r.status);
r = await call('POST', '/admin/login', { body: { user: 'admin', password: ADMIN_PASS } });
check('本机管理员账号 → 200(CF 连不上也能进)', r.status === 200 && r.data.ok, JSON.stringify(r.data).slice(0, 140));
const admCookie = String(r.cookie).split(';')[0];
check('会话带上角色 admin', r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data.user));
r = await call('GET', '/posts', { cookie: admCookie });
check('这枚 Cookie 能看到全部 3+1 篇', r.status === 200 && r.data.total >= 4, 'total=' + r.data.total);
r = await call('GET', '/admin/session', { cookie: admCookie });
check('会话探测返回身份(含 role)', r.status === 200 && r.data.user && r.data.user.role === 'admin', JSON.stringify(r.data));
r = await call('GET', '/admin/session');
check('没会话时 → 401 且 mode=direct', r.status === 401 && r.data.mode === 'direct', JSON.stringify(r.data));
await sectionCfLogin();
console.log('\n' + '─'.repeat(60));
console.log(`通过 ${pass} / ${pass + fails.length}`);
if (fails.length) {
console.log('\n失败项:');
for (const f of fails) console.log(' · ' + f);
}
return fails.length ? 1 : 0;
}
let code = 1;
try {
code = await main();
} catch (e) {
console.error('测试异常崩溃:', e);
} finally {
child.kill();
await new Promise((s) => setTimeout(s, 250));
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 3 });
} catch (e) {
console.error('(临时目录没删干净,可手动删:' + TMP + ')');
}
process.exit(code);
}