Security: Cloudflare Turnstile für Kontaktformular, server-seitige Verifizierung

This commit is contained in:
rene 2026-05-17 19:51:19 +02:00
parent cbaac4b5a4
commit cbbe6b9996
5 changed files with 64 additions and 8 deletions

View file

@ -64,7 +64,7 @@ deploy: check-ssh
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH)/" @COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH)/"
@echo "→ .env auf DS aktualisieren..." @echo "→ .env auf DS aktualisieren..."
@if [ -f .env ]; then \ @if [ -f .env ]; then \
grep BREVO_KEY .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \ grep -E "BREVO_KEY|TURNSTILE_SECRET" .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
fi fi
@echo "→ PocketBase Hooks synchronisieren..." @echo "→ PocketBase Hooks synchronisieren..."
@for f in $(HOOKS_SRC)/*.pb.js; do \ @for f in $(HOOKS_SRC)/*.pb.js; do \

View file

@ -9,6 +9,7 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">

View file

@ -4,6 +4,7 @@
import { pb } from '$lib/pb'; import { pb } from '$lib/pb';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
const PUBLIC_TURNSTILE_SITE_KEY = '0x4AAAAAADRLILtAf9XMk-g0';
const DEMO_TENANT = 'mengbzc3ajxpccz'; const DEMO_TENANT = 'mengbzc3ajxpccz';
@ -56,14 +57,22 @@
if (!formName.trim() || !formEmail.trim()) { formError = 'Bitte Name und E-Mail ausfüllen.'; return; } if (!formName.trim() || !formEmail.trim()) { formError = 'Bitte Name und E-Mail ausfüllen.'; return; }
formSending = true; formError = ''; formSending = true; formError = '';
try { try {
await pb.collection('inquiries').create({ const token = (window as any).turnstile?.getResponse() ?? '';
name: formName.trim(), const res = await fetch('/api/contact', {
company: formCompany.trim(), method: 'POST',
email: formEmail.trim(), headers: { 'Content-Type': 'application/json' },
phone: formPhone.trim(), body: JSON.stringify({
message: formMsg.trim(), token, name: formName.trim(), email: formEmail.trim(),
plan: modalPlan company: formCompany.trim(), phone: formPhone.trim(),
message: formMsg.trim(), plan: modalPlan
})
}); });
if (!res.ok) {
const d = await res.json();
formError = d.error ?? 'Fehler beim Senden.';
(window as any).turnstile?.reset();
return;
}
formDone = true; formDone = true;
formName = formCompany = formEmail = formPhone = formMsg = ''; formName = formCompany = formEmail = formPhone = formMsg = '';
} catch { } catch {
@ -438,6 +447,7 @@
<label for="m-msg">Nachricht (optional)</label> <label for="m-msg">Nachricht (optional)</label>
<textarea id="m-msg" rows="3" placeholder="Wie viele Kunden betreuen Sie? Welche Branche?" bind:value={formMsg}></textarea> <textarea id="m-msg" rows="3" placeholder="Wie viele Kunden betreuen Sie? Welche Branche?" bind:value={formMsg}></textarea>
</div> </div>
<div class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-theme="light"></div>
{#if formError}<p class="modal-error">{formError}</p>{/if} {#if formError}<p class="modal-error">{formError}</p>{/if}
<button type="submit" class="btn-primary" disabled={formSending}> <button type="submit" class="btn-primary" disabled={formSending}>
{formSending ? 'Wird gesendet…' : 'Anfrage senden'} {formSending ? 'Wird gesendet…' : 'Anfrage senden'}

View file

@ -0,0 +1,44 @@
import { json } from '@sveltejs/kit';
export async function POST({ request }) {
const body = await request.json();
const { token, name, email, company, phone, message, plan } = body;
if (!name?.trim() || !email?.trim()) {
return json({ error: 'Name und E-Mail sind Pflichtfelder.' }, { status: 400 });
}
const secret = process.env.TURNSTILE_SECRET ?? '';
// Turnstile serverseitig verifizieren
const verification = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, response: token })
});
const result = await verification.json();
if (!result.success) {
return json({ error: 'Captcha-Verifizierung fehlgeschlagen.' }, { status: 400 });
}
// Anfrage in PocketBase speichern
const pb = await fetch('https://api.checkflo.de/api/collections/inquiries/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
email: email.trim(),
company: company?.trim() ?? '',
phone: phone?.trim() ?? '',
message: message?.trim() ?? '',
plan: plan ?? ''
})
});
if (!pb.ok) {
return json({ error: 'Fehler beim Speichern.' }, { status: 500 });
}
return json({ success: true });
}

View file

@ -27,6 +27,7 @@ services:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=3000 - PORT=3000
- TURNSTILE_SECRET=${TURNSTILE_SECRET}
networks: networks:
- default - default
- npm_bridge - npm_bridge