Files
blog/blog-admin/src/routes/editor.ts
T
zqlit 39677e7b40 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、作者框只读;用户管理加角色下拉 + 新建用户
2026-10-05 14:34:56 +08:00

257 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 文章编辑 —— Worker 侧只做「鉴权 + 反代」,真正的读写在后端的 editor-api 容器里。
*
* 为什么要绕这一跳,而不是让浏览器直连后端:
* 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到;
* 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口;
* 3. 复用后台已有的登录会话 —— 这里只需要一套「谁在写文章」的判定。
*
* 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`,
* 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。
* 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。
*
* ★★ 本文件是「谁能写博客」的唯一闸门,动它之前先读完下面两段。
*/
import type { Ctx } from '../router';
import { fail, json } from '../lib/util';
import { getAdminUsers } from '../lib/db';
import { userFromToken } from '../lib/session';
import { roleOf } from '../lib/role';
/**
* 操作者身份:后端 editor-api 靠它判断「这篇文章是不是你的」。
* role 只有两档 —— admin(不受限)和 editor(只能碰自己的文章)。
*/
interface EditorIdentity {
id: number;
name: string;
role: 'admin' | 'editor';
}
/**
* ★ 鉴权只认「真实登录会话(Bearer token)」,**绝不能**用 isAdminRequest。
*
* isAdminRequest 里有一条 Artalk 为老客户端留的兜底:请求带
* `?name=<管理员名>&email=<管理员邮箱>` 就视为管理员。而 ADMIN_NAME /
* ADMIN_EMAIL 是写死在 wrangler.toml、并暴露在后台页面里的公开信息——
* 一旦这条兜底被用在编辑器路由上,等于把「读全部文章源码 + 改文件 +
* 删文章 + 传图 + 触发 git 发布(真推到 CNB/GitHub)」的能力裸露在公网,
* 任何人拼一个 query 就能删你的博客。这里必须硬性要求会话。
*
* 放行两种人(与「用户管理」里的角色一一对应):
* · is_admin=1,或邮箱命中配置里的管理员 → admin
* · users.role = 'editor' → editor
* 普通评论用户(role='')没有登录过的话根本走不到这里;走得到也一律 403。
*/
async function requireEditorSession(ctx: Ctx): Promise<EditorIdentity | null> {
const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization')));
if (!user) return null;
const role = roleOf(user);
if (role === 'admin') return { id: user.id, name: user.name, role: 'admin' };
// 已登录但没打 is_admin 标的老账号:邮箱命中配置里的管理员也算
const admins = await getAdminUsers(ctx.env);
if (admins.some((a) => a.email && a.email.toLowerCase() === String(user.email || '').toLowerCase())) {
return { id: user.id, name: user.name, role: 'admin' };
}
if (role === 'editor') return { id: user.id, name: user.name, role: 'editor' };
return null;
}
/** 鉴权 + 后端配置检查。返回身份,或一个可以直接返回给浏览器的错误响应 */
async function auth(ctx: Ctx): Promise<{ ident: EditorIdentity } | { deny: Response }> {
const ident = await requireEditorSession(ctx);
if (!ident) return { deny: fail(403, '需要管理员或编辑权限') };
if (!ctx.env.EDITOR_API_BASE) {
return { deny: fail(503, '后端未配置:缺少 EDITOR_API_BASE') };
}
if (!ctx.env.EDITOR_TOKEN) {
return { deny: fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)') };
}
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 {
const root = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
return root + path + (withQuery && ctx.url.search ? ctx.url.search : '');
}
/** 发到后端并原样把 JSON 与状态码带回来 */
async function relay(ctx: Ctx, path: string, init: RequestInit = {}, withQuery = false): Promise<Response> {
const a = await auth(ctx);
if ('deny' in a) return a.deny;
let res: Response;
try {
res = await fetch(target(ctx, path, withQuery), {
...init,
headers: {
...editorHeaders(ctx.env, a.ident),
...(init.headers || {}),
},
});
} catch (e) {
// 后端容器挂了 / DNS 不通 / 被墙,都要说人话,别让前端看到 Failed to fetch
return fail(502, '编辑后端不可达:' + (e instanceof Error ? e.message : String(e)));
}
const text = await res.text();
let data: unknown = text;
try {
data = text ? JSON.parse(text) : null;
} catch {
/* 后端没返回 JSON 就原样透传 */
}
return json(data, { status: res.status });
}
/** 把浏览器发来的 JSON 原样转发(体积上限交给后端把关) */
async function relayJson(ctx: Ctx, path: string, method: string): Promise<Response> {
let body: string;
try {
body = await ctx.req.text();
} catch {
return fail(400, '读取请求体失败');
}
return relay(ctx, path, {
method,
headers: { 'Content-Type': 'application/json' },
body: body || undefined,
});
}
// ---------------------------------------------------------------- 文章
export const listPosts = (ctx: Ctx) => relay(ctx, '/posts', { method: 'GET' }, true);
export const getPost = (ctx: Ctx) =>
relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'GET' });
export const createPost = (ctx: Ctx) => relayJson(ctx, '/posts', 'POST');
export const savePost = (ctx: Ctx) =>
relayJson(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), 'PUT');
export const deletePost = (ctx: Ctx) =>
relay(ctx, '/posts/' + encodeURIComponent(ctx.params.slug), { method: 'DELETE' });
// ---------------------------------------------------------------- 图片
/**
* 图片走**原始二进制**直传(不分片、不组 multipart):
* POST /api/v2/editor/upload?name=xxx.png&slug=<文章 slug>
* Content-Type: image/png
* Workers 的 req.body 是流,直接转给后端最省事,也不用把几十 MB 读进内存。
*/
export async function upload(ctx: Ctx): Promise<Response> {
const a = await auth(ctx);
if ('deny' in a) return a.deny;
const ct = ctx.req.headers.get('Content-Type') || '';
if (!ct.startsWith('image/')) return fail(400, '只接受图片');
try {
const res = await fetch(target(ctx, '/upload', true), {
method: 'POST',
headers: {
...editorHeaders(ctx.env, a.ident),
'Content-Type': ct,
},
body: ctx.req.body,
});
const text = await res.text();
let data: unknown = text;
try {
data = text ? JSON.parse(text) : null;
} catch {
/* 透传 */
}
return json(data, { status: res.status });
} catch (e) {
return fail(502, '上传失败:' + (e instanceof Error ? e.message : String(e)));
}
}
// ---------------------------------------------------------------- git
export const gitStatus = (ctx: Ctx) => relay(ctx, '/git/status', { method: 'GET' });
export const publish = (ctx: Ctx) => relayJson(ctx, '/git/publish', 'POST');
export const sync = (ctx: Ctx) => relay(ctx, '/git/sync', { method: 'POST' });
// ---------------------------------------------------------------- 一键跳转
async function hmacHex(secret: string, msg: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msg));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
/**
* 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 URL。
*
* 怎么做到免登录:浏览器直接开 writeapi.usj.cc/admin/ 是要重新输账号密码的
* (那边是独立的 Cookie 会话)。这里让 Worker 用共享令牌对当前时间戳做
* HMAC 签名,前端拿着签名去 editor-api 的 /admin/handoff 换会话 Cookie ——
* 两边本来就共享 EDITOR_TOKEN,等于用已登录的 CF 会话给国内机做了一次担保。
*
* ★ 签名载荷里必须带**身份**(uid/名字/角色)。否则编辑点一下这个按钮,
* 到了国内机就变成那个唯一的管理员账号(cfg.adminUser)—— 角色直接越权。
* 后端按载荷建会话,编辑过去还是编辑。
*
* 安全边界:签名 2 分钟内有效、同一签名只能换一次会话、且只有已登录的
* 管理员/编辑能从 Worker 拿到签名。URL 全程 HTTPS,不含任何账号密码。
*/
export async function handoff(ctx: Ctx): Promise<Response> {
const a = await auth(ctx);
if ('deny' in a) return a.deny;
const base = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
const ts = Date.now().toString();
const uid = String(a.ident.id);
const name = a.ident.name;
const role = a.ident.role;
const sig = await hmacHex(String(ctx.env.EDITOR_TOKEN), `${ts}\n${uid}\n${name}\n${role}`);
const qs = new URLSearchParams({ ts, u: uid, n: name, r: role, t: sig });
return json({ url: `${base}/api/v2/admin/handoff?${qs.toString()}` });
}
/** 健康检查:让后台能显示「编辑后端是否在线」(同样只认真实会话) */
export async function health(ctx: Ctx): Promise<Response> {
const a = await auth(ctx);
if ('deny' in a) return a.deny;
try {
const res = await fetch(target(ctx, '/health'), { method: 'GET' });
return json({ ...(await res.json() as object), reachable: true });
} catch (e) {
return json({ ok: false, reachable: false, reason: e instanceof Error ? e.message : String(e) });
}
}