presseportale/app/Http/Controllers/Web/PressReleaseReportController.php
Kevin Adametz b4b2e013d6 recht: Meldeverfahren v2 Phasen 1-4 — Re-Sync-Sperre, Anonymisierung, Magic-Link-Intake, Self-Service-Korrektur
Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte
PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert,
verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich).

Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff,
transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung,
Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann
DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore.

Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten-
Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse
(neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl
erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg.

Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %,
Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur
kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als
Change-Anfrage (Interim bis 10-€-Checkout, Phase 7).

Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in
docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:56:34 +00:00

339 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
use App\Enums\PressReleaseStatus;
use App\Http\Controllers\Controller;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Mail\PressReleaseReportLink;
use App\Models\LegalRequest;
use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Auth\MagicLinkGenerator;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
/**
* Meldeverfahren v2, Phase 3 — Deflektions-Trichter „PM melden/ändern":
* URL einfügen → System schlägt den hinterlegten Pressekontakt nach →
* Magic-Link an diesen (nie an eine eingegebene Adresse) → verifizierte
* Anliegen-Auswahl (vier Spuren). Kanal tot → Verweis auf die manuelle
* Verifikation über das bestehende DSGVO-Formular (§6-Fallback).
*/
class PressReleaseReportController extends Controller
{
private const SESSION_KEY = 'pm_report.verified';
/** @var array<string, LegalRequestType> */
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->matchPublishedRelease($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): 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'],
]);
$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'),
]);
}
$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' => [
'source' => 'self_service',
'concern' => $validated['concern'],
],
'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(),
]));
}
/**
* 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),
];
}
private function matchPublishedRelease(string $url): ?PressRelease
{
if (! preg_match('#/press-release/([^/?\#]+)#', $url, $matches)) {
return null;
}
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->where('slug', urldecode($matches[1]))
->where('status', PressReleaseStatus::Published)
->first();
}
/**
* „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']);
}
}