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:
rootandClaude Opus 5.5 committed 2026-09-29 21:44:13 +08:00
1 parent 3add4bab41
commit 289e8b297a
20 files changed
+1294 -159

No files matched your search

@@ -4,6 +4,7 @@ import type { Principal } from "../../http/auth.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 在根内、符号链接目标也须在根内,打开前再复核一次);
@@ -19,6 +20,7 @@ const MIME: Record<string, string> = {
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",
@@ -48,7 +50,10 @@ export async function downloadFile(
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs);
const kind = kindOfName(name);
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
// 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);
const file = await Deno.open(real, { read: true });
let size: number;
@@ -67,7 +72,7 @@ export async function downloadFile(
}
const ext = extname(name).replace(/^\./, "").toLowerCase();
// SVG 可带脚本:不内联、不给 image/svg+xml
const inline = q.inline && kind === "image" && ext !== "svg";
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,
@@ -0,0 +1,281 @@
import type { SubtitleCue } from "@umt/contracts";
// 预览用的纯解析函数(FM-2):编码探测、字幕(SRT/VTT/ASS/SSA/PGS)、图片尺寸。
// 输入都是已按上限截取的字节,不做 IO;非法输入一律返回空结果而不是抛错。
export type PreviewKind = "text" | "subtitle" | "torrent" | "image";
const EXT_PREVIEW: Record<string, PreviewKind> = {};
for (const e of "nfo txt log md markdown json xml yaml yml ini conf cfg csv tsv cue sfv md5 sha1 sha256 url".split(" "))
EXT_PREVIEW[e] = "text";
for (const e of "srt vtt ass ssa sup".split(" ")) EXT_PREVIEW[e] = "subtitle";
for (const e of "jpg jpeg png webp avif gif bmp".split(" ")) EXT_PREVIEW[e] = "image";
EXT_PREVIEW.torrent = "torrent";
export const extOf = (name: string) => /\.([A-Za-z0-9]{1,8})$/.exec(name)?.[1]?.toLowerCase() ?? "";
export const previewKindOf = (name: string): PreviewKind | null => EXT_PREVIEW[extOf(name)] ?? null;
// ── 编码探测 ──
function tryDecode(label: string, buf: Uint8Array): string | null {
try {
return new TextDecoder(label, { fatal: true, ignoreBOM: true }).decode(buf);
} catch {
return null;
}
}
/** 截断读取时末尾可能落在多字节字符中间:最多去掉 3 个字节再试 */
function tryDecodeTail(label: string, buf: Uint8Array, truncated: boolean): string | null {
const max = truncated ? 3 : 0;
for (let cut = 0; cut <= max && cut <= buf.length; cut++) {
const t = tryDecode(label, cut ? buf.subarray(0, buf.length - cut) : buf);
if (t !== null) return t;
}
return null;
}
/** 双字节编码的"像不像":常用汉字越多越像,生僻区/私用区/控制字符扣分 */
function cjkScore(s: string): number {
let score = 0;
for (const ch of s) {
const c = ch.codePointAt(0) ?? 0;
if (c >= 0x4e00 && c <= 0x9fa5) score += 1;
else if (c >= 0x3000 && c <= 0x303f) score += 1;
else if (c >= 0xff00 && c <= 0xffef) score += 0.5;
else if ((c >= 0x3400 && c <= 0x4dbf) || (c >= 0xe000 && c <= 0xf8ff) || c > 0xffff) score -= 3;
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) score -= 3;
}
return score;
}
function utf16Guess(buf: Uint8Array): "utf-16le" | "utf-16be" | null {
const n = Math.min(buf.length, 4096) & ~1;
if (n < 4) return null;
let evenZero = 0;
let oddZero = 0;
for (let i = 0; i < n; i += 2) {
if (buf[i] === 0) evenZero++;
if (buf[i + 1] === 0) oddZero++;
}
const pairs = n / 2;
if (oddZero > pairs * 0.3 && evenZero < pairs * 0.05) return "utf-16le";
if (evenZero > pairs * 0.3 && oddZero < pairs * 0.05) return "utf-16be";
return null;
}
/** 按 BOM → UTF-16 零字节分布 → UTF-8 → GB18030/Big5(择优)→ windows-1252 顺序探测 */
export function decodeText(buf: Uint8Array, truncated = false): { text: string; encoding: string } {
if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf)
return { text: new TextDecoder("utf-8").decode(buf.subarray(3)), encoding: "utf-8" };
if (buf[0] === 0xff && buf[1] === 0xfe)
return { text: new TextDecoder("utf-16le").decode(buf.subarray(2)), encoding: "utf-16le" };
if (buf[0] === 0xfe && buf[1] === 0xff)
return { text: new TextDecoder("utf-16be").decode(buf.subarray(2)), encoding: "utf-16be" };
const u16 = utf16Guess(buf);
if (u16) return { text: new TextDecoder(u16).decode(buf.subarray(0, buf.length & ~1)), encoding: u16 };
const utf8 = tryDecodeTail("utf-8", buf, truncated);
if (utf8 !== null) return { text: utf8, encoding: "utf-8" };
const cands = (["gb18030", "big5"] as const)
.map((enc) => ({ enc, text: tryDecodeTail(enc, buf, truncated) }))
.filter((c): c is { enc: "gb18030" | "big5"; text: string } => c.text !== null)
.map((c) => ({ ...c, score: cjkScore(c.text) }))
.sort((a, b) => b.score - a.score);
const best = cands[0];
if (best && best.score > 0) return { text: best.text, encoding: best.enc };
return { text: new TextDecoder("windows-1252").decode(buf), encoding: "windows-1252" };
}
// ── 字幕 ──
export const CUE_LIMIT = 200;
const TIME_RE = /(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/;
function parseTime(s: string): number | null {
const m = TIME_RE.exec(s);
if (!m) return null;
const [, h, mi, se, frac] = m;
const ms = Number((frac ?? "0").padEnd(3, "0"));
return ((Number(h ?? 0) * 60 + Number(mi)) * 60 + Number(se)) * 1000 + ms;
}
const stripTags = (s: string) =>
s
.replace(/\{\\[^}]*\}/g, "")
.replace(/<[^>]+>/g, "")
.trim();
/** SRT 与 WebVTT:按空行分块,取含 --> 的时间行 */
export function parseSrtLike(text: string): SubtitleCue[] {
const out: SubtitleCue[] = [];
for (const block of text.replace(/\r\n?/g, "\n").split(/\n{2,}/)) {
const lines = block.split("\n");
const i = lines.findIndex((l) => l.includes("-->"));
if (i < 0) continue;
const [a, b] = (lines[i] ?? "").split("-->");
const start = parseTime(a ?? "");
const end = parseTime(b ?? "");
if (start === null || end === null) continue;
out.push({ start, end, text: stripTags(lines.slice(i + 1).join("\n")) });
}
return out;
}
/** ASS/SSA:[Events] 段的 Format 决定 Start/End/Text 列;Text 是最后一列,可含逗号 */
export function parseAss(text: string): { cues: SubtitleCue[]; title: string | null } {
const cues: SubtitleCue[] = [];
let title: string | null = null;
let section = "";
let fields: string[] = ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"];
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
const line = raw.trim();
if (/^\[.+\]$/.test(line)) {
section = line.toLowerCase();
continue;
}
if (section === "[script info]" && /^title\s*:/i.test(line)) title = line.replace(/^title\s*:/i, "").trim() || null;
if (section !== "[events]") continue;
if (/^format\s*:/i.test(line)) {
fields = line
.replace(/^format\s*:/i, "")
.split(",")
.map((f) => f.trim().toLowerCase());
continue;
}
if (!/^dialogue\s*:/i.test(line)) continue;
const parts = line.replace(/^dialogue\s*:/i, "").split(",");
const textIdx = fields.indexOf("text");
const vals = [...parts.slice(0, textIdx), parts.slice(textIdx).join(",")];
const start = parseTime(vals[fields.indexOf("start")] ?? "");
const end = parseTime(vals[fields.indexOf("end")] ?? "");
if (start === null || end === null) continue;
const body = (vals[textIdx] ?? "")
.replace(/\{[^}]*\}/g, "")
.replace(/\\[Nn]/g, "\n")
.replace(/\\h/g, " ")
.trim();
cues.push({ start, end, text: body });
}
return { cues, title };
}
/**
* PGS(.sup):段头 "PG" + PTS(90 kHz)+ DTS + 类型 + 长度。
* 类型 0x16 是显示组合段:带对象的算一条起点,之后第一个不带对象的算终点。
*/
export function parseSup(buf: Uint8Array): SubtitleCue[] {
const cues: SubtitleCue[] = [];
let open: number | null = null;
let p = 0;
while (p + 13 <= buf.length) {
if (buf[p] !== 0x50 || buf[p + 1] !== 0x47) break;
const pts = (((buf[p + 2]! << 24) >>> 0) + (buf[p + 3]! << 16) + (buf[p + 4]! << 8) + buf[p + 5]!) / 90;
const type = buf[p + 10];
const len = (buf[p + 11]! << 8) + buf[p + 12]!;
const body = p + 13;
if (body + len > buf.length) break;
if (type === 0x16 && len >= 11) {
const objects = buf[body + 10] ?? 0;
const t = Math.round(pts);
if (objects > 0 && open === null) open = t;
else if (objects === 0 && open !== null) {
cues.push({ start: open, end: Math.max(open, t), text: "" });
open = null;
}
}
p = body + len;
}
return cues;
}
// ── 图片尺寸 ──
const u16be = (b: Uint8Array, o: number) => (b[o]! << 8) | b[o + 1]!;
const u16le = (b: Uint8Array, o: number) => b[o]! | (b[o + 1]! << 8);
const u32be = (b: Uint8Array, o: number) => ((b[o]! << 24) >>> 0) + (b[o + 1]! << 16) + (b[o + 2]! << 8) + b[o + 3]!;
const u24le = (b: Uint8Array, o: number) => b[o]! | (b[o + 1]! << 8) | (b[o + 2]! << 16);
const ascii = (b: Uint8Array, o: number, n: number) => String.fromCharCode(...b.subarray(o, o + n));
export const IMAGE_MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
avif: "image/avif",
gif: "image/gif",
bmp: "image/bmp",
};
/** 读文件头里的宽高(PNG/JPEG/GIF/BMP/WebP/AVIF);认不出返回 null */
export function imageSize(b: Uint8Array): { width: number; height: number } | null {
if (b.length < 24) return null;
if (b[0] === 0x89 && ascii(b, 1, 3) === "PNG") return { width: u32be(b, 16), height: u32be(b, 20) };
if (ascii(b, 0, 3) === "GIF") return { width: u16le(b, 6), height: u16le(b, 8) };
if (ascii(b, 0, 2) === "BM" && b.length >= 26)
return {
width: Math.abs(new DataView(b.buffer, b.byteOffset).getInt32(18, true)),
height: Math.abs(new DataView(b.buffer, b.byteOffset).getInt32(22, true)),
};
if (ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") {
const chunk = ascii(b, 12, 4);
if (chunk === "VP8 " && b.length >= 30) return { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff };
if (chunk === "VP8L" && b.length >= 25) {
const bits = b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24);
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
}
if (chunk === "VP8X" && b.length >= 30) return { width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 };
return null;
}
if (b[0] === 0xff && b[1] === 0xd8) {
let p = 2;
while (p + 9 < b.length) {
if (b[p] !== 0xff) {
p++;
continue;
}
const marker = b[p + 1]!;
if (marker === 0xff) {
p++;
continue;
}
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
p += 2;
continue;
}
const len = u16be(b, p + 2);
const sof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
if (sof) return { width: u16be(b, p + 7), height: u16be(b, p + 5) };
p += 2 + len;
}
return null;
}
if (ascii(b, 4, 4) === "ftyp") {
// AVIF/HEIF:在头部里找 ispe(image spatial extents)盒
for (let p = 8; p + 20 <= b.length; p++) {
if (b[p] === 0x69 && ascii(b, p, 4) === "ispe") return { width: u32be(b, p + 8), height: u32be(b, p + 12) };
}
}
return null;
}
// ── tracker ──
const SECRET_KEY = /pass|key|token|auth|secret|sign|uid|hash/i;
/** tracker 地址里的 passkey 不外露:敏感参数值与路径里的长十六进制/Base64 段替换成 *** */
export function maskTracker(url: string): string {
let u: URL;
try {
u = new URL(url);
} catch {
return url.replace(/[0-9a-f]{16,}/gi, "***");
}
for (const [k] of [...u.searchParams]) if (SECRET_KEY.test(k)) u.searchParams.set(k, "***");
u.pathname = u.pathname
.split("/")
.map((seg) => (/^[0-9a-z_-]{16,}$/i.test(seg) && /\d/.test(seg) ? "***" : seg))
.join("/");
u.username = "";
u.password = "";
return decodeURIComponent(u.toString());
}
@@ -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 };
}
@@ -14,6 +14,7 @@ import { idempotent } from "./ledger.ts";
import { listDir, listRoots, statEntry } from "./list.ts";
import { mkdirIn, renameEntry } from "./mutate.ts";
import { organizeFiles } from "./organize.ts";
import { previewFile } from "./preview.ts";
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
import { findRecords } from "./records.ts";
import { suggestName } from "./suggest-name.ts";
@@ -113,6 +114,7 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
mount(app, F.preview, async (c, { query }) => previewFile(d, c.get("principal"), query));
// W9-H 文件管理 v2
mount(app, F.entityFromCandidate, async (c, { body }) => entityFromCandidate(d, c.get("principal"), body));
mount(app, F.scrapeBatch, async (c, { body }) =>