import { randomUUID } from "node:crypto"; import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { 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 { isRemotePath, vlstat, vmkdirp, vreaddirStats, vremove, vrenameNoClobber } from "../../storage/vfs.ts"; import { parseVPath, toVPath } from "../../storage/vpath.ts"; import { upsertDecision } from "../acquisition-organize/services/common.ts"; import { fsRuntime } from "../acquisition-organize/services/organize.ts"; import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, hashJson, isAdmin, isAdminSession, jsonb, onlineRoot, recheck, resolveIn, rootDef, within, } from "./common.ts"; import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; import { bindingsAt, downloadsFor, isDownloading, isWritableTarget, managedDownloads } from "./list.ts"; import { type RecordFile, writeRecord } from "./records.ts"; // 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash///<原相对路径>,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[] }> { if (isRemotePath(abs)) return remoteSizeOf(abs, cap); 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 }; } /** 远端:按目录 list(每个子目录一次请求) */ async function remoteSizeOf(abs: string, cap: number): Promise<{ size: number; files: string[] }> { const st = await vlstat(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 it of await vreaddirStats(dir)) { if (files.length >= cap) return; const rel = prefix ? `${prefix}/${it.name}` : it.name; if (it.stats.isDirectory()) await walk(join(dir, it.name), rel); else { size += Number(it.stats.size); files.push(rel); } } }; await walk(abs, ""); return { size, files }; } /** * 回收站所在目录:本地 = <根>/.umt-trash;远端同布局,用 Alist 同驱动 move(立即完成)。 * 远端根是 Alist 的 "/"(各挂载的上一级,不能建目录)时放进该项所在挂载的 .umt-trash。 */ export function trashBase(rootReal: string, abs: string): string { const v = parseVPath(rootReal); if (v?.remote !== "/") return rootReal; const a = parseVPath(abs); const mount = a?.remote.split("/").filter(Boolean)[0]; return mount ? toVPath(v.storageId, `/${mount}`) : rootReal; } 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>, ): 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>; 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, [ConstructorParameters[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 { if (body.mode === "permanent") { if (!d.options.allowPermanentDelete) throw new FilesError("permanent_disabled", "未开启永久删除(FILES_ALLOW_PERMANENT_DELETE)。改用回收站"); if (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话"); } const downloads = await managedDownloads(d); const results: Awaited>[] = []; 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 = []; // 整理记录的文件级结果(删除/回收站也进整理记录页可追溯) const recFiles: RecordFile[] = []; for (const res of results) { const { check, ctx } = res; const abs = ctx.abs!; const inner = check.isDir ? await filesUnder(abs) : null; try { await recheck(abs, ctx.rootReal!); let where: string | null = null; if (body.mode === "permanent") { if (isRemotePath(abs)) await vremove(abs); else 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)); where = row.trash_path; } recFiles.push(...recordFilesOf(abs, where, check.size, inner, "succeeded")); done.push(res); } catch (e) { const f = { path: check.path, code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"), message: errMessage(e), }; failed.push(f); recFiles.push({ src: abs, dst: null, size: check.size, status: "failed", error: `${f.code}: ${f.message}` }); } } // 资产退役 / 解除绑定(只对已实际删除/移入回收站的项)+ 整理记录 const recordId = 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: `收录版本的文件已删除:${s.name}`, summary: "满足该收录版本的文件已在文件管理中删除,收录版本改为需关注。确认是否重新获取", 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(); if (recFiles.length === 0) return null; const libs = [...new Set(results.map((r) => r.ctx.libraryRootId))]; const entities = [...new Set(done.flatMap((r) => r.check.assets.map((a) => a.mediaEntityId)))]; return writeRecord(d, trx, { principal, action: body.mode === "permanent" ? "delete" : "trash", mediaEntityId: entities.length === 1 ? entities[0]! : null, sourcePath: results.length === 1 ? results[0]!.ctx.abs! : dirname(results[0]!.ctx.abs!), targetLibraryRootId: null, targetPath: null, plan: { op: "delete", mode: body.mode, planId, libraryRootId: libs.length === 1 ? libs[0] : null, items: checks.map((c) => ({ root: c.root, path: c.path, isDir: c.isDir, size: c.size })), trashed: trashed.map((t) => ({ id: t.id, trashPath: t.trashPath })), retiredLocations: done.flatMap((r) => r.check.assets.map((a) => a.locationId)), unboundBindings: bindIds, }, files: recFiles, }); }); 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, recordId, }); 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(整理记录只记目录本身一行) */ const RECORD_FILES_MAX = 2000; async function filesUnder(abs: string): Promise { const { files } = await sizeOf(abs, RECORD_FILES_MAX + 1).catch(() => ({ files: [] as string[] })); return files.length > RECORD_FILES_MAX ? null : files; } function recordFilesOf( abs: string, where: string | null, size: number, inner: string[] | null, status: RecordFile["status"], ): RecordFile[] { if (!inner || inner.length === 0) return [{ src: abs, dst: where, size, status }]; return inner.map((f) => ({ src: join(abs, f), dst: where ? join(where, f) : null, size: 0, status })); } /** 跨卷复制(进回收站 / 从回收站还原):逐文件经 fsproxy(挂死隔离:停滞超时 → SIGKILL → TIMEOUT),复制后核验 */ async function copyTree(d: FmDeps, src: string, dst: string, files: string[]) { const proxy = fsRuntime(d.ao).proxy; const hash = d.ao.ports.options.verifyHash; const st = await lstat(src); if (!st.isDirectory()) { await proxy.copy(src, dst, { hash, createParents: true }); return; } await mkdir(dst, { recursive: true }); for (const f of files) await proxy.copy(join(src, f), join(dst, f), { hash, createParents: true }); } async function moveToTrash( d: FmDeps, principal: Principal | undefined, check: FileDeleteCheck, ctx: CheckCtx, expiresAt: Date, ): Promise { const abs = ctx.abs!; const id = randomUUID(); const stamp = day(d.ao.ports.now()); if (isRemotePath(abs)) { // Alist:同存储 move 进 <根>/.umt-trash(MP 无回收站;Alist 驱动自己的回收站不依赖) const base = trashBase(ctx.rootReal!, abs); const rel = base === ctx.rootReal ? check.path : relOfRoot(base, abs); const trashPath = join(base, TRASH_DIR, stamp, id, rel); await vmkdirp(dirname(trashPath)); const r = await vrenameNoClobber(abs, trashPath); if (r.status !== "moved") throw new FilesError("exists", `移入回收站失败(${r.status}):${trashPath}`); return insertTrashRow(d, principal, check, ctx, { id, rootHost: base, trashPath, expiresAt }); } 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(d, abs, trashPath, files); await rm(abs, { recursive: true }); } return insertTrashRow(d, principal, check, ctx, { id, rootHost: ctx.rootReal!, trashPath, expiresAt }); } function insertTrashRow( d: FmDeps, principal: Principal | undefined, check: FileDeleteCheck, ctx: CheckCtx, x: { id: string; rootHost: string; trashPath: string; expiresAt: Date }, ): Promise { const { id, trashPath, expiresAt } = x; return d.ctx.db .insertInto("trash_item") .values({ id, root_id: check.root, library_root_id: ctx.libraryRootId, root_host_path: x.rootHost, 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 | null) ?? null, }; } export async function listTrash(d: FmDeps, q: FilesTrashQuery): Promise { 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`(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(".."); } /** * 在根内逐级建目录:每一级先 lstat,已存在的必须是真目录(符号链接一律拒绝,哪怕指向根内),不存在才 mkdir(非递归)。 * 避免 mkdir -p 跟随中间的符号链接在根外建出目录(验收 07 S-5)。 */ export async function mkdirWithin(rootReal: string, rel: string): Promise { if (rel === "" || rel === ".") return; if (isRemotePath(rootReal)) { if (rel.split("/").includes("..")) throw new FilesError("path_escape", `路径含 ..: ${rel}`); await vmkdirp(join(rootReal, rel)); return; } let cur = rootReal; for (const seg of rel.split("/")) { if (seg === "" || seg === ".") continue; if (seg === "..") throw new FilesError("path_escape", `路径含 ..: ${rel}`); cur = join(cur, seg); const st = await lstat(cur).catch((e) => { if (errnoOf(e) === "ENOENT") return null; throw e; }); if (st?.isSymbolicLink()) throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`); if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`); if (!st) await mkdir(cur); if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外:${rel}`); } } const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1)); const sameStorageOf = (a: string, b: string) => parseVPath(a)?.storageId === parseVPath(b)?.storageId; /** <…>/.umt-trash//:回收站路径里 uuid 那一级 */ function trashUuidDir(row: TrashItemRow): string | null { const segs = row.trash_path.split("/"); const i = segs.indexOf(row.id); return i > 0 ? segs.slice(0, i + 1).join("/") : null; } export async function restoreTrash( d: FmDeps, principal: Principal | undefined, body: FilesTrashRestoreRequest, ): Promise { const restored: FilesTrashRestoreResult["restored"] = []; const failed: FilesTrashRestoreResult["failed"] = []; const recFiles: RecordFile[] = []; const libs = new Set(); 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", "还原目标必须是已存在的目录"); // "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5) if (target && !(await isWritableTarget(target.def, target.rootReal))) throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 可写的媒体库目录`); 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) => { // 父目录已不存在:在根内逐级重建(每级先 lstat,已存在的必须是真目录,不跟随符号链接)后再解析 if (e instanceof FilesError && e.reason === "not_found") { await mkdirWithin(rootReal, dirname(row.original_path)); return resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal }); } throw e; }, ); dst = parent.abs; } if (await (isRemotePath(dst) ? vlstat(dst) : lstat(dst)).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」"); if ( isRemotePath(dst) !== isRemotePath(row.trash_path) || (isRemotePath(dst) && !sameStorageOf(dst, row.trash_path)) ) throw new FilesError("bad_request", "回收站里的项只能还原到它原来所在的存储"); const r = await vrenameNoClobber(row.trash_path, dst); if (r.status === "exists") throw new FilesError("exists", "原位置已被占用"); if (r.status === "exdev") { // 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本 const { size, files } = await sizeOf(row.trash_path); const bytes = Math.max(size, Number(row.size_bytes)); if (bytes > d.options.trashCrossMaxBytes) throw new FilesError( "too_large", `跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`, ); await copyTree(d, row.trash_path, dst, files); 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); libs.add(target ? target.def.libraryRootId : row.library_root_id); recFiles.push({ src: row.trash_path, dst: rel, size: Number(row.size_bytes), status: "succeeded" }); restored.push({ id, root: rootId as FilesTrashRestoreResult["restored"][number]["root"], path: rel }); filesChanged(d, rootId, [rel.includes("/") ? dirname(rel) : ""]); } catch (e) { const f = { id, code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"), message: errMessage(e) }; failed.push(f); recFiles.push({ src: `trash:${id}`, dst: null, size: 0, status: "failed", error: `${f.code}: ${f.message}` }); } } const lib = libs.size === 1 ? [...libs][0]! : null; const recordId = restored.length > 0 ? await d.ctx.db.transaction().execute((trx) => writeRecord(d, trx, { principal, action: "restore", mediaEntityId: null, sourcePath: recFiles.find((f) => f.status === "succeeded")!.src, targetLibraryRootId: lib, targetPath: restored.length === 1 ? restored[0]!.path : null, plan: { op: "trash_restore", ids: body.ids, target: body.target ?? null, restored }, files: recFiles, }), ) : null; await audit(d, principal, "trash_restore", { restored, failed, recordId }); return { restored, failed, recordId }; } /** * 清除:先删文件再删行(删文件失败时行留着,下次再清,不留没有登记的孤儿文件);ids 缺省 = 只清过期项, * 并顺带清掉各库根 `.umt-trash/replaced-*` 里过期、没有回收站登记的旧文件(替换旧文件登记回收站失败时留下的)。 */ export async function purgeTrash( d: FmDeps, principal: Principal | undefined, body: Pick, ): Promise { 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 uuidDir = trashUuidDir(row); if (isRemotePath(row.trash_path)) { await vremove(row.trash_path).catch((e) => { if (errnoOf(e) !== "ENOENT") throw e; }); if (uuidDir) await vremove(uuidDir).catch(() => undefined); } else { await rm(row.trash_path, { recursive: true, force: true }); if (uuidDir) await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined); } const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst(); if (gone) purged.push(row.id); } catch (e) { failed.push({ id: row.id, code: e instanceof FilesError ? e.reason : "io_error", message: errMessage(e) }); } } const orphans = body.ids ? [] : await purgeReplacedOrphans(d); if (purged.length || failed.length || orphans.length) await audit(d, principal, "trash_purge", { purged, failed, orphans, expiredOnly: !body.ids }); return { purged, failed }; } /** 各启用库根 `.umt-trash/replaced-*`:修改时间早于保留期、里面没有任何回收站登记的,整个删掉;返回删掉的目录 */ async function purgeReplacedOrphans(d: FmDeps): Promise { const out: string[] = []; const cutoff = d.ao.ports.now().getTime() - d.options.trashRetentionDays * 86_400_000; const roots = await d.ctx.db .selectFrom("library_root") .select(["local_path"]) .where("enabled", "=", true) .where("management_mode", "=", "managed") .execute(); for (const r of roots) { const rootReal = await realpath(r.local_path).catch(() => null); if (!rootReal) continue; const bin = join(rootReal, TRASH_DIR); for (const name of await readdir(bin).catch(() => [] as string[])) { if (!name.startsWith("replaced-")) continue; const dir = join(bin, name); const st = await lstat(dir).catch(() => null); if (!st?.isDirectory() || st.mtimeMs > cutoff) continue; const held = await d.ctx.db .selectFrom("trash_item") .select("id") .where("trash_path", "like", likePrefix(dir)) .executeTakeFirst(); if (held) continue; await rm(dir, { recursive: true, force: true }) .then(() => out.push(dir)) .catch(() => undefined); } } return out; }