diff --git a/.env.example b/.env.example index efbc8ec..3caa163 100644 --- a/.env.example +++ b/.env.example @@ -75,64 +75,69 @@ AWS_USE_PATH_STYLE_ENDPOINT=true DATABASE_BACKUP_DISK=s3 DATABASE_BACKUP_PREFIX=backups/databases -DATABASE_BACKUP_ACORE_DATABASES="acore_auth acore_characters acore_world acore_playerbots store" +DATABASE_BACKUP_VMANGOS_DATABASES="realmd characters mangos logs" WWWUSER=1000 WWWGROUP=1000 SAIL_XDEBUG_MODE=off SAIL_XDEBUG_CONFIG="client_host=host.docker.internal" -AZEROTHCORE_AUTH_CONNECTION=azerothcore_auth -AZEROTHCORE_AUTH_DB_HOST=host.docker.internal -AZEROTHCORE_AUTH_DB_PORT=3306 -AZEROTHCORE_AUTH_DB_DATABASE=acore_auth -AZEROTHCORE_AUTH_DB_USERNAME=root -AZEROTHCORE_AUTH_DB_PASSWORD=password -AZEROTHCORE_AUTH_DB_SOCKET= -AZEROTHCORE_AUTH_DB_CHARSET=utf8mb4 -AZEROTHCORE_AUTH_DB_COLLATION=utf8mb4_unicode_ci -AZEROTHCORE_CHARACTERS_CONNECTION=azerothcore_characters -AZEROTHCORE_CHARACTERS_DB_HOST=host.docker.internal -AZEROTHCORE_CHARACTERS_DB_PORT=3306 -AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters -AZEROTHCORE_CHARACTERS_DB_USERNAME=root -AZEROTHCORE_CHARACTERS_DB_PASSWORD=password -AZEROTHCORE_CHARACTERS_DB_SOCKET= -AZEROTHCORE_CHARACTERS_DB_CHARSET=utf8mb4 -AZEROTHCORE_CHARACTERS_DB_COLLATION=utf8mb4_unicode_ci -STORE_DB_HOST=host.docker.internal +VMANGOS_AUTH_CONNECTION=vmangos_auth +VMANGOS_DOCKER_NETWORK=vmangos-local_default +VMANGOS_AUTH_DB_HOST=db +VMANGOS_AUTH_DB_PORT=3306 +VMANGOS_AUTH_DB_DATABASE=realmd +VMANGOS_AUTH_DB_USERNAME=vmangos +VMANGOS_AUTH_DB_PASSWORD=change-me +LEGACY_AUTH_DB_HOST=ac-database +LEGACY_AUTH_DB_PORT=3306 +LEGACY_AUTH_DB_DATABASE=acore_auth +LEGACY_AUTH_DB_USERNAME=root +LEGACY_AUTH_DB_PASSWORD= +VMANGOS_AUTH_DB_SOCKET= +VMANGOS_AUTH_DB_CHARSET=utf8mb4 +VMANGOS_AUTH_DB_COLLATION=utf8mb4_unicode_ci +VMANGOS_CHARACTERS_CONNECTION=vmangos_characters +VMANGOS_CHARACTERS_DB_HOST=db +VMANGOS_CHARACTERS_DB_PORT=3306 +VMANGOS_CHARACTERS_DB_DATABASE=characters +VMANGOS_CHARACTERS_DB_USERNAME=vmangos +VMANGOS_CHARACTERS_DB_PASSWORD=change-me +VMANGOS_CHARACTERS_DB_SOCKET= +VMANGOS_CHARACTERS_DB_CHARSET=utf8mb4 +VMANGOS_CHARACTERS_DB_COLLATION=utf8mb4_unicode_ci +STORE_DB_HOST=mysql STORE_DB_PORT=3306 STORE_DB_DATABASE=store -STORE_DB_USERNAME=root +STORE_DB_USERNAME=sail STORE_DB_PASSWORD=password STORE_DB_SOCKET= -AZEROTHCORE_ACCOUNT_GMLEVEL=0 -AZEROTHCORE_ACCOUNT_REALM_ID=-1 -AZEROTHCORE_ACCOUNT_EXPANSION=2 -AZEROTHCORE_ACCOUNT_ACCESS_COMMENT="registered via moonwell-web" -AZEROTHCORE_ENFORCE_UNIQUE_EMAIL=false +VMANGOS_ACCOUNT_GMLEVEL=0 +VMANGOS_ACCOUNT_REALM_ID=-1 +VMANGOS_ENFORCE_UNIQUE_EMAIL=false MOONWELL_REGISTRATION_REQUIRE_INVITE=true MOONWELL_ADMIN_MIN_GMLEVEL=3 -MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin" MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT +MOONWELL_CLIENT_BUILD=5875 +MOONWELL_GAME_TICKET_TTL=60 MOONWELL_SERVER_NAME=MoonWell MOONWELL_REALM_NAME=MoonWell -MOONWELL_SERVER_TAGLINE="Пройди историю Азерота — от Классики до Короля-лича — в своём темпе, в одиночку или с небольшим отрядом." -MOONWELL_CLIENT_VERSION=3.3.5a -MOONWELL_REALMLIST="set realmlist strictlypragmatic.ru" -MOONWELL_RATE_EXPERIENCE=x3 -MOONWELL_RATE_GOLD=x2 -MOONWELL_RATE_PROFESSION=x2 +MOONWELL_SERVER_TAGLINE="Вернись в классический Азерот — в одиночку или с небольшим отрядом." +MOONWELL_CLIENT_VERSION=1.12.1 +MOONWELL_REALMLIST="set realmlist 127.0.0.1" +MOONWELL_RATE_EXPERIENCE=x1 +MOONWELL_RATE_GOLD=x1 +MOONWELL_RATE_PROFESSION=x1 MOONWELL_RATE_REPUTATION=x1 MOONWELL_CLIENT_DISK=s3 -MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip" +MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft 1.12.1.zip" +MOONWELL_LAUNCHER_BASE_PATH="World of Warcraft 1.12.1" +MOONWELL_LAUNCHER_MANIFEST_KEY=manifest-1.12.1.json MOONWELL_CLIENT_URL_TTL=30 -MOONWELL_REALM_STATUS_HOST=play.moon-well.online +MOONWELL_REALM_STATUS_HOST=mangosd MOONWELL_REALM_STATUS_TIMEOUT=0.5 MOONWELL_REALM_STATUS_CACHE=15 -MOONWELL_LAUNCHER_GAME_TICKET_TTL=60 -MOONWELL_CLIENT_BUILD=12340 VITE_APP_NAME="${APP_NAME}" diff --git a/README.md b/README.md index 23c8eee..e4d27e2 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,64 @@ -# Moonwell Web +# MoonWell Web -Laravel 12-приложение с WoW-лендингом и публичной регистрацией игрового аккаунта напрямую в auth-базу AzerothCore. +Laravel 12 сайт для World of Warcraft 1.12.1 на vMaNGOS (`/home/sindo/core`). Игровые аккаунты и права хранятся в базе `realmd`, персонажи — в `characters`. Сайт хранит свои данные в отдельной базе Laravel. -## Что уже сделано +## Подключение к локальному vMaNGOS -- Развёрнут Laravel `v12.12.1` со свежим `laravel/framework v12.x`. -- Главная страница оформлена в тёмной cinematic-стилистике с компоновкой, вдохновлённой официальным сайтом WoW. -- Форма регистрации создаёт аккаунт напрямую в `acore_auth` через отдельное соединение `azerothcore_auth`. -- При регистрации заполняются таблицы `account`, `realmcharacters` и `account_access`. -- Регистрация возможна только по инвайт-коду, который хранится в базе сайта. -- Добавлены личный кабинет и админка поверх игровых аккаунтов из auth-базы. -- В кабинете доступны смена пароля, список персонажей и ссылка на клиент игры из S3. -- В админке доступны просмотр всех аккаунтов, их персонажей, смена `gmlevel` и выпуск инвайт-кодов. -- В админке доступны новости для логин-экрана WoW 3.3.5a с публичным `SERVERALERT`-фидом для клиента. -- Проект подготовлен под Laravel Sail: приложение работает в контейнере, а AzerothCore auth DB берётся с хоста через `host.docker.internal:3306`. +Локальный стенд ядра описан в `/home/sindo/core/deploy/local/README.md`. Сначала запустите его. Контейнер сайта подключается к сети `vmangos-local_default` и обращается к MariaDB как `db:3306`. Пароль пользователя `vmangos` находится в `/home/sindo/core/deploy/local/secrets/db_password`; скопируйте его в `VMANGOS_AUTH_DB_PASSWORD` и `VMANGOS_CHARACTERS_DB_PASSWORD` своего `.env`. Текущий локальный `.env` сайта уже настроен на этот стенд. -## Как запускать через Sail +```dotenv +VMANGOS_DOCKER_NETWORK=vmangos-local_default +VMANGOS_AUTH_DB_HOST=db +VMANGOS_AUTH_DB_PORT=3306 +VMANGOS_AUTH_DB_DATABASE=realmd +VMANGOS_AUTH_DB_USERNAME=vmangos +VMANGOS_CHARACTERS_DB_HOST=db +VMANGOS_CHARACTERS_DB_PORT=3306 +VMANGOS_CHARACTERS_DB_DATABASE=characters +VMANGOS_CHARACTERS_DB_USERNAME=vmangos +MOONWELL_CLIENT_VERSION=1.12.1 +``` + +Для PHP, запущенного прямо на хосте, укажите `127.0.0.1:13306` вместо `db:3306`. В продакшене настройте `VMANGOS_DOCKER_NETWORK=vmangos_default` и учётные данные продакшен-БД vMaNGOS. Контейнер приложения присоединяется к указанной сети автоматически. + +## Запуск сайта ```bash -cp .env.example .env ./vendor/bin/sail up -d -./vendor/bin/sail composer install -./vendor/bin/sail artisan key:generate -./vendor/bin/sail artisan migrate +./vendor/bin/sail artisan migrate --force +npm ci +npm run build ``` -Сайт будет доступен на `http://localhost:8080`. +Сайт доступен на `http://localhost:8080`, если `APP_PORT=8080`. В Laravel остаются отдельные настройки `DB_*` для данных сайта. После смены `.env` выполните `./vendor/bin/sail artisan config:clear`. Скрипты продакшен-деплоя находятся в `scripts/deploy-production.sh` и `scripts/deploy-ubuntu.sh`. -## Почта техподдержки +## Игровые функции -Для собственного ящика `support@moon-well.online` подготовлен лёгкий -Stalwart-сервер с SMTP/IMAP, TLS, DKIM и антиспамом. Инструкция по первому -запуску, DNS и подключению Laravel находится в -[`docs/mail-server.md`](docs/mail-server.md). +- Регистрация создаёт случайный постоянный игровой пароль в `account.s`/`account.v`, SRP-данные пароля сайта в `site_credentials`, запись в `realmcharacters` для каждого реалма и доступ в `account_access`. +- Вход на сайт и через API лаунчера сверяет пароль с `site_credentials`. Смена пароля в кабинете обновляет только эти данные. +- Админка меняет уровень доступа в `account_access`. Для чтения персонажей используется `characters.played_time_total`. +- Список реалмов берётся из `realmd.realmlist`; доступность определяется проверкой игрового TCP-порта. +- Игровой клиент: 1.12.1, build 5875. Лаунчер получает одноразовый билет через `POST /api/launcher/game-ticket` с Bearer-токеном и `{"client_build":5875}` и передаёт возвращённые `account` и `ticket` клиенту как логин и пароль. Билет действителен не более 60 секунд; новый билет отзывает предыдущий. Модифицированный `realmd` из `/home/sindo/core` допускает вход в игру только с активным билетом. -## Деплой на Ubuntu - -Скрипт деплоя лежит в `scripts/deploy-ubuntu.sh`. Он рассчитан на сервер, где уже установлены: - -- `docker` -- `docker compose` - -Перед первым запуском проверь `.env`: - -- `APP_URL` -- `APP_PORT` -- `FORWARD_DB_PORT` -- `AZEROTHCORE_AUTH_DB_HOST` -- `AZEROTHCORE_AUTH_DB_PORT` -- `AZEROTHCORE_AUTH_DB_DATABASE` -- `AZEROTHCORE_AUTH_DB_USERNAME` -- `AZEROTHCORE_AUTH_DB_PASSWORD` - -Запуск: +Для импорта аккаунтов прежнего сайта из AzerothCore настройте `LEGACY_AUTH_DB_*`, затем выполните: ```bash -chmod +x scripts/deploy-ubuntu.sh -./scripts/deploy-ubuntu.sh +./vendor/bin/sail artisan migrate --force +./vendor/bin/sail artisan vmangos:import-web-accounts --dry-run +./vendor/bin/sail artisan vmangos:import-web-accounts ``` -Что делает скрипт: +Команда переносит веб-верификаторы без знания исходных паролей, создаёт игровые аккаунты со случайными паролями и при повторном запуске пропускает уже перенесённых пользователей. При первом импорте она отзывает прежние API-токены и сессии сайта, поскольку прежние ID аккаунтов могут указывать на другого пользователя. Аккаунты `RNDBOT` пропускаются; для их переноса есть `--include-bots`. Перед запуском `realmd` примените миграцию `/home/sindo/core/sql/migrations/20261003120000_logon.sql` к `realmd` и пересоберите ядро. -- создаёт `.env` из `.env.example`, если файла ещё нет; -- собирает контейнер приложения; -- ставит production-зависимости Composer; -- генерирует `APP_KEY`, если он пустой; -- поднимает контейнеры; -- ждёт готовности MySQL; -- выполняет `php artisan migrate --force`; -- прогревает `config`, `route` и `view` кэш. +Магазин по-прежнему использует отдельную базу `store` через `STORE_DB_*`. Эти таблицы не входят в vMaNGOS. Игровая валюта сайта хранится в дополнительных таблицах `realmd` после миграции Laravel; для работы внутри игры нужна отдельная интеграция ядра с API из [`docs/game-balance-integration.md`](docs/game-balance-integration.md). -Полезные флаги: - -```bash -./scripts/deploy-ubuntu.sh --skip-build -./scripts/deploy-ubuntu.sh --skip-cache -``` - -## Production без Sail - -Для production используется отдельный стек `compose.prod.yaml`: - -- `web` — непривилегированный nginx со статикой; -- `app` — PHP-FPM с кодом и Composer-зависимостями внутри образа; -- `mysql` — постоянная база на томе `sail-mysql`. - -Node.js, Playwright, Xdebug, dev-зависимости Composer и bind-mount исходного кода в production-образы не входят. - -```bash -chmod +x scripts/deploy-production.sh -./scripts/deploy-production.sh -``` - -Скрипт сначала собирает и проверяет новый PHP-FPM, выполняет миграции и прогрев кэша, а затем переключает порт с legacy Sail-контейнера на production nginx. Если HTTP-проверка не проходит, legacy-контейнер запускается обратно. - -## Как это связано с AzerothCore - -По умолчанию Sail-контейнер подключается к auth-базе core-сервера так: - -- хост: `host.docker.internal` -- порт: `3306` -- база: `acore_auth` -- пользователь: `root` -- пароль: `password` - -Это рассчитано на вашу docker-сборку из `/home/sindo/moonwell-core`, где MySQL опубликован наружу на порт `3306`. - -## Важные переменные - -Основные настройки лежат в `.env.example`: - -- `APP_PORT=8080` -- `FORWARD_DB_PORT=3307` -- `DB_HOST=mysql` -- `AZEROTHCORE_AUTH_DB_HOST=host.docker.internal` -- `AZEROTHCORE_AUTH_DB_PORT=3306` -- `AZEROTHCORE_AUTH_DB_DATABASE=acore_auth` -- `AZEROTHCORE_AUTH_DB_USERNAME=root` -- `AZEROTHCORE_AUTH_DB_PASSWORD=password` -- `AZEROTHCORE_CHARACTERS_DB_DATABASE=acore_characters` -- `MOONWELL_ADMIN_MIN_GMLEVEL=3` -- `MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT` -- `MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip"` -- `AWS_ENDPOINT=https://storage.yandexcloud.net` -- `AWS_BUCKET=warcraft-client` - -## Архитектура - -- `compose.yaml` — Sail-окружение с `laravel.test` и локальным MySQL для самого сайта. -- `config/database.php` — отдельное соединение `azerothcore_auth`. -- `config/moonwell.php` — настройки realm/landing и регистрации. -- `app/Auth/AzerothCoreUserProvider.php` — Laravel-auth поверх игровых аккаунтов. -- `app/Services/AzerothCoreAccountService.php` — работа с аккаунтами, персонажами, паролями и `gmlevel`. -- `app/Services/GameClientDownloadService.php` — генерация ссылки на клиент из S3. -- `app/Services/LoginScreenNewsService.php` — сборка plain-text фида для логин-экрана WoW. -- `app/Services/AzerothCoreAccountRegistrar.php` — SRP6-совместимое создание учётной записи AzerothCore. -- `app/Http/Controllers/LandingController.php` — данные лендинга и обработка формы. - -## Новости логин-экрана WoW - -- Админский раздел: `/admin/login-screen-news` -- Публичный фид для клиента: `/launcher/login-news.txt` -- Сайт сам добавляет префикс `SERVERALERT:` и склеивает опубликованные записи по `sort_order`. +Перед выдачей клиента загрузите архив 1.12.1 в S3 и укажите его ключ в `MOONWELL_CLIENT_OBJECT_KEY`. Для обновлений лаунчера разместите файлы под `MOONWELL_LAUNCHER_BASE_PATH` и новый манифест по ключу `MOONWELL_LAUNCHER_MANIFEST_KEY`. Старые файлы клиента 3.3.5a для vMaNGOS не подходят. ## Проверка -Вне Sail я уже проверил: - -- рендер лендинга по HTTP; -- компиляцию Blade; -- маршруты; -- ручную валидацию `POST /register`; -- совпадение SRP6 verifier с формулой AzerothCore. - -Для полноценного `php artisan test` внутри контейнера используйте: - ```bash ./vendor/bin/sail artisan test +npm run build ``` + +Тесты включают контрольный SRP6-вектор из `src/shared/Crypto/InitializeCrypto.cpp` ядра vMaNGOS, регистрацию, авторизацию, смену пароля, права и чтение персонажей. Локальное подключение к запущенным `realmd` и `characters` проверено отдельно. diff --git a/app/Auth/AzerothCoreUserProvider.php b/app/Auth/VMangosUserProvider.php similarity index 88% rename from app/Auth/AzerothCoreUserProvider.php rename to app/Auth/VMangosUserProvider.php index 034cc5b..e99608f 100644 --- a/app/Auth/AzerothCoreUserProvider.php +++ b/app/Auth/VMangosUserProvider.php @@ -3,13 +3,13 @@ namespace App\Auth; use App\Models\GameAccountUser; -use App\Services\AzerothCoreAccountService; +use App\Services\VMangosAccountService; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\UserProvider; -class AzerothCoreUserProvider implements UserProvider +class VMangosUserProvider implements UserProvider { - public function __construct(private readonly AzerothCoreAccountService $accounts) + public function __construct(private readonly VMangosAccountService $accounts) { } diff --git a/app/Console/Commands/ImportWebAccountsToVMangos.php b/app/Console/Commands/ImportWebAccountsToVMangos.php new file mode 100644 index 0000000..b6f2973 --- /dev/null +++ b/app/Console/Commands/ImportWebAccountsToVMangos.php @@ -0,0 +1,118 @@ +table('account')->selectRaw('id, username, email, locked, joindate, HEX(salt) AS salt_hex, HEX(verifier) AS verifier_hex')->orderBy('id'); + + if (! $this->option('include-bots')) { + $query->where('username', 'not like', config('moonwell.portal.bot_account_prefix').'%'); + } + + $created = 0; + $linked = 0; + $skipped = 0; + foreach ($query->cursor() as $legacy) { + $username = $srp->normalizeUsername($legacy->username); + if (preg_match('/^[A-Z0-9]{3,16}$/D', $username) !== 1) { + throw new RuntimeException("Unsupported legacy account name for ID {$legacy->id}."); + } + + $existing = $target->table('account')->where('username', $username)->first(['id']); + $credential = $existing ? $target->table('site_credentials')->where('account_id', $existing->id)->first() : null; + if ($credential) { + if ((int) $credential->legacy_account_id !== (int) $legacy->id) { + throw new RuntimeException("Account name conflict for legacy ID {$legacy->id}."); + } + $skipped++; + continue; + } + + if ($this->option('dry-run')) { + $existing ? $linked++ : $created++; + continue; + } + + $target->transaction(function () use ($target, $source, $srp, $legacy, $username, $existing): void { + // Only the SRP verifier is stored; the random plaintext is discarded. + [$gameSalt, $gameVerifier] = $srp->makeRegistrationData($username, bin2hex(random_bytes(16))); + if ($existing) { + $accountId = (int) $existing->id; + $target->table('account')->where('id', $accountId)->update([ + 's' => $gameSalt, + 'v' => $gameVerifier, + 'sessionkey' => null, + 'email' => $legacy->email, + 'locked' => (int) $legacy->locked, + ]); + } else { + $accountId = (int) $target->table('account')->insertGetId([ + 'username' => $username, + 's' => $gameSalt, + 'v' => $gameVerifier, + 'email' => $legacy->email, + 'locked' => (int) $legacy->locked, + 'expansion' => 0, + 'joindate' => $legacy->joindate, + ]); + } + + $target->table('site_credentials')->insert([ + 'account_id' => $accountId, + 'legacy_account_id' => (int) $legacy->id, + 'salt_hex' => $legacy->salt_hex, + 'verifier_hex' => $legacy->verifier_hex, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + foreach ($target->table('realmlist')->pluck('id') as $realmId) { + $target->table('realmcharacters')->insertOrIgnore(['realmid' => (int) $realmId, 'acctid' => $accountId, 'numchars' => 0]); + } + + $gmLevel = (int) $source->table('account_access')->where('id', $legacy->id)->max('gmlevel'); + if ($gmLevel > 0) { + $target->table('account_access')->updateOrInsert( + ['id' => $accountId, 'RealmID' => -1], + ['gmlevel' => $gmLevel], + ); + } + }, 3); + + $existing ? $linked++ : $created++; + } + + if (! $this->option('dry-run') && ($created + $linked) > 0) { + // Old bearer tokens and sessions contain AzerothCore IDs, which may + // now identify a different vMaNGOS account. Force fresh login. + DB::table('oauth_access_tokens')->where('revoked', false)->update(['revoked' => true]); + DB::table('oauth_refresh_tokens')->where('revoked', false)->update(['revoked' => true]); + if (config('session.driver') === 'database') { + DB::table(config('session.table', 'sessions'))->delete(); + } elseif (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; + } +} diff --git a/app/Http/Controllers/Admin/AccountController.php b/app/Http/Controllers/Admin/AccountController.php index 501def1..64da324 100644 --- a/app/Http/Controllers/Admin/AccountController.php +++ b/app/Http/Controllers/Admin/AccountController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\BuildsPortalProps; use App\Http\Controllers\Controller; use App\Http\Requests\UpdateAccountAccessRequest; -use App\Services\AzerothCoreAccountService; +use App\Services\VMangosAccountService; use App\Services\InviteCodeService; use App\Support\WowData; use Illuminate\Http\RedirectResponse; @@ -19,15 +19,15 @@ class AccountController extends Controller public function index( Request $request, - AzerothCoreAccountService $accounts, + VMangosAccountService $accounts, InviteCodeService $inviteCodes, ): Response { $search = trim((string) $request->query('search', '')); $searchTerm = $search !== '' ? $search : null; - $activeTab = (string) $request->query('tab', AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS); + $activeTab = (string) $request->query('tab', VMangosAccountService::ACCOUNT_TAB_PLAYERS); - if (! in_array($activeTab, [AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS, AzerothCoreAccountService::ACCOUNT_TAB_BOTS], true)) { - $activeTab = AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS; + if (! in_array($activeTab, [VMangosAccountService::ACCOUNT_TAB_PLAYERS, VMangosAccountService::ACCOUNT_TAB_BOTS], true)) { + $activeTab = VMangosAccountService::ACCOUNT_TAB_PLAYERS; } $accountStats = $accounts->accountTypeStats($searchTerm); @@ -60,13 +60,13 @@ class AccountController extends Controller 'accounts' => $accountItems, 'accountStats' => $accountStats, 'accountTabs' => [ - AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS => [ + VMangosAccountService::ACCOUNT_TAB_PLAYERS => [ 'label' => 'Игроки', - 'count' => $accountStats[AzerothCoreAccountService::ACCOUNT_TAB_PLAYERS] ?? 0, + 'count' => $accountStats[VMangosAccountService::ACCOUNT_TAB_PLAYERS] ?? 0, ], - AzerothCoreAccountService::ACCOUNT_TAB_BOTS => [ + VMangosAccountService::ACCOUNT_TAB_BOTS => [ 'label' => 'Боты', - 'count' => $accountStats[AzerothCoreAccountService::ACCOUNT_TAB_BOTS] ?? 0, + 'count' => $accountStats[VMangosAccountService::ACCOUNT_TAB_BOTS] ?? 0, ], ], 'activeTab' => $activeTab, @@ -85,7 +85,7 @@ class AccountController extends Controller ]); } - public function updateAccess(int $accountId, UpdateAccountAccessRequest $request, AzerothCoreAccountService $accounts): RedirectResponse + public function updateAccess(int $accountId, UpdateAccountAccessRequest $request, VMangosAccountService $accounts): RedirectResponse { abort_unless($accounts->findUserById($accountId), 404); diff --git a/app/Http/Controllers/Admin/BalanceController.php b/app/Http/Controllers/Admin/BalanceController.php index d2da9ac..9bd09b9 100644 --- a/app/Http/Controllers/Admin/BalanceController.php +++ b/app/Http/Controllers/Admin/BalanceController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\BuildsPortalProps; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\AdjustBalanceRequest; -use App\Services\AzerothCoreAccountService; +use App\Services\VMangosAccountService; use App\Services\BalanceService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -67,7 +67,7 @@ class BalanceController extends Controller public function adjust( AdjustBalanceRequest $request, - AzerothCoreAccountService $accounts, + VMangosAccountService $accounts, BalanceService $balances, ): RedirectResponse { $accountInput = trim($request->string('account')->toString()); diff --git a/app/Http/Controllers/Api/LauncherAuthController.php b/app/Http/Controllers/Api/LauncherAuthController.php index df736f8..a50b828 100644 --- a/app/Http/Controllers/Api/LauncherAuthController.php +++ b/app/Http/Controllers/Api/LauncherAuthController.php @@ -5,29 +5,29 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\LoginRequest; use App\Models\GameAccount; -use App\Services\AzerothCoreAccountService; +use App\Services\VMangosAccountService; use Illuminate\Http\JsonResponse; use OpenApi\Attributes as OA; class LauncherAuthController extends Controller { public function __construct( - private readonly AzerothCoreAccountService $accounts, + private readonly VMangosAccountService $accounts, ) { } #[OA\Post( path: '/api/launcher/login', summary: 'Авторизация лаунчера', - description: 'Авторизация по логину и паролю игрового аккаунта. Возвращает Bearer-токен для доступа к API.', + description: 'Авторизация по логину и паролю сайта. Возвращает Bearer-токен для доступа к API и выдачи игрового билета.', tags: ['Launcher Auth'], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['username', 'password'], properties: [ - new OA\Property(property: 'username', type: 'string', minLength: 3, maxLength: 32, pattern: '^[A-Za-z0-9]+$', example: 'Player'), - new OA\Property(property: 'password', type: 'string', minLength: 3, maxLength: 32, example: 'secret123'), + new OA\Property(property: 'username', type: 'string', minLength: 3, maxLength: 16, pattern: '^[A-Za-z0-9]+$', example: 'Player'), + new OA\Property(property: 'password', type: 'string', minLength: 3, maxLength: 16, example: 'secret123'), ], ), ), @@ -75,7 +75,7 @@ class LauncherAuthController extends Controller ], 401); } - $gameAccount = GameAccount::on('azerothcore_auth')->findOrFail($user->id); + $gameAccount = GameAccount::on('vmangos_auth')->findOrFail($user->id); $token = $gameAccount->createToken('launcher'); return response()->json([ diff --git a/app/Http/Controllers/Api/LauncherController.php b/app/Http/Controllers/Api/LauncherController.php index ccd56ce..37fb06f 100644 --- a/app/Http/Controllers/Api/LauncherController.php +++ b/app/Http/Controllers/Api/LauncherController.php @@ -211,6 +211,6 @@ class LauncherController extends Controller private function basePath(): string { - return config('moonwell.launcher.base_path', 'World of Warcraft'); + return config('moonwell.launcher.base_path', 'World of Warcraft 1.12.1'); } } diff --git a/app/Http/Controllers/Api/LauncherGameTicketController.php b/app/Http/Controllers/Api/LauncherGameTicketController.php index b4e51d9..41c61cd 100644 --- a/app/Http/Controllers/Api/LauncherGameTicketController.php +++ b/app/Http/Controllers/Api/LauncherGameTicketController.php @@ -16,55 +16,41 @@ class LauncherGameTicketController extends Controller #[OA\Post( path: '/api/launcher/game-ticket', - summary: 'Issue a single-use game login ticket', - description: 'Creates short-lived SRP credentials for authserver. A new ticket atomically revokes the previous generation. The plaintext ticket is never stored.', + summary: 'Выдать одноразовый билет для входа в vMaNGOS', + description: 'Создаёт короткоживущий SRP-билет. Новый билет отзывает предыдущий.', security: [['bearerAuth' => []]], tags: ['Launcher Auth'], - requestBody: new OA\RequestBody( - required: true, - content: new OA\JsonContent( - required: ['client_build'], - properties: [ - new OA\Property(property: 'client_build', type: 'integer', example: 12340), - ], - ), - ), + requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent( + required: ['client_build'], + properties: [new OA\Property(property: 'client_build', type: 'integer', example: 5875)], + )), responses: [ - new OA\Response( - response: 200, - description: 'Ticket issued', - content: new OA\JsonContent( - required: ['account', 'ticket', 'expires_at'], - properties: [ - new OA\Property(property: 'account', type: 'string', example: 'PLAYERONE'), - new OA\Property(property: 'ticket', type: 'string', pattern: '^[A-Z0-9]{16}$', example: '7W6D4M2Q9K8R3X5Z'), - new OA\Property(property: 'expires_at', type: 'string', format: 'date-time'), - ], - ), - ), + new OA\Response(response: 200, description: 'Билет выдан', content: new OA\JsonContent( + required: ['account', 'ticket', 'expires_at'], + properties: [ + new OA\Property(property: 'account', type: 'string', example: 'PLAYERONE'), + new OA\Property(property: 'ticket', type: 'string', pattern: '^[A-Z0-9]{16}$'), + new OA\Property(property: 'expires_at', type: 'string', format: 'date-time'), + ], + )), new OA\Response(response: 401, description: 'Launcher authentication required'), - new OA\Response(response: 422, description: 'Client build is not allowed'), + new OA\Response(response: 422, description: 'Unsupported client build'), new OA\Response(response: 429, description: 'Too many requests'), ], )] public function store(Request $request): JsonResponse { - $clientBuild = (int) $request->validate([ - 'client_build' => [ - 'required', - 'integer', - Rule::in([(int) config('moonwell.launcher.client_build', 12340)]), - ], + $build = (int) $request->validate([ + 'client_build' => ['required', 'integer', Rule::in([(int) config('moonwell.launcher.client_build')])], ])['client_build']; /** @var GameAccount $account */ $account = $request->user(); - $launcherSessionToken = $request->bearerToken(); - - abort_if($launcherSessionToken === null, 401); + $bearer = $request->bearerToken(); + abort_if($bearer === null, 401); return response() - ->json($this->tickets->issue($account, $launcherSessionToken, $clientBuild)) + ->json($this->tickets->issue($account, $bearer, $build)) ->header('Cache-Control', 'no-store'); } } diff --git a/app/Http/Controllers/Api/LauncherRegistrationController.php b/app/Http/Controllers/Api/LauncherRegistrationController.php index e1aef2f..256f1c7 100644 --- a/app/Http/Controllers/Api/LauncherRegistrationController.php +++ b/app/Http/Controllers/Api/LauncherRegistrationController.php @@ -6,7 +6,7 @@ use App\Exceptions\DuplicateGameAccountException; use App\Exceptions\InviteCodeException; use App\Http\Controllers\Controller; use App\Http\Requests\RegisterGameAccountRequest; -use App\Services\AzerothCoreAccountRegistrar; +use App\Services\VMangosAccountRegistrar; use App\Services\InviteCodeService; use Illuminate\Http\JsonResponse; use OpenApi\Attributes as OA; @@ -17,18 +17,18 @@ class LauncherRegistrationController extends Controller #[OA\Post( path: '/api/launcher/register', summary: 'Зарегистрировать игровой аккаунт', - description: 'Создаёт игровой аккаунт AzerothCore. Инвайт-код обязателен, если на сервере включена регистрация по приглашениям.', + description: 'Создаёт аккаунт сайта и vMaNGOS со случайным игровым паролем. Для входа в игру лаунчер выдаёт билет. Инвайт-код обязателен, если включена регистрация по приглашениям.', tags: ['Launcher Auth'], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['username', 'email', 'password', 'password_confirmation', 'terms'], properties: [ - new OA\Property(property: 'username', type: 'string', minLength: 3, maxLength: 32, pattern: '^[A-Za-z0-9]+$', example: 'Player'), + new OA\Property(property: 'username', type: 'string', minLength: 3, maxLength: 16, pattern: '^[A-Za-z0-9]+$', example: 'Player'), new OA\Property(property: 'email', type: 'string', format: 'email', maxLength: 255, example: 'player@example.com'), new OA\Property(property: 'invite_code', type: 'string', minLength: 6, maxLength: 32, nullable: true, example: 'ABCD-EFGH-IJKL'), - new OA\Property(property: 'password', type: 'string', minLength: 8, maxLength: 32, example: 'Secret123'), - new OA\Property(property: 'password_confirmation', type: 'string', minLength: 8, maxLength: 32, example: 'Secret123'), + new OA\Property(property: 'password', type: 'string', minLength: 8, maxLength: 16, example: 'Secret123'), + new OA\Property(property: 'password_confirmation', type: 'string', minLength: 8, maxLength: 16, example: 'Secret123'), new OA\Property(property: 'terms', type: 'boolean', example: true, description: 'Согласие с правилами сервера и пользовательским соглашением'), ], ), @@ -82,7 +82,7 @@ class LauncherRegistrationController extends Controller )] public function store( RegisterGameAccountRequest $request, - AzerothCoreAccountRegistrar $registrar, + VMangosAccountRegistrar $registrar, InviteCodeService $inviteCodes, ): JsonResponse { try { diff --git a/app/Http/Controllers/CabinetController.php b/app/Http/Controllers/CabinetController.php index d3773a3..655537a 100644 --- a/app/Http/Controllers/CabinetController.php +++ b/app/Http/Controllers/CabinetController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers; use App\Http\Requests\ChangePasswordRequest; -use App\Services\AzerothCoreAccountService; +use App\Services\VMangosAccountService; use App\Services\BalanceService; use App\Services\GameClientDownloadService; use App\Services\StoreAvailabilityService; @@ -17,7 +17,7 @@ class CabinetController extends Controller { public function index( Request $request, - AzerothCoreAccountService $accounts, + VMangosAccountService $accounts, BalanceService $balances, StoreAvailabilityService $storeAvailability, ): Response @@ -83,7 +83,7 @@ class CabinetController extends Controller ]); } - public function updatePassword(ChangePasswordRequest $request, AzerothCoreAccountService $accounts): RedirectResponse + public function updatePassword(ChangePasswordRequest $request, VMangosAccountService $accounts): RedirectResponse { $user = $request->user(); diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 28e254a..93ed53d 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -6,8 +6,8 @@ use App\Exceptions\DuplicateGameAccountException; use App\Exceptions\InviteCodeException; use App\Http\Requests\RegisterGameAccountRequest; use App\Models\News; -use App\Services\AzerothCoreAccountRegistrar; -use App\Services\AzerothCoreAccountService; +use App\Services\VMangosAccountRegistrar; +use App\Services\VMangosAccountService; use App\Services\InviteCodeService; use App\Services\NewsService; use Illuminate\Http\RedirectResponse; @@ -22,7 +22,7 @@ class LandingController extends Controller { public function index( Request $request, - AzerothCoreAccountService $accounts, + VMangosAccountService $accounts, NewsService $news, ): Response { $realmConfig = config('moonwell.realm'); @@ -68,7 +68,7 @@ class LandingController extends Controller 'ping' => 38, 'factions' => ['alliance' => 52, 'horde' => 48], 'rates' => $rates, - 'client_version' => $realmConfig['client_version'] ?? '3.3.5a', + 'client_version' => $realmConfig['client_version'] ?? '1.12.1', 'tagline' => $realmConfig['tagline'] ?? null, 'server_name' => $serverName, ], @@ -112,7 +112,7 @@ class LandingController extends Controller public function register( RegisterGameAccountRequest $request, - AzerothCoreAccountRegistrar $registrar, + VMangosAccountRegistrar $registrar, InviteCodeService $inviteCodes, ): RedirectResponse { try { diff --git a/app/Http/Controllers/MainController.php b/app/Http/Controllers/MainController.php index b756d4d..eaa7803 100644 --- a/app/Http/Controllers/MainController.php +++ b/app/Http/Controllers/MainController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers; use App\Models\News; -use App\Services\AzerothCoreAccountService; +use App\Services\VMangosAccountService; use App\Services\NewsService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -16,7 +16,7 @@ class MainController extends Controller { public function index( Request $request, - AzerothCoreAccountService $accounts, + VMangosAccountService $accounts, NewsService $news, ): Response { @@ -56,7 +56,7 @@ class MainController extends Controller 'ping' => 38, 'factions' => ['alliance' => 52, 'horde' => 48], 'rates' => $rates, - 'client_version' => $realmConfig['client_version'] ?? '3.3.5a', + 'client_version' => $realmConfig['client_version'] ?? '1.12.1', 'tagline' => $realmConfig['tagline'] ?? null, 'server_name' => $realmConfig['server_name'] ?? 'MoonWell', ], diff --git a/app/Http/Requests/ChangePasswordRequest.php b/app/Http/Requests/ChangePasswordRequest.php index e11dffd..e77cdc7 100644 --- a/app/Http/Requests/ChangePasswordRequest.php +++ b/app/Http/Requests/ChangePasswordRequest.php @@ -17,8 +17,8 @@ class ChangePasswordRequest extends FormRequest public function rules(): array { return [ - 'current_password' => ['required', 'string', 'max:32'], - 'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], + 'current_password' => ['required', 'string', 'max:16'], + 'password' => ['required', 'string', 'min:8', 'max:16', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], ]; } @@ -29,10 +29,10 @@ class ChangePasswordRequest extends FormRequest { return [ 'current_password.required' => 'Укажи текущий пароль.', - 'current_password.max' => 'Текущий пароль не должен быть длиннее 32 символов.', + 'current_password.max' => 'Текущий пароль не должен быть длиннее 16 символов.', 'password.required' => 'Укажи новый пароль.', 'password.min' => 'Новый пароль должен содержать минимум 8 символов.', - 'password.max' => 'Новый пароль не должен быть длиннее 32 символов.', + 'password.max' => 'Новый пароль не должен быть длиннее 16 символов.', 'password.confirmed' => 'Подтверждение нового пароля не совпадает.', 'password.regex' => 'Новый пароль должен содержать хотя бы одну букву и одну цифру.', ]; diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php index 668866e..e757504 100644 --- a/app/Http/Requests/LoginRequest.php +++ b/app/Http/Requests/LoginRequest.php @@ -17,8 +17,8 @@ class LoginRequest extends FormRequest public function rules(): array { return [ - 'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'], - 'password' => ['required', 'string', 'min:3', 'max:32'], + 'username' => ['required', 'string', 'min:3', 'max:16', 'regex:/^[A-Za-z0-9]+$/'], + 'password' => ['required', 'string', 'min:3', 'max:16'], ]; } @@ -30,11 +30,11 @@ class LoginRequest extends FormRequest return [ 'username.required' => 'Укажи логин аккаунта.', 'username.min' => 'Логин должен содержать минимум 3 символа.', - 'username.max' => 'Логин не должен быть длиннее 32 символов.', + 'username.max' => 'Логин не должен быть длиннее 16 символов.', 'username.regex' => 'Логин может содержать только латинские буквы и цифры.', 'password.required' => 'Укажи пароль.', 'password.min' => 'Пароль должен содержать минимум 3 символа.', - 'password.max' => 'Пароль не должен быть длиннее 32 символов.', + 'password.max' => 'Пароль не должен быть длиннее 16 символов.', ]; } } diff --git a/app/Http/Requests/RegisterGameAccountRequest.php b/app/Http/Requests/RegisterGameAccountRequest.php index 50a8b3f..ea759a7 100644 --- a/app/Http/Requests/RegisterGameAccountRequest.php +++ b/app/Http/Requests/RegisterGameAccountRequest.php @@ -17,10 +17,10 @@ class RegisterGameAccountRequest extends FormRequest public function rules(): array { return [ - 'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'], + 'username' => ['required', 'string', 'min:3', 'max:16', 'regex:/^[A-Za-z0-9]+$/'], 'email' => ['required', 'string', 'email:rfc', 'max:255'], 'invite_code' => [config('moonwell.registration.require_invite_code') ? 'required' : 'nullable', 'string', 'min:6', 'max:32'], - 'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], + 'password' => ['required', 'string', 'min:8', 'max:16', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'], 'terms' => ['accepted'], ]; } @@ -34,7 +34,7 @@ class RegisterGameAccountRequest extends FormRequest 'username.required' => 'Укажи логин аккаунта.', 'username.string' => 'Логин должен быть строкой.', 'username.min' => 'Логин должен содержать минимум 3 символа.', - 'username.max' => 'Логин не должен быть длиннее 32 символов.', + 'username.max' => 'Логин не должен быть длиннее 16 символов.', 'username.regex' => 'Логин может содержать только латинские буквы и цифры.', 'email.required' => 'Укажи email для аккаунта.', 'email.string' => 'Email должен быть строкой.', @@ -47,7 +47,7 @@ class RegisterGameAccountRequest extends FormRequest 'password.required' => 'Укажи пароль.', 'password.string' => 'Пароль должен быть строкой.', 'password.min' => 'Пароль должен содержать минимум 8 символов.', - 'password.max' => 'Пароль не должен быть длиннее 32 символов.', + 'password.max' => 'Пароль не должен быть длиннее 16 символов.', 'password.confirmed' => 'Подтверждение пароля не совпадает.', 'password.regex' => 'Пароль должен содержать хотя бы одну букву и одну цифру.', 'terms.accepted' => 'Нужно принять правила сервера и пользовательское соглашение.', diff --git a/app/Models/BalanceTransaction.php b/app/Models/BalanceTransaction.php index f049f18..400c23a 100644 --- a/app/Models/BalanceTransaction.php +++ b/app/Models/BalanceTransaction.php @@ -14,7 +14,7 @@ class BalanceTransaction extends Model public const TYPE_ADMIN_DEBIT = 'admin_debit'; - protected $connection = 'azerothcore_auth'; + protected $connection = 'vmangos_auth'; protected $table = 'balance_transactions'; diff --git a/app/Models/GameAccount.php b/app/Models/GameAccount.php index 705e68c..cbda817 100644 --- a/app/Models/GameAccount.php +++ b/app/Models/GameAccount.php @@ -9,17 +9,16 @@ class GameAccount extends Authenticatable { use HasApiTokens; - protected $connection = 'azerothcore_auth'; + protected $connection = 'vmangos_auth'; protected $table = 'account'; public $timestamps = false; protected $hidden = [ - 'salt', - 'verifier', - 'session_key', - 'session_key_auth', - 'totp_secret', + 's', + 'v', + 'sessionkey', + 'token_key', ]; } diff --git a/app/Models/GameAccountUser.php b/app/Models/GameAccountUser.php index 82c8c90..3498d87 100644 --- a/app/Models/GameAccountUser.php +++ b/app/Models/GameAccountUser.php @@ -34,7 +34,7 @@ class GameAccountUser implements AuthenticatableContract public function getAuthPasswordName(): string { - return 'verifier'; + return 'v'; } public function getAuthPassword(): string @@ -78,6 +78,8 @@ class GameAccountUser implements AuthenticatableContract private function formatDate(?string $value): string { - return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—'; + return $value && ! str_starts_with($value, '0000-00-00') + ? Carbon::parse($value)->format('d.m.Y H:i') + : '—'; } } diff --git a/app/Models/PaymentInvoice.php b/app/Models/PaymentInvoice.php index 5aa9310..fb80e55 100644 --- a/app/Models/PaymentInvoice.php +++ b/app/Models/PaymentInvoice.php @@ -14,7 +14,7 @@ class PaymentInvoice extends Model public const MODE_PRODUCTION = 'production'; - protected $connection = 'azerothcore_auth'; + protected $connection = 'vmangos_auth'; protected $table = 'payment_invoices'; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 749cff2..b59f043 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,7 @@ namespace App\Providers; -use App\Auth\AzerothCoreUserProvider; +use App\Auth\VMangosUserProvider; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Auth; use Laravel\Passport\Passport; @@ -22,8 +22,8 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - Auth::provider('azerothcore', function ($app): AzerothCoreUserProvider { - return $app->make(AzerothCoreUserProvider::class); + Auth::provider('vmangos', function ($app): VMangosUserProvider { + return $app->make(VMangosUserProvider::class); }); Passport::tokensExpireIn(now()->addDays(30)); diff --git a/app/Services/AzerothCoreSrpService.php b/app/Services/AzerothCoreSrpService.php index b774273..a55a2ff 100644 --- a/app/Services/AzerothCoreSrpService.php +++ b/app/Services/AzerothCoreSrpService.php @@ -4,64 +4,37 @@ namespace App\Services; use Brick\Math\BigInteger; +/** The site's legacy AzerothCore SRP format, independent of the game ticket. */ class AzerothCoreSrpService { private const string MODULUS_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7'; - private const int GENERATOR = 7; - - private const int BYTE_LENGTH = 32; - - public function normalizeUsername(string $username): string - { - return strtoupper(trim($username)); - } - - public function normalizeEmail(string $email): string - { - return strtoupper(trim($email)); - } - - public function normalizePassword(string $password): string - { - return strtoupper($password); - } - - /** - * @return array{0: string, 1: string} - */ public function makeRegistrationData(string $username, string $password): array { - $saltHex = strtoupper(bin2hex(random_bytes(self::BYTE_LENGTH))); + $saltHex = strtoupper(bin2hex(random_bytes(32))); return [$saltHex, $this->makeVerifierHex($username, $password, $saltHex)]; } public function makeVerifierHex(string $username, string $password, string $saltHex): string { - $normalizedUsername = $this->normalizeUsername($username); - $normalizedPassword = $this->normalizePassword($password); $salt = hex2bin($saltHex); - - if ($salt === false) { - throw new \InvalidArgumentException('Invalid salt hex provided.'); + if ($salt === false || strlen($salt) !== 32) { + throw new \InvalidArgumentException('Invalid site credential salt.'); } - $innerDigest = hash('sha1', sprintf('%s:%s', $normalizedUsername, $normalizedPassword), true); - $xDigest = hash('sha1', $salt.$innerDigest, true); - $modulus = BigInteger::fromBase(self::MODULUS_HEX, 16); - $exponent = BigInteger::fromBytes(strrev($xDigest), false); - $verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus); - $verifierBytes = strrev(str_pad($verifier->toBytes(false), self::BYTE_LENGTH, "\0", STR_PAD_LEFT)); + $digest = hash('sha1', strtoupper(trim($username)).':'.strtoupper($password), true); + $x = hash('sha1', $salt.$digest, true); + $verifier = BigInteger::of(7)->modPow( + BigInteger::fromBytes(strrev($x), false), + BigInteger::fromBase(self::MODULUS_HEX, 16), + ); - return strtoupper(bin2hex($verifierBytes)); + return strtoupper(bin2hex(strrev(str_pad($verifier->toBytes(false), 32, "\0", STR_PAD_LEFT)))); } public function credentialsMatch(string $username, string $password, string $saltHex, string $verifierHex): bool { - return hash_equals( - strtoupper($verifierHex), - $this->makeVerifierHex($username, $password, $saltHex), - ); + return hash_equals(strtoupper($verifierHex), $this->makeVerifierHex($username, $password, $saltHex)); } } diff --git a/app/Services/BalanceService.php b/app/Services/BalanceService.php index 1eb6fdf..9248647 100644 --- a/app/Services/BalanceService.php +++ b/app/Services/BalanceService.php @@ -12,7 +12,7 @@ class BalanceService { public function balance(int $accountId): string { - $value = DB::connection('azerothcore_auth')->table('account_balances') + $value = DB::connection('vmangos_auth')->table('account_balances') ->where('account_id', $accountId) ->value('balance'); @@ -36,7 +36,7 @@ class BalanceService throw new InvalidArgumentException('Сумма списания должна быть больше нуля.'); } - return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $reference, $description): BalanceTransaction { + return DB::connection('vmangos_auth')->transaction(function () use ($accountId, $cents, $reference, $description): BalanceTransaction { $existing = BalanceTransaction::query()->where('reference', $reference)->first(); if ($existing) { if ($existing->account_id !== $accountId || $this->toCents((string) $existing->amount) !== -$cents) { @@ -46,7 +46,7 @@ class BalanceService return $existing; } - $balance = DB::connection('azerothcore_auth')->table('account_balances') + $balance = DB::connection('vmangos_auth')->table('account_balances') ->where('account_id', $accountId) ->lockForUpdate() ->first(); @@ -57,7 +57,7 @@ class BalanceService } $after = $this->fromCents($current - $cents); - DB::connection('azerothcore_auth')->table('account_balances') + DB::connection('vmangos_auth')->table('account_balances') ->where('account_id', $accountId) ->update(['balance' => $after, 'updated_at' => now()]); @@ -85,14 +85,14 @@ class BalanceService throw new InvalidArgumentException('Некорректная корректировка баланса.'); } - return DB::connection('azerothcore_auth')->transaction(function () use ($accountId, $cents, $direction, $adminAccountId, $adminUsername, $reason): BalanceTransaction { - DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([ + return DB::connection('vmangos_auth')->transaction(function () use ($accountId, $cents, $direction, $adminAccountId, $adminUsername, $reason): BalanceTransaction { + DB::connection('vmangos_auth')->table('account_balances')->insertOrIgnore([ 'account_id' => $accountId, 'balance' => '0.00', 'created_at' => now(), 'updated_at' => now(), ]); - $balance = DB::connection('azerothcore_auth')->table('account_balances') + $balance = DB::connection('vmangos_auth')->table('account_balances') ->where('account_id', $accountId) ->lockForUpdate() ->firstOrFail(); @@ -104,7 +104,7 @@ class BalanceService } $after = $this->fromCents($current + $delta); - DB::connection('azerothcore_auth')->table('account_balances') + DB::connection('vmangos_auth')->table('account_balances') ->where('account_id', $accountId) ->update(['balance' => $after, 'updated_at' => now()]); diff --git a/app/Services/GameClientDownloadService.php b/app/Services/GameClientDownloadService.php index caa9e2a..57b8ec9 100644 --- a/app/Services/GameClientDownloadService.php +++ b/app/Services/GameClientDownloadService.php @@ -3,6 +3,7 @@ namespace App\Services; use Illuminate\Support\Facades\Storage; +use RuntimeException; use Throwable; class GameClientDownloadService @@ -18,6 +19,10 @@ class GameClientDownloadService $objectKey = $this->objectKey(); $filename = $this->filename(); + if (! $disk->exists($objectKey)) { + throw new RuntimeException('Game client archive is not available.'); + } + try { return $disk->temporaryUrl( $objectKey, diff --git a/app/Services/LauncherGameTicketService.php b/app/Services/LauncherGameTicketService.php index 9eae58e..6c7b11f 100644 --- a/app/Services/LauncherGameTicketService.php +++ b/app/Services/LauncherGameTicketService.php @@ -5,85 +5,46 @@ namespace App\Services; use App\Models\GameAccount; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\DB; -use RuntimeException; class LauncherGameTicketService { private const string ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - private const int TICKET_LENGTH = 16; + public function __construct(private readonly VMangosSrpService $srp) {} - public function __construct(private readonly AzerothCoreSrpService $srp) {} - - /** - * @return array{account: string, ticket: string, expires_at: string} - */ + /** @return array{account: string, ticket: string, expires_at: string} */ public function issue(GameAccount $account, string $launcherSessionToken, int $clientBuild): array { $username = $this->srp->normalizeUsername((string) $account->username); - $this->assertCompatibleAccountName($username); + if (preg_match('/^[A-Z0-9]{3,16}$/D', $username) !== 1) { + throw new \RuntimeException('Account name is incompatible with the 1.12.1 client.'); + } + + $ticket = ''; + for ($i = 0; $i < 16; $i++) { + $ticket .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)]; + } - $ticket = $this->randomTicket(); [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($username, $ticket); - $generationId = random_bytes(16); - $sessionHash = hash('sha256', $launcherSessionToken, true); - $issuedAt = CarbonImmutable::now('UTC'); - $ttl = max(5, min(60, (int) config('moonwell.launcher.game_ticket_ttl', 60))); - $expiresAt = $issuedAt->addSeconds($ttl); - $connection = DB::connection((string) config('moonwell.auth_connection')); + $now = CarbonImmutable::now('UTC'); + $expires = $now->addSeconds(max(5, min(60, (int) config('moonwell.launcher.game_ticket_ttl')))); - $connection->transaction(function () use ( - $connection, - $account, - $generationId, - $saltHex, - $verifierHex, - $clientBuild, - $sessionHash, - $issuedAt, - $expiresAt, - ): void { - $connection->table('launcher_ticket')->updateOrInsert( - ['account_id' => (int) $account->id], - [ - 'generation_id' => $generationId, - 'srp_salt' => hex2bin($saltHex), - 'srp_verifier' => hex2bin($verifierHex), - 'client_build' => $clientBuild, - 'launcher_session_hash' => $sessionHash, - 'issued_at' => $issuedAt, - 'expires_at' => $expiresAt, - ], - ); - }); + DB::connection(config('moonwell.auth_connection'))->table('launcher_ticket')->upsert([[ + 'account_id' => (int) $account->id, + 'generation_id' => random_bytes(16), + 'srp_salt' => hex2bin($saltHex), + 'srp_verifier' => hex2bin(str_pad($verifierHex, 64, '0', STR_PAD_LEFT)), + 'client_build' => $clientBuild, + 'launcher_session_hash' => hash('sha256', $launcherSessionToken, true), + 'issued_at' => $now->format('Y-m-d H:i:s.u'), + 'expires_at' => $expires->format('Y-m-d H:i:s.u'), + 'claim_nonce' => null, + ]], ['account_id'], ['generation_id', 'srp_salt', 'srp_verifier', 'client_build', 'launcher_session_hash', 'issued_at', 'expires_at', 'claim_nonce']); return [ 'account' => $username, 'ticket' => $ticket, - 'expires_at' => $expiresAt->format('Y-m-d\\TH:i:s\\Z'), + 'expires_at' => $expires->format('Y-m-d\TH:i:s\Z'), ]; } - - private function randomTicket(): string - { - $ticket = ''; - $lastIndex = strlen(self::ALPHABET) - 1; - - for ($i = 0; $i < self::TICKET_LENGTH; $i++) { - $ticket .= self::ALPHABET[random_int(0, $lastIndex)]; - } - - return $ticket; - } - - private function assertCompatibleAccountName(string $username): void - { - if ( - $username === '' - || strlen($username) > 17 - || preg_match('/^[!-<>-~]+$/D', $username) !== 1 - ) { - throw new RuntimeException('Game account is not compatible with the 3.3.5a authentication protocol.'); - } - } } diff --git a/app/Services/RobokassaService.php b/app/Services/RobokassaService.php index 0adacdc..ad037b3 100644 --- a/app/Services/RobokassaService.php +++ b/app/Services/RobokassaService.php @@ -74,7 +74,7 @@ class RobokassaService throw new InvalidArgumentException('Некорректная подпись Robokassa.'); } - return DB::connection('azerothcore_auth')->transaction(function () use ($invoiceId, $accountId, $amount): PaymentInvoice { + return DB::connection('vmangos_auth')->transaction(function () use ($invoiceId, $accountId, $amount): PaymentInvoice { $invoice = PaymentInvoice::query()->lockForUpdate()->findOrFail((int) $invoiceId); if ($invoice->account_id !== (int) $accountId || $this->normalizeAmount((string) $invoice->amount) !== $this->normalizeAmount($amount)) { @@ -85,13 +85,13 @@ class RobokassaService return $invoice; } - DB::connection('azerothcore_auth')->table('account_balances')->insertOrIgnore([ + DB::connection('vmangos_auth')->table('account_balances')->insertOrIgnore([ 'account_id' => $invoice->account_id, 'balance' => '0.00', 'created_at' => now(), 'updated_at' => now(), ]); - $balance = DB::connection('azerothcore_auth')->table('account_balances') + $balance = DB::connection('vmangos_auth')->table('account_balances') ->where('account_id', $invoice->account_id) ->lockForUpdate() ->firstOrFail(); @@ -102,7 +102,7 @@ class RobokassaService (int) config('services.robokassa.tears_per_ruble'), ); $after = $this->addAmounts($current, $tearsAmount); - DB::connection('azerothcore_auth')->table('account_balances') + DB::connection('vmangos_auth')->table('account_balances') ->where('account_id', $invoice->account_id) ->update(['balance' => $after, 'updated_at' => now()]); diff --git a/app/Services/StoreAvailabilityService.php b/app/Services/StoreAvailabilityService.php index 2a3db49..e0a5a30 100644 --- a/app/Services/StoreAvailabilityService.php +++ b/app/Services/StoreAvailabilityService.php @@ -3,6 +3,7 @@ namespace App\Services; use Illuminate\Support\Facades\DB; +use Throwable; class StoreAvailabilityService { @@ -10,10 +11,16 @@ class StoreAvailabilityService public function isEnabled(): bool { - return (bool) DB::connection('store') - ->table('store_config') - ->where('id', self::CONFIG_ID) - ->value('enabled'); + try { + return (bool) DB::connection('store') + ->table('store_config') + ->where('id', self::CONFIG_ID) + ->value('enabled'); + } catch (Throwable $exception) { + report($exception); + + return false; + } } public function setEnabled(bool $enabled): void diff --git a/app/Services/AzerothCoreAccountRegistrar.php b/app/Services/VMangosAccountRegistrar.php similarity index 57% rename from app/Services/AzerothCoreAccountRegistrar.php rename to app/Services/VMangosAccountRegistrar.php index 05f98e4..4779a30 100644 --- a/app/Services/AzerothCoreAccountRegistrar.php +++ b/app/Services/VMangosAccountRegistrar.php @@ -7,9 +7,9 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; -class AzerothCoreAccountRegistrar +class VMangosAccountRegistrar { - public function __construct(private readonly AzerothCoreSrpService $srp) + public function __construct(private readonly VMangosSrpService $srp, private readonly AzerothCoreSrpService $siteSrp) { } @@ -21,7 +21,8 @@ class AzerothCoreAccountRegistrar $normalizedUsername = $this->srp->normalizeUsername($username); $normalizedPassword = $this->srp->normalizePassword($password); $normalizedEmail = $this->srp->normalizeEmail($email); - [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($normalizedUsername, $normalizedPassword); + [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($normalizedUsername, bin2hex(random_bytes(16))); + [$siteSaltHex, $siteVerifierHex] = $this->siteSrp->makeRegistrationData($normalizedUsername, $normalizedPassword); $connection = DB::connection(config('moonwell.auth_connection')); @@ -31,6 +32,8 @@ class AzerothCoreAccountRegistrar $normalizedUsername, $saltHex, $verifierHex, + $siteSaltHex, + $siteVerifierHex, ): array { if ($this->accountExists($connection, 'username', $normalizedUsername)) { throw DuplicateGameAccountException::forUsername(); @@ -45,18 +48,14 @@ class AzerothCoreAccountRegistrar } try { - $connection->insert( - 'INSERT INTO account (username, salt, verifier, expansion, reg_mail, email, joindate) VALUES (?, UNHEX(?), UNHEX(?), ?, ?, ?, ?)', - [ - $normalizedUsername, - $saltHex, - $verifierHex, - config('moonwell.registration.expansion'), - $normalizedEmail, - $normalizedEmail, - now()->toDateTimeString(), - ], - ); + $accountId = (int) $connection->table('account')->insertGetId([ + 'username' => $normalizedUsername, + 's' => $saltHex, + 'v' => $verifierHex, + 'expansion' => 0, + 'email' => $normalizedEmail, + 'joindate' => now()->toDateTimeString(), + ]); } catch (QueryException $exception) { if ($this->isDuplicateKeyException($exception)) { throw DuplicateGameAccountException::forUsername(); @@ -65,21 +64,25 @@ class AzerothCoreAccountRegistrar throw $exception; } - $accountId = (int) $connection->getPdo()->lastInsertId(); + $connection->table('site_credentials')->insert([ + 'account_id' => $accountId, + 'salt_hex' => $siteSaltHex, + 'verifier_hex' => $siteVerifierHex, + 'created_at' => now(), + 'updated_at' => now(), + ]); - $connection->insert( - 'INSERT IGNORE INTO realmcharacters (realmid, acctid, numchars) SELECT id, ?, 0 FROM realmlist', - [$accountId], - ); + foreach ($connection->table('realmlist')->pluck('id') as $realmId) { + $connection->table('realmcharacters')->insertOrIgnore([ + 'realmid' => (int) $realmId, + 'acctid' => $accountId, + 'numchars' => 0, + ]); + } - $connection->statement( - 'INSERT INTO account_access (id, gmlevel, RealmID, comment) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE gmlevel = VALUES(gmlevel), comment = VALUES(comment)', - [ - $accountId, - config('moonwell.registration.gmlevel'), - config('moonwell.registration.realm_id'), - config('moonwell.registration.access_comment'), - ], + $connection->table('account_access')->updateOrInsert( + ['id' => $accountId, 'RealmID' => (int) config('moonwell.registration.realm_id')], + ['gmlevel' => (int) config('moonwell.registration.gmlevel')], ); return [ diff --git a/app/Services/AzerothCoreAccountService.php b/app/Services/VMangosAccountService.php similarity index 81% rename from app/Services/AzerothCoreAccountService.php rename to app/Services/VMangosAccountService.php index 0fc3ede..2ec9d32 100644 --- a/app/Services/AzerothCoreAccountService.php +++ b/app/Services/VMangosAccountService.php @@ -9,22 +9,23 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; -class AzerothCoreAccountService +class VMangosAccountService { public const ACCOUNT_TAB_PLAYERS = 'players'; public const ACCOUNT_TAB_BOTS = 'bots'; - public function __construct(private readonly AzerothCoreSrpService $srp) + public function __construct(private readonly VMangosSrpService $srp, private readonly AzerothCoreSrpService $siteSrp) { } public function findUserById(int $accountId): ?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('id', $accountId) + ->table('account as a') + ->join('site_credentials as c', 'c.account_id', '=', 'a.id') + ->selectRaw('a.id, a.username, a.email, a.locked, a.last_login, a.joindate, c.salt_hex, c.verifier_hex') + ->where('a.id', $accountId) ->first(); if (! $record) { @@ -39,9 +40,10 @@ class AzerothCoreAccountService 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)) + ->table('account as a') + ->join('site_credentials as c', 'c.account_id', '=', 'a.id') + ->selectRaw('a.id, a.username, a.email, a.locked, a.last_login, a.joindate, c.salt_hex, c.verifier_hex') + ->where('a.username', $this->srp->normalizeUsername($username)) ->first(); if (! $record) { @@ -55,15 +57,19 @@ class AzerothCoreAccountService public function validatePassword(GameAccountUser $user, string $password): bool { - return $this->srp->credentialsMatch($user->username, $password, $user->saltHex, $user->verifierHex); + if ($user->saltHex === '' || $user->verifierHex === '') { + return false; + } + + return $this->siteSrp->credentialsMatch($user->username, $password, $user->saltHex, $user->verifierHex); } public function updatePassword(GameAccountUser $user, string $newPassword): void { - [$saltHex, $verifierHex] = $this->srp->makeRegistrationData($user->username, $newPassword); + [$saltHex, $verifierHex] = $this->siteSrp->makeRegistrationData($user->username, $newPassword); $this->authConnection()->update( - 'UPDATE account SET salt = UNHEX(?), verifier = UNHEX(?), session_key = NULL, failed_logins = 0 WHERE id = ?', + 'UPDATE site_credentials SET salt_hex = ?, verifier_hex = ?, updated_at = CURRENT_TIMESTAMP WHERE account_id = ?', [$saltHex, $verifierHex, $user->id], ); } @@ -90,7 +96,7 @@ class AzerothCoreAccountService 'id' => $accountId, 'username' => $account->username, 'email' => $account->email, - 'reg_mail' => $account->reg_mail, + 'reg_mail' => $account->email, 'gm_level' => $gmLevel, 'access_label' => WowData::securityLevelName($gmLevel), 'locked' => (bool) $account->locked, @@ -131,14 +137,9 @@ class AzerothCoreAccountService { $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'), - ], + $this->authConnection()->table('account_access')->updateOrInsert( + ['id' => $accountId, 'RealmID' => $realmId], + ['gmlevel' => $gmLevel], ); } @@ -154,7 +155,7 @@ class AzerothCoreAccountService return $this->charactersConnection() ->table('characters') - ->select('guid', 'account', 'name', 'race', 'class', 'gender', 'level', 'online', 'money', 'totaltime') + ->select('guid', 'account', 'name', 'race', 'class', 'gender', 'level', 'online', 'money', 'played_time_total') ->whereIn('account', $accountIds) ->orderBy('account') ->orderByDesc('level') @@ -171,7 +172,7 @@ class AzerothCoreAccountService 'gender' => WowData::genderName((int) $character->gender), 'online' => (bool) $character->online, 'money' => WowData::formatMoney((int) $character->money), - 'played' => WowData::formatPlayedTime((int) $character->totaltime), + 'played' => WowData::formatPlayedTime((int) $character->played_time_total), ]; }) ->groupBy('account') @@ -211,27 +212,29 @@ class AzerothCoreAccountService return new GameAccountUser( id: (int) $record->id, username: $record->username, - email: $record->email, - regMail: $record->reg_mail, + email: (string) $record->email, + regMail: (string) $record->email, gmLevel: $gmLevel, locked: (bool) $record->locked, lastLoginAt: $record->last_login, joinedAt: $record->joindate, - saltHex: strtoupper($record->salt_hex), - verifierHex: strtoupper($record->verifier_hex), + saltHex: strtoupper((string) $record->salt_hex), + verifierHex: strtoupper((string) $record->verifier_hex), ); } private function formatDate(?string $value): string { - return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—'; + return $value && ! str_starts_with($value, '0000-00-00') + ? 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') + ->select('id', 'username', 'email', 'locked', 'last_login', 'joindate', 'online') ->orderByDesc('id'); if ($search !== null && trim($search) !== '') { @@ -240,8 +243,7 @@ class AzerothCoreAccountService $query->where(function ($builder) use ($normalizedSearch): void { $builder ->where('username', 'like', "%{$normalizedSearch}%") - ->orWhere('email', 'like', "%{$normalizedSearch}%") - ->orWhere('reg_mail', 'like', "%{$normalizedSearch}%"); + ->orWhere('email', 'like', "%{$normalizedSearch}%"); }); } diff --git a/app/Services/VMangosSrpService.php b/app/Services/VMangosSrpService.php new file mode 100644 index 0000000..826e084 --- /dev/null +++ b/app/Services/VMangosSrpService.php @@ -0,0 +1,70 @@ +makeVerifierHex($username, $password, $saltHex)]; + } + + public function makeVerifierHex(string $username, string $password, string $saltHex): string + { + $normalizedUsername = $this->normalizeUsername($username); + $normalizedPassword = $this->normalizePassword($password); + // vMaNGOS stores BigNumber::AsHexStr() (big endian) and hashes + // BigNumber::AsByteArray() (little endian). + $normalizedSalt = ltrim($saltHex, '0'); + $salt = hex2bin(str_pad($normalizedSalt, (int) (ceil(strlen($normalizedSalt) / 2) * 2), '0', STR_PAD_LEFT)); + + if ($salt === false || $salt === '') { + throw new \InvalidArgumentException('Invalid salt hex provided.'); + } + + $innerDigest = hash('sha1', sprintf('%s:%s', $normalizedUsername, $normalizedPassword), true); + $xDigest = hash('sha1', strrev($salt).$innerDigest, true); + $modulus = BigInteger::fromBase(self::MODULUS_HEX, 16); + $exponent = BigInteger::fromBytes(strrev($xDigest), false); + $verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus); + return strtoupper($verifier->toBase(16)); + } + + public function credentialsMatch(string $username, string $password, string $saltHex, string $verifierHex): bool + { + return hash_equals( + ltrim(strtoupper($verifierHex), '0'), + ltrim($this->makeVerifierHex($username, $password, $saltHex), '0'), + ); + } +} diff --git a/app/Support/WowData.php b/app/Support/WowData.php index a4f8760..59138ba 100644 --- a/app/Support/WowData.php +++ b/app/Support/WowData.php @@ -33,8 +33,6 @@ class WowData 6 => 'Таурен', 7 => 'Гном', 8 => 'Тролль', - 10 => 'Эльф крови', - 11 => 'Дреней', ][$race] ?? 'Неизвестно'; } @@ -46,7 +44,6 @@ class WowData 3 => 'Охотник', 4 => 'Разбойник', 5 => 'Жрец', - 6 => 'Рыцарь смерти', 7 => 'Шаман', 8 => 'Маг', 9 => 'Чернокнижник', diff --git a/compose.prod.yaml b/compose.prod.yaml index 57ca99e..eef84c9 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -23,6 +23,7 @@ services: - '/var/www/html/bootstrap/cache:uid=${WWWUSER:-1000},gid=${WWWGROUP:-1000},mode=0770' networks: - sail + - vmangos depends_on: mysql: condition: service_healthy @@ -99,6 +100,9 @@ services: networks: sail: driver: bridge + vmangos: + external: true + name: '${VMANGOS_DOCKER_NETWORK:-vmangos_default}' volumes: sail-mysql: diff --git a/compose.yaml b/compose.yaml index d029d31..0b23369 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,6 +22,7 @@ services: - '.:/var/www/html' networks: - sail + - vmangos depends_on: - mysql healthcheck: @@ -59,6 +60,9 @@ services: networks: sail: driver: bridge + vmangos: + external: true + name: '${VMANGOS_DOCKER_NETWORK:-vmangos-local_default}' volumes: sail-mysql: driver: local diff --git a/config/auth.php b/config/auth.php index 8ec054f..9dd4c0a 100644 --- a/config/auth.php +++ b/config/auth.php @@ -66,7 +66,7 @@ return [ 'providers' => [ 'game_accounts' => [ - 'driver' => 'azerothcore', + 'driver' => 'vmangos', ], 'game_accounts_eloquent' => [ diff --git a/config/database.php b/config/database.php index f530446..96e3528 100644 --- a/config/database.php +++ b/config/database.php @@ -64,53 +64,69 @@ return [ ]) : [], ], - 'azerothcore_auth' => [ + 'vmangos_auth' => [ 'driver' => 'mysql', - 'url' => env('AZEROTHCORE_AUTH_DB_URL'), - 'host' => env('AZEROTHCORE_AUTH_DB_HOST', '127.0.0.1'), - 'port' => env('AZEROTHCORE_AUTH_DB_PORT', '3306'), - 'database' => env('AZEROTHCORE_AUTH_DB_DATABASE', 'acore_auth'), - 'username' => env('AZEROTHCORE_AUTH_DB_USERNAME', 'root'), - 'password' => env('AZEROTHCORE_AUTH_DB_PASSWORD', 'password'), - 'unix_socket' => env('AZEROTHCORE_AUTH_DB_SOCKET', ''), - 'charset' => env('AZEROTHCORE_AUTH_DB_CHARSET', 'utf8mb4'), - 'collation' => env('AZEROTHCORE_AUTH_DB_COLLATION', 'utf8mb4_unicode_ci'), + // vMaNGOS uses zero timestamps such as account.last_login. + 'modes' => ['NO_ENGINE_SUBSTITUTION'], + 'url' => env('VMANGOS_AUTH_DB_URL'), + 'host' => env('VMANGOS_AUTH_DB_HOST', '127.0.0.1'), + 'port' => env('VMANGOS_AUTH_DB_PORT', '13306'), + 'database' => env('VMANGOS_AUTH_DB_DATABASE', 'realmd'), + 'username' => env('VMANGOS_AUTH_DB_USERNAME', 'vmangos'), + 'password' => env('VMANGOS_AUTH_DB_PASSWORD', ''), + 'unix_socket' => env('VMANGOS_AUTH_DB_SOCKET', ''), + 'charset' => env('VMANGOS_AUTH_DB_CHARSET', 'utf8mb4'), + 'collation' => env('VMANGOS_AUTH_DB_COLLATION', 'utf8mb4_unicode_ci'), 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('AZEROTHCORE_AUTH_DB_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('VMANGOS_AUTH_DB_SSL_CA'), ]) : [], ], - 'azerothcore_characters' => [ + 'legacy_auth' => [ 'driver' => 'mysql', - 'url' => env('AZEROTHCORE_CHARACTERS_DB_URL'), - 'host' => env('AZEROTHCORE_CHARACTERS_DB_HOST', env('AZEROTHCORE_AUTH_DB_HOST', '127.0.0.1')), - 'port' => env('AZEROTHCORE_CHARACTERS_DB_PORT', env('AZEROTHCORE_AUTH_DB_PORT', '3306')), - 'database' => env('AZEROTHCORE_CHARACTERS_DB_DATABASE', 'acore_characters'), - 'username' => env('AZEROTHCORE_CHARACTERS_DB_USERNAME', env('AZEROTHCORE_AUTH_DB_USERNAME', 'root')), - 'password' => env('AZEROTHCORE_CHARACTERS_DB_PASSWORD', env('AZEROTHCORE_AUTH_DB_PASSWORD', 'password')), - 'unix_socket' => env('AZEROTHCORE_CHARACTERS_DB_SOCKET', ''), - 'charset' => env('AZEROTHCORE_CHARACTERS_DB_CHARSET', env('AZEROTHCORE_AUTH_DB_CHARSET', 'utf8mb4')), - 'collation' => env('AZEROTHCORE_CHARACTERS_DB_COLLATION', env('AZEROTHCORE_AUTH_DB_COLLATION', 'utf8mb4_unicode_ci')), + 'host' => env('LEGACY_AUTH_DB_HOST', '127.0.0.1'), + 'port' => env('LEGACY_AUTH_DB_PORT', '13307'), + 'database' => env('LEGACY_AUTH_DB_DATABASE', 'acore_auth'), + 'username' => env('LEGACY_AUTH_DB_USERNAME', 'root'), + 'password' => env('LEGACY_AUTH_DB_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => true, + ], + + 'vmangos_characters' => [ + 'driver' => 'mysql', + 'modes' => ['NO_ENGINE_SUBSTITUTION'], + 'url' => env('VMANGOS_CHARACTERS_DB_URL'), + 'host' => env('VMANGOS_CHARACTERS_DB_HOST', env('VMANGOS_AUTH_DB_HOST', '127.0.0.1')), + 'port' => env('VMANGOS_CHARACTERS_DB_PORT', env('VMANGOS_AUTH_DB_PORT', '3306')), + 'database' => env('VMANGOS_CHARACTERS_DB_DATABASE', 'characters'), + 'username' => env('VMANGOS_CHARACTERS_DB_USERNAME', env('VMANGOS_AUTH_DB_USERNAME', 'root')), + 'password' => env('VMANGOS_CHARACTERS_DB_PASSWORD', env('VMANGOS_AUTH_DB_PASSWORD', '')), + 'unix_socket' => env('VMANGOS_CHARACTERS_DB_SOCKET', ''), + 'charset' => env('VMANGOS_CHARACTERS_DB_CHARSET', env('VMANGOS_AUTH_DB_CHARSET', 'utf8mb4')), + 'collation' => env('VMANGOS_CHARACTERS_DB_COLLATION', env('VMANGOS_AUTH_DB_COLLATION', 'utf8mb4_unicode_ci')), 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('AZEROTHCORE_CHARACTERS_DB_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('VMANGOS_CHARACTERS_DB_SSL_CA'), ]) : [], ], 'store' => [ 'driver' => 'mysql', - 'host' => env('STORE_DB_HOST', env('AZEROTHCORE_AUTH_DB_HOST', '127.0.0.1')), - 'port' => env('STORE_DB_PORT', env('AZEROTHCORE_AUTH_DB_PORT', '3306')), + 'host' => env('STORE_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('STORE_DB_PORT', env('DB_PORT', '3306')), 'database' => env('STORE_DB_DATABASE', 'store'), - 'username' => env('STORE_DB_USERNAME', env('AZEROTHCORE_AUTH_DB_USERNAME', 'root')), - 'password' => env('STORE_DB_PASSWORD', env('AZEROTHCORE_AUTH_DB_PASSWORD', 'password')), + 'username' => env('STORE_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('STORE_DB_PASSWORD', env('DB_PASSWORD', '')), 'unix_socket' => env('STORE_DB_SOCKET', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_general_ci', diff --git a/config/moonwell.php b/config/moonwell.php index 1ce78ad..f1409d1 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -1,21 +1,19 @@ env('AZEROTHCORE_AUTH_CONNECTION', 'azerothcore_auth'), - 'characters_connection' => env('AZEROTHCORE_CHARACTERS_CONNECTION', 'azerothcore_characters'), + 'auth_connection' => env('VMANGOS_AUTH_CONNECTION', 'vmangos_auth'), + 'characters_connection' => env('VMANGOS_CHARACTERS_CONNECTION', 'vmangos_characters'), + 'legacy_auth_connection' => env('LEGACY_AUTH_CONNECTION', 'legacy_auth'), 'registration' => [ 'require_invite_code' => (bool) env('MOONWELL_REGISTRATION_REQUIRE_INVITE', true), - 'gmlevel' => (int) env('AZEROTHCORE_ACCOUNT_GMLEVEL', 0), - 'realm_id' => (int) env('AZEROTHCORE_ACCOUNT_REALM_ID', -1), - 'expansion' => (int) env('AZEROTHCORE_ACCOUNT_EXPANSION', 2), - 'access_comment' => env('AZEROTHCORE_ACCOUNT_ACCESS_COMMENT', 'registered via moonwell-web'), - 'enforce_unique_email' => (bool) env('AZEROTHCORE_ENFORCE_UNIQUE_EMAIL', false), + 'gmlevel' => (int) env('VMANGOS_ACCOUNT_GMLEVEL', 0), + 'realm_id' => (int) env('VMANGOS_ACCOUNT_REALM_ID', -1), + 'enforce_unique_email' => (bool) env('VMANGOS_ENFORCE_UNIQUE_EMAIL', false), ], 'portal' => [ 'admin_min_gm_level' => (int) env('MOONWELL_ADMIN_MIN_GMLEVEL', 3), - 'access_comment' => env('MOONWELL_ADMIN_ACCESS_COMMENT', 'updated via moonwell-web admin'), 'bot_account_prefix' => env('MOONWELL_BOT_ACCOUNT_PREFIX', 'RNDBOT'), ], @@ -30,17 +28,17 @@ return [ 'client' => [ 'disk' => env('MOONWELL_CLIENT_DISK', 's3'), - 'object_key' => env('MOONWELL_CLIENT_OBJECT_KEY', 'moonwell_launcher_setup.exe'), + 'object_key' => env('MOONWELL_CLIENT_OBJECT_KEY', 'World of Warcraft 1.12.1.zip'), 'temporary_url_minutes' => (int) env('MOONWELL_CLIENT_URL_TTL', 30), ], 'launcher' => [ + 'client_build' => (int) env('MOONWELL_CLIENT_BUILD', 5875), + 'game_ticket_ttl' => (int) env('MOONWELL_GAME_TICKET_TTL', 60), 'disk' => env('MOONWELL_LAUNCHER_DISK', 's3'), - 'base_path' => env('MOONWELL_LAUNCHER_BASE_PATH', 'World of Warcraft'), - 'manifest_key' => env('MOONWELL_LAUNCHER_MANIFEST_KEY', 'manifest.json'), + 'base_path' => env('MOONWELL_LAUNCHER_BASE_PATH', 'World of Warcraft 1.12.1'), + 'manifest_key' => env('MOONWELL_LAUNCHER_MANIFEST_KEY', 'manifest-1.12.1.json'), 'download_ttl' => (int) env('MOONWELL_LAUNCHER_DOWNLOAD_TTL', 60), - 'game_ticket_ttl' => (int) env('MOONWELL_LAUNCHER_GAME_TICKET_TTL', 60), - 'client_build' => (int) env('MOONWELL_CLIENT_BUILD', 12340), 'realm_status_host' => env('MOONWELL_REALM_STATUS_HOST'), 'realm_status_timeout' => (float) env('MOONWELL_REALM_STATUS_TIMEOUT', 0.5), 'realm_status_cache' => (int) env('MOONWELL_REALM_STATUS_CACHE', 15), @@ -49,13 +47,13 @@ return [ 'realm' => [ 'server_name' => env('MOONWELL_SERVER_NAME', 'MoonWell'), 'realm_name' => env('MOONWELL_REALM_NAME', 'MoonWell'), - 'tagline' => env('MOONWELL_SERVER_TAGLINE', 'Пройди историю Азерота — от Классики до Короля-лича — в своём темпе, в одиночку или с небольшим отрядом.'), - 'client_version' => env('MOONWELL_CLIENT_VERSION', '3.3.5a'), + 'tagline' => env('MOONWELL_SERVER_TAGLINE', 'Вернись в классический Азерот — в одиночку или с небольшим отрядом.'), + 'client_version' => env('MOONWELL_CLIENT_VERSION', '1.12.1'), 'realmlist' => env('MOONWELL_REALMLIST', 'set realmlist 127.0.0.1'), 'rates' => [ - 'experience' => env('MOONWELL_RATE_EXPERIENCE', 'x3'), - 'gold' => env('MOONWELL_RATE_GOLD', 'x2'), - 'profession' => env('MOONWELL_RATE_PROFESSION', 'x2'), + 'experience' => env('MOONWELL_RATE_EXPERIENCE', 'x1'), + 'gold' => env('MOONWELL_RATE_GOLD', 'x1'), + 'profession' => env('MOONWELL_RATE_PROFESSION', 'x1'), 'reputation' => env('MOONWELL_RATE_REPUTATION', 'x1'), ], ], diff --git a/config/session.php b/config/session.php index 5b541b7..b5580c2 100644 --- a/config/session.php +++ b/config/session.php @@ -129,7 +129,7 @@ return [ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + Str::slug((string) env('APP_NAME', 'laravel')).'-vmangos-session' ), /* diff --git a/database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php b/database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php index acd80f8..eb38cee 100644 --- a/database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php +++ b/database/migrations/2026_07_19_000001_create_moonwell_balance_tables.php @@ -8,7 +8,7 @@ return new class extends Migration { public function getConnection(): ?string { - return app()->environment('testing') ? config('database.default') : 'azerothcore_auth'; + return app()->environment('testing') ? config('database.default') : 'vmangos_auth'; } public function up(): void diff --git a/database/migrations/2026_10_03_000001_create_vmangos_balance_tables.php b/database/migrations/2026_10_03_000001_create_vmangos_balance_tables.php new file mode 100644 index 0000000..6fb2227 --- /dev/null +++ b/database/migrations/2026_10_03_000001_create_vmangos_balance_tables.php @@ -0,0 +1,56 @@ +environment('testing') ? config('database.default') : 'vmangos_auth'; + } + + public function up(): void + { + $schema = Schema::connection($this->getConnection()); + + if (! $schema->hasTable('account_balances')) { + $schema->create('account_balances', function (Blueprint $table): void { + $table->unsignedInteger('account_id')->primary(); + $table->decimal('balance', 14, 2)->default(0); + $table->timestamps(); + }); + } + + if (! $schema->hasTable('payment_invoices')) { + $schema->create('payment_invoices', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('account_id')->index(); + $table->decimal('amount', 14, 2); + $table->string('mode', 20)->default('test'); + $table->string('status', 20)->default('pending')->index(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + } + + if (! $schema->hasTable('balance_transactions')) { + $schema->create('balance_transactions', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('account_id')->index(); + $table->string('type', 20); + $table->decimal('amount', 14, 2); + $table->decimal('balance_after', 14, 2); + $table->string('reference', 191)->unique(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + }); + } + } + + public function down(): void + { + // The tables may predate this migration. Preserve shared account balances. + } +}; diff --git a/database/migrations/2026_10_03_000002_create_vmangos_site_credentials.php b/database/migrations/2026_10_03_000002_create_vmangos_site_credentials.php new file mode 100644 index 0000000..f58ef3e --- /dev/null +++ b/database/migrations/2026_10_03_000002_create_vmangos_site_credentials.php @@ -0,0 +1,32 @@ +environment('testing') ? config('database.default') : 'vmangos_auth'; + } + + public function up(): void + { + Schema::connection($this->getConnection())->create('site_credentials', function (Blueprint $table): void { + $table->unsignedInteger('account_id')->primary(); + $table->unsignedInteger('legacy_account_id')->nullable()->unique(); + $table->char('salt_hex', 64); + $table->char('verifier_hex', 64); + $table->timestamps(); + if (! app()->environment('testing')) { + $table->foreign('account_id')->references('id')->on('account')->cascadeOnDelete(); + } + }); + } + + public function down(): void + { + Schema::connection($this->getConnection())->dropIfExists('site_credentials'); + } +}; diff --git a/docs/game-balance-integration.md b/docs/game-balance-integration.md index 4742cc7..3c70f6a 100644 --- a/docs/game-balance-integration.md +++ b/docs/game-balance-integration.md @@ -1,6 +1,6 @@ # Интеграция Слёз Элуны -Слёзы Элуны хранятся в `acore_auth.account_balances`, поэтому сайт и игровой сервер используют один источник истины. Все движения валюты записываются в `acore_auth.balance_transactions`. +Слёзы Элуны хранятся в `realmd.account_balances`, Сайт использует их как источник истины. Стандартный vMaNGOS не читает эти таблицы: для операций внутри игры нужна отдельная интеграция ядра с API ниже. Все движения валюты записываются в `realmd.balance_transactions`. Курс пополнения задаётся через `MOONWELL_TEARS_PER_RUBLE` и по умолчанию равен `10`: **1 рубль = 10 Слёз Элуны**. Суммы API игрового сервера и ручные операции администратора всегда указываются в Слёзах Элуны, а не в рублях. @@ -45,4 +45,4 @@ Content-Type: application/json `idempotency_key` должен быть уникальным для каждой игровой покупки. `amount` — количество списываемых Слёз Элуны. Повтор запроса с тем же ключом возвращает ту же операцию и не списывает валюту второй раз. Ответ `422` означает недостаточное количество или конфликт ключа; `401` — неверный серверный токен. -После развёртывания выполните `php artisan migrate --force`. Миграция создаёт таблицы непосредственно в подключении `azerothcore_auth`. +После развёртывания выполните `php artisan migrate --force`. Миграция создаёт таблицы непосредственно в подключении `vmangos_auth`. diff --git a/openapi.json b/openapi.json index c46485b..f9570a0 100644 --- a/openapi.json +++ b/openapi.json @@ -65,7 +65,7 @@ "Launcher Auth" ], "summary": "Авторизация лаунчера", - "description": "Авторизация по логину и паролю игрового аккаунта. Возвращает Bearer-токен для доступа к API.", + "description": "Авторизация по логину и паролю сайта. Возвращает Bearer-токен для доступа к API и выдачи игрового билета.", "operationId": "8b934358769733a6925c1fbc8dc07705", "requestBody": { "required": true, @@ -81,13 +81,13 @@ "type": "string", "pattern": "^[A-Za-z0-9]+$", "example": "Player", - "maxLength": 32, + "maxLength": 16, "minLength": 3 }, "password": { "type": "string", "example": "secret123", - "maxLength": 32, + "maxLength": 16, "minLength": 3 } }, @@ -409,8 +409,8 @@ "tags": [ "Launcher Auth" ], - "summary": "Issue a single-use game login ticket", - "description": "Creates short-lived SRP credentials for authserver. A new ticket atomically revokes the previous generation. The plaintext ticket is never stored.", + "summary": "Выдать одноразовый билет для входа в vMaNGOS", + "description": "Создаёт короткоживущий SRP-билет. Новый билет отзывает предыдущий.", "operationId": "7b54b9b05714077ff61483260a0e64a7", "requestBody": { "required": true, @@ -423,7 +423,7 @@ "properties": { "client_build": { "type": "integer", - "example": 12340 + "example": 5875 } }, "type": "object" @@ -433,7 +433,7 @@ }, "responses": { "200": { - "description": "Ticket issued", + "description": "Билет выдан", "content": { "application/json": { "schema": { @@ -449,8 +449,7 @@ }, "ticket": { "type": "string", - "pattern": "^[A-Z0-9]{16}$", - "example": "7W6D4M2Q9K8R3X5Z" + "pattern": "^[A-Z0-9]{16}$" }, "expires_at": { "type": "string", @@ -466,7 +465,7 @@ "description": "Launcher authentication required" }, "422": { - "description": "Client build is not allowed" + "description": "Unsupported client build" }, "429": { "description": "Too many requests" @@ -660,7 +659,7 @@ "Launcher Auth" ], "summary": "Зарегистрировать игровой аккаунт", - "description": "Создаёт игровой аккаунт AzerothCore. Инвайт-код обязателен, если на сервере включена регистрация по приглашениям.", + "description": "Создаёт аккаунт сайта и vMaNGOS со случайным игровым паролем. Для входа в игру лаунчер выдаёт билет. Инвайт-код обязателен, если включена регистрация по приглашениям.", "operationId": "bfc1dbd42cbcc339bc8a13dbf189c525", "requestBody": { "required": true, @@ -679,7 +678,7 @@ "type": "string", "pattern": "^[A-Za-z0-9]+$", "example": "Player", - "maxLength": 32, + "maxLength": 16, "minLength": 3 }, "email": { @@ -698,13 +697,13 @@ "password": { "type": "string", "example": "Secret123", - "maxLength": 32, + "maxLength": 16, "minLength": 8 }, "password_confirmation": { "type": "string", "example": "Secret123", - "maxLength": 32, + "maxLength": 16, "minLength": 8 }, "terms": { diff --git a/resources/js/Pages/Admin/LoginScreenNews.vue b/resources/js/Pages/Admin/LoginScreenNews.vue index 4244269..746c0d2 100644 --- a/resources/js/Pages/Admin/LoginScreenNews.vue +++ b/resources/js/Pages/Admin/LoginScreenNews.vue @@ -41,9 +41,9 @@ const editLabels = { submit: 'Сохранить запись', }; -const example = `|TInterface\\ICONS\\achievement_boss_lichking:25:25:0:-5|t |cFF00D1FFWrath of the Lich King|r -|TInterface\\ICONS\\achievement_zone_icecrown_01:18:18:0:-8|t |cFFA6D8FF- Артас пробуждается как Король-лич|r -|TInterface\\ICONS\\spell_deathknight_iceboundfortitude:18:18:0:-8|t |cFFA6D8FF- Рыцари смерти вторгаются в Азерот|r`; +const example = `|TInterface\\ICONS\\INV_Misc_QuestionMark:25:25:0:-5|t |cFF00D1FFКлассический Азерот|r +|cFFA6D8FF- Клиент 1.12.1 (build 5875)|r +|cFFA6D8FF- Собери отряд и отправляйся в приключение|r`;