тикеты ланучера

This commit is contained in:
2026-10-04 12:15:27 +04:00
parent 93a9442e1c
commit 028aa9e35d
3 changed files with 119 additions and 1 deletions
+17
View File
@@ -34,6 +34,23 @@ npm run build
## Игровые функции
Манифест нового Rust-клиента публикуется инструментом `moonwell-deploy` из
`moonwell-client-core`. Файлы лежат в
`World of Warcraft 1.12.1/releases/<buildHash>/`, активный манифест —
`manifest-1.12.1.json`. Для этих значений задайте
`MOONWELL_LAUNCHER_BASE_PATH="World of Warcraft 1.12.1"` и
`MOONWELL_LAUNCHER_MANIFEST_KEY=manifest-1.12.1.json` в `.env`, затем очистите кеш
конфигурации. Учётные данные `AWS_*` должны указывать на то же хранилище, что
сборщик. Секреты не передаются лаунчеру.
`GET /api/launcher/download/{path}` разрешает путь только из активного манифеста
и подписывает его `objectKey` в пределах настроенного префикса. Старый манифест
без `objectKey` продолжает использовать `<base-path>/<path>`. Новый манифест
переключается после загрузки всех объектов; старые релизы остаются в S3 для
докачивания и отката. Публикации запускаются последовательно. Windows и macOS
нуждаются в отдельных наборах файлов и настройках манифеста: выбор ОС API пока
не выполняет.
- Регистрация создаёт случайный постоянный игровой пароль в `account.s`/`account.v`, SRP-данные пароля сайта в `site_credentials`, запись в `realmcharacters` для каждого реалма и доступ в `account_access`.
- Вход на сайт и через API лаунчера сверяет пароль с `site_credentials`. Смена пароля в кабинете обновляет только эти данные.
- Админка меняет уровень доступа в `account_access`. Для чтения персонажей используется `characters.played_time_total`.
@@ -120,7 +120,30 @@ class LauncherController extends Controller
public function download(string $path): JsonResponse
{
$disk = Storage::disk($this->disk());
$fullPath = $this->basePath() . '/' . $path;
if (! $this->validRelativePath($path)) {
return response()->json(['message' => 'Недопустимый путь.'], 422);
}
$manifestKey = config('moonwell.launcher.manifest_key');
if (! $disk->exists($manifestKey)) {
return response()->json(['message' => 'Манифест не найден.'], 404);
}
$manifest = json_decode($disk->get($manifestKey), true);
$file = collect($manifest['files'] ?? [])->first(
fn ($file) => is_array($file) && ($file['path'] ?? null) === $path,
);
if ($file === null) {
return response()->json(['message' => 'Файл не найден в манифесте.'], 404);
}
// New Rust deployments pin each file to an immutable release; legacy inventories use the base path.
$basePath = $this->basePath();
$fullPath = $file['objectKey'] ?? $basePath.'/'.$path;
if (! is_string($fullPath) || ! $this->validRelativePath($fullPath)
|| ! str_starts_with($fullPath, $basePath.'/')) {
return response()->json(['message' => 'Некорректный ключ файла в манифесте.'], 500);
}
if (! $disk->exists($fullPath)) {
return response()->json(['message' => 'Файл не найден.'], 404);
@@ -213,4 +236,10 @@ class LauncherController extends Controller
{
return config('moonwell.launcher.base_path', 'World of Warcraft 1.12.1');
}
private function validRelativePath(string $path): bool
{
return $path !== '' && ! preg_match('/[\\\\:\x00-\x1f\x7f]/', $path)
&& ! array_intersect(explode('/', $path), ['', '.', '..']);
}
}
@@ -0,0 +1,72 @@
<?php
namespace Tests\Feature;
use App\Http\Controllers\Api\LauncherController;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class LauncherManifestFeatureTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Storage::fake('launcher-test');
config([
'moonwell.launcher.disk' => 'launcher-test',
'moonwell.launcher.base_path' => 'World of Warcraft 1.12.1',
'moonwell.launcher.manifest_key' => 'manifest-1.12.1.json',
]);
Storage::disk('launcher-test')->buildTemporaryUrlsUsing(
fn ($path) => 'https://download.example/'.rawurlencode($path),
);
}
public function test_download_resolves_the_file_from_the_active_release(): void
{
$key = 'World of Warcraft 1.12.1/releases/build-a/MoonWell.exe';
$this->putManifest([['path' => 'MoonWell.exe', 'objectKey' => $key]]);
Storage::disk('launcher-test')->put($key, 'player binary');
$response = app(LauncherController::class)->download('MoonWell.exe');
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('https://download.example/'.rawurlencode($key), $response->getData(true)['url']);
}
public function test_legacy_manifest_without_object_keys_still_works(): void
{
$key = 'World of Warcraft 1.12.1/Data/dbc.MPQ';
$this->putManifest([['path' => 'Data/dbc.MPQ']]);
Storage::disk('launcher-test')->put($key, 'vanilla data');
$response = app(LauncherController::class)->download('Data/dbc.MPQ');
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('https://download.example/'.rawurlencode($key), $response->getData(true)['url']);
}
public function test_unlisted_objects_and_path_traversal_cannot_be_downloaded(): void
{
$this->putManifest([]);
Storage::disk('launcher-test')->put('World of Warcraft 1.12.1/secret.txt', 'secret');
$controller = app(LauncherController::class);
$this->assertSame(404, $controller->download('secret.txt')->getStatusCode());
foreach (['../secret.txt', '/secret.txt', 'Data/../secret.txt', 'Data\\secret.txt', 'C:/secret.txt'] as $path) {
$this->assertSame(422, $controller->download($path)->getStatusCode());
}
}
public function test_manifest_cannot_reference_an_object_outside_the_client_prefix(): void
{
$this->putManifest([['path' => 'MoonWell.exe', 'objectKey' => 'another-project/secret.txt']]);
$this->assertSame(500, app(LauncherController::class)->download('MoonWell.exe')->getStatusCode());
}
private function putManifest(array $files): void
{
Storage::disk('launcher-test')->put('manifest-1.12.1.json', json_encode(['files' => $files]));
}
}