import { lookup as dnsLookup } from 'node:dns/promises'; import { NextRequest } from 'next/server'; import { getSetting } from '@/lib/db'; import { corsJson, corsPreflight } from '../cors'; /** A thin proxy over api.2captcha.com, so the runner never handles the key. * * The runner is a desktop process that may not even be on this machine; keeping * the key here means it lives in exactly one place — the settings table — and * a captcha solve is one more dashboard call like every other step. */ const API_BASE = 'https://api.2captcha.com'; const UPSTREAM_TIMEOUT_MS = 20_000; /** 2captcha's residential proxy, so the solve comes from a residential address * rather than a datacentre one the site may already score badly. * * Hardcoded on request. Note what that means: unlike the sqlite database, this * file IS in the repo, so these credentials travel with it. Override without * editing code via TWOCAPTCHA_PROXY, or by PATCHing `twocaptcha_proxy` to * /api/settings; either accepts `none` to go back to solving proxyless. * * Caveat worth knowing before trusting it: the token is scored partly on the * address that solved it, while the form is submitted from THIS machine's * address. The two only match if the browser goes out through the same proxy, * which it does not. For reCAPTCHA v2 and Turnstile that is usually tolerated; * for anything scoring the IP harder, a mismatched proxy can be worse than * none. */ const DEFAULT_PROXY = 'http://u3229993f561005c2-zone-custom:u3229993f561005c2@na.proxy.2captcha.com:2334'; interface Proxy { type: string; host: string; port: number; login: string; password: string; } /** Settings first, then the environment, then the hardcoded default. `none` * anywhere in that chain means solve without a proxy. */ function proxyConfig(): Proxy | null { const raw = (getSetting('twocaptcha_proxy') || process.env.TWOCAPTCHA_PROXY || DEFAULT_PROXY).trim(); if (!raw || /^(none|off|proxyless)$/i.test(raw)) return null; try { const url = new URL(/^\w+:\/\//.test(raw) ? raw : `http://${raw}`); const port = Number(url.port); if (!url.hostname || !port) return null; return { type: url.protocol.replace(':', '').toLowerCase(), host: url.hostname, port, login: decodeURIComponent(url.username), password: decodeURIComponent(url.password), }; } catch { return null; } } /** 2captcha rejects host names in `proxyAddress` — it wants a literal IP — so * `na.proxy.2captcha.com` has to be resolved here. Cached: the gateway address * is stable, and a DNS lookup per solve would be a needless round trip. */ const IS_IP = /^(\d{1,3}\.){3}\d{1,3}$|:/; const dnsCache = new Map(); const DNS_TTL_MS = 5 * 60_000; async function proxyAddress(host: string): Promise { if (IS_IP.test(host)) return host; const hit = dnsCache.get(host); if (hit && Date.now() - hit.at < DNS_TTL_MS) return hit.ip; const { address } = await dnsLookup(host); dnsCache.set(host, { ip: address, at: Date.now() }); return address; } type Provider = 'recaptcha' | 'hcaptcha' | 'turnstile'; /** The slice of 2captcha's answer we act on. Both endpoints share the shape. */ interface UpstreamResult { errorId?: number; errorCode?: string; errorDescription?: string; taskId?: number | string; status?: string; solution?: { token?: string; gRecaptchaResponse?: string }; cost?: string; } function message(err: unknown, fallback: string): string { return err instanceof Error ? err.message : fallback; } /** The 2captcha account these solves are billed to. * * Hardcoded on request, with the same caveat as the proxy below: this file is in * the repo, so the key travels with it. Anyone who ends up with a copy can spend * the balance. To bill another account, set TWOCAPTCHA_API_KEY, or PATCH * `twocaptcha_api_key` to /api/settings — the settings row still wins, it just * has no field on the Settings page any more. */ const DEFAULT_API_KEY = 'bde31ec7a337243e7331fbb882111a62'; /** Settings first, then the environment, then the hardcoded default. */ function apiKey(): string { return (getSetting('twocaptcha_api_key') || process.env.TWOCAPTCHA_API_KEY || DEFAULT_API_KEY).trim(); } /** Build the task 2captcha expects for this widget. * * Each type comes in two spellings: `…TaskProxyless` solves from 2captcha's own * address, `…Task` solves through the proxy named in `proxy`. Same fields * otherwise, so the only difference is the suffix and the five proxy keys. */ function buildTask( body: Record, proxy: (Proxy & { address: string }) | null, ): { task?: Record; error?: string } { const provider = String(body.provider || '') as Provider; const websiteKey = String(body.sitekey || '').trim(); const websiteURL = String(body.pageurl || '').trim(); if (!websiteKey) return { error: '`sitekey` is required' }; if (!/^https?:\/\//.test(websiteURL)) return { error: '`pageurl` must be an http(s) URL' }; const suffix = proxy ? '' : 'Proxyless'; const common: Record = { websiteURL, websiteKey, ...(proxy ? { proxyType: proxy.type, proxyAddress: proxy.address, proxyPort: proxy.port, proxyLogin: proxy.login, proxyPassword: proxy.password, } : {}), // The browser that will submit the form. Sent only alongside a proxy: // a solve routed through a residential address is more convincing when // it also claims the same browser, and 2captcha ignores it otherwise. ...(proxy && body.userAgent ? { userAgent: String(body.userAgent) } : {}), }; if (provider === 'turnstile') { return { task: { type: `TurnstileTask${suffix}`, ...common, // Only present when the page rendered the widget programmatically // with them. Sending empty ones makes 2captcha reject the task. ...(body.action ? { action: String(body.action) } : {}), ...(body.data ? { data: String(body.data) } : {}), ...(body.pagedata ? { pagedata: String(body.pagedata) } : {}), }, }; } if (provider === 'hcaptcha') { return { task: { type: `HCaptchaTask${suffix}`, ...common, isInvisible: !!body.invisible, }, }; } if (provider === 'recaptcha') { return { task: { type: body.enterprise ? `RecaptchaV2EnterpriseTask${suffix}` : `RecaptchaV2Task${suffix}`, ...common, isInvisible: !!body.invisible, }, }; } return { error: `unknown captcha provider ${provider || '(none)'}` }; } async function callUpstream(path: string, payload: unknown): Promise { const res = await fetch(`${API_BASE}/${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); if (!res.ok) throw new Error(`2captcha ${path} -> HTTP ${res.status}`); return res.json(); } /** Queue a solve. Returns the 2captcha task id to poll with GET. */ export async function POST(req: NextRequest) { const clientKey = apiKey(); if (!clientKey) { return corsJson( { error: 'No 2captcha API key — set TWOCAPTCHA_API_KEY, or `twocaptcha_api_key` via /api/settings' }, { status: 400 }, ); } let body: Record; try { body = await req.json(); } catch { return corsJson({ error: 'Invalid JSON body' }, { status: 400 }); } const proxy = proxyConfig(); let resolved: (Proxy & { address: string }) | null = null; if (proxy) { try { resolved = { ...proxy, address: await proxyAddress(proxy.host) }; } catch (err) { return corsJson( { error: `cannot resolve proxy host ${proxy.host}: ${message(err, 'DNS lookup failed')}` }, { status: 502 }, ); } } const { task, error } = buildTask(body, resolved); if (!task) return corsJson({ error }, { status: 400 }); try { const out = await callUpstream('createTask', { clientKey, task, softId: 0 }); if (out.errorId) { // A rejected task is 2captcha's verdict, not a broken pipeline — a bad // key or an empty balance both land here, and the runner reports them // as the step failure they are. return corsJson({ error: `${out.errorCode}: ${out.errorDescription}` }, { status: 400 }); } return corsJson({ taskId: out.taskId, type: task.type, // Echoed so the run log says which address solved it — the first // thing to check when a token comes back and the site rejects it. via: resolved ? `${proxy!.host}:${proxy!.port} (${resolved.address})` : 'no proxy', }); } catch (err) { return corsJson({ error: message(err, 'createTask failed') }, { status: 502 }); } } /** Poll one task. `processing` means keep waiting; `failed` means this attempt * is dead and a fresh task is the only way forward. */ export async function GET(req: NextRequest) { const clientKey = apiKey(); if (!clientKey) return corsJson({ error: 'No 2captcha API key configured' }, { status: 400 }); const taskId = req.nextUrl.searchParams.get('taskId'); if (!taskId) return corsJson({ error: '`taskId` is required' }, { status: 400 }); try { const out = await callUpstream('getTaskResult', { clientKey, taskId: Number(taskId) || taskId }); if (out.errorId) { return corsJson({ status: 'failed', error: `${out.errorCode}: ${out.errorDescription}` }); } if (out.status !== 'ready') return corsJson({ status: 'processing' }); // Turnstile and hCaptcha answer with `token`, reCAPTCHA with // `gRecaptchaResponse`. Same string, two names. const token = out.solution?.token || out.solution?.gRecaptchaResponse || ''; if (!token) return corsJson({ status: 'failed', error: 'solved, but the answer carried no token' }); return corsJson({ status: 'ready', token, cost: out.cost ?? null }); } catch (err) { return corsJson({ error: message(err, 'getTaskResult failed') }, { status: 502 }); } } export async function OPTIONS() { return corsPreflight(); }