- 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>
145 lines
5.2 KiB
PHP
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;
|
|
}
|
|
}
|