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:
Kevin Adametz 2026-07-06 09:35:30 +00:00
parent 61a677aa0b
commit 2a9be7a9af
14 changed files with 673 additions and 12 deletions

View file

@ -6,11 +6,14 @@ use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType; use App\Enums\LegalRequestType;
use App\Enums\Portal; use App\Enums\Portal;
use App\Enums\PressReleaseStatus; use App\Enums\PressReleaseStatus;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest; use App\Models\LegalRequest;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -78,7 +81,7 @@ class LegalRequestController extends Controller
RateLimiter::hit($this->throttleKey($release), 3600); RateLimiter::hit($this->throttleKey($release), 3600);
LegalRequest::query()->create([ $legalRequest = LegalRequest::query()->create([
'press_release_id' => $release->getKey(), 'press_release_id' => $release->getKey(),
'portal' => $release->portal?->value, 'portal' => $release->portal?->value,
'type' => $type->value, 'type' => $type->value,
@ -88,7 +91,19 @@ class LegalRequestController extends Controller
'requester_ip' => $request->ip(), '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 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.')); ->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 private function resolvePublishedRelease(string $slug): PressRelease
{ {
$portal = str_contains(request()->getHost(), 'presseecho') $portal = str_contains(request()->getHost(), 'presseecho')

View 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();
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Mail;
use App\Models\LegalRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Unverzügliche Eingangsbestätigung einer Rechts-/Compliance-Meldung an die
* meldende Person (T10, DSA Art. 16 Abs. 4) — mit Vorgangsnummer sowie Datum
* und Uhrzeit des Eingangs.
*/
class LegalRequestReceived extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly LegalRequest $legalRequest,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Eingangsbestätigung: Ihre Meldung '.$this->legalRequest->reference(),
);
}
public function content(): Content
{
return new Content(view: 'emails.legal.received');
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Mail;
use App\Models\LegalRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Interner Hinweis an das Melde-Postfach: neue Rechts-/Compliance-Anfrage in
* der Admin-Queue (DSA-Fristen — Meldungen sollen nicht erst beim nächsten
* Blick in den Admin auffallen).
*/
class LegalRequestSubmitted extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly LegalRequest $legalRequest,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Neue Meldung '.$this->legalRequest->reference().': '.$this->legalRequest->type->label(),
);
}
public function content(): Content
{
return new Content(view: 'emails.legal.submitted');
}
}

View file

@ -44,6 +44,32 @@ class LegalRequest extends Model
]; ];
} }
/**
* Öffentliche Vorgangsnummer (T10) — analog zur Kündigung ("K-{id}").
* Wird in der Eingangsbestätigung genannt und bei Rückfragen referenziert.
*/
public function reference(): string
{
return 'M-'.$this->id;
}
/**
* Name des Meldenden (Standalone-Formulare, DSA Art. 16) — strukturierte
* Zusatzfelder liegen bewusst im payload (WS-3).
*/
public function requesterName(): ?string
{
return $this->payload['requester_name'] ?? null;
}
/**
* Gemeldete URL, wie vom Meldenden angegeben (Standalone-Formulare).
*/
public function reportedUrl(): ?string
{
return $this->payload['reported_url'] ?? null;
}
public function pressRelease(): BelongsTo public function pressRelease(): BelongsTo
{ {
return $this->belongsTo(PressRelease::class); return $this->belongsTo(PressRelease::class);

View file

@ -0,0 +1,37 @@
<x-emails.layout title="Eingangsbestätigung Ihrer Meldung" preheader="Wir bestätigen den Eingang Ihrer Meldung und prüfen sie sorgfältig und zeitnah.">
<p>Guten Tag{{ $legalRequest->requesterName() ? ' '.$legalRequest->requesterName() : '' }},</p>
<p>
wir bestätigen den Eingang Ihrer Meldung. Sie ist bei uns am
<strong>{{ $legalRequest->created_at->format('d.m.Y') }}
um {{ $legalRequest->created_at->format('H:i') }} Uhr</strong> eingegangen.
</p>
<dl class="meta">
<dt>Vorgangsnummer</dt>
<dd>{{ $legalRequest->reference() }}</dd>
<dt>Art der Meldung</dt>
<dd>{{ $legalRequest->type->publicLabel() }}</dd>
@if ($legalRequest->reportedUrl())
<dt>Betroffene URL</dt>
<dd>{{ $legalRequest->reportedUrl() }}</dd>
@elseif ($legalRequest->pressRelease)
<dt>Betroffene Pressemitteilung</dt>
<dd>{{ $legalRequest->pressRelease->title }}</dd>
@endif
</dl>
<p>
Wir prüfen Ihre Meldung sorgfältig und zeitnah. Anschließend teilen wir Ihnen
unsere Entscheidung mit einer Begründung mit. Bitte geben Sie bei Rückfragen
die Vorgangsnummer an.
</p>
<div class="note">
<p>
Diese Bestätigung wurde automatisch erstellt und dokumentiert Datum und
Uhrzeit des Eingangs Ihrer Meldung. Die Meldung rechtswidriger Inhalte und
die Ausübung gesetzlicher Rechte sind kostenlos.
</p>
</div>
</x-emails.layout>

View file

@ -0,0 +1,41 @@
<x-emails.layout title="Neue Rechts-/Compliance-Meldung" preheader="Eine neue Meldung ist in der Admin-Queue eingegangen.">
<p>Eine neue Meldung ist eingegangen und wartet in der Queue „Recht &amp; Compliance“.</p>
<dl class="meta">
<dt>Vorgangsnummer</dt>
<dd>{{ $legalRequest->reference() }}</dd>
<dt>Art</dt>
<dd>{{ $legalRequest->type->label() }}</dd>
@if ($legalRequest->portal)
<dt>Portal</dt>
<dd>{{ $legalRequest->portal }}</dd>
@endif
@if ($legalRequest->reportedUrl())
<dt>Gemeldete URL</dt>
<dd>{{ $legalRequest->reportedUrl() }}</dd>
@endif
@if ($legalRequest->pressRelease)
<dt>Zugeordnete Pressemitteilung</dt>
<dd>{{ $legalRequest->pressRelease->title }}</dd>
@endif
@if ($legalRequest->requesterName())
<dt>Name</dt>
<dd>{{ $legalRequest->requesterName() }}</dd>
@endif
@if ($legalRequest->requester_email)
<dt>E-Mail</dt>
<dd>{{ $legalRequest->requester_email }}</dd>
@endif
</dl>
<p><strong>Anliegen:</strong></p>
<p>{{ $legalRequest->message }}</p>
<div class="note">
<p>
DSA-Meldungen bitte zeitnah bearbeiten — die meldende Person hat eine
Eingangsbestätigung mit dieser Vorgangsnummer erhalten und erwartet eine
begründete Entscheidung.
</p>
</div>
</x-emails.layout>

View file

@ -207,6 +207,9 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]"> <span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $request->type->label() }} {{ $request->type->label() }}
</span> </span>
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">
{{ $request->reference() }}
</span>
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>
<span class="badge dot {{ $this->statusBadgeClass($request->status) }}"> <span class="badge dot {{ $this->statusBadgeClass($request->status) }}">
@ -215,7 +218,7 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]"> <span class="text-[12.5px] text-[color:var(--color-ink)]">
{{ \Illuminate\Support\Str::limit($request->pressRelease?->title ?? __('PM gelöscht'), 60) }} {{ \Illuminate\Support\Str::limit($request->pressRelease?->title ?? $request->reportedUrl() ?? __('PM gelöscht'), 60) }}
</span> </span>
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>

View file

@ -120,6 +120,16 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
</div> </div>
<dl class="grid gap-3 sm:grid-cols-2 text-[12.5px]"> <dl class="grid gap-3 sm:grid-cols-2 text-[12.5px]">
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Vorgangsnummer') }}</dt>
<dd class="text-[color:var(--color-ink)] font-mono">{{ $request->reference() }}</dd>
</div>
@if ($request->requesterName())
<div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Name') }}</dt>
<dd class="text-[color:var(--color-ink)]">{{ $request->requesterName() }}</dd>
</div>
@endif
<div> <div>
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Absender') }}</dt> <dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Absender') }}</dt>
<dd class="text-[color:var(--color-ink)] break-all"> <dd class="text-[color:var(--color-ink)] break-all">
@ -144,6 +154,10 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
<a href="{{ route('admin.press-releases.show', $request->pressRelease->id) }}" wire:navigate <a href="{{ route('admin.press-releases.show', $request->pressRelease->id) }}" wire:navigate
class="ml-2 text-[12px] text-[color:var(--color-hub)] underline underline-offset-2">{{ __('öffnen') }}</a> class="ml-2 text-[12px] text-[color:var(--color-hub)] underline underline-offset-2">{{ __('öffnen') }}</a>
@endif @endif
@elseif ($request->reportedUrl())
{{ __('Keine PM zugeordnet — gemeldete URL:') }}
<a href="{{ $request->reportedUrl() }}" target="_blank" rel="noopener"
class="text-[color:var(--color-hub)] underline underline-offset-2 break-all">{{ $request->reportedUrl() }}</a>
@else @else
{{ __('PM wurde gelöscht') }} {{ __('PM wurde gelöscht') }}
@endif @endif

View file

@ -0,0 +1,132 @@
@extends('web.layouts.web-master')
@php
// T10: Eigenständige Meldeformulare (Meldeverfahren v0.1). Weg 1 =
// DSA-Meldung rechtswidriger Inhalte, Weg 2 = DSGVO-Antrag auf
// Entfernung eigener Kontaktdaten. Beide speisen die Admin-Queue
// "Recht & Compliance" und bestätigen den Eingang per E-Mail.
$isIllegalContent = $way === 'rechtswidrig';
$pageTitle = $isIllegalContent
? 'Rechtswidrige Inhalte melden'
: 'Eigene Kontaktdaten entfernen lassen';
@endphp
@section('title', $pageTitle)
@section('meta_description', $isIllegalContent
? 'Rechtswidrige Inhalte oder Rechteverletzungen in einer Pressemitteilung melden (DSA) — kostenlos, mit Eingangsbestätigung.'
: 'Entfernung eigener personenbezogener Kontaktdaten aus einer Pressemitteilung beantragen (DSGVO) — kostenlos.')
@section('content')
<x-web.static-page
eyebrow="Rechtliches"
:title="$pageTitle"
breadcrumb="Inhalte melden"
>
<div class="max-w-[640px]">
<p class="text-[13.5px] text-ink-2 leading-relaxed mb-2">
@if ($isIllegalContent)
Nutzen Sie dieses Formular, wenn Sie eine veröffentlichte Pressemitteilung für
rechtswidrig halten oder Ihre Rechte verletzt sehen. Die Meldung ist kostenlos;
Sie erhalten unverzüglich eine Eingangsbestätigung mit Vorgangsnummer und
anschließend unsere begründete Entscheidung.
@else
Nutzen Sie dieses Formular, wenn Ihre persönlichen Kontaktdaten (z.&nbsp;B. Name,
Telefonnummer oder E-Mail-Adresse) in einer Pressemitteilung stehen. Die Prüfung
erfolgt nach den Vorgaben der DSGVO und ist für Sie kostenlos.
@endif
</p>
<p class="text-[12.5px] text-ink-3 leading-relaxed mb-6">
Alle Wege im Überblick: <a href="{{ route('melden') }}" class="underline underline-offset-2 hover:text-ink">Inhalte melden oder Änderungen beantragen</a>
</p>
@if (session('report-status'))
<div class="mb-6 px-4 py-3 border border-bg-rule bg-bg-elev text-[13px] text-ink-2" role="status">
{{ session('report-status') }}
</div>
@endif
<form method="POST" action="{{ route('melden.form.store', ['way' => $way]) }}" class="space-y-5">
@csrf
<div>
<label for="reported_url" class="block text-[12px] font-semibold text-ink mb-1.5">URL der betroffenen Pressemitteilung</label>
<input id="reported_url" type="url" name="reported_url" value="{{ old('reported_url') }}" required
placeholder="https://…"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand" />
@error('reported_url')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="requester_name" class="block text-[12px] font-semibold text-ink mb-1.5">Ihr Name</label>
<input id="requester_name" type="text" name="requester_name" value="{{ old('requester_name') }}" required
autocomplete="name"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand" />
@error('requester_name')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="requester_email" class="block text-[12px] font-semibold text-ink mb-1.5">
{{ $isIllegalContent ? 'Ihre E-Mail-Adresse' : 'Ihre E-Mail-Adresse (für Rückmeldung und ggf. Bestätigungslink)' }}
</label>
<input id="requester_email" type="email" name="requester_email" value="{{ old('requester_email') }}" required
autocomplete="email"
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand" />
@error('requester_email')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label for="message" class="block text-[12px] font-semibold text-ink mb-1.5">
{{ $isIllegalContent ? 'Begründung: Warum halten Sie den Inhalt für rechtswidrig?' : 'Welche Angaben sind betroffen?' }}
</label>
<textarea id="message" name="message" rows="6" required
class="w-full border border-bg-rule px-3 py-2.5 text-[13px] bg-white text-ink focus:outline-none focus:border-brand"
placeholder="{{ $isIllegalContent ? 'Bitte beschreiben Sie konkret, welche Inhalte betroffen sind und warum.' : 'z. B. „Mein Name und meine Telefonnummer stehen im Pressekontakt.“' }}">{{ old('message') }}</textarea>
@error('message')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
@if ($isIllegalContent)
<div>
<label class="flex items-start gap-2.5 text-[12.5px] text-ink-2 leading-relaxed cursor-pointer">
<input type="checkbox" name="truth_affirmed" value="1" @checked(old('truth_affirmed'))
class="mt-0.5 flex-shrink-0" />
<span>
Ich versichere, dass ich diese Meldung nach bestem Wissen und Gewissen
abgebe und die Angaben richtig und vollständig sind.
</span>
</label>
@error('truth_affirmed')
<p class="mt-1 text-[12px] text-red-600">{{ $message }}</p>
@enderror
</div>
@endif
{{-- Bot-Schutz: Honeypot-Felder (siehe legal-request.blade.php). --}}
<div class="hidden" aria-hidden="true">
<label for="website">Website</label>
<input id="website" type="text" name="website" tabindex="-1" autocomplete="off" value="{{ old('website') }}" />
<label for="homepage">Homepage</label>
<input id="homepage" type="text" name="homepage" tabindex="-1" autocomplete="off" value="{{ old('homepage') }}" />
</div>
<button type="submit"
class="bg-brand hover:bg-brand-deep text-white px-5 py-2.5 text-[13px] font-semibold transition-colors">
{{ $isIllegalContent ? 'Meldung absenden' : 'Antrag absenden' }}
</button>
</form>
<p class="mt-6 text-[11.5px] text-ink-3 leading-relaxed">
Ihre Angaben werden ausschließlich zur Bearbeitung Ihrer Meldung verarbeitet —
Details in der <a href="{{ route('datenschutz') }}" class="underline hover:text-ink">Datenschutzerklärung</a>.
Meldungen und die Ausübung gesetzlicher Rechte sind kostenlos.
</p>
</div>
</x-web.static-page>
@endsection

View file

@ -50,11 +50,13 @@
<li>sonstigen gesetzlichen Verstößen.</li> <li>sonstigen gesetzlichen Verstößen.</li>
</ul> </ul>
<p> <p>
<strong>Meldung:</strong> Öffnen Sie die betroffene Pressemitteilung und nutzen Sie dort <strong>Meldung:</strong> Nutzen Sie unser
den Link <em>„Pressemitteilung melden"</em> — die Mitteilung wird dabei automatisch dem <a href="{{ route('melden.form', ['way' => 'rechtswidrig']) }}">Meldeformular für
Vorgang zugeordnet. Alternativ senden Sie Ihre Meldung mit der URL der betroffenen rechtswidrige Inhalte</a> — Sie erhalten unverzüglich eine Eingangsbestätigung mit
Pressemitteilung an Vorgangsnummer. Alternativ öffnen Sie die betroffene Pressemitteilung und nutzen dort
<a href="mailto:{{ $reportEmail }}">{{ $reportEmail }}</a>. den Link <em>„Inhalt melden"</em> (die Mitteilung wird dabei automatisch dem Vorgang
zugeordnet), oder Sie senden Ihre Meldung mit der URL der betroffenen Pressemitteilung
an <a href="mailto:{{ $reportEmail }}">{{ $reportEmail }}</a>.
</p> </p>
<p>Damit wir Ihre Meldung prüfen können, benötigen wir:</p> <p>Damit wir Ihre Meldung prüfen können, benötigen wir:</p>
<ul> <ul>
@ -81,9 +83,11 @@
E-Mail-Adresse. E-Mail-Adresse.
</p> </p>
<p> <p>
<strong>Antrag:</strong> Öffnen Sie die betroffene Pressemitteilung und nutzen Sie dort <strong>Antrag:</strong> Nutzen Sie unser
den Link <em>„Datenschutz &amp; Persönlichkeitsrecht"</em> — oder senden Sie Ihren <a href="{{ route('melden.form', ['way' => 'kontaktdaten']) }}">Antragsformular zur
Antrag an <a href="mailto:{{ $reportEmail }}">{{ $reportEmail }}</a>. Entfernung eigener Kontaktdaten</a>. Alternativ öffnen Sie die betroffene
Pressemitteilung und nutzen dort den Link <em>„Eigene Daten entfernen"</em> — oder Sie
senden Ihren Antrag an <a href="mailto:{{ $reportEmail }}">{{ $reportEmail }}</a>.
</p> </p>
<p> <p>
Wir prüfen Ihren Antrag nach den Vorgaben der Datenschutz-Grundverordnung. Die Prüfung und Wir prüfen Ihren Antrag nach den Vorgaben der Datenschutz-Grundverordnung. Die Prüfung und

View file

@ -9,6 +9,7 @@ use App\Http\Controllers\Web\NewsroomDetailController;
use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\NewsroomsController;
use App\Http\Controllers\Web\PreiseController; use App\Http\Controllers\Web\PreiseController;
use App\Http\Controllers\Web\ReleaseDetailController; use App\Http\Controllers\Web\ReleaseDetailController;
use App\Http\Controllers\Web\ReportFormController;
use App\Http\Controllers\Web\SitemapController; use App\Http\Controllers\Web\SitemapController;
use App\Http\Controllers\Web\SucheController; use App\Http\Controllers\Web\SucheController;
use App\Http\Controllers\Web\TerminationController; use App\Http\Controllers\Web\TerminationController;
@ -232,6 +233,16 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
// drei Domains ausgeliefert. // drei Domains ausgeliefert.
Route::view('richtlinien', 'web.richtlinien')->name('richtlinien'); Route::view('richtlinien', 'web.richtlinien')->name('richtlinien');
Route::view('melden', 'web.melden')->name('melden'); Route::view('melden', 'web.melden')->name('melden');
// T10: Eigenständige Meldeformulare mit URL-Feld (Meldeverfahren Weg 1/2)
// — Vorgangsnummer + Eingangsbestätigung per E-Mail (DSA Art. 16).
Route::get('melden/{way}', [ReportFormController::class, 'show'])
->whereIn('way', ReportFormController::WAYS)
->name('melden.form');
Route::post('melden/{way}', [ReportFormController::class, 'store'])
->middleware('throttle:10,1')
->whereIn('way', ReportFormController::WAYS)
->name('melden.form.store');
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -3,10 +3,13 @@
use App\Enums\LegalRequestStatus; use App\Enums\LegalRequestStatus;
use App\Enums\LegalRequestType; use App\Enums\LegalRequestType;
use App\Enums\Portal; use App\Enums\Portal;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest; use App\Models\LegalRequest;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\User; use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder; use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt; use Livewire\Volt\Volt;
use Tests\TestCase; use Tests\TestCase;
@ -51,13 +54,15 @@ test('the form 404s for a non-published release', function () {
test('a dsgvo request is stored in the queue', function () { test('a dsgvo request is stored in the queue', function () {
/** @var TestCase $this */ /** @var TestCase $this */
Mail::fake();
$release = publishedRelease(); $release = publishedRelease();
$this->post(route('legal-request.store', ['slug' => $release->slug]), [ $this->post(route('legal-request.store', ['slug' => $release->slug]), [
'type' => LegalRequestType::Dsgvo->value, 'type' => LegalRequestType::Dsgvo->value,
'requester_email' => 'betroffen@example.test', 'requester_email' => 'betroffen@example.test',
'message' => 'Bitte meine personenbezogenen Daten anonymisieren.', 'message' => 'Bitte meine personenbezogenen Daten anonymisieren.',
])->assertRedirect(route('release.detail', ['slug' => $release->slug])); ])->assertRedirect(route('release.detail', ['slug' => $release->slug]))
->assertSessionHas('legal-status');
$request = LegalRequest::query()->firstOrFail(); $request = LegalRequest::query()->firstOrFail();
@ -66,6 +71,11 @@ test('a dsgvo request is stored in the queue', function () {
expect($request->press_release_id)->toBe($release->id); expect($request->press_release_id)->toBe($release->id);
expect($request->requester_email)->toBe('betroffen@example.test'); expect($request->requester_email)->toBe('betroffen@example.test');
expect($request->portal)->toBe(Portal::Businessportal24->value); expect($request->portal)->toBe(Portal::Businessportal24->value);
// T10: Vorgangsnummer im Hinweis + Eingangsbestätigung/Queue-Hinweis.
expect(session('legal-status'))->toContain($request->reference());
Mail::assertSent(LegalRequestReceived::class, fn ($mail) => $mail->hasTo('betroffen@example.test'));
Mail::assertSent(LegalRequestSubmitted::class);
}); });
test('a personal rights request requires an email', function () { test('a personal rights request requires an email', function () {
@ -82,6 +92,7 @@ test('a personal rights request requires an email', function () {
test('a report can be submitted without an email and feeds the same queue', function () { test('a report can be submitted without an email and feeds the same queue', function () {
/** @var TestCase $this */ /** @var TestCase $this */
Mail::fake();
$release = publishedRelease(); $release = publishedRelease();
$this->post(route('legal-request.store', ['slug' => $release->slug]), [ $this->post(route('legal-request.store', ['slug' => $release->slug]), [
@ -93,6 +104,10 @@ test('a report can be submitted without an email and feeds the same queue', func
expect($request->type)->toBe(LegalRequestType::Report); expect($request->type)->toBe(LegalRequestType::Report);
expect($request->requester_email)->toBeNull(); expect($request->requester_email)->toBeNull();
// Ohne E-Mail keine Eingangsbestätigung — der interne Hinweis geht trotzdem raus.
Mail::assertNotSent(LegalRequestReceived::class);
Mail::assertSent(LegalRequestSubmitted::class);
}); });
test('a filled honeypot field blocks bots without creating a request', function (string $field) { test('a filled honeypot field blocks bots without creating a request', function (string $field) {

View file

@ -0,0 +1,129 @@
<?php
use App\Enums\LegalRequestType;
use App\Enums\Portal;
use App\Mail\LegalRequestReceived;
use App\Mail\LegalRequestSubmitted;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
test('the standalone report forms render on every domain', function (string $way, string $heading) {
/** @var TestCase $this */
$this->get("https://presseecho.test/de/melden/{$way}")
->assertSuccessful()
->assertSeeText($heading)
->assertSee('name="reported_url"', false);
$this->get("https://pressekonto.test/de/melden/{$way}")->assertSuccessful();
})->with([
'rechtswidrig' => ['rechtswidrig', 'Rechtswidrige Inhalte melden'],
'kontaktdaten' => ['kontaktdaten', 'Eigene Kontaktdaten entfernen lassen'],
]);
test('an illegal content report creates a request and confirms receipt with a reference', function () {
/** @var TestCase $this */
Mail::fake();
$response = $this->from('https://businessportal24.test/de/melden/rechtswidrig')
->post('https://businessportal24.test/de/melden/rechtswidrig', [
'reported_url' => 'https://businessportal24.com/de/press-release/irgendein-artikel',
'requester_name' => 'Erika Musterfrau',
'requester_email' => 'erika@example.com',
'message' => 'Diese Mitteilung enthält nachweislich falsche Tatsachenbehauptungen über mich.',
'truth_affirmed' => '1',
]);
$response->assertRedirect('https://businessportal24.test/de/melden/rechtswidrig')
->assertSessionHas('report-status');
$request = LegalRequest::query()->sole();
expect($request->type)->toBe(LegalRequestType::Report);
expect($request->requester_email)->toBe('erika@example.com');
expect($request->requesterName())->toBe('Erika Musterfrau');
expect($request->reportedUrl())->toBe('https://businessportal24.com/de/press-release/irgendein-artikel');
expect($request->payload['truth_affirmed'])->toBeTrue();
expect($request->portal)->toBe('businessportal24');
expect(session('report-status'))->toContain($request->reference());
// DSA Art. 16 Abs. 4: unverzügliche Eingangsbestätigung + interner Hinweis.
Mail::assertSent(LegalRequestReceived::class, fn ($mail) => $mail->hasTo('erika@example.com'));
Mail::assertSent(LegalRequestSubmitted::class);
});
test('a reported url matching a published release is linked to it', function () {
/** @var TestCase $this */
Mail::fake();
$release = PressRelease::factory()
->published()
->forPortal(Portal::Businessportal24)
->create(['published_at' => now()->subDay()]);
$this->post('https://businessportal24.test/de/melden/rechtswidrig', [
'reported_url' => 'https://businessportal24.com/de/press-release/'.$release->slug,
'requester_name' => 'Erika Musterfrau',
'requester_email' => 'erika@example.com',
'message' => 'Diese Mitteilung verletzt meine Markenrechte an mehreren Stellen.',
'truth_affirmed' => '1',
]);
expect(LegalRequest::query()->sole()->press_release_id)->toBe($release->id);
});
test('the illegal content report requires the truthfulness affirmation and a valid url', function () {
/** @var TestCase $this */
Mail::fake();
$this->from('https://businessportal24.test/de/melden/rechtswidrig')
->post('https://businessportal24.test/de/melden/rechtswidrig', [
'reported_url' => 'keine-url',
'requester_name' => 'Erika Musterfrau',
'requester_email' => 'erika@example.com',
'message' => 'Diese Mitteilung enthält falsche Tatsachenbehauptungen über mich.',
])
->assertSessionHasErrors(['reported_url', 'truth_affirmed']);
expect(LegalRequest::query()->count())->toBe(0);
Mail::assertNothingSent();
});
test('the contact data removal form creates a dsgvo request without affirmation checkbox', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://presseecho.test/de/melden/kontaktdaten', [
'reported_url' => 'https://presseecho.de/de/press-release/alter-artikel',
'requester_name' => 'Max Mustermann',
'requester_email' => 'max@example.com',
'message' => 'Mein Name und meine Telefonnummer stehen im Pressekontakt dieser Mitteilung.',
])->assertSessionHas('report-status');
$request = LegalRequest::query()->sole();
expect($request->type)->toBe(LegalRequestType::Dsgvo);
expect($request->portal)->toBe('presseecho');
Mail::assertSent(LegalRequestReceived::class);
});
test('a filled honeypot is silently discarded', function () {
/** @var TestCase $this */
Mail::fake();
$this->post('https://businessportal24.test/de/melden/rechtswidrig', [
'reported_url' => 'https://businessportal24.com/de/press-release/spam',
'requester_name' => 'Bot',
'requester_email' => 'bot@example.com',
'message' => 'Automatisch generierter Inhalt mit ausreichend vielen Zeichen.',
'truth_affirmed' => '1',
'website' => 'https://spam.example.com',
])->assertSessionHas('report-status');
expect(LegalRequest::query()->count())->toBe(0);
Mail::assertNothingSent();
});
test('an unknown form way responds 404', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/de/melden/sonstiges')->assertNotFound();
});