diff --git a/src/cli/args.ts b/src/cli/args.ts index fbae28ed..b78c5e23 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,5 +1,6 @@ import { isInfoHash } from "../sources/magnet"; import { parseDuration } from "../util/duration"; +import { setFlareSolverrUrl } from "../util/flaresolverr"; export type CliCommand = | { kind: "version" } @@ -70,6 +71,12 @@ function seedTimeFrom(raw: string | undefined): number | undefined { } export function parseCliArgs(argv: string[]): CliCommand { + const fsIdx = argv.findIndex((a) => a === "--flaresolverr"); + if (fsIdx !== -1 && argv[fsIdx + 1]) { + setFlareSolverrUrl(argv[fsIdx + 1]); + argv = argv.filter((_, idx) => idx !== fsIdx && idx !== fsIdx + 1); + } + const args = argv.filter((a) => a.trim() !== ""); if (args.length === 0) return { kind: "run" }; const a = args[0]!; @@ -127,6 +134,7 @@ export const HELP_TEXT = `torlink, terminal-native torrent search usage torlnk open the search TUI + torlnk --flaresolverr enable FlareSolverr proxy (e.g. http://localhost:8191/v1) torlnk "magnet:?xt=..." start a download on launch torlnk path/to/file.torrent open a .torrent file on launch torlnk watch headless: download torrents dropped into diff --git a/src/util/flaresolverr.test.ts b/src/util/flaresolverr.test.ts new file mode 100644 index 00000000..958d2c38 --- /dev/null +++ b/src/util/flaresolverr.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { + setFlareSolverrUrl, + getFlareSolverrUrl, + isFlareSolverrEnabled, + isCloudflareBlock, +} from "./flaresolverr"; + +function fakeRes(status: number, headers: Record = {}): Response { + return { + status, + ok: status >= 200 && status < 300, + headers: { get: (k: string) => headers[k.toLowerCase()] ?? null }, + } as unknown as Response; +} + +describe("flaresolverr module", () => { + it("defaults to disabled (undefined)", () => { + setFlareSolverrUrl(undefined); + expect(getFlareSolverrUrl()).toBeUndefined(); + expect(isFlareSolverrEnabled()).toBe(false); + }); + + it("sets and gets FlareSolverr URL correctly", () => { + setFlareSolverrUrl("http://localhost:8191/v1"); + expect(getFlareSolverrUrl()).toBe("http://localhost:8191/v1"); + expect(isFlareSolverrEnabled()).toBe(true); + + setFlareSolverrUrl(undefined); + }); + + it("detects Cloudflare 503 and 403 blocks correctly", () => { + expect(isCloudflareBlock(fakeRes(503, { server: "cloudflare" }))).toBe(true); + expect(isCloudflareBlock(fakeRes(503, { server: "ddos-guard" }))).toBe(true); + expect(isCloudflareBlock(fakeRes(403, { server: "cloudflare" }))).toBe(true); + expect(isCloudflareBlock(fakeRes(200, { server: "cloudflare" }))).toBe(false); + expect(isCloudflareBlock(fakeRes(404, { server: "nginx" }))).toBe(false); + }); +}); diff --git a/src/util/flaresolverr.ts b/src/util/flaresolverr.ts new file mode 100644 index 00000000..2b85bf76 --- /dev/null +++ b/src/util/flaresolverr.ts @@ -0,0 +1,75 @@ +import { HttpError } from "./net"; + +let globalFlareSolverrUrl: string | undefined = undefined; + +export function setFlareSolverrUrl(url: string | undefined): void { + globalFlareSolverrUrl = url && url.trim() ? url.trim() : undefined; +} + +export function getFlareSolverrUrl(): string | undefined { + return globalFlareSolverrUrl; +} + +export function isFlareSolverrEnabled(): boolean { + return Boolean(globalFlareSolverrUrl); +} + +export function isCloudflareBlock(res: Response): boolean { + const server = res.headers.get("server")?.toLowerCase() || ""; + return ( + (res.status === 503 || res.status === 403) && + (server.includes("ddos-guard") || server.includes("cloudflare") || res.status === 403) + ); +} + +export async function fetchViaFlareSolverr( + targetUrl: string, + solverUrl: string, + signal?: AbortSignal, +): Promise { + const payload = { + cmd: "request.get", + url: targetUrl, + maxTimeout: 60000, + }; + + let res: Response; + try { + res = await fetch(solverUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal, + }); + } catch (err) { + throw new HttpError( + 0, + `FlareSolverr request to ${solverUrl} failed: ${(err as Error).message}. Ensure FlareSolverr is running.`, + ); + } + + if (!res.ok) { + throw new HttpError(res.status, `FlareSolverr returned HTTP ${res.status}`); + } + + const data = (await res.json()) as { + status?: string; + message?: string; + solution?: { + status?: number; + response?: string; + }; + }; + + if (data.status !== "ok" || !data.solution) { + throw new HttpError(0, `FlareSolverr failed to solve challenge: ${data.message || "Unknown error"}`); + } + + const status = data.solution.status ?? 200; + const html = data.solution.response ?? ""; + + return new Response(html, { + status, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +} diff --git a/src/util/net.ts b/src/util/net.ts index d60b5a0c..0baf30ae 100644 --- a/src/util/net.ts +++ b/src/util/net.ts @@ -1,3 +1,17 @@ +import { + fetchViaFlareSolverr, + getFlareSolverrUrl, + isCloudflareBlock, + setFlareSolverrUrl, +} from "./flaresolverr"; + +export { + fetchViaFlareSolverr, + getFlareSolverrUrl, + isCloudflareBlock, + setFlareSolverrUrl, +}; + export const USER_AGENT = "torlink (+https://www.npmjs.com/package/torlnk)"; export type FetchImpl = (url: string, init?: RequestInit) => Promise; @@ -111,16 +125,22 @@ export async function fetchResilient( throw e; } - if (!RETRY_STATUS.has(res.status)) return res; - - const server = res.headers.get("server")?.toLowerCase() || ""; - if (res.status === 503 && (server.includes("ddos-guard") || server.includes("cloudflare"))) { - throw new HttpError( - res.status, - `Request to ${url} blocked by ${server} (HTTP ${res.status}).`, - ); + if (isCloudflareBlock(res)) { + const solverUrl = getFlareSolverrUrl(); + if (solverUrl) { + return fetchViaFlareSolverr(url, solverUrl, signal ?? undefined); + } + const server = res.headers.get("server")?.toLowerCase() || ""; + if (res.status === 503 && (server.includes("ddos-guard") || server.includes("cloudflare"))) { + throw new HttpError( + res.status, + `Request to ${url} blocked by ${server} (HTTP ${res.status}).`, + ); + } } + if (!RETRY_STATUS.has(res.status)) return res; + if (attempt >= retries) { throw new HttpError( res.status,