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.
104 lines
4.2 KiB
Python
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)
|