Files
UHD_Media_Tool/apps/server/src/modules/file-manager/trash.ts
T
rootandClaude Opus 5.5 9d95076a0e feat(files): 文件管理接入 Alist 存储(浏览、增删改、移动复制、上传下载、回收站)
- 文件管理根多出 st:<存储 id>(对照 MP 存储切换),Alist 媒体库目录照常出现在媒体库组
- 列目录一次 list,refresh 只在用户刷新时带;连不上返回 503 不当成空目录
- 新建目录 / 改名(同名不覆盖)/ 删除进 Alist 上的 .umt-trash 并可还原
- 存储内移动复制走 Alist API,本地 ↔ Alist 走整理同一套执行与账本
- 新增 PUT /files/upload(同名不覆盖,Alist 经服务端转传);下载 / 预览流式转发直链

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 23:52:39 +08:00

735 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<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[] }> {
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<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 (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
}
const downloads = await managedDownloads(d);
const results: Awaited<ReturnType<typeof checkItem>>[] = [];
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<string[] | null> {
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<TrashItemRow> {
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<TrashItemRow> {
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<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("..");
}
/**
* 在根内逐级建目录:每一级先 lstat,已存在的必须是真目录(符号链接一律拒绝,哪怕指向根内),不存在才 mkdir(非递归)。
* 避免 mkdir -p 跟随中间的符号链接在根外建出目录(验收 07 S-5)。
*/
export async function mkdirWithin(rootReal: string, rel: string): Promise<void> {
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/<yyyymmdd>/<uuid>:回收站路径里 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<FilesTrashRestoreResult> {
const restored: FilesTrashRestoreResult["restored"] = [];
const failed: FilesTrashRestoreResult["failed"] = [];
const recFiles: RecordFile[] = [];
const libs = new Set<string | null>();
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<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", "回收站路径异常,拒绝清除");
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录
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<string[]> {
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;
}