Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

Expand Down
8 changes: 7 additions & 1 deletion scripts/postbuild.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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');
852 changes: 852 additions & 0 deletions src/daemon/assets/ui.html

Large diffs are not rendered by default.

193 changes: 192 additions & 1 deletion src/daemon/serve.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -67,6 +70,14 @@ describe("handleApi", () => {
expect(res.body.ok).toBe(true);
});

it("includes the configured TUI theme on /health", async () => {
const res = await handleApi(runtime, "tok", "GET", "/health", undefined, "");
expect(res.status).toBe(200);
const theme = res.body.theme as { id: string; name: string; colors: Record<string, string> };
expect(typeof theme.id).toBe("string");
expect(typeof theme.colors.accent).toBe("string");
});

it("401s a protected route without a token", async () => {
const res = await handleApi(runtime, "tok", "POST", "/add", undefined, `{"magnet":"${MAGNET}"}`);
expect(res.status).toBe(401);
Expand Down Expand Up @@ -127,6 +138,186 @@ 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 }],
});
});

it("400s /search without a query", async () => {
const res = await handleApi(runtime, null, "GET", "/search", undefined, "");
expect(res.status).toBe(400);
});

it("runs an injected search and validates the category", async () => {
const search = vi.fn().mockResolvedValue({ results: [], failed: [] });
runtime.queue = { getItems: () => [], getSeeds: () => [] } as unknown as Runtime["queue"];

const ok = await handleApi(
runtime, null, "GET", "/search", undefined, "",
new URLSearchParams("q=ubuntu&cat=Movies"), search,
);
expect(ok.status).toBe(200);
expect(search).toHaveBeenCalledWith("ubuntu", "Movies");

const bogus = await handleApi(
runtime, null, "GET", "/search", undefined, "",
new URLSearchParams("q=ubuntu&cat=Bogus"), search,
);
expect(bogus.status).toBe(200);
expect(search).toHaveBeenLastCalledWith("ubuntu", null);
});
});

describe("createServeHandler (web remote routes)", () => {
let server: http.Server;

function fakeQueue(overrides: Record<string, unknown> = {}): 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"],
searchFn?: Parameters<typeof createServeHandler>[3],
): Promise<string> {
const runtime = { queue, downloadDir: "unused" } as unknown as Runtime;
server = http.createServer(createServeHandler(runtime, token, () => {}, searchFn));
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<void>((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("<!DOCTYPE html");
expect(html).toContain("torhunt");
});

it("serves the shell on /ui as well", async () => {
const base = await start(null, fakeQueue());
const res = await fetch(`${base}/ui`);
expect(res.status).toBe(200);
expect(await res.text()).toContain("<!DOCTYPE html");
});

it("streams snapshots on /events without a token", async () => {
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);
});

it("wires /search end to end with the query string", async () => {
const searchFn = vi.fn().mockResolvedValue({
results: [{ infoHash: HASH, name: "Result", sizeBytes: 1, seeders: 2, leechers: 0, source: "yts", magnet: MAGNET }],
failed: ["EZTV"],
});
const base = await start(null, fakeQueue(), searchFn);
const res = await fetch(`${base}/search?q=film&cat=Movies`);
expect(res.status).toBe(200);
expect(searchFn).toHaveBeenCalledWith("film", "Movies");
const body = (await res.json()) as { results: unknown[]; failed: string[] };
expect(body.results).toHaveLength(1);
expect(body.failed).toEqual(["EZTV"]);
});
});

describe("parseControl", () => {
Expand Down
Loading
Loading