table('users')->select('id', 'name', 'email', 'created_at')->orderBy('id'); $prefix = strtoupper(trim((string) config('moonwell.portal.bot_account_prefix'))); if (! $this->option('include-bots') && $prefix !== '') { $query->whereRaw('UPPER(name) NOT LIKE ?', [$prefix.'%']); } $users = $query->get(); $names = []; foreach ($users as $user) { $username = $srp->normalizeUsername($user->name); if (preg_match('/^[A-Z0-9]{3,16}$/D', $username) !== 1) { $this->error("Unsupported web.users name for ID {$user->id}; expected 3–16 Latin letters or digits."); return self::FAILURE; } if (isset($names[$username])) { $this->error("Duplicate normalized username for web.users IDs {$names[$username]} and {$user->id}."); return self::FAILURE; } $names[$username] = $user->id; } try { [$created, $linked, $skipped] = $source->transaction(fn (): array => $target->transaction(function () use ($target, $source, $srp, $users): array { $created = $linked = $skipped = 0; $plans = []; foreach ($users as $user) { $username = $srp->normalizeUsername($user->name); $mapping = $target->table('site_credentials')->where('web_user_id', $user->id)->first(); $existing = $target->table('account')->where('username', $username)->lockForUpdate()->first(['id', 'email']); if ($mapping) { if (! $existing || (int) $mapping->account_id !== (int) $existing->id) { throw new RuntimeException("Account name changed or mapping conflict for web.users ID {$user->id}."); } $skipped++; continue; } $credential = $existing ? $target->table('site_credentials')->where('account_id', $existing->id)->first() : null; if ($credential?->web_user_id !== null) { throw new RuntimeException("Account already belongs to another web user; web.users ID {$user->id}."); } if ($existing && $existing->email && $user->email !== null && strcasecmp(trim($existing->email), trim($user->email)) !== 0) { throw new RuntimeException("Existing game account email differs for web.users ID {$user->id}; resolve the conflict before importing."); } $existing ? $linked++ : $created++; $plans[] = [$user, $username, $existing, $credential]; } if ($this->option('dry-run')) { return [$created, $linked, $skipped]; } foreach ($plans as [$user, $username, $existing, $credential]) { // Discard the random plaintext. Web authentication reads users.password. [$salt, $verifier] = $srp->makeRegistrationData($username, bin2hex(random_bytes(16))); if ($existing) { $accountId = (int) $existing->id; $target->table('account')->where('id', $accountId)->update([ 's' => $salt, 'v' => $verifier, 'sessionkey' => null, 'email' => $user->email, ]); $target->table('launcher_ticket')->where('account_id', $accountId)->delete(); } else { $accountId = (int) $target->table('account')->insertGetId([ 'username' => $username, 's' => $salt, 'v' => $verifier, 'email' => $user->email, 'expansion' => 0, 'joindate' => $user->created_at ? CarbonImmutable::parse($user->created_at, config('app.timezone'))->utc()->toDateTimeString() : now('UTC')->toDateTimeString(), ]); $target->table('account_access')->insert([ 'id' => $accountId, 'RealmID' => (int) config('moonwell.registration.realm_id'), 'gmlevel' => (int) config('moonwell.registration.gmlevel'), ]); } $target->table('site_credentials')->updateOrInsert(['account_id' => $accountId], [ 'web_user_id' => (int) $user->id, 'salt_hex' => null, 'verifier_hex' => null, 'created_at' => $credential?->created_at ?? now('UTC'), 'updated_at' => now('UTC'), ]); foreach ($target->table('realmlist')->pluck('id') as $realmId) { $target->table('realmcharacters')->insertOrIgnore(['realmid' => (int) $realmId, 'acctid' => $accountId, 'numchars' => 0]); } } if (! $this->option('dry-run') && ($created + $linked) > 0) { // Old sessions contain IDs from the former authentication source. $source->table('oauth_access_tokens')->where('revoked', false)->update(['revoked' => true]); $source->table('oauth_refresh_tokens')->where('revoked', false)->update(['revoked' => true]); if (config('session.driver') === 'database') { $source->table(config('session.table', 'sessions'))->delete(); } } return [$created, $linked, $skipped]; })); } 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 $sessionFile) { File::delete($sessionFile->getPathname()); } } $this->info("Created: {$created}; linked: {$linked}; already imported: {$skipped}."); return self::SUCCESS; } }