Files
moonwell-web/app/Console/Commands/ImportAzerothCoreAccounts.php
T
2026-10-04 20:21:28 +04:00

204 lines
12 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Services\AzerothCoreAccountDumpReader;
use App\Services\VMangosSrpService;
use App\Services\WebAccountPasswordService;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use RuntimeException;
class ImportAzerothCoreAccounts extends Command
{
protected $signature = 'vmangos:import-azerothcore-accounts
{dump : Path to acore_auth.sql or acore_auth.sql.gz}
{--database=acore_auth : Source database name inside the dump}
{--dry-run : Validate and show counts without writing}
{--include-bots : Include accounts with the configured bot prefix}';
protected $description = 'Recover web users and vMaNGOS accounts from an AzerothCore auth backup, preserving old login passwords';
public function handle(AzerothCoreAccountDumpReader $reader, WebAccountPasswordService $passwords, VMangosSrpService $gameSrp): int
{
$web = DB::connection();
$auth = DB::connection(config('moonwell.auth_connection'));
$accounts = $levels = $names = $emails = [];
$bots = 0;
$prefix = strtoupper(trim((string) config('moonwell.portal.bot_account_prefix')));
try {
foreach ($reader->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;
}
}