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>
339 lines
12 KiB
PHP
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']);
|
|
}
|
|
}
|