import { basename, extname } from "node:path"; import type { FilesDownloadQuery } from "@umt/contracts"; import type { Principal } from "../../http/auth.ts"; import { remote, remoteError } from "../../storage/vfs.ts"; import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; import { audit } from "./ledger.ts"; import { kindOfName } from "./list.ts"; import { previewKindOf } from "./preview-parse.ts"; // GET /files/download(W5-A2):仅管理员会话;只下载 NFO/字幕/图片小文件(视频不经浏览器,规格 §1 边界); // 路径守卫同其它接口(realpath 在根内、符号链接目标也须在根内,打开前再复核一次); // 打开后按文件句柄再 stat,校验的就是要流出去的那个文件;流式返回,写审计。 const ALLOWED = new Set(["nfo", "subtitle", "image"]); const MIME: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", avif: "image/avif", torrent: "application/x-bittorrent", 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 { // 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御) if (!isAdminSession(principal)) throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV 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); // FM-2:可预览的文本类(txt/log/md/json…)、.torrent、avif/gif 也允许下载 const pk = previewKindOf(name); if (!ALLOWED.has(kind) && !pk) throw new FilesError("forbidden", `只能下载 NFO、字幕、图片、文本和 .torrent,此文件类型为 ${kind}`); const real = await recheck(r.abs, r.rootReal); if (r.def.storageId) return downloadRemote(d, principal, q, r.rel, real, name, kind, pk); 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" || pk === "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", }, }); } /** Alist 文件:经服务端流式转发(不落本地盘,同一下载上限与类型限制;MP 是先下到临时目录再回传) */ async function downloadRemote( d: FmDeps, principal: Principal | undefined, q: FilesDownloadQuery, rel: string, abs: string, name: string, kind: string, pk: ReturnType, ): Promise { const { v, client } = await remote(abs); let size: number; let res: Response; try { const o = await client.get(v.remote); if (o.isDir) throw new FilesError("bad_request", "只能下载文件,不能下载目录"); size = o.size; if (size > d.options.downloadMaxBytes) throw new FilesError( "too_large", `文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`, ); res = await client.openRead(v.remote); } catch (e) { if (e instanceof FilesError) throw e; throw remoteError(e, abs); } const ext = extname(name).replace(/^\./, "").toLowerCase(); const inline = q.inline && (kind === "image" || pk === "image") && ext !== "svg"; await audit(d, principal, "download", { root: q.root, path: rel, size, inline }); return new Response(res.body, { status: 200, headers: { "content-type": (ext !== "svg" && MIME[ext]) || "application/octet-stream", ...(size ? { "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", }, }); }