feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier, <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert - Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden); Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen - Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach - Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true) - contact-messages:quarantine-spam für den Altbestand - CSP erlaubt challenges.cloudflare.com, Log-Kanal spam - Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
61f449f483
commit
06d2889a4d
40 changed files with 1992 additions and 40 deletions
145
app/Console/Commands/PruneBotRegistrations.php
Normal file
145
app/Console/Commands/PruneBotRegistrations.php
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Spam\GibberishDetector;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Löscht Bot-Registrierungen (Spamschutz B4): 389 Konten in 30 Tagen bis
|
||||
* 07.10.2026, jedes mit einer Bestätigungsmail an eine fremde Adresse.
|
||||
*
|
||||
* Gelöscht wird nur, was über das Registrierungsformular entstand und nie
|
||||
* genutzt wurde — alle Bedingungen zugleich:
|
||||
* - keine Legacy-ID (66.870 unbestätigte Altkonten bleiben unberührt),
|
||||
* - E-Mail nie bestätigt, Konto inaktiv, nie angemeldet, kein Google-Login,
|
||||
* - älter als die Frist (Standard 14 Tage),
|
||||
* - keine Firma, kein Kontakt, keine Meldung, keine Zahlung, kein Token.
|
||||
*/
|
||||
class PruneBotRegistrations extends Command
|
||||
{
|
||||
protected $signature = 'users:prune-bot-registrations
|
||||
{--days= : Frist in Tagen (Standard: config auth.prune_bot_registrations.after_days)}
|
||||
{--dry-run : Nur auflisten, nichts löschen}
|
||||
{--limit=2000 : Höchstzahl je Lauf}';
|
||||
|
||||
protected $description = 'Löscht unbestätigte, nie genutzte Konten aus dem Registrierungsformular (Bot-Registrierungen).';
|
||||
|
||||
/** Tabellen, deren Einträge ein Konto als genutzt ausweisen. */
|
||||
private const USAGE_TABLES = [
|
||||
'company_user',
|
||||
'contact_user',
|
||||
'press_releases',
|
||||
'invoices',
|
||||
'legacy_invoices',
|
||||
'subscriptions',
|
||||
'single_purchases',
|
||||
'credit_transactions',
|
||||
'credit_topups',
|
||||
'booking_confirmations',
|
||||
'boosts',
|
||||
'billing_addresses',
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) ($this->option('days') ?? config('auth.prune_bot_registrations.after_days', 14));
|
||||
|
||||
if ($days < 7) {
|
||||
$this->error('Die Frist muss mindestens 7 Tage betragen.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$candidates = $this->candidates($days)->limit((int) $this->option('limit'))->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info('Keine Bot-Registrierungen zu löschen.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->table(
|
||||
['ID', 'Angelegt', 'Name', 'E-Mail', 'Zufallsname'],
|
||||
$candidates->map(fn (User $user): array => [
|
||||
$user->id,
|
||||
$user->created_at?->format('d.m.Y H:i'),
|
||||
Str::limit((string) $user->name, 30),
|
||||
$this->maskEmail((string) $user->email),
|
||||
GibberishDetector::isRandomPersonName((string) $user->name) ? 'ja' : '',
|
||||
])->all(),
|
||||
);
|
||||
$this->info(sprintf(
|
||||
'Probelauf: %d Konten älter als %d Tage würden gelöscht, davon %d mit Zufallsnamen.',
|
||||
$candidates->count(),
|
||||
$days,
|
||||
$candidates->filter(fn (User $user): bool => GibberishDetector::isRandomPersonName((string) $user->name))->count(),
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$deleted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($candidates as $user) {
|
||||
try {
|
||||
DB::transaction(fn () => $user->forceDelete());
|
||||
$deleted++;
|
||||
} catch (\Throwable $exception) {
|
||||
$failed++;
|
||||
Log::channel('spam')->warning('Bot-Registrierung konnte nicht gelöscht werden.', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::channel('spam')->info('Bot-Registrierungen gelöscht.', ['deleted' => $deleted, 'failed' => $failed, 'days' => $days]);
|
||||
$this->info("Bot-Registrierungen gelöscht: {$deleted}".($failed > 0 ? ", {$failed} fehlgeschlagen (siehe spam-Log)" : '').'.');
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<User>
|
||||
*/
|
||||
private function candidates(int $days): Builder
|
||||
{
|
||||
$query = User::query()
|
||||
->whereNull('legacy_id')
|
||||
->whereNull('email_verified_at')
|
||||
->whereNull('last_login_at')
|
||||
->whereNull('oauth_provider')
|
||||
->where('is_active', false)
|
||||
->where('is_super_admin', false)
|
||||
->where('created_at', '<', now()->subDays($days))
|
||||
->whereDoesntHave('roles')
|
||||
->whereNotExists(fn (QueryBuilder $query) => $query
|
||||
->from('personal_access_tokens')
|
||||
->whereColumn('personal_access_tokens.tokenable_id', 'users.id')
|
||||
->where('personal_access_tokens.tokenable_type', (new User)->getMorphClass()));
|
||||
|
||||
foreach (self::USAGE_TABLES as $table) {
|
||||
$query->whereNotExists(fn (QueryBuilder $sub) => $sub
|
||||
->from($table)
|
||||
->whereColumn($table.'.user_id', 'users.id'));
|
||||
}
|
||||
|
||||
return $query->orderBy('id');
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
|
||||
|
||||
return Str::substr($local, 0, 2).'***@'.$domain;
|
||||
}
|
||||
}
|
||||
60
app/Console/Commands/QuarantineContactSpam.php
Normal file
60
app/Console/Commands/QuarantineContactSpam.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ContactMessage;
|
||||
use App\Services\Spam\ContactSpamFilter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Einmalig für den Altbestand (Spamschutz, Entscheidung 2): offene
|
||||
* Kontakt-Anliegen von Gästen nach denselben Regeln wie beim Eingang in die
|
||||
* Spam-Quarantäne legen. Was die Regeln nicht erkennen, markiert der Admin
|
||||
* über die Sammelaktion der Kontakt-Queue.
|
||||
*/
|
||||
class QuarantineContactSpam extends Command
|
||||
{
|
||||
protected $signature = 'contact-messages:quarantine-spam {--dry-run : Nur auflisten, nichts ändern}';
|
||||
|
||||
protected $description = 'Legt offene Kontakt-Anliegen von Gästen, die der Spamfilter erkennt, in die Quarantäne.';
|
||||
|
||||
public function handle(ContactSpamFilter $filter): int
|
||||
{
|
||||
$hits = ContactMessage::query()
|
||||
->open()
|
||||
->whereNull('user_id')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (ContactMessage $message): array => [
|
||||
'message' => $message,
|
||||
// Ohne Zeitstempel: Der Altbestand hatte kein Formular-Feld dafür.
|
||||
'reason' => $filter->reasonFor((string) $message->name, (string) $message->message, null),
|
||||
])
|
||||
->filter(fn (array $hit): bool => $hit['reason'] !== null);
|
||||
|
||||
$this->table(
|
||||
['Vorgang', 'Eingang', 'Grund', 'Nachricht'],
|
||||
$hits->map(fn (array $hit): array => [
|
||||
$hit['message']->reference(),
|
||||
$hit['message']->created_at?->format('d.m.Y'),
|
||||
$hit['reason'],
|
||||
Str::limit(Str::squish((string) $hit['message']->message), 60),
|
||||
])->all(),
|
||||
);
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info("Probelauf: {$hits->count()} Anliegen würden als Spam markiert.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($hits as $hit) {
|
||||
$hit['message']->markAsSpam(null, $hit['reason']);
|
||||
}
|
||||
|
||||
$this->info("{$hits->count()} Anliegen als Spam markiert. Offen bleiben: ".ContactMessage::query()->open()->count().'.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,9 @@ enum ActivityType: string
|
|||
|
||||
case TwoFactorFailed = 'two_factor_failed';
|
||||
|
||||
/** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */
|
||||
case BotCheckUnavailable = 'bot_check_unavailable';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -73,6 +76,7 @@ enum ActivityType: string
|
|||
self::LoginFailed => 'Anmeldung fehlgeschlagen',
|
||||
self::LoginLockout => 'Anmeldesperre',
|
||||
self::TwoFactorFailed => '2FA fehlgeschlagen',
|
||||
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +106,7 @@ enum ActivityType: string
|
|||
self::ManualInvoice => 'amber',
|
||||
self::LoginFailed, self::TwoFactorFailed => 'orange',
|
||||
self::LoginLockout => 'red',
|
||||
self::BotCheckUnavailable => 'orange',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
app/Enums/BotCheckStatus.php
Normal file
27
app/Enums/BotCheckStatus.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Ergebnis der Bot-Prüfung (Cloudflare Turnstile) eines öffentlichen
|
||||
* Formulars, siehe TurnstileVerifier.
|
||||
*/
|
||||
enum BotCheckStatus: string
|
||||
{
|
||||
/** Cloudflare hat den Menschen bestätigt. */
|
||||
case Passed = 'passed';
|
||||
|
||||
/** Token fehlt, ist ungültig, abgelaufen oder gehört zu einem anderen Formular. */
|
||||
case Failed = 'failed';
|
||||
|
||||
/** Cloudflare war nicht erreichbar — durchgelassen und zur Nachprüfung markiert. */
|
||||
case Unavailable = 'unavailable';
|
||||
|
||||
/** Prüfung abgeschaltet (keine Keys, z. B. in Tests). */
|
||||
case Skipped = 'skipped';
|
||||
|
||||
public function blocks(): bool
|
||||
{
|
||||
return $this === self::Failed;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ enum ContactMessageStatus: string
|
|||
case Resolved = 'resolved';
|
||||
case Rejected = 'rejected';
|
||||
|
||||
/** Quarantäne des Spamschutzes: keine Mail, nicht im Zähler, nach 30 Tagen gelöscht. */
|
||||
case Spam = 'spam';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
|
|
@ -16,6 +19,7 @@ enum ContactMessageStatus: string
|
|||
self::InProgress => 'In Bearbeitung',
|
||||
self::Resolved => 'Erledigt',
|
||||
self::Rejected => 'Abgelehnt',
|
||||
self::Spam => 'Spam',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,21 @@
|
|||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\BotCheckStatus;
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Enums\ContactMessageTopic;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ContactRequestSubmitted;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Spam\ContactSpamFilter;
|
||||
use App\Services\Spam\SpamLog;
|
||||
use App\Services\Spam\TurnstileVerifier;
|
||||
use App\Services\Web\ContactMessageNotifier;
|
||||
use App\Support\WebPortalContext;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
|
@ -20,9 +27,21 @@ use Illuminate\Validation\ValidationException;
|
|||
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
|
||||
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
|
||||
* Absenderadresse der Anwendung).
|
||||
*
|
||||
* Spamschutz (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md):
|
||||
* Cloudflare Turnstile für Gäste, danach Inhaltsfilter. Treffer des Filters
|
||||
* landen in der Quarantäne (Status Spam, keine Mail); war Cloudflare nicht
|
||||
* erreichbar, wird angenommen und im Verlauf markiert.
|
||||
*/
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TurnstileVerifier $turnstile,
|
||||
private readonly ContactSpamFilter $spamFilter,
|
||||
private readonly ContactMessageNotifier $notifier,
|
||||
private readonly ActivityLog $activityLog,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
|
||||
|
|
@ -53,27 +72,66 @@ class ContactController extends Controller
|
|||
$this->ensureNotRateLimited($request);
|
||||
RateLimiter::hit($this->throttleKey($request), 3600);
|
||||
|
||||
// Bestätigte Kunden sind keine Bots. Unbestätigte Konten (dort
|
||||
// landen die Bot-Registrierungen) prüfen wir wie Gäste.
|
||||
$trusted = $request->user()?->hasVerifiedEmail() === true;
|
||||
|
||||
$botCheck = $trusted ? null : $this->turnstile->verifyRequest($request, 'contact');
|
||||
|
||||
if ($botCheck?->blocks()) {
|
||||
throw ValidationException::withMessages([
|
||||
'turnstile' => __('web.launcher.bot_check_failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
$spamReason = $trusted ? null : $this->spamFilter->reasonFor(
|
||||
$validated['name'],
|
||||
$validated['message'],
|
||||
$request->string(ContactSpamFilter::STARTED_FIELD)->toString(),
|
||||
);
|
||||
|
||||
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
|
||||
|
||||
ContactMessage::create([
|
||||
$message = ContactMessage::create([
|
||||
'portal' => $this->portal($request),
|
||||
'topic' => $topic,
|
||||
'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open,
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'message' => $validated['message'],
|
||||
'page_url' => $validated['page_url'] ?? null,
|
||||
'payload' => array_filter([
|
||||
'spam_reason' => $spamReason,
|
||||
'bot_check' => $botCheck?->status === BotCheckStatus::Skipped ? null : $botCheck?->status->value,
|
||||
'bot_check_reason' => $botCheck?->needsReview() ? $botCheck->reason : null,
|
||||
]) ?: null,
|
||||
'requester_ip' => $request->ip(),
|
||||
'user_id' => $request->user()?->id,
|
||||
]);
|
||||
|
||||
Mail::to($this->recipient())->queue((new ContactRequestSubmitted(
|
||||
senderName: $validated['name'],
|
||||
senderEmail: $validated['email'],
|
||||
messageText: $validated['message'],
|
||||
host: $request->getHost(),
|
||||
topicLabel: $topic->label(),
|
||||
pageUrl: $validated['page_url'] ?? null,
|
||||
))->afterCommit());
|
||||
// Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender
|
||||
// bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts.
|
||||
if ($spamReason !== null) {
|
||||
Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [
|
||||
'reason' => $spamReason,
|
||||
'portal' => $message->portal,
|
||||
'ip_prefix' => SpamLog::ipPrefix($request->ip()),
|
||||
]);
|
||||
|
||||
return $this->done();
|
||||
}
|
||||
|
||||
if ($botCheck?->needsReview()) {
|
||||
$this->activityLog->record(
|
||||
ActivityType::BotCheckUnavailable,
|
||||
sprintf('Kontaktanfrage %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $message->reference(), $botCheck->reason),
|
||||
subject: $message,
|
||||
actorLabel: (string) $message->email,
|
||||
meta: ['form' => 'contact', 'reason' => $botCheck->reason],
|
||||
);
|
||||
}
|
||||
|
||||
$this->notifier->notify($message, $request->getHost());
|
||||
|
||||
return $this->done();
|
||||
}
|
||||
|
|
@ -96,19 +154,6 @@ class ContactController extends Controller
|
|||
return WebPortalContext::portalFromHost()->value;
|
||||
}
|
||||
|
||||
private function recipient(): string
|
||||
{
|
||||
// Auch die Hub-Domain (pressekonto) nutzt das Formular — sie ist kein
|
||||
// Spoke-Portal, daher vor dem WebPortalContext am Host unterscheiden.
|
||||
if (str_contains(request()->getHost(), 'pressekonto')) {
|
||||
$brand = config('domains.domains.pressekonto.brand', []);
|
||||
} else {
|
||||
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
|
||||
}
|
||||
|
||||
return $brand['contact_email'] ?? config('mail.from.address');
|
||||
}
|
||||
|
||||
private function ensureNotRateLimited(Request $request): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Enums\ContactMessageTopic;
|
|||
use Database\Factories\ContactMessageFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\MassPrunable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
|
|
@ -21,6 +22,11 @@ class ContactMessage extends Model
|
|||
/** @use HasFactory<ContactMessageFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use MassPrunable;
|
||||
|
||||
/** Tage, die Spam zur Nachprüfung in der Quarantäne bleibt. */
|
||||
public const SPAM_RETENTION_DAYS = 30;
|
||||
|
||||
protected $fillable = [
|
||||
'portal',
|
||||
'topic',
|
||||
|
|
@ -47,6 +53,87 @@ class ContactMessage extends Model
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spam-Quarantäne nach Ablauf der Prüffrist löschen (model:prune).
|
||||
*
|
||||
* @return Builder<ContactMessage>
|
||||
*/
|
||||
public function prunable(): Builder
|
||||
{
|
||||
return self::query()
|
||||
->where('status', ContactMessageStatus::Spam->value)
|
||||
->where('created_at', '<', now()->subDays(self::SPAM_RETENTION_DAYS));
|
||||
}
|
||||
|
||||
public function isSpam(): bool
|
||||
{
|
||||
return $this->status === ContactMessageStatus::Spam;
|
||||
}
|
||||
|
||||
/**
|
||||
* In die Spam-Quarantäne legen (Admin, Sammelaktion oder Altbestand).
|
||||
* Ein vorhandener Filtergrund bleibt erhalten.
|
||||
*/
|
||||
public function markAsSpam(?User $by, string $reason = 'manual'): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => ContactMessageStatus::Spam->value,
|
||||
'payload' => [...($this->payload ?? []), 'spam_reason' => $this->payload['spam_reason'] ?? $reason],
|
||||
'resolved_by_user_id' => $by?->id,
|
||||
'resolved_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlalarm: zurück in die offene Queue. Gibt zurück, ob die Team-Mail
|
||||
* noch aussteht: Hat der Filter die Anfrage beim Eingang abgefangen, ging
|
||||
* keine raus — beim ersten Zurückholen wird sie nachgeholt. Von Hand
|
||||
* markierter Spam hatte seine Mail schon.
|
||||
*/
|
||||
public function releaseFromSpam(): bool
|
||||
{
|
||||
$payload = $this->payload ?? [];
|
||||
$mailPending = isset($payload['spam_reason'])
|
||||
&& $payload['spam_reason'] !== 'manual'
|
||||
&& ! isset($payload['released_from_spam_at']);
|
||||
|
||||
$this->update([
|
||||
'status' => ContactMessageStatus::Open->value,
|
||||
'payload' => [...$payload, 'released_from_spam_at' => now()->toIso8601String()],
|
||||
'resolved_by_user_id' => null,
|
||||
'resolved_at' => null,
|
||||
]);
|
||||
|
||||
return $mailPending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grund der Quarantäne für die Admin-Ansicht (ContactSpamFilter).
|
||||
*/
|
||||
public function spamReasonLabel(): ?string
|
||||
{
|
||||
$reason = $this->payload['spam_reason'] ?? null;
|
||||
|
||||
return match ($reason) {
|
||||
null => null,
|
||||
'link' => 'Link im Text',
|
||||
'page_title' => 'Floskel mit Seitentitel',
|
||||
'gibberish' => 'Zeichensalat',
|
||||
'blacklist' => 'Begriff der Sperrliste',
|
||||
'too_fast' => 'Zu schnell abgesendet',
|
||||
'manual' => 'Von Hand markiert',
|
||||
default => (string) $reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Angenommen, obwohl Cloudflare Turnstile nicht erreichbar war.
|
||||
*/
|
||||
public function skippedBotCheck(): bool
|
||||
{
|
||||
return ($this->payload['bot_check'] ?? null) === 'unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}").
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Observers;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\ContactMessageStatus;
|
||||
use App\Models\ContactMessage;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\NewsletterSubscription;
|
||||
|
|
@ -23,6 +24,8 @@ class ActivityLogObserver
|
|||
public function created(Model $model): void
|
||||
{
|
||||
match (true) {
|
||||
// Spam-Quarantäne gehört nicht in den Verlauf.
|
||||
$model instanceof ContactMessage && $model->status === ContactMessageStatus::Spam => null,
|
||||
$model instanceof ContactMessage => $this->activityLog->record(
|
||||
ActivityType::ContactRequest,
|
||||
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
|
|||
class NoPromotionalText implements ValidationRule
|
||||
{
|
||||
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */
|
||||
private const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
|
||||
public const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
|
||||
|
||||
private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Services\Admin;
|
||||
|
||||
use App\Authorization\Permission as PermissionCatalog;
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\FailedLoginTally;
|
||||
use App\Models\PressReleaseAttachment;
|
||||
|
|
@ -11,6 +13,7 @@ use App\Services\Archive\ArchivePendingQueue;
|
|||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use App\Services\Spam\TurnstileVerifier;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
@ -251,6 +254,32 @@ class AdminSystemHealth
|
|||
: 'Imagick fehlt — Herkunftsprüfung'.($precheckEnabled ? ' und KI-Vorab-Check' : '').' der Bilder laufen ins Leere. Auf dem Server php8.4-imagick installieren und PHP-FPM neu laden.',
|
||||
];
|
||||
|
||||
// Spamschutz (Cloudflare Turnstile): Keys in Produktion gesetzt, und
|
||||
// Formulare, die bei nicht erreichbarem Cloudflare durchgelassen und
|
||||
// markiert wurden (Entscheidung 09.10.2026) — die gehören nachgeprüft.
|
||||
$turnstileEnabled = TurnstileVerifier::enabled();
|
||||
$checks[] = [
|
||||
'key' => 'turnstile',
|
||||
'label' => 'Spamschutz (Cloudflare Turnstile)',
|
||||
'status' => ($turnstileEnabled || ! app()->isProduction()) ? self::StatusOk : self::StatusWarn,
|
||||
'detail' => $turnstileEnabled
|
||||
? 'Bot-Prüfung für Kontakt-Widget und Registrierung ist aktiv.'
|
||||
: 'TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY fehlen — Kontakt-Widget und Registrierung laufen ohne Bot-Prüfung.',
|
||||
];
|
||||
|
||||
$unchecked = ActivityEvent::query()
|
||||
->where('type', ActivityType::BotCheckUnavailable->value)
|
||||
->where('occurred_at', '>=', now()->subDays(7))
|
||||
->count();
|
||||
$checks[] = [
|
||||
'key' => 'bot_check_unavailable',
|
||||
'label' => 'Formulare ohne Bot-Prüfung',
|
||||
'status' => $unchecked > 0 ? self::StatusWarn : self::StatusOk,
|
||||
'detail' => $unchecked > 0
|
||||
? sprintf('%d Formular(e) in 7 Tagen angenommen, während Cloudflare nicht erreichbar war — im Verlauf (Typ „Ohne Bot-Prüfung“) nachprüfen.', $unchecked)
|
||||
: 'In den letzten 7 Tagen wurde jedes Formular geprüft.',
|
||||
];
|
||||
|
||||
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
|
||||
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
|
||||
// nichts, muss aber sichtbar bleiben.
|
||||
|
|
|
|||
27
app/Services/Spam/BotCheck.php
Normal file
27
app/Services/Spam/BotCheck.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Spam;
|
||||
|
||||
use App\Enums\BotCheckStatus;
|
||||
|
||||
/**
|
||||
* Ergebnis einer Turnstile-Prüfung samt technischem Grund (für Log und
|
||||
* Markierung, nie für den Nutzer).
|
||||
*/
|
||||
final readonly class BotCheck
|
||||
{
|
||||
public function __construct(
|
||||
public BotCheckStatus $status,
|
||||
public string $reason = '',
|
||||
) {}
|
||||
|
||||
public function blocks(): bool
|
||||
{
|
||||
return $this->status->blocks();
|
||||
}
|
||||
|
||||
public function needsReview(): bool
|
||||
{
|
||||
return $this->status === BotCheckStatus::Unavailable;
|
||||
}
|
||||
}
|
||||
136
app/Services/Spam/ContactSpamFilter.php
Normal file
136
app/Services/Spam/ContactSpamFilter.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Spam;
|
||||
|
||||
use App\Rules\NoPromotionalText;
|
||||
use App\Services\PressRelease\BlacklistService;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Inhaltsfilter des Kontaktformulars für nicht angemeldete Absender
|
||||
* (Spamschutz B2). Gemessen an 141 Anfragen aus 30 Tagen bis 07.10.2026:
|
||||
* 56 mit Link, 43 mit Floskel plus Seitentitel, dazu Zeichensalat.
|
||||
*
|
||||
* Ein Treffer blockiert nicht, sondern legt die Anfrage in die Quarantäne
|
||||
* (ContactMessageStatus::Spam) — Fehlalarme holt der Admin zurück.
|
||||
*/
|
||||
class ContactSpamFilter
|
||||
{
|
||||
/** Feld mit dem verschlüsselten Zeitpunkt, zu dem das Formular ausgeliefert wurde. */
|
||||
public const STARTED_FIELD = 'form_started';
|
||||
|
||||
/** Schneller füllt kein Mensch Name, E-Mail und Nachricht aus. */
|
||||
private const MIN_SECONDS = 3;
|
||||
|
||||
/** HTML-Links und BBCode, die das Link-Muster nicht abdeckt. */
|
||||
private const MARKUP_PATTERN = '~<a\s|\[url[\]=]~iu';
|
||||
|
||||
public function __construct(private readonly BlacklistService $blacklist) {}
|
||||
|
||||
public static function startedToken(): string
|
||||
{
|
||||
return Crypt::encryptString((string) now()->getTimestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Grund (technischer Schlüssel) oder null, wenn die Anfrage sauber ist.
|
||||
*/
|
||||
public function reasonFor(string $name, string $message, ?string $startedToken): ?string
|
||||
{
|
||||
if ($this->containsForeignLink($message)) {
|
||||
return 'link';
|
||||
}
|
||||
|
||||
if ($this->quotesPageTitle($message)) {
|
||||
return 'page_title';
|
||||
}
|
||||
|
||||
if (GibberishDetector::isRandomToken($name) || GibberishDetector::isRandomToken($message)) {
|
||||
return 'gibberish';
|
||||
}
|
||||
|
||||
if ($this->blacklist->find($name.' '.$message) !== null) {
|
||||
return 'blacklist';
|
||||
}
|
||||
|
||||
if ($this->submittedTooFast($startedToken)) {
|
||||
return 'too_fast';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gäste dürfen keine Links schicken (Entscheidung 5) — außer die Nachricht
|
||||
* verweist auf eine unserer Seiten: Korrekturwünsche nennen die eigene
|
||||
* Pressemitteilung und oft daneben die Firmen-Website (KT-32, KT-161).
|
||||
*/
|
||||
private function containsForeignLink(string $message): bool
|
||||
{
|
||||
if (preg_match(self::MARKUP_PATTERN, $message) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match(NoPromotionalText::LINK_PATTERN, $message) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ownHosts = collect((array) config('domains.domains', []))
|
||||
->pluck('domain_name')
|
||||
->filter()
|
||||
->map(fn (string $host): string => preg_quote(Str::after($host, 'www.'), '~'))
|
||||
->unique()
|
||||
->implode('|');
|
||||
|
||||
if ($ownHosts === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mit Schema („https://presseecho.de“) oder mit Pfad („presseecho.de/de/…“).
|
||||
$ownLink = '~https?://(?:www\.)?(?:'.$ownHosts.')\b|\b(?:www\.)?(?:'.$ownHosts.')/~i';
|
||||
|
||||
return preg_match($ownLink, $message) !== 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular-Bots hängen den Seitentitel an eine Floskel: „Ich wünsche weitere
|
||||
* Informationen … — aktuelle pressemitteilungen aus der deutschen wirtschaft.“
|
||||
*/
|
||||
private function quotesPageTitle(string $message): bool
|
||||
{
|
||||
$haystack = Str::lower($message);
|
||||
|
||||
foreach ((array) config('domains.domains', []) as $domain) {
|
||||
foreach (preg_split('/\s[–-]\s/u', (string) ($domain['brand']['meta_title'] ?? '')) ?: [] as $segment) {
|
||||
$segment = Str::lower(trim($segment));
|
||||
|
||||
if (mb_strlen($segment) >= 20 && str_contains($haystack, $segment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fehlt der Zeitstempel oder ist er manipuliert, entscheidet Turnstile —
|
||||
* dieser Filter greift nur bei nachweislich zu schnellem Absenden.
|
||||
*/
|
||||
private function submittedTooFast(?string $startedToken): bool
|
||||
{
|
||||
if (blank($startedToken)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$startedAt = (int) Crypt::decryptString($startedToken);
|
||||
} catch (DecryptException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return now()->getTimestamp() - $startedAt < self::MIN_SECONDS;
|
||||
}
|
||||
}
|
||||
44
app/Services/Spam/GibberishDetector.php
Normal file
44
app/Services/Spam/GibberishDetector.php
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Spam;
|
||||
|
||||
/**
|
||||
* Erkennt Zufallszeichenketten, wie sie Bots in Namensfelder schreiben
|
||||
* („ruLrlotwaCoVStmZetSxD“, „NAEWTRER2720160NEYRTHYT“).
|
||||
*
|
||||
* Beide Regeln sind am Bestand vom 07.10.2026 gemessen: keine einzige
|
||||
* Person unter rund 67.000 Konten und 0 Kontakte getroffen.
|
||||
*/
|
||||
final class GibberishDetector
|
||||
{
|
||||
/**
|
||||
* Personenname (Registrierung): ein einziges Wort aus mindestens 12
|
||||
* Buchstaben mit mindestens drei Großbuchstaben nach dem ersten Zeichen.
|
||||
* Trifft im Bestand 511 Bot-Konten und keinen echten Namen. Firmennamen
|
||||
* wie „StammZellenPowerTeam“ fielen darunter — darum nur für Personen.
|
||||
*/
|
||||
public static function isRandomPersonName(string $name): bool
|
||||
{
|
||||
$name = trim($name);
|
||||
|
||||
return preg_match('/^[A-Za-z]{12,}$/', $name) === 1
|
||||
&& preg_match_all('/[A-Z]/', substr($name, 1)) >= 3
|
||||
&& preg_match_all('/[a-z]/', $name) >= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freies Namensfeld, das auch Firmen tragen kann (Kontaktformular):
|
||||
* mindestens vier Wechsel von klein auf groß („…aCoVStmZet…“), oder ein
|
||||
* Block Großbuchstaben + Ziffern + Großbuchstaben.
|
||||
*/
|
||||
public static function isRandomToken(string $value): bool
|
||||
{
|
||||
$value = trim($value);
|
||||
|
||||
if (preg_match('/^[A-Za-z]{12,}$/', $value) === 1 && preg_match_all('/[a-z][A-Z]/', $value) >= 4) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/[A-Z]{5,}\d{4,}[A-Z]{5,}/', $value) === 1;
|
||||
}
|
||||
}
|
||||
29
app/Services/Spam/SpamLog.php
Normal file
29
app/Services/Spam/SpamLog.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Spam;
|
||||
|
||||
/**
|
||||
* Hilfen für den Log-Kanal `spam`: nur Netz-Präfix statt voller IP, nie
|
||||
* Inhalte — der Kanal zählt abgewiesene Anfragen, er archiviert sie nicht.
|
||||
*/
|
||||
final class SpamLog
|
||||
{
|
||||
public static function ipPrefix(?string $ip): ?string
|
||||
{
|
||||
if ($ip === null || $ip === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return implode('.', array_slice(explode('.', $ip), 0, 3)).'.x';
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
$expanded = explode(':', (string) inet_ntop((string) inet_pton($ip)));
|
||||
|
||||
return implode(':', array_slice($expanded, 0, 3)).'::/48';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
151
app/Services/Spam/TurnstileVerifier.php
Normal file
151
app/Services/Spam/TurnstileVerifier.php
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Spam;
|
||||
|
||||
use App\Enums\BotCheckStatus;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Serverseitige Prüfung des Cloudflare-Turnstile-Tokens
|
||||
* (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md, B1).
|
||||
*
|
||||
* Entscheidung 09.10.2026: Ist Cloudflare nicht erreichbar, wird nicht
|
||||
* blockiert, sondern durchgelassen und markiert (BotCheckStatus::Unavailable).
|
||||
* Das gilt für drei Fälle:
|
||||
* - Siteverify antwortet nicht (Timeout, Netzfehler, 5xx, internal-error),
|
||||
* - unsere Keys werden abgelehnt (Konfigurationsfehler, nicht Schuld des Nutzers),
|
||||
* - das Widget konnte im Browser nicht laden (Feld `turnstile_unavailable`).
|
||||
* Der letzte Fall ist vom Absender behauptet — darum bleibt er sichtbar
|
||||
* markiert und zählt in der Betriebsüberwachung. Wird er missbraucht,
|
||||
* schaltet TURNSTILE_FAIL_OPEN=false auf Ablehnen um.
|
||||
*
|
||||
* Die Hostname-Bindung prüft Cloudflare selbst (Widget-Einstellungen);
|
||||
* hier zählt zusätzlich die Aktion, damit ein Token nur für das Formular
|
||||
* gilt, für das es gelöst wurde.
|
||||
*/
|
||||
class TurnstileVerifier
|
||||
{
|
||||
public const ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
/** Name des Token-Felds, das das Widget im Formular anlegt. */
|
||||
public const TOKEN_FIELD = 'cf-turnstile-response';
|
||||
|
||||
/** Vom Widget gesetzt, wenn das Skript im Browser nicht lud. */
|
||||
public const UNAVAILABLE_FIELD = 'turnstile_unavailable';
|
||||
|
||||
private const MAX_TOKEN_LENGTH = 2048;
|
||||
|
||||
public static function enabled(): bool
|
||||
{
|
||||
return filled(config('services.turnstile.site_key')) && filled(config('services.turnstile.secret_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein klassisches Formular (Token und Ausfall-Flag im Request).
|
||||
*/
|
||||
public function verifyRequest(Request $request, string $action): BotCheck
|
||||
{
|
||||
return $this->verify(
|
||||
$request->string(self::TOKEN_FIELD)->toString(),
|
||||
$request->boolean(self::UNAVAILABLE_FIELD),
|
||||
$action,
|
||||
$request->ip(),
|
||||
);
|
||||
}
|
||||
|
||||
public function verify(?string $token, bool $clientUnavailable, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
if (! self::enabled()) {
|
||||
return new BotCheck(BotCheckStatus::Skipped);
|
||||
}
|
||||
|
||||
$token = trim((string) $token);
|
||||
|
||||
if ($token === '') {
|
||||
return $clientUnavailable
|
||||
? $this->unavailable('widget_not_loaded', $action, $ip)
|
||||
: $this->failed('missing_token', $action, $ip);
|
||||
}
|
||||
|
||||
if (strlen($token) > self::MAX_TOKEN_LENGTH) {
|
||||
return $this->failed('token_too_long', $action, $ip);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout((int) config('services.turnstile.timeout', 3))
|
||||
->post(self::ENDPOINT, array_filter([
|
||||
'secret' => config('services.turnstile.secret_key'),
|
||||
'response' => $token,
|
||||
'remoteip' => $ip,
|
||||
]));
|
||||
} catch (ConnectionException) {
|
||||
return $this->unavailable('siteverify_unreachable', $action, $ip);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return $this->unavailable('siteverify_http_'.$response->status(), $action, $ip);
|
||||
}
|
||||
|
||||
$errors = (array) $response->json('error-codes', []);
|
||||
|
||||
if ($response->json('success') !== true) {
|
||||
if (in_array('internal-error', $errors, true)) {
|
||||
return $this->unavailable('siteverify_internal_error', $action, $ip);
|
||||
}
|
||||
|
||||
if (array_intersect(['missing-input-secret', 'invalid-input-secret'], $errors) !== []) {
|
||||
// Unser Fehler, nicht der des Absenders — laut melden, nicht blockieren.
|
||||
Log::error('Turnstile lehnt den Secret-Key ab — TURNSTILE_SECRET_KEY prüfen.', ['errors' => $errors]);
|
||||
|
||||
return $this->unavailable('invalid_secret', $action, $ip);
|
||||
}
|
||||
|
||||
return $this->failed(implode(',', $errors) ?: 'rejected', $action, $ip);
|
||||
}
|
||||
|
||||
$returnedAction = (string) $response->json('action', '');
|
||||
|
||||
// Die Cloudflare-Testkeys (1x…/2x…/3x…) liefern keine echte Aktion.
|
||||
if (! $this->usesTestSecret() && $returnedAction !== $action) {
|
||||
return $this->failed('action_mismatch', $action, $ip);
|
||||
}
|
||||
|
||||
return new BotCheck(BotCheckStatus::Passed);
|
||||
}
|
||||
|
||||
private function unavailable(string $reason, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
$this->log('Bot-Prüfung nicht verfügbar — durchgelassen und markiert.', $reason, $action, $ip);
|
||||
|
||||
if (! config('services.turnstile.fail_open', true)) {
|
||||
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||
}
|
||||
|
||||
return new BotCheck(BotCheckStatus::Unavailable, $reason);
|
||||
}
|
||||
|
||||
private function failed(string $reason, string $action, ?string $ip): BotCheck
|
||||
{
|
||||
$this->log('Bot-Prüfung nicht bestanden.', $reason, $action, $ip);
|
||||
|
||||
return new BotCheck(BotCheckStatus::Failed, $reason);
|
||||
}
|
||||
|
||||
private function log(string $message, string $reason, string $action, ?string $ip): void
|
||||
{
|
||||
Log::channel('spam')->info($message, [
|
||||
'form' => $action,
|
||||
'reason' => $reason,
|
||||
'ip_prefix' => SpamLog::ipPrefix($ip),
|
||||
]);
|
||||
}
|
||||
|
||||
private function usesTestSecret(): bool
|
||||
{
|
||||
return (bool) preg_match('/^[123]x0{20,}/', (string) config('services.turnstile.secret_key'));
|
||||
}
|
||||
}
|
||||
40
app/Services/Web/ContactMessageNotifier.php
Normal file
40
app/Services/Web/ContactMessageNotifier.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Web;
|
||||
|
||||
use App\Mail\ContactRequestSubmitted;
|
||||
use App\Models\ContactMessage;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Benachrichtigt die Brand-Kontaktadresse über ein Kontakt-Anliegen
|
||||
* (config domains.domains.{portal}.brand.contact_email, Fallback:
|
||||
* Absenderadresse der Anwendung). Läuft beim Eingang und beim Zurückholen
|
||||
* eines Fehlalarms aus der Spam-Quarantäne.
|
||||
*/
|
||||
class ContactMessageNotifier
|
||||
{
|
||||
public function notify(ContactMessage $message, ?string $host = null): void
|
||||
{
|
||||
Mail::to($this->recipient($message))->queue((new ContactRequestSubmitted(
|
||||
senderName: (string) $message->name,
|
||||
senderEmail: (string) $message->email,
|
||||
messageText: (string) $message->message,
|
||||
host: $host ?? $this->hostOf($message),
|
||||
topicLabel: $message->topic->label(),
|
||||
pageUrl: $message->page_url,
|
||||
))->afterCommit());
|
||||
}
|
||||
|
||||
private function recipient(ContactMessage $message): string
|
||||
{
|
||||
return config('domains.domains.'.$message->portal.'.brand.contact_email')
|
||||
?? config('mail.from.address');
|
||||
}
|
||||
|
||||
private function hostOf(ContactMessage $message): string
|
||||
{
|
||||
return parse_url((string) $message->page_url, PHP_URL_HOST)
|
||||
?: (string) config('domains.domains.'.$message->portal.'.domain_name', $message->portal);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,17 +23,19 @@ class ContentSecurityPolicy
|
|||
$own = array_merge(["'self'"], self::ownOrigins());
|
||||
$umami = config('services.umami.enabled') ? self::originOf((string) config('services.umami.script_url')) : null;
|
||||
$errors = self::originOf((string) config('monitoring.browser.dsn'));
|
||||
// Cloudflare Turnstile (Spamschutz): Skript und Challenge-iframe.
|
||||
$turnstile = 'https://challenges.cloudflare.com';
|
||||
|
||||
$directives = [
|
||||
'default-src' => ["'self'"],
|
||||
'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami],
|
||||
'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami, $turnstile],
|
||||
'style-src' => [...$own, "'unsafe-inline'"],
|
||||
'img-src' => [...$own, 'data:', 'blob:', 'https:'],
|
||||
'font-src' => [...$own, 'data:'],
|
||||
'connect-src' => [...$own, $umami, $errors],
|
||||
'media-src' => [...$own],
|
||||
'object-src' => ["'none'"],
|
||||
'frame-src' => ["'self'"],
|
||||
'frame-src' => ["'self'", $turnstile],
|
||||
'frame-ancestors' => ["'self'"],
|
||||
'base-uri' => ["'self'"],
|
||||
'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue