Spaces:
Running
Running
| # 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": <hex>, "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) | |
| 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.", | |
| } | |
| 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: | |
| payload = await _sketchfab_get("/models", token, { | |
| "q": str(q or "").strip(), | |
| "downloadable": "true", | |
| "type": "models", | |
| "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]} | |
| 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]} | |
| 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."} | |
| 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."} | |
| 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."} | |