diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 659379e..233f669 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -89,6 +89,8 @@ jobs:
IMAGE_CACHE_DIR=/data/image-cache
PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
+ PUBLIC_TURNSTILE_SITE_KEY=${{ secrets.PUBLIC_TURNSTILE_SITE_KEY }}
+ TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }}
ENVEOF
- name: Pull and restart container
diff --git a/src/lib/components/internal/ContactFormComponent.svelte b/src/lib/components/internal/ContactFormComponent.svelte
index 426cd58..d20d8d6 100644
--- a/src/lib/components/internal/ContactFormComponent.svelte
+++ b/src/lib/components/internal/ContactFormComponent.svelte
@@ -1,4 +1,6 @@
@@ -187,6 +276,10 @@
{t(T.contact_privacy_note)}
+ {#if TURNSTILE_ENABLED}
+
+ {/if}
+
{#if serverError}
{serverError}
diff --git a/src/lib/translations.ts b/src/lib/translations.ts
index 1ca1238..80e4efc 100644
--- a/src/lib/translations.ts
+++ b/src/lib/translations.ts
@@ -314,6 +314,8 @@ const TRANSLATION_KEYS = [
"contact_error_message_too_many_links", // {{n}}
"contact_error_message_invalid_chars",
"contact_error_consent_required",
+ "contact_captcha_pending",
+ "contact_server_error_captcha",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
diff --git a/src/routes/api/contact/+server.ts b/src/routes/api/contact/+server.ts
index c9bd095..66bd862 100644
--- a/src/routes/api/contact/+server.ts
+++ b/src/routes/api/contact/+server.ts
@@ -1,5 +1,6 @@
import { json } from "@sveltejs/kit";
import { env as publicEnv } from "$env/dynamic/public";
+import { env as privateEnv } from "$env/dynamic/private";
import type { RequestHandler } from "./$types";
import {
HONEYPOT_FIELDS,
@@ -7,6 +8,27 @@ import {
validateContact,
} 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 {
+ 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([
"https://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 });
}
+ // 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
const fields = {
name: typeof body.name === "string" ? body.name : "",