# backend/routes/model_proxy_routes.py # S4: real Sketchfab proxy backed by the OMEGA vault token (usb_vault.get_secret). # Previously all endpoints were honest AR-FIX-SESSION stubs (ok=False) and the phone # flow silently degraded to curated fallbacks even though the vault held a working # Sketchfab token. CGTrader/TurboSquid/Mixamo remain honest stubs — no public APIs. # Mounted at /api prefix in main.py (no auth — WebAR calls these from the browser; # the vault token never leaves the backend, only search results/download links do). import asyncio import logging from fastapi import APIRouter logger = logging.getLogger(__name__) router = APIRouter() SKETCHFAB_API = "https://api.sketchfab.com/v3" SKETCHFAB_TIMEOUT_S = 15 # Mirrors SKETCHFAB_ALLOWED_LICENSE_SLUGS in phone/eightwall_ar/utils/model-sources.js — # free licenses only; the client re-filters, this is defense in depth. ALLOWED_LICENSES = {"by", "by-sa", "by-nd", "by-nc", "by-nc-sa", "by-nc-nd", "cc0", "standard"} def _vault_sketchfab_token() -> str: try: from backend.services.usb_vault import get_secret return get_secret("SKETCHFAB_TOKEN") or "" except Exception as e: logger.warning(f"Sketchfab proxy: vault unavailable: {e}") return "" def _normalize_license(value) -> str: # Mirrors normalizeSketchfabLicense in model-sources.js. Verified live 2026-07-17: # the v3 API returns {"uid": , "label": "CC Attribution-..."} — no slug — # so slug-only matching filtered every result to zero. Label heuristics required. if not value: return "" if isinstance(value, dict): value = value.get("slug") or value.get("label") or value.get("uid") or "" raw = str(value).strip().lower() if raw in ALLOWED_LICENSES: return raw if "free standard" in raw or raw == "standard": return "standard" if "cc0" in raw or "public domain" in raw: return "cc0" if "attribution" not in raw and "creative commons" not in raw: return raw non_commercial = "noncommercial" in raw or "non-commercial" in raw no_derivatives = "noderivs" in raw or "no derivatives" in raw or "no-derivatives" in raw share_alike = "sharealike" in raw or "share alike" in raw or "share-alike" in raw if non_commercial and no_derivatives: return "by-nc-nd" if non_commercial and share_alike: return "by-nc-sa" if non_commercial: return "by-nc" if no_derivatives: return "by-nd" if share_alike: return "by-sa" return "by" def _archive_entry(archives: dict, fmt: str) -> dict: # With archives_flavours=true the API returns a LIST of flavours per format # (largest first, verified live 2026-07-17); without it, a single dict. entry = archives.get(fmt) if isinstance(entry, list): return entry[0] if entry else {} return entry or {} def _map_model(item: dict) -> dict: archives = item.get("archives") or {} glb = _archive_entry(archives, "glb") gltf = _archive_entry(archives, "gltf") thumbs = ((item.get("thumbnails") or {}).get("images")) or [] best_thumb = max(thumbs, key=lambda t: t.get("width") or 0, default={}) user = item.get("user") or {} return { "uid": item.get("uid"), "name": item.get("name") or "Sketchfab model", "license": _normalize_license(item.get("license")), "author": user.get("displayName") or user.get("username") or "Sketchfab creator", "authorUrl": user.get("profileUrl") or "", "viewerUrl": item.get("viewerUrl") or f"https://sketchfab.com/3d-models/{item.get('uid')}", "thumbnail": best_thumb.get("url") or "", "faceCount": item.get("faceCount") or glb.get("faceCount") or gltf.get("faceCount") or 0, "vertexCount": item.get("vertexCount") or glb.get("vertexCount") or gltf.get("vertexCount") or 0, "size": glb.get("size") or gltf.get("size") or 0, } async def _sketchfab_get(path: str, token: str, params: dict | None = None) -> dict: # S4: uses `requests` in a thread, NOT aiohttp — verified live 2026-07-17 that # Sketchfab's edge deflects aiohttp with an empty HTTP 202 regardless of UA/auth, # while requests/curl get 200. Low-QPS endpoints; the thread hop is fine. import requests def _get(): # Vault holds a 32-hex Sketchfab API token → "Token" scheme, not OAuth "Bearer". # Verified live 2026-07-17: Bearer → 401, Token → 200 on /download. headers = {"Authorization": f"Token {token}"} if token else {} resp = requests.get(f"{SKETCHFAB_API}{path}", headers=headers, params=params or {}, timeout=SKETCHFAB_TIMEOUT_S) if resp.status_code != 200: raise RuntimeError(f"Sketchfab {path} returned {resp.status_code}: {resp.text[:200]}") return resp.json() return await asyncio.to_thread(_get) @router.get("/sketchfab/status") async def sketchfab_status(): token = _vault_sketchfab_token() return { "ok": bool(token), "configured": bool(token), "source": "omega-vault" if token else "none", "message": "Sketchfab proxy live via OMEGA vault token." if token else "No Sketchfab token in vault or environment.", } @router.get("/sketchfab/search") async def sketchfab_search(q: str = "", count: int = 12): token = _vault_sketchfab_token() if not token: return {"ok": False, "models": [], "reason": "no_token", "message": "No Sketchfab token in the OMEGA vault."} count = max(1, min(int(count or 12), 24)) try: # /v3/search, NOT /v3/models. Verified live 2026-08-04: `q` is not a # supported filter on /v3/models, so it was silently ignored and the # endpoint returned an arbitrary page of downloadable models — every # search this proxy has ever served came back topically unrelated to the # query. Caught by searching "movie theater interior" and getting # "MaszynkaDoMielenia" and "farmerhouse" back with ok=True. payload = await _sketchfab_get("/search", token, { "type": "models", "q": str(q or "").strip(), "downloadable": "true", "count": str(count * 2), # headroom: license filter trims below "archives_flavours": "true", }) results = payload.get("results") or [] models, seen = [], set() for item in results: uid = item.get("uid") if not uid or uid in seen: continue if item.get("isDownloadable") is False: continue if _normalize_license(item.get("license")) not in ALLOWED_LICENSES: continue seen.add(uid) models.append(_map_model(item)) if len(models) >= count: break return {"ok": True, "models": models} except (asyncio.TimeoutError, Exception) as e: logger.warning(f"Sketchfab proxy search failed: {e}") return {"ok": False, "models": [], "reason": "upstream_error", "message": str(e)[:200]} @router.get("/sketchfab/download") async def sketchfab_download(uid: str = ""): token = _vault_sketchfab_token() if not token: return {"ok": False, "download": None, "reason": "no_token", "message": "No Sketchfab token in the OMEGA vault."} clean = str(uid or "").strip() if not clean or not clean.replace("-", "").isalnum(): return {"ok": False, "download": None, "reason": "bad_uid", "message": "Invalid model uid."} try: # Response shape matches the direct v3 endpoint the client also understands: # {"glb": {"url": ..., "expires": ...}, "gltf": {...}, ...} payload = await _sketchfab_get(f"/models/{clean}/download", token) return {"ok": True, "download": payload} except (asyncio.TimeoutError, Exception) as e: logger.warning(f"Sketchfab proxy download failed for {clean}: {e}") return {"ok": False, "download": None, "reason": "upstream_error", "message": str(e)[:200]} @router.get("/cgtrader/search") async def cgtrader_search(q: str = "", count: int = 12, api_key: str = ""): # STUB (kept honest): CGTrader has no public model-download API; direct calls are # CORS-blocked in the browser and undocumented server-side. ok=False → client falls # through to curated fallbacks by design. return {"ok": False, "models": [], "reason": "not_implemented", "message": "CGTrader has no public API; curated fallbacks are used."} @router.get("/turbosquid/search") async def turbosquid_search(q: str = "", count: int = 12, username: str = "", api_key: str = ""): # STUB (kept honest): TurboSquid's partner API is invite-only; no public search API. return {"ok": False, "models": [], "reason": "not_implemented", "message": "TurboSquid has no public API; curated fallbacks are used."} @router.post("/mixamo/search") async def mixamo_search(payload: dict = {}): # STUB (kept honest): Mixamo requires Adobe IMS OAuth; password-grant flows are # blocked for third parties. ok=False → curated rigged fallbacks are used. return {"ok": False, "models": [], "reason": "not_implemented", "message": "Mixamo requires Adobe IMS OAuth; curated rigged fallbacks are used."}