diff --git a/packages/adapters/deno.json b/packages/adapters/deno.json index a458dc95..0c71b687 100644 --- a/packages/adapters/deno.json +++ b/packages/adapters/deno.json @@ -1,7 +1,7 @@ { "name": "@umt/adapters", "version": "0.3.34", - "exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" }, + "exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts", "./alist-mock": "./src/alist/mock.ts" }, "tasks": { "test": "deno run -A npm:vitest run", "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" diff --git a/packages/adapters/package.json b/packages/adapters/package.json index ebe1453a..4a1a00d4 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -4,6 +4,7 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./fsworker": "./src/fs/fsworker.ts" + "./fsworker": "./src/fs/fsworker.ts", + "./alist-mock": "./src/alist/mock.ts" } } diff --git a/packages/adapters/src/alist/client.test.ts b/packages/adapters/src/alist/client.test.ts new file mode 100644 index 00000000..e2f02cb5 --- /dev/null +++ b/packages/adapters/src/alist/client.test.ts @@ -0,0 +1,548 @@ +import { Buffer } from "node:buffer"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AlistClient, type AlistClientOptions, hashLocalFile, PARTIAL_SUFFIX } from "./client.ts"; +import { AlistError, classifyAlist } from "./errors.ts"; +import { AlistLimiter } from "./limiter.ts"; +import { createMockAlist, type MockAlistOptions } from "./mock.ts"; +import { redactAlistText, redactAlistUrl } from "./redact.ts"; + +// 适配器:mock Alist 覆盖全部接口、错误码、令牌失效重登、异步任务、上传下载进度与中断(docs/design/storage-alist.md §12)。 + +let dir: string; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "umt-alist-")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const noSleep = async () => {}; +function setup(mo: MockAlistOptions = { token: "perm-token" }, co: Partial = {}) { + const mock = createMockAlist(mo); + const client = new AlistClient({ + baseUrl: mock.base, + auth: mo.username + ? { kind: "password", username: mo.username, password: mo.password ?? "" } + : { kind: "token", token: mo.token ?? "" }, + fetch: mock.fetch, + sleep: noSleep, + qps: 0, + concurrency: 0, + ...co, + }); + return { mock, client }; +} +const bytes = (n: number, seed = 7) => { + const b = new Uint8Array(n); + for (let i = 0; i < n; i++) b[i] = (i * 31 + seed) & 0xff; + return b; +}; +const caught = async (p: Promise) => { + try { + await p; + } catch (e) { + return e as AlistError; + } + throw new Error("expected rejection"); +}; + +describe("错误分类(不存在 ≠ 失败)", () => { + it("真机返回形状 → 类型", () => { + expect(classifyAlist(200, 500, "failed to get obj: object not found").kind).toBe("not_found"); + expect(classifyAlist(200, 401, "token is invalidated").kind).toBe("auth"); + expect(classifyAlist(200, 403, "file [x.bin] exists").kind).toBe("conflict"); + expect(classifyAlist(429, null, "Too Many Requests").kind).toBe("rate_limited"); + expect(classifyAlist(502, null, "bad gateway").kind).toBe("unavailable"); + expect(classifyAlist(200, 500, "failed get storage: storage not found; please add a storage first").kind).toBe( + "not_found", + ); + expect(classifyAlist(200, 500, "some driver error").kind).toBe("error"); + expect(new AlistError("unavailable", "x").retryable).toBe(true); + expect(new AlistError("not_found", "x").fsCode).toBe("ENOENT"); + expect(new AlistError("conflict", "x").fsCode).toBe("EEXIST"); + expect(new AlistError("auth", "x").fsCode).toBe("EACCES"); + }); + + it("get:不存在抛 not_found,stat 返回 null;连不上抛 unavailable(不当成不存在)", async () => { + const { mock, client } = setup({ token: "t" }, { retries: 0 }); + expect((await caught(client.get("/s1/nope"))).kind).toBe("not_found"); + expect(await client.stat("/s1/nope")).toBeNull(); + mock.failNext(1); + expect((await caught(client.stat("/s1/nope"))).kind).toBe("unavailable"); + }); + + it("可重试错误退避重试;次数用尽才失败", async () => { + const { mock, client } = setup({ token: "t" }, { retries: 2 }); + mock.put("/s1/a.txt", "hi"); + mock.failNext(2); + expect((await client.get("/s1/a.txt")).size).toBe(2); + expect(client.stats.retries).toBe(2); + mock.failNext(3); + expect((await caught(client.get("/s1/a.txt"))).kind).toBe("unavailable"); + }); + + it("限流 / 风控(429)退避后成功", async () => { + const slept: number[] = []; + const { mock, client } = setup({ token: "t" }, { sleep: async (ms) => void slept.push(ms) }); + mock.put("/s1/a.txt", "hi"); + mock.rateLimitNext(2); + expect((await client.get("/s1/a.txt")).name).toBe("a.txt"); + expect(slept.length).toBe(2); + expect(slept[0]).toBeGreaterThanOrEqual(5000); + }); +}); + +describe("令牌", () => { + it("永久令牌原样放 Authorization", async () => { + const { mock, client } = setup({ token: "perm" }); + await client.me(); + expect(mock.requests.at(-1)!.headers.authorization).toBe("perm"); + }); + + it("永久令牌失效:直接报鉴权错误,不重试", async () => { + const mock = createMockAlist({ token: "right" }); + const client = new AlistClient({ + baseUrl: mock.base, + auth: { kind: "token", token: "wrong" }, + fetch: mock.fetch, + qps: 0, + }); + const e = await caught(client.me()); + expect(e.kind).toBe("auth"); + expect(mock.requests.length).toBe(1); + }); + + it("账号密码:登录换令牌并缓存;令牌失效自动重登一次再试", async () => { + const { mock, client } = setup({ username: "u", password: "p" }); + await client.me(); + await client.me(); + expect(mock.requests.filter((r) => r.path === "/api/auth/login").length).toBe(1); + mock.invalidateSessions(); + expect((await client.me()).username).toBe("u"); + expect(client.stats.relogins).toBe(1); + expect(mock.requests.filter((r) => r.path === "/api/auth/login").length).toBe(2); + }); + + it("密码错误:鉴权错误", async () => { + const mock = createMockAlist({ username: "u", password: "p" }); + const client = new AlistClient({ + baseUrl: mock.base, + auth: { kind: "password", username: "u", password: "bad" }, + fetch: mock.fetch, + qps: 0, + }); + expect((await caught(client.me())).kind).toBe("auth"); + }); + + it("令牌缓存到期(约 2 天)后重新登录", async () => { + let now = 1_000_000; + const { mock, client } = setup({ username: "u", password: "p" }, { now: () => now }); + await client.me(); + now += 2 * 86_400_000; + await client.me(); + expect(mock.requests.filter((r) => r.path === "/api/auth/login").length).toBe(2); + }); +}); + +describe("读接口", () => { + it("list 自动翻页(每页 500),不带 refresh;用户刷新才带", async () => { + const { mock, client } = setup(); + for (let i = 0; i < 1203; i++) mock.put(`/s1/big/f${String(i).padStart(4, "0")}.txt`, "x"); + const all = await client.list("/s1/big"); + expect(all.length).toBe(1203); + const lists = mock.requests.filter((r) => r.path === "/api/fs/list"); + expect(lists.map((r) => r.body!.page)).toEqual([1, 2, 3]); + expect(lists.every((r) => r.body!.per_page === 500 && r.body!.refresh === false)).toBe(true); + await client.list("/s1/big", { refresh: true }); + expect(mock.requests.filter((r) => r.path === "/api/fs/list").at(-3)!.body!.refresh).toBe(true); + expect(client.stats.refreshes).toBe(1); + }); + + it("路径密码:list / get 都带上", async () => { + const { mock, client } = setup({ token: "t", pathPassword: "pw1" }, { pathPassword: "pw1" }); + mock.put("/s1/a.txt", "hi"); + expect((await client.list("/s1")).map((o) => o.name)).toEqual(["a.txt"]); + expect(mock.requests.at(-1)!.body!.password).toBe("pw1"); + const noPw = new AlistClient({ + baseUrl: mock.base, + auth: { kind: "token", token: "t" }, + fetch: mock.fetch, + qps: 0, + }); + expect((await caught(noPw.list("/s1"))).kind).toBe("auth"); + }); + + it("get 带 raw_url / sign / hash_info;rawUrl 优先 raw_url", async () => { + const { mock, client } = setup(); + mock.put("/s1/a b.mkv", "data", { hashInfo: { md5: "abc" } }); + const o = await client.get("/s1/a b.mkv"); + expect(o.size).toBe(4); + expect(o.hashInfo).toEqual({ md5: "abc" }); + expect(o.sign).not.toBe(""); + expect(await client.rawUrl("/s1/a b.mkv")).toContain("/d/s1/a%20b.mkv?sign="); + }); + + it("容量:有 mount_details 显示,没有返回 null 不报错", async () => { + const { mock, client } = setup({ + token: "t", + mountDetails: { "/s1": { total_space: 1000, free_space: 400, used_space: 600 } }, + }); + mock.mkdirp("/s1/x"); + mock.mkdirp("/s2/y"); + expect(await client.usage("/s1/x")).toEqual({ total: 1000, free: 400, used: 600 }); + expect(await client.usage("/s2/y")).toBeNull(); + mock.failNext(1); + expect(await client.usage("/s1")).toBeNull(); + }); + + it("readFile 读小文件", async () => { + const { mock, client } = setup(); + mock.put("/s1/movie.nfo", ""); + expect(new TextDecoder().decode(await client.readFile("/s1/movie.nfo"))).toBe(""); + }); +}); + +describe("写接口与异步任务", () => { + it("mkdir / rename / remove;改名到已存在 → conflict", async () => { + const { mock, client } = setup(); + await client.mkdir("/s1/a/b"); + expect(mock.exists("/s1/a/b")).toBe(true); + mock.put("/s1/a/x.txt", "1"); + mock.put("/s1/a/y.txt", "2"); + expect((await caught(client.rename("/s1/a/x.txt", "y.txt"))).kind).toBe("conflict"); + await client.rename("/s1/a/x.txt", "z.txt"); + expect(mock.exists("/s1/a/z.txt")).toBe(true); + await client.removePath("/s1/a/z.txt"); + expect(mock.exists("/s1/a/z.txt")).toBe(false); + }); + + it("复制:轮询任务到完成(不是固定 sleep),成功 = 任务成功 + 目标存在", async () => { + const { mock, client } = setup({ token: "t", taskPolls: 3 }); + mock.put("/s1/src/a.mkv", bytes(1000)); + const o = await client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv"); + expect(o.size).toBe(1000); + expect(mock.exists("/s1/src/a.mkv")).toBe(true); + const polls = mock.requests.filter((r) => r.path.startsWith("/api/task/copy/info")); + expect(polls.length).toBe(4); + }); + + it("任务失败:拿到任务的错误信息", async () => { + const { mock, client } = setup(); + mock.put("/s1/src/a.mkv", "x"); + mock.failNextTask("upstream quota exceeded"); + const e = await caught(client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv")); + expect(e.kind).toBe("task_failed"); + expect(e.message).toContain("upstream quota exceeded"); + }); + + it("任务超时可配", async () => { + let now = 0; + const { mock, client } = setup( + { token: "t", taskPolls: 1000 }, + { + now: () => now, + sleep: async (ms) => { + now += ms; + }, + taskTimeoutMs: 5000, + }, + ); + mock.put("/s1/src/a.mkv", "x"); + expect((await caught(client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv"))).kind).toBe("timeout"); + }); + + it("老版本没有任务接口:改为等目标出现", async () => { + const { mock, client } = setup({ token: "t", noTaskApi: true, noTaskIds: true, taskPolls: 2 }); + mock.put("/s1/src/a.mkv", bytes(10)); + const o = await client.copyFile("/s1/src/a.mkv", "/s1/dst/a.mkv"); + expect(o.size).toBe(10); + }); + + it("目标已存在:冲突,不覆盖", async () => { + const { mock, client } = setup(); + mock.put("/s1/src/a.mkv", "new"); + mock.put("/s2/dst/a.mkv", "old"); + expect((await caught(client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv"))).kind).toBe("conflict"); + expect(new TextDecoder().decode(mock.read("/s2/dst/a.mkv")!)).toBe("old"); + }); + + it("改名复制走暂存目录:目标目录里已有与源同名的文件也不受影响,暂存清理干净", async () => { + const { mock, client } = setup(); + mock.put("/s1/src/raw.mkv", "new"); + mock.put("/s1/lib/raw.mkv", "unrelated"); + await client.copyFile("/s1/src/raw.mkv", "/s1/lib/Movie (2020).mkv"); + expect(new TextDecoder().decode(mock.read("/s1/lib/Movie (2020).mkv")!)).toBe("new"); + expect(new TextDecoder().decode(mock.read("/s1/lib/raw.mkv")!)).toBe("unrelated"); + expect(mock.listNames("/s1/lib").some((n) => n.startsWith(".umt-stage-"))).toBe(false); + }); + + it("改名复制失败:清理暂存", async () => { + const { mock, client } = setup(); + mock.put("/s1/src/raw.mkv", "new"); + mock.failNextTask("disk full"); + await caught(client.copyFile("/s1/src/raw.mkv", "/s2/lib/Movie.mkv")); + expect(mock.listNames("/s2/lib")).toEqual([]); + }); + + it("移动:同目录只改名;同驱动立即完成;跨驱动等任务;改名移动经暂存", async () => { + const { mock, client } = setup(); + mock.put("/s1/a/x.mkv", "1"); + await client.moveFile("/s1/a/x.mkv", "/s1/a/y.mkv"); + expect(mock.exists("/s1/a/y.mkv")).toBe(true); + await client.moveFile("/s1/a/y.mkv", "/s1/b/y.mkv"); + expect(mock.exists("/s1/b/y.mkv") && !mock.exists("/s1/a/y.mkv")).toBe(true); + await client.moveFile("/s1/b/y.mkv", "/s2/c/Final.mkv"); + expect(mock.exists("/s2/c/Final.mkv")).toBe(true); + expect(mock.exists("/s1/b/y.mkv")).toBe(false); + expect(mock.listNames("/s2/c")).toEqual(["Final.mkv"]); + }); + + it("移动失败:源搬回原处", async () => { + const { mock, client } = setup(); + mock.put("/s1/a/x.mkv", "1"); + mock.failNextTask("network"); + await caught(client.moveFile("/s1/a/x.mkv", "/s2/c/Final.mkv")); + expect(mock.exists("/s1/a/x.mkv")).toBe(true); + expect(mock.listNames("/s2/c")).toEqual([]); + }); +}); + +describe("上传", () => { + it("三种哈希一遍读出,作秒传头;先传临时名再改名;进度回调", async () => { + const { mock, client } = setup(); + const f = join(dir, "a.mkv"); + const data = bytes(3 * 1024 * 1024 + 5); + await writeFile(f, data); + const progress: number[] = []; + const r = await client.upload(f, "/s1/lib/A (2020)/a.mkv", { onProgress: (d) => progress.push(d) }); + expect(r.obj.size).toBe(data.length); + expect(progress.at(-1)).toBe(data.length); + expect(progress.length).toBeGreaterThan(2); + const put = mock.requests.find((r) => r.path === "/api/fs/put")!; + expect(put.headers["x-file-sha256"]).toBe(r.hashes.sha256); + expect(put.headers["x-file-md5"]).toBe(r.hashes.md5); + expect(put.headers["x-file-sha1"]).toBe(r.hashes.sha1); + expect(put.headers["file-path"]).toBe(`/s1/lib/A%20(2020)/a.mkv${PARTIAL_SUFFIX}`); + expect(put.headers["as-task"]).toBe("false"); + expect(put.headers["content-length"]).toBe(String(data.length)); + expect(mock.listNames("/s1/lib/A (2020)")).toEqual(["a.mkv"]); + expect(await hashLocalFile(f)).toEqual(r.hashes); + }); + + it("目标已存在:冲突,不靠 put 覆盖", async () => { + const { mock, client } = setup(); + const f = join(dir, "a.mkv"); + await writeFile(f, "new"); + mock.put("/s1/lib/a.mkv", "old"); + expect((await caught(client.upload(f, "/s1/lib/a.mkv"))).kind).toBe("conflict"); + expect(new TextDecoder().decode(mock.read("/s1/lib/a.mkv")!)).toBe("old"); + expect(mock.requests.some((r) => r.path === "/api/fs/put")).toBe(false); + }); + + it("中断:退避重传;次数用尽时失败且远端不留临时文件", async () => { + const { mock, client } = setup({ token: "t" }, { retries: 1 }); + const f = join(dir, "a.mkv"); + await writeFile(f, bytes(2 * 1024 * 1024)); + mock.breakUploadAfter(1024 * 1024); + const ok = await client.upload(f, "/s1/lib/a.mkv"); + expect(ok.obj.size).toBe(2 * 1024 * 1024); + expect(client.stats.retries).toBe(1); + + const { mock: m2, client: c2 } = setup({ token: "t" }, { retries: 0 }); + m2.breakUploadAfter(1024 * 1024); + const e = await caught(c2.upload(f, "/s1/lib/a.mkv")); + expect(e.kind).toBe("unavailable"); + expect(m2.exists("/s1/lib/a.mkv")).toBe(false); + expect(m2.exists(`/s1/lib/a.mkv${PARTIAL_SUFFIX}`)).toBe(false); + }); + + it("接口成功但目标取不到:失败(调用方据此不删源)", async () => { + let now = 0; + const { mock, client } = setup( + { token: "t" }, + { + now: () => now, + sleep: async (ms) => { + now += ms; + }, + }, + ); + const f = join(dir, "a.mkv"); + await writeFile(f, "x"); + mock.dropNextUpload(); + expect((await caught(client.upload(f, "/s1/lib/a.mkv"))).kind).toBe("not_found"); + }); + + it("As-Task:等上传任务完成", async () => { + const { mock, client } = setup({ token: "t", taskPolls: 2 }, { uploadAsTask: true }); + const f = join(dir, "a.mkv"); + await writeFile(f, "hello"); + const r = await client.upload(f, "/s1/lib/a.mkv"); + expect(r.obj.size).toBe(5); + expect(mock.requests.find((x) => x.path === "/api/fs/put")!.headers["as-task"]).toBe("true"); + expect(mock.requests.filter((x) => x.path.startsWith("/api/task/upload/info")).length).toBe(3); + }); + + it("空闲超时:服务端不读数据时中止", async () => { + const mock = createMockAlist({ token: "t" }); + const stuck: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(String(input), init); + if (new URL(req.url).pathname === "/api/fs/put") + return new Promise((_, reject) => + init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)), + ); + return mock.fetch(input, init); + }) as typeof fetch; + const client = new AlistClient({ + baseUrl: mock.base, + auth: { kind: "token", token: "t" }, + fetch: stuck, + qps: 0, + idleTimeoutMs: 50, + retries: 0, + }); + const f = join(dir, "a.mkv"); + await writeFile(f, "x"); + expect((await caught(client.upload(f, "/s1/a.mkv"))).kind).toBe("timeout"); + }); +}); + +describe("下载", () => { + it("写临时文件成功后改名;带进度;sha256 记事实", async () => { + const { mock, client } = setup(); + const data = bytes(1_500_000); + mock.put("/s1/a.mkv", data); + const out = join(dir, "a.mkv"); + const seen: number[] = []; + const r = await client.download("/s1/a.mkv", out, { onProgress: (d) => seen.push(d) }); + expect(r.bytes).toBe(data.length); + expect(seen.at(-1)).toBe(data.length); + expect(Buffer.compare(await readFile(out), data)).toBe(0); + await expect(stat(`${out}${PARTIAL_SUFFIX}`)).rejects.toThrow(); + }); + + it("中断后 Range 续传", async () => { + const { mock, client } = setup({ token: "t" }, { retries: 2 }); + const data = bytes(1_000_000); + mock.put("/s1/a.mkv", data); + mock.breakDownloadAfter(400_000); + const out = join(dir, "a.mkv"); + await client.download("/s1/a.mkv", out); + expect(Buffer.compare(await readFile(out), data)).toBe(0); + expect(client.stats.retries).toBe(1); + }); + + it("中断且次数用尽:不改名,保留 partial;下次续传完成", async () => { + const { mock, client } = setup({ token: "t" }, { retries: 0 }); + const data = bytes(800_000); + mock.put("/s1/a.mkv", data); + mock.breakDownloadAfter(300_000); + const out = join(dir, "a.mkv"); + expect((await caught(client.download("/s1/a.mkv", out))).kind).toBe("unavailable"); + await expect(stat(out)).rejects.toThrow(); + expect((await stat(`${out}${PARTIAL_SUFFIX}`)).size).toBe(300_000); + await client.download("/s1/a.mkv", out); + expect(Buffer.compare(await readFile(out), data)).toBe(0); + }); + + it("直链过期(401):重新 get 再续", async () => { + const { mock, client } = setup(); + mock.put("/s1/a.mkv", bytes(1000)); + const realFetch = mock.fetch; + let expired = false; + const f: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const u = String(input instanceof Request ? input.url : input); + if (u.includes("/d/") && !expired) { + expired = true; + mock.expireSigns(); + } + return realFetch(input, init); + }) as typeof fetch; + const c2 = new AlistClient({ baseUrl: mock.base, auth: { kind: "token", token: "perm-token" }, fetch: f, qps: 0 }); + await c2.download("/s1/a.mkv", join(dir, "a.mkv")); + expect((await stat(join(dir, "a.mkv"))).size).toBe(1000); + void client; + }); + + it("本地目标已存在:冲突", async () => { + const { mock, client } = setup(); + mock.put("/s1/a.mkv", "x"); + await writeFile(join(dir, "a.mkv"), "local"); + expect((await caught(client.download("/s1/a.mkv", join(dir, "a.mkv")))).kind).toBe("conflict"); + }); + + it("第三方直链不带令牌", async () => { + const mock = createMockAlist({ token: "t" }); + mock.put("/s1/a.mkv", "x"); + const calls: { url: string; auth: string | null }[] = []; + const f: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(String(input), init); + calls.push({ url: req.url, auth: req.headers.get("authorization") }); + if (req.url.startsWith("https://cdn.example")) return new Response("x"); + const res = await mock.fetch(req); + if (new URL(req.url).pathname === "/api/fs/get") { + const j = await res.json(); + j.data.raw_url = "https://cdn.example/file?sign=zzz"; + return Response.json(j); + } + return res; + }) as typeof fetch; + const c = new AlistClient({ baseUrl: mock.base, auth: { kind: "token", token: "t" }, fetch: f, qps: 0 }); + await c.download("/s1/a.mkv", join(dir, "a.mkv")); + expect(calls.find((x) => x.url.startsWith("https://cdn.example"))!.auth).toBeNull(); + }); +}); + +describe("限速与脱敏", () => { + it("令牌桶:QPS 限制按时钟放行", async () => { + let now = 0; + const waits: number[] = []; + const l = new AlistLimiter( + 2, + 0, + () => now, + async (ms) => { + waits.push(ms); + now += ms; + }, + ); + for (let i = 0; i < 5; i++) await l.run(async () => i); + expect(waits.length).toBe(3); + expect(now).toBeGreaterThanOrEqual(1500); + }); + + it("并发上限", async () => { + const l = new AlistLimiter(0, 2); + let max = 0; + await Promise.all( + Array.from({ length: 6 }, () => + l.run(async () => { + max = Math.max(max, l.inFlight); + await new Promise((r) => setTimeout(r, 5)); + }), + ), + ); + expect(max).toBe(2); + }); + + it("直链 sign、令牌、路径密码不进日志", () => { + expect(redactAlistUrl("http://u:p@h/d/a.mkv?sign=SECRET123:0")).not.toContain("SECRET123"); + const t = redactAlistText("GET http://h/d/a.mkv?sign=abcdef failed token=perm-token-xyz", ["perm-token-xyz"]); + expect(t).not.toContain("abcdef"); + expect(t).not.toContain("perm-token-xyz"); + }); + + it("日志记录里没有令牌", async () => { + const lines: string[] = []; + const logger = { warn: (o: Record, m: string) => void lines.push(JSON.stringify(o) + m) }; + const { mock, client } = setup({ username: "u", password: "secretpw" }, { logger }); + await client.me(); + mock.invalidateSessions(); + await client.me(); + expect(lines.length).toBeGreaterThan(0); + expect(lines.join("\n")).not.toMatch(/secretpw|sess-/); + }); +}); diff --git a/packages/adapters/src/alist/client.ts b/packages/adapters/src/alist/client.ts new file mode 100644 index 00000000..74eac551 --- /dev/null +++ b/packages/adapters/src/alist/client.ts @@ -0,0 +1,951 @@ +// Alist / OpenList v3 接口客户端(docs/design/storage-alist.md)。对照 MP v3 `storages/alist.py`,只借行为。 +// 与 MP 的差异(§9):错误与「不存在」分开、401 自动重登、异步任务轮询到完成、同名冲突检查 + 暂存目录改名、 +// 上传先传临时名、下载断点续传 + 直链过期重取、refresh 节制 + 每实例限速。 +// 网盘方向的成功判定与 MP 一致:接口成功 + 目标存在,不比对大小 / 哈希(用户决定)。 +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { link, open, rm, stat, unlink } from "node:fs/promises"; +import { posix } from "node:path"; +import { AlistError, classifyAlist, isAlistNotFound } from "./errors.ts"; +import { AlistLimiter } from "./limiter.ts"; + +export type AlistAuth = + | { kind: "token"; token: string } + | { kind: "password"; username: string; password: string } + | { kind: "guest" }; + +export interface AlistLogger { + debug?(obj: Record, msg: string): void; + warn?(obj: Record, msg: string): void; +} + +export interface AlistClientOptions { + baseUrl: string; + auth: AlistAuth; + /** 路径密码(Alist「元信息」里给目录设的密码),list / get 时带上 */ + pathPassword?: string | null; + fetch?: typeof fetch; + /** 普通请求超时(ms),缺省 30 s */ + timeoutMs?: number; + /** 数据流无进度空闲超时(ms),缺省 120 s */ + idleTimeoutMs?: number; + /** 可重试错误的重试次数,缺省 3 */ + retries?: number; + /** 每秒请求数上限,缺省 5;0 不限 */ + qps?: number; + /** 并发上限,缺省 4;0 不限 */ + concurrency?: number; + /** 异步任务等待上限(ms),缺省 6 h */ + taskTimeoutMs?: number; + /** 上传用 As-Task(缺省 false,同 MP) */ + uploadAsTask?: boolean; + /** 退避起点(ms),缺省 500 */ + backoffMs?: number; + sleep?: (ms: number) => Promise; + now?: () => number; + logger?: AlistLogger; +} + +export interface AlistObj { + /** Alist 内绝对路径(无尾斜杠) */ + path: string; + name: string; + isDir: boolean; + size: number; + /** 修改时间(ms) */ + modified: number; + created: number; + sign: string; + thumb: string; + hashInfo: Record | null; + rawUrl?: string; + provider?: string; +} + +export interface AlistHashes { + md5: string; + sha1: string; + sha256: string; +} + +export interface AlistTask { + id: string; + name: string; + state: number; + status: string; + progress: number; + error: string; + totalBytes: number; +} + +export interface AlistUsage { + total: number; + free: number; + used: number; +} + +export type ProgressCallback = (done: number, total: number) => void; +export type TaskKind = "copy" | "move" | "upload"; + +/** 任务状态:0 待执行 1 执行中 2 成功 3 取消中 4 已取消 5 出错 6 失败中 7 失败 8 等待重试 9 重试前 */ +export const TASK_SUCCEEDED = 2; +const TASK_CANCELED = new Set([3, 4]); +const TASK_FAILED = new Set([5, 7]); + +/** MP:per_page<=0 会退回后端默认 200,显式指定最大页大小 */ +export const ALIST_PAGE_SIZE = 500; +/** MP:账号密码令牌缓存 2 天、提前 5 分钟更新 */ +export const ALIST_TOKEN_TTL_MS = 2 * 86_400_000 - 5 * 60_000; +export const PARTIAL_SUFFIX = ".uhd-partial"; +const STAGE_PREFIX = ".umt-stage-"; + +/** 规范化 Alist 路径:以 / 开头、无尾斜杠、无空段 */ +export function normAlistPath(p: string): string { + const segs = p.split("/").filter((s) => s.length > 0 && s !== "."); + if (segs.some((s) => s === "..")) throw new AlistError("error", `路径不能含 ..: ${p}`); + return `/${segs.join("/")}`; +} + +/** 按段 URL 编码(保留 /),File-Path 头与 /d 下载链接用 */ +export const encodeAlistPath = (p: string) => normAlistPath(p).split("/").map(encodeURIComponent).join("/"); + +const parseTime = (v: unknown): number => { + const t = typeof v === "string" ? Date.parse(v) : Number.NaN; + return Number.isFinite(t) ? t : 0; +}; + +function parseHashInfo(v: unknown): Record | null { + if (!v || typeof v !== "object") return null; + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) if (typeof val === "string" && val) out[k] = val; + return Object.keys(out).length ? out : null; +} + +function toObj(path: string, d: Record): AlistObj { + return { + path: normAlistPath(path), + name: String(d.name ?? posix.basename(path)), + isDir: d.is_dir === true, + size: Number(d.size ?? 0) || 0, + modified: parseTime(d.modified), + created: parseTime(d.created), + sign: String(d.sign ?? ""), + thumb: String(d.thumb ?? ""), + hashInfo: parseHashInfo(d.hash_info), + ...(typeof d.raw_url === "string" && d.raw_url ? { rawUrl: d.raw_url } : {}), + ...(typeof d.provider === "string" ? { provider: d.provider } : {}), + }; +} + +function toTask(d: Record): AlistTask { + return { + id: String(d.id ?? ""), + name: String(d.name ?? ""), + state: Number(d.state ?? 0), + status: String(d.status ?? ""), + progress: Number(d.progress ?? 0), + error: String(d.error ?? ""), + totalBytes: Number(d.total_bytes ?? 0), + }; +} + +/** 一遍读出 md5 / sha1 / sha256(MP 秒传头;§9-5) */ +export async function hashLocalFile( + path: string, + opts: { signal?: AbortSignal; onProgress?: ProgressCallback } = {}, +): Promise { + const md5 = createHash("md5"); + const sha1 = createHash("sha1"); + const sha256 = createHash("sha256"); + const total = (await stat(path)).size; + let done = 0; + for await (const chunk of createReadStream(path, { highWaterMark: 1024 * 1024 })) { + opts.signal?.throwIfAborted(); + const b = chunk as Uint8Array; + md5.update(b); + sha1.update(b); + sha256.update(b); + done += b.length; + opts.onProgress?.(done, total); + } + return { md5: md5.digest("hex"), sha1: sha1.digest("hex"), sha256: sha256.digest("hex") }; +} + +const abortedError = () => new AlistError("aborted", "操作已取消"); + +/** 空闲计时器:每次前进 touch(),超过 ms 没前进就 abort */ +function idleWatch(ms: number, outer?: AbortSignal) { + const ctl = new AbortController(); + let timedOut = false; + let timer: ReturnType | undefined; + const arm = () => { + clearTimeout(timer); + timer = setTimeout(() => { + timedOut = true; + ctl.abort(new AlistError("timeout", `数据流 ${Math.round(ms / 1000)} 秒没有进展`)); + }, ms); + }; + const onOuter = () => ctl.abort(abortedError()); + outer?.addEventListener("abort", onOuter, { once: true }); + arm(); + return { + signal: ctl.signal, + touch: arm, + get timedOut() { + return timedOut; + }, + done() { + clearTimeout(timer); + outer?.removeEventListener("abort", onOuter); + }, + }; +} + +export class AlistClient { + readonly baseUrl: string; + private readonly fetchFn: typeof fetch; + private readonly limiter: AlistLimiter; + private readonly o: Required< + Pick< + AlistClientOptions, + "timeoutMs" | "idleTimeoutMs" | "retries" | "taskTimeoutMs" | "uploadAsTask" | "backoffMs" | "sleep" | "now" + > + >; + private tokenCache: { token: string; exp: number } | null = null; + private loginInflight: Promise | null = null; + /** 观测用:每个 API 请求(路径 + 是否 refresh) */ + readonly stats = { requests: 0, refreshes: 0, relogins: 0, retries: 0 }; + + constructor(private readonly opts: AlistClientOptions) { + this.baseUrl = opts.baseUrl.trim().replace(/\/+$/, ""); + if (!/^https?:\/\//i.test(this.baseUrl)) throw new AlistError("error", "Alist 地址必须以 http:// 或 https:// 开头"); + this.fetchFn = opts.fetch ?? fetch; + const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = opts.now ?? (() => Date.now()); + this.o = { + timeoutMs: opts.timeoutMs ?? 30_000, + idleTimeoutMs: opts.idleTimeoutMs ?? 120_000, + retries: opts.retries ?? 3, + taskTimeoutMs: opts.taskTimeoutMs ?? 6 * 3600_000, + uploadAsTask: opts.uploadAsTask ?? false, + backoffMs: opts.backoffMs ?? 500, + sleep, + now, + }; + this.limiter = new AlistLimiter(opts.qps ?? 5, opts.concurrency ?? 4, now, sleep); + } + + // ── 令牌(§2.2、§9-2)── + + private async login(): Promise { + const a = this.opts.auth; + if (a.kind !== "password") throw new AlistError("auth", "没有账号密码,无法登录"); + const data = await this.request<{ token?: string }>("POST", "/api/auth/login", { + body: { username: a.username, password: a.password }, + auth: false, + }); + if (!data?.token) throw new AlistError("auth", "登录成功但没有返回令牌"); + this.tokenCache = { token: data.token, exp: this.o.now() + ALIST_TOKEN_TTL_MS }; + this.opts.logger?.debug?.({ base: this.baseUrl }, "Alist 登录换取令牌成功"); + return data.token; + } + + private async token(): Promise { + const a = this.opts.auth; + if (a.kind === "token") return a.token; + if (a.kind === "guest") return null; + if (this.tokenCache && this.tokenCache.exp > this.o.now()) return this.tokenCache.token; + this.loginInflight ??= this.login().finally(() => { + this.loginInflight = null; + }); + return this.loginInflight; + } + + /** 配置变更时调用方重建客户端;这里只供测试 / 运维清缓存 */ + invalidateToken(): void { + this.tokenCache = null; + } + + // ── 请求 ── + + private backoff(attempt: number, err: AlistError): number { + const base = err.kind === "rate_limited" ? Math.max(this.o.backoffMs, 5000) : this.o.backoffMs; + return Math.min(base * 2 ** attempt, 60_000); + } + + private async request( + method: "GET" | "POST", + apiPath: string, + opts: { body?: unknown; auth?: boolean; signal?: AbortSignal; path?: string } = {}, + ): Promise { + const headers: Record = { "Content-Type": "application/json" }; + if (opts.auth !== false) { + const t = await this.token(); + if (t) headers.Authorization = t; + } + const signal = opts.signal + ? AbortSignal.any([opts.signal, AbortSignal.timeout(this.o.timeoutMs)]) + : AbortSignal.timeout(this.o.timeoutMs); + let res: Response; + this.stats.requests++; + try { + res = await this.fetchFn(`${this.baseUrl}${apiPath}`, { + method, + headers, + ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}), + signal, + }); + } catch (e) { + if (opts.signal?.aborted) throw abortedError(); + if ((e as Error)?.name === "TimeoutError") + throw new AlistError("timeout", `请求超时:${apiPath}`, opts.path ? { path: opts.path } : {}); + throw new AlistError( + "unavailable", + `连不上 Alist:${(e as Error)?.message ?? e}`, + opts.path ? { path: opts.path } : {}, + ); + } + const text = await res.text().catch(() => ""); + let json: { code?: number; message?: string; data?: unknown } | null = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + if (!res.ok || !json || typeof json.code !== "number") { + throw classifyAlist(res.status, json?.code ?? null, json?.message ?? text.slice(0, 200), opts.path); + } + if (json.code !== 200) throw classifyAlist(res.status, json.code, json.message ?? "", opts.path); + return json.data as T; + } + + /** 带限速、401 重登一次、可重试错误退避的 API 调用 */ + private async api( + apiPath: string, + body: unknown, + opts: { retry?: boolean; signal?: AbortSignal; path?: string; method?: "GET" | "POST" } = {}, + ): Promise { + let attempt = 0; + let relogged = false; + for (;;) { + try { + return await this.limiter.run(() => + this.request(opts.method ?? "POST", apiPath, { + body, + ...(opts.signal ? { signal: opts.signal } : {}), + ...(opts.path ? { path: opts.path } : {}), + }), + ); + } catch (e) { + const err = e instanceof AlistError ? e : new AlistError("error", String(e)); + if (err.kind === "auth" && this.opts.auth.kind === "password" && !relogged) { + relogged = true; + this.stats.relogins++; + this.tokenCache = null; + this.opts.logger?.warn?.({ base: this.baseUrl, api: apiPath }, "Alist 令牌失效,重新登录"); + continue; + } + if (opts.retry !== false && err.retryable && attempt < this.o.retries && !opts.signal?.aborted) { + this.stats.retries++; + await this.o.sleep(this.backoff(attempt, err)); + attempt++; + continue; + } + throw err; + } + } + } + + // ── 基本信息 ── + + async me(): Promise<{ username: string; basePath: string; role: number; permission: number }> { + const d = await this.api>("/api/me", undefined, { method: "GET" }); + return { + username: String(d.username ?? ""), + basePath: String(d.base_path ?? "/"), + role: Number(d.role ?? 0), + permission: Number(d.permission ?? 0), + }; + } + + async version(): Promise { + try { + const d = await this.limiter.run(() => + this.request>("GET", "/api/public/settings", { auth: false }), + ); + return typeof d?.version === "string" ? d.version : null; + } catch (e) { + if (e instanceof AlistError && (e.kind === "unavailable" || e.kind === "timeout")) throw e; + return null; + } + } + + // ── 读 ── + + /** 列目录(自动翻页;refresh 只在用户刷新 / 写后确认时传,§9-8) */ + async list(path: string, opts: { refresh?: boolean; signal?: AbortSignal } = {}): Promise { + const dir = normAlistPath(path); + const out: AlistObj[] = []; + if (opts.refresh) this.stats.refreshes++; + for (let page = 1; ; page++) { + const d = await this.api>( + "/api/fs/list", + { + path: dir, + password: this.opts.pathPassword ?? "", + page, + per_page: ALIST_PAGE_SIZE, + refresh: page === 1 && opts.refresh === true, + }, + { path: dir, ...(opts.signal ? { signal: opts.signal } : {}) }, + ); + const content = (d?.content as Record[] | null) ?? []; + for (const it of content) out.push(toObj(posix.join(dir, String(it.name ?? "")), it)); + const total = Number(d?.filtered_total ?? d?.total ?? 0) || 0; + const pagesTotal = Number(d?.pages_total ?? 0) || 0; + if (content.length === 0 || out.length >= total) return out; + if (d?.has_more === false || (pagesTotal && page >= pagesTotal)) return out; + } + } + + /** 取单个对象;不存在抛 AlistError(not_found),其它失败抛对应类型(§9-1) */ + async get(path: string, opts: { refresh?: boolean; signal?: AbortSignal } = {}): Promise { + const p = normAlistPath(path); + if (opts.refresh) this.stats.refreshes++; + const d = await this.api>( + "/api/fs/get", + { path: p, password: this.opts.pathPassword ?? "", page: 1, per_page: 0, refresh: opts.refresh === true }, + { path: p, ...(opts.signal ? { signal: opts.signal } : {}) }, + ); + return toObj(p, d ?? {}); + } + + /** 不存在 → null;其它失败照抛 */ + async stat(path: string, opts: { refresh?: boolean; signal?: AbortSignal } = {}): Promise { + try { + return await this.get(path, opts); + } catch (e) { + if (isAlistNotFound(e)) return null; + throw e; + } + } + + /** 下载直链:raw_url 优先,否则 /d?sign=(同 MP) */ + async rawUrl(path: string, obj?: AlistObj): Promise { + const o = obj ?? (await this.get(path)); + if (o.isDir) throw new AlistError("error", `是目录,不能下载:${o.path}`); + if (o.rawUrl) return o.rawUrl; + return `${this.baseUrl}/d${encodeAlistPath(o.path)}${o.sign ? `?sign=${encodeURIComponent(o.sign)}` : ""}`; + } + + /** 直链与 Alist 同源时才带令牌(第三方 CDN 直链不能带令牌,MP 会带) */ + private async downloadHeaders(url: string, extra: Record = {}): Promise> { + const h = { ...extra }; + if (new URL(url).origin === new URL(this.baseUrl).origin) { + const t = await this.token(); + if (t) h.Authorization = t; + } + return h; + } + + /** 打开下载流(文件管理下载代理用;支持 Range 透传) */ + async openRead(path: string, opts: { range?: string; signal?: AbortSignal } = {}): Promise { + const obj = await this.get(path); + for (let i = 0; i < 3; i++) { + const url = await this.rawUrl(path, i === 0 ? obj : undefined); + const res = await this.limiter.run( + async () => + this.fetchFn(url, { + headers: await this.downloadHeaders(url, opts.range ? { Range: opts.range } : {}), + ...(opts.signal ? { signal: opts.signal } : {}), + }), + { data: true }, + ); + if ([401, 403, 410].includes(res.status)) { + await res.body?.cancel(); + continue; + } + if (!res.ok) { + await res.body?.cancel(); + throw classifyAlist(res.status, null, `下载失败 HTTP ${res.status}`, obj.path); + } + return res; + } + throw new AlistError("auth", "下载直链多次被拒绝(签名过期或无权限)", { path: obj.path }); + } + + /** 读小文件到内存(NFO 比对等);超过 maxBytes 抛错 */ + async readFile(path: string, maxBytes = 4 * 1024 * 1024): Promise { + const res = await this.openRead(path); + const buf = new Uint8Array(await res.arrayBuffer()); + if (buf.length > maxBytes) throw new AlistError("error", `文件过大(${buf.length} 字节)`, { path }); + return buf; + } + + /** 容量:/api/admin/storage/list 的 mount_details;拿不到返回 null,不报错(§9-10) */ + async usage(path = "/"): Promise { + try { + const d = await this.api<{ content?: Record[] }>("/api/admin/storage/list", undefined, { + method: "GET", + retry: false, + }); + const p = normAlistPath(path); + const hit = (d?.content ?? []) + .filter((s) => { + const m = normAlistPath(String(s.mount_path ?? "/")); + return m === "/" || p === m || p.startsWith(`${m}/`); + }) + .sort((a, b) => String(b.mount_path).length - String(a.mount_path).length)[0]; + const md = hit?.mount_details as Record | undefined; + const total = Number(md?.total_space ?? 0); + if (!md || !(total > 0)) return null; + const free = Number(md.free_space ?? 0); + return { total, free, used: Number(md.used_space ?? total - free) }; + } catch { + return null; + } + } + + // ── 写 ── + + /** 建目录(Alist 递归建,已存在也成功) */ + async mkdir(path: string): Promise { + const p = normAlistPath(path); + if (p === "/") return; + await this.api("/api/fs/mkdir", { path: p }, { path: p }); + } + + /** 改名(只改最后一段);目标已存在抛 conflict */ + async rename(path: string, newName: string): Promise { + if (!newName || newName.includes("/")) throw new AlistError("error", `非法文件名:${newName}`); + const p = normAlistPath(path); + await this.api("/api/fs/rename", { path: p, name: newName }, { path: p, retry: false }); + } + + /** 删除(注意 Alist 删除不存在的名字也返回成功) */ + async remove(dir: string, names: string[]): Promise { + if (!names.length) return; + const d = normAlistPath(dir); + await this.api("/api/fs/remove", { dir: d, names }, { path: d, retry: false }); + } + + async removePath(path: string): Promise { + const p = normAlistPath(path); + if (p === "/") throw new AlistError("error", "不能删除根目录"); + await this.remove(posix.dirname(p), [posix.basename(p)]); + } + + /** 原始 copy / move:返回任务 id(同步完成时为空) */ + async rawTransfer(kind: "copy" | "move", srcDir: string, dstDir: string, names: string[]): Promise { + const d = await this.api<{ tasks?: Record[] } | null>( + `/api/fs/${kind}`, + { src_dir: normAlistPath(srcDir), dst_dir: normAlistPath(dstDir), names }, + { path: srcDir, retry: false }, + ); + return (d?.tasks ?? []).map((t) => String(t.id ?? "")).filter(Boolean); + } + + /** 查任务;接口不支持(老版本)返回 null,任务已不在返回 undefined */ + async taskInfo(kind: TaskKind, id: string): Promise { + for (const prefix of ["/api/task", "/api/admin/task"]) { + try { + const d = await this.api>( + `${prefix}/${kind}/info?tid=${encodeURIComponent(id)}`, + undefined, + {}, + ); + return toTask(d ?? {}); + } catch (e) { + if (!(e instanceof AlistError)) throw e; + // HTTP 404(路由不存在)→ 试下一个前缀;JSON code 404 task not found → 任务不在了 + if (e.detail.status === 404 && e.detail.code === undefined) continue; + if (e.kind === "not_found") return undefined; + throw e; + } + } + return null; + } + + /** + * 等异步任务完成(§9-3):指数退避轮询,成功返回 "succeeded";接口不支持 / 任务已不在返回 "unknown" + * (调用方改为等目标出现);取消 / 失败抛 task_failed(带任务 error);超时抛 timeout。 + */ + async waitTask( + kind: TaskKind, + id: string, + opts: { timeoutMs?: number; signal?: AbortSignal; onProgress?: (t: AlistTask) => void } = {}, + ): Promise<"succeeded" | "unknown"> { + const deadline = this.o.now() + (opts.timeoutMs ?? this.o.taskTimeoutMs); + let delay = 500; + for (;;) { + opts.signal?.throwIfAborted(); + const t = await this.taskInfo(kind, id); + if (t === null || t === undefined) return "unknown"; + opts.onProgress?.(t); + if (t.state === TASK_SUCCEEDED) return "succeeded"; + if (TASK_CANCELED.has(t.state)) throw new AlistError("task_failed", `Alist ${kind} 任务已取消:${t.name}`); + if (TASK_FAILED.has(t.state)) + throw new AlistError("task_failed", `Alist ${kind} 任务失败:${t.error || t.status || t.name}`); + if (this.o.now() >= deadline) throw new AlistError("timeout", `等待 Alist ${kind} 任务超时:${t.name}`); + await this.o.sleep(delay); + delay = Math.min(delay * 2, 10_000); + } + } + + /** 写后等目标出现(refresh,退避);超时抛 not_found */ + async waitExists(path: string, opts: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise { + const deadline = this.o.now() + (opts.timeoutMs ?? 60_000); + let delay = 500; + for (;;) { + opts.signal?.throwIfAborted(); + const o = await this.stat(path, { refresh: true }); + if (o) return o; + if (this.o.now() >= deadline) throw new AlistError("not_found", `写入后目标仍不存在:${normAlistPath(path)}`); + await this.o.sleep(delay); + delay = Math.min(delay * 2, 10_000); + } + } + + private async finishTransfer(kind: "copy" | "move", ids: string[], target: string, signal?: AbortSignal) { + let unknown = ids.length === 0; + for (const id of ids) { + if ((await this.waitTask(kind, id, signal ? { signal } : {})) === "unknown") unknown = true; + } + // 成功判定(同 MP、用户决定):任务成功 + 目标存在;没有任务信息时等目标出现 + return this.waitExists(target, { + timeoutMs: unknown ? this.o.taskTimeoutMs : 60_000, + ...(signal ? { signal } : {}), + }); + } + + /** + * 同实例复制一个文件或目录到 dst(完整目标路径)。目标已存在抛 conflict(§9-4); + * 名字不同或目标目录里已有同名源名时走暂存目录 `.umt-stage-*`:复制 → 改名 → 移入,失败清理暂存。 + */ + async copyFile(src: string, dst: string, opts: { signal?: AbortSignal } = {}): Promise { + return this.transferFile("copy", src, dst, opts); + } + + /** 同实例移动(语义同 copyFile;同目录只改名) */ + async moveFile(src: string, dst: string, opts: { signal?: AbortSignal } = {}): Promise { + return this.transferFile("move", src, dst, opts); + } + + private async transferFile( + kind: "copy" | "move", + srcIn: string, + dstIn: string, + opts: { signal?: AbortSignal }, + ): Promise { + const src = normAlistPath(srcIn); + const dst = normAlistPath(dstIn); + if (src === dst) throw new AlistError("conflict", `源与目标相同:${src}`); + if (dst.startsWith(`${src}/`)) throw new AlistError("error", "不能把目录复制 / 移动到它自己里面"); + await this.get(src); // 源不存在 → not_found + if (await this.stat(dst)) throw new AlistError("conflict", `目标已存在:${dst}`, { path: dst }); + const srcDir = posix.dirname(src); + const srcName = posix.basename(src); + const dstDir = posix.dirname(dst); + const dstName = posix.basename(dst); + await this.mkdir(dstDir); + if (kind === "move" && srcDir === dstDir) { + await this.rename(src, dstName); + return this.waitExists(dst, opts.signal ? { signal: opts.signal } : {}); + } + if (srcName === dstName) { + const ids = await this.rawTransfer(kind, srcDir, dstDir, [srcName]); + return this.finishTransfer(kind, ids, dst, opts.signal); + } + // 暂存目录:不在目标目录里按源名落地,避免与已有同名文件冲突 / 覆盖 + const stageName = `${STAGE_PREFIX}${randomUUID().slice(0, 8)}`; + const stage = posix.join(dstDir, stageName); + let movedToStage = false; + try { + await this.mkdir(stage); + const ids = await this.rawTransfer(kind, srcDir, stage, [srcName]); + movedToStage = kind === "move"; + await this.finishTransfer(kind, ids, posix.join(stage, srcName), opts.signal); + await this.rename(posix.join(stage, srcName), dstName); + // 暂存目录与目标同目录 → 同驱动 move,立即完成 + const ids2 = await this.rawTransfer("move", stage, dstDir, [dstName]); + movedToStage = false; + const obj = await this.finishTransfer("move", ids2, dst, opts.signal); + await this.remove(dstDir, [stageName]).catch(() => undefined); + return obj; + } catch (e) { + if (movedToStage) { + // 移动失败:尽量把源搬回去 + for (const n of [srcName, dstName]) { + const p = posix.join(stage, n); + if (await this.stat(p, { refresh: true }).catch(() => null)) { + await this.rename(p, srcName).catch(() => undefined); + await this.rawTransfer("move", stage, srcDir, [srcName]).catch(() => undefined); + break; + } + } + } + await this.remove(dstDir, [stageName]).catch(() => undefined); + throw e; + } + } + + // ── 上传(§2.6)── + + /** + * 上传本地文件到 dst(完整路径)。目标已存在抛 conflict(Alist 的 put 会静默覆盖,不依赖它); + * 先传 `.uhd-partial` 再改名;成功判定 = 接口成功 + 目标存在(同 MP)。 + */ + async upload( + localPath: string, + dstIn: string, + opts: { + signal?: AbortSignal; + onProgress?: ProgressCallback; + onHashProgress?: ProgressCallback; + hashes?: AlistHashes; + asTask?: boolean; + } = {}, + ): Promise<{ obj: AlistObj; hashes: AlistHashes; bytes: number }> { + const dst = normAlistPath(dstIn); + const st = await stat(localPath); + if (!st.isFile()) throw new AlistError("error", `不是文件:${localPath}`); + const hashes = + opts.hashes ?? + (await hashLocalFile(localPath, { + ...(opts.signal ? { signal: opts.signal } : {}), + ...(opts.onHashProgress ? { onProgress: opts.onHashProgress } : {}), + })); + if (await this.stat(dst)) throw new AlistError("conflict", `目标已存在:${dst}`, { path: dst }); + const tmp = `${dst}${PARTIAL_SUFFIX}`; + await this.mkdir(posix.dirname(dst)); + if (await this.stat(tmp)) await this.removePath(tmp); + let attempt = 0; + for (;;) { + try { + await this.putOnce(localPath, tmp, st.size, st.mtimeMs, hashes, opts); + break; + } catch (e) { + const err = e instanceof AlistError ? e : new AlistError("unavailable", String((e as Error)?.message ?? e)); + await this.removePath(tmp).catch(() => undefined); + if (err.retryable && attempt < this.o.retries && !opts.signal?.aborted) { + this.stats.retries++; + this.opts.logger?.warn?.({ dst, attempt, kind: err.kind }, "Alist 上传失败,重试"); + await this.o.sleep(this.backoff(attempt, err)); + attempt++; + continue; + } + throw err; + } + } + try { + await this.waitExists(tmp, { timeoutMs: 60_000, ...(opts.signal ? { signal: opts.signal } : {}) }); + await this.rename(tmp, posix.basename(dst)); + const obj = await this.waitExists(dst, opts.signal ? { signal: opts.signal } : {}); + return { obj, hashes, bytes: st.size }; + } catch (e) { + await this.removePath(tmp).catch(() => undefined); + throw e; + } + } + + private async putOnce( + localPath: string, + target: string, + size: number, + mtimeMs: number, + hashes: AlistHashes, + opts: { signal?: AbortSignal; onProgress?: ProgressCallback; asTask?: boolean }, + ): Promise { + const asTask = opts.asTask ?? this.o.uploadAsTask; + const t = await this.token(); + const watch = idleWatch(this.o.idleTimeoutMs, opts.signal); + const fh = await open(localPath, "r"); + let sent = 0; + const body = new ReadableStream({ + async pull(ctl) { + const buf = new Uint8Array(1024 * 1024); + const { bytesRead } = await fh.read(buf, 0, buf.length, null); + if (bytesRead === 0) { + ctl.close(); + return; + } + sent += bytesRead; + watch.touch(); + opts.onProgress?.(sent, size); + ctl.enqueue(buf.subarray(0, bytesRead)); + }, + }); + const headers: Record = { + "Content-Type": "application/octet-stream", + "Content-Length": String(size), + "File-Path": encodeAlistPath(target), + "As-Task": String(asTask), + "Last-Modified": String(Math.floor(mtimeMs)), + "X-File-Md5": hashes.md5, + "X-File-Sha1": hashes.sha1, + "X-File-Sha256": hashes.sha256, + }; + if (t) headers.Authorization = t; + let data: { task?: Record } | null; + try { + data = await this.limiter.run( + async () => { + this.stats.requests++; + let res: Response; + try { + res = await this.fetchFn(`${this.baseUrl}/api/fs/put`, { + method: "PUT", + headers, + body, + signal: watch.signal, + duplex: "half", + } as RequestInit); + } catch (e) { + if (opts.signal?.aborted) throw abortedError(); + if (watch.timedOut) + throw new AlistError("timeout", `上传 ${Math.round(this.o.idleTimeoutMs / 1000)} 秒没有进展`); + throw new AlistError("unavailable", `上传中断:${(e as Error)?.message ?? e}`, { path: target }); + } + const text = await res.text().catch(() => ""); + let json: { code?: number; message?: string; data?: unknown } | null = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + if (!res.ok || !json || json.code !== 200) + throw classifyAlist(res.status, json?.code ?? null, json?.message ?? text.slice(0, 200), target); + return json.data as { task?: Record } | null; + }, + { data: true }, + ); + } finally { + watch.done(); + await fh.close().catch(() => undefined); + } + const taskId = data?.task?.id; + if (asTask && typeof taskId === "string" && taskId) { + await this.waitTask("upload", taskId, opts.signal ? { signal: opts.signal } : {}); + } + } + + // ── 下载(§2.7)── + + /** + * 下载到本地 localPath:写 `.uhd-partial`,支持 Range 续传、直链过期重取; + * 流正常结束即成功(同 MP,不比对大小 / 哈希),之后不覆盖地改名。目标已存在抛 conflict。 + */ + async download( + remote: string, + localPath: string, + opts: { signal?: AbortSignal; onProgress?: ProgressCallback } = {}, + ): Promise<{ bytes: number; sha256: string; obj: AlistObj }> { + if ( + await stat(localPath).then( + () => true, + () => false, + ) + ) + throw new AlistError("conflict", `本地目标已存在:${localPath}`); + let obj = await this.get(remote, opts.signal ? { signal: opts.signal } : {}); + if (obj.isDir) throw new AlistError("error", `是目录,不能下载:${obj.path}`); + const partial = `${localPath}${PARTIAL_SUFFIX}`; + let signFailures = 0; + let attempt = 0; + let url = await this.rawUrl(remote, obj); + for (;;) { + opts.signal?.throwIfAborted(); + const have = await stat(partial).then( + (s) => s.size, + () => 0, + ); + const watch = idleWatch(this.o.idleTimeoutMs, opts.signal); + try { + const res = await this.limiter.run( + async () => + this.fetchFn(url, { + headers: await this.downloadHeaders(url, have > 0 ? { Range: `bytes=${have}-` } : {}), + signal: watch.signal, + }), + { data: true }, + ); + if ([401, 403, 410].includes(res.status)) { + await res.body?.cancel(); + if (++signFailures > 3) + throw new AlistError("auth", `下载直链多次被拒绝(HTTP ${res.status})`, { path: obj.path }); + this.opts.logger?.warn?.({ path: obj.path, status: res.status }, "Alist 直链失效,重新获取"); + obj = await this.get(remote); + url = await this.rawUrl(remote, obj); + continue; + } + if (res.status === 416 && have > 0) { + // 已经下全了(上次在改名前中断) + await res.body?.cancel(); + break; + } + if (!res.ok || !res.body) { + await res.body?.cancel(); + throw classifyAlist(res.status, null, `下载失败 HTTP ${res.status}`, obj.path); + } + const append = have > 0 && res.status === 206; + const total = append + ? have + Number(res.headers.get("content-length") ?? 0) + : Number(res.headers.get("content-length") ?? obj.size) || obj.size; + const fh = await open(partial, append ? "a" : "w"); + let done = append ? have : 0; + try { + const reader = res.body.getReader(); + for (;;) { + const { value, done: end } = await reader.read(); + if (end) break; + watch.touch(); + await fh.write(value); + done += value.length; + opts.onProgress?.(done, total); + } + } finally { + await fh.close(); + } + break; + } catch (e) { + if (opts.signal?.aborted) throw abortedError(); + const err = + e instanceof AlistError + ? e + : watch.timedOut + ? new AlistError("timeout", `下载 ${Math.round(this.o.idleTimeoutMs / 1000)} 秒没有进展`, { + path: obj.path, + }) + : new AlistError("unavailable", `下载中断:${(e as Error)?.message ?? e}`, { path: obj.path }); + if (err.retryable && attempt < this.o.retries) { + this.stats.retries++; + await this.o.sleep(this.backoff(attempt, err)); + attempt++; + continue; + } + throw err; + } finally { + watch.done(); + } + } + const sha = createHash("sha256"); + let bytes = 0; + for await (const chunk of createReadStream(partial, { highWaterMark: 1024 * 1024 })) { + sha.update(chunk as Uint8Array); + bytes += (chunk as Uint8Array).length; + } + try { + await link(partial, localPath); + } catch (e) { + if ((e as { code?: string })?.code === "EEXIST") throw new AlistError("conflict", `本地目标已存在:${localPath}`); + throw e; + } + await unlink(partial).catch(() => undefined); + return { bytes, sha256: sha.digest("hex"), obj }; + } + + /** 清掉本地 partial(调用方放弃续传时) */ + static async dropPartial(localPath: string): Promise { + await rm(`${localPath}${PARTIAL_SUFFIX}`, { force: true }); + } +} diff --git a/packages/adapters/src/alist/errors.ts b/packages/adapters/src/alist/errors.ts new file mode 100644 index 00000000..268a3678 --- /dev/null +++ b/packages/adapters/src/alist/errors.ts @@ -0,0 +1,70 @@ +// Alist / OpenList 错误分类(docs/design/storage-alist.md §2.1、§9-1): +// 「不存在」与连接失败 / 鉴权失败 / 服务端错误分开,调用方不能把后者当成不存在。 + +export type AlistErrorKind = + | "not_found" + | "conflict" + | "auth" + | "rate_limited" + | "unavailable" + | "task_failed" + | "timeout" + | "aborted" + | "error"; + +export class AlistError extends Error { + override name = "AlistError"; + constructor( + readonly kind: AlistErrorKind, + message: string, + readonly detail: { status?: number; code?: number; path?: string } = {}, + ) { + super(message); + } + /** 可重试:连不上、超时、限流 / 风控、5xx */ + get retryable(): boolean { + return this.kind === "unavailable" || this.kind === "rate_limited" || this.kind === "timeout"; + } + /** 对应本地文件系统的错误码(vfs 用;与 FsErrorCode 同名) */ + get fsCode(): string { + switch (this.kind) { + case "not_found": + return "ENOENT"; + case "conflict": + return "EEXIST"; + case "auth": + return "EACCES"; + case "timeout": + case "rate_limited": + return "TIMEOUT"; + case "aborted": + return "ABORTED"; + default: + return "IO"; + } + } +} + +export const isAlistNotFound = (e: unknown): boolean => e instanceof AlistError && e.kind === "not_found"; +export const isAlistConflict = (e: unknown): boolean => e instanceof AlistError && e.kind === "conflict"; + +const NOT_FOUND_RE = /object not found|not exist|no such file|not found/i; +const EXISTS_RE = /\bexists?\b|already exist/i; +const AUTH_RE = /token is (invalidated|expired)|invalid token|unauthorized|password is incorrect|incorrect password/i; +const RATE_RE = /too many|rate limit|频繁|风控|429/i; + +/** HTTP 状态 + 业务 code + message → 错误类型 */ +export function classifyAlist(status: number, code: number | null, message: string, path?: string): AlistError { + const detail = { status, ...(code !== null ? { code } : {}), ...(path ? { path } : {}) }; + const msg = message || `HTTP ${status}`; + if (status === 429 || code === 429 || RATE_RE.test(msg)) return new AlistError("rate_limited", msg, detail); + if (status === 401 || code === 401 || AUTH_RE.test(msg)) return new AlistError("auth", msg, detail); + if (code === 404 && /task/i.test(msg)) return new AlistError("not_found", msg, detail); + if (NOT_FOUND_RE.test(msg)) return new AlistError("not_found", msg, detail); + if (code === 403 && EXISTS_RE.test(msg)) return new AlistError("conflict", msg, detail); + if (status === 403 || code === 403) return new AlistError("auth", msg, detail); + if (status >= 500 && code === null) return new AlistError("unavailable", msg, detail); + if (status >= 500 || (code !== null && code >= 500 && /timeout|connect|EOF|reset|unavailable|bad gateway/i.test(msg))) + return new AlistError("unavailable", msg, detail); + return new AlistError("error", msg, detail); +} diff --git a/packages/adapters/src/alist/index.ts b/packages/adapters/src/alist/index.ts new file mode 100644 index 00000000..2eb7fe89 --- /dev/null +++ b/packages/adapters/src/alist/index.ts @@ -0,0 +1,4 @@ +export * from "./client.ts"; +export * from "./errors.ts"; +export * from "./limiter.ts"; +export * from "./redact.ts"; diff --git a/packages/adapters/src/alist/limiter.ts b/packages/adapters/src/alist/limiter.ts new file mode 100644 index 00000000..2a951962 --- /dev/null +++ b/packages/adapters/src/alist/limiter.ts @@ -0,0 +1,61 @@ +// 每个 Alist 实例一个限速器:令牌桶(QPS)+ 并发上限(docs/design/storage-alist.md §2.3、§9-8)。 +// 数据流(上传 / 下载)占并发但不占 QPS。 + +export class AlistLimiter { + private tokens: number; + private last: number; + private active = 0; + private readonly waiters: (() => void)[] = []; + + constructor( + private readonly qps: number, + private readonly concurrency: number, + private readonly now: () => number = () => Date.now(), + private readonly sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms)), + ) { + this.tokens = Math.max(1, qps); + this.last = now(); + } + + private async takeToken(): Promise { + if (this.qps <= 0) return; + for (;;) { + const t = this.now(); + this.tokens = Math.min(Math.max(1, this.qps), this.tokens + ((t - this.last) / 1000) * this.qps); + this.last = t; + if (this.tokens >= 1) { + this.tokens -= 1; + return; + } + await this.sleep(Math.ceil(((1 - this.tokens) / this.qps) * 1000)); + } + } + + private async acquire(): Promise { + if (this.concurrency <= 0 || this.active < this.concurrency) { + this.active++; + return; + } + await new Promise((r) => this.waiters.push(r)); + this.active++; + } + + private release(): void { + this.active--; + this.waiters.shift()?.(); + } + + async run(fn: () => Promise, opts: { data?: boolean } = {}): Promise { + if (!opts.data) await this.takeToken(); + await this.acquire(); + try { + return await fn(); + } finally { + this.release(); + } + } + + get inFlight(): number { + return this.active; + } +} diff --git a/packages/adapters/src/alist/mock.ts b/packages/adapters/src/alist/mock.ts new file mode 100644 index 00000000..40337843 --- /dev/null +++ b/packages/adapters/src/alist/mock.ts @@ -0,0 +1,491 @@ +// 测试用的进程内 Alist / OpenList v4 模拟(docs/design/storage-alist.md §12)。 +// 返回形状照真机(OpenList v4.2.6)核对过:HTTP 200 + {code,message,data};不存在 code 500 "object not found"; +// 令牌无效 code 401;copy 一律异步任务;同挂载 move 立即完成、跨挂载 move 是任务;put 覆盖同名;/d 支持 Range 与 sign。 +// 顶层目录视为「挂载」(驱动),用于区分同驱动 / 跨驱动。 +import { createHash, randomUUID } from "node:crypto"; + +export interface MockFile { + data: Uint8Array; + modified: number; + hashInfo?: Record | null; +} + +export interface MockAlistOptions { + /** 永久令牌 */ + token?: string; + /** 账号密码 */ + username?: string; + password?: string; + /** 目录密码(需要时 list / get 必须带) */ + pathPassword?: string; + /** 任务完成前需要被查询的次数(模拟异步) */ + taskPolls?: number; + /** 不支持任务接口(模拟老 Alist v3) */ + noTaskApi?: boolean; + /** copy / move 不返回任务 id(模拟老 Alist v3 同步返回 data:null,但实际延迟完成) */ + noTaskIds?: boolean; + /** 挂载容量(mount_details) */ + mountDetails?: Record; + version?: string; +} + +interface Task { + id: string; + kind: "copy" | "move" | "upload"; + name: string; + polls: number; + state: number; + error: string; + run: () => void; +} + +export interface MockAlist { + readonly base: string; + setOrigin(origin: string): void; + handler: (req: Request) => Promise; + fetch: typeof fetch; + files: Map; + dirs: Map; + tasks: Map; + requests: { path: string; body: Record | null; headers: Record }[]; + /** 已签发且有效的临时令牌 */ + sessions: Set; + /** 注入:下 n 个 API 请求返回 429 */ + rateLimitNext(n: number): void; + /** 注入:下 n 个 API 请求连接失败(fetch 抛 TypeError) */ + failNext(n: number): void; + /** 注入:上传读到 n 字节后断开 */ + breakUploadAfter(bytes: number | null): void; + /** 注入:下载读到 n 字节后断开(仅一次) */ + breakDownloadAfter(bytes: number | null): void; + /** 注入:使所有 sign 失效一次(下载返回 401) */ + expireSigns(): void; + /** 注入:下一个任务以失败结束 */ + failNextTask(message: string): void; + /** 注入:put 成功但文件不落地(模拟「接口成功目标不存在」) */ + dropNextUpload(): void; + invalidateSessions(): void; + put(path: string, data: Uint8Array | string, opts?: { modified?: number; hashInfo?: Record }): void; + mkdirp(path: string): void; + read(path: string): Uint8Array | null; + exists(path: string): boolean; + listNames(path: string): string[]; +} + +const norm = (p: string) => + `/${p + .split("/") + .filter((s) => s && s !== ".") + .join("/")}`; +const parent = (p: string) => norm(p.split("/").slice(0, -1).join("/")); +const base = (p: string) => p.split("/").filter(Boolean).pop() ?? ""; +const mountOf = (p: string) => p.split("/").filter(Boolean)[0] ?? ""; +const iso = (ms: number) => new Date(ms).toISOString(); + +export function createMockAlist(opts: MockAlistOptions = {}): MockAlist { + let origin = "http://alist.mock"; + const files = new Map(); + const dirs = new Map([["/", Date.now()]]); + const tasks = new Map(); + const sessions = new Set(); + const requests: MockAlist["requests"] = []; + let signEpoch = 1; + let rateLimit = 0; + let fail = 0; + let uploadBreak: number | null = null; + let downloadBreak: number | null = null; + let nextTaskError: string | null = null; + let dropUpload = false; + + const mkdirp = (p: string) => { + const segs = norm(p).split("/").filter(Boolean); + let cur = ""; + for (const s of segs) { + cur += `/${s}`; + if (files.has(cur)) throw new Error(`not a dir: ${cur}`); + if (!dirs.has(cur)) dirs.set(cur, Date.now()); + } + }; + const touch = (dir: string) => { + if (dirs.has(dir)) dirs.set(dir, Date.now()); + }; + const putFile = (p: string, data: Uint8Array, modified = Date.now(), hashInfo?: Record | null) => { + mkdirp(parent(p)); + files.set(norm(p), { data, modified, ...(hashInfo !== undefined ? { hashInfo } : {}) }); + touch(parent(p)); + }; + const children = (dir: string) => { + const d = norm(dir); + const pre = d === "/" ? "/" : `${d}/`; + const names = new Set(); + for (const k of [...files.keys(), ...dirs.keys()]) { + if (k !== d && k.startsWith(pre)) { + const rest = k.slice(pre.length); + if (rest && !rest.includes("/")) names.add(rest); + } + } + return [...names].sort(); + }; + const exists = (p: string) => files.has(norm(p)) || dirs.has(norm(p)); + const signOf = (p: string) => createHash("sha256").update(`${p}:${signEpoch}`).digest("hex").slice(0, 16); + const objOf = (p: string, withRaw = false) => { + const f = files.get(p); + const isDir = !f; + return { + name: base(p), + size: f ? f.data.length : 0, + is_dir: isDir, + modified: iso(f ? f.modified : (dirs.get(p) ?? 0)), + created: iso(f ? f.modified : (dirs.get(p) ?? 0)), + sign: isDir ? "" : signOf(p), + thumb: "", + type: isDir ? 1 : 0, + hashinfo: "null", + hash_info: f?.hashInfo ?? null, + ...(withRaw + ? { + raw_url: isDir ? "" : `${origin}/d${p.split("/").map(encodeURIComponent).join("/")}?sign=${signOf(p)}`, + provider: "Local", + readme: "", + header: "", + related: null, + } + : {}), + }; + }; + const ok = (data: unknown = null) => Response.json({ code: 200, message: "success", data }); + const err = (code: number, message: string) => Response.json({ code, message, data: null }); + const copyTree = (src: string, dst: string) => { + if (files.has(src)) { + const f = files.get(src)!; + putFile(dst, f.data.slice(), f.modified, f.hashInfo ?? null); + return; + } + mkdirp(dst); + for (const n of children(src)) copyTree(`${src}/${n}`, `${dst}/${n}`); + }; + const removeTree = (p: string) => { + for (const k of [...files.keys()]) if (k === p || k.startsWith(`${p}/`)) files.delete(k); + for (const k of [...dirs.keys()]) if (k === p || k.startsWith(`${p}/`)) dirs.delete(k); + touch(parent(p)); + }; + const newTask = (kind: Task["kind"], name: string, run: () => void): Task => { + const t: Task = { id: randomUUID().slice(0, 21), kind, name, polls: 0, state: 0, error: "", run }; + if (nextTaskError) { + t.error = nextTaskError; + nextTaskError = null; + t.run = () => { + throw new Error(t.error); + }; + } + tasks.set(t.id, t); + return t; + }; + const advanceTask = (t: Task) => { + if (t.state >= 2) return; + t.polls++; + t.state = 1; + if (t.polls > (opts.taskPolls ?? 1)) { + try { + t.run(); + t.state = 2; + } catch (e) { + t.state = 7; + t.error = (e as Error).message; + } + } + }; + const taskJson = (t: Task) => ({ + id: t.id, + name: t.name, + creator: "admin", + creator_role: 2, + state: t.state, + status: t.state === 2 ? "done" : "running", + progress: t.state === 2 ? 100 : 0, + start_time: null, + end_time: null, + total_bytes: 0, + error: t.state === 7 ? t.error : "", + }); + + const authed = (req: Request) => { + const a = req.headers.get("authorization") ?? ""; + if (opts.token && a === opts.token) return true; + if (sessions.has(a)) return true; + return !opts.token && !opts.username; + }; + const pwOk = (body: Record) => !opts.pathPassword || body.password === opts.pathPassword; + + const handler = async (req: Request): Promise => { + const url = new URL(req.url); + const path = url.pathname; + if (path.startsWith("/d/")) { + const p = norm(decodeURIComponent(path.slice(2))); + if (url.searchParams.get("sign") !== signOf(p)) return new Response("sign invalid", { status: 401 }); + const f = files.get(p); + if (!f) return new Response("not found", { status: 404 }); + const range = req.headers.get("range"); + let start = 0; + if (range) { + const m = /bytes=(\d+)-/.exec(range); + start = m ? Number(m[1]) : 0; + if (start >= f.data.length) return new Response(null, { status: 416 }); + } + let chunk = f.data.subarray(start); + const broken = downloadBreak; + if (broken !== null) { + downloadBreak = null; + const cut = Math.max(0, broken - start); + const head = chunk.subarray(0, cut); + let sentHead = false; + const stream = new ReadableStream({ + pull(ctl) { + if (!sentHead && head.length) { + sentHead = true; + ctl.enqueue(head.slice()); + return; + } + ctl.error(new Error("connection reset")); + }, + }); + return new Response(stream, { + status: start ? 206 : 200, + headers: { "content-length": String(chunk.length) }, + }); + } + chunk = chunk.slice(); + return new Response(chunk as Uint8Array, { + status: start ? 206 : 200, + headers: { "content-length": String(chunk.length) }, + }); + } + let body: Record | null = null; + if (path !== "/api/fs/put" && req.method === "POST") body = (await req.json().catch(() => null)) ?? null; + requests.push({ path: path + url.search, body, headers: Object.fromEntries(req.headers) }); + if (fail > 0) { + fail--; + throw new TypeError("error sending request: connection refused"); + } + if (rateLimit > 0) { + rateLimit--; + return new Response("Too Many Requests", { status: 429 }); + } + if (path === "/api/public/settings") return ok({ version: opts.version ?? "v4.2.6 (mock)" }); + if (path === "/api/auth/login") { + if (body?.username === opts.username && body?.password === opts.password) { + const t = `sess-${randomUUID()}`; + sessions.add(t); + return ok({ token: t }); + } + return err(400, "password is incorrect or you have no permission"); + } + if (!authed(req)) return err(401, "token is invalidated"); + const b = body ?? {}; + switch (path) { + case "/api/me": + return ok({ id: 1, username: opts.username ?? "admin", base_path: "/", role: 2, permission: 29183 }); + case "/api/admin/storage/list": + return ok({ + content: [...new Set([...dirs.keys()].map(mountOf).filter(Boolean))].map((m, i) => ({ + id: i + 1, + mount_path: `/${m}`, + driver: "Local", + status: "work", + ...(opts.mountDetails?.[`/${m}`] ? { mount_details: opts.mountDetails[`/${m}`] } : {}), + })), + total: 0, + }); + case "/api/fs/list": { + const p = norm(String(b.path ?? "/")); + if (!pwOk(b)) return err(403, "password is incorrect or you have no permission"); + if (!dirs.has(p)) return err(500, "failed to get dir: failed to get obj: object not found"); + const all = children(p).map((n) => objOf(p === "/" ? `/${n}` : `${p}/${n}`)); + const per = Number(b.per_page ?? 0) || 200; + const page = Number(b.page ?? 1) || 1; + return ok({ + content: all.slice((page - 1) * per, page * per), + total: all.length, + readme: "", + header: "", + write: true, + provider: "Local", + }); + } + case "/api/fs/get": { + const p = norm(String(b.path ?? "/")); + if (!pwOk(b)) return err(403, "password is incorrect or you have no permission"); + if (!exists(p)) return err(500, "failed to get obj: object not found"); + return ok(objOf(p, true)); + } + case "/api/fs/mkdir": + try { + mkdirp(String(b.path ?? "/")); + } catch (e) { + return err(500, (e as Error).message); + } + return ok(); + case "/api/fs/rename": { + const p = norm(String(b.path ?? "")); + if (!exists(p)) return err(500, "failed to get obj: object not found"); + const to = `${parent(p)}/${String(b.name)}`.replace("//", "/"); + if (exists(to)) return err(403, `file [${b.name}] exists`); + copyTree(p, to); + removeTree(p); + return ok(); + } + case "/api/fs/remove": { + const dir = norm(String(b.dir ?? "/")); + for (const n of (b.names as string[]) ?? []) removeTree(`${dir}/${n}`.replace("//", "/")); + return ok(); + } + case "/api/fs/copy": + case "/api/fs/move": { + const kind = path.endsWith("copy") ? "copy" : "move"; + const srcDir = norm(String(b.src_dir)); + const dstDir = norm(String(b.dst_dir)); + if (!dirs.has(dstDir)) return err(500, "failed to get dst dir: failed to get obj: object not found"); + const names = (b.names as string[]) ?? []; + for (const n of names) { + if (!exists(`${srcDir}/${n}`)) return err(500, "failed to get src obj: failed to get obj: object not found"); + if (exists(`${dstDir}/${n}`)) return err(403, `file [${n}] exists`); + } + if (kind === "move" && mountOf(srcDir) === mountOf(dstDir)) { + for (const n of names) { + copyTree(`${srcDir}/${n}`, `${dstDir}/${n}`); + removeTree(`${srcDir}/${n}`); + } + return ok({ message: "Move operations completed immediately" }); + } + const created = names.map((n) => + newTask(kind, `${kind} [${srcDir}](${n}) to [${dstDir}]`, () => { + copyTree(`${srcDir}/${n}`, `${dstDir}/${n}`); + if (kind === "move") removeTree(`${srcDir}/${n}`); + }), + ); + if (opts.noTaskIds) { + // 老版本:同步返回,但内容稍后才出现(由任务表推进) + return ok(null); + } + return ok({ message: `Successfully created ${created.length} ${kind} task(s)`, tasks: created.map(taskJson) }); + } + case "/api/fs/put": { + const p = norm(decodeURIComponent(req.headers.get("file-path") ?? "")); + const asTask = req.headers.get("as-task") === "true"; + const reader = req.body!.getReader(); + const chunks: Uint8Array[] = []; + let got = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + got += value.length; + if (uploadBreak !== null && got >= uploadBreak) { + uploadBreak = null; + await reader.cancel(); + throw new TypeError("connection reset by peer"); + } + } + const data = new Uint8Array(got); + let off = 0; + for (const c of chunks) { + data.set(c, off); + off += c.length; + } + const mod = Number(req.headers.get("last-modified") ?? Date.now()); + const commit = () => { + if (dropUpload) { + dropUpload = false; + return; + } + putFile(p, data, mod); + }; + if (asTask) { + const t = newTask("upload", `upload ${base(p)} to [${parent(p)}]`, commit); + return ok({ task: taskJson(t) }); + } + commit(); + return ok(); + } + } + const tm = /^\/api\/(admin\/)?task\/(copy|move|upload)\/info$/.exec(path); + if (tm) { + if (opts.noTaskApi) return new Response("404 page not found", { status: 404 }); + const t = tasks.get(url.searchParams.get("tid") ?? ""); + if (!t) return err(404, "task not found"); + advanceTask(t); + return ok(taskJson(t)); + } + return new Response("404 page not found", { status: 404 }); + }; + + const mock: MockAlist = { + get base() { + return origin; + }, + setOrigin(o: string) { + origin = o; + }, + handler, + fetch: (async (input: Request | URL | string, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(String(input), init); + // 老版本(noTaskIds):没有任务 id 的复制 / 移动,在之后的任意 get 上推进 + if (opts.noTaskIds) for (const t of tasks.values()) advanceTask(t); + return handler(req); + }) as typeof fetch, + files, + dirs, + tasks, + requests, + sessions, + rateLimitNext: (n) => { + rateLimit = n; + }, + failNext: (n) => { + fail = n; + }, + breakUploadAfter: (n) => { + uploadBreak = n; + }, + breakDownloadAfter: (n) => { + downloadBreak = n; + }, + expireSigns: () => { + signEpoch++; + }, + failNextTask: (m) => { + nextTaskError = m; + }, + dropNextUpload: () => { + dropUpload = true; + }, + invalidateSessions: () => sessions.clear(), + put: (p, d, o = {}) => putFile(p, typeof d === "string" ? new TextEncoder().encode(d) : d, o.modified, o.hashInfo), + mkdirp, + read: (p) => files.get(norm(p))?.data ?? null, + exists, + listNames: children, + }; + return mock; +} + +/** 起一个真 HTTP 监听(ffprobe / 下载代理等需要真实 URL 的测试用);返回 base 与关闭函数 */ +export function serveMockAlist(mock: MockAlist): { base: string; close: () => Promise } { + const server = Deno.serve({ port: 0, hostname: "127.0.0.1", onListen: () => {} }, (req) => { + const u = new URL(req.url); + return mock + .handler(new Request(`${mock.base}${u.pathname}${u.search}`, req)) + .catch(() => new Response("connection reset", { status: 502 })); + }); + const addr = server.addr as Deno.NetAddr; + const prev = mock.base; + const base = `http://127.0.0.1:${addr.port}`; + mock.setOrigin(base); + return { + base, + close: async () => { + await server.shutdown(); + mock.setOrigin(prev); + }, + }; +} diff --git a/packages/adapters/src/alist/redact.ts b/packages/adapters/src/alist/redact.ts new file mode 100644 index 00000000..9901c123 --- /dev/null +++ b/packages/adapters/src/alist/redact.ts @@ -0,0 +1,22 @@ +import { redactString, redactUrl } from "@umt/contracts"; + +// Alist 相关文本进日志 / 界面前的脱敏:直链的 sign 参数、令牌、密码(§2.3、§9-11)。 + +/** 遮蔽 URL 查询串里的凭证(sign 等)与 userinfo */ +export function redactAlistUrl(url: string): string { + try { + const u = new URL(url); + u.username = ""; + u.password = ""; + return redactUrl(u.toString()); + } catch { + return redactString(url); + } +} + +/** 遮蔽任意文本里出现的给定秘密值(令牌 / 密码 / 路径密码)与直链签名 */ +export function redactAlistText(text: string, secrets: (string | null | undefined)[] = []): string { + let out = text.replace(/([?&]sign=)[^&\s"']+/gi, "$1***"); + for (const s of secrets) if (s && s.length >= 4) out = out.split(s).join("***"); + return redactString(out); +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 4b83bba7..8ec0329e 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -1,5 +1,6 @@ // @umt/adapters 公开入口;W1 起在此导出实现 export const PACKAGE_NAME = "@umt/adapters"; +export * from "./alist/index.ts"; export * from "./browser/index.ts"; export * from "./emby/index.ts"; export * from "./fs/index.ts";