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 { 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): Promise { 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 { // 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 []; }