feat(server): W5-A file manager module (/files)
Roots/list/stat with inline business status, path guard (realpath within registered roots, symlink escape, offline root 503), mkdir/rename syncing asset_location and path_binding revisions, two-phase move/copy/organize (planId + source facts), trash with reference checks and purgeTrash task, identify/probe/scrape, audit + idempotency keys. Runbook docs/runbook/files.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09f6e3a3e5
commit
29cd10b27e
20 files changed
+4380
-1
No files matched your search
@@ -0,0 +1,101 @@
|
||||
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 以 `<reason>: ` 开头)。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 入参已按 contracts schema 校验
|
||||
type Input = { query: any; body: any };
|
||||
type Handler = (c: Context<AuthEnv>, input: Input) => Promise<unknown>;
|
||||
|
||||
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<AuthEnv>) => {
|
||||
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 = <B extends { idempotencyKey: string; dryRun?: boolean }>(
|
||||
d: FmDeps,
|
||||
c: Context<AuthEnv>,
|
||||
op: string,
|
||||
body: B,
|
||||
fn: () => Promise<unknown>,
|
||||
) => {
|
||||
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));
|
||||
}
|
||||
Reference in new issue
Block a user