Skip to content
Open
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
8 changes: 8 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isInfoHash } from "../sources/magnet";
import { parseDuration } from "../util/duration";
import { setFlareSolverrUrl } from "../util/flaresolverr";

export type CliCommand =
| { kind: "version" }
Expand Down Expand Up @@ -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]!;
Expand Down Expand Up @@ -127,6 +134,7 @@ export const HELP_TEXT = `torlink, terminal-native torrent search

usage
torlnk open the search TUI
torlnk --flaresolverr <url> 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 <dir> headless: download torrents dropped into <dir>
Expand Down
39 changes: 39 additions & 0 deletions src/util/flaresolverr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import {
setFlareSolverrUrl,
getFlareSolverrUrl,
isFlareSolverrEnabled,
isCloudflareBlock,
} from "./flaresolverr";

function fakeRes(status: number, headers: Record<string, string> = {}): 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);
});
});
75 changes: 75 additions & 0 deletions src/util/flaresolverr.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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" },
});
}
36 changes: 28 additions & 8 deletions src/util/net.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;
Expand Down Expand Up @@ -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,
Expand Down