Die size:0-Regeln liefen vor dem Honeypot-Zweig: ein gefuelltes Feld produzierte einen Validierungsfehler (verraet die Falle), der stille done()-Pfad war tot. Honeypot-Check jetzt vor der Validierung; Test schaerft das Soll (kein Fehler, normaler Erfolgs-Redirect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
141 lines
5 KiB
PHP
141 lines
5 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);
|
||
|
||
// 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([
|
||
'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'],
|
||
]);
|
||
|
||
$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::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();
|
||
}
|
||
}
|