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,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 };
|
||||
}
|
||||
Reference in new issue
Block a user