diff --git a/README.md b/README.md index e4d27e2..299688d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,23 @@ npm run build ## Игровые функции +Манифест нового Rust-клиента публикуется инструментом `moonwell-deploy` из +`moonwell-client-core`. Файлы лежат в +`World of Warcraft 1.12.1/releases//`, активный манифест — +`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` продолжает использовать `/`. Новый манифест +переключается после загрузки всех объектов; старые релизы остаются в S3 для +докачивания и отката. Публикации запускаются последовательно. Windows и macOS +нуждаются в отдельных наборах файлов и настройках манифеста: выбор ОС API пока +не выполняет. + - Регистрация создаёт случайный постоянный игровой пароль в `account.s`/`account.v`, SRP-данные пароля сайта в `site_credentials`, запись в `realmcharacters` для каждого реалма и доступ в `account_access`. - Вход на сайт и через API лаунчера сверяет пароль с `site_credentials`. Смена пароля в кабинете обновляет только эти данные. - Админка меняет уровень доступа в `account_access`. Для чтения персонажей используется `characters.played_time_total`. diff --git a/app/Http/Controllers/Api/LauncherController.php b/app/Http/Controllers/Api/LauncherController.php index 37fb06f..7136546 100644 --- a/app/Http/Controllers/Api/LauncherController.php +++ b/app/Http/Controllers/Api/LauncherController.php @@ -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), ['', '.', '..']); + } } diff --git a/tests/Feature/LauncherManifestFeatureTest.php b/tests/Feature/LauncherManifestFeatureTest.php new file mode 100644 index 0000000..f68da54 --- /dev/null +++ b/tests/Feature/LauncherManifestFeatureTest.php @@ -0,0 +1,72 @@ + '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])); + } +}