/** * 文章编辑 —— Worker 侧只做「鉴权 + 反代」,真正的读写在后端的 editor-api 容器里。 * * 为什么要绕这一跳,而不是让浏览器直连后端: * 1. 令牌(EDITOR_TOKEN)只存在 Worker 的环境变量里,浏览器永远拿不到; * 2. 后端可以只绑 127.0.0.1,公网扫不到,攻击面从「整个 write-server」缩到一个转发口; * 3. 复用后台已有的登录会话 —— 这里只需要 isAdminRequest,不用再造一套账号体系。 * * 路径挂载在 /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 { isAdminRequest } from '../lib/session'; async function guard(ctx: Ctx): Promise { if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) { return fail(403, '需要管理员权限'); } if (!ctx.env.EDITOR_API_BASE) { return fail(503, '后端未配置:缺少 EDITOR_API_BASE'); } if (!ctx.env.EDITOR_TOKEN) { return fail(503, '后端未配置:缺少 EDITOR_TOKEN(wrangler secret put)'); } return null; } 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 { const deny = await guard(ctx); if (deny) return deny; let res: Response; try { res = await fetch(target(ctx, path, withQuery), { ...init, headers: { 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), ...(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 { 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 { const deny = await guard(ctx); if (deny) return 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: { 'X-Editor-Token': String(ctx.env.EDITOR_TOKEN), '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' }); /** 健康检查:让后台能显示「编辑后端是否在线」 */ export async function health(ctx: Ctx): Promise { if (!(await isAdminRequest(ctx.env, ctx.req, ctx.user))) return fail(403, '需要管理员权限'); if (!ctx.env.EDITOR_API_BASE) return json({ ok: false, reason: '缺少 EDITOR_API_BASE' }); 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) }); } }