feat: Fix all the security issues
This commit is contained in:
parent
1859ef3d24
commit
b9e04f6601
|
|
@ -47,17 +47,19 @@ export function configRouter(config: Config) {
|
||||||
return res.badRequest(c, "minUploadRateBytesPerSec must be ≤ maxUploadRateBytesPerSec");
|
return res.badRequest(c, "minUploadRateBytesPerSec must be ≤ maxUploadRateBytesPerSec");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply patch to the live config object (affects all new seeders)
|
// Persist to disk first — only apply to live config if write succeeds
|
||||||
Object.assign(config, patch);
|
const merged = { ...config, ...patch };
|
||||||
|
|
||||||
// Persist to disk so the next container start picks it up
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
||||||
writeFileSync(CONFIG_PATH, dump(config), "utf8");
|
writeFileSync(CONFIG_PATH, dump(merged), "utf8");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Could not persist config to disk:", 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);
|
return c.json(config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@ import { Hono } from "hono";
|
||||||
import { streamSSE } from "hono/streaming";
|
import { streamSSE } from "hono/streaming";
|
||||||
import type { SeederRegistry } from "../SeederRegistry.js";
|
import type { SeederRegistry } from "../SeederRegistry.js";
|
||||||
|
|
||||||
|
/** Maximum number of concurrent SSE connections */
|
||||||
|
const MAX_SSE_CONNECTIONS = 50;
|
||||||
|
|
||||||
export function statusRouter(registry: SeederRegistry) {
|
export function statusRouter(registry: SeederRegistry) {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
let activeConnections = 0;
|
||||||
|
|
||||||
/** Global stats snapshot */
|
/** Global stats snapshot */
|
||||||
app.get("/", (c) => {
|
app.get("/", (c) => {
|
||||||
|
|
@ -17,9 +21,25 @@ export function statusRouter(registry: SeederRegistry) {
|
||||||
* Events:
|
* Events:
|
||||||
* - "stats" with global stats (every change)
|
* - "stats" with global stats (every change)
|
||||||
* - "torrent" with per-torrent state (on each seeder stateChange)
|
* - "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) => {
|
app.get("/stream", (c) => {
|
||||||
|
if (activeConnections >= MAX_SSE_CONNECTIONS) {
|
||||||
|
return c.text("Too many SSE connections", 429);
|
||||||
|
}
|
||||||
|
activeConnections++;
|
||||||
|
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
|
let cleaned = false;
|
||||||
|
function cleanup() {
|
||||||
|
if (cleaned) return;
|
||||||
|
cleaned = true;
|
||||||
|
activeConnections--;
|
||||||
|
clearInterval(keepAlive);
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
// Send initial snapshot
|
// Send initial snapshot
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
event: "stats",
|
event: "stats",
|
||||||
|
|
@ -43,27 +63,23 @@ export function statusRouter(registry: SeederRegistry) {
|
||||||
data: JSON.stringify(registry.globalStats()),
|
data: JSON.stringify(registry.globalStats()),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// client disconnected
|
cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep alive with a comment every 30s
|
// Keep alive with a ping every 30s
|
||||||
const keepAlive = setInterval(async () => {
|
const keepAlive = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
await stream.writeSSE({ event: "ping", data: "" });
|
await stream.writeSSE({ event: "ping", data: "" });
|
||||||
} catch {
|
} catch {
|
||||||
clearInterval(keepAlive);
|
cleanup();
|
||||||
unsubscribe();
|
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
// Clean up when client disconnects
|
// Clean up when client disconnects
|
||||||
stream.onAbort(() => {
|
stream.onAbort(() => cleanup());
|
||||||
clearInterval(keepAlive);
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hold the connection open
|
// Hold the connection open until abort
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
stream.onAbort(() => resolve());
|
stream.onAbort(() => resolve());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,16 @@ import { Hono } from "hono";
|
||||||
import { res } from "../response.js";
|
import { res } from "../response.js";
|
||||||
import type { SeederRegistry } from "../SeederRegistry.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) {
|
export function torrentsRouter(registry: SeederRegistry) {
|
||||||
const app = new Hono();
|
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 */
|
/** Add a torrent — accepts multipart/form-data with a 'torrent' file field */
|
||||||
app.post("/", async (c) => {
|
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 body = await c.req.parseBody();
|
||||||
const file = body.torrent;
|
const file = body.torrent;
|
||||||
|
|
||||||
|
|
@ -21,6 +37,10 @@ export function torrentsRouter(registry: SeederRegistry) {
|
||||||
|
|
||||||
const buf = Buffer.from(await (file as File).arrayBuffer());
|
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 {
|
try {
|
||||||
const state = await registry.addTorrent(buf);
|
const state = await registry.addTorrent(buf);
|
||||||
return res.created(c, state);
|
return res.created(c, state);
|
||||||
|
|
@ -33,6 +53,8 @@ export function torrentsRouter(registry: SeederRegistry) {
|
||||||
/** Get stats + announce history for a specific torrent */
|
/** Get stats + announce history for a specific torrent */
|
||||||
app.get("/:hash", (c) => {
|
app.get("/:hash", (c) => {
|
||||||
const { hash } = c.req.param();
|
const { hash } = c.req.param();
|
||||||
|
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||||
|
|
||||||
const list = registry.listTorrents();
|
const list = registry.listTorrents();
|
||||||
const torrent = list.find((t) => t.infoHashHex === hash);
|
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 */
|
/** Pause a torrent — sends 'stopped' to trackers and halts the announce loop */
|
||||||
app.post("/:hash/pause", async (c) => {
|
app.post("/:hash/pause", async (c) => {
|
||||||
const { hash } = c.req.param();
|
const { hash } = c.req.param();
|
||||||
|
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||||
const ok = await registry.pauseTorrent(hash);
|
const ok = await registry.pauseTorrent(hash);
|
||||||
if (!ok) return res.notFound(c);
|
if (!ok) return res.notFound(c);
|
||||||
return c.json({ ok: true });
|
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 */
|
/** Resume a paused torrent — re-announces as 'started' and restarts the loop */
|
||||||
app.post("/:hash/resume", async (c) => {
|
app.post("/:hash/resume", async (c) => {
|
||||||
const { hash } = c.req.param();
|
const { hash } = c.req.param();
|
||||||
|
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||||
const ok = await registry.resumeTorrent(hash);
|
const ok = await registry.resumeTorrent(hash);
|
||||||
if (!ok) return res.notFound(c);
|
if (!ok) return res.notFound(c);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|
@ -65,6 +89,7 @@ export function torrentsRouter(registry: SeederRegistry) {
|
||||||
/** Remove a torrent — sends 'stopped' to trackers then removes it */
|
/** Remove a torrent — sends 'stopped' to trackers then removes it */
|
||||||
app.delete("/:hash", async (c) => {
|
app.delete("/:hash", async (c) => {
|
||||||
const { hash } = c.req.param();
|
const { hash } = c.req.param();
|
||||||
|
if (!validateHash(hash)) return res.badRequest(c, "Invalid info hash format");
|
||||||
const removed = await registry.removeTorrent(hash);
|
const removed = await registry.removeTorrent(hash);
|
||||||
|
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ export function decode(data: Buffer | Uint8Array): BencodeValue {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeAt(buf: Buffer, offset: number): DecodeResult {
|
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];
|
const byte = buf[offset];
|
||||||
|
|
||||||
if (byte === 0x69) {
|
if (byte === 0x69) {
|
||||||
|
|
@ -68,11 +72,17 @@ function decodeInteger(buf: Buffer, offset: number): DecodeResult {
|
||||||
function decodeString(buf: Buffer, offset: number): DecodeResult {
|
function decodeString(buf: Buffer, offset: number): DecodeResult {
|
||||||
const colonIdx = buf.indexOf(0x3a, offset); // ':'
|
const colonIdx = buf.indexOf(0x3a, offset); // ':'
|
||||||
if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`);
|
if (colonIdx === -1) throw new Error(`Malformed string at offset ${offset}`);
|
||||||
const len = Number(buf.subarray(offset, colonIdx).toString("ascii"));
|
const lenStr = buf.subarray(offset, colonIdx).toString("ascii");
|
||||||
if (!Number.isInteger(len) || len < 0) {
|
const len = Number.parseInt(lenStr, 10);
|
||||||
throw new Error(`Invalid string length at offset ${offset}`);
|
if (!Number.isInteger(len) || len < 0 || lenStr !== String(len)) {
|
||||||
|
throw new Error(`Invalid string length "${lenStr}" at offset ${offset}`);
|
||||||
}
|
}
|
||||||
const start = colonIdx + 1;
|
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));
|
const value = Buffer.from(buf.subarray(start, start + len));
|
||||||
return { value, end: start + len };
|
return { value, end: start + len };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export class FakeSeeder extends EventEmitter {
|
||||||
on(event: "stopped", listener: () => void): this;
|
on(event: "stopped", listener: () => void): this;
|
||||||
on(event: "stateChange", listener: (state: SeederState) => void): this;
|
on(event: "stateChange", listener: (state: SeederState) => void): this;
|
||||||
on(event: string, listener: (...args: unknown[]) => 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 torrent: TorrentFileInfo;
|
||||||
private readonly profile: ClientProfile;
|
private readonly profile: ClientProfile;
|
||||||
|
|
@ -104,14 +104,7 @@ export class FakeSeeder extends EventEmitter {
|
||||||
this.setStatus("running");
|
this.setStatus("running");
|
||||||
|
|
||||||
// Start speed tick
|
// Start speed tick
|
||||||
this.lastTickTime = Date.now();
|
this.startTickTimer();
|
||||||
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);
|
|
||||||
|
|
||||||
// First announce: started
|
// First announce: started
|
||||||
await this.doAnnounce("started");
|
await this.doAnnounce("started");
|
||||||
|
|
@ -154,14 +147,7 @@ export class FakeSeeder extends EventEmitter {
|
||||||
this.setStatus("running");
|
this.setStatus("running");
|
||||||
|
|
||||||
// Restart speed tick
|
// Restart speed tick
|
||||||
this.lastTickTime = Date.now();
|
this.startTickTimer();
|
||||||
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);
|
|
||||||
|
|
||||||
// Re-announce as started then resume loop
|
// Re-announce as started then resume loop
|
||||||
await this.doAnnounce("started");
|
await this.doAnnounce("started");
|
||||||
|
|
@ -170,7 +156,7 @@ export class FakeSeeder extends EventEmitter {
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (this.status === "stopped" || this.status === "stopping") return;
|
if (this.status === "stopped" || this.status === "stopping") return;
|
||||||
const waspaused = this.status === "paused";
|
const wasPaused = this.status === "paused";
|
||||||
this.setStatus("stopping");
|
this.setStatus("stopping");
|
||||||
|
|
||||||
if (this.announceTimer) {
|
if (this.announceTimer) {
|
||||||
|
|
@ -183,7 +169,7 @@ export class FakeSeeder extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already paused, trackers were already notified — skip the stopped announce
|
// If already paused, trackers were already notified — skip the stopped announce
|
||||||
if (!waspaused) {
|
if (!wasPaused) {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
this.trackers.map((t) =>
|
this.trackers.map((t) =>
|
||||||
t.stop({
|
t.stop({
|
||||||
|
|
@ -204,6 +190,22 @@ export class FakeSeeder extends EventEmitter {
|
||||||
this.emit("stopped");
|
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 {
|
private scheduleNextAnnounce(): void {
|
||||||
const intervalMs = this.lastInterval * 1000;
|
const intervalMs = this.lastInterval * 1000;
|
||||||
this.announceTimer = setTimeout(async () => {
|
this.announceTimer = setTimeout(async () => {
|
||||||
|
|
@ -258,19 +260,21 @@ export class FakeSeeder extends EventEmitter {
|
||||||
};
|
};
|
||||||
this.emit("announce", record);
|
this.emit("announce", record);
|
||||||
} else {
|
} 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
|
// Fallback: scrape to at least get current seeder/leecher counts
|
||||||
if (tracker.scrape) {
|
if (tracker.scrape && this.status === "running") {
|
||||||
tracker
|
tracker
|
||||||
.scrape(this.torrent.infoHash)
|
.scrape(this.torrent.infoHash)
|
||||||
.then((stats) => {
|
.then((stats) => {
|
||||||
if (stats) {
|
if (stats && this.status === "running") {
|
||||||
this.lastSeeder = stats.seeders;
|
this.lastSeeder = stats.seeders;
|
||||||
this.lastLeecher = stats.leechers;
|
this.lastLeecher = stats.leechers;
|
||||||
this.emit("stateChange", this.getState());
|
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[] {
|
function buildTrackers(urls: string[]): ITracker[] {
|
||||||
return urls
|
return urls
|
||||||
.map((url) => {
|
.map((url): ITracker | null => {
|
||||||
try {
|
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://")) {
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
return new HttpTracker(url);
|
return new HttpTracker(url);
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +333,7 @@ function buildTrackers(urls: string[]): ITracker[] {
|
||||||
return new UdpTracker(url);
|
return new UdpTracker(url);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// skip malformed URLs
|
console.warn(`[tracker] Skipping malformed URL: ${url}`);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ export interface TorrentFileEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
|
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 ---
|
// --- info_hash: SHA-1 of raw bencoded info dict ---
|
||||||
const infoRaw = rawSlice(buf, "info");
|
const infoRaw = rawSlice(buf, "info");
|
||||||
|
|
@ -36,15 +40,24 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
|
||||||
const infoHashHex = createHash("sha1").update(infoRaw).digest("hex");
|
const infoHashHex = createHash("sha1").update(infoRaw).digest("hex");
|
||||||
const infoHash = Buffer.from(infoHashHex, "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;
|
const info = torrent.info as BencodeDict;
|
||||||
if (!info) throw new Error("Missing 'info' dict");
|
|
||||||
|
|
||||||
const name = bufToStr(info.name as Buffer);
|
if (!info.name || !Buffer.isBuffer(info.name)) {
|
||||||
const pieceLength = info["piece length"] as number;
|
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 ---
|
// --- tracker URLs ---
|
||||||
const announce = torrent.announce ? bufToStr(torrent.announce as Buffer) : "";
|
const announce = Buffer.isBuffer(torrent.announce) ? torrent.announce.toString("utf8") : "";
|
||||||
const announceList = flattenAnnounceList(torrent["announce-list"] as BencodeList | undefined);
|
const announceList = flattenAnnounceList(torrent["announce-list"]);
|
||||||
if (announce && !announceList.includes(announce)) {
|
if (announce && !announceList.includes(announce)) {
|
||||||
announceList.unshift(announce);
|
announceList.unshift(announce);
|
||||||
}
|
}
|
||||||
|
|
@ -55,20 +68,33 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
|
||||||
const isMultiFile = "files" in info;
|
const isMultiFile = "files" in info;
|
||||||
|
|
||||||
if (isMultiFile) {
|
if (isMultiFile) {
|
||||||
const fileList = info.files as BencodeList;
|
if (!Array.isArray(info.files)) {
|
||||||
files = fileList.map((f) => {
|
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 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 {
|
return {
|
||||||
path: pathParts.join("/"),
|
path: pathParts.join("/"),
|
||||||
size: fd.length as number,
|
size: fd.length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||||
} else {
|
} else {
|
||||||
const length = info.length as number;
|
if (typeof info.length !== "number") {
|
||||||
files = [{ path: name, size: length }];
|
throw new Error("Missing 'info.length' for single-file torrent");
|
||||||
totalSize = length;
|
}
|
||||||
|
files = [{ path: name, size: info.length }];
|
||||||
|
totalSize = info.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -84,17 +110,19 @@ export function parseTorrentBuffer(buf: Buffer): TorrentFileInfo {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function bufToStr(buf: Buffer): string {
|
function bufToStr(val: unknown): string {
|
||||||
return Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf);
|
if (Buffer.isBuffer(val)) return val.toString("utf8");
|
||||||
|
if (typeof val === "string") return val;
|
||||||
|
return String(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenAnnounceList(announceList: BencodeList | undefined): string[] {
|
function flattenAnnounceList(raw: unknown): string[] {
|
||||||
if (!announceList) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
for (const tier of announceList) {
|
for (const tier of raw) {
|
||||||
if (Array.isArray(tier)) {
|
if (Array.isArray(tier)) {
|
||||||
for (const url of tier) {
|
for (const url of tier) {
|
||||||
const s = bufToStr(url as Buffer);
|
const s = bufToStr(url);
|
||||||
if (s && !urls.includes(s)) urls.push(s);
|
if (s && !urls.includes(s)) urls.push(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import { type BencodeDict, type BencodeList, decode } from "../bencode/decoder.j
|
||||||
import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js";
|
import type { AnnounceParams, ITracker, ScrapeResult } from "./ITracker.js";
|
||||||
import type { TrackerPeer, TrackerResponse } from "./TrackerResponse.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.
|
* HTTP/HTTPS tracker client.
|
||||||
*
|
*
|
||||||
|
|
@ -29,25 +35,37 @@ export class HttpTracker implements ITracker {
|
||||||
"User-Agent": params.userAgent,
|
"User-Agent": params.userAgent,
|
||||||
"Accept-Encoding": "gzip",
|
"Accept-Encoding": "gzip",
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
|
throw new Error(`HTTP tracker returned ${res.status} for ${this.url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = Buffer.from(await res.arrayBuffer());
|
const arrayBuf = await res.arrayBuffer();
|
||||||
const decoded = decode(body) as BencodeDict;
|
if (arrayBuf.byteLength > MAX_RESPONSE_SIZE) {
|
||||||
|
throw new Error(`Tracker response too large (${arrayBuf.byteLength} bytes)`);
|
||||||
if (decoded["failure reason"]) {
|
|
||||||
throw new Error(`Tracker failure: ${(decoded["failure reason"] as Buffer).toString("utf8")}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decoded["tracker id"]) {
|
const body = Buffer.from(arrayBuf);
|
||||||
this.trackerId = (decoded["tracker id"] as Buffer).toString("utf8");
|
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<AnnounceParams, "event">): Promise<void> {
|
async stop(params: Omit<AnnounceParams, "event">): Promise<void> {
|
||||||
|
|
@ -73,22 +91,30 @@ export class HttpTracker implements ITracker {
|
||||||
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
|
const fullUrl = `${scrapeUrl}${scrapeQSep}info_hash=${percentEncode(infoHash)}`;
|
||||||
const res = await fetch(fullUrl, {
|
const res = await fetch(fullUrl, {
|
||||||
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
|
headers: { "User-Agent": this.userAgent || "Mozilla/5.0" },
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
|
|
||||||
const body = Buffer.from(await res.arrayBuffer());
|
const body = Buffer.from(await res.arrayBuffer());
|
||||||
const decoded = decode(body) as BencodeDict;
|
const decoded = decode(body);
|
||||||
if (!decoded.files) return null;
|
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;
|
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 {
|
return {
|
||||||
seeders: (entry.complete as number) ?? 0,
|
seeders: typeof complete === "number" ? complete : 0,
|
||||||
leechers: (entry.incomplete as number) ?? 0,
|
leechers: typeof incomplete === "number" ? incomplete : 0,
|
||||||
completed: (entry.downloaded as number) ?? 0,
|
completed: typeof downloaded === "number" ? downloaded : 0,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -155,16 +181,18 @@ function percentEncode(buf: Buffer): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseResponse(dict: BencodeDict): TrackerResponse {
|
function parseResponse(dict: BencodeDict): TrackerResponse {
|
||||||
const interval = (dict.interval as number) ?? 1800;
|
const rawInterval = dict.interval;
|
||||||
const minInterval = dict["min interval"] as number | undefined;
|
const interval = typeof rawInterval === "number" ? rawInterval : 1800;
|
||||||
const seeders = dict.complete as number | undefined;
|
const rawMinInterval = dict["min interval"];
|
||||||
const leechers = dict.incomplete as number | undefined;
|
const minInterval = typeof rawMinInterval === "number" ? rawMinInterval : undefined;
|
||||||
const warning = dict["warning message"]
|
const rawSeeders = dict.complete;
|
||||||
? (dict["warning message"] as Buffer).toString("utf8")
|
const seeders = typeof rawSeeders === "number" ? rawSeeders : undefined;
|
||||||
: undefined;
|
const rawLeechers = dict.incomplete;
|
||||||
const trackerId = dict["tracker id"]
|
const leechers = typeof rawLeechers === "number" ? rawLeechers : undefined;
|
||||||
? (dict["tracker id"] as Buffer).toString("utf8")
|
const rawWarning = dict["warning message"];
|
||||||
: undefined;
|
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);
|
const peers = parsePeers(dict.peers);
|
||||||
|
|
||||||
|
|
@ -177,7 +205,7 @@ function parsePeers(raw: unknown): TrackerPeer[] {
|
||||||
// Compact format: 6 bytes per peer (4 IP + 2 port)
|
// Compact format: 6 bytes per peer (4 IP + 2 port)
|
||||||
if (Buffer.isBuffer(raw)) {
|
if (Buffer.isBuffer(raw)) {
|
||||||
const peers: TrackerPeer[] = [];
|
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 ip = `${raw[i]}.${raw[i + 1]}.${raw[i + 2]}.${raw[i + 3]}`;
|
||||||
const port = raw.readUInt16BE(i + 4);
|
const port = raw.readUInt16BE(i + 4);
|
||||||
peers.push({ ip, port });
|
peers.push({ ip, port });
|
||||||
|
|
@ -187,13 +215,12 @@ function parsePeers(raw: unknown): TrackerPeer[] {
|
||||||
|
|
||||||
// Dict format (older trackers)
|
// Dict format (older trackers)
|
||||||
if (Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
return (raw as BencodeList).map((p) => {
|
return (raw as BencodeList)
|
||||||
const pd = p as BencodeDict;
|
.filter((p): p is BencodeDict => !!p && typeof p === "object" && !Array.isArray(p))
|
||||||
return {
|
.map((pd) => ({
|
||||||
ip: (pd.ip as Buffer).toString("ascii"),
|
ip: Buffer.isBuffer(pd.ip) ? pd.ip.toString("ascii") : String(pd.ip ?? "0.0.0.0"),
|
||||||
port: pd.port as number,
|
port: typeof pd.port === "number" ? pd.port : 0,
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
11
src/index.ts
11
src/index.ts
|
|
@ -22,12 +22,17 @@ if (config.autoLoad) {
|
||||||
const state = await registry.addTorrent(buf);
|
const state = await registry.addTorrent(buf);
|
||||||
console.log(` ✓ ${state.name} [${state.infoHashHex.slice(0, 8)}]`);
|
console.log(` ✓ ${state.name} [${state.infoHashHex.slice(0, 8)}]`);
|
||||||
} catch (err) {
|
} 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 {
|
} catch (err) {
|
||||||
// torrentsDir doesn't exist yet — that's fine
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackerHostname(url: string): string {
|
||||||
|
try { return new URL(url).hostname; } catch { return url; }
|
||||||
|
}
|
||||||
|
|
||||||
function statusColor(status: TorrentState["status"]): string {
|
function statusColor(status: TorrentState["status"]): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "running": return "#4ade80";
|
case "running": return "#4ade80";
|
||||||
|
|
@ -65,7 +69,7 @@
|
||||||
{#each torrents as t (t.infoHashHex)}
|
{#each torrents as t (t.infoHashHex)}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="name" title={t.name}>{t.name}</td>
|
<td class="name" title={t.name}>{t.name}</td>
|
||||||
<td class="hash" title={t.trackerUrl}>{new URL(t.trackerUrl).hostname}</td>
|
<td class="hash" title={t.trackerUrl}>{trackerHostname(t.trackerUrl)}</td>
|
||||||
<td class="hash" title={t.infoHashHex}>{t.infoHashHex.slice(0, 8)}…</td>
|
<td class="hash" title={t.infoHashHex}>{t.infoHashHex.slice(0, 8)}…</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" style="color: {statusColor(t.status)}">{t.status}</span>
|
<span class="badge" style="color: {statusColor(t.status)}">{t.status}</span>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,22 @@ export interface GlobalStats {
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract a human-readable error message from a failed response */
|
||||||
|
async function extractError(res: Response): Promise<string> {
|
||||||
|
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<TorrentState[]> {
|
export async function listTorrents(): Promise<TorrentState[]> {
|
||||||
const res = await fetch(`${BASE}/torrents`);
|
const res = await fetch(`${BASE}/torrents`);
|
||||||
|
if (!res.ok) throw new Error(await extractError(res));
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,23 +48,23 @@ export async function addTorrent(file: File): Promise<TorrentState> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("torrent", file);
|
form.append("torrent", file);
|
||||||
const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form });
|
const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form });
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(await extractError(res));
|
||||||
const err = await res.json() as { error: string };
|
|
||||||
throw new Error(err.error);
|
|
||||||
}
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTorrent(hash: string): Promise<void> {
|
export async function removeTorrent(hash: string): Promise<void> {
|
||||||
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<void> {
|
export async function pauseTorrent(hash: string): Promise<void> {
|
||||||
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<void> {
|
export async function resumeTorrent(hash: string): Promise<void> {
|
||||||
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 {
|
export interface AppConfig {
|
||||||
|
|
@ -65,6 +79,7 @@ export interface AppConfig {
|
||||||
|
|
||||||
export async function getConfig(): Promise<AppConfig> {
|
export async function getConfig(): Promise<AppConfig> {
|
||||||
const res = await fetch(`${BASE}/config`);
|
const res = await fetch(`${BASE}/config`);
|
||||||
|
if (!res.ok) throw new Error(await extractError(res));
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,15 +89,13 @@ export async function updateConfig(patch: Partial<Pick<AppConfig, "minUploadRate
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(patch),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(await extractError(res));
|
||||||
const err = await res.json() as { error: string };
|
|
||||||
throw new Error(typeof err.error === "string" ? err.error : JSON.stringify(err.error));
|
|
||||||
}
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStats(): Promise<GlobalStats> {
|
export async function getStats(): Promise<GlobalStats> {
|
||||||
const res = await fetch(`${BASE}/status`);
|
const res = await fetch(`${BASE}/status`);
|
||||||
|
if (!res.ok) throw new Error(await extractError(res));
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,15 +107,15 @@ export function connectSSE(
|
||||||
const es = new EventSource(`${BASE}/status/stream`);
|
const es = new EventSource(`${BASE}/status/stream`);
|
||||||
|
|
||||||
es.addEventListener("torrent", (e) => {
|
es.addEventListener("torrent", (e) => {
|
||||||
onTorrent(JSON.parse(e.data));
|
try { onTorrent(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener("stats", (e) => {
|
es.addEventListener("stats", (e) => {
|
||||||
onStats(JSON.parse(e.data));
|
try { onStats(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener("torrents", (e) => {
|
es.addEventListener("torrents", (e) => {
|
||||||
onTorrents(JSON.parse(e.data));
|
try { onTorrents(JSON.parse(e.data)); } catch { /* malformed SSE data */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
return es;
|
return es;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue