141 lines
7.0 KiB
PHP
141 lines
7.0 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Services\VMangosSrpService;
|
||
use Carbon\CarbonImmutable;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\File;
|
||
use RuntimeException;
|
||
|
||
class ImportWebAccountsToVMangos extends Command
|
||
{
|
||
protected $signature = 'vmangos:import-web-accounts {--dry-run : Show counts without writing} {--include-bots : Import RNDBOT accounts too}';
|
||
|
||
protected $description = 'Create vMaNGOS accounts from web.users with random game passwords; keep web passwords unchanged';
|
||
|
||
public function handle(VMangosSrpService $srp): int
|
||
{
|
||
$source = DB::connection();
|
||
$target = DB::connection(config('moonwell.auth_connection'));
|
||
$query = $source->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;
|
||
}
|
||
}
|