lib/turnstile.ts
Rama main · réplica de lectura del repositorio original.
import { NextResponse } from "next/server";
type TurnstileSiteverifyResponse = {
success: boolean;
"error-codes"?: string[];
challenge_ts?: string;
hostname?: string;
action?: string;
};
const SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
function cleanEnv(name: string) {
return process.env[name]?.trim() ?? "";
}
export function getTurnstileSiteKey() {
return cleanEnv("NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY") || cleanEnv("NEXT_PUBLIC_TURNSTILE_SITE_KEY");
}
function getTurnstileSecretKey() {
return cleanEnv("CLOUDFLARE_TURNSTILE_SECRET_KEY") || cleanEnv("TURNSTILE_SECRET_KEY");
}
function hasTurnstileSecretKey() {
return Boolean(getTurnstileSecretKey());
}
export function isTurnstileConfigured() {
return Boolean(getTurnstileSiteKey() && hasTurnstileSecretKey());
}
export function isTurnstileRequired() {
return cleanEnv("CLOUDFLARE_TURNSTILE_REQUIRED") === "true" || hasTurnstileSecretKey();
}
export function getTurnstileRemoteIp(request: Request) {
const forwarded = request.headers.get("cf-connecting-ip")
?? request.headers.get("x-real-ip")
?? request.headers.get("x-forwarded-for");
return forwarded?.split(",")[0]?.trim() || undefined;
}
export async function verifyTurnstileToken(token: string | null | undefined, remoteIp?: string, expectedAction?: string) {
const secret = getTurnstileSecretKey();
const siteKey = getTurnstileSiteKey();
if (!isTurnstileRequired()) {
return { ok: true as const, skipped: true as const };
}
if (!secret || !siteKey) {
return {
ok: false as const,
status: 503,
error: "Cloudflare Turnstile is required but not configured.",
};
}
if (!token) {
return {
ok: false as const,
status: 403,
error: "Complete the Cloudflare challenge before continuing.",
};
}
const form = new URLSearchParams();
form.set("secret", secret);
form.set("response", token);
if (remoteIp) form.set("remoteip", remoteIp);
try {
const response = await fetch(SITEVERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form,
});
const result = (await response.json()) as TurnstileSiteverifyResponse;
if (response.ok && result.success && (!expectedAction || result.action === expectedAction)) {
return { ok: true as const, skipped: false as const };
}
return {
ok: false as const,
status: 403,
error: expectedAction && result.success
? "Cloudflare challenge action did not match this request."
: "Cloudflare challenge failed. Refresh the challenge and try again.",
codes: expectedAction && result.success
? ["action-mismatch"]
: result["error-codes"] ?? [],
};
} catch {
return {
ok: false as const,
status: 502,
error: "Could not verify the Cloudflare challenge.",
};
}
}
export async function requireTurnstile(request: Request, token: string | null | undefined, expectedAction?: string) {
const result = await verifyTurnstileToken(token, getTurnstileRemoteIp(request), expectedAction);
if (result.ok) return null;
return NextResponse.json({
error: result.error,
turnstile: {
required: true,
configured: isTurnstileConfigured(),
codes: result.codes ?? [],
},
}, { status: result.status });
}