presseportale/app/Http/Controllers/LegalRequestController.php
Kevin Adametz 9afbedddeb recht: Meldeverfahren v2 Phasen 5+7 — DSGVO-Strukturformular, 10-€-Checkout mit KI-Vorprüfung, Triage & Panel-Modal
Phase 7 (Spur D, E2 revidiert): KI-Vorprüfung VOR der Zahlung im Modal —
rot lehnt ohne Zahlung ab, gelb/grün führt zum Stripe-Checkout
(SinglePurchaseType::Change, 10 € netto), nach Zahlungseingang übernimmt
der Webhook die geprüfte Fassung sofort (applyPaid: Snapshot, Sperre,
Verlauf). Nur einmal je PM; Obergrenze legal.change_max_distance_percent
(50 %) verhindert die verdeckte Selbst-Depublizierung.

Phase 5 (Spur B): strukturiertes Schwärzungsformular mit Pflicht-Vorschau
im verifizierten Web-Bereich UND als Modal im Kunden-Panel; Admin-Editor
wird aus payload.redaction vorbefüllt. Umsetzungsmail LegalRequestExecuted
an Antragsteller, Bestätigungsmail PressReleaseChangeApplied (Spur A/D,
neuer abbestellbarer Typ press_release_corrected).

Außerdem:
- Re-Sync-Sperre + Sitemap-Invalidierung fest in depublish() verankert
  (Lücke im bezahlten Webhook-Pfad geschlossen)
- Öffentliche „Rechtliches"-Seite als zweistufige Triage: primär der
  Portal-Anmeldelink, Formular nur Fallback mit Identifizierungs-Hinweis;
  Store auf report/dsgvo/personal_rights gehärtet
- Panel-Button „Ändern/Melden" öffnet Anliegen-Modal (4 Spuren) statt
  des Web-Formulars
- Pressekontakt-Fallback für Legacy-Bestände (30k PMs ohne Verknüpfung):
  Detailseite + pressContactEmail() nutzen PM-Kontakt → Firmen-Kontakt →
  Firmendaten
- Korrektur-Gate entity-fest (Editor-Normalisierung zählt nicht als
  Änderung), toEditorHtml() in Admin-Edit + Korrektur-Panel,
  Wochenlimit-Hinweis in Text und Bremsen-Meldung

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:02:57 +00:00

190 lines
7.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
/**
* Öffentliche Rechts-/Compliance-Anfragen (WS-3): DSGVO-Anonymisierung (E),
* Persönlichkeitsrecht (F) und Meldungen speisen dieselbe Admin-Queue. Keine KI.
*/
class LegalRequestController extends Controller
{
public function create(Request $request, string $slug): View
{
$release = $this->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();
}
}