- Magic-Link-Login übergibt bei aktiviertem 2FA an die 2FA-Challenge, statt sie zu umgehen (M1). - Bestätigungs-/Benachrichtigungs-Mails (Kontakt, Recht, Kündigung, Widerruf, Magic-Link) laufen als ShouldQueue+afterCommit über die Queue, damit die Response nicht am SMTP-Roundtrip hängt. Hinweis: Der Queue-Worker muss im Betrieb laufen und überwacht werden, da fristrelevante Bestätigungen darüber zugestellt werden (siehe Betriebsüberwachung). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
169 lines
6.2 KiB
PHP
169 lines
6.2 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\Mail\LegalRequestReceived;
|
||
use App\Mail\LegalRequestSubmitted;
|
||
use App\Models\LegalRequest;
|
||
use App\Models\PressRelease;
|
||
use App\Scopes\PortalScope;
|
||
use Illuminate\Http\RedirectResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Mail;
|
||
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 = 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(),
|
||
]);
|
||
|
||
// T10/DSA Art. 16 Abs. 4: unverzügliche Eingangsbestätigung mit
|
||
// Vorgangsnummer (sofern eine E-Mail-Adresse angegeben wurde) +
|
||
// interner Hinweis ans Melde-Postfach.
|
||
if ($legalRequest->requester_email) {
|
||
Mail::to($legalRequest->requester_email)->queue(
|
||
(new LegalRequestReceived($legalRequest))->afterCommit()
|
||
);
|
||
}
|
||
Mail::to($this->operatorRecipient())->queue(
|
||
(new LegalRequestSubmitted($legalRequest))->afterCommit()
|
||
);
|
||
|
||
return redirect()
|
||
->route('release.detail', ['slug' => $release->slug])
|
||
->with('legal-status', __('Vielen Dank. Ihre Anfrage ist unter der Vorgangsnummer :reference eingegangen und wird von unserem Team geprüft.', [
|
||
'reference' => $legalRequest->reference(),
|
||
]));
|
||
}
|
||
|
||
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 operatorRecipient(): string
|
||
{
|
||
$brand = str_contains(request()->getHost(), 'presseecho')
|
||
? config('domains.domains.presseecho.brand', [])
|
||
: config('domains.domains.businessportal24.brand', []);
|
||
|
||
return $brand['report_email'] ?? (string) config('legal.contact_email');
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|