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:
rootandClaude Opus 5.5 committed 2026-09-27 23:02:20 +08:00
1 parent 09f6e3a3e5
commit 29cd10b27e
20 files changed
+4380 -1

No files matched your search

@@ -0,0 +1,510 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, readdir, rm } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { copyWithVerify, moveSafe } from "@umt/adapters";
import type {
FileDeleteCheck,
FilesDeleteRequest,
FilesDeleteResult,
FilesTrashList,
FilesTrashPurgeRequest,
FilesTrashPurgeResult,
FilesTrashQuery,
FilesTrashRestoreRequest,
FilesTrashRestoreResult,
TrashItem,
} from "@umt/contracts";
import { sql, type TrashItemRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import { upsertDecision } from "../acquisition-organize/services/common.ts";
import {
actorOfP,
errMessage,
errnoOf,
FilesError,
type FmDeps,
hashJson,
isAdmin,
jsonb,
onlineRoot,
recheck,
resolveIn,
rootDef,
within,
} from "./common.ts";
import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { bindingsAt, downloadsFor, isDownloading, managedDownloads } from "./list.ts";
// 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,O(1) rename;
// 不可写或跨卷时复制到 DATA_DIR/trash,超过上限拒绝)。引用检查:纳管做种 / 已登记资产 / 路径绑定 / 根本身。
// 删除永不连带下载器任务。
export const TRASH_DIR = ".umt-trash";
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
const day = (t: Date) => t.toISOString().slice(0, 10).replace(/-/g, "");
async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files: string[] }> {
const st = await lstat(abs);
if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
let size = 0;
const files: string[] = [];
const walk = async (dir: string, prefix: string) => {
for (const n of await readdir(dir)) {
if (files.length >= cap) return;
const p = join(dir, n);
const s = await lstat(p);
const rel = prefix ? `${prefix}/${n}` : n;
if (s.isDirectory()) await walk(p, rel);
else {
size += Number(s.size);
files.push(rel);
}
}
};
await walk(abs, "");
return { size, files };
}
interface CheckCtx {
abs: string | null;
rootReal: string | null;
libraryRootId: string | null;
}
async function checkItem(
d: FmDeps,
body: FilesDeleteRequest,
it: { root: string; path: string },
downloads: Awaited<ReturnType<typeof managedDownloads>>,
): Promise<{ check: FileDeleteCheck; ctx: CheckCtx }> {
const empty = (blocked: FileDeleteCheck["blocked"]): { check: FileDeleteCheck; ctx: CheckCtx } => ({
check: {
root: it.root as FileDeleteCheck["root"],
path: it.path,
isDir: false,
size: 0,
blocked,
downloads: [],
assets: [],
bindings: [],
sharedInode: false,
},
ctx: { abs: null, rootReal: null, libraryRootId: null },
});
let r: Awaited<ReturnType<typeof resolveIn>>;
try {
r = await resolveIn(d, it.root, it.path);
} catch (e) {
if (e instanceof FilesError && e.reason === "root_unavailable") return empty("root_unavailable");
if (e instanceof FilesError && e.reason === "not_found") return empty("not_found");
if (e instanceof FilesError && e.reason === "path_escape") return empty("escape");
throw e;
}
if (r.rel === "") return empty("is_root");
const isDir = !!r.lst?.isDirectory() && !r.isSymlink;
const size = r.isSymlink ? 0 : (await sizeOf(r.abs)).size;
const dls = downloadsFor(downloads, r.abs);
const lib = r.def.libraryRootId;
const assets = lib
? (
await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "a.id as asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", lib)
.where("l.status", "=", "available")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", r.rel), eb("l.relative_path", "like", likePrefix(r.rel))])
: eb("l.relative_path", "=", r.rel),
)
.execute()
).filter((a) => a.media_entity_id)
: [];
const bindings = isDir ? await bindingsAt(d, r.def, r.rel, true) : [];
let blocked: FileDeleteCheck["blocked"] = null;
if (!r.def.modifiable) blocked = "is_root";
else if (dls.some((t) => isDownloading(t.status) || t.hnr !== "satisfied")) blocked = "download_active";
else if (assets.length > 0 && !body.retireAssets) blocked = "needs_retire";
else if (bindings.length > 0 && !body.unbind) blocked = "needs_unbind";
return {
check: {
root: it.root as FileDeleteCheck["root"],
path: r.rel,
isDir,
size,
blocked,
downloads: dls.map((t) => ({ taskId: t.taskId, status: t.status, hnr: t.hnr })),
assets: assets.map((a) => ({
assetId: a.asset_id,
locationId: a.id,
mediaEntityId: a.media_entity_id!,
relativePath: a.relative_path,
})),
bindings,
sharedInode: !isDir && Number(r.lst?.nlink ?? 1) > 1,
},
ctx: { abs: r.abs, rootReal: r.rootReal, libraryRootId: lib },
};
}
const BLOCK_ERR: Record<
NonNullable<FileDeleteCheck["blocked"]>,
[ConstructorParameters<typeof FilesError>[0], string]
> = {
download_active: ["download_active", "属于纳管下载任务(未完成或做种义务未满足)"],
is_root: ["bad_request", "不能删除根本身或只读登记根下的内容"],
needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"],
root_unavailable: ["root_unavailable", "根不可用,不当作已删除"],
not_found: ["not_found", "不存在"],
escape: ["path_escape", "路径逃出根"],
};
export async function deleteFiles(
d: FmDeps,
principal: Principal | undefined,
body: FilesDeleteRequest,
): Promise<FilesDeleteResult> {
if (body.mode === "permanent") {
if (!d.options.allowPermanentDelete)
throw new FilesError("permanent_disabled", "永久删除未开启(FILES_ALLOW_PERMANENT_DELETE),请使用回收站");
if (!isAdmin(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
}
const downloads = await managedDownloads(d);
const results = [];
for (const it of body.items) results.push(await checkItem(d, body, it, downloads));
const checks = results.map((r) => r.check);
const requestHash = hashJson({
items: body.items,
mode: body.mode,
retireAssets: body.retireAssets,
unbind: body.unbind,
});
if (body.dryRun) {
const plan = await savePlan(d, "delete", requestHash, principal, { checks });
return {
dryRun: true,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
mode: body.mode,
checks,
trashed: [],
deleted: [],
failed: [],
recordId: null,
};
}
const blocked = checks.find((c) => c.blocked);
if (blocked) {
const [reason, msg] = BLOCK_ERR[blocked.blocked!];
throw new FilesError(reason, `${blocked.path}: ${msg}`);
}
let planId: string | null = null;
if (body.planId) planId = (await loadPlan(d, body.planId, "delete", requestHash)).id;
if (!isAdmin(principal)) {
// API Key:每次删除都要一次性审批,审批绑定 dryRun 的计划 id + 内容哈希
if (!planId) throw new FilesError("approval_required", "API Key 删除需先 dryRun 取得 planId 并获得一次性审批");
const plan = await loadPlan(d, planId, "delete", requestHash);
const ok = await consumeApproval(d, plan.id, plan.contentHash);
if (!ok) throw new FilesError("approval_required", `删除需要一次性审批(planType=files_plan, planId=${plan.id})`);
}
if (body.mode === "permanent" && body.items.length === 1 && body.confirmName !== basename(checks[0]!.path))
throw new FilesError("bad_request", "永久删除需在 confirmName 中输入文件名确认");
if (planId) await retirePlan(d, planId);
const now = d.ao.ports.now();
const expiresAt = new Date(now.getTime() + d.options.trashRetentionDays * 86_400_000);
const trashed: TrashItem[] = [];
const deleted: string[] = [];
const failed: FilesDeleteResult["failed"] = [];
const done: typeof results = [];
for (const res of results) {
const { check, ctx } = res;
const abs = ctx.abs!;
try {
await recheck(abs, ctx.rootReal!);
if (body.mode === "permanent") {
await rm(abs, { recursive: true, force: false });
deleted.push(check.path);
} else {
const row = await moveToTrash(d, principal, check, ctx, expiresAt);
trashed.push(toTrashItem(row));
}
done.push(res);
} catch (e) {
failed.push({
path: check.path,
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"),
message: errMessage(e),
});
}
}
// 资产退役 / 解除绑定(只对已实际删除/移入回收站的项)
await d.ctx.db.transaction().execute(async (trx) => {
const locIds = done.flatMap((r) => r.check.assets.map((a) => a.locationId));
if (locIds.length > 0) {
await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "in", locIds).execute();
const assetIds = [...new Set(done.flatMap((r) => r.check.assets.map((a) => a.assetId)))];
const retired = await sql<{ id: string }>`
update media_asset set status = 'retired'
where id = any(${assetIds}::uuid[])
and not exists (select 1 from asset_location l where l.media_asset_id = media_asset.id and l.status <> 'removed')
returning id`.execute(trx);
const retiredIds = retired.rows.map((x) => x.id);
if (retiredIds.length > 0) {
const slots = await trx
.updateTable("acquisition_slot")
.set({ status: "attention", status_reason: "asset_deleted" })
.where("current_asset_id", "in", retiredIds)
.returning(["id", "media_entity_id", "name"])
.execute();
for (const s of slots)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `Slot 满足资产已被删除:${s.name}`,
summary: "文件管理删除了满足该 Slot 的资产(进回收站或永久删除),Slot 退回 attention,请确认是否重新获取",
subjectType: "acquisition_slot",
subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`,
evidence: { mediaEntityId: s.media_entity_id, assets: retiredIds, by: actorOfP(principal) },
});
}
}
const bindIds = done.flatMap((r) => r.check.bindings.map((b) => b.bindingId));
if (bindIds.length > 0)
await trx
.updateTable("path_binding")
.set({ status: "superseded", note: `files.delete by ${actorOfP(principal)}` })
.where("id", "in", bindIds)
.execute();
});
await audit(d, principal, body.mode === "permanent" ? "delete_permanent" : "delete", {
planId,
items: checks.map((c) => ({ root: c.root, path: c.path, assets: c.assets.length, bindings: c.bindings.length })),
trashed: trashed.map((t) => t.id),
deleted,
failed,
});
for (const c of checks) filesChanged(d, c.root, [c.path.includes("/") ? dirname(c.path) : ""]);
return {
dryRun: false,
planId,
planExpiresAt: null,
mode: body.mode,
checks,
trashed,
deleted,
failed,
recordId: null,
};
}
async function copyTree(src: string, dst: string, files: string[], hash: "size" | "sha256" | "sample") {
const st = await lstat(src);
if (!st.isDirectory()) {
await copyWithVerify(src, dst, { hash, createParents: true });
return;
}
await mkdir(dst, { recursive: true });
for (const f of files) await copyWithVerify(join(src, f), join(dst, f), { hash, createParents: true });
}
async function moveToTrash(
d: FmDeps,
principal: Principal | undefined,
check: FileDeleteCheck,
ctx: CheckCtx,
expiresAt: Date,
): Promise<TrashItemRow> {
const abs = ctx.abs!;
const id = randomUUID();
const stamp = day(d.ao.ports.now());
let trashPath = join(ctx.rootReal!, TRASH_DIR, stamp, id, check.path);
let moved = false;
try {
await mkdir(dirname(trashPath), { recursive: true });
const r = await moveSafe(abs, trashPath);
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在: ${trashPath}`);
moved = r.status === "moved";
} catch (e) {
if (e instanceof FilesError) throw e;
const code = errnoOf(e) ?? (e as { code?: string }).code;
if (!["EACCES", "EPERM", "EROFS", "EXDEV"].includes(code ?? "")) throw e;
}
if (!moved) {
// 根不可写或跨卷:复制到 DATA_DIR/trash(有上限),核验后删源
if (check.size > d.options.trashCrossMaxBytes)
throw new FilesError(
"bad_request",
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes};请使用永久删除`,
);
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
const { files } = await sizeOf(abs);
await copyTree(abs, trashPath, files, d.ao.ports.options.verifyHash);
await rm(abs, { recursive: true });
}
return d.ctx.db
.insertInto("trash_item")
.values({
id,
root_id: check.root,
library_root_id: ctx.libraryRootId,
root_host_path: ctx.rootReal!,
original_path: check.path,
trash_path: trashPath,
is_dir: check.isDir,
size_bytes: check.size,
actor: actorOfP(principal),
deleted_at: d.ao.ports.now(),
expires_at: expiresAt,
asset_snapshot:
check.assets.length || check.bindings.length ? jsonb({ assets: check.assets, bindings: check.bindings }) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
}
export function toTrashItem(r: TrashItemRow): TrashItem {
return {
id: r.id,
rootId: r.root_id as TrashItem["rootId"],
originalPath: r.original_path,
trashPath: r.trash_path,
isDir: r.is_dir,
size: Number(r.size_bytes),
actor: r.actor,
deletedAt: r.deleted_at.toISOString(),
expiresAt: r.expires_at.toISOString(),
assetSnapshot: (r.asset_snapshot as Record<string, unknown> | null) ?? null,
};
}
export async function listTrash(d: FmDeps, q: FilesTrashQuery): Promise<FilesTrashList> {
let qb = d.ctx.db.selectFrom("trash_item").selectAll();
if (q.root) qb = qb.where("root_id", "=", q.root);
if (q.cursor) {
const [at, id] = Buffer.from(q.cursor, "base64url").toString("utf8").split("|");
if (!at || !id || Number.isNaN(Date.parse(at))) throw new FilesError("bad_request", "cursor 无效");
qb = qb.where(sql<boolean>`(deleted_at, id) < (${at}::timestamptz, ${id}::uuid)`);
}
const rows = await qb
.orderBy("deleted_at", "desc")
.orderBy("id", "desc")
.limit(q.limit + 1)
.execute();
const page = rows.slice(0, q.limit);
const last = page.at(-1);
return {
items: page.map(toTrashItem),
nextCursor:
rows.length > q.limit && last
? Buffer.from(`${last.deleted_at.toISOString()}|${last.id}`, "utf8").toString("base64url")
: null,
};
}
/** 回收站路径必须在某个根的 .umt-trash 或 DATA_DIR/trash 下(清除前的最后一道闸) */
function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean {
const inRoot = within(row.trash_path, join(row.root_host_path, TRASH_DIR));
const inData = within(row.trash_path, join(resolve(d.options.dataDir), "trash"));
return (inRoot || inData) && !row.trash_path.split("/").includes("..");
}
export async function restoreTrash(
d: FmDeps,
principal: Principal | undefined,
body: FilesTrashRestoreRequest,
): Promise<FilesTrashRestoreResult> {
const restored: FilesTrashRestoreResult["restored"] = [];
const failed: FilesTrashRestoreResult["failed"] = [];
const target = body.target ? await resolveIn(d, body.target.root, body.target.path) : null;
if (target && (!target.lst?.isDirectory() || target.isSymlink))
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
for (const id of body.ids) {
try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new FilesError("not_found", "回收站条目不存在");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
let rootId = row.root_id;
let dst: string;
let rootReal: string;
if (target) {
rootId = body.target!.root;
rootReal = target.rootReal;
dst = join(target.abs, basename(row.original_path));
} else {
const def = await rootDef(d.ctx.db, row.root_id);
rootReal = await onlineRoot(d, def);
const parent = await resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal }).catch(
async (e) => {
// 父目录已不存在:在根内重建后再解析
if (e instanceof FilesError && e.reason === "not_found") {
const p = join(rootReal, dirname(row.original_path));
if (!within(resolve(p), rootReal)) throw e;
await mkdir(p, { recursive: true });
return resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal });
}
throw e;
},
);
dst = parent.abs;
}
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择“还原到…”");
const r = await moveSafe(row.trash_path, dst);
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
if (r.status === "exdev") {
const { files } = await sizeOf(row.trash_path);
await copyTree(row.trash_path, dst, files, d.ao.ports.options.verifyHash);
await rm(row.trash_path, { recursive: true });
}
await d.ctx.db.deleteFrom("trash_item").where("id", "=", id).execute();
const rel = dst === rootReal ? "" : dst.slice(rootReal.length + 1);
restored.push({ id, root: rootId as FilesTrashRestoreResult["restored"][number]["root"], path: rel });
filesChanged(d, rootId, [rel.includes("/") ? dirname(rel) : ""]);
} catch (e) {
failed.push({
id,
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"),
message: errMessage(e),
});
}
}
await audit(d, principal, "trash_restore", { restored, failed });
return { restored, failed };
}
/** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */
export async function purgeTrash(
d: FmDeps,
principal: Principal | undefined,
body: Pick<FilesTrashPurgeRequest, "ids">,
): Promise<FilesTrashPurgeResult> {
let q = d.ctx.db.selectFrom("trash_item").selectAll();
q = body.ids ? q.where("id", "in", body.ids) : q.where("expires_at", "<=", d.ao.ports.now());
const rows = await q.limit(1000).execute();
const purged: string[] = [];
const failed: FilesTrashPurgeResult["failed"] = [];
for (const row of rows) {
try {
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
if (!gone) continue;
await rm(row.trash_path, { recursive: true, force: true });
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录
const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1);
await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined);
purged.push(row.id);
} catch (e) {
failed.push({ id: row.id, code: e instanceof FilesError ? e.reason : "io_error", message: errMessage(e) });
}
}
if (purged.length || failed.length)
await audit(d, principal, "trash_purge", { purged, failed, expiredOnly: !body.ids });
return { purged, failed };
}