*/ public const CONCERNS = [ 'correction' => LegalRequestType::Correction, 'dsgvo' => LegalRequestType::Dsgvo, 'depublication' => LegalRequestType::Depublication, 'change' => LegalRequestType::Change, ]; public function showIntake(): View { return view('web.melden-pm'); } public function requestLink(Request $request, string $edition, MagicLinkGenerator $generator): RedirectResponse { // Honeypot vor der Validierung (sonst verrät die Regel die Falle). if (filled($request->input('website'))) { return back()->with('pm-report-status', __('static.pm_report.flash_link_sent', ['email' => '***'])); } $validated = $request->validate([ 'reported_url' => ['required', 'url', 'max:500'], ]); $this->ensureNotRateLimited($request); RateLimiter::hit($this->throttleKey($request), 3600); $pressRelease = $this->reportedUrls->resolve($validated['reported_url']); if (! $pressRelease) { throw ValidationException::withMessages([ 'reported_url' => __('static.pm_report.validation_not_found'), ]); } $contactEmail = $pressRelease->pressContactEmail(); // Kanal tot / keine Adresse hinterlegt (Huraux-Fall): kein // vertrauenswürdiger Kanal → manuelle Verifikation über das // bestehende Formular, Admin prüft (§6-Fallback). if (! $contactEmail) { return back()->with('pm-report-manual', true); } // Rate-Limit pro PM: Der Link-Versand darf kein Werkzeug werden, // fremde Kontakt-Postfächer zu fluten. $perReleaseKey = 'pm-report-link|'.$pressRelease->id; if (RateLimiter::tooManyAttempts($perReleaseKey, (int) config('legal.report_links_per_day'))) { throw ValidationException::withMessages([ 'reported_url' => __('static.pm_report.throttle_release'), ]); } RateLimiter::hit($perReleaseKey, 86400); $link = $generator->createReportLink($pressRelease, $contactEmail); Mail::to($contactEmail)->queue( (new PressReleaseReportLink( $pressRelease, route('melden.pm.verify', ['edition' => $edition, 'token' => $link['plain_token']]), $link['expires_at'], ))->afterCommit() ); return back()->with('pm-report-status', __('static.pm_report.flash_link_sent', [ 'email' => $this->maskEmail($contactEmail), ])); } public function verify(Request $request, string $edition, string $token): RedirectResponse { $magicLink = MagicLink::query() ->where('token_hash', hash('sha256', $token)) ->where('purpose', 'press_release_report') ->first(); if (! $magicLink || $magicLink->consumed_at !== null || $magicLink->expires_at->isPast()) { return redirect() ->route('melden.pm', ['edition' => $edition]) ->with('pm-report-error', __('static.pm_report.flash_link_invalid')); } // Atomar beanspruchen (Muster MagicLinkConsumeController): nur ein // paralleler Request darf den Link verbrauchen. $claimed = MagicLink::query() ->whereKey($magicLink->id) ->whereNull('consumed_at') ->update(['consumed_at' => now(), 'ip_consumed' => $request->ip()]); if ($claimed === 0) { return redirect() ->route('melden.pm', ['edition' => $edition]) ->with('pm-report-error', __('static.pm_report.flash_link_invalid')); } $pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0); $contactEmail = (string) ($magicLink->payload['contact_email'] ?? ''); $request->session()->put(self::SESSION_KEY, [ 'press_release_id' => $pressReleaseId, 'contact_email' => $contactEmail, 'method' => LegalRequestVerificationMethod::MagicLink->value, 'expires_at' => now()->addMinutes((int) config('legal.report_session_minutes'))->timestamp, ]); return redirect()->route('melden.pm.anliegen', ['edition' => $edition]); } public function showConcerns(Request $request, string $edition): View|RedirectResponse { $verified = $this->verifiedContext($request); if (! $verified) { return redirect() ->route('melden.pm', ['edition' => $edition]) ->with('pm-report-error', __('static.pm_report.flash_session_expired')); } return view('web.melden-pm-anliegen', [ 'pressRelease' => $verified['press_release'], 'contactEmail' => $this->maskEmail($verified['contact_email']), ]); } public function storeConcern(Request $request, string $edition): View|RedirectResponse { $verified = $this->verifiedContext($request); if (! $verified) { return redirect() ->route('melden.pm', ['edition' => $edition]) ->with('pm-report-error', __('static.pm_report.flash_session_expired')); } $validated = $request->validate([ 'concern' => ['required', 'string', 'in:'.implode(',', array_keys(self::CONCERNS))], 'message' => ['required', 'string', 'min:10', 'max:5000'], // Spur B (Phase 5): strukturierter Schwärzungsauftrag. 'redaction_terms' => ['required_if:concern,dsgvo', 'nullable', 'string', 'max:2000'], 'detach_contacts' => ['nullable', 'boolean'], 'remove_images' => ['nullable', 'boolean'], 'confirmed' => ['nullable', 'boolean'], ]); $pressRelease = $verified['press_release']; $type = self::CONCERNS[$validated['concern']]; // Bremse: 1 offene Anfrage pro PM/Typ — verhindert Duplikate in der Queue. $alreadyOpen = LegalRequest::query() ->where('press_release_id', $pressRelease->id) ->where('type', $type->value) ->open() ->exists(); if ($alreadyOpen) { throw ValidationException::withMessages([ 'concern' => __('static.pm_report.validation_already_open'), ]); } $payload = [ 'source' => 'self_service', 'concern' => $validated['concern'], ]; if ($validated['concern'] === 'dsgvo') { $terms = $this->redactionTermList((string) ($validated['redaction_terms'] ?? '')); $detachContacts = $request->boolean('detach_contacts'); $removeImages = $request->boolean('remove_images'); if ($terms === [] && ! $detachContacts && ! $removeImages) { throw ValidationException::withMessages([ 'redaction_terms' => __('static.pm_report.dsgvo_validation_empty'), ]); } $preview = app(AnonymizationService::class)->preview($pressRelease, $terms); if ($terms !== [] && $preview['total_hits'] === 0) { throw ValidationException::withMessages([ 'redaction_terms' => __('static.pm_report.dsgvo_validation_no_hits'), ]); } // Pflicht-Diff (Konzept §Spur B): erst die Vorschau bestätigen, // dann einreichen — ohne gesehenen Diff keine Anfrage. if (! $request->boolean('confirmed')) { return view('web.melden-pm-anliegen', [ 'pressRelease' => $pressRelease, 'contactEmail' => $this->maskEmail($verified['contact_email']), 'dsgvoPreview' => $preview, 'dsgvoInput' => [ 'redaction_terms' => (string) ($validated['redaction_terms'] ?? ''), 'detach_contacts' => $detachContacts, 'remove_images' => $removeImages, 'message' => $validated['message'], ], ]); } $payload['redaction'] = [ 'terms' => $terms, 'detach_contacts' => $detachContacts, 'remove_images_requested' => $removeImages, 'preview_total_hits' => $preview['total_hits'], ]; } $legalRequest = LegalRequest::query()->create([ 'press_release_id' => $pressRelease->id, 'portal' => $pressRelease->portal?->value, 'type' => $type->value, 'status' => LegalRequestStatus::Open->value, 'requester_email' => $verified['contact_email'], 'message' => $validated['message'], 'payload' => $payload, 'requester_ip' => $request->ip(), 'verified_at' => now(), 'verification_method' => $verified['method'], ]); Mail::to($legalRequest->requester_email)->queue( (new LegalRequestReceived($legalRequest))->afterCommit() ); Mail::to($this->operatorRecipient($request))->queue( (new LegalRequestSubmitted($legalRequest))->afterCommit() ); return redirect() ->route('melden.pm.anliegen', ['edition' => $edition]) ->with('pm-report-status', __('static.pm_report.flash_concern_received', [ 'reference' => $legalRequest->reference(), ])); } /** * Zeilen-Eingabe („ein Begriff pro Zeile") in eine bereinigte, * eindeutige Begriffsliste wandeln. * * @return list */ private function redactionTermList(string $raw): array { return collect(preg_split('/\r\n|\r|\n/', $raw) ?: []) ->map(fn (string $term): string => trim($term)) ->filter() ->unique() ->values() ->all(); } /** * Verifizierten Kontext aus der Session lesen — abgelaufene Marker und * verschwundene PMs beenden die Sitzung. * * @return array{press_release: PressRelease, contact_email: string, method: string}|null */ private function verifiedContext(Request $request): ?array { $verified = (array) $request->session()->get(self::SESSION_KEY, []); if (($verified['expires_at'] ?? 0) < now()->timestamp) { $request->session()->forget(self::SESSION_KEY); return null; } $pressRelease = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->find((int) ($verified['press_release_id'] ?? 0)); if (! $pressRelease) { $request->session()->forget(self::SESSION_KEY); return null; } return [ 'press_release' => $pressRelease, 'contact_email' => (string) ($verified['contact_email'] ?? ''), 'method' => (string) ($verified['method'] ?? LegalRequestVerificationMethod::MagicLink->value), ]; } /** * „kontakt@firma.de" → „k***@firma.de" — die Adresse selbst wird nie * angezeigt und nie aus dem Formular übernommen. */ private function maskEmail(string $email): string { [$local, $domain] = array_pad(explode('@', $email, 2), 2, ''); return Str::substr($local, 0, 1).'***@'.$domain; } private function operatorRecipient(Request $request): string { if (str_contains($request->getHost(), 'pressekonto')) { $brand = config('domains.domains.pressekonto.brand', []); } else { $brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? []; } return $brand['report_email'] ?? (string) config('legal.contact_email'); } private function ensureNotRateLimited(Request $request): void { if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { return; } $seconds = RateLimiter::availableIn($this->throttleKey($request)); throw ValidationException::withMessages([ 'reported_url' => __('static.report_form.throttle', [ 'minutes' => max(1, (int) ceil($seconds / 60)), ]), ]); } private function throttleKey(Request $request): string { return 'pm-report|'.$request->ip(); } /** * Eingeloggter Creator mit zugeordneter PM: überspringt die Magic-Link- * Verifikation (verification_method=logged_in) und landet direkt in der * Anliegen-Auswahl. */ public function startFromCustomer(Request $request, int $pressReleaseId): RedirectResponse { abort_unless($request->user() !== null, 403); $pressRelease = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->whereKey($pressReleaseId) ->accessibleBy($request->user()) ->first(); abort_unless($pressRelease !== null, 403); $request->session()->put(self::SESSION_KEY, [ 'press_release_id' => $pressRelease->id, 'contact_email' => (string) $request->user()->email, 'method' => LegalRequestVerificationMethod::LoggedIn->value, 'expires_at' => now()->addMinutes((int) config('legal.report_session_minutes'))->timestamp, ]); return redirect()->route('melden.pm.anliegen', ['edition' => 'de']); } }