302 lines
11 KiB
PHP
302 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Support\UsernamePolicy;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Carbon\Carbon;
|
|
|
|
class RepairLegacyWallzUsersCommand extends Command
|
|
{
|
|
protected $signature = 'skinbase:repair-legacy-wallz-users
|
|
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
|
{--legacy-connection=legacy : Legacy database connection name}
|
|
{--legacy-table=wallz : Legacy table to update}
|
|
{--artworks-table=artworks : Current DB artworks table name}
|
|
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
|
|
{--dry-run : Preview matches and inserts without writing changes}';
|
|
|
|
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
|
|
|
|
public function handle(): int
|
|
{
|
|
$chunk = max(1, (int) $this->option('chunk'));
|
|
$legacyConnection = (string) $this->option('legacy-connection');
|
|
$legacyTable = (string) $this->option('legacy-table');
|
|
$artworksTable = (string) $this->option('artworks-table');
|
|
$fixArtworks = (bool) $this->option('fix-artworks');
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
|
|
if (! $this->legacyTableExists($legacyConnection, $legacyTable)) {
|
|
$this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->warn('[DRY RUN] No changes will be written.');
|
|
}
|
|
|
|
if ($fixArtworks) {
|
|
$this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun);
|
|
}
|
|
|
|
$total = (int) DB::connection($legacyConnection)
|
|
->table($legacyTable)
|
|
->where('user_id', 0)
|
|
->count();
|
|
|
|
if ($total === 0) {
|
|
if (! $fixArtworks) {
|
|
$this->info('No legacy wallz rows with user_id = 0 were found.');
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}.");
|
|
|
|
$processed = 0;
|
|
$updatedRows = 0;
|
|
$matchedUsers = 0;
|
|
$createdUsers = 0;
|
|
$skippedRows = 0;
|
|
$usernameMap = [];
|
|
|
|
DB::connection($legacyConnection)
|
|
->table($legacyTable)
|
|
->select(['id', 'uname'])
|
|
->where('user_id', 0)
|
|
->orderBy('id')
|
|
->chunkById($chunk, function ($rows) use (
|
|
&$processed,
|
|
&$updatedRows,
|
|
&$matchedUsers,
|
|
&$createdUsers,
|
|
&$skippedRows,
|
|
&$usernameMap,
|
|
$dryRun,
|
|
$legacyConnection,
|
|
$legacyTable
|
|
) {
|
|
foreach ($rows as $row) {
|
|
$processed++;
|
|
|
|
$rawUsername = trim((string) ($row->uname ?? ''));
|
|
if ($rawUsername === '') {
|
|
$skippedRows++;
|
|
$this->warn("Skipping wallz id={$row->id}: uname is empty.");
|
|
continue;
|
|
}
|
|
|
|
$lookupKey = UsernamePolicy::normalize($rawUsername);
|
|
if ($lookupKey === '') {
|
|
$skippedRows++;
|
|
$this->warn("Skipping wallz id={$row->id}: uname normalizes to empty.");
|
|
continue;
|
|
}
|
|
|
|
if (! array_key_exists($lookupKey, $usernameMap)) {
|
|
$existingUser = $this->findUserByUsername($lookupKey);
|
|
|
|
if ($existingUser !== null) {
|
|
$usernameMap[$lookupKey] = [
|
|
'user_id' => (int) $existingUser->id,
|
|
'created' => false,
|
|
];
|
|
} else {
|
|
$usernameMap[$lookupKey] = [
|
|
'user_id' => $dryRun
|
|
? 0
|
|
: $this->createUserForLegacyUsername($rawUsername, $legacyConnection),
|
|
'created' => true,
|
|
];
|
|
}
|
|
}
|
|
|
|
$resolved = $usernameMap[$lookupKey];
|
|
|
|
if ($resolved['created']) {
|
|
$createdUsers++;
|
|
$usernameMap[$lookupKey]['created'] = false;
|
|
$resolved['created'] = false;
|
|
$this->line($dryRun
|
|
? "[dry] Would create user for uname='{$rawUsername}'"
|
|
: "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'");
|
|
} else {
|
|
$matchedUsers++;
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$targetUser = $usernameMap[$lookupKey]['user_id'] > 0
|
|
? (string) $usernameMap[$lookupKey]['user_id']
|
|
: '<new-user-id>';
|
|
$this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'");
|
|
$updatedRows++;
|
|
continue;
|
|
}
|
|
|
|
$affected = DB::connection($legacyConnection)
|
|
->table($legacyTable)
|
|
->where('id', $row->id)
|
|
->where('user_id', 0)
|
|
->update([
|
|
'user_id' => $usernameMap[$lookupKey]['user_id'],
|
|
]);
|
|
|
|
if ($affected > 0) {
|
|
$updatedRows += $affected;
|
|
}
|
|
}
|
|
}, 'id');
|
|
|
|
$this->info(sprintf(
|
|
'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d',
|
|
$processed,
|
|
$updatedRows,
|
|
$matchedUsers,
|
|
$createdUsers,
|
|
$skippedRows
|
|
));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void
|
|
{
|
|
$this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0");
|
|
|
|
$total = (int) DB::table($artworksTable)->where('user_id', 0)->count();
|
|
$this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}.");
|
|
|
|
$processed = 0;
|
|
$updated = 0;
|
|
DB::table($artworksTable)
|
|
->select(['id'])
|
|
->where('user_id', 0)
|
|
->orderBy('id')
|
|
->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) {
|
|
foreach ($rows as $row) {
|
|
$processed++;
|
|
|
|
$legacyUser = DB::connection($legacyConnection)
|
|
->table($legacyTable)
|
|
->where('id', $row->id)
|
|
->value('user_id');
|
|
|
|
$legacyUser = (int) ($legacyUser ?? 0);
|
|
if ($legacyUser <= 0) {
|
|
continue;
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}");
|
|
$updated++;
|
|
continue;
|
|
}
|
|
|
|
$affected = DB::table($artworksTable)
|
|
->where('id', $row->id)
|
|
->where('user_id', 0)
|
|
->update(['user_id' => $legacyUser]);
|
|
|
|
if ($affected > 0) {
|
|
$updated += $affected;
|
|
}
|
|
}
|
|
}, 'id');
|
|
|
|
$this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated));
|
|
}
|
|
|
|
private function legacyTableExists(string $connection, string $table): bool
|
|
{
|
|
try {
|
|
return DB::connection($connection)->getSchemaBuilder()->hasTable($table);
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function findUserByUsername(string $normalizedUsername): ?object
|
|
{
|
|
return DB::table('users')
|
|
->select(['id', 'username'])
|
|
->whereRaw('LOWER(username) = ?', [$normalizedUsername])
|
|
->first();
|
|
}
|
|
|
|
private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int
|
|
{
|
|
$username = UsernamePolicy::uniqueCandidate($legacyUsername);
|
|
$emailLocal = $this->sanitizeEmailLocal($username);
|
|
$email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org');
|
|
$now = now();
|
|
|
|
// Attempt to copy legacy joinDate from the legacy `users` table when available.
|
|
$legacyJoin = null;
|
|
try {
|
|
$legacyJoin = DB::connection($legacyConnection)
|
|
->table('users')
|
|
->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)])
|
|
->value('joinDate');
|
|
} catch (\Throwable) {
|
|
$legacyJoin = null;
|
|
}
|
|
|
|
$createdAt = $now;
|
|
if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) {
|
|
try {
|
|
$createdAt = Carbon::parse($legacyJoin);
|
|
} catch (\Throwable) {
|
|
$createdAt = $now;
|
|
}
|
|
}
|
|
|
|
$userId = (int) DB::table('users')->insertGetId([
|
|
'username' => $username,
|
|
'username_changed_at' => $now,
|
|
'name' => $legacyUsername,
|
|
'email' => $email,
|
|
'password' => Hash::make(Str::random(64)),
|
|
'is_active' => true,
|
|
'needs_password_reset' => true,
|
|
'role' => 'user',
|
|
'legacy_password_algo' => null,
|
|
'created_at' => $createdAt,
|
|
'updated_at' => $now,
|
|
]);
|
|
|
|
return $userId;
|
|
}
|
|
|
|
private function uniqueEmailCandidate(string $email): string
|
|
{
|
|
$candidate = strtolower(trim($email));
|
|
$suffix = 1;
|
|
|
|
while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) {
|
|
$parts = explode('@', $email, 2);
|
|
$local = $parts[0] ?? 'user';
|
|
$domain = $parts[1] ?? 'users.skinbase.org';
|
|
$candidate = $local . '+' . $suffix . '@' . $domain;
|
|
$suffix++;
|
|
}
|
|
|
|
return $candidate;
|
|
}
|
|
|
|
private function sanitizeEmailLocal(string $value): string
|
|
{
|
|
$local = strtolower(trim($value));
|
|
$local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user';
|
|
|
|
return trim($local, '.-') ?: 'user';
|
|
}
|
|
}
|