feat(contact): add Cloudflare Turnstile captcha
Client renders explicit Turnstile widget (flexible, light theme) when PUBLIC_TURNSTILE_SITE_KEY is set. Token sent with form payload, server verifies via siteverify endpoint before forwarding to CMS. Skipped entirely when no site key configured (dev fallback). New i18n keys: contact_captcha_pending, contact_server_error_captcha. Deploy workflow forwards PUBLIC_TURNSTILE_SITE_KEY + TURNSTILE_SECRET. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,8 @@ jobs:
|
|||||||
IMAGE_CACHE_DIR=/data/image-cache
|
IMAGE_CACHE_DIR=/data/image-cache
|
||||||
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
|
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
|
||||||
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
|
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
|
PUBLIC_TURNSTILE_SITE_KEY=${{ secrets.PUBLIC_TURNSTILE_SITE_KEY }}
|
||||||
|
TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
|
|
||||||
- name: Pull and restart container
|
- name: Pull and restart container
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
import Button from "$lib/ui/Button.svelte";
|
import Button from "$lib/ui/Button.svelte";
|
||||||
import TextInput from "$lib/ui/TextInput.svelte";
|
import TextInput from "$lib/ui/TextInput.svelte";
|
||||||
import Textarea from "$lib/ui/Textarea.svelte";
|
import Textarea from "$lib/ui/Textarea.svelte";
|
||||||
@@ -15,6 +17,9 @@
|
|||||||
|
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
|
const TURNSTILE_SITE_KEY = publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? "";
|
||||||
|
const TURNSTILE_ENABLED = TURNSTILE_SITE_KEY !== "";
|
||||||
|
|
||||||
let name = $state("");
|
let name = $state("");
|
||||||
let email = $state("");
|
let email = $state("");
|
||||||
let subject = $state("");
|
let subject = $state("");
|
||||||
@@ -32,6 +37,10 @@
|
|||||||
let serverError = $state("");
|
let serverError = $state("");
|
||||||
let submitAttempted = $state(false);
|
let submitAttempted = $state(false);
|
||||||
|
|
||||||
|
let turnstileToken = $state("");
|
||||||
|
let turnstileEl: HTMLDivElement | null = null;
|
||||||
|
let turnstileWidgetId: string | null = null;
|
||||||
|
|
||||||
const fields = $derived<ContactFields>({ name, email, subject, message, consent });
|
const fields = $derived<ContactFields>({ name, email, subject, message, consent });
|
||||||
const errors = $derived<ContactErrors>(validateContact(fields));
|
const errors = $derived<ContactErrors>(validateContact(fields));
|
||||||
const isValid = $derived(Object.keys(errors).length === 0);
|
const isValid = $derived(Object.keys(errors).length === 0);
|
||||||
@@ -43,6 +52,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageRemaining = $derived(LIMITS.MESSAGE_MAX - message.length);
|
const messageRemaining = $derived(LIMITS.MESSAGE_MAX - message.length);
|
||||||
|
|
||||||
|
type TurnstileAPI = {
|
||||||
|
render: (
|
||||||
|
el: HTMLElement,
|
||||||
|
opts: {
|
||||||
|
sitekey: string;
|
||||||
|
theme?: "light" | "dark" | "auto";
|
||||||
|
size?: "normal" | "compact" | "flexible";
|
||||||
|
callback?: (token: string) => void;
|
||||||
|
"expired-callback"?: () => void;
|
||||||
|
"error-callback"?: () => void;
|
||||||
|
},
|
||||||
|
) => string;
|
||||||
|
reset: (id?: string) => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTurnstile(): TurnstileAPI | undefined {
|
||||||
|
return (window as unknown as { turnstile?: TurnstileAPI }).turnstile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTurnstile() {
|
||||||
|
if (!TURNSTILE_ENABLED || !turnstileEl) return;
|
||||||
|
const ts = getTurnstile();
|
||||||
|
if (!ts) return;
|
||||||
|
if (turnstileWidgetId !== null) return;
|
||||||
|
turnstileWidgetId = ts.render(turnstileEl, {
|
||||||
|
sitekey: TURNSTILE_SITE_KEY,
|
||||||
|
theme: "light",
|
||||||
|
size: "flexible",
|
||||||
|
callback: (token) => {
|
||||||
|
turnstileToken = token;
|
||||||
|
},
|
||||||
|
"expired-callback": () => {
|
||||||
|
turnstileToken = "";
|
||||||
|
},
|
||||||
|
"error-callback": () => {
|
||||||
|
turnstileToken = "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTurnstileScript() {
|
||||||
|
if (!TURNSTILE_ENABLED) return;
|
||||||
|
if (document.querySelector("script[data-turnstile]")) {
|
||||||
|
renderTurnstile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||||
|
s.async = true;
|
||||||
|
s.defer = true;
|
||||||
|
s.dataset.turnstile = "1";
|
||||||
|
s.onload = renderTurnstile;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadTurnstileScript);
|
||||||
|
onDestroy(() => {
|
||||||
|
if (turnstileWidgetId !== null) {
|
||||||
|
getTurnstile()?.remove(turnstileWidgetId);
|
||||||
|
turnstileWidgetId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
const messageHelp = $derived(
|
const messageHelp = $derived(
|
||||||
messageRemaining >= 0
|
messageRemaining >= 0
|
||||||
? t(T.contact_message_remaining, { n: messageRemaining })
|
? t(T.contact_message_remaining, { n: messageRemaining })
|
||||||
@@ -59,6 +132,11 @@
|
|||||||
el?.focus();
|
el?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (TURNSTILE_ENABLED && !turnstileToken) {
|
||||||
|
serverError = t(T.contact_captcha_pending);
|
||||||
|
formState = "error";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
formState = "pending";
|
formState = "pending";
|
||||||
serverError = "";
|
serverError = "";
|
||||||
@@ -77,6 +155,7 @@
|
|||||||
website,
|
website,
|
||||||
company,
|
company,
|
||||||
_client_age_ms: Date.now() - pageLoadedAt,
|
_client_age_ms: Date.now() - pageLoadedAt,
|
||||||
|
cf_turnstile_token: turnstileToken,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,14 +170,24 @@
|
|||||||
} else if (res.status === 429) {
|
} else if (res.status === 429) {
|
||||||
serverError = t(T.contact_server_error_throttle);
|
serverError = t(T.contact_server_error_throttle);
|
||||||
} else if (res.status === 403) {
|
} else if (res.status === 403) {
|
||||||
serverError = t(T.contact_server_error_blocked);
|
serverError = body?.error === "captcha"
|
||||||
|
? t(T.contact_server_error_captcha)
|
||||||
|
: t(T.contact_server_error_blocked);
|
||||||
} else {
|
} else {
|
||||||
serverError = body?.error || t(T.contact_server_error_generic);
|
serverError = body?.error || t(T.contact_server_error_generic);
|
||||||
}
|
}
|
||||||
formState = "error";
|
formState = "error";
|
||||||
|
if (TURNSTILE_ENABLED) {
|
||||||
|
turnstileToken = "";
|
||||||
|
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
serverError = t(T.contact_server_error_network);
|
serverError = t(T.contact_server_error_network);
|
||||||
formState = "error";
|
formState = "error";
|
||||||
|
if (TURNSTILE_ENABLED) {
|
||||||
|
turnstileToken = "";
|
||||||
|
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -187,6 +276,10 @@
|
|||||||
<p class="mt-1 text-xs text-stein-500">{t(T.contact_privacy_note)}</p>
|
<p class="mt-1 text-xs text-stein-500">{t(T.contact_privacy_note)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if TURNSTILE_ENABLED}
|
||||||
|
<div bind:this={turnstileEl} class="pt-1"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if serverError}
|
{#if serverError}
|
||||||
<div class="rounded-sm border border-error bg-error-subtle px-3 py-2 text-sm text-error shadow-sm" role="alert">
|
<div class="rounded-sm border border-error bg-error-subtle px-3 py-2 text-sm text-error shadow-sm" role="alert">
|
||||||
{serverError}
|
{serverError}
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ const TRANSLATION_KEYS = [
|
|||||||
"contact_error_message_too_many_links", // {{n}}
|
"contact_error_message_too_many_links", // {{n}}
|
||||||
"contact_error_message_invalid_chars",
|
"contact_error_message_invalid_chars",
|
||||||
"contact_error_consent_required",
|
"contact_error_consent_required",
|
||||||
|
"contact_captcha_pending",
|
||||||
|
"contact_server_error_captcha",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { json } from "@sveltejs/kit";
|
import { json } from "@sveltejs/kit";
|
||||||
import { env as publicEnv } from "$env/dynamic/public";
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
|
import { env as privateEnv } from "$env/dynamic/private";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
import {
|
import {
|
||||||
HONEYPOT_FIELDS,
|
HONEYPOT_FIELDS,
|
||||||
@@ -7,6 +8,27 @@ import {
|
|||||||
validateContact,
|
validateContact,
|
||||||
} from "$lib/components/internal/contact-form-validation";
|
} from "$lib/components/internal/contact-form-validation";
|
||||||
|
|
||||||
|
const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? "";
|
||||||
|
const TURNSTILE_ENABLED = TURNSTILE_SECRET !== "";
|
||||||
|
|
||||||
|
async function verifyTurnstile(token: string, ip: string): Promise<boolean> {
|
||||||
|
if (!TURNSTILE_ENABLED) return true;
|
||||||
|
if (!token) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token, remoteip: ip }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = (await res.json()) as { success?: boolean };
|
||||||
|
return data.success === true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[contact] turnstile verify failed:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = new Set([
|
const ALLOWED_ORIGINS = new Set([
|
||||||
"https://windwiderstand.de",
|
"https://windwiderstand.de",
|
||||||
"https://www.windwiderstand.de",
|
"https://www.windwiderstand.de",
|
||||||
@@ -44,6 +66,15 @@ export const POST: RequestHandler = async ({ request, getClientAddress, url }) =
|
|||||||
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
|
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cloudflare Turnstile (skipped when no secret configured)
|
||||||
|
if (TURNSTILE_ENABLED) {
|
||||||
|
const token = typeof body.cf_turnstile_token === "string" ? body.cf_turnstile_token : "";
|
||||||
|
const ok = await verifyTurnstile(token, getClientAddress());
|
||||||
|
if (!ok) {
|
||||||
|
return json({ error: "captcha" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Full validation
|
// Full validation
|
||||||
const fields = {
|
const fields = {
|
||||||
name: typeof body.name === "string" ? body.name : "",
|
name: typeof body.name === "string" ? body.name : "",
|
||||||
|
|||||||
Reference in New Issue
Block a user