goon/app/api/expo_updates.py
https://github.com/goon-foss/goon 7979d5fa61 session work: bug-report fixes + WIP cleanup
User-facing bugs resolved (per bug_reports table 2026-05-25):
- 40cd28aa (short-scene filter): mobile api.ts default min_duration_sec=60
  hides 6519 sub-60s scenes across all list endpoints (Performer/Site/Tag/
  Browse). Caller may override with explicit 0.
- 5e89ef7e (porndoe needs cookies/play click): INJECTED_JS in PlayerScreen
  now auto-clicks player-poster overlay (player-poster-play, big-play-button,
  vjs-big-play-button, jw-icon-display, btn-big-play, mejs__overlay-button,
  play-button, btn-play, videoPlayButton). Triggered same interval as
  consent-dismiss + ad-iframe removal.
- b1b5e1a2 (Mixdrop czarny ekran): re-enable mixdrop direct stream via VPS
  curl_cffi proxy (was: skip → WebView fallback → blank screen). Backend
  pipeline (mixdrop.py extract + stream_proxy._curl_cffi_stream with JA3 +
  auto-refetch on token expire) was already complete; just removed the skip
  in app/api/playback.py.

Plus ongoing WIP (paradisehill multi-part extraction, stream_proxy refetch
logic, gesture race fix for long-press 2x speed, anti-adblock INJECTED_JS
defenses, scripts for freshporno backfill, new sources API).
2026-05-25 22:02:52 +02:00

110 lines
4.6 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.
"""
# Windows publish quirk: Expo metadata.json zapisuje assets[].path z backslashami
# (os.sep) na Windowsie. publish_update.py kopiuje to do URL → manifest zawiera
# `?asset=<update>/assets\<hash>`. Na Linux backslash nie jest separatorem path-a,
# więc Path resolve nie znalazłby pliku (404 na każdy asset → mobile odrzuca cały
# update). Normalizujemy tutaj zamiast wymagać re-publishu starych bundle'i.
asset = asset.replace("\\", "/")
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)