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