feat(files): wide file manager layout and basic previews (FM-2)
- Files page uses a wide layout (route handle layout:"wide"): no max width, full-height directory tree docked next to the sidebar, resizable (200-520px, remembered, double-click resets) and collapsible; mobile keeps the tree in a drawer. - GET /files/preview (admin console session only, same path guards as download): text/NFO with encoding detection (BOM/UTF-16/UTF-8/GB18030/Big5), subtitles first 200 cues (SRT/VTT/ASS/SSA, PGS timings), .torrent file list/trackers (passkey masked)/infohash, image dimensions. Reads at most 1 MiB of text; .torrent capped at 4 MiB. - Download also allows text files, .torrent and avif/gif. - Research notes in docs/design/file-preview-research.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3add4bab41
commit
289e8b297a
20 files changed
+1294
-159
No files matched your search
@@ -0,0 +1,150 @@
|
||||
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<FilePreview> {
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user