- Login success = HTTP 200 "Ok." or 204 (5.2+); session cookie taken from
Set-Cookie by name (SID / QBT_SID_<port> / custom) and sent back as-is.
- Failure classes: "Fails." / 401 -> auth; 403 IP ban / 429 -> rate;
wrong API Key (403) -> auth with hint; 5xx -> protocol.
- Downloader instance auth kind: password / api_key (qB 5.2+ Bearer, no
/auth/login) / none (whitelisted subnet); stored in settings.authKind,
credential write-only in secret_ref, cleared when switching to none.
One qbAuthConfig() shared by sync, agent capability and connection test.
- Settings form: auth kind select, password / API Key fields (no echo).
- testkit: mock qB 5.2 login behaviour, IP ban, bypassAuth;
makeTorrent({ realPieces }) builds real piece hashes for real qB checks.
- Real-container matrix (QB_REAL, skipped by default): 5.1.2 and 5.2.4,
login / add / query / tags / pause-resume / delete keeping data.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
189 lines
7.0 KiB
TypeScript
189 lines
7.0 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
/** 测试用 bencode 编解码与种子构造器(独立于 adapters 实现,用于交叉校验 infohash)。 */
|
|
export type BVal = number | string | Uint8Array | BVal[] | { [k: string]: BVal };
|
|
|
|
export function bencode(v: BVal): Buffer {
|
|
if (typeof v === "number") return Buffer.from(`i${Math.trunc(v)}e`);
|
|
if (typeof v === "string") {
|
|
const b = Buffer.from(v, "utf8");
|
|
return Buffer.concat([Buffer.from(`${b.length}:`), b]);
|
|
}
|
|
if (v instanceof Uint8Array) return Buffer.concat([Buffer.from(`${v.length}:`), v]);
|
|
if (Array.isArray(v)) return Buffer.concat([Buffer.from("l"), ...v.map(bencode), Buffer.from("e")]);
|
|
const keys = Object.keys(v).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
return Buffer.concat([Buffer.from("d"), ...keys.flatMap((k) => [bencode(k), bencode(v[k]!)]), Buffer.from("e")]);
|
|
}
|
|
|
|
type Dec = number | Buffer | Dec[] | { [k: string]: Dec };
|
|
|
|
export function bdecodeLoose(buf: Buffer): Dec {
|
|
let pos = 0;
|
|
const readUntil = (ch: string) => {
|
|
const end = buf.indexOf(ch, pos);
|
|
if (end < 0) throw new Error("bad bencode");
|
|
const s = buf.subarray(pos, end).toString();
|
|
pos = end + 1;
|
|
return s;
|
|
};
|
|
const val = (): Dec => {
|
|
const c = String.fromCharCode(buf[pos]!);
|
|
if (c === "i") {
|
|
pos++;
|
|
return Number(readUntil("e"));
|
|
}
|
|
if (c === "l") {
|
|
pos++;
|
|
const out: Dec[] = [];
|
|
while (buf[pos] !== 0x65) out.push(val());
|
|
pos++;
|
|
return out;
|
|
}
|
|
if (c === "d") {
|
|
pos++;
|
|
const out: { [k: string]: Dec } = {};
|
|
while (buf[pos] !== 0x65) {
|
|
const k = (val() as Buffer).toString();
|
|
out[k] = val();
|
|
}
|
|
pos++;
|
|
return out;
|
|
}
|
|
const n = Number(readUntil(":"));
|
|
const b = buf.subarray(pos, pos + n);
|
|
pos += n;
|
|
return b;
|
|
};
|
|
return val();
|
|
}
|
|
|
|
export interface MakeTorrentInput {
|
|
name: string;
|
|
/** 省略则为单文件种子,长度用 length */
|
|
files?: { path: string; length: number }[];
|
|
length?: number;
|
|
pieceLength?: number;
|
|
private?: boolean;
|
|
announce?: string;
|
|
/** 改变伪 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 {
|
|
bytes: Buffer;
|
|
hash: string;
|
|
name: string;
|
|
totalSize: number;
|
|
files: { path: string; length: number }[];
|
|
/** realPieces 时:每个文件的内容(path 同 files[].path,含种子根目录) */
|
|
contents?: { path: string; bytes: Uint8Array }[];
|
|
}
|
|
|
|
/** 确定性伪随机内容(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 };
|
|
let files: { path: string; length: number }[];
|
|
if (input.files) {
|
|
info.files = input.files.map((f) => ({ length: f.length, path: f.path.split("/") }));
|
|
files = input.files.map((f) => ({ path: `${input.name}/${f.path}`, length: f.length }));
|
|
} else {
|
|
info.length = input.length ?? 1024 * 1024 * 1024;
|
|
files = [{ path: input.name, length: info.length as number }];
|
|
}
|
|
const total = files.reduce((a, f) => a + f.length, 0);
|
|
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, ...(contents ? { contents } : {}) };
|
|
}
|
|
|
|
export function makeMagnet(hash: string, name?: string): string {
|
|
return `magnet:?xt=urn:btih:${hash}${name ? `&dn=${encodeURIComponent(name)}` : ""}`;
|
|
}
|
|
|
|
/** 从 .torrent 字节解析(mock 服务器使用):hash = sha1(bencode(info)) */
|
|
export function inspectTorrent(bytes: Buffer): MadeTorrent {
|
|
const root = bdecodeLoose(bytes) as { [k: string]: Dec };
|
|
const info = root.info as { [k: string]: Dec };
|
|
if (!info) throw new Error("no info");
|
|
const reenc = (d: Dec): BVal =>
|
|
Buffer.isBuffer(d)
|
|
? d
|
|
: Array.isArray(d)
|
|
? d.map(reenc)
|
|
: typeof d === "number"
|
|
? d
|
|
: Object.fromEntries(Object.entries(d).map(([k, v]) => [k, reenc(v)]));
|
|
const hash = createHash("sha1")
|
|
.update(bencode(reenc(info)))
|
|
.digest("hex");
|
|
const name = (info.name as Buffer).toString();
|
|
const files = Array.isArray(info.files)
|
|
? (info.files as { [k: string]: Dec }[]).map((f) => ({
|
|
path: [name, ...(f.path as Buffer[]).map((p) => p.toString())].join("/"),
|
|
length: f.length as number,
|
|
}))
|
|
: [{ path: name, length: info.length as number }];
|
|
return { bytes, hash, name, totalSize: files.reduce((a, f) => a + f.length, 0), files };
|
|
}
|