presseportale/resources/views/components/turnstile.blade.php
Kevin Adametz 06d2889a4d feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier,
  <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und
  im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert
- Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit
  Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden);
  Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen
- Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach
- Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations
  (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true)
- contact-messages:quarantine-spam für den Altbestand
- CSP erlaubt challenges.cloudflare.com, Log-Kanal spam
- Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:49:58 +00:00

133 lines
5.7 KiB
PHP

{{--
Cloudflare Turnstile (Spamschutz B1, dev/sicherheit/ENTWICKLUNGSPLAN-
SPAMSCHUTZ-2026-10-09.md). Serverseitig prüft TurnstileVerifier.
- Das Skript lädt erst, wenn das Widget sichtbar wird (Kontakt-Widget:
beim Öffnen) — normale Seitenaufrufe sprechen Cloudflare nicht an.
- Klassisches Formular: Turnstile legt das Feld `cf-turnstile-response`
selbst an. Wird abgeschickt, bevor das Token da ist, wartet das Formular
und sendet automatisch nach.
- Livewire: `wire-token` / `wire-unavailable` nennen die Properties, die
das Widget ohne Request setzt; `turnstile-reset` (Browser-Event) holt
nach einer abgelehnten Prüfung ein frisches Token.
- Lädt das Skript nicht (Cloudflare nicht erreichbar, Netz blockiert)
oder ist das Widget falsch konfiguriert, meldet es `turnstile_unavailable`:
Der Server lässt dann durch und markiert (Entscheidung 09.10.2026).
Nicht bestandene Challenges (Fehlercodes 300xxx/600xxx) zählen nicht
als Ausfall — Turnstile versucht es selbst erneut.
--}}
@props([
'action',
'wireToken' => null,
'wireUnavailable' => null,
])
@if (\App\Services\Spam\TurnstileVerifier::enabled())
<div wire:ignore
x-data="{
config: @js([
'sitekey' => (string) config('services.turnstile.site_key'),
'action' => $action,
'language' => app()->getLocale() === 'en' ? 'en' : 'de',
'wireToken' => $wireToken,
'wireUnavailable' => $wireUnavailable,
]),
token: '',
unavailable: false,
waiting: false,
widgetId: null,
init() {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
this.load();
}
});
observer.observe(this.$el);
const form = this.$el.closest('form');
if (form && ! this.config.wireToken) {
form.addEventListener('submit', (event) => {
if (this.token || this.unavailable) {
return;
}
event.preventDefault();
this.waiting = true;
this.load();
});
}
},
load() {
if (this.widgetId !== null || this.unavailable) {
return;
}
window.pkTurnstileReady ??= new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), 10000);
window.pkTurnstileOnload = () => { clearTimeout(timer); resolve(window.turnstile); };
const script = document.createElement('script');
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=pkTurnstileOnload';
script.async = true;
script.onerror = () => { clearTimeout(timer); reject(new Error('load')); };
document.head.appendChild(script);
});
window.pkTurnstileReady.then((turnstile) => {
if (this.widgetId !== null) {
return;
}
this.widgetId = turnstile.render(this.$refs.box, {
sitekey: this.config.sitekey,
action: this.config.action,
language: this.config.language,
size: 'flexible',
'refresh-expired': 'auto',
callback: (token) => this.setToken(token),
'expired-callback': () => this.setToken(''),
'error-callback': (code) => {
if (! /^(300|600)/.test(String(code))) {
this.markUnavailable();
}
},
});
}).catch(() => {
window.pkTurnstileReady = null;
this.markUnavailable();
});
},
setToken(token) {
this.token = token;
if (this.config.wireToken) {
this.$wire.$set(this.config.wireToken, token, false);
}
if (token) {
this.submitPending();
}
},
markUnavailable() {
this.unavailable = true;
if (this.config.wireUnavailable) {
this.$wire.$set(this.config.wireUnavailable, true, false);
}
this.submitPending();
},
submitPending() {
if (! this.waiting) {
return;
}
this.waiting = false;
this.$nextTick(() => this.$el.closest('form')?.requestSubmit());
},
reset() {
this.setToken('');
if (this.widgetId !== null && window.turnstile) {
window.turnstile.reset(this.widgetId);
}
},
}"
@turnstile-reset.window="reset()"
{{ $attributes->class('min-h-[65px]') }}
>
<div x-ref="box"></div>
<input type="hidden" name="{{ \App\Services\Spam\TurnstileVerifier::UNAVAILABLE_FIELD }}" :value="unavailable ? '1' : ''" />
<p x-show="waiting" x-cloak class="mt-1 text-[11.5px] text-ink-3">{{ __('web.launcher.bot_check_waiting') }}</p>
</div>
@endif