208 lines
7.0 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|