presseportale/app/Http/Controllers/Web/PressReleaseReportController.php
Kevin Adametz 72432fdb82 web/api/ki: Soft Deletes wieder wirksam, Caches bei Firmenwechsel, KI-Ergebnis an Inhalt gebunden (Audit Block 3+4, Deploy D)
Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben

F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope
auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und
Medienpfade sowie die PressRelease-Helfer schreiben jetzt
withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht
kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit
Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter
und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst
verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein.
Der Vorschau-Test prüfte bisher forceDelete statt delete.

F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen
Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht
auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER.
Neuer Index contacts_email_idx.

F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei
Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die
Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt-
Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache
vorher nicht und traf den Fehler nie.

F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst
(ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung
gewinnt die Erstquelle.

F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss
aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren
Vertrag.

F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann
Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht
als verlorenes Logo.

Block 4 — KI-Prüfung und Scheduler

F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei
Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen
(ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler
veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum
aktuellen Text passt, sondern plant eine Prüfung mit Routing ein.

F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period).
Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der
Reset ist idempotent und lässt den laufenden Monat in Ruhe.

F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen
Fehler-Exit-Code, damit der Failure-Hook greift.

F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt.

F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:40:48 +00:00

395 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType;
use App\Enums\LegalRequestVerificationMethod;
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\Services\PressRelease\AnonymizationService;
use App\Services\Web\ReportedUrlResolver;
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';
public function __construct(private readonly ReportedUrlResolver $reportedUrls) {}
/** @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->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<string>
*/
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']);
}
}