feat(server): W5-A file manager module (/files)
Roots/list/stat with inline business status, path guard (realpath within registered roots, symlink escape, offline root 503), mkdir/rename syncing asset_location and path_binding revisions, two-phase move/copy/organize (planId + source facts), trash with reference checks and purgeTrash task, identify/probe/scrape, audit + idempotency keys. Runbook docs/runbook/files.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09f6e3a3e5
commit
29cd10b27e
20 files changed
+4380
-1
No files matched your search
@@ -0,0 +1,174 @@
|
||||
import { mkdir, rename, symlink, utimes, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFmHarness,
|
||||
type FmHarness,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
seedAsset,
|
||||
seedDownload,
|
||||
seedEntity,
|
||||
} from "./harness.ts";
|
||||
|
||||
// 读接口 + 路径守卫:只列登记根、路径逃逸(.. / URL 编码 / 符号链接 / 根外绝对路径)、离线 503、截断与分页、行内状态。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
const q = (o: Record<string, string>) => new URLSearchParams(o).toString();
|
||||
|
||||
describe.skipIf(skip)("files 浏览与守卫", () => {
|
||||
let h: FmHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness("browse", { options: { listMaxEntries: 300 } });
|
||||
await mkdir(join(h.lib, "Movie (2020)"));
|
||||
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv"), "video");
|
||||
await writeFile(join(h.lib, "Movie (2020)", "Other.2021.mkv"), "video2");
|
||||
await writeFile(join(h.lib, "Movie (2020)", ".hidden"), "x");
|
||||
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv.uhd-partial"), "x");
|
||||
await symlink(h.outside, join(h.lib, "escape-dir"));
|
||||
await symlink(join(h.outside, "secret.txt"), join(h.lib, "escape-file.txt"));
|
||||
await symlink(join(h.lib, "Movie (2020)"), join(h.lib, "inner-link"));
|
||||
await mkdir(join(h.dl, "Show.S01.1080p"));
|
||||
await writeFile(join(h.dl, "Show.S01.1080p", "Show.S01E01.mkv"), "ep1");
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "Movie", year: 2020 });
|
||||
await seedAsset(h.db, h, { entityId: e, rel: "Movie (2020)/Movie.2020.1080p.mkv" });
|
||||
await seedDownload(h.db, h, { name: "Show.S01.1080p", status: "downloading" });
|
||||
// 根为 "/" 的配置永不出现
|
||||
await h.db
|
||||
.insertInto("library_root")
|
||||
.values({ name: "危险", media_kind: "mixed", local_path: "/", management_mode: "managed" })
|
||||
.execute();
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
it("GET /files/roots 只列登记根,带健康与可写", async () => {
|
||||
const r = await h.api("GET", "/files/roots");
|
||||
expect(r.status).toBe(200);
|
||||
const items = r.body.data.items;
|
||||
expect(items.map((x: { id: string }) => x.id).sort()).toEqual([h.dlRoot, h.libRoot].sort());
|
||||
const lib = items.find((x: { id: string }) => x.id === h.libRoot);
|
||||
expect(lib).toMatchObject({ kind: "library", health: "healthy", writable: true });
|
||||
const dl = items.find((x: { id: string }) => x.id === h.dlRoot);
|
||||
expect(dl).toMatchObject({ kind: "download", writable: false, mediaKind: null });
|
||||
// API Key 只读 scope 可读
|
||||
expect((await h.api("GET", "/files/roots", undefined, READ_KEY)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("列目录:行内业务状态、隐藏文件、符号链接逃逸标记", async () => {
|
||||
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)" })}`);
|
||||
expect(r.status).toBe(200);
|
||||
const byName = Object.fromEntries(r.body.data.entries.map((e: { name: string }) => [e.name, e]));
|
||||
expect(Object.keys(byName).sort()).toEqual(["Movie.2020.1080p.mkv", "Other.2021.mkv"]);
|
||||
expect(byName["Movie.2020.1080p.mkv"]).toMatchObject({ status: "registered", fileKind: "video" });
|
||||
expect(byName["Movie.2020.1080p.mkv"].asset).toMatchObject({ title: "Movie", year: 2020 });
|
||||
expect(byName["Other.2021.mkv"].status).toBe("unidentified");
|
||||
|
||||
const all = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", hidden: "true" })}`);
|
||||
const names = all.body.data.entries.map((e: { name: string }) => e.name);
|
||||
expect(names).toContain(".hidden");
|
||||
const partial = all.body.data.entries.find((e: { name: string }) => e.name.endsWith(".uhd-partial"));
|
||||
expect(partial.status).toBe("temp");
|
||||
|
||||
const root = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
|
||||
const esc = root.body.data.entries.find((e: { name: string }) => e.name === "escape-dir");
|
||||
expect(esc).toMatchObject({ type: "symlink", escape: true });
|
||||
const inner = root.body.data.entries.find((e: { name: string }) => e.name === "inner-link");
|
||||
expect(inner.escape).toBe(false);
|
||||
const dir = root.body.data.entries.find((e: { name: string }) => e.name === "Movie (2020)");
|
||||
expect(dir.dirSummary).toMatchObject({ videoCount: 2, registeredCount: 1 });
|
||||
});
|
||||
|
||||
it("下载根:纳管下载中的内容标 downloading", async () => {
|
||||
const r = await h.api("GET", `/files?${q({ root: h.dlRoot })}`);
|
||||
const e = r.body.data.entries.find((x: { name: string }) => x.name === "Show.S01.1080p");
|
||||
expect(e.status).toBe("downloading");
|
||||
expect(e.download.status).toBe("downloading");
|
||||
const inner = await h.api("GET", `/files?${q({ root: h.dlRoot, path: "Show.S01.1080p" })}`);
|
||||
expect(inner.body.data.entries[0].status).toBe("downloading");
|
||||
});
|
||||
|
||||
it("路径逃逸一律拒绝:..、URL 编码、根外绝对路径、符号链接(目录与文件)、未登记根", async () => {
|
||||
const bad = [
|
||||
`/files?${q({ root: h.libRoot, path: ".." })}`,
|
||||
`/files?${q({ root: h.libRoot, path: "Movie (2020)/../.." })}`,
|
||||
`/files?root=${h.libRoot}&path=..%2F..%2Fetc`,
|
||||
`/files?${q({ root: h.libRoot, path: "/etc" })}`,
|
||||
`/files?${q({ root: h.libRoot, path: "escape-dir" })}`,
|
||||
`/files/stat?${q({ root: h.libRoot, path: "escape-dir/secret.txt" })}`,
|
||||
`/files/stat?${q({ root: h.libRoot, path: "escape-file.txt" })}`,
|
||||
];
|
||||
for (const p of bad) {
|
||||
const r = await h.api("GET", p);
|
||||
expect([400, 422], p).toContain(r.status);
|
||||
expect(JSON.stringify(r.body)).not.toContain("top secret");
|
||||
}
|
||||
const unknown = await h.api("GET", `/files?${q({ root: "lib:00000000-0000-4000-8000-000000000000" })}`);
|
||||
expect(unknown.status).toBe(404);
|
||||
// 根内符号链接(目标仍在根内)可以浏览
|
||||
const ok = await h.api("GET", `/files?${q({ root: h.libRoot, path: "inner-link" })}`);
|
||||
expect(ok.status).toBe(200);
|
||||
// 写操作同样受守卫
|
||||
const mk = await h.api("POST", "/files/mkdir", {
|
||||
idempotencyKey: "k-escape-0001",
|
||||
root: h.libRoot,
|
||||
path: "escape-dir",
|
||||
name: "x",
|
||||
});
|
||||
expect(mk.status).toBe(400);
|
||||
});
|
||||
|
||||
it("根离线(哨兵缺失)→ 503 root_unavailable,不返回空列表", async () => {
|
||||
await rename(join(h.lib, ".uhd-root"), join(h.lib, ".uhd-root.bak"));
|
||||
try {
|
||||
const r = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
|
||||
expect(r.status).toBe(503);
|
||||
expect(r.body.error.message).toMatch(/^root_unavailable:/);
|
||||
const roots = await h.api("GET", "/files/roots");
|
||||
expect(roots.body.data.items.find((x: { id: string }) => x.id === h.libRoot).health).toBe("offline");
|
||||
} finally {
|
||||
await rename(join(h.lib, ".uhd-root.bak"), join(h.lib, ".uhd-root"));
|
||||
}
|
||||
});
|
||||
|
||||
it("大目录截断 + 分页稳定 + 目录变化 stale + 过滤", async () => {
|
||||
const big = join(h.lib, "big");
|
||||
await mkdir(big);
|
||||
for (let i = 0; i < 400; i++) await writeFile(join(big, `f${String(i).padStart(4, "0")}.txt`), "");
|
||||
const r1 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100" })}`);
|
||||
expect(r1.body.data).toMatchObject({ truncated: true, total: 300, stale: false });
|
||||
expect(r1.body.data.entries).toHaveLength(100);
|
||||
const seen = new Set(r1.body.data.entries.map((e: { name: string }) => e.name));
|
||||
let cursor = r1.body.data.nextCursor;
|
||||
while (cursor) {
|
||||
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100", cursor })}`);
|
||||
for (const e of r.body.data.entries) {
|
||||
expect(seen.has(e.name)).toBe(false);
|
||||
seen.add(e.name);
|
||||
}
|
||||
cursor = r.body.data.nextCursor;
|
||||
}
|
||||
expect(seen.size).toBe(300);
|
||||
// 自然排序(截断取的是目录读取顺序的前 N 项,排序在其内进行)
|
||||
const page1 = r1.body.data.entries.map((e: { name: string }) => e.name);
|
||||
expect(page1).toEqual([...page1].sort());
|
||||
// 目录 mtime 变化 → stale
|
||||
await writeFile(join(big, "zz-new.txt"), "");
|
||||
await utimes(big, new Date(), new Date(Date.now() + 5000));
|
||||
const r2 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", cursor: r1.body.data.nextCursor })}`);
|
||||
expect(r2.body.data.stale).toBe(true);
|
||||
// 过滤 + glob
|
||||
const g = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", q: "movie*.mkv" })}`);
|
||||
expect(g.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Movie.2020.1080p.mkv"]);
|
||||
const f = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", filter: "unidentified" })}`);
|
||||
expect(f.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Other.2021.mkv"]);
|
||||
});
|
||||
|
||||
it("GET /files/stat:inode/nlink/sidecars", async () => {
|
||||
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.chs.srt"), "sub");
|
||||
const r = await h.api("GET", `/files/stat?${q({ root: h.libRoot, path: "Movie (2020)/Movie.2020.1080p.mkv" })}`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.sidecars).toEqual(["Movie (2020)/Movie.2020.1080p.chs.srt"]);
|
||||
expect(r.body.data.nlink).toBe(1);
|
||||
expect(r.body.data.sharedInode).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user