[]]], 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 = 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: 'Возвращает временную presigned-ссылку на файл в 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\JsonContent( properties: [ new OA\Property(property: 'url', type: 'string', format: 'uri', description: 'Presigned URL для скачивания'), new OA\Property(property: 'expires_in', type: 'integer', description: 'Время жизни ссылки в секундах', example: 3600), ], ), ), 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): JsonResponse { $disk = Storage::disk($this->disk()); $fullPath = $this->basePath() . '/' . $path; if (! $disk->exists($fullPath)) { return response()->json(['message' => 'Файл не найден.'], 404); } $ttlMinutes = config('moonwell.launcher.download_ttl', 60); $url = $disk->temporaryUrl($fullPath, now()->addMinutes($ttlMinutes)); return response()->json([ 'url' => $url, 'expires_in' => $ttlMinutes * 60, ]); } #[OA\Post( path: '/api/service/update-appcast', summary: 'Обновить appcast лаунчера', description: 'Служебный endpoint для разработчика лаунчера. Принимает готовый XML appcast в формате RSS 2.0/Sparkle, который использует Flutter-пакет `upgrader`, и сохраняет его как публичный `/appcast.xml` на сервере. Тело запроса передается как raw XML, без JSON-обертки.', tags: ['Launcher Service'], security: [['launcherAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\MediaType( mediaType: 'application/xml', schema: new OA\Schema( type: 'string', example: "\n\n \n Moonwell Launcher - Appcast\n \n Version 1.2.0\n Описание изменений для окна обновления.\n Sat, 16 May 2026 12:00:00 +0400\n \n \n \n", ), ), ), responses: [ new OA\Response( response: 200, description: 'Appcast сохранен', content: new OA\JsonContent( properties: [ new OA\Property(property: 'message', type: 'string', example: 'XML saved'), ], ), ), new OA\Response( response: 403, description: 'Нет доступа или передано пустое тело запроса', content: new OA\JsonContent( properties: [ new OA\Property(property: 'message', type: 'string', example: 'empty body'), ], ), ), new OA\Response( response: 500, description: 'Не удалось сохранить appcast.xml', content: new OA\JsonContent( properties: [ new OA\Property(property: 'message', type: 'string', example: 'Failed to save XML'), ], ), ), ], )] public function updateAppcast(Request $request): JsonResponse { $xml = $request->getContent(); if (blank($xml)) { return response()->json([ 'message' => 'empty body', ], 403); } $saved = Storage::disk('public')->put('appcast.xml', $xml); if (! $saved) { return response()->json([ 'message' => 'Failed to save XML', ], 500); } return response()->json([ 'message' => 'XML saved', ]); } private function disk(): string { return config('moonwell.launcher.disk', 's3'); } private function basePath(): string { return config('moonwell.launcher.base_path', 'World of Warcraft'); } }