presseportale/app/Console/Commands/PruneBotRegistrations.php
Kevin Adametz 06d2889a4d 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>
2026-10-09 08:49:58 +00:00

145 lines
5.2 KiB
PHP

<?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;
}
}