Launch-pflichtiger Compliance-Slice: öffentliche Anfrage zu einer PM speist eine
manuelle Admin-Queue (keine KI).
- Migration legal_requests + Model + Enums (Type: dsgvo/personal_rights/report,
Status: open/in_progress/resolved/rejected) + Factory.
- Öffentliches Formular /release/{slug}/rechtliches (LegalRequestController +
web/legal-request.blade.php): typ-abhängiger Hinweistext (Alpine), E-Mail bei
DSGVO/Persönlichkeitsrecht erforderlich, zwei versteckte Honeypot-Felder,
Rate-Limit + Bremse "1 offene Anfrage pro PM/Typ". Regeltexte als Entwurf mit
TODO für rechtliche Finalisierung markiert.
- Routen bewusst in eigener routes/legal.php (entkoppelt vom laufenden Web-Umbau),
host-agnostisch via domains.php eingebunden.
- Admin-Bereich "Recht & Compliance": Sidebar-Nav mit Offen-Zähler, Volt-Queue
index/show (in Bearbeitung/erledigt/abgelehnt/wieder öffnen + interne Notiz).
- Tests: je Typ, Honeypots (Dataset), Bremse, Admin-Queue + Status-Übergänge.
- Doku: Detailplan WS-3-Status + Deployment-Migrationsreihenfolge ergänzt.
Hinweis: Der "Melden"-/E&F-Button auf der PM-Detailseite (release-detail.blade.php)
wird mit dem separaten Web-Frontend-Commit verdrahtet; Ziel ist legal-request.create.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
5.1 KiB
PHP
143 lines
5.1 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Enums\LegalRequestStatus;
|
||
use App\Enums\LegalRequestType;
|
||
use App\Enums\Portal;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\LegalRequest;
|
||
use App\Models\PressRelease;
|
||
use App\Scopes\PortalScope;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
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;
|
||
|
||
return view('web.legal-request', [
|
||
'release' => $release,
|
||
'type' => $type,
|
||
'types' => LegalRequestType::cases(),
|
||
]);
|
||
}
|
||
|
||
public function store(Request $request, string $slug): RedirectResponse
|
||
{
|
||
$release = $this->resolvePublishedRelease($slug);
|
||
|
||
$validated = $request->validate([
|
||
'type' => ['required', Rule::enum(LegalRequestType::class)],
|
||
// 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'],
|
||
// Honeypots: müssen leer bleiben (versteckte Felder, nur für Bots).
|
||
'website' => ['nullable', 'size:0'],
|
||
'homepage' => ['nullable', 'size:0'],
|
||
]);
|
||
|
||
$type = LegalRequestType::from($validated['type']);
|
||
|
||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||
// nichts speichern (keine Fehlermeldung, die einem Bot etwas verrät).
|
||
if (filled($request->input('website')) || filled($request->input('homepage'))) {
|
||
return $this->done($release);
|
||
}
|
||
|
||
$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::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(),
|
||
]);
|
||
|
||
return $this->done($release);
|
||
}
|
||
|
||
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 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();
|
||
}
|
||
}
|