diff --git a/src/api/routes/config.ts b/src/api/routes/config.ts index fab4f1a..d3633d1 100644 --- a/src/api/routes/config.ts +++ b/src/api/routes/config.ts @@ -47,17 +47,19 @@ export function configRouter(config: Config) { return res.badRequest(c, "minUploadRateBytesPerSec must be ≤ maxUploadRateBytesPerSec"); } - // Apply patch to the live config object (affects all new seeders) - Object.assign(config, patch); - - // Persist to disk so the next container start picks it up + // Persist to disk first — only apply to live config if write succeeds + const merged = { ...config, ...patch }; try { mkdirSync(dirname(CONFIG_PATH), { recursive: true }); - writeFileSync(CONFIG_PATH, dump(config), "utf8"); + writeFileSync(CONFIG_PATH, dump(merged), "utf8"); } catch (err) { console.warn("Could not persist config to disk:", err); + return res.unprocessable(c, "Failed to persist config to disk"); } + // Apply patch to the live config object (affects all new seeders) + Object.assign(config, patch); + return c.json(config); }); diff --git a/src/api/routes/status.ts b/src/api/routes/status.ts index e3c4b7a..46464b0 100644 --- a/src/api/routes/status.ts +++ b/src/api/routes/status.ts @@ -2,8 +2,12 @@ import { Hono } from "hono"; import { streamSSE } from "hono/streaming"; import type { SeederRegistry } from "../SeederRegistry.js"; +/** Maximum number of concurrent SSE connections */ +const MAX_SSE_CONNECTIONS = 50; + export function statusRouter(registry: SeederRegistry) { const app = new Hono(); + let activeConnections = 0; /** Global stats snapshot */ app.get("/", (c) => { @@ -17,9 +21,25 @@ export function statusRouter(registry: SeederRegistry) { * Events: * - "stats" with global stats (every change) * - "torrent" with per-torrent state (on each seeder stateChange) + * - "torrents" with full list on initial connection + * - "ping" keep-alive every 30s */ app.get("/stream", (c) => { + if (activeConnections >= MAX_SSE_CONNECTIONS) { + return c.text("Too many SSE connections", 429); + } + activeConnections++; + return streamSSE(c, async (stream) => { + let cleaned = false; + function cleanup() { + if (cleaned) return; + cleaned = true; + activeConnections--; + clearInterval(keepAlive); + unsubscribe(); + } + // Send initial snapshot await stream.writeSSE({ event: "stats", @@ -43,27 +63,23 @@ export function statusRouter(registry: SeederRegistry) { data: JSON.stringify(registry.globalStats()), }); } catch { - // client disconnected + cleanup(); } }); - // Keep alive with a comment every 30s + // Keep alive with a ping every 30s const keepAlive = setInterval(async () => { try { await stream.writeSSE({ event: "ping", data: "" }); } catch { - clearInterval(keepAlive); - unsubscribe(); + cleanup(); } }, 30_000); // Clean up when client disconnects - stream.onAbort(() => { - clearInterval(keepAlive); - unsubscribe(); - }); + stream.onAbort(() => cleanup()); - // Hold the connection open + // Hold the connection open until abort await new Promise((resolve) => { stream.onAbort(() => resolve()); }); diff --git a/src/api/routes/torrents.ts b/src/api/routes/torrents.ts index 338626a..afc2551 100644 --- a/src/api/routes/torrents.ts +++ b/src/api/routes/torrents.ts @@ -2,6 +2,16 @@ import { Hono } from "hono"; import { res } from "../response.js"; import type { SeederRegistry } from "../SeederRegistry.js"; +/** Maximum upload size for .torrent files (10 MB) */ +const MAX_TORRENT_SIZE = 10 * 1024 * 1024; + +/** Validates that a hash parameter is a 40-char hex string (SHA-1) */ +const HASH_RE = /^[a-f0-9]{40}$/i; + +function validateHash(hash: string): boolean { + return HASH_RE.test(hash); +} + export function torrentsRouter(registry: SeederRegistry) { const app = new Hono(); @@ -12,6 +22,12 @@ export function torrentsRouter(registry: SeederRegistry) { /** Add a torrent — accepts multipart/form-data with a 'torrent' file field */ app.post("/", async (c) => { + // Reject oversized uploads early + const contentLength = Number(c.req.header("content-length") ?? 0); + if (contentLength > MAX_TORRENT_SIZE) { + return res.badRequest(c, `File too large (max ${MAX_TORRENT_SIZE / 1024 / 1024} MB)`); + } + const body = await c.req.parseBody(); const file = body.torrent; @@ -21,6 +37,10 @@ export function torrentsRouter(registry: SeederRegistry) { const buf = Buffer.from(await (file as File).arrayBuffer()); + if (buf.length > MAX_TORRENT_SIZE) { + return res.badRequest(c, `File too large (max ${MAX_TORRENT_SIZE / 1024 / 1024} MB)`); + } + try { const state = await registry.addTorrent(buf); return res.created(c, state); @@ -33,6 +53,8 @@ export function torrentsRouter(registry: SeederRegistry) { /** Get stats + announce history for a specific torrent */ app.get("/:hash", (c) => { const { hash } = c.req.param(); + if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format"); + const list = registry.listTorrents(); const torrent = list.find((t) => t.infoHashHex === hash); @@ -49,6 +71,7 @@ export function torrentsRouter(registry: SeederRegistry) { /** Pause a torrent — sends 'stopped' to trackers and halts the announce loop */ app.post("/:hash/pause", async (c) => { const { hash } = c.req.param(); + if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format"); const ok = await registry.pauseTorrent(hash); if (!ok) return res.notFound(c); return c.json({ ok: true }); @@ -57,6 +80,7 @@ export function torrentsRouter(registry: SeederRegistry) { /** Resume a paused torrent — re-announces as 'started' and restarts the loop */ app.post("/:hash/resume", async (c) => { const { hash } = c.req.param(); + if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format"); const ok = await registry.resumeTorrent(hash); if (!ok) return res.notFound(c); return c.json({ ok: true }); @@ -65,6 +89,7 @@ export function torrentsRouter(registry: SeederRegistry) { /** Remove a torrent — sends 'stopped' to trackers then removes it */ app.delete("/:hash", async (c) => { const { hash } = c.req.param(); + if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format"); const removed = await registry.removeTorrent(hash); if (!removed) { diff --git a/src/core/bencode/decoder.ts b/src/core/bencode/decoder.ts index e00da1a..8d5713d 100644 --- a/src/core/bencode/decoder.ts +++ b/src/core/bencode/decoder.ts @@ -29,6 +29,10 @@ export function decode(data: Buffer | Uint8Array): BencodeValue { } export function decodeAt(buf: Buffer, offset: number): DecodeResult { + if (offset >= buf.length) { + throw new Error(`Unexpected end of data at offset ${offset}`); + } + const byte = buf[offset]; if (byte === 0x69) { @@ -68,11 +72,17 @@ function decodeInteger(buf: Buffer, offset: number): DecodeResult { function decodeString(buf: Buffer, offset: number): DecodeResult { const colonIdx = buf.indexOf(0x3a, offset); // ':' if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`); - const len = Number(buf.subarray(offset, colonIdx).toString("ascii")); - if (!Number.isInteger(len) || len < 0) { - throw new Error(`Invalid string length at offset ${offset}`); + const lenStr = buf.subarray(offset, colonIdx).toString("ascii"); + const len = Number.parseInt(lenStr, 10); + if (!Number.isInteger(len) || len < 0 || lenStr !== String(len)) { + throw new Error(`Invalid string length "${lenStr}" at offset ${offset}`); } const start = colonIdx + 1; + if (start + len > buf.length) { + throw new Error( + `String at offset ${offset} claims length ${len} but only ${buf.length - start} bytes remain` + ); + } const value = Buffer.from(buf.subarray(start, start + len)); return { value, end: start + len }; } diff --git a/src/core/seeder/FakeSeeder.ts b/src/core/seeder/FakeSeeder.ts index 37f1bfc..b119d8e 100644 --- a/src/core/seeder/FakeSeeder.ts +++ b/src/core/seeder/FakeSeeder.ts @@ -42,7 +42,7 @@ export class FakeSeeder extends EventEmitter { on(event: "stopped", listener: () => void): this; on(event: "stateChange", listener: (state: SeederState) => void): this; on(event: string, listener: (...args: unknown[]) => void): this { - return super.on(event, listener); + return super.on(event, listener) as this; } private readonly torrent: TorrentFileInfo; private readonly profile: ClientProfile; @@ -104,14 +104,7 @@ export class FakeSeeder extends EventEmitter { this.setStatus("running"); // Start speed tick - this.lastTickTime = Date.now(); - this.tickTimer = setInterval(() => { - const now = Date.now(); - const delta = now - this.lastTickTime; - this.lastTickTime = now; - this.uploaded += this.speedSim.tick(delta); - this.emit("stateChange", this.getState()); - }, 5_000); + this.startTickTimer(); // First announce: started await this.doAnnounce("started"); @@ -154,14 +147,7 @@ export class FakeSeeder extends EventEmitter { this.setStatus("running"); // Restart speed tick - this.lastTickTime = Date.now(); - this.tickTimer = setInterval(() => { - const now = Date.now(); - const delta = now - this.lastTickTime; - this.lastTickTime = now; - this.uploaded += this.speedSim.tick(delta); - this.emit("stateChange", this.getState()); - }, 5_000); + this.startTickTimer(); // Re-announce as started then resume loop await this.doAnnounce("started"); @@ -170,7 +156,7 @@ export class FakeSeeder extends EventEmitter { async stop(): Promise { if (this.status === "stopped" || this.status === "stopping") return; - const waspaused = this.status === "paused"; + const wasPaused = this.status === "paused"; this.setStatus("stopping"); if (this.announceTimer) { @@ -183,7 +169,7 @@ export class FakeSeeder extends EventEmitter { } // If already paused, trackers were already notified — skip the stopped announce - if (!waspaused) { + if (!wasPaused) { await Promise.allSettled( this.trackers.map((t) => t.stop({ @@ -204,6 +190,22 @@ export class FakeSeeder extends EventEmitter { this.emit("stopped"); } + /** Starts the 5-second speed simulation tick timer */ + private startTickTimer(): void { + this.lastTickTime = Date.now(); + this.tickTimer = setInterval(() => { + try { + const now = Date.now(); + const delta = now - this.lastTickTime; + this.lastTickTime = now; + this.uploaded += this.speedSim.tick(delta); + this.emit("stateChange", this.getState()); + } catch (err) { + console.error("[seeder] Tick error:", err); + } + }, 5_000); + } + private scheduleNextAnnounce(): void { const intervalMs = this.lastInterval * 1000; this.announceTimer = setTimeout(async () => { @@ -258,19 +260,21 @@ export class FakeSeeder extends EventEmitter { }; this.emit("announce", record); } else { - this.error = (result.reason as Error).message; + this.error = result.reason instanceof Error ? result.reason.message : String(result.reason); // Fallback: scrape to at least get current seeder/leecher counts - if (tracker.scrape) { + if (tracker.scrape && this.status === "running") { tracker .scrape(this.torrent.infoHash) .then((stats) => { - if (stats) { + if (stats && this.status === "running") { this.lastSeeder = stats.seeders; this.lastLeecher = stats.leechers; this.emit("stateChange", this.getState()); } }) - .catch(() => {}); + .catch((err) => { + console.warn(`[seeder] Scrape fallback failed for ${tracker.url}:`, err); + }); } } } @@ -285,10 +289,43 @@ export class FakeSeeder extends EventEmitter { } } +/** + * Reject tracker URLs that point to private/internal networks (SSRF protection). + * Checks the hostname against RFC 1918, loopback, link-local, and metadata ranges. + */ +function isPrivateHost(hostname: string): boolean { + // Strip IPv6 brackets + const host = hostname.replace(/^\[|\]$/g, ""); + + // Loopback + if (host === "localhost" || host === "::1" || host.startsWith("127.")) return true; + + // Cloud metadata endpoints + if (host === "169.254.169.254" || host === "metadata.google.internal") return true; + + // IPv4 private ranges + const ipv4 = host.match(/^(\d+)\.(\d+)\.\d+\.\d+$/); + if (ipv4) { + const [, a, b] = ipv4.map(Number); + if (a === 10) return true; // 10.0.0.0/8 + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + if (a === 0) return true; // 0.0.0.0/8 + } + + return false; +} + function buildTrackers(urls: string[]): ITracker[] { return urls - .map((url) => { + .map((url): ITracker | null => { try { + const parsed = new URL(url); + if (isPrivateHost(parsed.hostname)) { + console.warn(`[tracker] Skipping private/internal URL: ${url}`); + return null; + } if (url.startsWith("http://") || url.startsWith("https://")) { return new HttpTracker(url); } @@ -296,7 +333,7 @@ function buildTrackers(urls: string[]): ITracker[] { return new UdpTracker(url); } } catch { - // skip malformed URLs + console.warn(`[tracker] Skipping malformed URL: ${url}`); } return null; }) diff --git a/src/core/torrent/TorrentFile.ts b/src/core/torrent/TorrentFile.ts index c3ff0fc..f03ecf8 100644 --- a/src/core/torrent/TorrentFile.ts +++ b/src/core/torrent/TorrentFile.ts @@ -28,7 +28,11 @@ export interface TorrentFileEntry { } export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo { - const torrent = decode(buf) as BencodeDict; + const decoded = decode(buf); + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + throw new Error("Invalid torrent file: expected a bencoded dictionary"); + } + const torrent = decoded as BencodeDict; // --- info_hash: SHA-1 of raw bencoded info dict --- const infoRaw = rawSlice(buf, "info"); @@ -36,15 +40,24 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo { const infoHashHex = createHash("sha1").update(infoRaw).digest("hex"); const infoHash = Buffer.from(infoHashHex, "hex"); + if (!torrent.info || typeof torrent.info !== "object" || Array.isArray(torrent.info)) { + throw new Error("Missing or invalid 'info' dict in torrent file"); + } const info = torrent.info as BencodeDict; - if (!info) throw new Error("Missing 'info' dict"); - const name = bufToStr(info.name as Buffer); - const pieceLength = info["piece length"] as number; + if (!info.name || !Buffer.isBuffer(info.name)) { + throw new Error("Missing or invalid 'info.name' in torrent file"); + } + const name = info.name.toString("utf8"); + + if (typeof info["piece length"] !== "number") { + throw new Error("Missing or invalid 'info.piece length' in torrent file"); + } + const pieceLength = info["piece length"]; // --- tracker URLs --- - const announce = torrent.announce ? bufToStr(torrent.announce as Buffer) : ""; - const announceList = flattenAnnounceList(torrent["announce-list"] as BencodeList | undefined); + const announce = Buffer.isBuffer(torrent.announce) ? torrent.announce.toString("utf8") : ""; + const announceList = flattenAnnounceList(torrent["announce-list"]); if (announce && !announceList.includes(announce)) { announceList.unshift(announce); } @@ -55,20 +68,33 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo { const isMultiFile = "files" in info; if (isMultiFile) { - const fileList = info.files as BencodeList; - files = fileList.map((f) => { + if (!Array.isArray(info.files)) { + throw new Error("Invalid 'info.files': expected a list"); + } + files = (info.files as BencodeList).map((f, i) => { + if (!f || typeof f !== "object" || Array.isArray(f)) { + throw new Error(`Invalid file entry at index ${i}`); + } const fd = f as BencodeDict; - const pathParts = (fd.path as BencodeList).map((p) => bufToStr(p as Buffer)); + if (!Array.isArray(fd.path)) { + throw new Error(`Missing or invalid 'path' in file entry ${i}`); + } + const pathParts = (fd.path as BencodeList).map((p) => bufToStr(p)); + if (typeof fd.length !== "number") { + throw new Error(`Missing or invalid 'length' in file entry ${i}`); + } return { path: pathParts.join("/"), - size: fd.length as number, + size: fd.length, }; }); totalSize = files.reduce((sum, f) => sum + f.size, 0); } else { - const length = info.length as number; - files = [{ path: name, size: length }]; - totalSize = length; + if (typeof info.length !== "number") { + throw new Error("Missing 'info.length' for single-file torrent"); + } + files = [{ path: name, size: info.length }]; + totalSize = info.length; } return { @@ -84,17 +110,19 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo { }; } -function bufToStr(buf: Buffer): string { - return Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf); +function bufToStr(val: unknown): string { + if (Buffer.isBuffer(val)) return val.toString("utf8"); + if (typeof val === "string") return val; + return String(val); } -function flattenAnnounceList(announceList: BencodeList | undefined): string[] { - if (!announceList) return []; +function flattenAnnounceList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; const urls: string[] = []; - for (const tier of announceList) { + for (const tier of raw) { if (Array.isArray(tier)) { for (const url of tier) { - const s = bufToStr(url as Buffer); + const s = bufToStr(url); if (s && !urls.includes(s)) urls.push(s); } } diff --git a/src/core/tracker/HttpTracker.ts b/src/core/tracker/HttpTracker.ts index 468ff22..ea5d7cb 100644 --- a/src/core/tracker/HttpTracker.ts +++ b/src/core/tracker/HttpTracker.ts @@ -2,6 +2,12 @@ import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.j 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. * @@ -29,25 +35,37 @@ export class HttpTracker implements ITracker { "User-Agent": params.userAgent, "Accept-Encoding": "gzip", }, - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!res.ok) { throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`); } - const body = Buffer.from(await res.arrayBuffer()); - const decoded = decode(body) as BencodeDict; - - if (decoded["failure reason"]) { - throw new Error(`Tracker failure: ${(decoded["failure reason"] as Buffer).toString("utf8")}`); + const arrayBuf = await res.arrayBuffer(); + if (arrayBuf.byteLength > MAX_RESPONSE_SIZE) { + throw new Error(`Tracker response too large (${arrayBuf.byteLength} bytes)`); } - if (decoded["tracker id"]) { - this.trackerId = (decoded["tracker id"] as Buffer).toString("utf8"); + 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}`); } - return parseResponse(decoded); + 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 { @@ -73,22 +91,30 @@ export class HttpTracker implements ITracker { const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`; const res = await fetch(fullUrl, { headers: { "User-Agent": this.userAgent || "Mozilla/5.0" }, - signal: AbortSignal.timeout(15_000), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!res.ok) return null; const body = Buffer.from(await res.arrayBuffer()); - const decoded = decode(body) as BencodeDict; - if (!decoded.files) return null; + 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(decoded.files as BencodeDict); + const entries = Object.values(dict.files as BencodeDict); if (entries.length === 0) return null; - const entry = entries[0] as BencodeDict; + 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: (entry.complete as number) ?? 0, - leechers: (entry.incomplete as number) ?? 0, - completed: (entry.downloaded as number) ?? 0, + seeders: typeof complete === "number" ? complete : 0, + leechers: typeof incomplete === "number" ? incomplete : 0, + completed: typeof downloaded === "number" ? downloaded : 0, }; } catch { return null; @@ -155,16 +181,18 @@ function percentEncode(buf: Buffer): string { } function parseResponse(dict: BencodeDict): TrackerResponse { - const interval = (dict.interval as number) ?? 1800; - const minInterval = dict["min interval"] as number | undefined; - const seeders = dict.complete as number | undefined; - const leechers = dict.incomplete as number | undefined; - const warning = dict["warning message"] - ? (dict["warning message"] as Buffer).toString("utf8") - : undefined; - const trackerId = dict["tracker id"] - ? (dict["tracker id"] as Buffer).toString("utf8") - : undefined; + 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); @@ -177,7 +205,7 @@ function parsePeers(raw: unknown): TrackerPeer[] { // Compact format: 6 bytes per peer (4 IP + 2 port) if (Buffer.isBuffer(raw)) { const peers: TrackerPeer[] = []; - for (let i = 0; i + 5 < raw.length; i += 6) { + 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 }); @@ -187,13 +215,12 @@ function parsePeers(raw: unknown): TrackerPeer[] { // Dict format (older trackers) if (Array.isArray(raw)) { - return (raw as BencodeList).map((p) => { - const pd = p as BencodeDict; - return { - ip: (pd.ip as Buffer).toString("ascii"), - port: pd.port as number, - }; - }); + 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 []; diff --git a/src/index.ts b/src/index.ts index 80f058f..9568393 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,12 +22,17 @@ if (config.autoLoad) { const state = await registry.addTorrent(buf); console.log(` ✓ ${state.name} [${state.infoHashHex.slice(0, 8)}]`); } catch (err) { - console.warn(` ✗ Failed to load ${file}: ${(err as Error).message}`); + console.warn(` ✗ Failed to load ${file}: ${err instanceof Error ? err.message : err}`); } } } - } catch { - // torrentsDir doesn't exist yet — that's fine + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + // torrentsDir doesn't exist yet — that's fine + } else { + console.error(`Failed to read torrents directory (${config.torrentsDir}):`, err); + } } } diff --git a/ui/src/components/TorrentList.svelte b/ui/src/components/TorrentList.svelte index b6b3211..d43472c 100644 --- a/ui/src/components/TorrentList.svelte +++ b/ui/src/components/TorrentList.svelte @@ -32,6 +32,10 @@ } } + function trackerHostname(url: string): string { + try { return new URL(url).hostname; } catch { return url; } + } + function statusColor(status: TorrentState["status"]): string { switch (status) { case "running": return "#4ade80"; @@ -65,7 +69,7 @@ {#each torrents as t (t.infoHashHex)} {t.name} - {new URL(t.trackerUrl).hostname} + {trackerHostname(t.trackerUrl)} {t.infoHashHex.slice(0, 8)}… {t.status} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 8223534..e6aea06 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -25,8 +25,22 @@ export interface GlobalStats { startedAt: string; } +/** Extract a human-readable error message from a failed response */ +async function extractError(res: Response): Promise { + try { + const body = await res.json(); + if (body && typeof body === "object" && "error" in body) { + return typeof body.error === "string" ? body.error : JSON.stringify(body.error); + } + } catch { + // response wasn't JSON + } + return `Request failed (${res.status})`; +} + export async function listTorrents(): Promise { const res = await fetch(`${BASE}/torrents`); + if (!res.ok) throw new Error(await extractError(res)); return res.json(); } @@ -34,23 +48,23 @@ export async function addTorrent(file: File): Promise { const form = new FormData(); form.append("torrent", file); const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form }); - if (!res.ok) { - const err = await res.json() as { error: string }; - throw new Error(err.error); - } + if (!res.ok) throw new Error(await extractError(res)); return res.json(); } export async function removeTorrent(hash: string): Promise { - await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" }); + const res = await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" }); + if (!res.ok) throw new Error(await extractError(res)); } export async function pauseTorrent(hash: string): Promise { - await fetch(`${BASE}/torrents/${hash}/pause`, { method: "POST" }); + const res = await fetch(`${BASE}/torrents/${hash}/pause`, { method: "POST" }); + if (!res.ok) throw new Error(await extractError(res)); } export async function resumeTorrent(hash: string): Promise { - await fetch(`${BASE}/torrents/${hash}/resume`, { method: "POST" }); + const res = await fetch(`${BASE}/torrents/${hash}/resume`, { method: "POST" }); + if (!res.ok) throw new Error(await extractError(res)); } export interface AppConfig { @@ -65,6 +79,7 @@ export interface AppConfig { export async function getConfig(): Promise { const res = await fetch(`${BASE}/config`); + if (!res.ok) throw new Error(await extractError(res)); return res.json(); } @@ -74,15 +89,13 @@ export async function updateConfig(patch: Partial { const res = await fetch(`${BASE}/status`); + if (!res.ok) throw new Error(await extractError(res)); return res.json(); } @@ -94,15 +107,15 @@ export function connectSSE( const es = new EventSource(`${BASE}/status/stream`); es.addEventListener("torrent", (e) => { - onTorrent(JSON.parse(e.data)); + try { onTorrent(JSON.parse(e.data)); } catch { /* malformed SSE data */ } }); es.addEventListener("stats", (e) => { - onStats(JSON.parse(e.data)); + try { onStats(JSON.parse(e.data)); } catch { /* malformed SSE data */ } }); es.addEventListener("torrents", (e) => { - onTorrents(JSON.parse(e.data)); + try { onTorrents(JSON.parse(e.data)); } catch { /* malformed SSE data */ } }); return es;