- Add hcaptcha_site_key and hcaptcha to captcha_provider options in admin settings - Update captcha.ts server-side verification for hCaptcha (new endpoint + secret key) - Add hCaptcha widget rendering in register-form.tsx - All TypeScript and Biome checks pass
108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
/**
|
|
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
|
|
* provider in housekeeping. Supports Cloudflare Turnstile, Google reCAPTCHA,
|
|
* and hCaptcha (three AtomCMS offers, mutually exclusive).
|
|
*
|
|
* Behaviour:
|
|
* - provider "none" (or unset) → fail-open (allow)
|
|
* - provider configured but site key / secret missing, token absent, provider
|
|
* API error, or network failure → fail-closed (deny)
|
|
*
|
|
* Settings keys:
|
|
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none" (default none)
|
|
* turnstile_secret / turnstile_site_key
|
|
* recaptcha_secret / recaptcha_site_key
|
|
* hcaptcha_secret / hcaptcha_site_key
|
|
*/
|
|
export interface CaptchaConfig {
|
|
provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
|
|
siteKey: string;
|
|
/** Form field the widget writes the token into. */
|
|
field: string;
|
|
}
|
|
|
|
const TURNSTILE_URL =
|
|
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
|
|
const HCAPTCHA_URL = "https://hcaptcha.com/siteverify";
|
|
|
|
/** Public config the register/login pages need to render the widget (no secrets). */
|
|
export async function captchaConfig(): Promise<CaptchaConfig> {
|
|
const provider = (
|
|
(await siteSettings.get("captcha_provider", "none")) ?? "none"
|
|
).toLowerCase();
|
|
if (provider === "turnstile") {
|
|
return {
|
|
provider: "turnstile",
|
|
siteKey: (await siteSettings.get("turnstile_site_key", "")) ?? "",
|
|
field: "cf-turnstile-response",
|
|
};
|
|
}
|
|
if (provider === "recaptcha") {
|
|
return {
|
|
provider: "recaptcha",
|
|
siteKey: (await siteSettings.get("recaptcha_site_key", "")) ?? "",
|
|
field: "g-recaptcha-response",
|
|
};
|
|
}
|
|
if (provider === "hcaptcha") {
|
|
return {
|
|
provider: "hcaptcha",
|
|
siteKey: (await siteSettings.get("hcaptcha_site_key", "")) ?? "",
|
|
field: "hcaptcha-response",
|
|
};
|
|
}
|
|
return { provider: "none", siteKey: "", field: "" };
|
|
}
|
|
|
|
/** Verify a submitted token. Fail-closed when a provider is configured. */
|
|
export async function verifyCaptcha(
|
|
token: string | null,
|
|
remoteIp?: string,
|
|
): Promise<boolean> {
|
|
const cfg = await captchaConfig();
|
|
if (cfg.provider === "none") return true;
|
|
|
|
if (!cfg.siteKey) return false;
|
|
|
|
const secretKey: string =
|
|
cfg.provider === "turnstile"
|
|
? "turnstile_secret"
|
|
: cfg.provider === "recaptcha"
|
|
? "recaptcha_secret"
|
|
: "hcaptcha_secret";
|
|
const secret = (await siteSettings.get(secretKey, "")) ?? "";
|
|
if (!secret) return false;
|
|
if (!token) return false;
|
|
|
|
const url: string =
|
|
cfg.provider === "turnstile"
|
|
? TURNSTILE_URL
|
|
: cfg.provider === "recaptcha"
|
|
? RECAPTCHA_URL
|
|
: HCAPTCHA_URL;
|
|
const body = new URLSearchParams({ secret, response: token });
|
|
if (remoteIp) body.set("remoteip", remoteIp);
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body,
|
|
signal: controller.signal,
|
|
cache: "no-store",
|
|
});
|
|
clearTimeout(timer);
|
|
if (!res.ok) return false;
|
|
const data = (await res.json()) as { success?: boolean };
|
|
return data?.success === true;
|
|
} catch {
|
|
// Network/timeout/misconfig — fail-closed so captcha can't be bypassed.
|
|
return false;
|
|
}
|
|
}
|