launcher api
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user