Files
WRNexusJS/packages/captcha/src/http.ts
T
2026-07-27 12:42:18 +05:30

208 lines
7.0 KiB
TypeScript

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<Record<string, unknown>> {
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json"))
return (await request.json()) as Record<string, unknown>;
if (contentType.includes("form")) {
const form = await request.formData();
const payload: Record<string, unknown> = {};
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<string, Counter>();
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<Response> => {
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<Response> => {
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<Response> => {
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;
},
};
}