Merge branch 'qb1'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
8e47d8715b
36 files changed
+2053
-89
No files matched your search
@@ -65,8 +65,14 @@ export interface MakeTorrentInput {
|
||||
pieceLength?: number;
|
||||
private?: boolean;
|
||||
announce?: string;
|
||||
/** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子 */
|
||||
/** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子(realPieces 时也参与确定性内容生成) */
|
||||
pieceSeed?: number;
|
||||
/**
|
||||
* 生成真实 piece hash(可被真 qB 校验通过):true 时按 pieceSeed + 文件路径生成确定性内容,
|
||||
* 也可传函数自定义每个文件的内容(长度须等于 length)。内容随返回值 contents 给出,调用方写盘后 qB 重新校验即完成。
|
||||
* 默认 false:pieces 为伪值,不可真正下载(旧行为)。
|
||||
*/
|
||||
realPieces?: boolean | ((path: string, length: number) => Uint8Array);
|
||||
}
|
||||
|
||||
export interface MadeTorrent {
|
||||
@@ -75,9 +81,28 @@ export interface MadeTorrent {
|
||||
name: string;
|
||||
totalSize: number;
|
||||
files: { path: string; length: number }[];
|
||||
/** realPieces 时:每个文件的内容(path 同 files[].path,含种子根目录) */
|
||||
contents?: { path: string; bytes: Uint8Array }[];
|
||||
}
|
||||
|
||||
/** 构造一个合法的 v1 .torrent(pieces 为伪值,不可真正下载)。 */
|
||||
/** 确定性伪随机内容(xorshift32),同 seed + path 得到同样字节 */
|
||||
export function deterministicContent(seedKey: string, length: number): Uint8Array {
|
||||
let x = 2166136261;
|
||||
for (let i = 0; i < seedKey.length; i++) x = Math.imul(x ^ seedKey.charCodeAt(i), 16777619) >>> 0;
|
||||
if (x === 0) x = 1;
|
||||
const out = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
x ^= x << 13;
|
||||
x >>>= 0;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5;
|
||||
x >>>= 0;
|
||||
out[i] = x & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 构造一个合法的 v1 .torrent;realPieces 时 pieces 为按内容计算的真实 SHA-1,否则为伪值。 */
|
||||
export function makeTorrent(input: MakeTorrentInput): MadeTorrent {
|
||||
const pieceLength = input.pieceLength ?? 4 * 1024 * 1024;
|
||||
const info: { [k: string]: BVal } = { name: input.name, "piece length": pieceLength };
|
||||
@@ -90,13 +115,46 @@ export function makeTorrent(input: MakeTorrentInput): MadeTorrent {
|
||||
files = [{ path: input.name, length: info.length as number }];
|
||||
}
|
||||
const total = files.reduce((a, f) => a + f.length, 0);
|
||||
const pieces = Math.max(1, Math.ceil(total / pieceLength));
|
||||
info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7);
|
||||
let contents: MadeTorrent["contents"];
|
||||
if (input.realPieces) {
|
||||
const gen =
|
||||
typeof input.realPieces === "function"
|
||||
? input.realPieces
|
||||
: (path: string, length: number) => deterministicContent(`${input.pieceSeed ?? 7}:${path}`, length);
|
||||
contents = files.map((f) => {
|
||||
const bytes = gen(f.path, f.length);
|
||||
if (bytes.length !== f.length) throw new Error(`realPieces: ${f.path} 内容长度 ${bytes.length} ≠ ${f.length}`);
|
||||
return { path: f.path, bytes };
|
||||
});
|
||||
// v1:所有文件按顺序拼成一个字节流,按 piece length 切片逐片 SHA-1
|
||||
const hashes: Buffer[] = [];
|
||||
let h = createHash("sha1");
|
||||
let filled = 0;
|
||||
for (const c of contents) {
|
||||
let off = 0;
|
||||
while (off < c.bytes.length) {
|
||||
const n = Math.min(pieceLength - filled, c.bytes.length - off);
|
||||
h.update(c.bytes.subarray(off, off + n));
|
||||
off += n;
|
||||
filled += n;
|
||||
if (filled === pieceLength) {
|
||||
hashes.push(h.digest());
|
||||
h = createHash("sha1");
|
||||
filled = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filled > 0 || hashes.length === 0) hashes.push(h.digest());
|
||||
info.pieces = Buffer.concat(hashes);
|
||||
} else {
|
||||
const pieces = Math.max(1, Math.ceil(total / pieceLength));
|
||||
info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7);
|
||||
}
|
||||
if (input.private) info.private = 1;
|
||||
const root: { [k: string]: BVal } = { announce: input.announce ?? "https://tracker.example.invalid/announce", info };
|
||||
const bytes = bencode(root);
|
||||
const hash = createHash("sha1").update(bencode(info)).digest("hex");
|
||||
return { bytes, hash, name: input.name, totalSize: total, files };
|
||||
return { bytes, hash, name: input.name, totalSize: total, files, ...(contents ? { contents } : {}) };
|
||||
}
|
||||
|
||||
export function makeMagnet(hash: string, name?: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
deterministicContent,
|
||||
inspectTorrent as inspectTorrentFixture,
|
||||
type MadeTorrent,
|
||||
type MakeTorrentInput,
|
||||
|
||||
@@ -70,7 +70,17 @@ export interface MockQbFault {
|
||||
}
|
||||
|
||||
export interface MockQbOptions {
|
||||
version?: "4.6.7" | "5.1.0";
|
||||
/**
|
||||
* 登录行为随版本:≤5.1 成功 200 "Ok." + SID、失败 200 "Fails.";
|
||||
* 5.2.x 成功 204 空体 + QBT_SID_<port>、失败 401 "Unauthorized"。
|
||||
*/
|
||||
version?: "4.6.7" | "5.1.0" | "5.2.4";
|
||||
/** 模拟 qB"对白名单子网跳过认证":不带会话/Key 的请求也放行 */
|
||||
bypassAuth?: boolean;
|
||||
/** 连续登录失败达到该次数后封禁(403 "Your IP address has been banned…"),默认不封禁 */
|
||||
banAfterFailures?: number;
|
||||
/** 5.2.x 会话 cookie 名里的端口(真实 qB 用容器内 WebUI 端口),默认 8080 */
|
||||
webUiPort?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
apiKey?: string;
|
||||
@@ -136,6 +146,9 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
|
||||
const faults = new Map<string, MockQbFault[]>();
|
||||
const calls: MockQbCall[] = [];
|
||||
let rid = 0;
|
||||
const v52 = version.startsWith("5.2");
|
||||
const cookieName = v52 ? `QBT_SID_${opts.webUiPort ?? 8080}` : "SID";
|
||||
let loginFailures = 0;
|
||||
|
||||
const pausedState = (done: boolean) => (v5 ? (done ? "stoppedUP" : "stoppedDL") : done ? "pausedUP" : "pausedDL");
|
||||
|
||||
@@ -557,17 +570,25 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
|
||||
}
|
||||
|
||||
if (endpoint === "auth/login") {
|
||||
if (opts.banAfterFailures !== undefined && loginFailures >= opts.banAfterFailures)
|
||||
return send(403, "Your IP address has been banned after too many failed authentication attempts.");
|
||||
if (form.username === user && form.password === pass) {
|
||||
loginFailures = 0;
|
||||
const sid = randomBytes(16).toString("hex");
|
||||
sessions.add(sid);
|
||||
return send(200, "Ok.", { "set-cookie": `SID=${sid}; HttpOnly; path=/` });
|
||||
return v52
|
||||
? send(204, "", { "set-cookie": `${cookieName}=${sid}; HttpOnly; SameSite=Lax; path=/` })
|
||||
: send(200, "Ok.", { "set-cookie": `SID=${sid}; HttpOnly; path=/` });
|
||||
}
|
||||
return send(200, "Fails.");
|
||||
loginFailures++;
|
||||
return v52 ? send(401, "Unauthorized") : send(200, "Fails.");
|
||||
}
|
||||
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
|
||||
const sid = parseCookies(req.headers.cookie).SID;
|
||||
const authed = (opts.apiKey && bearer === opts.apiKey) || (sid !== undefined && sessions.has(sid));
|
||||
if (!authed) return send(403, "Forbidden");
|
||||
const sid = parseCookies(req.headers.cookie)[cookieName];
|
||||
const authed =
|
||||
opts.bypassAuth === true || (opts.apiKey && bearer === opts.apiKey) || (sid !== undefined && sessions.has(sid));
|
||||
// 真 qB 5.2:错误的 Bearer Key → 403;没有会话 → 401
|
||||
if (!authed) return v52 && !bearer ? send(401, "Unauthorized") : send(403, "Forbidden");
|
||||
|
||||
const h = handlers[endpoint];
|
||||
if (!h) return send(404, "Not Found");
|
||||
|
||||
Reference in new issue
Block a user