launcher api
This commit is contained in:
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
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
|
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 App\Auth\AzerothCoreUserProvider;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -24,5 +25,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Auth::provider('azerothcore', function ($app): AzerothCoreUserProvider {
|
Auth::provider('azerothcore', function ($app): AzerothCoreUserProvider {
|
||||||
return $app->make(AzerothCoreUserProvider::class);
|
return $app->make(AzerothCoreUserProvider::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Passport::tokensExpireIn(now()->addDays(30));
|
||||||
|
Passport::refreshTokensExpireIn(now()->addDays(60));
|
||||||
|
Passport::personalAccessTokensExpireIn(now()->addDays(30));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
|
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
|
||||||
|
|
||||||
|
$middleware->api(prepend: [
|
||||||
|
\Illuminate\Http\Middleware\SetCacheHeaders::class.':no_store',
|
||||||
|
\App\Http\Middleware\ForceJsonResponse::class,
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
|
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
+4
-2
@@ -7,9 +7,11 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"league/flysystem-aws-s3-v3": "^3.29",
|
|
||||||
"laravel/framework": "^12.0",
|
"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": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
Generated
+1366
-78
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,11 @@ return [
|
|||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'game_accounts',
|
'provider' => 'game_accounts',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'api' => [
|
||||||
|
'driver' => 'passport',
|
||||||
|
'provider' => 'game_accounts_eloquent',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -63,6 +68,11 @@ return [
|
|||||||
'game_accounts' => [
|
'game_accounts' => [
|
||||||
'driver' => 'azerothcore',
|
'driver' => 'azerothcore',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'game_accounts_eloquent' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => App\Models\GameAccount::class,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ return [
|
|||||||
'temporary_url_minutes' => (int) env('MOONWELL_CLIENT_URL_TTL', 30),
|
'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' => [
|
'realm' => [
|
||||||
'server_name' => env('MOONWELL_SERVER_NAME', 'Moonwell'),
|
'server_name' => env('MOONWELL_SERVER_NAME', 'Moonwell'),
|
||||||
'realm_name' => env('MOONWELL_REALM_NAME', 'Moonwell'),
|
'realm_name' => env('MOONWELL_REALM_NAME', 'Moonwell'),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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', '.*');
|
||||||
|
});
|
||||||
@@ -208,6 +208,19 @@ main() {
|
|||||||
log "Выполняю миграции"
|
log "Выполняю миграции"
|
||||||
app_exec php artisan migrate --force
|
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
|
if (( WARM_CACHE )); then
|
||||||
log "Обновляю Laravel-кэш"
|
log "Обновляю Laravel-кэш"
|
||||||
app_exec php artisan optimize:clear
|
app_exec php artisan optimize:clear
|
||||||
|
|||||||
Reference in New Issue
Block a user