resolvePublishedRelease($slug); $type = LegalRequestType::tryFrom((string) $request->query('type')) ?? LegalRequestType::Report; // Zweistufige Triage (Meldeverfahren v2): Absender-Anliegen // (Korrektur/Änderung/Depublizierung) laufen über den Magic-Link-Weg, // nicht über dieses Formular. $initialConcern = match ($type) { LegalRequestType::Dsgvo => 'dsgvo', LegalRequestType::PersonalRights => 'personal_rights', LegalRequestType::Correction, LegalRequestType::Depublication, LegalRequestType::Change => 'owner', default => 'report', }; return view('web.legal-request', [ 'release' => $release, 'initialConcern' => $initialConcern, // Passwortloser Portal-Login (Entscheidung 23.07.): Der Anmeldelink // führt Pressekontakte direkt ins pressekonto — dort werden ihre // PMs im Konto zusammengeführt und alle Selbstservice-Funktionen // (Korrektur, Änderung, Depublizierung, Datenschutz) stehen bereit. 'portalMagicLinkUrl' => rtrim(config('domains.domain_portal_url', config('app.url')), '/').'/anmeldelink', ]); } public function store(Request $request, string $slug): RedirectResponse { $release = $this->resolvePublishedRelease($slug); // Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken, // nichts speichern. Bewusst VOR der Validierung — eine size:0-Regel // würde mit ihrer Fehlermeldung einem Bot die Falle verraten. if (filled($request->input('website')) || filled($request->input('homepage'))) { return $this->done($release); } $validated = $request->validate([ // Bewusst NICHT das volle Enum: Absender-Spuren (Korrektur, // Änderung, Depublizierung) haben eigene verifizierte Flows und // dürfen hier nicht als anonyme Queue-Anfrage einlaufen. 'type' => ['required', Rule::in([ LegalRequestType::Report->value, LegalRequestType::Dsgvo->value, LegalRequestType::PersonalRights->value, ])], // E-Mail für DSGVO/Persönlichkeitsrecht nötig (Rückmeldung); bei // einer Meldung optional. 'requester_email' => [ Rule::requiredIf(fn () => in_array($request->input('type'), [ LegalRequestType::Dsgvo->value, LegalRequestType::PersonalRights->value, ], true)), 'nullable', 'email', 'max:255', ], 'message' => ['required', 'string', 'min:10', 'max:5000'], ]); $type = LegalRequestType::from($validated['type']); $this->ensureNotRateLimited($release); // Bremse: max. 1 offene Anfrage pro PM + Typ (nicht hart gesperrt – andere // Typen und spätere Anfragen nach Erledigung bleiben möglich). $hasOpen = LegalRequest::query() ->where('press_release_id', $release->getKey()) ->where('type', $type->value) ->open() ->exists(); if ($hasOpen) { return back()->with('legal-status', __('Zu dieser Pressemitteilung liegt bereits eine offene Anfrage dieser Art vor. Wir bearbeiten sie schnellstmöglich.')); } RateLimiter::hit($this->throttleKey($release), 3600); $legalRequest = LegalRequest::query()->create([ 'press_release_id' => $release->getKey(), 'portal' => $release->portal?->value, 'type' => $type->value, 'status' => LegalRequestStatus::Open->value, 'requester_email' => $validated['requester_email'] ?? null, 'message' => $validated['message'], 'requester_ip' => $request->ip(), ]); // T10/DSA Art. 16 Abs. 4: unverzügliche Eingangsbestätigung mit // Vorgangsnummer (sofern eine E-Mail-Adresse angegeben wurde) + // interner Hinweis ans Melde-Postfach. if ($legalRequest->requester_email) { Mail::to($legalRequest->requester_email)->queue( (new LegalRequestReceived($legalRequest))->afterCommit() ); } Mail::to($this->operatorRecipient())->queue( (new LegalRequestSubmitted($legalRequest))->afterCommit() ); return redirect() ->route('release.detail', ['slug' => $release->slug]) ->with('legal-status', __('Vielen Dank. Ihre Anfrage ist unter der Vorgangsnummer :reference eingegangen und wird von unserem Team geprüft.', [ 'reference' => $legalRequest->reference(), ])); } private function done(PressRelease $release): RedirectResponse { return redirect() ->route('release.detail', ['slug' => $release->slug]) ->with('legal-status', __('Vielen Dank. Ihre Anfrage ist eingegangen und wird von unserem Team geprüft.')); } private function operatorRecipient(): string { $brand = str_contains(request()->getHost(), 'presseecho') ? config('domains.domains.presseecho.brand', []) : config('domains.domains.businessportal24.brand', []); return $brand['report_email'] ?? (string) config('legal.contact_email'); } private function resolvePublishedRelease(string $slug): PressRelease { $portal = str_contains(request()->getHost(), 'presseecho') ? Portal::Presseecho : Portal::Businessportal24; $release = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereIn('portal', [$portal->value, Portal::Both->value]) ->where('status', PressReleaseStatus::Published) ->where('language', 'de') ->whereNotNull('published_at') ->where('published_at', '<=', now()) ->where('slug', $slug) ->first(); abort_if($release === null, 404); return $release; } private function ensureNotRateLimited(PressRelease $release): void { if (! RateLimiter::tooManyAttempts($this->throttleKey($release), 5)) { return; } $seconds = RateLimiter::availableIn($this->throttleKey($release)); throw ValidationException::withMessages([ 'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [ 'minutes' => max(1, (int) ceil($seconds / 60)), ]), ]); } private function throttleKey(PressRelease $release): string { return 'legal-request|'.request()->ip().'|'.$release->getKey(); } }