- 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>
176 lines
6.8 KiB
PHP
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();
|
|
}
|
|
}
|