goon/app/api/expo_updates.py
goon-foss ad0284585b Initial commit
Goon — self-hosted aggregator for adult-content scene metadata.

Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites.
Cross-source deduplication via perceptual hash + Levenshtein distance.
FastAPI backend + APScheduler worker + React Native (Expo) mobile client.

FOSS, ad-free, donation-funded. See README for details.
2026-05-20 10:10:22 +02:00

104 lines
4.2 KiB
Python

"""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/<runtime>/<update_id>/`. Plik
`app/static/expo-updates/<runtime>/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/<runtime>/` —
zwykle `<update_id>/_expo/static/js/android/<hash>.js` lub
`<update_id>/assets/<hash>`. 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)