import { open } from "node:fs/promises"; import { basename } from "node:path"; import { BencodeError, type BencodeValue, bdecode, parseTorrent } from "@umt/adapters"; import type { FilePreview, FilesPreviewQuery } from "@umt/contracts"; import type { Principal } from "../../http/auth.ts"; import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts"; import { CUE_LIMIT, decodeText, extOf, IMAGE_MIME, imageSize, maskTracker, parseAss, parseSrtLike, parseSup, previewKindOf, } from "./preview-parse.ts"; // GET /files/preview(FM-2):仅管理员会话;路径守卫同下载(realpath 在根内、符号链接目标也须在根内,打开前复核)。 // 只读文件头:文本/字幕 ≤ 1 MiB、图片头 512 KiB;.torrent 整个读但上限 4 MiB。只回 JSON,不回原始内容。 export const TEXT_PREVIEW_MAX = 1024 * 1024; const IMAGE_HEAD = 512 * 1024; const TORRENT_MAX = 4 * 1024 * 1024; const TORRENT_FILES = 500; async function readHead(abs: string, max: number): Promise<{ buf: Uint8Array; size: number }> { const fh = await open(abs, "r"); try { const st = await fh.stat(); if (!st.isFile()) throw new FilesError("bad_request", "不是普通文件"); const n = Math.min(st.size, max); const buf = new Uint8Array(n); let off = 0; while (off < n) { const { bytesRead } = await fh.read(buf, off, n - off, off); if (!bytesRead) break; off += bytesRead; } return { buf: buf.subarray(0, off), size: st.size }; } finally { await fh.close(); } } const str = (v: BencodeValue | undefined) => (v instanceof Uint8Array ? new TextDecoder().decode(v) : null); function trackersOf(root: { [k: string]: BencodeValue }): string[] { const out: string[] = []; const tiers = root["announce-list"]; if (Array.isArray(tiers)) for (const tier of tiers) for (const t of Array.isArray(tier) ? tier : [tier]) out.push(str(t) ?? ""); out.push(str(root.announce) ?? ""); return [...new Set(out.filter(Boolean).map(maskTracker))]; } export async function previewFile( d: FmDeps, principal: Principal | undefined, q: FilesPreviewQuery, ): Promise { if (!isAdminSession(principal)) throw new FilesError("forbidden", "预览仅限控制台管理员会话"); 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 = previewKindOf(name); const ext = extOf(name); if (!kind) throw new FilesError("forbidden", `不支持预览 .${ext || "无扩展名"} 文件`); const real = await recheck(r.abs, r.rootReal); if (kind === "image") { const { buf, size } = await readHead(real, IMAGE_HEAD); const dim = imageSize(buf); return { type: "image", size, mime: IMAGE_MIME[ext] ?? "application/octet-stream", width: dim?.width ?? null, height: dim?.height ?? null, }; } if (kind === "torrent") { const { buf, size } = await readHead(real, TORRENT_MAX); if (size > TORRENT_MAX) throw new FilesError("too_large", `.torrent 文件 ${size} 字节,超过预览上限 4 MiB`); try { const meta = parseTorrent(buf); const root = bdecode(buf) as { [k: string]: BencodeValue }; const info = root.info as { [k: string]: BencodeValue } | undefined; const created = typeof root["creation date"] === "number" ? root["creation date"] : null; return { type: "torrent", size, name: meta.name, hash: meta.hash, infohashV1: meta.infohashV1, infohashV2: meta.infohashV2, private: meta.private, totalSize: meta.totalSize, fileCount: meta.files.length, files: meta.files.slice(0, TORRENT_FILES).map((f) => ({ path: f.path, length: Math.max(0, f.length) })), trackers: trackersOf(root), comment: str(root["comment.utf-8"]) ?? str(root.comment), createdBy: str(root["created by"]), createdAt: created && created > 0 ? new Date(created * 1000).toISOString() : null, pieceLength: typeof info?.["piece length"] === "number" ? info["piece length"] : null, }; } catch (e) { if (e instanceof BencodeError) throw new FilesError("bad_request", "不是有效的 .torrent 文件"); throw e; } } const { buf, size } = await readHead(real, TEXT_PREVIEW_MAX); const truncated = size > buf.length; if (kind === "subtitle") { if (ext === "sup") { const all = parseSup(buf); return { type: "subtitle", size, format: "sup", encoding: null, cues: all.slice(0, CUE_LIMIT), parsed: all.length, truncated, title: null, }; } const { text, encoding } = decodeText(buf, truncated); const ass = ext === "ass" || ext === "ssa"; const parsed = ass ? parseAss(text) : { cues: parseSrtLike(text), title: null }; const cues = [...parsed.cues].sort((a, b) => a.start - b.start); return { type: "subtitle", size, format: ext as "srt" | "vtt" | "ass" | "ssa", encoding, cues: cues.slice(0, CUE_LIMIT), parsed: cues.length, truncated, title: parsed.title, }; } const { text, encoding } = decodeText(buf, truncated); return { type: "text", size, format: ext, encoding, text, truncated }; }