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
@@ -68,6 +68,7 @@ describe("Api 注册表鉴权标注", () => {
|
||||
"PATCH /auth/passkeys/{id}",
|
||||
"DELETE /auth/passkeys/{id}",
|
||||
"GET /files/download",
|
||||
"GET /files/preview",
|
||||
"PATCH /approvals/{id}",
|
||||
"POST /approvals",
|
||||
"POST /auth/password",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodeText,
|
||||
imageSize,
|
||||
maskTracker,
|
||||
parseAss,
|
||||
parseSrtLike,
|
||||
parseSup,
|
||||
previewKindOf,
|
||||
} from "../../src/modules/file-manager/preview-parse.ts";
|
||||
|
||||
// FM-2 预览纯解析:编码探测、字幕、PGS、图片尺寸(不碰 PG)。
|
||||
|
||||
const enc = (s: string) => new TextEncoder().encode(s);
|
||||
const u16le = (s: string) => {
|
||||
const b = new Uint8Array(s.length * 2);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
b[i * 2] = s.charCodeAt(i) & 0xff;
|
||||
b[i * 2 + 1] = s.charCodeAt(i) >> 8;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
describe("previewKindOf", () => {
|
||||
it("按扩展名分类,大小写不敏感", () => {
|
||||
expect(previewKindOf("a.NFO")).toBe("text");
|
||||
expect(previewKindOf("x.log")).toBe("text");
|
||||
expect(previewKindOf("x.ass")).toBe("subtitle");
|
||||
expect(previewKindOf("x.AVIF")).toBe("image");
|
||||
expect(previewKindOf("x.torrent")).toBe("torrent");
|
||||
expect(previewKindOf("x.mkv")).toBeNull();
|
||||
expect(previewKindOf("x.svg")).toBeNull();
|
||||
expect(previewKindOf("README")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeText", () => {
|
||||
it("BOM:UTF-8 / UTF-16LE / UTF-16BE", () => {
|
||||
expect(decodeText(new Uint8Array([0xef, 0xbb, 0xbf, ...enc("中文")]))).toEqual({ text: "中文", encoding: "utf-8" });
|
||||
expect(decodeText(new Uint8Array([0xff, 0xfe, ...u16le("Hi 中")]))).toEqual({
|
||||
text: "Hi 中",
|
||||
encoding: "utf-16le",
|
||||
});
|
||||
expect(decodeText(new Uint8Array([0xfe, 0xff, 0x00, 0x41]))).toEqual({ text: "A", encoding: "utf-16be" });
|
||||
});
|
||||
it("无 BOM 的 UTF-16LE 按零字节分布识别", () => {
|
||||
expect(decodeText(u16le("[Script Info]\nTitle: x\n")).encoding).toBe("utf-16le");
|
||||
});
|
||||
it("UTF-8;截断在多字节字符中间也按 UTF-8", () => {
|
||||
const b = enc("沙丘 2 字幕");
|
||||
expect(decodeText(b).encoding).toBe("utf-8");
|
||||
expect(decodeText(b.subarray(0, b.length - 1), true)).toEqual({ text: "沙丘 2 字", encoding: "utf-8" });
|
||||
});
|
||||
it("GBK 与 Big5", () => {
|
||||
// "你好,世界。沙丘" 的 GBK 字节
|
||||
const gbk = new Uint8Array([
|
||||
0xc4, 0xe3, 0xba, 0xc3, 0xa3, 0xac, 0xca, 0xc0, 0xbd, 0xe7, 0xa1, 0xa3, 0xc9, 0xb3, 0xc7, 0xf0,
|
||||
]);
|
||||
expect(decodeText(gbk)).toEqual({ text: "你好,世界。沙丘", encoding: "gb18030" });
|
||||
// "電影字幕" 的 Big5 字节
|
||||
const big5 = new Uint8Array([0xb9, 0x71, 0xbc, 0x76, 0xa6, 0x72, 0xb9, 0xf5]);
|
||||
expect(decodeText(big5)).toEqual({ text: "電影字幕", encoding: "big5" });
|
||||
});
|
||||
it("都不像时退回 windows-1252", () => {
|
||||
expect(decodeText(new Uint8Array([0x43, 0x61, 0x66, 0xe9])).encoding).toBe("windows-1252");
|
||||
});
|
||||
});
|
||||
|
||||
describe("字幕", () => {
|
||||
it("SRT:时间轴、多行、去标签", () => {
|
||||
const cues = parseSrtLike(
|
||||
"1\r\n00:00:01,000 --> 00:00:04,500\r\n<i>厄拉科斯</i>\r\n第二行\r\n\r\n2\r\n00:01:05,20 --> 00:01:07,000\r\n{\\an8}上方\r\n",
|
||||
);
|
||||
expect(cues).toEqual([
|
||||
{ start: 1000, end: 4500, text: "厄拉科斯\n第二行" },
|
||||
{ start: 65200, end: 67000, text: "上方" },
|
||||
]);
|
||||
});
|
||||
it("VTT:mm:ss.mmm", () => {
|
||||
expect(parseSrtLike("WEBVTT\n\n00:01.500 --> 00:02.000 align:start\nhi\n")).toEqual([
|
||||
{ start: 1500, end: 2000, text: "hi" },
|
||||
]);
|
||||
});
|
||||
it("ASS:按 Format 取列,Text 里的逗号保留,覆写标签与 \\N 处理", () => {
|
||||
const text = [
|
||||
"[Script Info]",
|
||||
"Title: 沙丘 简中",
|
||||
"[Events]",
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
||||
"Dialogue: 0,0:00:02.50,0:00:04.00,Default,,0,0,0,,{\\fad(100,100)}你好,世界,再见\\N第二行",
|
||||
"Comment: 0,0:00:05.00,0:00:06.00,Default,,0,0,0,,注释",
|
||||
].join("\n");
|
||||
expect(parseAss(text)).toEqual({
|
||||
title: "沙丘 简中",
|
||||
cues: [{ start: 2500, end: 4000, text: "你好,世界,再见\n第二行" }],
|
||||
});
|
||||
});
|
||||
it("PGS:带对象的组合段起、空组合段止", () => {
|
||||
const seg = (ptsMs: number, type: number, body: number[]) => {
|
||||
const pts = ptsMs * 90;
|
||||
return [
|
||||
0x50,
|
||||
0x47,
|
||||
(pts >>> 24) & 255,
|
||||
(pts >>> 16) & 255,
|
||||
(pts >>> 8) & 255,
|
||||
pts & 255,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
type,
|
||||
0,
|
||||
body.length,
|
||||
...body,
|
||||
];
|
||||
};
|
||||
const pcs = (objects: number) => [
|
||||
7,
|
||||
128,
|
||||
4,
|
||||
56,
|
||||
0x10,
|
||||
0,
|
||||
0,
|
||||
0x80,
|
||||
0,
|
||||
0,
|
||||
objects,
|
||||
...(objects ? [0, 0, 0, 0, 0, 0, 0, 0] : []),
|
||||
];
|
||||
const buf = new Uint8Array([
|
||||
...seg(1000, 0x16, pcs(1)),
|
||||
...seg(1000, 0x80, []),
|
||||
...seg(3000, 0x16, pcs(0)),
|
||||
...seg(5000, 0x16, pcs(1)),
|
||||
]);
|
||||
expect(parseSup(buf)).toEqual([{ start: 1000, end: 3000, text: "" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageSize", () => {
|
||||
it("PNG / GIF / JPEG / WebP VP8X", () => {
|
||||
const png = new Uint8Array(24);
|
||||
png.set([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 7, 128, 0, 0, 4, 56,
|
||||
]);
|
||||
expect(imageSize(png)).toEqual({ width: 1920, height: 1080 });
|
||||
const gif = new Uint8Array(24);
|
||||
gif.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x2c, 0x01, 0xc2, 0x01]);
|
||||
expect(imageSize(gif)).toEqual({ width: 300, height: 450 });
|
||||
const jpg = new Uint8Array([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0, 4, 0, 0, 0xff, 0xc0, 0, 17, 8, 0x05, 0xdc, 0x03, 0xe8, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
]);
|
||||
expect(imageSize(jpg)).toEqual({ width: 1000, height: 1500 });
|
||||
const webp = new Uint8Array(30);
|
||||
webp.set([...enc("RIFF"), 0, 0, 0, 0, ...enc("WEBP"), ...enc("VP8X"), 10, 0, 0, 0, 0, 0, 0, 0]);
|
||||
webp.set([0x7f, 0x07, 0x00, 0x37, 0x04, 0x00], 24);
|
||||
expect(imageSize(webp)).toEqual({ width: 1920, height: 1080 });
|
||||
expect(imageSize(enc("not an image at all, just text"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskTracker", () => {
|
||||
it("参数里的 passkey、路径里的长密钥段打码", () => {
|
||||
expect(maskTracker("https://t.example/announce.php?passkey=0123456789abcdef0123&foo=1")).toBe(
|
||||
"https://t.example/announce.php?passkey=***&foo=1",
|
||||
);
|
||||
expect(maskTracker("https://t.example/0123456789abcdef0123456789abcdef/announce")).toBe(
|
||||
"https://t.example/***/announce",
|
||||
);
|
||||
expect(maskTracker("udp://tracker.opentrackr.org:1337/announce")).toBe(
|
||||
"udp://tracker.opentrackr.org:1337/announce",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user