jarvis-cloud / backend /routes /model_proxy_routes.py
Jarvis2345's picture
Squash history — remove all prior commits (secret hygiene, S4)
a31f556
Raw
History Blame
8.96 kB
# 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)
@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:
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]}
@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."}