authConnection() ->table('account') ->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex') ->where('id', $accountId) ->first(); if (! $record) { return null; } $gmLevel = $this->accessLevelsFor([$accountId])[$accountId] ?? 0; return $this->mapUser($record, $gmLevel); } public function findUserByUsername(string $username): ?GameAccountUser { $record = $this->authConnection() ->table('account') ->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex') ->where('username', $this->srp->normalizeUsername($username)) ->first(); if (! $record) { return null; } $gmLevel = $this->accessLevelsFor([(int) $record->id])[(int) $record->id] ?? 0; return $this->mapUser($record, $gmLevel); } public function validatePassword(GameAccountUser $user, string $password): bool { return $this->srp->credentialsMatch($user->username, $password, $user->saltHex, $user->verifierHex); } public function updatePassword(GameAccountUser $user, string $newPassword): void { [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($user->username, $newPassword); $this->authConnection()->update( 'UPDATE account SET salt = UNHEX(?), verifier = UNHEX(?), session_key = NULL, failed_logins = 0 WHERE id = ?', [$saltHex, $verifierHex, $user->id], ); } /** * @return \Illuminate\Support\Collection> */ public function listAccounts(?string $search = null, string $accountTab = self::ACCOUNT_TAB_PLAYERS): Collection { $query = $this->baseAccountsQuery($search); $this->applyAccountTabFilter($query, $accountTab); $accounts = $query->get(); $accountIds = $accounts->pluck('id')->map(fn ($id): int => (int) $id)->all(); $accessLevels = $this->accessLevelsFor($accountIds); $characters = $this->charactersForAccountIds($accountIds); return $accounts->map(function (object $account) use ($accessLevels, $characters): array { $accountId = (int) $account->id; $gmLevel = $accessLevels[$accountId] ?? 0; return [ 'id' => $accountId, 'username' => $account->username, 'email' => $account->email, 'reg_mail' => $account->reg_mail, 'gm_level' => $gmLevel, 'access_label' => WowData::securityLevelName($gmLevel), 'locked' => (bool) $account->locked, 'online' => (bool) $account->online, 'joined_at' => $this->formatDate($account->joindate), 'last_login_at' => $this->formatDate($account->last_login), 'characters' => $characters->get($accountId, collect()), ]; }); } /** * @return array{players: int, bots: int} */ public function accountTypeStats(?string $search = null): array { $playersQuery = $this->baseAccountsQuery($search); $botsQuery = $this->baseAccountsQuery($search); $this->applyAccountTabFilter($playersQuery, self::ACCOUNT_TAB_PLAYERS); $this->applyAccountTabFilter($botsQuery, self::ACCOUNT_TAB_BOTS); return [ self::ACCOUNT_TAB_PLAYERS => $playersQuery->count(), self::ACCOUNT_TAB_BOTS => $botsQuery->count(), ]; } /** * @return \Illuminate\Support\Collection> */ public function charactersForAccount(int $accountId): Collection { return $this->charactersForAccountIds([$accountId])->get($accountId, collect()); } public function updateAccessLevel(int $accountId, int $gmLevel): void { $realmId = $this->targetRealmId(); $this->authConnection()->statement( 'INSERT INTO account_access (id, gmlevel, RealmID, comment) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE gmlevel = VALUES(gmlevel), comment = VALUES(comment)', [ $accountId, $gmLevel, $realmId, config('moonwell.portal.access_comment'), ], ); } /** * @param array $accountIds * @return \Illuminate\Support\Collection>> */ private function charactersForAccountIds(array $accountIds): Collection { if ($accountIds === []) { return collect(); } return $this->charactersConnection() ->table('characters') ->select('guid', 'account', 'name', 'race', 'class', 'gender', 'level', 'online', 'money', 'totaltime') ->whereIn('account', $accountIds) ->orderBy('account') ->orderByDesc('level') ->orderBy('name') ->get() ->map(function (object $character): array { return [ 'guid' => (int) $character->guid, 'account' => (int) $character->account, 'name' => $character->name, 'level' => (int) $character->level, 'race' => WowData::raceName((int) $character->race), 'class' => WowData::className((int) $character->class), 'gender' => WowData::genderName((int) $character->gender), 'online' => (bool) $character->online, 'money' => WowData::formatMoney((int) $character->money), 'played' => WowData::formatPlayedTime((int) $character->totaltime), ]; }) ->groupBy('account') ->map(fn (Collection $group): Collection => $group->values()); } /** * @param array $accountIds * @return array */ private function accessLevelsFor(array $accountIds): array { if ($accountIds === []) { return []; } $targetRealmId = $this->targetRealmId(); $realmIds = array_values(array_unique([$targetRealmId, -1])); return $this->authConnection() ->table('account_access') ->select('id', 'gmlevel', 'RealmID') ->whereIn('id', $accountIds) ->whereIn('RealmID', $realmIds) ->get() ->groupBy('id') ->map(function (Collection $rows) use ($targetRealmId): int { $exactMatch = $rows->firstWhere('RealmID', $targetRealmId); return (int) ($exactMatch->gmlevel ?? $rows->firstWhere('RealmID', -1)?->gmlevel ?? 0); }) ->all(); } private function mapUser(object $record, int $gmLevel): GameAccountUser { return new GameAccountUser( id: (int) $record->id, username: $record->username, email: $record->email, regMail: $record->reg_mail, gmLevel: $gmLevel, locked: (bool) $record->locked, lastLoginAt: $record->last_login, joinedAt: $record->joindate, saltHex: strtoupper($record->salt_hex), verifierHex: strtoupper($record->verifier_hex), ); } private function formatDate(?string $value): string { return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—'; } private function baseAccountsQuery(?string $search = null): Builder { $query = $this->authConnection() ->table('account') ->select('id', 'username', 'email', 'reg_mail', 'locked', 'last_login', 'joindate', 'online') ->orderByDesc('id'); if ($search !== null && trim($search) !== '') { $normalizedSearch = $this->srp->normalizeUsername($search); $query->where(function ($builder) use ($normalizedSearch): void { $builder ->where('username', 'like', "%{$normalizedSearch}%") ->orWhere('email', 'like', "%{$normalizedSearch}%") ->orWhere('reg_mail', 'like', "%{$normalizedSearch}%"); }); } return $query; } private function applyAccountTabFilter(Builder $query, string $accountTab): void { $botPrefix = $this->botAccountPrefix(); if ($botPrefix === '') { return; } $pattern = $botPrefix.'%'; if ($accountTab === self::ACCOUNT_TAB_BOTS) { $query->where('username', 'like', $pattern); return; } $query->where('username', 'not like', $pattern); } private function botAccountPrefix(): string { return strtoupper(trim((string) config('moonwell.portal.bot_account_prefix', 'RNDBOT'))); } private function targetRealmId(): int { return (int) config('moonwell.registration.realm_id', -1); } public function onlinePlayerCount(): int|null { try { return (int) $this->charactersConnection() ->table('characters') ->where('online', 1) ->count(); } catch (\Throwable $exception) { report($exception); return null; } } private function authConnection() { return DB::connection(config('moonwell.auth_connection')); } private function charactersConnection() { return DB::connection(config('moonwell.characters_connection')); } }