From 20f10004bf7b840f9b5a83f4b5e5ebc8b20a731f Mon Sep 17 00:00:00 2001 From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:42:22 +0530 Subject: [PATCH 1/7] feat(serve): built-in web remote UI with live SSE updates Serve a self-contained, zero-dependency web remote from torhunt serve. The shell (src/daemon/assets/ui.html) mirrors the TUI's Electric Cyan design language and works on desktop and mobile: add magnets or info hashes, watch live progress with per-torrent speed/peers/ETA, pause/resume/delete downloads, stop seeding, and browse the completed archive. Server side: new src/daemon/webui.ts holds the asset locator, the server-sent-event stream manager (initial snapshot + push-on-update + keepalive + self-cleanup), and an Origin-vs-Host cross-site guard for state-changing requests. serve.ts gains GET / and /ui (secretless HTML shell), token-authenticated GET /events (EventSource-friendly query-param token), and read-only GET /history; statusPayload now includes totalBytes/downloadedBytes/eta additively. The request handler is extracted into createServeHandler so tests can drive a real HTTP server against a fake runtime. The TUI is untouched. postbuild ships dist/ui.html beside the bundle; runServe delegates to the shared handler. 45 daemon tests pass (9 new webui unit tests, 10 new integration tests); full suite 348 green; typecheck clean. --- README.md | 2 +- scripts/postbuild.cjs | 8 +- src/daemon/assets/ui.html | 827 ++++++++++++++++++++++++++++++++++++++ src/daemon/serve.test.ts | 143 ++++++- src/daemon/serve.ts | 125 ++++-- src/daemon/webui.test.ts | 105 +++++ src/daemon/webui.ts | 107 +++++ 7 files changed, 1287 insertions(+), 30 deletions(-) create mode 100644 src/daemon/assets/ui.html create mode 100644 src/daemon/webui.test.ts create mode 100644 src/daemon/webui.ts diff --git a/README.md b/README.md index 46c918d..079abfb 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ torhunt files range-aware HTTP server for media streaming torhunt attach persistent tmux session for remote SSH usage ``` -Append `--daemon` to run `watch`, `serve`, or `files` as background processes. Run `torhunt --help` for all commands and flags. +Append `--daemon` to run `watch`, `serve`, or `files` as background processes. `torhunt serve` also ships a built-in **web remote**: open `http://127.0.0.1:9161/` in any browser (phone included) to add magnets, watch progress live, pause/resume, and manage seeding. Run with `--token` when exposing the port beyond loopback. Run `torhunt --help` for all commands and flags. ## Privacy & security diff --git a/scripts/postbuild.cjs b/scripts/postbuild.cjs index e28f88d..571eb0d 100644 --- a/scripts/postbuild.cjs +++ b/scripts/postbuild.cjs @@ -11,6 +11,12 @@ copyFileSync(src, dest); // The WebRTC fallback stub must ship beside cli.cjs, which resolves it via // __dirname when the node-datachannel binary is unavailable. copyFileSync(resolve(root, 'scripts/webrtc-stub.mjs'), resolve(root, 'dist/webrtc-stub.mjs')); +// The web remote's HTML shell ships beside the bundle; src/daemon/webui.ts +// loads it relative to the bundled entry at runtime. +copyFileSync( + resolve(root, 'src/daemon/assets/ui.html'), + resolve(root, 'dist/ui.html'), +); // On Windows chmod is effectively a no-op, and npm re-applies bin permissions on install anyway, so a failure // here shouldn't fail the build, but warn rather than swallow the error. @@ -20,4 +26,4 @@ try { console.warn('postbuild: could not set executable bit on dist/cli.cjs:', err.message); } -console.log('postbuild: wrote dist/cli.cjs and dist/webrtc-stub.mjs'); +console.log('postbuild: wrote dist/cli.cjs, dist/webrtc-stub.mjs and dist/ui.html'); diff --git a/src/daemon/assets/ui.html b/src/daemon/assets/ui.html new file mode 100644 index 0000000..bbf0003 --- /dev/null +++ b/src/daemon/assets/ui.html @@ -0,0 +1,827 @@ + + + + + +torhunt · remote + + + + + + +
+
⚡
+
+

torhunt / web remote

+
headless download console
+
+
offline
+
+ +
+
+
add magnet or info hash
+
+ + +
+
downloads start immediately and resume on their own if the daemon restarts
+
+ +
+
–downloading
+
–total speed
+
–seeding
+
+ + + +
+ + +
+ + + +
+ + + + + + + + + + + + + diff --git a/src/daemon/serve.test.ts b/src/daemon/serve.test.ts index 5e0537a..03d1caa 100644 --- a/src/daemon/serve.test.ts +++ b/src/daemon/serve.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import os from "node:os"; import path from "node:path"; +import http from "node:http"; +import { AddressInfo } from "node:net"; +import { EventEmitter } from "node:events"; import { promises as fs } from "node:fs"; -import { handleApi, isAuthorized, extractMagnet, parseControl, applyControl } from "./serve"; +import { handleApi, isAuthorized, extractMagnet, parseControl, applyControl, createServeHandler } from "./serve"; import type { Runtime } from "./runtime"; const HASH = "abcdef0123456789abcdef0123456789abcdef01"; @@ -127,6 +130,144 @@ describe("handleApi", () => { expect(res.body).toMatchObject({ ok: true, action: "pause" }); expect(pause).toHaveBeenCalledWith(HASH); }); + + it("lists history on GET /history", async () => { + const completedAt = Date.now(); + runtime.queue = { + getItems: () => [], + getSeeds: () => [], + getHistory: () => [{ id: HASH, name: "Done", sizeBytes: 1234, completedAt }], + } as unknown as Runtime["queue"]; + const res = await handleApi(runtime, null, "GET", "/history", undefined, ""); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + history: [{ id: HASH, name: "Done", sizeBytes: 1234, completedAt }], + }); + }); +}); + +describe("createServeHandler (web remote routes)", () => { + let server: http.Server; + + function fakeQueue(overrides: Record = {}): Runtime["queue"] { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + getItems: () => [], + getSeeds: () => [], + getHistory: () => [], + has: () => false, + add: vi.fn(), + ...overrides, + }) as unknown as Runtime["queue"]; + } + + function start(token: string | null, queue: Runtime["queue"]): Promise { + const runtime = { queue, downloadDir: "unused" } as unknown as Runtime; + server = http.createServer(createServeHandler(runtime, token, () => {})); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => + resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`), + ); + }); + } + + afterEach(async () => { + if (!server) return; + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + server = undefined as unknown as http.Server; + }); + + it("serves the web remote shell on /", async () => { + const base = await start(null, fakeQueue()); + const res = await fetch(`${base}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain(" { + const base = await start(null, fakeQueue()); + const res = await fetch(`${base}/ui`); + expect(res.status).toBe(200); + expect(await res.text()).toContain(" { + const base = await start(null, fakeQueue()); + const controller = new AbortController(); + const res = await fetch(`${base}/events`, { signal: controller.signal }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + const reader = res.body!.getReader()!; + const { value } = await reader.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain("retry:"); + expect(text).toContain('"downloads"'); + controller.abort(); + }); + + it("401s /events with a wrong token", async () => { + const base = await start("tok", fakeQueue()); + const res = await fetch(`${base}/events?token=nope`); + expect(res.status).toBe(401); + }); + + it("accepts the correct token via query for /events", async () => { + const base = await start("tok", fakeQueue()); + const controller = new AbortController(); + const res = await fetch(`${base}/events?token=tok`, { signal: controller.signal }); + expect(res.status).toBe(200); + controller.abort(); + }); + + it("rejects cross-site POSTs by origin", async () => { + const base = await start(null, fakeQueue()); + const res = await fetch(`${base}/add`, { + method: "POST", + headers: { Origin: "http://evil.example", "Content-Type": "application/json" }, + body: JSON.stringify({ magnet: MAGNET }), + }); + expect(res.status).toBe(403); + expect(((await res.json()) as { error: string }).error).toContain("cross-origin"); + }); + + it("lets same-origin POSTs through to the API", async () => { + const add = vi.fn(); + const base = await start(null, fakeQueue({ add })); + const res = await fetch(`${base}/add`, { + method: "POST", + headers: { Origin: base, "Content-Type": "application/json" }, + body: JSON.stringify({ magnet: MAGNET }), + }); + expect(res.status).toBe(200); + expect(add).toHaveBeenCalled(); + }); + + it("keeps plain curl POSTs working (no Origin header)", async () => { + const add = vi.fn(); + const base = await start(null, fakeQueue({ add })); + const res = await fetch(`${base}/add`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ magnet: MAGNET }), + }); + expect(res.status).toBe(200); + expect(add).toHaveBeenCalled(); + }); + + it("exposes history over HTTP for the Completed tab", async () => { + const completedAt = Date.now(); + const base = await start( + null, + fakeQueue({ getHistory: () => [{ id: HASH, name: "Done", sizeBytes: 99, completedAt }] }), + ); + const res = await fetch(`${base}/history`); + expect(res.status).toBe(200); + expect(((await res.json()) as { history: unknown[] }).history).toHaveLength(1); + }); }); describe("parseControl", () => { diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index 6e7c828..7c0cbb1 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -11,6 +11,7 @@ import http from "node:http"; import { startRuntime, addInput, type Runtime } from "./runtime"; import { startSeedReaper } from "./seed-reaper"; import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth"; +import { loadUiHtml, openEventStream, originAllowed } from "./webui"; import { VERSION } from "../version"; export { isAuthorized } from "./auth"; @@ -136,6 +137,11 @@ function statusPayload(runtime: Runtime): Record { progress: it.progress, peers: it.peers, speed: it.speed, + // Extra context for the web remote; additive so existing API consumers + // that only read the original fields keep working untouched. + totalBytes: it.totalBytes, + downloadedBytes: it.downloadedBytes, + eta: it.eta, })); const seeds = runtime.queue.getSeeds().map((s) => ({ id: s.id, @@ -147,6 +153,18 @@ function statusPayload(runtime: Runtime): Record { return { downloads, seeds }; } +// Completed-download archive for the web remote's Completed tab. Read-only and +// capped by whatever the queue keeps (HISTORY_MAX), so the payload stays small. +function historyPayload(runtime: Runtime): Record { + const history = runtime.queue.getHistory().map((h) => ({ + id: h.id, + name: h.name, + sizeBytes: h.sizeBytes, + completedAt: h.completedAt, + })); + return { history }; +} + // Pure request router — no node:http types, so it's trivially testable. export async function handleApi( runtime: Runtime, @@ -165,6 +183,9 @@ export async function handleApi( if (method === "GET" && (urlPath === "/downloads" || urlPath === "/status")) { return { status: 200, body: statusPayload(runtime) }; } + if (method === "GET" && urlPath === "/history") { + return { status: 200, body: historyPayload(runtime) }; + } if (method === "POST" && urlPath === "/add") { const magnet = extractMagnet(bodyText); if (!magnet) return { status: 400, body: { error: "missing magnet or info hash" } }; @@ -220,37 +241,63 @@ function log(message: string): void { console.log(`[torhunt serve] ${new Date().toISOString()} ${message}`); } -export async function runServe(options: ServeOptions = {}): Promise { - const port = options.port ?? DEFAULT_API_PORT; - const host = options.host ?? "127.0.0.1"; - const token = options.token && options.token.trim() ? options.token.trim() : null; - - // Fail soft, not open: never expose a public interface without a token. - if (!LOOPBACK_HOSTS.has(host) && !token) { - console.error( - `error: refusing to bind ${host} without a token. Pass --token ` + - `(or set TORHUNT_API_TOKEN), or bind 127.0.0.1.`, - ); - process.exit(1); - return; - } - - const runtime = await startRuntime(options.downloadDir); - - if (options.seedTimeMs && options.seedTimeMs > 0) { - startSeedReaper(runtime.queue, options.seedTimeMs, { deleteFiles: options.deleteFiles, log }); - } - - const server = http.createServer((req, res) => { +// Build the request handler for the headless add API. Split out from runServe +// so tests can spin a real server against a fake runtime. +export function createServeHandler( + runtime: Runtime, + token: string | null, + logFn: (message: string) => void = log, +): (req: http.IncomingMessage, res: http.ServerResponse) => void { + return (req, res) => { void (async () => { const method = req.method ?? "GET"; - const urlPath = (req.url ?? "/").split("?")[0]!; + const url = new URL(req.url ?? "/", "http://localhost"); + const urlPath = url.pathname; // Tokenless means loopback-bound; require a loopback Host so a hostile // webpage can't reach us through DNS rebinding. if (!token && !hostHeaderOk(req.headers.host)) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "forbidden host" })); - log(`${method} ${urlPath} -> 403 (host)`); + logFn(`${method} ${urlPath} -> 403 (host)`); + return; + } + // Web remote shell. Like /health it carries no secrets — data stays + // behind auth below — so any browser that passes the Host check gets it. + if (method === "GET" && (urlPath === "/" || urlPath === "/ui")) { + const html = loadUiHtml(); + if (html === null) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "web ui not bundled" })); + return; + } + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); + res.end(html); + return; + } + // Live snapshot stream. EventSource can't set headers, so the token is + // also accepted as a query parameter here (never on JSON routes). + if (method === "GET" && urlPath === "/events") { + const queryToken = url.searchParams.get("token"); + const auth = + req.headers.authorization ?? (queryToken ? `Bearer ${queryToken}` : undefined); + if (!isAuthorized(token, auth)) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + logFn(`GET /events -> 401`); + return; + } + openEventStream(res, runtime.queue, () => statusPayload(runtime)); + logFn("event stream attached"); + return; + } + // Browsers attach Origin to every POST they send; cross-site ones don't + // match our Host. curl and scripts send no Origin and pass untouched. + // This keeps the JSON API un-forgeable from hostile web pages even in + // tokenless mode (where parsers alone already reject form encodings). + if (method !== "GET" && method !== "HEAD" && !originAllowed(req.headers.origin, req.headers.host)) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "cross-origin request rejected" })); + logFn(`${method} ${urlPath} -> 403 (origin)`); return; } const body = method === "POST" ? await readBody(req) : { text: "", tooLarge: false }; @@ -258,7 +305,7 @@ export async function runServe(options: ServeOptions = {}): Promise { res.writeHead(413, { "Content-Type": "application/json", Connection: "close" }); res.end(JSON.stringify({ error: "body too large" })); res.once("finish", () => req.destroy()); - log(`${method} ${urlPath} -> 413`); + logFn(`${method} ${urlPath} -> 413`); return; } const bodyText = body.text; @@ -272,10 +319,34 @@ export async function runServe(options: ServeOptions = {}): Promise { res.writeHead(out.status, { "Content-Type": "application/json" }); res.end(payload); if (method !== "GET" || urlPath !== "/health") { - log(`${method} ${urlPath} -> ${out.status}`); + logFn(`${method} ${urlPath} -> ${out.status}`); } })(); - }); + }; +} + +export async function runServe(options: ServeOptions = {}): Promise { + const port = options.port ?? DEFAULT_API_PORT; + const host = options.host ?? "127.0.0.1"; + const token = options.token && options.token.trim() ? options.token.trim() : null; + + // Fail soft, not open: never expose a public interface without a token. + if (!LOOPBACK_HOSTS.has(host) && !token) { + console.error( + `error: refusing to bind ${host} without a token. Pass --token ` + + `(or set TORHUNT_API_TOKEN), or bind 127.0.0.1.`, + ); + process.exit(1); + return; + } + + const runtime = await startRuntime(options.downloadDir); + + if (options.seedTimeMs && options.seedTimeMs > 0) { + startSeedReaper(runtime.queue, options.seedTimeMs, { deleteFiles: options.deleteFiles, log }); + } + + const server = http.createServer(createServeHandler(runtime, token)); await new Promise((resolve) => { server.listen(port, host, () => { diff --git a/src/daemon/webui.test.ts b/src/daemon/webui.test.ts new file mode 100644 index 0000000..283bd63 --- /dev/null +++ b/src/daemon/webui.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { EventEmitter } from "node:events"; +import { + loadUiHtml, + openEventStream, + originAllowed, + type EventStreamWriter, +} from "./webui"; + +describe("loadUiHtml", () => { + it("finds the web remote shell in the source tree", () => { + const html = loadUiHtml(); + expect(html).not.toBeNull(); + expect(html).toContain(" { + expect(loadUiHtml()).toBe(loadUiHtml()); + }); +}); + +describe("originAllowed", () => { + it("allows requests without an Origin (curl, scripts, health checks)", () => { + expect(originAllowed(undefined, "localhost:9161")).toBe(true); + }); + + it("allows same-origin browser posts regardless of case or trailing path", () => { + expect(originAllowed("http://localhost:9161", "localhost:9161")).toBe(true); + expect(originAllowed("http://LOCALHOST:9161/add", "localhost:9161")).toBe(true); + }); + + it("rejects cross-site origins", () => { + expect(originAllowed("http://evil.example", "localhost:9161")).toBe(false); + expect(originAllowed("http://localhost:9999", "localhost:9161")).toBe(false); + }); + + it("rejects malformed origins and requests with no host", () => { + expect(originAllowed("not a url", "localhost:9161")).toBe(false); + expect(originAllowed("http://localhost:9161", undefined)).toBe(false); + }); +}); + +describe("openEventStream", () => { + function fakeRes(): { + head: { status: number; headers: Record } | null; + writes: string[]; + on(event: string, listener: () => void): unknown; + writeHead(status: number, headers: Record): void; + write(chunk: string): void; + emitClose(): void; + } { + const listeners = new Map void>(); + return { + head: null, + writes: [], + on(event, listener) { + listeners.set(event, listener); + return this; + }, + writeHead(status, headers) { + this.head = { status, headers }; + }, + write(chunk) { + this.writes.push(chunk); + }, + emitClose() { + listeners.get("close")?.(); + }, + }; + } + + it("writes sse headers, an initial snapshot, and pushes on queue updates", () => { + const res = fakeRes(); + const queue = new EventEmitter(); + openEventStream(res as unknown as EventStreamWriter, queue, () => ({ n: 1 })); + expect(res.head?.status).toBe(200); + expect(res.head?.headers["Content-Type"]).toContain("text/event-stream"); + expect(res.head?.headers["Cache-Control"]).toBe("no-store"); + expect(res.writes[0]).toContain("retry:"); + expect(res.writes[1]).toBe('data: {"n":1}\n\n'); + queue.emit("update"); + expect(res.writes[2]).toBe('data: {"n":1}\n\n'); + }); + + it("detaches from the queue when the client disconnects", () => { + const res = fakeRes(); + const queue = new EventEmitter(); + openEventStream(res as unknown as EventStreamWriter, queue, () => ({})); + res.emitClose(); + const before = res.writes.length; + queue.emit("update"); + expect(res.writes.length).toBe(before); + }); + + it("survives a snapshot function that throws", () => { + const res = fakeRes(); + const queue = new EventEmitter(); + openEventStream(res as unknown as EventStreamWriter, queue, () => { + throw new Error("boom"); + }); + queue.emit("update"); + expect(res.writes.every((w) => !w.startsWith("data: {"))).toBe(true); + }); +}); diff --git a/src/daemon/webui.ts b/src/daemon/webui.ts new file mode 100644 index 0000000..9bb6d0a --- /dev/null +++ b/src/daemon/webui.ts @@ -0,0 +1,107 @@ +// Web remote: the static front-end served by `torhunt serve`, plus the two +// pieces of server plumbing it needs that the plain JSON API doesn't provide — +// an asset locator for the bundled HTML shell and a server-sent-event stream +// that pushes queue snapshots to the browser. Everything here is structural and +// dependency-free so it stays trivially testable without node:http. +// +// Security posture (mirrors serve.ts): the HTML shell carries no secrets, so it +// is served like /health — after the tokenless Host check but without bearer +// auth. Every data route (/events included) still requires authorization, and +// cross-site POSTs are rejected by origin comparison before any handler runs. + +import { readFileSync } from "node:fs"; + +// The shell ships beside the bundle in dist/ (postbuild copies it there) and +// lives under src/daemon/assets/ in a checkout. Try both layouts; cache the +// first hit so a hot loop never re-stats the disk. +let cachedHtml: string | null | undefined; + +export function loadUiHtml(): string | null { + if (cachedHtml !== undefined) return cachedHtml; + const candidates = [ + new URL("./ui.html", import.meta.url), // bundled: dist/ui.html + new URL("./assets/ui.html", import.meta.url), // source tree + ]; + for (const url of candidates) { + try { + cachedHtml = readFileSync(url, "utf8"); + return cachedHtml; + } catch { + // try the next layout + } + } + cachedHtml = null; + return cachedHtml; +} + +// Minimal structural types so tests can pass plain objects instead of real +// http.ServerResponse / EventEmitter instances. +export interface EventStreamWriter { + writeHead(status: number, headers: Record): unknown; + write(chunk: string): unknown; + on(event: string, listener: () => void): unknown; +} + +export interface QueueEventSource { + on(event: string, listener: () => void): unknown; + off(event: string, listener: () => void): unknown; +} + +export const SSE_HEARTBEAT_MS = 15_000; + +// Open an SSE stream on `res` and push a fresh snapshot on every queue update, +// plus a keepalive comment so intermediaries don't reap an idle connection. +// The stream cleans up after itself when the client disconnects. +export function openEventStream( + res: EventStreamWriter, + queue: QueueEventSource, + snapshot: () => unknown, +): void { + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-store", + Connection: "keep-alive", + }); + // Ask browsers to retry quickly after a daemon restart. + res.write("retry: 3000\n\n"); + + const push = (): void => { + try { + res.write(`data: ${JSON.stringify(snapshot())}\n\n`); + } catch { + // A torn write means the socket died; the close listener detaches us. + } + }; + push(); + + queue.on("update", push); + const heartbeat = setInterval(() => { + try { + res.write(": keepalive\n\n"); + } catch { + // same as above — close will clean up + } + }, SSE_HEARTBEAT_MS); + heartbeat.unref?.(); + + const detach = (): void => { + queue.off("update", push); + clearInterval(heartbeat); + }; + res.on("close", detach); +} + +// Cross-site request guard for state-changing calls. Browsers attach an Origin +// header to every POST they send — same-origin ones match the request's Host, +// forged ones don't. curl and scripts send no Origin at all and pass through. +export function originAllowed(origin: string | undefined, hostHeader: string | undefined): boolean { + if (!origin) return true; + let originHost: string; + try { + originHost = new URL(origin).host.toLowerCase(); + } catch { + return false; // malformed Origin is never trusted + } + if (!hostHeader) return false; + return originHost === hostHeader.trim().toLowerCase(); +} From 561ea41b723595c2912f178a690882123a55bf35 Mon Sep 17 00:00:00 2001 From: AK CHAVAN <90214281+iamakchavan@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:29:05 +0530 Subject: [PATCH 2/7] feat(web): search all indexers from the web remote The web remote could only accept magnets; searching still required the terminal. This wires the same source adapters the TUI uses into the headless server. New src/daemon/websearch.ts runs every source in parallel under one deadline (15s per source, matching the TUI hook), dedupes shared infoHashes keeping the healthiest row, orders seeders-first like the results view, caps the payload at 60 rows, and degrades unreachable sources to a per-source `failed` list instead of failing the request. serve.ts gains GET /search?q=&cat= (auth-protected, category validated against Games/Movies/TV/Anime), with both handleApi and createServeHandler taking an injectable search function so tests never touch the network. The web UI gets a Search tab as its default view: query input, category select, results with size/seeder/leecher/source metadata, and one-click Download buttons that enqueue via POST /add and flip to "Queued". Unreachable sources are reported inline in the status line. 9 new tests (6 websearch unit tests, 3 search API/integration); full suite 357 green; typecheck clean. --- README.md | 2 +- src/daemon/assets/ui.html | 148 +++++++++++++++++++++++++++++++++-- src/daemon/serve.test.ts | 46 ++++++++++- src/daemon/serve.ts | 34 +++++++- src/daemon/websearch.test.ts | 111 ++++++++++++++++++++++++++ src/daemon/websearch.ts | 74 ++++++++++++++++++ 6 files changed, 406 insertions(+), 9 deletions(-) create mode 100644 src/daemon/websearch.test.ts create mode 100644 src/daemon/websearch.ts diff --git a/README.md b/README.md index 079abfb..944e09c 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ torhunt files range-aware HTTP server for media streaming torhunt attach persistent tmux session for remote SSH usage ``` -Append `--daemon` to run `watch`, `serve`, or `files` as background processes. `torhunt serve` also ships a built-in **web remote**: open `http://127.0.0.1:9161/` in any browser (phone included) to add magnets, watch progress live, pause/resume, and manage seeding. Run with `--token` when exposing the port beyond loopback. Run `torhunt --help` for all commands and flags. +Append `--daemon` to run `watch`, `serve`, or `files` as background processes. `torhunt serve` also ships a built-in **web remote**: open `http://127.0.0.1:9161/` in any browser (phone included) to search all indexers, add magnets or info hashes, watch progress live, pause/resume, and manage seeding. Run with `--token` when exposing the port beyond loopback. Run `torhunt --help` for all commands and flags. ## Privacy & security diff --git a/src/daemon/assets/ui.html b/src/daemon/assets/ui.html index bbf0003..8ab3a5a 100644 --- a/src/daemon/assets/ui.html +++ b/src/daemon/assets/ui.html @@ -151,7 +151,6 @@ font-weight: 400; } - +
@@ -471,12 +510,30 @@

torhunt / web remote

-
+ + + @@ -497,7 +554,14 @@

🔒 Token required

"use strict"; // ---- state ----------------------------------------------------------------- -const state = { downloads: [], seeds: [], history: [], tab: "downloads" }; +const state = { + downloads: [], + seeds: [], + history: [], + results: [], + resultByHash: {}, + tab: "search", +}; let token = localStorage.getItem("torhunt-token") || ""; // ---- tiny helpers ---------------------------------------------------------- @@ -668,6 +732,7 @@

🔒 Token required

renderDownloads(); renderSeeds(); renderHistory(); + renderSearch(); const active = state.downloads.filter((d) => d.status === "downloading").length; const totalSpeed = state.downloads.reduce((sum, d) => sum + (d.speed || 0), 0); const seeding = state.seeds.filter((s) => s.status === "seeding").length; @@ -675,6 +740,31 @@

🔒 Token required

$("#st-speed").textContent = totalSpeed > 0 ? fmtSpeed(totalSpeed) : "–"; $("#st-seeds").textContent = String(seeding); } + +// ---- search ---------------------------------------------------------------- +function renderSearch() { + const el = $("#search-results"); + const items = state.results; + if (!items.length) { el.innerHTML = ""; return; } + el.innerHTML = items.map((r) => ` +
+
${esc(r.name)}
+
+ ${r.sizeBytes ? `${fmtBytes(r.sizeBytes)}` : ""} + ▲ ${r.seeders} seeders + ▼ ${r.leechers} + ${esc(sourceLabel(r.source))} +
+
+
`).join(""); +} + +// Source ids read like "x1337-tv"; show the site part for the tag. +function sourceLabel(id) { + const s = String(id ?? ""); + const cut = s.replace(/-(movies|tv)$/, ""); + return cut === "x1337" ? "1337x" : cut === "tpb" ? "TPB" : cut; +} - - - - - -
-