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,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');
}
}