import type { Context } from "@wrnexus/core"; import type { CaptchaEngine, CaptchaHttpHandlers, CreateCaptchaOptions, VerifyCaptchaInput, } from "./types.ts"; export interface CaptchaHttpOptions { allowedOrigins?: string[]; trustProxy?: boolean; createLimit?: number; verifyLimit?: number; windowMs?: number; } interface Counter { count: number; resetAt: number; } function json(body: unknown, status = 200, headers: HeadersInit = {}): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store, max-age=0", pragma: "no-cache", "x-content-type-options": "nosniff", ...headers, }, }); } async function readPayload(request: Request): Promise> { const contentType = request.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) return (await request.json()) as Record; if (contentType.includes("form")) { const form = await request.formData(); const payload: Record = {}; for (const [key, value] of form) { if (key === "selections") { const current = payload[key]; payload[key] = Array.isArray(current) ? [...current, String(value)] : [String(value)]; } else payload[key] = typeof value === "string" ? value : value.name; } return payload; } return {}; } function clientIp(request: Request, ctx: Context | undefined, trustProxy: boolean): string { if (trustProxy) { const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); if (forwarded) return forwarded; const real = request.headers.get("x-real-ip"); if (real) return real; } return ctx?.ip ?? "global"; } function requestBinding(request: Request, ctx: Context | undefined, trustProxy: boolean) { const url = new URL(request.url); return { hostname: url.hostname, sessionId: ctx?.session.id(), ip: clientIp(request, ctx, trustProxy), }; } export function createCaptchaHttpHandlers( engine: CaptchaEngine, options: CaptchaHttpOptions = {}, ): CaptchaHttpHandlers { const counters = new Map(); const windowMs = options.windowMs ?? 60_000; const originAllowed = (request: Request): boolean => { const origin = request.headers.get("origin"); if (!origin) return true; const own = new URL(request.url).origin; return origin === own || options.allowedOrigins?.includes(origin) === true; }; const withinLimit = (key: string, maximum: number): { allowed: boolean; retryAfter: number } => { const now = Date.now(); let counter = counters.get(key); if (!counter || counter.resetAt <= now) { counter = { count: 0, resetAt: now + windowMs }; counters.set(key, counter); } counter.count += 1; return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)), }; }; const create = async (request: Request, ctx?: Context): Promise => { if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403); if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" }); const ip = clientIp(request, ctx, options.trustProxy ?? false); const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30); if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter), }); try { const payload = await readPayload(request); const challenge = await engine.create({ ...(payload as unknown as CreateCaptchaOptions), ...requestBinding(request, ctx, options.trustProxy ?? false), }); return json(challenge, 201); } catch (error) { return json( { success: false, code: "invalid-input", message: error instanceof Error ? error.message : "Unable to create CAPTCHA challenge", }, 400, ); } }; const verify = async (request: Request, ctx?: Context): Promise => { if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403); if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" }); const ip = clientIp(request, ctx, options.trustProxy ?? false); const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60); if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter), }); try { const payload = await readPayload(request); const result = await engine.verify({ ...(payload as unknown as VerifyCaptchaInput), ...requestBinding(request, ctx, options.trustProxy ?? false), }); return json(result, result.success ? 200 : 400); } catch (error) { return json( { success: false, code: "invalid-input", message: error instanceof Error ? error.message : "Unable to verify CAPTCHA", }, 400, ); } }; const audio = async (request: Request): Promise => { if (!originAllowed(request)) return new Response("Forbidden", { status: 403 }); if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 }); const url = new URL(request.url); const prefix = `${engine.basePath}/audio/`; const id = decodeURIComponent(url.pathname.slice(prefix.length)); const key = url.searchParams.get("key") ?? ""; try { const audio = await engine.renderAudio(id, key); if (!audio) return new Response("Not Found", { status: 404 }); const body: BodyInit | null = request.method === "HEAD" ? null : Uint8Array.from(audio.bytes).buffer; return new Response(body, { headers: { "content-type": audio.contentType, "content-length": String(audio.bytes.byteLength), "cache-control": "private, no-store, max-age=0", "x-content-type-options": "nosniff", }, }); } catch (error) { console.error( "[wrnexus/captcha] Unable to render CAPTCHA audio:", error instanceof Error ? error.message : error, ); return new Response("CAPTCHA audio is unavailable", { status: 503, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store, max-age=0", "x-content-type-options": "nosniff", }, }); } }; return { create, verify, audio, async handle(request, ctx) { const pathname = new URL(request.url).pathname; if (pathname === `${engine.basePath}/challenge`) return create(request, ctx); if (pathname === `${engine.basePath}/verify`) return verify(request, ctx); if (pathname.startsWith(`${engine.basePath}/audio/`)) return audio(request); return undefined; }, }; }