feat(adapters): Alist / OpenList 客户端与测试用模拟服务

登录(永久令牌优先、账号密码换令牌缓存、401 自动重登)、list 分页、get/raw_url、mkdir/rename/remove、
copy/move 轮询异步任务与暂存目录改名、上传(三种哈希一遍读出、临时名、空闲超时、退避重试、As-Task)、
下载(Range 续传、直链过期重取、临时文件改名)、容量、每实例限速与脱敏。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 23:20:09 +08:00
1 parent b8b2a3d2d5
commit 5ff24110f8
10 files changed
+2151 -2

No files matched your search

+548
View File
@@ -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<AlistClientOptions> = {}) {
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<unknown>) => {
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", "<movie/>");
expect(new TextDecoder().decode(await client.readFile("/s1/movie.nfo"))).toBe("<movie/>");
});
});
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<Response>((_, 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<string, unknown>, 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-/);
});
});
+951
View File
@@ -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<string, unknown>, msg: string): void;
warn?(obj: Record<string, unknown>, 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<void>;
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<string, string> | 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<string, string> | null {
if (!v || typeof v !== "object") return null;
const out: Record<string, string> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) if (typeof val === "string" && val) out[k] = val;
return Object.keys(out).length ? out : null;
}
function toObj(path: string, d: Record<string, unknown>): 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<string, unknown>): 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<AlistHashes> {
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<typeof setTimeout> | 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<string> | 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<void>((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<string> {
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<string | null> {
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<T>(
method: "GET" | "POST",
apiPath: string,
opts: { body?: unknown; auth?: boolean; signal?: AbortSignal; path?: string } = {},
): Promise<T> {
const headers: Record<string, string> = { "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<T>(
apiPath: string,
body: unknown,
opts: { retry?: boolean; signal?: AbortSignal; path?: string; method?: "GET" | "POST" } = {},
): Promise<T> {
let attempt = 0;
let relogged = false;
for (;;) {
try {
return await this.limiter.run(() =>
this.request<T>(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<Record<string, unknown>>("/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<string | null> {
try {
const d = await this.limiter.run(() =>
this.request<Record<string, unknown>>("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<AlistObj[]> {
const dir = normAlistPath(path);
const out: AlistObj[] = [];
if (opts.refresh) this.stats.refreshes++;
for (let page = 1; ; page++) {
const d = await this.api<Record<string, unknown>>(
"/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<string, unknown>[] | 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<AlistObj> {
const p = normAlistPath(path);
if (opts.refresh) this.stats.refreshes++;
const d = await this.api<Record<string, unknown>>(
"/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<AlistObj | null> {
try {
return await this.get(path, opts);
} catch (e) {
if (isAlistNotFound(e)) return null;
throw e;
}
}
/** 下载直链:raw_url 优先,否则 /d<path>?sign=(同 MP) */
async rawUrl(path: string, obj?: AlistObj): Promise<string> {
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<string, string> = {}): Promise<Record<string, string>> {
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<Response> {
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<Uint8Array> {
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<AlistUsage | null> {
try {
const d = await this.api<{ content?: Record<string, unknown>[] }>("/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<string, unknown> | 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<void> {
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<void> {
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<void> {
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<void> {
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<string[]> {
const d = await this.api<{ tasks?: Record<string, unknown>[] } | 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<AlistTask | null | undefined> {
for (const prefix of ["/api/task", "/api/admin/task"]) {
try {
const d = await this.api<Record<string, unknown>>(
`${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<AlistObj> {
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<AlistObj> {
return this.transferFile("copy", src, dst, opts);
}
/** 同实例移动(语义同 copyFile;同目录只改名) */
async moveFile(src: string, dst: string, opts: { signal?: AbortSignal } = {}): Promise<AlistObj> {
return this.transferFile("move", src, dst, opts);
}
private async transferFile(
kind: "copy" | "move",
srcIn: string,
dstIn: string,
opts: { signal?: AbortSignal },
): Promise<AlistObj> {
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 会静默覆盖,不依赖它);
* 先传 `<name>.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<void> {
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<Uint8Array>({
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<string, string> = {
"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<string, unknown> } | 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<string, unknown> } | 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:写 `<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<void> {
await rm(`${localPath}${PARTIAL_SUFFIX}`, { force: true });
}
}
+70
View File
@@ -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);
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./client.ts";
export * from "./errors.ts";
export * from "./limiter.ts";
export * from "./redact.ts";
+61
View File
@@ -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<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
) {
this.tokens = Math.max(1, qps);
this.last = now();
}
private async takeToken(): Promise<void> {
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<void> {
if (this.concurrency <= 0 || this.active < this.concurrency) {
this.active++;
return;
}
await new Promise<void>((r) => this.waiters.push(r));
this.active++;
}
private release(): void {
this.active--;
this.waiters.shift()?.();
}
async run<T>(fn: () => Promise<T>, opts: { data?: boolean } = {}): Promise<T> {
if (!opts.data) await this.takeToken();
await this.acquire();
try {
return await fn();
} finally {
this.release();
}
}
get inFlight(): number {
return this.active;
}
}
+491
View File
@@ -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<string, string> | 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<string, { total_space: number; free_space: number; used_space: number }>;
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<Response>;
fetch: typeof fetch;
files: Map<string, MockFile>;
dirs: Map<string, number>;
tasks: Map<string, Task>;
requests: { path: string; body: Record<string, unknown> | null; headers: Record<string, string> }[];
/** 已签发且有效的临时令牌 */
sessions: Set<string>;
/** 注入:下 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<string, string> }): 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<string, MockFile>();
const dirs = new Map<string, number>([["/", Date.now()]]);
const tasks = new Map<string, Task>();
const sessions = new Set<string>();
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<string, string> | 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<string>();
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<string, unknown>) => !opts.pathPassword || body.password === opts.pathPassword;
const handler = async (req: Request): Promise<Response> => {
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<Uint8Array>({
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<ArrayBuffer>, {
status: start ? 206 : 200,
headers: { "content-length": String(chunk.length) },
});
}
let body: Record<string, unknown> | 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<void> } {
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);
},
};
}
+22
View File
@@ -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);
}
+1
View File
@@ -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";