From 6c18cad816f756ba4962a97ddf970eb38b53c13a Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 28 Jul 2026 20:42:23 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=20=D1=84=D1=83=D0=BA=D0=BD?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B0=D0=BF=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../Api/LauncherAccountController.php | 50 +++ .../Api/LauncherRealmController.php | 73 +++++ .../Api/LauncherRegistrationController.php | 125 +++++++ app/Http/Controllers/Controller.php | 2 +- app/Services/LauncherRealmService.php | 74 +++++ app/Services/RealmStatusProbe.php | 33 ++ config/moonwell.php | 3 + openapi.json | 304 +++++++++++++++++- routes/api.php | 8 + tests/Feature/LauncherAccountFeatureTest.php | 40 +++ tests/Feature/LauncherRealmsFeatureTest.php | 88 +++++ .../LauncherRegistrationFeatureTest.php | 97 ++++++ 13 files changed, 894 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Api/LauncherAccountController.php create mode 100644 app/Http/Controllers/Api/LauncherRealmController.php create mode 100644 app/Http/Controllers/Api/LauncherRegistrationController.php create mode 100644 app/Services/LauncherRealmService.php create mode 100644 app/Services/RealmStatusProbe.php create mode 100644 tests/Feature/LauncherAccountFeatureTest.php create mode 100644 tests/Feature/LauncherRealmsFeatureTest.php create mode 100644 tests/Feature/LauncherRegistrationFeatureTest.php diff --git a/.env.example b/.env.example index a024a70..94652f6 100644 --- a/.env.example +++ b/.env.example @@ -124,6 +124,9 @@ MOONWELL_RATE_REPUTATION=x1 MOONWELL_CLIENT_DISK=s3 MOONWELL_CLIENT_OBJECT_KEY="World of Warcraft.zip" MOONWELL_CLIENT_URL_TTL=30 +MOONWELL_REALM_STATUS_HOST=play.moon-well.online +MOONWELL_REALM_STATUS_TIMEOUT=0.5 +MOONWELL_REALM_STATUS_CACHE=15 VITE_APP_NAME="${APP_NAME}" diff --git a/app/Http/Controllers/Api/LauncherAccountController.php b/app/Http/Controllers/Api/LauncherAccountController.php new file mode 100644 index 0000000..cd4268d --- /dev/null +++ b/app/Http/Controllers/Api/LauncherAccountController.php @@ -0,0 +1,50 @@ + []]], + responses: [ + new OA\Response( + response: 200, + description: 'Информация об аккаунте', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'username', type: 'string', example: 'PLAYER'), + new OA\Property(property: 'balance', type: 'string', example: '1250.00'), + ], + ), + ), + new OA\Response( + response: 401, + description: 'Не авторизован', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Unauthenticated.'), + ], + ), + ), + ], + )] + public function show(Request $request, BalanceService $balances): JsonResponse + { + $account = $request->user(); + + return response()->json([ + 'username' => (string) $account->username, + 'balance' => $balances->balance((int) $account->id), + ]); + } +} diff --git a/app/Http/Controllers/Api/LauncherRealmController.php b/app/Http/Controllers/Api/LauncherRealmController.php new file mode 100644 index 0000000..484069d --- /dev/null +++ b/app/Http/Controllers/Api/LauncherRealmController.php @@ -0,0 +1,73 @@ +json([ + 'data' => $realms->realms(), + 'checked_at' => now()->toIso8601String(), + ]); + } catch (Throwable $exception) { + report($exception); + + return response()->json([ + 'message' => 'Не удалось получить список реалмов.', + ], 503); + } + } +} diff --git a/app/Http/Controllers/Api/LauncherRegistrationController.php b/app/Http/Controllers/Api/LauncherRegistrationController.php new file mode 100644 index 0000000..e1aef2f --- /dev/null +++ b/app/Http/Controllers/Api/LauncherRegistrationController.php @@ -0,0 +1,125 @@ + $registrar->register( + $request->string('username')->toString(), + $request->string('email')->toString(), + $request->string('password')->toString(), + ); + + $account = config('moonwell.registration.require_invite_code') + ? $inviteCodes->redeemForRegistration( + $request->string('invite_code')->toString(), + $request->string('username')->toString(), + $register, + ) + : $register(); + } catch (DuplicateGameAccountException|InviteCodeException $exception) { + return response()->json([ + 'message' => $exception->getMessage(), + 'errors' => [ + $exception->field => [$exception->getMessage()], + ], + ], 422); + } catch (Throwable $exception) { + report($exception); + + return response()->json([ + 'message' => 'Не удалось создать аккаунт. Попробуй снова позже.', + ], 500); + } + + return response()->json([ + 'message' => 'Игровой аккаунт успешно создан.', + 'account' => [ + 'id' => $account['id'], + 'username' => $account['username'], + ], + ], 201); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 6e34faf..0f8bd23 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -7,7 +7,7 @@ use OpenApi\Attributes as OA; #[OA\Info( version: '1.0.0', title: 'Moonwell Launcher API', - description: 'API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.', + description: 'API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.', )] #[OA\SecurityScheme( securityScheme: 'bearerAuth', diff --git a/app/Services/LauncherRealmService.php b/app/Services/LauncherRealmService.php new file mode 100644 index 0000000..16a22bb --- /dev/null +++ b/app/Services/LauncherRealmService.php @@ -0,0 +1,74 @@ +> + */ + public function realms(): Collection + { + $cacheSeconds = max(0, (int) config('moonwell.launcher.realm_status_cache', 15)); + + if ($cacheSeconds === 0) { + return $this->loadRealms(); + } + + return Cache::remember( + 'launcher.realm-status', + now()->addSeconds($cacheSeconds), + fn (): Collection => $this->loadRealms(), + ); + } + + /** + * @return Collection> + */ + private function loadRealms(): Collection + { + return DB::connection(config('moonwell.auth_connection')) + ->table('realmlist') + ->orderBy('id') + ->get([ + 'id', + 'name', + 'address', + 'port', + 'icon', + 'flag', + 'timezone', + 'population', + ]) + ->map(function (object $realm): array { + $probeAddress = trim((string) config('moonwell.launcher.realm_status_host')); + $online = $this->statusProbe->isOnline( + $probeAddress !== '' ? $probeAddress : (string) $realm->address, + (int) $realm->port, + ); + + return [ + 'id' => (int) $realm->id, + 'name' => (string) $realm->name, + 'address' => (string) $realm->address, + 'port' => (int) $realm->port, + 'icon' => (int) $realm->icon, + 'flag' => (int) $realm->flag, + 'timezone' => (int) $realm->timezone, + 'population' => (float) $realm->population, + 'online' => $online, + 'status' => $online ? 'online' : 'offline', + ]; + }) + ->values(); + } +} diff --git a/app/Services/RealmStatusProbe.php b/app/Services/RealmStatusProbe.php new file mode 100644 index 0000000..2c1dbb6 --- /dev/null +++ b/app/Services/RealmStatusProbe.php @@ -0,0 +1,33 @@ + 65535) { + return false; + } + + $host = str_contains($address, ':') && ! str_starts_with($address, '[') + ? '['.$address.']' + : $address; + $timeout = max(0.1, min(3.0, (float) config('moonwell.launcher.realm_status_timeout', 0.5))); + $socket = @stream_socket_client( + 'tcp://'.$host.':'.$port, + $errorCode, + $errorMessage, + $timeout, + STREAM_CLIENT_CONNECT, + ); + + if ($socket === false) { + return false; + } + + fclose($socket); + + return true; + } +} diff --git a/config/moonwell.php b/config/moonwell.php index c980d7e..795b19e 100644 --- a/config/moonwell.php +++ b/config/moonwell.php @@ -39,6 +39,9 @@ return [ 'base_path' => env('MOONWELL_LAUNCHER_BASE_PATH', 'World of Warcraft'), 'manifest_key' => env('MOONWELL_LAUNCHER_MANIFEST_KEY', 'manifest.json'), 'download_ttl' => (int) env('MOONWELL_LAUNCHER_DOWNLOAD_TTL', 60), + '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), ], 'realm' => [ diff --git a/openapi.json b/openapi.json index ccad65d..c4f30ea 100644 --- a/openapi.json +++ b/openapi.json @@ -2,10 +2,63 @@ "openapi": "3.0.0", "info": { "title": "Moonwell Launcher API", - "description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.", + "description": "API для лаунчера WoW-клиента Moonwell. Авторизация, новости, реалмы и их статусы, получение манифеста файлов и скачивание обновлений.", "version": "1.0.0" }, "paths": { + "/api/launcher/account": { + "get": { + "tags": [ + "Launcher" + ], + "summary": "Получить информацию об аккаунте", + "description": "Возвращает имя и текущий баланс авторизованного игрового аккаунта.", + "operationId": "9a18d29372e8de08e1d24237580f19e5", + "responses": { + "200": { + "description": "Информация об аккаунте", + "content": { + "application/json": { + "schema": { + "properties": { + "username": { + "type": "string", + "example": "PLAYER" + }, + "balance": { + "type": "string", + "example": "1250.00" + } + }, + "type": "object" + } + } + } + }, + "401": { + "description": "Не авторизован", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Unauthenticated." + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "/api/launcher/login": { "post": { "tags": [ @@ -425,6 +478,247 @@ } ] } + }, + "/api/launcher/realms": { + "get": { + "tags": [ + "Launcher" + ], + "summary": "Получить список реалмов и их статус", + "description": "Возвращает реалмы из таблицы realmlist. Статус online означает, что игровой порт реалма доступен по TCP.", + "operationId": "6d142b37a7b9a121b7ad02fd6405e054", + "responses": { + "200": { + "description": "Список реалмов", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "MoonWell" + }, + "address": { + "type": "string", + "example": "logon.moon-well.online" + }, + "port": { + "type": "integer", + "example": 8085 + }, + "icon": { + "type": "integer", + "example": 1 + }, + "flag": { + "type": "integer", + "example": 0 + }, + "timezone": { + "type": "integer", + "example": 4 + }, + "population": { + "type": "number", + "format": "float", + "example": 0.5 + }, + "online": { + "type": "boolean", + "example": true + }, + "status": { + "type": "string", + "example": "online", + "enum": [ + "online", + "offline" + ] + } + }, + "type": "object" + } + }, + "checked_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + } + } + } + }, + "503": { + "description": "Не удалось получить список реалмов", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Не удалось получить список реалмов." + } + }, + "type": "object" + } + } + } + }, + "429": { + "description": "Слишком много запросов" + } + } + } + }, + "/api/launcher/register": { + "post": { + "tags": [ + "Launcher Auth" + ], + "summary": "Зарегистрировать игровой аккаунт", + "description": "Создаёт игровой аккаунт AzerothCore. Инвайт-код обязателен, если на сервере включена регистрация по приглашениям.", + "operationId": "bfc1dbd42cbcc339bc8a13dbf189c525", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "username", + "email", + "password", + "password_confirmation", + "terms" + ], + "properties": { + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9]+$", + "example": "Player", + "maxLength": 32, + "minLength": 3 + }, + "email": { + "type": "string", + "format": "email", + "example": "player@example.com", + "maxLength": 255 + }, + "invite_code": { + "type": "string", + "example": "ABCD-EFGH-IJKL", + "nullable": true, + "maxLength": 32, + "minLength": 6 + }, + "password": { + "type": "string", + "example": "Secret123", + "maxLength": 32, + "minLength": 8 + }, + "password_confirmation": { + "type": "string", + "example": "Secret123", + "maxLength": 32, + "minLength": 8 + }, + "terms": { + "description": "Согласие с правилами сервера и пользовательским соглашением", + "type": "boolean", + "example": true + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Аккаунт создан", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Игровой аккаунт успешно создан." + }, + "account": { + "properties": { + "id": { + "type": "integer", + "example": 77 + }, + "username": { + "type": "string", + "example": "PLAYER" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } + }, + "422": { + "description": "Ошибка валидации, занятый логин или неверный инвайт-код", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Такой игровой аккаунт уже существует." + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "type": "object" + } + } + } + }, + "500": { + "description": "Внутренняя ошибка регистрации", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Не удалось создать аккаунт. Попробуй снова позже." + } + }, + "type": "object" + } + } + } + }, + "429": { + "description": "Слишком много запросов" + } + } + } } }, "components": { @@ -444,14 +738,14 @@ } }, "tags": [ - { - "name": "Launcher Auth", - "description": "Launcher Auth" - }, { "name": "Launcher", "description": "Launcher" }, + { + "name": "Launcher Auth", + "description": "Launcher Auth" + }, { "name": "Launcher Service", "description": "Launcher Service" diff --git a/routes/api.php b/routes/api.php index 0580a6f..78db427 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,16 +1,24 @@ middleware('throttle:10,1'); +Route::post('launcher/register', [LauncherRegistrationController::class, 'store']) + ->middleware('throttle:8,1'); +Route::get('launcher/realms', [LauncherRealmController::class, 'index']) + ->middleware('throttle:60,1'); Route::middleware('auth:api')->prefix('launcher')->group(function () { + Route::get('account', [LauncherAccountController::class, 'show']); Route::get('manifest', [LauncherController::class, 'manifest']); Route::get('download/{path}', [LauncherController::class, 'download']) ->where('path', '.*'); diff --git a/tests/Feature/LauncherAccountFeatureTest.php b/tests/Feature/LauncherAccountFeatureTest.php new file mode 100644 index 0000000..d07574c --- /dev/null +++ b/tests/Feature/LauncherAccountFeatureTest.php @@ -0,0 +1,40 @@ +id = 7; + $account->username = 'PLAYERONE'; + Passport::actingAs($account); + + $this->mock(BalanceService::class, function (MockInterface $mock): void { + $mock->shouldReceive('balance') + ->once() + ->with(7) + ->andReturn('1250.00'); + }); + + $this->getJson('/api/launcher/account') + ->assertOk() + ->assertExactJson([ + 'username' => 'PLAYERONE', + 'balance' => '1250.00', + ]); + } + + public function test_launcher_account_endpoint_requires_authentication(): void + { + $this->getJson('/api/launcher/account') + ->assertUnauthorized(); + } +} diff --git a/tests/Feature/LauncherRealmsFeatureTest.php b/tests/Feature/LauncherRealmsFeatureTest.php new file mode 100644 index 0000000..f4365fb --- /dev/null +++ b/tests/Feature/LauncherRealmsFeatureTest.php @@ -0,0 +1,88 @@ + 'azerothcore_auth', + 'moonwell.launcher.realm_status_cache' => 0, + 'moonwell.launcher.realm_status_host' => null, + 'database.connections.azerothcore_auth' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ], + ]); + DB::purge('azerothcore_auth'); + + Schema::connection('azerothcore_auth')->create('realmlist', function (Blueprint $table): void { + $table->unsignedInteger('id')->primary(); + $table->string('name'); + $table->string('address'); + $table->unsignedInteger('port'); + $table->unsignedInteger('icon')->default(0); + $table->unsignedInteger('flag')->default(0); + $table->unsignedInteger('timezone')->default(0); + $table->float('population')->default(0); + $table->unsignedInteger('gamebuild')->default(12340); + }); + } + + public function test_launcher_can_get_realms_and_their_status_without_authentication(): void + { + DB::connection('azerothcore_auth')->table('realmlist')->insert([ + [ + 'id' => 1, + 'name' => 'MoonWell', + 'address' => 'realm.moon-well.online', + 'port' => 8085, + 'icon' => 1, + 'flag' => 0, + 'timezone' => 4, + 'population' => 0.5, + 'gamebuild' => 12340, + ], + [ + 'id' => 2, + 'name' => 'MoonWell PTR', + 'address' => 'ptr.moon-well.online', + 'port' => 8085, + 'icon' => 1, + 'flag' => 0, + 'timezone' => 4, + 'population' => 0, + 'gamebuild' => 12340, + ], + ]); + $this->mock(RealmStatusProbe::class, function (MockInterface $mock): void { + $mock->shouldReceive('isOnline') + ->once() + ->with('realm.moon-well.online', 8085) + ->andReturnTrue(); + $mock->shouldReceive('isOnline') + ->once() + ->with('ptr.moon-well.online', 8085) + ->andReturnFalse(); + }); + + $this->getJson('/api/launcher/realms') + ->assertOk() + ->assertJsonPath('data.0.name', 'MoonWell') + ->assertJsonPath('data.0.online', true) + ->assertJsonPath('data.0.status', 'online') + ->assertJsonPath('data.1.status', 'offline') + ->assertJsonStructure(['data', 'checked_at']); + } +} diff --git a/tests/Feature/LauncherRegistrationFeatureTest.php b/tests/Feature/LauncherRegistrationFeatureTest.php new file mode 100644 index 0000000..5a9345b --- /dev/null +++ b/tests/Feature/LauncherRegistrationFeatureTest.php @@ -0,0 +1,97 @@ + true]); + } + + public function test_launcher_can_register_game_account(): void + { + $this->mock(InviteCodeService::class, function (MockInterface $mock): void { + $mock->shouldReceive('redeemForRegistration') + ->once() + ->with('INVITE-1234', 'playerone', \Mockery::type(Closure::class)) + ->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback()); + }); + $this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void { + $mock->shouldReceive('register') + ->once() + ->with('playerone', 'player@example.com', 'Pass1234') + ->andReturn(['id' => 77, 'username' => 'PLAYERONE']); + }); + + $this->postJson('/api/launcher/register', [ + 'username' => 'playerone', + 'email' => 'player@example.com', + 'invite_code' => 'INVITE-1234', + 'password' => 'Pass1234', + 'password_confirmation' => 'Pass1234', + 'terms' => true, + ]) + ->assertCreated() + ->assertJson([ + 'message' => 'Игровой аккаунт успешно создан.', + 'account' => [ + 'id' => 77, + 'username' => 'PLAYERONE', + ], + ]); + } + + public function test_launcher_registration_returns_validation_errors_as_json(): void + { + $this->postJson('/api/launcher/register', [ + 'username' => 'игрок!', + 'email' => 'invalid', + 'password' => '123', + 'password_confirmation' => '456', + 'terms' => false, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors([ + 'username', + 'email', + 'invite_code', + 'password', + 'terms', + ]); + } + + public function test_launcher_registration_reports_duplicate_username(): void + { + $this->mock(InviteCodeService::class, function (MockInterface $mock): void { + $mock->shouldReceive('redeemForRegistration') + ->once() + ->andReturnUsing(fn (string $code, string $username, Closure $callback): array => $callback()); + }); + $this->mock(AzerothCoreAccountRegistrar::class, function (MockInterface $mock): void { + $mock->shouldReceive('register') + ->once() + ->andThrow(DuplicateGameAccountException::forUsername()); + }); + + $this->postJson('/api/launcher/register', [ + 'username' => 'playerone', + 'email' => 'player@example.com', + 'invite_code' => 'INVITE-1234', + 'password' => 'Pass1234', + 'password_confirmation' => 'Pass1234', + 'terms' => true, + ]) + ->assertUnprocessable() + ->assertJsonPath('errors.username.0', 'Такой игровой аккаунт уже существует.'); + } +}