"""Expo Updates serving endpoints (OTA JS bundle distribution). Mobile sprawdza `/expo-updates/manifest` przy każdym launch (lub on-foreground). Serwer zwraca aktualny manifest dla danego `expo-runtime-version`. Mobile pobiera launchAsset (bundle) + assets, zapisuje, restartuje aplikację z nowym bundle. Każdy update wgrany przez `scripts/publish_update.py` ląduje w `app/static/expo-updates///`. Plik `app/static/expo-updates//current.json` wskazuje aktywny update_id. Endpointy SĄ PUBLICZNE (no auth) — Expo Updates SDK nie wstrzykuje X-API-Key. Bezpieczeństwo opiera się na TLS pinningu (mobile ufa tylko naszej self-signed cert SPKI z network_security_config) — ktoś bez tego pinu nie podstawi MITM manifestu. Jeśli kiedyś trzeba twardo: dorobić expo-updates code signing key. """ from __future__ import annotations import json import logging from pathlib import Path from fastapi import APIRouter, Header, HTTPException, Query from fastapi.responses import FileResponse, JSONResponse, Response log = logging.getLogger(__name__) router = APIRouter(tags=["expo-updates"]) _STATIC_DIR = Path(__file__).resolve().parent.parent / "static" / "expo-updates" @router.get("/expo-updates/manifest") def get_manifest( expo_runtime_version: str | None = Header(default=None, alias="expo-runtime-version"), expo_platform: str | None = Header(default=None, alias="expo-platform"), ) -> Response: """Zwraca aktualny manifest dla podanego `expo-runtime-version` (default 1.0) + platform (default android — i tak tylko Android wspieramy). 204 No Content gdy nie ma update'u dla tego runtime'u → klient nadal odpala embedded bundle z APK. Mobile zna `expo-protocol-version` (single-manifest Mode), więc nie potrzebujemy multipart. """ runtime = expo_runtime_version or "1.0" runtime_dir = _STATIC_DIR / runtime current_file = runtime_dir / "current.json" if not current_file.exists(): return Response(status_code=204) try: current = json.loads(current_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: log.warning("expo-updates: bad current.json for runtime=%s: %s", runtime, e) return Response(status_code=204) update_id = current.get("update_id") if not update_id: return Response(status_code=204) manifest_file = runtime_dir / update_id / "manifest.json" if not manifest_file.exists(): log.warning("expo-updates: current points to missing update %s", update_id) return Response(status_code=204) try: manifest = json.loads(manifest_file.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: log.error("expo-updates: bad manifest.json for %s: %s", update_id, e) return Response(status_code=204) return JSONResponse( manifest, headers={ "expo-protocol-version": "1", "expo-sfv-version": "0", "cache-control": "private, max-age=0", "content-type": "application/json; charset=utf-8", }, ) @router.get("/expo-updates/asset") def get_asset( asset: str = Query(..., description="Relative path do pliku w runtime dir"), runtimeVersion: str = Query("1.0"), platform: str = Query("android"), ) -> Response: """Serwuje pojedynczy asset (JS bundle, image, font) z update directory. `asset` to relative path względem `static/expo-updates//` — zwykle `/_expo/static/js/android/.js` lub `/assets/`. Path traversal blocked przez resolve+is_relative. """ runtime_dir = (_STATIC_DIR / runtimeVersion).resolve() target = (runtime_dir / asset).resolve() if not str(target).startswith(str(runtime_dir)): raise HTTPException(status_code=400, detail="invalid asset path") if not target.exists() or not target.is_file(): raise HTTPException(status_code=404, detail="asset not found") # Content type — bundle to text/javascript, reszta autodetect przez FileResponse. media_type = None if target.suffix in (".js", ".bundle"): media_type = "application/javascript" return FileResponse(target, media_type=media_type)