- 文件管理根多出 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
135 lines
5.4 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|