228 lines
7.5 KiB
TypeScript
228 lines
7.5 KiB
TypeScript
import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.js";
|
|
import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js";
|
|
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.js";
|
|
|
|
/** Maximum response body size from trackers (1 MB) */
|
|
const MAX_RESPONSE_SIZE = 1024 * 1024;
|
|
|
|
/** Tracker HTTP request timeout (15 seconds) */
|
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
|
|
/**
|
|
* HTTP/HTTPS tracker client.
|
|
*
|
|
* Key correctness concern: info_hash and peer_id are raw binary — they must be
|
|
* percent-encoded byte-by-byte (not hex, not base64). We build the query string
|
|
* manually instead of using URLSearchParams to avoid double-encoding.
|
|
*/
|
|
export class HttpTracker implements ITracker {
|
|
readonly url: string;
|
|
private trackerId?: string;
|
|
private userAgent = "";
|
|
|
|
constructor(url: string) {
|
|
this.url = url;
|
|
}
|
|
|
|
async announce(params: AnnounceParams): Promise<TrackerResponse> {
|
|
this.userAgent = params.userAgent;
|
|
const qs = buildQueryString(params, this.trackerId);
|
|
const sep = this.url.includes("?") ? "&" : "?";
|
|
const fullUrl = `${this.url}${sep}${qs}`;
|
|
|
|
const res = await fetch(fullUrl, {
|
|
headers: {
|
|
"User-Agent": params.userAgent,
|
|
"Accept-Encoding": "gzip",
|
|
},
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
|
|
}
|
|
|
|
const arrayBuf = await res.arrayBuffer();
|
|
if (arrayBuf.byteLength > MAX_RESPONSE_SIZE) {
|
|
throw new Error(`Tracker response too large (${arrayBuf.byteLength} bytes)`);
|
|
}
|
|
|
|
const body = Buffer.from(arrayBuf);
|
|
const decoded = decode(body);
|
|
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
|
|
throw new Error("Tracker returned invalid bencode (expected dictionary)");
|
|
}
|
|
const dict = decoded as BencodeDict;
|
|
|
|
if (dict["failure reason"]) {
|
|
const raw = dict["failure reason"];
|
|
const reason = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
|
throw new Error(`Tracker failure: ${reason}`);
|
|
}
|
|
|
|
if (dict["tracker id"]) {
|
|
const raw = dict["tracker id"];
|
|
this.trackerId = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
|
}
|
|
|
|
return parseResponse(dict);
|
|
}
|
|
|
|
async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
|
|
try {
|
|
await this.announce({ ...params, event: "stopped" });
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scrape the tracker for current seeder/leecher counts without announcing.
|
|
* Derives the scrape URL by replacing the last `/announce` path segment with `/scrape`.
|
|
* Returns null if the tracker doesn't support scrape or the request fails.
|
|
*/
|
|
async scrape(infoHash: Buffer): Promise<ScrapeResult | null> {
|
|
// Only supported if announce URL contains /announce in the path
|
|
const scrapeUrl = this.url.replace(/(\/announce)([^/]*)$/, "/scrape$2");
|
|
if (scrapeUrl === this.url) return null;
|
|
|
|
try {
|
|
const scrapeQSep = scrapeUrl.includes("?") ? "&" : "?";
|
|
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
|
|
const res = await fetch(fullUrl, {
|
|
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) return null;
|
|
|
|
const body = Buffer.from(await res.arrayBuffer());
|
|
const decoded = decode(body);
|
|
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return null;
|
|
const dict = decoded as BencodeDict;
|
|
if (!dict.files || typeof dict.files !== "object") return null;
|
|
|
|
const entries = Object.values(dict.files as BencodeDict);
|
|
if (entries.length === 0) return null;
|
|
|
|
const entry = entries[0];
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
|
const e = entry as BencodeDict;
|
|
|
|
const complete = e.complete;
|
|
const incomplete = e.incomplete;
|
|
const downloaded = e.downloaded;
|
|
return {
|
|
seeders: typeof complete === "number" ? complete : 0,
|
|
leechers: typeof incomplete === "number" ? incomplete : 0,
|
|
completed: typeof downloaded === "number" ? downloaded : 0,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildQueryString(params: AnnounceParams, trackerId?: string): string {
|
|
const parts: string[] = [
|
|
`info_hash=${percentEncode(params.infoHash)}`,
|
|
`peer_id=${percentEncode(params.peerId)}`,
|
|
`port=${params.port}`,
|
|
`uploaded=${params.uploaded}`,
|
|
`downloaded=${params.downloaded}`,
|
|
`left=${params.left}`,
|
|
`compact=${params.compact !== false ? 1 : 0}`,
|
|
`no_peer_id=1`,
|
|
];
|
|
|
|
if (params.event) {
|
|
parts.push(`event=${params.event}`);
|
|
}
|
|
|
|
if (params.numWant !== undefined) {
|
|
parts.push(`numwant=${params.numWant}`);
|
|
}
|
|
|
|
if (params.key) {
|
|
parts.push(`key=${encodeURIComponent(params.key)}`);
|
|
}
|
|
|
|
if (trackerId) {
|
|
parts.push(`trackerid=${encodeURIComponent(trackerId)}`);
|
|
}
|
|
|
|
return parts.join("&");
|
|
}
|
|
|
|
/**
|
|
* Percent-encode each byte of a Buffer individually.
|
|
* e.g. 0x1a → %1A (uppercase hex, safe chars left as-is... but for binary data
|
|
* we encode everything non-alphanumeric to be safe).
|
|
*/
|
|
function percentEncode(buf: Buffer): string {
|
|
let result = "";
|
|
for (let i = 0; i < buf.length; i++) {
|
|
const byte = buf[i];
|
|
// safe chars: A-Z a-z 0-9 - _ . ~
|
|
if (
|
|
(byte >= 0x41 && byte <= 0x5a) || // A-Z
|
|
(byte >= 0x61 && byte <= 0x7a) || // a-z
|
|
(byte >= 0x30 && byte <= 0x39) || // 0-9
|
|
byte === 0x2d || // -
|
|
byte === 0x5f || // _
|
|
byte === 0x2e || // .
|
|
byte === 0x7e // ~
|
|
) {
|
|
result += String.fromCharCode(byte);
|
|
} else {
|
|
result += `%${byte.toString(16).padStart(2, "0").toUpperCase()}`;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseResponse(dict: BencodeDict): TrackerResponse {
|
|
const rawInterval = dict.interval;
|
|
const interval = typeof rawInterval === "number" ? rawInterval : 1800;
|
|
const rawMinInterval = dict["min interval"];
|
|
const minInterval = typeof rawMinInterval === "number" ? rawMinInterval : undefined;
|
|
const rawSeeders = dict.complete;
|
|
const seeders = typeof rawSeeders === "number" ? rawSeeders : undefined;
|
|
const rawLeechers = dict.incomplete;
|
|
const leechers = typeof rawLeechers === "number" ? rawLeechers : undefined;
|
|
const rawWarning = dict["warning message"];
|
|
const warning = Buffer.isBuffer(rawWarning) ? rawWarning.toString("utf8") : undefined;
|
|
const rawTrackerId = dict["tracker id"];
|
|
const trackerId = Buffer.isBuffer(rawTrackerId) ? rawTrackerId.toString("utf8") : undefined;
|
|
|
|
const peers = parsePeers(dict.peers);
|
|
|
|
return { interval, minInterval, seeders, leechers, peers, warning, trackerId };
|
|
}
|
|
|
|
function parsePeers(raw: unknown): TrackerPeer[] {
|
|
if (!raw) return [];
|
|
|
|
// Compact format: 6 bytes per peer (4 IP + 2 port)
|
|
if (Buffer.isBuffer(raw)) {
|
|
const peers: TrackerPeer[] = [];
|
|
for (let i = 0; i + 6 <= raw.length; i += 6) {
|
|
const ip = `${raw[i]}.${raw[i + 1]}.${raw[i + 2]}.${raw[i + 3]}`;
|
|
const port = raw.readUInt16BE(i + 4);
|
|
peers.push({ ip, port });
|
|
}
|
|
return peers;
|
|
}
|
|
|
|
// Dict format (older trackers)
|
|
if (Array.isArray(raw)) {
|
|
return (raw as BencodeList)
|
|
.filter((p): p is BencodeDict => !!p && typeof p === "object" && !Array.isArray(p))
|
|
.map((pd) => ({
|
|
ip: Buffer.isBuffer(pd.ip) ? pd.ip.toString("ascii") : String(pd.ip ?? "0.0.0.0"),
|
|
port: typeof pd.port === "number" ? pd.port : 0,
|
|
}));
|
|
}
|
|
|
|
return [];
|
|
}
|