feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download

- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

@@ -0,0 +1,82 @@
import { basename, extname } from "node:path";
import type { FilesDownloadQuery } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { FilesError, type FmDeps, isAdmin, recheck, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts";
import { kindOfName } from "./list.ts";
// GET /files/download(W5-A2):仅管理员会话;只下载 NFO/字幕/图片小文件(视频不经浏览器,规格 §1 边界);
// 路径守卫同其它接口(realpath 在根内、符号链接目标也须在根内,打开前再复核一次);
// 打开后按文件句柄再 stat,校验的就是要流出去的那个文件;流式返回,写审计。
const ALLOWED = new Set(["nfo", "subtitle", "image"]);
const MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
avif: "image/avif",
nfo: "text/plain; charset=utf-8",
xml: "text/plain; charset=utf-8",
srt: "text/plain; charset=utf-8",
ass: "text/plain; charset=utf-8",
ssa: "text/plain; charset=utf-8",
vtt: "text/plain; charset=utf-8",
sub: "text/plain; charset=utf-8",
sup: "application/octet-stream",
idx: "text/plain; charset=utf-8",
};
/** RFC 6266 / 5987:ASCII 兜底名 + UTF-8 原名 */
function disposition(kind: "inline" | "attachment", name: string): string {
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
}
export async function downloadFile(
d: FmDeps,
principal: Principal | undefined,
q: FilesDownloadQuery,
): Promise<Response> {
if (!isAdmin(principal)) throw new FilesError("forbidden", "下载只允许控制台管理员会话(API Key 不可用)");
const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs);
const kind = kindOfName(name);
if (!ALLOWED.has(kind))
throw new FilesError("forbidden", `只允许下载 NFO/字幕/图片(${kind}),视频等大文件不经浏览器下载`);
const real = await recheck(r.abs, r.rootReal);
const file = await Deno.open(real, { read: true });
let size: number;
try {
const st = await file.stat();
if (!st.isFile) throw new FilesError("bad_request", "不是普通文件");
size = st.size;
if (size > d.options.downloadMaxBytes)
throw new FilesError(
"too_large",
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
);
} catch (e) {
file.close();
throw e;
}
const ext = extname(name).replace(/^\./, "").toLowerCase();
// SVG 可带脚本:不内联、不给 image/svg+xml
const inline = q.inline && kind === "image" && ext !== "svg";
await audit(d, principal, "download", { root: q.root, path: r.rel, size, inline });
return new Response(file.readable, {
status: 200,
headers: {
"content-type": (ext !== "svg" && MIME[ext]) || "application/octet-stream",
"content-length": String(size),
"content-disposition": disposition(inline ? "inline" : "attachment", name),
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'; sandbox",
"cache-control": "private, no-store",
},
});
}