- 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]>
126 lines
5.5 KiB
TypeScript
126 lines
5.5 KiB
TypeScript
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { makeTorrent } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { createFmHarness, EXEC_KEY, type FmHarness, pgAvailable, READ_KEY, SESSION } from "./harness.ts";
|
|
|
|
// FM-2:GET /files/preview —— 仅管理员会话;路径守卫同下载;文本编码探测、字幕前 N 条、.torrent 解析、图片尺寸。
|
|
|
|
const skip = !(await pgAvailable());
|
|
|
|
describe.skipIf(skip)("files preview FM-2", () => {
|
|
let h: FmHarness;
|
|
beforeAll(async () => {
|
|
h = await createFmHarness("fm2pv", {}, { runTransfers: false });
|
|
const dir = join(h.lib, "pv");
|
|
await mkdir(dir, { recursive: true });
|
|
// GBK "你好,世界。"
|
|
await writeFile(
|
|
join(dir, "readme.txt"),
|
|
new Uint8Array([0xc4, 0xe3, 0xba, 0xc3, 0xa3, 0xac, 0xca, 0xc0, 0xbd, 0xe7, 0xa1, 0xa3]),
|
|
);
|
|
await writeFile(join(dir, "movie.nfo"), "<movie><title>沙丘</title></movie>");
|
|
await writeFile(join(dir, "big.log"), "a".repeat(1024 * 1024 + 10));
|
|
const srt = Array.from(
|
|
{ length: 250 },
|
|
(_, i) =>
|
|
`${i + 1}\n00:00:${String(i % 60).padStart(2, "0")},000 --> 00:00:${String(i % 60).padStart(2, "0")},500\n第 ${i + 1} 句\n`,
|
|
).join("\n");
|
|
await writeFile(join(dir, "a.srt"), srt);
|
|
const t = makeTorrent({
|
|
name: "Dune.Part.Two.2024",
|
|
files: [
|
|
{ path: "Dune.mkv", length: 1000 },
|
|
{ path: "Subs/chs.srt", length: 20 },
|
|
],
|
|
private: true,
|
|
announce: "https://tracker.example/announce",
|
|
});
|
|
await writeFile(join(dir, "dune.torrent"), t.bytes);
|
|
await writeFile(join(dir, "bad.torrent"), "not bencode");
|
|
const png = new Uint8Array(32);
|
|
png.set([
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 3, 232, 0, 0, 5, 220,
|
|
]);
|
|
await writeFile(join(dir, "poster.png"), png);
|
|
await writeFile(join(dir, "v.mkv"), "video");
|
|
await symlink(join(h.outside, "secret.txt"), join(dir, "leak.txt"));
|
|
(h as unknown as { torrentHash: string }).torrentHash = t.hash;
|
|
});
|
|
afterAll(async () => h?.close());
|
|
|
|
const url = (path: string) => `/files/preview?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}`;
|
|
|
|
it("只接受管理员会话", async () => {
|
|
expect((await h.api("GET", url("pv/movie.nfo"), undefined, READ_KEY)).status).toBe(403);
|
|
expect((await h.api("GET", url("pv/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403);
|
|
expect((await h.api("GET", url("pv/movie.nfo"))).status).toBe(403);
|
|
});
|
|
|
|
it("文本:编码探测、1 MiB 截断", async () => {
|
|
const gbk = await h.api("GET", url("pv/readme.txt"), undefined, SESSION);
|
|
expect(gbk.status).toBe(200);
|
|
expect(gbk.body.data).toMatchObject({
|
|
type: "text",
|
|
format: "txt",
|
|
encoding: "gb18030",
|
|
text: "你好,世界。",
|
|
truncated: false,
|
|
});
|
|
const nfo = await h.api("GET", url("pv/movie.nfo"), undefined, SESSION);
|
|
expect(nfo.body.data).toMatchObject({ type: "text", encoding: "utf-8" });
|
|
const big = await h.api("GET", url("pv/big.log"), undefined, SESSION);
|
|
expect(big.body.data.truncated).toBe(true);
|
|
expect(big.body.data.text.length).toBe(1024 * 1024);
|
|
expect(big.body.data.size).toBe(1024 * 1024 + 10);
|
|
});
|
|
|
|
it("字幕:按时间排序、只回前 200 条", async () => {
|
|
const r = await h.api("GET", url("pv/a.srt"), undefined, SESSION);
|
|
expect(r.body.data.type).toBe("subtitle");
|
|
expect(r.body.data.cues.length).toBe(200);
|
|
expect(r.body.data.parsed).toBe(250);
|
|
expect(r.body.data.cues[0]).toEqual({ start: 0, end: 500, text: "第 1 句" });
|
|
});
|
|
|
|
it(".torrent:文件列表、tracker、hash;坏文件 400", async () => {
|
|
const r = await h.api("GET", url("pv/dune.torrent"), undefined, SESSION);
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data).toMatchObject({
|
|
type: "torrent",
|
|
name: "Dune.Part.Two.2024",
|
|
hash: (h as unknown as { torrentHash: string }).torrentHash,
|
|
private: true,
|
|
fileCount: 2,
|
|
totalSize: 1020,
|
|
trackers: ["https://tracker.example/announce"],
|
|
});
|
|
expect(r.body.data.files[1]).toEqual({ path: "Dune.Part.Two.2024/Subs/chs.srt", length: 20 });
|
|
const bad = await h.api("GET", url("pv/bad.torrent"), undefined, SESSION);
|
|
expect(bad.status).toBe(400);
|
|
});
|
|
|
|
it("图片只回尺寸;视频/目录/根外链接拒绝", async () => {
|
|
const img = await h.api("GET", url("pv/poster.png"), undefined, SESSION);
|
|
expect(img.body.data).toEqual({ type: "image", size: 32, mime: "image/png", width: 1000, height: 1500 });
|
|
expect((await h.api("GET", url("pv/v.mkv"), undefined, SESSION)).status).toBe(403);
|
|
expect((await h.api("GET", url("pv"), undefined, SESSION)).status).toBe(400);
|
|
const leak = await h.api("GET", url("pv/leak.txt"), undefined, SESSION);
|
|
expect(leak.status).toBe(400);
|
|
expect(leak.body.error.message).toMatch(/^path_escape:/);
|
|
expect((await h.api("GET", url("pv/none.txt"), undefined, SESSION)).status).toBe(404);
|
|
});
|
|
|
|
it("下载也放行文本与 .torrent(附件)", async () => {
|
|
const r = await h.raw(
|
|
"GET",
|
|
`/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent("pv/dune.torrent")}`,
|
|
SESSION,
|
|
);
|
|
expect(r.status).toBe(200);
|
|
expect(r.headers.get("content-type")).toBe("application/x-bittorrent");
|
|
expect(r.headers.get("content-disposition")).toMatch(/^attachment;/);
|
|
await r.arrayBuffer();
|
|
});
|
|
});
|