launcher api

This commit is contained in:
2026-03-21 17:53:03 +04:00
parent 269255e04f
commit a7c5b36588
22 changed files with 2264 additions and 80 deletions
@@ -0,0 +1,3 @@
# Memory Index
- [project_overview.md](project_overview.md) — Laravel 12 + AzerothCore + Yandex S3, общая архитектура проекта Moonwell
@@ -0,0 +1,18 @@
---
name: Moonwell project overview
description: Laravel 12 web app for WoW private server (AzerothCore), with S3 storage and dual DB connections
type: project
---
Moonwell — веб-интерфейс для WoW приватного сервера на AzerothCore.
- **Стек:** Laravel 12, PHP 8.2+
- **S3:** Yandex Cloud Storage (endpoint: storage.yandexcloud.net, бакет: warcraft-client)
- **Базы данных AzerothCore:**
- `acore_auth` — авторизация
- `acore_characters` — персонажи
- **Локализация:** русский (APP_LOCALE=ru)
- **Деплой:** Docker Compose
**Why:** понимание стека и архитектуры проекта для корректной помощи.
**How to apply:** учитывать при работе с кодом — две внешние БД, S3 через Yandex Cloud, русскоязычный проект.
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Models\GameAccount;
use App\Services\AzerothCoreAccountService;
use Illuminate\Http\JsonResponse;
use OpenApi\Attributes as OA;
class LauncherAuthController extends Controller
{
public function __construct(
private readonly AzerothCoreAccountService $accounts,
) {
}
#[OA\Post(
path: '/api/launcher/login',
summary: 'Авторизация лаунчера',
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'),
],
),
),
responses: [
new OA\Response(
response: 200,
description: 'Успешная авторизация',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'access_token', type: 'string', description: 'JWT access token'),
new OA\Property(property: 'token_type', type: 'string', example: 'Bearer'),
new OA\Property(property: 'expires_at', type: 'string', format: 'date-time', example: '2026-04-20T13:00:00+00:00'),
],
),
),
new OA\Response(
response: 401,
description: 'Неверный логин или пароль',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Неверный логин или пароль.'),
],
),
),
new OA\Response(
response: 422,
description: 'Ошибка валидации',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'errors', type: 'object'),
],
),
),
new OA\Response(response: 429, description: 'Слишком много запросов'),
],
)]
public function login(LoginRequest $request): JsonResponse
{
$user = $this->accounts->findUserByUsername($request->input('username'));
if (! $user || ! $this->accounts->validatePassword($user, $request->input('password'))) {
return response()->json([
'message' => 'Неверный логин или пароль.',
], 401);
}
$gameAccount = GameAccount::on('azerothcore_auth')->findOrFail($user->id);
$token = $gameAccount->createToken('launcher');
return response()->json([
'access_token' => $token->accessToken,
'token_type' => 'Bearer',
'expires_at' => $token->token->expires_at?->toIso8601String(),
]);
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\StreamedResponse;
class LauncherController extends Controller
{
#[OA\Get(
path: '/api/launcher/manifest',
summary: 'Получить манифест файлов клиента',
description: 'Возвращает JSON-манифест со списком файлов клиента, их размерами и SHA-256 хешами. Лаунчер сверяет локальные файлы с манифестом и докачивает недостающие.',
tags: ['Launcher'],
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'Манифест файлов',
content: new OA\JsonContent(
properties: [
new OA\Property(
property: 'files',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'path', type: 'string', example: 'Data/common.MPQ'),
new OA\Property(property: 'size', type: 'integer', format: 'int64', example: 2881154862),
new OA\Property(property: 'sha256', type: 'string', format: 'hex', example: 'd850ffa5efd6a1ba845899a7d9f9ad27f6eea7ac606e95033506c72c86ee0236'),
],
),
),
],
),
),
new OA\Response(
response: 401,
description: 'Не авторизован',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Unauthenticated.'),
],
),
),
new OA\Response(
response: 404,
description: 'Манифест не найден в хранилище',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Манифест не найден.'),
],
),
),
],
)]
public function manifest(): JsonResponse
{
$disk = Storage::disk($this->disk());
$key = $this->basePath().'/'.config('moonwell.launcher.manifest_key');
if (! $disk->exists($key)) {
return response()->json(['message' => 'Манифест не найден.'], 404);
}
$manifest = json_decode($disk->get($key), true);
return response()->json($manifest);
}
#[OA\Get(
path: '/api/launcher/download/{path}',
summary: 'Скачать файл клиента',
description: 'Стримит файл из S3 по относительному пути из манифеста. Путь может содержать вложенные директории (например `Data/ruRU/patch-ruRU-3.MPQ`).',
tags: ['Launcher'],
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(
name: 'path',
in: 'path',
required: true,
description: 'Относительный путь к файлу из манифеста',
schema: new OA\Schema(type: 'string'),
example: 'Data/common.MPQ',
),
],
responses: [
new OA\Response(
response: 200,
description: 'Бинарное содержимое файла',
content: new OA\MediaType(
mediaType: 'application/octet-stream',
schema: new OA\Schema(type: 'string', format: 'binary'),
),
),
new OA\Response(
response: 401,
description: 'Не авторизован',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Unauthenticated.'),
],
),
),
new OA\Response(
response: 404,
description: 'Файл не найден',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Файл не найден.'),
],
),
),
],
)]
public function download(string $path): StreamedResponse|JsonResponse
{
$disk = Storage::disk($this->disk());
$fullPath = $this->basePath().'/'.$path;
if (! $disk->exists($fullPath)) {
return response()->json(['message' => 'Файл не найден.'], 404);
}
return $disk->download($fullPath);
}
private function disk(): string
{
return config('moonwell.launcher.disk', 's3');
}
private function basePath(): string
{
return config('moonwell.launcher.base_path', 'World of Warcraft');
}
}
+14
View File
@@ -2,6 +2,20 @@
namespace App\Http\Controllers;
use OpenApi\Attributes as OA;
#[OA\Info(
version: '1.0.0',
title: 'Moonwell Launcher API',
description: 'API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.',
)]
#[OA\SecurityScheme(
securityScheme: 'bearerAuth',
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Токен, полученный через POST /api/launcher/login',
)]
abstract class Controller
{
//
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForceJsonResponse
{
public function handle(Request $request, Closure $next): Response
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;
class GameAccount extends Authenticatable
{
use HasApiTokens;
protected $connection = 'azerothcore_auth';
protected $table = 'account';
public $timestamps = false;
protected $hidden = [
'salt',
'verifier',
'session_key',
'session_key_auth',
'totp_secret',
];
}
+5
View File
@@ -5,6 +5,7 @@ namespace App\Providers;
use App\Auth\AzerothCoreUserProvider;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider
{
@@ -24,5 +25,9 @@ class AppServiceProvider extends ServiceProvider
Auth::provider('azerothcore', function ($app): AzerothCoreUserProvider {
return $app->make(AzerothCoreUserProvider::class);
});
Passport::tokensExpireIn(now()->addDays(30));
Passport::refreshTokensExpireIn(now()->addDays(60));
Passport::personalAccessTokensExpireIn(now()->addDays(30));
}
}
+6
View File
@@ -7,12 +7,18 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
$middleware->api(prepend: [
\Illuminate\Http\Middleware\SetCacheHeaders::class.':no_store',
\App\Http\Middleware\ForceJsonResponse::class,
]);
$middleware->alias([
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
]);
+4 -2
View File
@@ -7,9 +7,11 @@
"license": "MIT",
"require": {
"php": "^8.2",
"league/flysystem-aws-s3-v3": "^3.29",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1"
"laravel/passport": "^13.6",
"laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.29",
"zircote/swagger-php": "^6.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
Generated
+1366 -78
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -40,6 +40,11 @@ return [
'driver' => 'session',
'provider' => 'game_accounts',
],
'api' => [
'driver' => 'passport',
'provider' => 'game_accounts_eloquent',
],
],
/*
@@ -63,6 +68,11 @@ return [
'game_accounts' => [
'driver' => 'azerothcore',
],
'game_accounts_eloquent' => [
'driver' => 'eloquent',
'model' => App\Models\GameAccount::class,
],
],
/*
+6
View File
@@ -24,6 +24,12 @@ return [
'temporary_url_minutes' => (int) env('MOONWELL_CLIENT_URL_TTL', 30),
],
'launcher' => [
'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'),
],
'realm' => [
'server_name' => env('MOONWELL_SERVER_NAME', 'Moonwell'),
'realm_name' => env('MOONWELL_REALM_NAME', 'Moonwell'),
+48
View File
@@ -0,0 +1,48 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Passport Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Passport will use when
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'api',
'middleware' => [],
/*
|--------------------------------------------------------------------------
| Encryption Keys
|--------------------------------------------------------------------------
|
| Passport uses encryption keys while generating secure access tokens for
| your application. By default, the keys are stored as local files but
| can be set via environment variables when that is more convenient.
|
*/
'private_key' => env('PASSPORT_PRIVATE_KEY'),
'public_key' => env('PASSPORT_PUBLIC_KEY'),
/*
|--------------------------------------------------------------------------
| Passport Database Connection
|--------------------------------------------------------------------------
|
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
|
*/
'connection' => env('PASSPORT_CONNECTION'),
];
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_auth_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->index();
$table->foreignUuid('client_id');
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_auth_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_access_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id');
$table->string('name')->nullable();
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->timestamps();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_access_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->char('access_token_id', 80)->index();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_refresh_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->nullableMorphs('owner');
$table->string('name');
$table->string('secret')->nullable();
$table->string('provider')->nullable();
$table->text('redirect_uris');
$table->text('grant_types');
$table->boolean('revoked');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_clients');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};
+288
View File
@@ -0,0 +1,288 @@
{
"openapi": "3.0.0",
"info": {
"title": "Moonwell Launcher API",
"description": "API для лаунчера WoW-клиента Moonwell. Авторизация, получение манифеста файлов и скачивание обновлений.",
"version": "1.0.0"
},
"paths": {
"/api/launcher/login": {
"post": {
"tags": [
"Launcher Auth"
],
"summary": "Авторизация лаунчера",
"description": "Авторизация по логину и паролю игрового аккаунта. Возвращает Bearer-токен для доступа к API.",
"operationId": "8b934358769733a6925c1fbc8dc07705",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"required": [
"username",
"password"
],
"properties": {
"username": {
"type": "string",
"pattern": "^[A-Za-z0-9]+$",
"example": "Player",
"maxLength": 32,
"minLength": 3
},
"password": {
"type": "string",
"example": "secret123",
"maxLength": 32,
"minLength": 3
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Успешная авторизация",
"content": {
"application/json": {
"schema": {
"properties": {
"access_token": {
"description": "JWT access token",
"type": "string"
},
"token_type": {
"type": "string",
"example": "Bearer"
},
"expires_at": {
"type": "string",
"format": "date-time",
"example": "2026-04-20T13:00:00+00:00"
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Неверный логин или пароль",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Неверный логин или пароль."
}
},
"type": "object"
}
}
}
},
"422": {
"description": "Ошибка валидации",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string"
},
"errors": {
"type": "object"
}
},
"type": "object"
}
}
}
},
"429": {
"description": "Слишком много запросов"
}
}
}
},
"/api/launcher/manifest": {
"get": {
"tags": [
"Launcher"
],
"summary": "Получить манифест файлов клиента",
"description": "Возвращает JSON-манифест со списком файлов клиента, их размерами и SHA-256 хешами. Лаунчер сверяет локальные файлы с манифестом и докачивает недостающие.",
"operationId": "2c449e4bb08c2d4c838e36d260a3b61d",
"responses": {
"200": {
"description": "Манифест файлов",
"content": {
"application/json": {
"schema": {
"properties": {
"files": {
"type": "array",
"items": {
"properties": {
"path": {
"type": "string",
"example": "Data/common.MPQ"
},
"size": {
"type": "integer",
"format": "int64",
"example": 2881154862
},
"sha256": {
"type": "string",
"format": "hex",
"example": "d850ffa5efd6a1ba845899a7d9f9ad27f6eea7ac606e95033506c72c86ee0236"
}
},
"type": "object"
}
}
},
"type": "object"
}
}
}
},
"401": {
"description": "Не авторизован",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Unauthenticated."
}
},
"type": "object"
}
}
}
},
"404": {
"description": "Манифест не найден в хранилище",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Манифест не найден."
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/launcher/download/{path}": {
"get": {
"tags": [
"Launcher"
],
"summary": "Скачать файл клиента",
"description": "Стримит файл из S3 по относительному пути из манифеста. Путь может содержать вложенные директории (например `Data/ruRU/patch-ruRU-3.MPQ`).",
"operationId": "9a94331833a7af795c1c57dfa3a77eb3",
"parameters": [
{
"name": "path",
"in": "path",
"description": "Относительный путь к файлу из манифеста",
"required": true,
"schema": {
"type": "string"
},
"example": "Data/common.MPQ"
}
],
"responses": {
"200": {
"description": "Бинарное содержимое файла",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"description": "Не авторизован",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Unauthenticated."
}
},
"type": "object"
}
}
}
},
"404": {
"description": "Файл не найден",
"content": {
"application/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Файл не найден."
}
},
"type": "object"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
]
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"description": "Токен, полученный через POST /api/launcher/login",
"bearerFormat": "JWT",
"scheme": "bearer"
}
}
},
"tags": [
{
"name": "Launcher Auth",
"description": "Launcher Auth"
},
{
"name": "Launcher",
"description": "Launcher"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\Api\LauncherAuthController;
use App\Http\Controllers\Api\LauncherController;
use Illuminate\Support\Facades\Route;
Route::post('launcher/login', [LauncherAuthController::class, 'login'])
->middleware('throttle:10,1');
Route::middleware('auth:api')->prefix('launcher')->group(function () {
Route::get('manifest', [LauncherController::class, 'manifest']);
Route::get('download/{path}', [LauncherController::class, 'download'])
->where('path', '.*');
});
+13
View File
@@ -208,6 +208,19 @@ main() {
log "Выполняю миграции"
app_exec php artisan migrate --force
log "Настраиваю Passport"
if [[ ! -f "${ROOT_DIR}/storage/oauth-private.key" ]]; then
app_exec php artisan passport:keys --force
log "Сгенерированы ключи Passport"
fi
if ! app_exec php artisan passport:client --personal --no-interaction 2>/dev/null | grep -q 'already exists'; then
app_exec php artisan passport:client --personal --name="Launcher" --no-interaction
log "Создан Personal Access Client «Launcher»"
else
log "Personal Access Client уже существует"
fi
if (( WARM_CACHE )); then
log "Обновляю Laravel-кэш"
app_exec php artisan optimize:clear