rows((string) $this->argument('dump'), (string) $this->option('database')) as $record) { $row = $record['row']; if ($record['table'] === 'account_access') { $id = (int) ($row['id'] ?? 0); $level = (int) ($row['gmlevel'] ?? 0); if ($id < 1 || $level < 0 || $level > 255) { throw new RuntimeException('Invalid account_access row in the auth backup.'); } $levels[$id] = max($levels[$id] ?? 0, $level); continue; } $username = $gameSrp->normalizeUsername((string) ($row['username'] ?? '')); if (! $this->option('include-bots') && $prefix !== '' && str_starts_with($username, $prefix)) { $bots++; continue; } $id = (int) ($row['id'] ?? 0); if ($id < 1 || preg_match('/^[A-Z0-9]{3,16}$/D', $username) !== 1) { throw new RuntimeException("Unsupported legacy account ID or name: ID {$id}."); } if (isset($accounts[$id]) || isset($names[$username])) { throw new RuntimeException("Duplicate legacy ID or username in the auth backup: ID {$id}."); } if (! isset($row['salt'], $row['verifier']) || strlen($row['salt']) !== 32 || strlen($row['verifier']) !== 32) { throw new RuntimeException("Missing or invalid AzerothCore SRP credentials for legacy ID {$id}."); } $email = strtolower(trim((string) ($row['email'] ?? ''))); if ($email === '') { $email = strtolower(trim((string) ($row['reg_mail'] ?? ''))); } if (strlen($email) > 255) { throw new RuntimeException("The source email does not fit web.users for legacy ID {$id}."); } $joined = (string) ($row['joindate'] ?? ''); try { $joined = $joined !== '' && ! str_starts_with($joined, '0000-00-00') ? CarbonImmutable::parse($joined, 'UTC')->toDateTimeString() : now('UTC')->toDateTimeString(); } catch (\Exception) { throw new RuntimeException("Invalid registration date for legacy ID {$id}."); } $lastIp = (string) ($row['last_ip'] ?? '0.0.0.0'); if (strlen($lastIp) > 30) { throw new RuntimeException("The source last_ip does not fit vMaNGOS for legacy ID {$id}."); } $accounts[$id] = [ 'id' => $id, 'username' => $username, 'email' => $email, 'joined' => $joined, 'locked' => (bool) ($row['locked'] ?? false), 'last_ip' => $lastIp, 'password' => $passwords->legacyCredential($username, bin2hex($row['salt']), bin2hex($row['verifier'])), ]; $names[$username] = $id; if ($email !== '') { $emails[$email] = ($emails[$email] ?? 0) + 1; } } if ($accounts === []) { throw new RuntimeException('No eligible AzerothCore accounts found; check the dump and --database option.'); } [$created, $linked, $skipped, $missingEmails] = $web->transaction(fn (): array => $auth->transaction(function () use ($web, $auth, $gameSrp, $accounts, $levels, $emails): array { $plans = []; $created = $linked = $skipped = $missingEmails = 0; foreach ($accounts as $source) { $game = $auth->table('account')->where('username', $source['username'])->lockForUpdate()->first(['id', 'email']); $credential = $game ? $auth->table('site_credentials')->where('account_id', $game->id)->first() : null; $legacyMapping = $auth->table('site_credentials')->where('legacy_account_id', $source['id'])->first(); if ($legacyMapping && (! $game || (int) $legacyMapping->account_id !== (int) $game->id)) { throw new RuntimeException("Legacy ID mapping conflict for ID {$source['id']}."); } if ($credential?->legacy_account_id !== null && (int) $credential->legacy_account_id !== $source['id']) { throw new RuntimeException("Game account belongs to another legacy ID: {$source['id']}."); } if ($credential?->web_user_id !== null) { $existingWeb = $web->table('users')->where('id', $credential->web_user_id)->first(['name']); if (! $existingWeb || strtoupper(trim($existingWeb->name)) !== $source['username']) { throw new RuntimeException("Existing web mapping is missing or belongs to another user for legacy ID {$source['id']}."); } // Preserve passwords changed after the original migration. $skipped++; continue; } if ($web->table('users')->whereRaw('UPPER(name) = ?', [$source['username']])->exists()) { throw new RuntimeException("An unlinked web user already has this name for legacy ID {$source['id']}; resolve the mapping first."); } if ($game && $game->email && $source['email'] !== '' && strcasecmp(trim($game->email), $source['email']) !== 0) { throw new RuntimeException("Existing game account email differs for legacy ID {$source['id']}."); } $webEmail = $source['email']; if ($webEmail === '' || $emails[$webEmail] > 1 || $web->table('users')->whereRaw('LOWER(email) = ?', [$webEmail])->exists()) { // Original contact data remains in realmd.account.email. $webEmail = null; $missingEmails++; } $plans[] = [$source, $game, $credential, $webEmail]; $game ? $linked++ : $created++; } if ($this->option('dry-run')) { return [$created, $linked, $skipped, $missingEmails]; } foreach ($plans as [$source, $game, $credential, $webEmail]) { // Insert directly: the User hashed cast would hash the SRP encoding. $webId = $web->table('users')->insertGetId([ 'name' => $source['username'], 'email' => $webEmail, 'password' => $source['password'], 'created_at' => $source['joined'], 'updated_at' => now('UTC'), ]); [$salt, $verifier] = $gameSrp->makeRegistrationData($source['username'], bin2hex(random_bytes(16))); if ($game) { $accountId = (int) $game->id; $auth->table('account')->where('id', $accountId)->update([ 's' => $salt, 'v' => $verifier, 'sessionkey' => null, 'email' => $source['email'], ]); $auth->table('launcher_ticket')->where('account_id', $accountId)->delete(); } else { $accountId = (int) $auth->table('account')->insertGetId([ 'username' => $source['username'], 'email' => $source['email'], 's' => $salt, 'v' => $verifier, 'expansion' => 0, 'joindate' => $source['joined'], 'locked' => $source['locked'], 'last_ip' => $source['last_ip'], ]); $auth->table('account_access')->insert([ 'id' => $accountId, 'RealmID' => (int) config('moonwell.registration.realm_id'), 'gmlevel' => $levels[$source['id']] ?? (int) config('moonwell.registration.gmlevel'), ]); } $auth->table('site_credentials')->updateOrInsert(['account_id' => $accountId], [ 'legacy_account_id' => $source['id'], 'web_user_id' => $webId, 'salt_hex' => null, 'verifier_hex' => null, 'created_at' => $credential?->created_at ?? now('UTC'), 'updated_at' => now('UTC'), ]); foreach ($auth->table('realmlist')->pluck('id') as $realmId) { $auth->table('realmcharacters')->insertOrIgnore(['realmid' => (int) $realmId, 'acctid' => $accountId, 'numchars' => 0]); } } if ($plans !== []) { $web->table('oauth_access_tokens')->where('revoked', false)->update(['revoked' => true]); $web->table('oauth_refresh_tokens')->where('revoked', false)->update(['revoked' => true]); if (config('session.driver') === 'database') { $web->table(config('session.table', 'sessions'))->delete(); } } return [$created, $linked, $skipped, $missingEmails]; })); } catch (QueryException $exception) { // Do not print INSERT bindings: they contain password verifiers. $this->error('Database operation failed (SQLSTATE '.$exception->getCode().'). Check migrations and connectivity.'); return self::FAILURE; } catch (RuntimeException $exception) { $this->error($exception->getMessage()); return self::FAILURE; } if (! $this->option('dry-run') && ($created + $linked) > 0 && config('session.driver') === 'file') { foreach (File::files(config('session.files')) as $file) { File::delete($file->getPathname()); } } $this->info("Created: {$created}; linked: {$linked}; already imported: {$skipped}; bots skipped: {$bots}."); if ($missingEmails > 0) { $this->warn("Web users without a unique email: {$missingEmails}; original emails are retained in game accounts."); } return self::SUCCESS; } }