2026-10-04 21:16:06 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 文章编辑 —— Worker 侧只做「鉴权 + 反代」,真正的读写在后端的 editor-api 容器里。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么要绕这一跳,而不是让浏览器直连后端:
|
|
|
|
|
|
* 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到;
|
|
|
|
|
|
* 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口;
|
2026-10-05 14:34:56 +08:00
|
|
|
|
* 3. 复用后台已有的登录会话 —— 这里只需要一套「谁在写文章」的判定。
|
2026-10-04 21:16:06 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 路径挂载在 /api/v2/editor/*:Router.dispatch 会同时尝试 `/api/v2/x` 和 `/x`,
|
|
|
|
|
|
* 而 /api/*(非 v2)已经被 RSS 模块接走了(见 src/index.ts),所以必须走 v2 前缀。
|
|
|
|
|
|
* 后台前端本来就是 `API = location.origin + '/api/v2'`,拼起来天然一致。
|
2026-10-05 14:34:56 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ★★ 本文件是「谁能写博客」的唯一闸门,动它之前先读完下面两段。
|
2026-10-04 21:16:06 +08:00
|
|
|
|
*/
|
|
|
|
|
|
import type { Ctx } from '../router';
|
|
|
|
|
|
import { fail, json } from '../lib/util';
|
2026-10-04 23:40:49 +08:00
|
|
|
|
import { getAdminUsers } from '../lib/db';
|
|
|
|
|
|
import { userFromToken } from '../lib/session';
|
2026-10-05 14:34:56 +08:00
|
|
|
|
import { roleOf } from '../lib/role';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 操作者身份:后端 editor-api 靠它判断「这篇文章是不是你的」。
|
|
|
|
|
|
* role 只有两档 —— admin(不受限)和 editor(只能碰自己的文章)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
interface EditorIdentity {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
role: 'admin' | 'editor';
|
|
|
|
|
|
}
|
2026-10-04 23:40:49 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* ★ 鉴权只认「真实登录会话(Bearer token)」,**绝不能**用 isAdminRequest。
|
|
|
|
|
|
*
|
|
|
|
|
|
* isAdminRequest 里有一条 Artalk 为老客户端留的兜底:请求带
|
|
|
|
|
|
* `?name=<管理员名>&email=<管理员邮箱>` 就视为管理员。而 ADMIN_NAME /
|
|
|
|
|
|
* ADMIN_EMAIL 是写死在 wrangler.toml、并暴露在后台页面里的公开信息——
|
|
|
|
|
|
* 一旦这条兜底被用在编辑器路由上,等于把「读全部文章源码 + 改文件 +
|
|
|
|
|
|
* 删文章 + 传图 + 触发 git 发布(真推到 CNB/GitHub)」的能力裸露在公网,
|
|
|
|
|
|
* 任何人拼一个 query 就能删你的博客。这里必须硬性要求会话。
|
|
|
|
|
|
*
|
2026-10-05 14:34:56 +08:00
|
|
|
|
* 放行两种人(与「用户管理」里的角色一一对应):
|
|
|
|
|
|
* · is_admin=1,或邮箱命中配置里的管理员 → admin
|
|
|
|
|
|
* · users.role = 'editor' → editor
|
|
|
|
|
|
* 普通评论用户(role='')没有登录过的话根本走不到这里;走得到也一律 403。
|
2026-10-04 23:40:49 +08:00
|
|
|
|
*/
|
2026-10-05 14:34:56 +08:00
|
|
|
|
async function requireEditorSession(ctx: Ctx): Promise<EditorIdentity | null> {
|
2026-10-04 23:40:49 +08:00
|
|
|
|
const user = ctx.user ?? (await userFromToken(ctx.env, ctx.req.headers.get('Authorization')));
|
|
|
|
|
|
if (!user) return null;
|
2026-10-05 14:34:56 +08:00
|
|
|
|
|
|
|
|
|
|
const role = roleOf(user);
|
|
|
|
|
|
if (role === 'admin') return { id: user.id, name: user.name, role: 'admin' };
|
|
|
|
|
|
|
2026-10-04 23:40:49 +08:00
|
|
|
|
// 已登录但没打 is_admin 标的老账号:邮箱命中配置里的管理员也算
|
|
|
|
|
|
const admins = await getAdminUsers(ctx.env);
|
2026-10-05 14:34:56 +08:00
|
|
|
|
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;
|
2026-10-04 23:40:49 +08:00
|
|
|
|
}
|
2026-10-04 21:16:06 +08:00
|
|
|
|
|
2026-10-05 14:34:56 +08:00
|
|
|
|
/** 鉴权 + 后端配置检查。返回身份,或一个可以直接返回给浏览器的错误响应 */
|
|
|
|
|
|
async function auth(ctx: Ctx): Promise<{ ident: EditorIdentity } | { deny: Response }> {
|
|
|
|
|
|
const ident = await requireEditorSession(ctx);
|
|
|
|
|
|
if (!ident) return { deny: fail(403, '需要管理员或编辑权限') };
|
2026-10-04 21:16:06 +08:00
|
|
|
|
if (!ctx.env.EDITOR_API_BASE) {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
return { deny: fail(503, '后端未配置:缺少 EDITOR_API_BASE') };
|
2026-10-04 21:16:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (!ctx.env.EDITOR_TOKEN) {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
return { deny: fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)') };
|
2026-10-04 21:16:06 +08:00
|
|
|
|
}
|
2026-10-05 14:34:56 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
2026-10-04 21:16:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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> {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
const a = await auth(ctx);
|
|
|
|
|
|
if ('deny' in a) return a.deny;
|
2026-10-04 21:16:06 +08:00
|
|
|
|
|
|
|
|
|
|
let res: Response;
|
|
|
|
|
|
try {
|
|
|
|
|
|
res = await fetch(target(ctx, path, withQuery), {
|
|
|
|
|
|
...init,
|
|
|
|
|
|
headers: {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
...editorHeaders(ctx.env, a.ident),
|
2026-10-04 21:16:06 +08:00
|
|
|
|
...(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> {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
const a = await auth(ctx);
|
|
|
|
|
|
if ('deny' in a) return a.deny;
|
2026-10-04 21:16:06 +08:00
|
|
|
|
|
|
|
|
|
|
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: {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
...editorHeaders(ctx.env, a.ident),
|
2026-10-04 21:16:06 +08:00
|
|
|
|
'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' });
|
|
|
|
|
|
|
2026-10-05 07:36:26 +08:00
|
|
|
|
// ---------------------------------------------------------------- 一键跳转
|
|
|
|
|
|
|
|
|
|
|
|
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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-05 14:34:56 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 URL。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 怎么做到免登录:浏览器直接开 writeapi.usj.cc/admin/ 是要重新输账号密码的
|
|
|
|
|
|
* (那边是独立的 Cookie 会话)。这里让 Worker 用共享令牌对当前时间戳做
|
|
|
|
|
|
* HMAC 签名,前端拿着签名去 editor-api 的 /admin/handoff 换会话 Cookie ——
|
|
|
|
|
|
* 两边本来就共享 EDITOR_TOKEN,等于用已登录的 CF 会话给国内机做了一次担保。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ★ 签名载荷里必须带**身份**(uid/名字/角色)。否则编辑点一下这个按钮,
|
|
|
|
|
|
* 到了国内机就变成那个唯一的管理员账号(cfg.adminUser)—— 角色直接越权。
|
|
|
|
|
|
* 后端按载荷建会话,编辑过去还是编辑。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 安全边界:签名 2 分钟内有效、同一签名只能换一次会话、且只有已登录的
|
|
|
|
|
|
* 管理员/编辑能从 Worker 拿到签名。URL 全程 HTTPS,不含任何账号密码。
|
|
|
|
|
|
*/
|
2026-10-05 07:36:26 +08:00
|
|
|
|
export async function handoff(ctx: Ctx): Promise<Response> {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
const a = await auth(ctx);
|
|
|
|
|
|
if ('deny' in a) return a.deny;
|
2026-10-05 07:36:26 +08:00
|
|
|
|
|
|
|
|
|
|
const base = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
|
|
|
|
|
|
const ts = Date.now().toString();
|
2026-10-05 14:34:56 +08:00
|
|
|
|
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()}` });
|
2026-10-05 07:36:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-04 23:40:49 +08:00
|
|
|
|
/** 健康检查:让后台能显示「编辑后端是否在线」(同样只认真实会话) */
|
2026-10-04 21:16:06 +08:00
|
|
|
|
export async function health(ctx: Ctx): Promise<Response> {
|
2026-10-05 14:34:56 +08:00
|
|
|
|
const a = await auth(ctx);
|
|
|
|
|
|
if ('deny' in a) return a.deny;
|
2026-10-04 21:16:06 +08:00
|
|
|
|
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) });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|