T10: DSA-Meldeformulare mit URL-Feld, Vorgangsnummer und Eingangsbestaetigung
Zwei eigenstaendige Formulare unter /melden/rechtswidrig (DSA Art. 16:
URL, Name, E-Mail, Begruendung, Wahrheitsversicherung) und
/melden/kontaktdaten (DSGVO-Antrag) auf allen drei Domains. Gemeldete
URLs werden automatisch der passenden veroeffentlichten PM zugeordnet;
ohne Treffer bleibt die URL im payload. Jede Meldung erhaelt eine
Vorgangsnummer (M-{id}, analog K-{id} der Kuendigung), die meldende
Person unverzueglich eine Eingangsbestaetigung (DSA Art. 16 Abs. 4) und
das Melde-Postfach einen internen Hinweis — auch im bestehenden
Per-PM-Flow. Admin-Queue zeigt Vorgangsnummer, Name und gemeldete URL;
/melden verlinkt die neuen Formulare als ersten Weg.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
61a677aa0b
commit
2a9be7a9af
14 changed files with 673 additions and 12 deletions
|
|
@ -6,11 +6,14 @@ 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;
|
||||
|
|
@ -78,7 +81,7 @@ class LegalRequestController extends Controller
|
|||
|
||||
RateLimiter::hit($this->throttleKey($release), 3600);
|
||||
|
||||
LegalRequest::query()->create([
|
||||
$legalRequest = LegalRequest::query()->create([
|
||||
'press_release_id' => $release->getKey(),
|
||||
'portal' => $release->portal?->value,
|
||||
'type' => $type->value,
|
||||
|
|
@ -88,7 +91,19 @@ class LegalRequestController extends Controller
|
|||
'requester_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
return $this->done($release);
|
||||
// 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)->send(new LegalRequestReceived($legalRequest));
|
||||
}
|
||||
Mail::to($this->operatorRecipient())->send(new LegalRequestSubmitted($legalRequest));
|
||||
|
||||
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
|
||||
|
|
@ -98,6 +113,15 @@ class LegalRequestController extends Controller
|
|||
->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')
|
||||
|
|
|
|||
153
app/Http/Controllers/Web/ReportFormController.php
Normal file
153
app/Http/Controllers/Web/ReportFormController.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\LegalRequestStatus;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\LegalRequestReceived;
|
||||
use App\Mail\LegalRequestSubmitted;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Eigenständige Meldeformulare (T10, Meldeverfahren v0.1): DSA-Meldung
|
||||
* rechtswidriger Inhalte ("rechtswidrig") und DSGVO-Antrag auf Entfernung
|
||||
* eigener Kontaktdaten ("kontaktdaten") — mit URL-Feld statt Bindung an
|
||||
* eine konkrete Pressemitteilung. Speist dieselbe Admin-Queue wie die
|
||||
* Melde-Links an den Mitteilungen; die meldende Person erhält unverzüglich
|
||||
* eine Eingangsbestätigung mit Vorgangsnummer (DSA Art. 16 Abs. 4).
|
||||
*/
|
||||
class ReportFormController extends Controller
|
||||
{
|
||||
public const WAYS = ['rechtswidrig', 'kontaktdaten'];
|
||||
|
||||
public function show(string $edition, string $way): View
|
||||
{
|
||||
return view('web.melden-form', ['way' => $way]);
|
||||
}
|
||||
|
||||
public function store(Request $request, string $edition, string $way): RedirectResponse
|
||||
{
|
||||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||
// bewusst VOR der Validierung (sonst verrät die Regel die Falle).
|
||||
if (filled($request->input('website')) || filled($request->input('homepage'))) {
|
||||
return back()->with('report-status', __('Vielen Dank. Ihre Meldung ist eingegangen.'));
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reported_url' => ['required', 'url', 'max:500'],
|
||||
'requester_name' => ['required', 'string', 'max:120'],
|
||||
'requester_email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'min:20', 'max:5000'],
|
||||
// DSA Art. 16 Abs. 1 lit. d: Erklärung, dass die Meldung nach
|
||||
// bestem Wissen und Gewissen abgegeben wird (nur Weg 1).
|
||||
'truth_affirmed' => $way === 'rechtswidrig' ? ['accepted'] : ['nullable'],
|
||||
], [
|
||||
'truth_affirmed.accepted' => __('Bitte bestätigen Sie, dass Sie die Meldung nach bestem Wissen und Gewissen abgeben.'),
|
||||
]);
|
||||
|
||||
$this->ensureNotRateLimited($request);
|
||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||
|
||||
$release = $this->matchPublishedRelease($validated['reported_url']);
|
||||
|
||||
$legalRequest = LegalRequest::query()->create([
|
||||
'press_release_id' => $release?->getKey(),
|
||||
'portal' => $release?->portal?->value ?? $this->portalFromHost($request),
|
||||
'type' => $way === 'kontaktdaten' ? LegalRequestType::Dsgvo->value : LegalRequestType::Report->value,
|
||||
'status' => LegalRequestStatus::Open->value,
|
||||
'requester_email' => $validated['requester_email'],
|
||||
'message' => $validated['message'],
|
||||
'payload' => [
|
||||
'way' => $way,
|
||||
'reported_url' => $validated['reported_url'],
|
||||
'requester_name' => $validated['requester_name'],
|
||||
'truth_affirmed' => $request->boolean('truth_affirmed'),
|
||||
],
|
||||
'requester_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
// Unverzügliche Eingangsbestätigung (DSA Art. 16 Abs. 4) + interner
|
||||
// Hinweis ans Melde-Postfach, damit Fristen nicht an der Queue hängen.
|
||||
Mail::to($legalRequest->requester_email)->send(new LegalRequestReceived($legalRequest));
|
||||
Mail::to($this->operatorRecipient($request))->send(new LegalRequestSubmitted($legalRequest));
|
||||
|
||||
return back()->with('report-status', __('Vielen Dank. Ihre Meldung ist unter der Vorgangsnummer :reference eingegangen — Sie erhalten eine Eingangsbestätigung per E-Mail.', [
|
||||
'reference' => $legalRequest->reference(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordnet die gemeldete URL einer veröffentlichten Pressemitteilung zu,
|
||||
* wenn sie einer Detail-URL entspricht — sonst bleibt die Meldung ohne
|
||||
* PM-Bezug in der Queue (URL steht im payload).
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
private function portalFromHost(Request $request): ?string
|
||||
{
|
||||
$host = $request->getHost();
|
||||
|
||||
if (str_contains($host, 'presseecho')) {
|
||||
return 'presseecho';
|
||||
}
|
||||
|
||||
if (str_contains($host, 'businessportal')) {
|
||||
return 'businessportal24';
|
||||
}
|
||||
|
||||
return null; // Hub: Meldung ohne Portal-Zuordnung.
|
||||
}
|
||||
|
||||
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([
|
||||
'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'minutes' => max(1, (int) ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function throttleKey(Request $request): string
|
||||
{
|
||||
return 'report-form|'.$request->ip();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue