presseportale/app/Http/Controllers/Web/ContactController.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

176 lines
6.8 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Enums\ActivityType;
use App\Enums\BotCheckStatus;
use App\Enums\ContactMessageStatus;
use App\Enums\ContactMessageTopic;
use App\Http\Controllers\Controller;
use App\Models\ContactMessage;
use App\Services\ActivityLog;
use App\Services\Spam\ContactSpamFilter;
use App\Services\Spam\SpamLog;
use App\Services\Spam\TurnstileVerifier;
use App\Services\Web\ContactMessageNotifier;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Öffentliches Kontaktformular der Web-Domains (Hub-Modal + schwebendes
* Kontakt-Widget). Persistiert das Anliegen in der Admin-Queue „Kontakt"
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
* Absenderadresse der Anwendung).
*
* Spamschutz (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md):
* Cloudflare Turnstile für Gäste, danach Inhaltsfilter. Treffer des Filters
* landen in der Quarantäne (Status Spam, keine Mail); war Cloudflare nicht
* erreichbar, wird angenommen und im Verlauf markiert.
*/
class ContactController extends Controller
{
public function __construct(
private readonly TurnstileVerifier $turnstile,
private readonly ContactSpamFilter $spamFilter,
private readonly ContactMessageNotifier $notifier,
private readonly ActivityLog $activityLog,
) {}
public function __invoke(Request $request): RedirectResponse
{
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// bewusst VOR der Validierung (sonst verrät die size:0-Regel die Falle).
if (filled($request->input('website')) || filled($request->input('homepage'))) {
return $this->done();
}
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'min:10', 'max:5000'],
'topic' => ['nullable', Rule::enum(ContactMessageTopic::class)],
// Nur http/https: page_url wird im Admin-Backend als klickbarer Link
// gerendert — verhindert eingeschleuste javascript:-URIs (Stored XSS).
'page_url' => ['nullable', 'url:http,https', 'max:500'],
], [
'name.required' => __('web.launcher.validation_name'),
'name.max' => __('web.launcher.validation_name'),
'email.required' => __('web.launcher.validation_email'),
'email.email' => __('web.launcher.validation_email'),
'email.max' => __('web.launcher.validation_email'),
'message.required' => __('web.launcher.validation_message'),
'message.min' => __('web.launcher.validation_message_min'),
'message.max' => __('web.launcher.validation_message_max'),
]);
$this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600);
// Bestätigte Kunden sind keine Bots. Unbestätigte Konten (dort
// landen die Bot-Registrierungen) prüfen wir wie Gäste.
$trusted = $request->user()?->hasVerifiedEmail() === true;
$botCheck = $trusted ? null : $this->turnstile->verifyRequest($request, 'contact');
if ($botCheck?->blocks()) {
throw ValidationException::withMessages([
'turnstile' => __('web.launcher.bot_check_failed'),
]);
}
$spamReason = $trusted ? null : $this->spamFilter->reasonFor(
$validated['name'],
$validated['message'],
$request->string(ContactSpamFilter::STARTED_FIELD)->toString(),
);
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
$message = ContactMessage::create([
'portal' => $this->portal($request),
'topic' => $topic,
'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open,
'name' => $validated['name'],
'email' => $validated['email'],
'message' => $validated['message'],
'page_url' => $validated['page_url'] ?? null,
'payload' => array_filter([
'spam_reason' => $spamReason,
'bot_check' => $botCheck?->status === BotCheckStatus::Skipped ? null : $botCheck?->status->value,
'bot_check_reason' => $botCheck?->needsReview() ? $botCheck->reason : null,
]) ?: null,
'requester_ip' => $request->ip(),
'user_id' => $request->user()?->id,
]);
// Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender
// bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts.
if ($spamReason !== null) {
Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [
'reason' => $spamReason,
'portal' => $message->portal,
'ip_prefix' => SpamLog::ipPrefix($request->ip()),
]);
return $this->done();
}
if ($botCheck?->needsReview()) {
$this->activityLog->record(
ActivityType::BotCheckUnavailable,
sprintf('Kontaktanfrage %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $message->reference(), $botCheck->reason),
subject: $message,
actorLabel: (string) $message->email,
meta: ['form' => 'contact', 'reason' => $botCheck->reason],
);
}
$this->notifier->notify($message, $request->getHost());
return $this->done();
}
private function done(): RedirectResponse
{
return back()->with('contact-status', __('web.launcher.success'));
}
/**
* Portal-Kennung für die Admin-Queue, aus dem Host abgeleitet:
* Hub (pressekonto) vs. Spoke-Portale.
*/
private function portal(Request $request): string
{
if (str_contains($request->getHost(), 'pressekonto')) {
return 'pressekonto';
}
return WebPortalContext::portalFromHost()->value;
}
private function ensureNotRateLimited(Request $request): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'message' => __('web.launcher.throttle', [
'minutes' => max(1, (int) ceil($seconds / 60)),
]),
]);
}
private function throttleKey(Request $request): string
{
return 'contact-form|'.$request->ip();
}
}