Files
EpicNext-Cms/src/lib/services/captcha.ts
T
openhands 9f024dc6ac perf(app): batch catalog updates, tune caches, add captcha env fallback
- catalog: apply bulk offer edits in a single transaction with one RCON
  refresh instead of N round-trips, virtualize the items table past 150
  rows and cap the shop preview at 200 tiles
- cache: lift the public cache TTLs and the boot warm-up into env-tunable
  constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED)
- auth: resolve the captcha provider, site key and secret from
  CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the
  admin settings are unset; an explicit DB value (including "none") still
  wins, so Turnstile can be enabled per-deploy without touching the DB.
2026-10-15 16:00:00 +02:00

132 lines
4.4 KiB
TypeScript

import { env } from "@/env";
import { siteSettings } from "@/lib/services/site-settings";
/**
* Server-side CAPTCHA verification. The provider is driven by website_settings
* (staff pick it in housekeeping) with an ENV FALLBACK so a deploy can enable
* Turnstile without touching the DB: unset DB keys fall back to
* CAPTCHA_PROVIDER / TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY. An explicit DB
* value — including "none" — always wins over env. Supports Cloudflare
* Turnstile, Google reCAPTCHA, and hCaptcha (mutually exclusive).
*
* Behaviour:
* - provider "none" (or unset everywhere) → fail-open (allow)
* - provider configured but site key / secret missing, token absent, provider
* API error, or network failure → fail-closed (deny)
*
* Settings keys (DB) / env fallbacks:
* captcha_provider = "turnstile" | "recaptcha" | "hcaptcha" | "none"
* CAPTCHA_PROVIDER (env, used when the DB key is unset)
* turnstile_site_key / TURNSTILE_SITE_KEY
* turnstile_secret / TURNSTILE_SECRET_KEY
* recaptcha_site_key (DB only)
* recaptcha_secret (DB only)
* hcaptcha_site_key (DB only)
* hcaptcha_secret (DB only)
*/
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";
/** DB first (staff can override live), env fallback for unconfigured deploys. */
async function settingOrEnv(
settingKey: string,
settingDefault: string,
envValue: string | undefined,
): Promise<string> {
const dbValue = (await siteSettings.get(settingKey, settingDefault)) ?? "";
if (dbValue.trim() !== "") return dbValue.trim();
return envValue?.trim() || "";
}
/** Public config the register/login pages need to render the widget (no secrets). */
export async function captchaConfig(): Promise<CaptchaConfig> {
// Empty DB fallback distinguishes "unset" from an explicit "none": only an
// explicit DB value short-circuits the env fallback.
const provider = (
await settingOrEnv("captcha_provider", "", env.CAPTCHA_PROVIDER)
).toLowerCase();
if (provider === "turnstile") {
return {
provider: "turnstile",
siteKey: await settingOrEnv(
"turnstile_site_key",
"",
env.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 secret =
cfg.provider === "turnstile"
? await settingOrEnv("turnstile_secret", "", env.TURNSTILE_SECRET_KEY)
: cfg.provider === "recaptcha"
? ((await siteSettings.get("recaptcha_secret", "")) ?? "")
: ((await siteSettings.get("hcaptcha_secret", "")) ?? "");
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;
}
}