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(); } }