- 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>
133 lines
5.7 KiB
PHP
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
|