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,303 @@
import { lstat, mkdir, readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { copyWithVerify, crossDeviceMove, moveSafe } from "@umt/adapters";
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { authorizeMove } from "../acquisition-organize/services/organize-guard.ts";
import {
actorOfP,
errMessage,
FilesError,
type FmDeps,
hashJson,
isAdmin,
type Resolved,
recheck,
relOf,
resolveIn,
within,
} from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { downloadsFor, isWritableTarget, managedDownloads } from "./list.ts";
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts";
// 移动/复制到目录:dryRun 冻结源事实(文件 size/mtime/inode;目录递归文件清单指纹)→ planId;
// 执行校验 planId(未过期、请求一致)与源事实未变,否则 409 plan_stale。
// 搬运原语与整理流程相同(adapters moveSafe / crossDeviceMove / copyWithVerify:no-clobber、跨卷 Copy→核验→删源、EXDEV 不降级)。
interface SourceSnap {
root: string;
rel: string;
abs: string;
isDir: boolean;
size: number;
fingerprint: string;
files: { rel: string; size: number }[];
truncated: boolean;
}
async function snapshot(d: FmDeps, r: Resolved, rootId: string): Promise<SourceSnap> {
const st = await lstat(r.abs);
if (!st.isDirectory()) {
const f = { size: Number(st.size), mtimeMs: Number(st.mtimeMs), ino: Number(st.ino) };
return {
root: rootId,
rel: r.rel,
abs: r.abs,
isDir: false,
size: f.size,
fingerprint: hashJson(f),
files: [{ rel: "", size: f.size }],
truncated: false,
};
}
const max = d.ao.ports.options.walkMaxFiles;
const files: { rel: string; size: number; mtimeMs: number }[] = [];
let truncated = false;
const walk = async (dir: string, prefix: string): Promise<void> => {
const names = (await readdir(dir)).sort();
for (const n of names) {
if (files.length >= max) {
truncated = true;
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 if (s.isFile()) files.push({ rel, size: Number(s.size), mtimeMs: Number(s.mtimeMs) });
}
};
await walk(r.abs, "");
return {
root: rootId,
rel: r.rel,
abs: r.abs,
isDir: true,
size: files.reduce((a, f) => a + f.size, 0),
fingerprint: hashJson(files),
files: files.map(({ rel, size }) => ({ rel, size })),
truncated,
};
}
export async function transfer(
d: FmDeps,
principal: Principal | undefined,
body: FilesMoveRequest,
): Promise<FilesTransferResult> {
const mode = body.mode;
const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写(仅 managed 库根可作为目标)`);
const sources: Resolved[] = [];
for (const it of body.items) {
const s = await resolveIn(d, it.root, it.path);
if (s.rel === "") throw new FilesError("bad_request", "不能移动/复制根目录本身");
if (mode === "move" && !s.def.modifiable)
throw new FilesError("not_writable", `源根 ${s.def.name} 为只读登记,不能移动(可复制)`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内: ${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中: ${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不移动符号链接本身: ${s.rel}`);
sources.push(s);
}
const names = sources.map((s) => basename(s.abs));
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
const snaps = await Promise.all(sources.map((s, i) => snapshot(d, s, body.items[i]!.root)));
const downloads = await managedDownloads(d);
const hnrBlocks: FilesTransferResult["hnrBlocks"] = [];
if (mode === "move")
for (const s of sources)
for (const h of downloadsFor(downloads, s.abs))
hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status });
const targetDev = Number((await stat(t.abs)).dev);
const pairs: FilePlanPair[] = [];
let needBytes = 0;
for (const [i, s] of sources.entries()) {
const dst = join(t.abs, names[i]!);
const conflict = !!(await lstat(dst).catch(() => null));
const registered = s.def.libraryRootId
? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0
: false;
pairs.push({
source: { root: body.items[i]!.root, path: s.rel },
target: { root: body.target.root, path: relOf(t.rootReal, dst) },
size: snaps[i]!.size,
conflict,
registered,
});
const sameDev = Number((await lstat(s.abs)).dev) === targetDev;
if (!(mode === "move" && sameDev)) needBytes += snaps[i]!.size;
}
const du = await d.ao.ports.diskUsage(t.abs).catch(() => null);
const freeBytes = du?.status === "ok" ? du.availableBytes : null;
const spaceOk = freeBytes === null ? true : needBytes <= freeBytes;
const truncated = snaps.some((s) => s.truncated);
const totalBytes = snaps.reduce((a, s) => a + s.size, 0);
const requestHash = hashJson({ items: body.items, target: body.target, mode });
const request = {
sourcePaths: sources.map((s) => s.abs),
action: mode,
targetLibraryRootId: t.def.libraryRootId ?? undefined,
};
if (body.dryRun) {
const plan = await savePlan(d, "transfer", requestHash, principal, {
request,
sources: snaps.map((s) => ({ abs: s.abs, fingerprint: s.fingerprint })),
pairs,
});
return {
dryRun: true,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
mode,
pairs,
totalBytes,
freeBytes,
spaceOk,
hnrBlocks,
truncated,
executed: null,
};
}
// ── 执行 ──
if (!body.planId) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planId");
const plan = await loadPlan<{ sources: { abs: string; fingerprint: string }[] }>(
d,
body.planId,
"transfer",
requestHash,
);
for (const [i, s] of snaps.entries()) {
const frozen = plan.content.sources[i];
if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint)
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`);
}
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,请分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
if (mode === "move") {
if (hnrBlocks.length > 0)
throw new FilesError(
"download_active",
`源属于纳管下载任务(${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")}),裸移动会让下载器丢失文件;请用整理入库(Move 会按 H&R 判定并移交)`,
);
if (!isAdmin(principal)) {
const entity = await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["a.media_entity_id"])
.where("l.library_root_id", "=", sources[0]!.def.libraryRootId ?? "00000000-0000-0000-0000-000000000000")
.where("l.relative_path", "=", sources[0]!.rel)
.executeTakeFirst();
const auth = await authorizeMove(d.ao, principal, request, entity?.media_entity_id ?? null);
if (!auth.allowed) {
await audit(d, principal, "move_denied", { reason: auth.reason, planId: plan.id });
throw new FilesError("approval_required", auth.reason);
}
}
}
await retirePlan(d, plan.id);
const hash = d.ao.ports.options.verifyHash;
const succeeded: string[] = [];
const failed: { path: string; code: string; message: string }[] = [];
const recFiles: RecordFile[] = [];
const moved: { s: Resolved; dstRel: string; isDir: boolean }[] = [];
for (const [i, s] of sources.entries()) {
const pair = pairs[i]!;
const dst = join(t.abs, names[i]!);
const fail = (code: string, message: string) => {
failed.push({ path: s.rel, code, message });
recFiles.push({ src: s.abs, dst: pair.target.path, size: pair.size, status: "failed", error: message });
};
try {
await recheck(s.abs, s.rootReal);
if (await lstat(dst).catch(() => null)) {
fail("exists", "目标已存在,不覆盖");
continue;
}
const isDir = snaps[i]!.isDir;
if (mode === "move") {
const r = await moveSafe(s.abs, dst);
if (r.status === "exists") {
fail("exists", "目标已存在,不覆盖");
continue;
}
if (r.status === "exdev") {
if (isDir) {
fail("exdev_dir", "跨卷移动目录暂不支持(W5),请先复制再删除,或逐个文件移动");
continue;
}
const x = await crossDeviceMove(s.abs, dst, { hash });
if (x.status !== "completed") {
fail(x.status, `跨卷移动未完成(${x.status}):${JSON.stringify(x.steps.at(-1) ?? {})}`);
continue;
}
}
moved.push({ s, dstRel: pair.target.path, isDir });
} else if (isDir) {
for (const f of snaps[i]!.files) {
await copyWithVerify(join(s.abs, f.rel), join(dst, f.rel), { hash, createParents: true });
}
await mkdir(dst, { recursive: true });
} else {
await copyWithVerify(s.abs, dst, { hash });
}
succeeded.push(s.rel);
recFiles.push({ src: s.abs, dst: pair.target.path, size: pair.size, status: "succeeded" });
} catch (e) {
fail(e instanceof FilesError ? e.reason : "io_error", errMessage(e));
}
}
const recordId = await d.ctx.db.transaction().execute(async (trx) => {
const entities = new Set<string>();
if (mode === "move" && t.def.libraryRootId) {
for (const m of moved) {
if (!m.s.def.libraryRootId) continue;
const rr = await relocate(trx, {
fromLib: m.s.def.libraryRootId,
fromRel: m.s.rel,
toLib: t.def.libraryRootId,
toRel: m.dstRel,
isDir: m.isDir,
actor: actorOfP(principal),
opKind: "move",
});
for (const e of rr.mediaEntityIds) entities.add(e);
}
}
return writeRecord(d, trx, {
principal,
action: mode === "move" ? "relocate" : "copy",
mediaEntityId: entities.size === 1 ? [...entities][0]! : null,
sourcePath: sources.length === 1 ? sources[0]!.abs : dirname(sources[0]!.abs),
targetLibraryRootId: t.def.libraryRootId,
targetPath: t.rel,
plan: { op: "transfer", mode, planId: plan.id, pairs },
files: recFiles,
});
});
await audit(d, principal, mode, { planId: plan.id, succeeded, failed, recordId, target: body.target });
for (const it of body.items) filesChanged(d, it.root, [it.path.includes("/") ? dirname(it.path) : ""]);
filesChanged(d, body.target.root, [t.rel]);
return {
dryRun: false,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
mode,
pairs,
totalBytes,
freeBytes,
spaceOk,
hnrBlocks,
truncated,
executed: { succeeded, failed, recordId },
};
}