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,195 @@
|
||||
import { lstat, mkdir } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { moveSafe } from "@umt/adapters";
|
||||
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
|
||||
import { sanitizeSegment } from "@umt/domain";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
|
||||
import { audit, filesChanged } from "./ledger.ts";
|
||||
import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts";
|
||||
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts";
|
||||
|
||||
// mkdir / rename:同目录改名不覆盖(moveSafe = link+unlink / lstat+rename),执行后 inode 比对;
|
||||
// 已登记资产同事务同步 asset_location,目录命中 path_binding 写修订。
|
||||
|
||||
const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
|
||||
|
||||
function assertCleanName(name: string) {
|
||||
if (sanitizeSegment(name) !== name)
|
||||
throw new FilesError("bad_request", `文件名含非法字符或首尾空白/点,建议:${sanitizeSegment(name)}`);
|
||||
}
|
||||
|
||||
export async function mkdirIn(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
body: FilesMkdirRequest,
|
||||
): Promise<FilesMkdirResult> {
|
||||
const r = await resolveIn(d, body.root, body.path);
|
||||
if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `根 ${r.def.name} 不可写`);
|
||||
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
|
||||
assertCleanName(body.name);
|
||||
await recheck(r.abs, r.rootReal);
|
||||
const abs = join(r.abs, body.name);
|
||||
try {
|
||||
await mkdir(abs);
|
||||
} catch (e) {
|
||||
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在: ${body.name}`);
|
||||
throw new FilesError("bad_request", `创建目录失败: ${errMessage(e)}`);
|
||||
}
|
||||
const rel = relOf(r.rootReal, abs);
|
||||
await audit(d, principal, "mkdir", { root: body.root, path: rel });
|
||||
filesChanged(d, body.root, [r.rel]);
|
||||
return { root: body.root, path: rel };
|
||||
}
|
||||
|
||||
interface Pair {
|
||||
from: string;
|
||||
to: string;
|
||||
fromAbs: string;
|
||||
toAbs: string;
|
||||
role: "main" | "sidecar";
|
||||
}
|
||||
|
||||
export async function renameEntry(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
body: FilesRenameRequest,
|
||||
): Promise<FilesRenameResult> {
|
||||
const r = await resolveIn(d, body.root, body.path);
|
||||
if (r.rel === "") throw new FilesError("bad_request", "不能改名根目录");
|
||||
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 为只读登记(register_only)`);
|
||||
assertCleanName(body.newName);
|
||||
const oldName = basename(r.abs);
|
||||
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
|
||||
const isDir = !!r.lst?.isDirectory() && !r.isSymlink;
|
||||
|
||||
// 纳管下载任务的内容(或目录下有):改名会让下载器找不到文件
|
||||
const hits = downloadsFor(await managedDownloads(d), r.abs);
|
||||
if (hits.length > 0)
|
||||
throw new FilesError(
|
||||
"download_active",
|
||||
`该路径属于纳管下载任务(${hits.map((h) => h.taskId).join(", ")}),改名会让下载器找不到文件;请先完成整理或移交`,
|
||||
);
|
||||
|
||||
const dirAbs = dirname(r.abs);
|
||||
const pRel = parentRel(r.rel);
|
||||
const relIn = (n: string) => (pRel ? `${pRel}/${n}` : n);
|
||||
const pairs: Pair[] = [
|
||||
{ from: r.rel, to: relIn(body.newName), fromAbs: r.abs, toAbs: join(dirAbs, body.newName), role: "main" },
|
||||
];
|
||||
if (!isDir && body.withSidecars && kindOfName(oldName) === "video") {
|
||||
const oldStem = oldName.slice(0, oldName.length - extname(oldName).length);
|
||||
const newStem = body.newName.slice(0, body.newName.length - extname(body.newName).length);
|
||||
for (const n of await sidecarsOf(r.abs)) {
|
||||
const nn = `${newStem}${n.slice(oldStem.length)}`;
|
||||
pairs.push({ from: relIn(n), to: relIn(nn), fromAbs: join(dirAbs, n), toAbs: join(dirAbs, nn), role: "sidecar" });
|
||||
}
|
||||
}
|
||||
// 不覆盖:任一目标已存在 → 409,原文件不动
|
||||
const taken: string[] = [];
|
||||
for (const p of pairs) {
|
||||
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e));
|
||||
if (st) taken.push(p.to);
|
||||
}
|
||||
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖: ${taken.join(", ")}`);
|
||||
|
||||
const lib = r.def.libraryRootId;
|
||||
let locCount = 0;
|
||||
let bindingIds: string[] = [];
|
||||
if (lib) {
|
||||
for (const p of pairs) {
|
||||
const pv = await relocatePreview(d.ctx.db, lib, p.from, isDir && p.role === "main");
|
||||
locCount += pv.locations;
|
||||
bindingIds = [...bindingIds, ...pv.bindingIds];
|
||||
}
|
||||
}
|
||||
const renames = pairs.map((p) => ({ from: p.from, to: p.to, role: p.role }));
|
||||
if (body.dryRun)
|
||||
return {
|
||||
dryRun: true,
|
||||
renames,
|
||||
assetLocations: locCount,
|
||||
bindings: bindingIds.map((id) => ({ oldBindingId: id, newBindingId: null })),
|
||||
recordId: null,
|
||||
};
|
||||
|
||||
// 执行:逐个 no-clobber 改名;任一失败回滚已改的
|
||||
const done: Pair[] = [];
|
||||
const rollback = async () => {
|
||||
for (const p of done.reverse()) await moveSafe(p.toAbs, p.fromAbs).catch(() => undefined);
|
||||
};
|
||||
for (const p of pairs) {
|
||||
try {
|
||||
await recheck(p.fromAbs, r.rootReal);
|
||||
const before = await lstat(p.fromAbs);
|
||||
const res = await moveSafe(p.fromAbs, p.toAbs);
|
||||
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖: ${p.to}`);
|
||||
if (res.status !== "moved") throw new FilesError("bad_request", `改名失败(${res.status}): ${p.from}`);
|
||||
const after = await lstat(p.toAbs);
|
||||
if (after.ino !== before.ino || after.dev !== before.dev)
|
||||
throw new FilesError("bad_request", `改名后 inode 不一致,已中止: ${p.to}`);
|
||||
done.push(p);
|
||||
} catch (e) {
|
||||
await rollback();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
let recordId: string | null = null;
|
||||
const bindings: FilesRenameResult["bindings"] = [];
|
||||
let synced = 0;
|
||||
if (lib) {
|
||||
try {
|
||||
await d.ctx.db.transaction().execute(async (trx) => {
|
||||
const files: RecordFile[] = [];
|
||||
const entities = new Set<string>();
|
||||
for (const p of pairs) {
|
||||
const rr = await relocate(trx, {
|
||||
fromLib: lib,
|
||||
fromRel: p.from,
|
||||
toLib: lib,
|
||||
toRel: p.to,
|
||||
isDir: isDir && p.role === "main",
|
||||
actor: actorOfP(principal),
|
||||
opKind: "rename",
|
||||
});
|
||||
synced += rr.locations.length;
|
||||
bindings.push(...rr.bindings);
|
||||
for (const e of rr.mediaEntityIds) entities.add(e);
|
||||
if (rr.locations.length > 0 || rr.bindings.length > 0)
|
||||
files.push({
|
||||
src: p.from,
|
||||
dst: p.to,
|
||||
size: 0,
|
||||
status: "succeeded",
|
||||
role: p.role === "main" ? "main" : "other",
|
||||
});
|
||||
}
|
||||
if (files.length > 0)
|
||||
recordId = await writeRecord(d, trx, {
|
||||
principal,
|
||||
action: "rename",
|
||||
mediaEntityId: entities.size === 1 ? [...entities][0]! : null,
|
||||
sourcePath: r.abs,
|
||||
targetLibraryRootId: lib,
|
||||
targetPath: pairs[0]!.to,
|
||||
plan: { op: "rename", renames, bindings },
|
||||
files,
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
// 数据库同步失败:文件改回原名,保持"路径变更与登记同事务"
|
||||
await rollback();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await audit(d, principal, "rename", {
|
||||
root: body.root,
|
||||
renames,
|
||||
assetLocations: synced,
|
||||
bindings,
|
||||
recordId,
|
||||
});
|
||||
filesChanged(d, body.root, [pRel]);
|
||||
return { dryRun: false, renames, assetLocations: synced, bindings, recordId };
|
||||
}
|
||||
Reference in new issue
Block a user