Files
UHD_Media_Tool/apps/server/src/modules/file-manager/download.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

135 lines
5.4 KiB
TypeScript

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<string, string> = {
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<Response> {
// 路由中间件已按 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<typeof previewKindOf>,
): Promise<Response> {
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",
},
});
}