- 文件管理根多出 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
735 lines
30 KiB
TypeScript
735 lines
30 KiB
TypeScript
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;
|
||
}
|