Add hCaptcha support alongside Turnstile and reCAPTCHA
CI / check (push) Successful in 1m9s
CI / release (push) Skipped
CI / deploy (push) Successful in 2m6s

- 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
This commit is contained in:
openhands committed 2026-08-14 17:04:34 +02:00
1 parent 1bca9657fa
commit 0f35bc8529
3 files changed
+34 -8

No files matched your search

@@ -269,7 +269,7 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
{ {
key: "captcha_provider", key: "captcha_provider",
label: "Captcha provider", label: "Captcha provider",
description: "none | turnstile | recaptcha", description: "none | turnstile | recaptcha | hcaptcha",
type: "text", type: "text",
defaultValue: "none", defaultValue: "none",
}, },
@@ -278,6 +278,11 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
label: "Turnstile site key", label: "Turnstile site key",
type: "text", type: "text",
}, },
{
key: "hcaptcha_site_key",
label: "hCaptcha site key",
type: "text",
},
{ {
key: "recaptcha_site_key", key: "recaptcha_site_key",
label: "reCAPTCHA site key", label: "reCAPTCHA site key",
+3
View File
@@ -322,6 +322,9 @@ export function RegisterForm({
{showCaptcha && captcha.provider === "recaptcha" && ( {showCaptcha && captcha.provider === "recaptcha" && (
<div className="g-recaptcha" data-sitekey={captcha.siteKey} /> <div className="g-recaptcha" data-sitekey={captcha.siteKey} />
)} )}
{showCaptcha && captcha.provider === "hcaptcha" && (
<div className="h-captcha" data-sitekey={captcha.siteKey} />
)}
{/* Submit */} {/* Submit */}
<button <button
+25 -7
View File
@@ -2,8 +2,8 @@ import { siteSettings } from "@/lib/services/site-settings";
/** /**
* Server-side CAPTCHA verification, driven by website_settings so staff pick the * Server-side CAPTCHA verification, driven by website_settings so staff pick the
* provider in housekeeping. Supports Cloudflare Turnstile and Google reCAPTCHA * provider in housekeeping. Supports Cloudflare Turnstile, Google reCAPTCHA,
* (the two AtomCMS offers, mutually exclusive). * and hCaptcha (three AtomCMS offers, mutually exclusive).
* *
* Behaviour: * Behaviour:
* - provider "none" (or unset) → fail-open (allow) * - provider "none" (or unset) → fail-open (allow)
@@ -11,12 +11,13 @@ import { siteSettings } from "@/lib/services/site-settings";
* API error, or network failure → fail-closed (deny) * API error, or network failure → fail-closed (deny)
* *
* Settings keys: * Settings keys:
* captcha_provider = "turnstile" | "recaptcha" | "none" (default none) * captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none" (default none)
* turnstile_secret / turnstile_site_key * turnstile_secret / turnstile_site_key
* recaptcha_secret / recaptcha_site_key * recaptcha_secret / recaptcha_site_key
* hcaptcha_secret / hcaptcha_site_key
*/ */
export interface CaptchaConfig { export interface CaptchaConfig {
provider: "turnstile" | "recaptcha" | "none"; provider: "turnstile" | "recaptcha" | "hcaptcha" | "none";
siteKey: string; siteKey: string;
/** Form field the widget writes the token into. */ /** Form field the widget writes the token into. */
field: string; field: string;
@@ -25,6 +26,7 @@ export interface CaptchaConfig {
const TURNSTILE_URL = const TURNSTILE_URL =
"https://challenges.cloudflare.com/turnstile/v0/siteverify"; "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/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). */ /** Public config the register/login pages need to render the widget (no secrets). */
export async function captchaConfig(): Promise<CaptchaConfig> { export async function captchaConfig(): Promise<CaptchaConfig> {
@@ -45,6 +47,13 @@ export async function captchaConfig(): Promise<CaptchaConfig> {
field: "g-recaptcha-response", 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: "" }; return { provider: "none", siteKey: "", field: "" };
} }
@@ -58,13 +67,22 @@ export async function verifyCaptcha(
if (!cfg.siteKey) return false; if (!cfg.siteKey) return false;
const secretKey = const secretKey: string =
cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret"; cfg.provider === "turnstile"
? "turnstile_secret"
: cfg.provider === "recaptcha"
? "recaptcha_secret"
: "hcaptcha_secret";
const secret = (await siteSettings.get(secretKey, "")) ?? ""; const secret = (await siteSettings.get(secretKey, "")) ?? "";
if (!secret) return false; if (!secret) return false;
if (!token) return false; if (!token) return false;
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL; const url: string =
cfg.provider === "turnstile"
? TURNSTILE_URL
: cfg.provider === "recaptcha"
? RECAPTCHA_URL
: HCAPTCHA_URL;
const body = new URLSearchParams({ secret, response: token }); const body = new URLSearchParams({ secret, response: token });
if (remoteIp) body.set("remoteip", remoteIp); if (remoteIp) body.set("remoteip", remoteIp);