import { createRoute } from "@hono/zod-openapi"; import { Api, apiResponse, type EndpointDef } from "@umt/contracts"; import type { Context } from "hono"; import type { z } from "zod"; import { type AuthEnv, requireScope } from "../../http/auth.ts"; import { errorBody } from "../../http/errors.ts"; import type { ModuleApp } from "../types.ts"; import { FilesError, type FmDeps, isAdmin } from "./common.ts"; import { identify } from "./identify.ts"; import { idempotent } from "./ledger.ts"; import { listDir, listRoots, statEntry } from "./list.ts"; import { mkdirIn, renameEntry } from "./mutate.ts"; import { organizeFiles } from "./organize.ts"; import { probeFiles, scrape } from "./probe.ts"; import { transfer } from "./transfer.ts"; import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts"; // /files 路由:scope 由 contracts 端点定义(读 organize.read / 写 organize.execute),处理器内再按授权矩阵细分。 // FilesError → 信封 + 对应状态(根离线 503,message 以 `: ` 开头)。 // biome-ignore lint/suspicious/noExplicitAny: 入参已按 contracts schema 校验 type Input = { query: any; body: any }; type Handler = (c: Context, input: Input) => Promise; function mount(app: ModuleApp, def: EndpointDef, handler: Handler) { const route = createRoute({ method: def.method, path: def.path, tags: ["files"], summary: def.summary, middleware: [requireScope(def.scope)] as const, request: { ...(def.query ? { query: def.query as z.ZodObject } : {}), ...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}), }, responses: { 200: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } }, }, }); app.openapi(route, (async (c: Context) => { const v = c.req as unknown as { valid(t: string): unknown }; try { const data = await handler(c, { query: def.query ? v.valid("query") : {}, body: def.body ? v.valid("json") : undefined, }); return c.json({ ok: true as const, data }, 200); } catch (err) { if (err instanceof FilesError) return c.json(errorBody(err.code, `${err.reason}: ${err.message}`), err.status as 400); throw err; } }) as never); } /** 写操作的幂等包装(dryRun 不占键) */ const idem = ( d: FmDeps, c: Context, op: string, body: B, fn: () => Promise, ) => { if (body.dryRun) return fn(); const { idempotencyKey, ...rest } = body; return idempotent(d, c.get("principal"), op, idempotencyKey, rest, fn); }; export function registerFileRoutes(app: ModuleApp, d: FmDeps) { const F = Api.files; mount(app, F.roots, async () => listRoots(d)); mount(app, F.list, async (_c, { query }) => listDir(d, query)); mount(app, F.stat, async (_c, { query }) => statEntry(d, query.root, query.path)); mount(app, F.mkdir, async (c, { body }) => idem(d, c, "mkdir", body, () => mkdirIn(d, c.get("principal"), body))); mount(app, F.rename, async (c, { body }) => idem(d, c, "rename", body, () => renameEntry(d, c.get("principal"), body)), ); mount(app, F.move, async (c, { body }) => idem(d, c, "move", body, () => transfer(d, c.get("principal"), body))); mount(app, F.copy, async (c, { body }) => { const b = { ...body, mode: "copy" as const }; return idem(d, c, "copy", b, () => transfer(d, c.get("principal"), b)); }); mount(app, F.organize, async (c, { body }) => idem(d, c, "organize", body, () => organizeFiles(d, c.get("principal"), body)), ); mount(app, F.delete, async (c, { body }) => idem(d, c, "delete", body, () => deleteFiles(d, c.get("principal"), body)), ); mount(app, F.trash, async (_c, { query }) => listTrash(d, query)); mount(app, F.trashRestore, async (c, { body }) => idem(d, c, "trash_restore", body, () => restoreTrash(d, c.get("principal"), body)), ); mount(app, F.trashPurge, async (c, { body }) => { // 授权矩阵:清空回收站仅 admin 会话,API Key(含 admin scope)一律拒绝 if (!isAdmin(c.get("principal"))) throw new FilesError("forbidden", "清空回收站只允许控制台管理员会话"); return idem(d, c, "trash_purge", body, () => purgeTrash(d, c.get("principal"), body)); }); mount(app, F.identify, async (c, { body }) => identify(d, c.get("principal"), body)); mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body))); mount(app, F.probe, async (_c, { body }) => probeFiles(d, body)); }