Spaces:
Configuration error
Configuration error
| """backend/api/deploy.py — S750: CI status + deploy trigger (Railway) + S754-C: Quick Preview.""" | |
| import os, time, json, pathlib, asyncio, secrets as _prv_secrets, mimetypes as _prv_mimetypes | |
| from fastapi import APIRouter, HTTPException, Request | |
| from fastapi.responses import HTMLResponse as _HtmlResp, Response as _BinResp | |
| from pydantic import BaseModel | |
| import httpx | |
| import logging | |
| _logger = logging.getLogger("agente_ai") # S-BUGFIX | |
| router = APIRouter() | |
| _GH_TOKEN = os.getenv("GITHUB_TOKEN", "") | |
| _CF_TOKEN = os.getenv("CF_API_TOKEN", os.getenv("CLOUDFLARE_API_TOKEN", "")) | |
| _CF_ACCT = os.getenv("CF_ACCOUNT_ID", "") | |
| _HF_TOKEN = os.getenv("HF_TOKEN", "") | |
| _INT_TOKEN = os.getenv("INTERNAL_TOKEN", "") | |
| # GAP-M: rimosso _REPLIT_DEPLOY_URL — Replit si addormenta dopo 30min → deploy notturni falliscono. | |
| # CF Pages deploy ora sempre via GitHub Actions dispatch (cloudflare-deploy.yml). | |
| # S-GAP4: parametrizzati da env — portabili su qualsiasi repo | |
| GH_OWNER = os.getenv("GH_OWNER", "Baida98") | |
| GH_REPO = os.getenv("GH_REPO", "AI") | |
| HF_SPACE_ID = os.getenv("HF_SPACE_ID", "Arjanit98/Terminal") # GAP-3: parametrizzato da env | |
| def _auth(request: Request) -> None: | |
| if not _INT_TOKEN: | |
| return | |
| tok = request.headers.get("x-internal-token", "") | |
| if tok != _INT_TOKEN: | |
| raise HTTPException(status_code=401, detail="Unauthorized") | |
| async def ci_status(request: Request): | |
| """Controlla stato GitHub Actions + ultimo deploy CF Pages.""" | |
| _auth(request) | |
| result: dict = { | |
| "ci_ok": False, "billing_ok": True, | |
| "last_run_status": "unknown", "last_run_conclusion": "unknown", | |
| "last_deploy": None, "message": "", | |
| } | |
| headers = {"Accept": "application/vnd.github.v3+json"} | |
| if _GH_TOKEN: | |
| headers["Authorization"] = f"token {_GH_TOKEN}" | |
| async with httpx.AsyncClient(timeout=10) as c: | |
| try: | |
| r = await c.get( | |
| f"https://api.github.com/repos/{GH_OWNER}/{GH_REPO}/actions/runs?per_page=6", | |
| headers=headers, | |
| ) | |
| if r.status_code == 200: | |
| runs = r.json().get("workflow_runs", []) | |
| if runs: | |
| latest = runs[0] | |
| conclusion = latest.get("conclusion", "") | |
| status = latest.get("status", "") | |
| result["last_run_status"] = status | |
| result["last_run_conclusion"] = conclusion | |
| result["last_deploy"] = latest.get("created_at", "") | |
| all_failed = len(runs) >= 4 and all( | |
| r2.get("conclusion") == "failure" for r2 in runs[:4] | |
| ) | |
| result["ci_ok"] = conclusion in ("success", "skipped") or status == "in_progress" | |
| result["billing_ok"] = not all_failed | |
| except Exception as e: | |
| result["message"] = f"GitHub API error: {e}" | |
| if result["ci_ok"]: | |
| result["message"] = "✅ CI attiva — deploy automatico su ogni push" | |
| elif not result["billing_ok"]: | |
| result["message"] = "⚠️ GitHub Actions bloccate (budget esaurito). Deploy manuale disponibile." | |
| else: | |
| result["message"] = "⏳ Deploy in corso o in attesa" | |
| return result | |
| class DeployRequest(BaseModel): | |
| reason: str = "deploy manuale" | |
| skip_hf: bool = False | |
| skip_cf: bool = False | |
| # ── Deploy Status multi-target (S-DEPLOY-UNIFY) ────────────────────────────── | |
| async def deploy_status_all(request: Request): | |
| """ | |
| S-DEPLOY-UNIFY: Stato in tempo reale di tutti e 3 i target di deploy. | |
| Controlla in parallelo (asyncio.gather, timeout 5s per target): | |
| - CF Pages: ultimo deployment via Cloudflare API | |
| - Railway: health check HTTP con latenza | |
| - HF Space: runtime stage via HuggingFace API pubblica | |
| Non richiede token se _CF_TOKEN è assente: risponde ugualmente con stato parziale. | |
| """ | |
| _auth(request) | |
| checked_at = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) | |
| async def _check_cloudflare() -> dict: | |
| r: dict = { | |
| "ok": False, "status": "unknown", | |
| "url": "https://agente-ai.pages.dev", | |
| "last_deploy": None, "deployment_id": None, "error": None, | |
| } | |
| if not _CF_TOKEN or not _CF_ACCT: | |
| r["status"] = "no_token" | |
| r["error"] = "CF_API_TOKEN o CF_ACCOUNT_ID non configurati su Railway" | |
| return r | |
| try: | |
| async with httpx.AsyncClient(timeout=5) as c: | |
| resp = await c.get( | |
| f"https://api.cloudflare.com/client/v4/accounts/{_CF_ACCT}" | |
| f"/pages/projects/agente-ai/deployments?per_page=1", | |
| headers={"Authorization": f"Bearer {_CF_TOKEN}"}, | |
| ) | |
| if resp.status_code == 200: | |
| deps = resp.json().get("result", []) | |
| if deps: | |
| d = deps[0] | |
| stage = (d.get("latest_stage") or {}).get("status", "unknown") | |
| r["ok"] = stage in ("success", "idle") | |
| r["status"] = stage | |
| r["last_deploy"] = d.get("created_on") | |
| r["deployment_id"] = (d.get("id") or "")[:8] | |
| else: | |
| r["status"] = "no_deployments" | |
| else: | |
| r["error"] = f"HTTP {resp.status_code}" | |
| except Exception as e: | |
| r["error"] = str(e)[:80] | |
| return r | |
| async def _check_railway() -> dict: | |
| url = "https://ai-production-4c06.up.railway.app" | |
| r: dict = {"ok": False, "status": "unknown", "url": url, "latency_ms": None, "error": None} | |
| t0 = time.monotonic() | |
| try: | |
| async with httpx.AsyncClient(timeout=5) as c: | |
| resp = await c.get(f"{url}/health") | |
| r["latency_ms"] = round((time.monotonic() - t0) * 1000) | |
| r["ok"] = resp.status_code == 200 | |
| r["status"] = "up" if r["ok"] else f"HTTP {resp.status_code}" | |
| except httpx.TimeoutException: | |
| r["status"] = "timeout"; r["error"] = "Timeout dopo 5s" | |
| except Exception as e: | |
| r["status"] = "down"; r["error"] = str(e)[:80] | |
| return r | |
| async def _check_huggingface() -> dict: | |
| r: dict = { | |
| "ok": False, "status": "unknown", | |
| "url": f"https://huggingface.co/spaces/{HF_SPACE_ID}", | |
| "sdk": None, "error": None, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=5) as c: | |
| resp = await c.get(f"https://huggingface.co/api/spaces/{HF_SPACE_ID}") | |
| if resp.status_code == 200: | |
| data = resp.json() | |
| runtime = data.get("runtime") or {} | |
| stage = runtime.get("stage", "UNKNOWN") | |
| r["ok"] = stage in ("RUNNING", "BUILDING") | |
| r["status"] = stage | |
| r["sdk"] = data.get("sdk", "unknown") | |
| else: | |
| r["error"] = f"HTTP {resp.status_code}" | |
| except Exception as e: | |
| r["error"] = str(e)[:80] | |
| return r | |
| cf, railway, hf = await asyncio.gather( | |
| _check_cloudflare(), | |
| _check_railway(), | |
| _check_huggingface(), | |
| ) | |
| return { | |
| "targets": {"cloudflare": cf, "railway": railway, "huggingface": hf}, | |
| "checked_at": checked_at, | |
| } | |
| # ── Auto-gestione deploy (S-AUTO) ───────────────────────────────────────────── | |
| class AutoRepairRequest(BaseModel): | |
| targets: list[str] = ["cloudflare", "railway", "huggingface"] | |
| dry_run: bool = False # True = diagnostica senza eseguire azioni | |
| async def deploy_auto(body: AutoRepairRequest, request: Request): | |
| """ | |
| S-AUTO: Diagnostica + auto-riparazione di tutti i target in un'unica chiamata. | |
| Logica per target: | |
| - CF Pages: dispatch cloudflare-deploy.yml (build + deploy completo) | |
| - Railway: wake-up ping × 5 con backoff 2s; se ancora down → dispatch backend-deploy.yml | |
| - HF Space: GET wake-up sulla URL pubblica; se ancora sleeping → dispatch backend-deploy.yml | |
| dry_run=True restituisce il piano senza eseguire azioni reali. | |
| """ | |
| _auth(request) | |
| t0 = time.time() | |
| actions: list[str] = [] | |
| fixed: list[str] = [] | |
| broken: list[str] = [] | |
| gh_headers = {"Accept": "application/vnd.github.v3+json"} | |
| if _GH_TOKEN: | |
| gh_headers["Authorization"] = f"token {_GH_TOKEN}" | |
| # ── Helper: dispatch GitHub Actions workflow ────────────────────────────── | |
| async def _dispatch(workflow_file: str, inputs: dict | None = None) -> tuple[bool, str]: | |
| if not _GH_TOKEN: | |
| return False, "GITHUB_TOKEN non configurato" | |
| try: | |
| async with httpx.AsyncClient(timeout=15) as c: | |
| r = await c.post( | |
| f"https://api.github.com/repos/{GH_OWNER}/{GH_REPO}" | |
| f"/actions/workflows/{workflow_file}/dispatches", | |
| headers={**gh_headers, "Content-Type": "application/json"}, | |
| content=json.dumps({"ref": "main", "inputs": inputs or {}}).encode(), | |
| ) | |
| return r.status_code in (204, 200), f"HTTP {r.status_code}" | |
| except Exception as e: | |
| return False, str(e)[:60] | |
| # ── CF Pages ────────────────────────────────────────────────────────────── | |
| if "cloudflare" in body.targets: | |
| if not body.dry_run: | |
| # Controlla stato prima di dispatchare inutilmente | |
| cf_stage = "unknown" | |
| if _CF_TOKEN and _CF_ACCT: | |
| try: | |
| async with httpx.AsyncClient(timeout=5) as c: | |
| r = await c.get( | |
| f"https://api.cloudflare.com/client/v4/accounts/{_CF_ACCT}" | |
| f"/pages/projects/agente-ai/deployments?per_page=1", | |
| headers={"Authorization": f"Bearer {_CF_TOKEN}"}, | |
| ) | |
| if r.status_code == 200: | |
| deps = r.json().get("result", []) | |
| cf_stage = (deps[0].get("latest_stage") or {}).get("status", "unknown") if deps else "no_deployments" | |
| except Exception as _exc: | |
| _logger.debug("[deploy] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| if cf_stage in ("success", "idle"): | |
| actions.append("✅ CF Pages già live — nessun deploy necessario") | |
| fixed.append("cloudflare") | |
| else: | |
| actions.append(f"⚠️ CF Pages status={cf_stage!r} — avvio cloudflare-deploy.yml") | |
| ok, msg = await _dispatch("cloudflare-deploy.yml") | |
| if ok: | |
| actions.append(" ✅ CF Pages: Actions avviato — deploy live in ~3 min") | |
| fixed.append("cloudflare") | |
| else: | |
| actions.append(f" ❌ CF Pages dispatch fallito: {msg}") | |
| broken.append("cloudflare") | |
| else: | |
| actions.append("[dry_run] CF Pages: dispatch cloudflare-deploy.yml se non live") | |
| fixed.append("cloudflare") | |
| # ── Railway ─────────────────────────────────────────────────────────────── | |
| if "railway" in body.targets: | |
| if not body.dry_run: | |
| railway_url = "https://ai-production-4c06.up.railway.app/health" | |
| alive = False | |
| for attempt in range(1, 6): | |
| try: | |
| async with httpx.AsyncClient(timeout=8) as c: | |
| r = await c.get(railway_url) | |
| if r.status_code == 200: | |
| alive = True | |
| actions.append(f"✅ Railway attivo (ping {attempt}/5 — HTTP 200)") | |
| fixed.append("railway") | |
| break | |
| else: | |
| actions.append(f" Railway ping {attempt}/5: HTTP {r.status_code}") | |
| except Exception as e: | |
| actions.append(f" Railway ping {attempt}/5: {str(e)[:50]}") | |
| if attempt < 5: | |
| await asyncio.sleep(2) | |
| if not alive: | |
| actions.append(" ⚠️ Railway non risponde — avvio re-deploy via backend-deploy.yml") | |
| ok, msg = await _dispatch("backend-deploy.yml", {"target": "railway"}) | |
| if ok: | |
| actions.append(" ✅ Railway re-deploy avviato — attivo entro ~2 min") | |
| fixed.append("railway") | |
| else: | |
| actions.append(f" ❌ Railway dispatch fallito: {msg}") | |
| broken.append("railway") | |
| else: | |
| actions.append("[dry_run] Railway: ping × 5, se down → dispatch backend-deploy.yml(railway)") | |
| fixed.append("railway") | |
| # ── HuggingFace Space ───────────────────────────────────────────────────── | |
| if "huggingface" in body.targets: | |
| if not body.dry_run: | |
| # Controlla stage runtime | |
| hf_stage = "UNKNOWN" | |
| try: | |
| async with httpx.AsyncClient(timeout=5) as c: | |
| r = await c.get(f"https://huggingface.co/api/spaces/{HF_SPACE_ID}") | |
| if r.status_code == 200: | |
| hf_stage = (r.json().get("runtime") or {}).get("stage", "UNKNOWN") | |
| except Exception as _exc: | |
| _logger.debug("[deploy] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| if hf_stage == "RUNNING": | |
| actions.append("✅ HF Space già RUNNING — nessuna azione") | |
| fixed.append("huggingface") | |
| elif hf_stage == "BUILDING": | |
| actions.append("ℹ️ HF Space in BUILDING — deploy già in corso, attendi ~1 min") | |
| fixed.append("huggingface") | |
| else: | |
| actions.append(f"⚠️ HF Space status={hf_stage!r} — invio GET wake-up") | |
| # GET la URL pubblica → HF Spaces si svegliano automaticamente su GET | |
| woke = False | |
| try: | |
| async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c: | |
| wr = await c.get("https://arjanit98-terminal.hf.space") | |
| woke = wr.status_code < 500 | |
| actions.append(f" GET wake-up: HTTP {wr.status_code}") | |
| except Exception as e: | |
| actions.append(f" GET wake-up: {str(e)[:50]}") | |
| if woke: | |
| actions.append(" ✅ HF Space: wake-up inviato — running entro ~30s") | |
| fixed.append("huggingface") | |
| else: | |
| actions.append(" ⚠️ Wake-up non sufficiente — dispatch backend-deploy.yml(hf)") | |
| ok, msg = await _dispatch("backend-deploy.yml", {"target": "hf"}) | |
| if ok: | |
| actions.append(" ✅ HF Space re-deploy avviato via GitHub Actions") | |
| fixed.append("huggingface") | |
| else: | |
| actions.append(f" ❌ HF Space dispatch fallito: {msg}") | |
| broken.append("huggingface") | |
| else: | |
| actions.append("[dry_run] HF Space: GET wake-up → se sleeping → dispatch backend-deploy.yml(hf)") | |
| fixed.append("huggingface") | |
| all_ok = len(broken) == 0 | |
| parts = [] | |
| if fixed: parts.append(f"Risolti: {', '.join(fixed)}") | |
| if broken: parts.append(f"Non risolti: {', '.join(broken)}") | |
| return { | |
| "ok": all_ok, | |
| "fixed": fixed, | |
| "broken": broken, | |
| "actions": actions, | |
| "dry_run": body.dry_run, | |
| "message": " · ".join(parts) if parts else "Tutto OK", | |
| "elapsed": round(time.time() - t0, 1), | |
| } | |
| async def deploy_trigger_endpoint(body: DeployRequest, request: Request): | |
| """Triggera deploy: prima Actions dispatch, poi fallback HF + Replit CF.""" | |
| _auth(request) | |
| t0 = time.time() | |
| steps: list[str] = [] | |
| headers = {"Accept": "application/vnd.github.v3+json"} | |
| if _GH_TOKEN: | |
| headers["Authorization"] = f"token {_GH_TOKEN}" | |
| async with httpx.AsyncClient(timeout=30) as c: | |
| # GAP-M: rimossa euristica billing (all_failed) — poco affidabile e bloccava dispatch validi. | |
| # Path 1: SEMPRE tenta GitHub Actions dispatch (cloudflare-deploy.yml). | |
| # Build + quality check + CF Pages deploy in ~3 min. | |
| # Path 2: HF Space sync backend (fallback — non dipende da Actions). | |
| # Rimosso path Replit: Replit si addormenta dopo 30min → deploy notturni fallivano silenziosamente. | |
| if not body.skip_cf and _GH_TOKEN: | |
| try: | |
| wf_r = await c.post( | |
| f"https://api.github.com/repos/{GH_OWNER}/{GH_REPO}/actions/workflows/cloudflare-deploy.yml/dispatches", | |
| headers={**headers, "Content-Type": "application/json"}, | |
| content=json.dumps({"ref": "main", "inputs": {}}).encode(), | |
| ) | |
| if wf_r.status_code in (204, 200): | |
| # GAP-2: poll breve (1×3s) per verificare che GitHub Actions abbia accodato il run. | |
| # deploy_verify=True → run già visibile nelle Actions (GH fast path) | |
| # deploy_verify=False → run ancora in coda, normale nei primi ~10s | |
| await asyncio.sleep(3) | |
| deploy_verify = False | |
| try: | |
| r_check = await c.get( | |
| f"https://api.github.com/repos/{GH_OWNER}/{GH_REPO}/actions/runs?per_page=2", | |
| headers=headers, timeout=5, | |
| ) | |
| if r_check.status_code == 200: | |
| _runs = r_check.json().get("workflow_runs", []) | |
| deploy_verify = bool( | |
| _runs and _runs[0].get("status") in ("queued", "in_progress", "completed") | |
| ) | |
| except Exception: | |
| pass # poll non bloccante — mai nasconde il successo del dispatch | |
| return { | |
| "ok": True, "mode": "ci_dispatch", | |
| "deploy_verify": deploy_verify, | |
| "steps": [ | |
| "✅ GitHub Actions avviato — build + deploy CF Pages live in ~3 min" | |
| + (" · run confermato ✓" if deploy_verify else " · run in coda (attendi ~10s)"), | |
| ], | |
| "url": "https://agente-ai.pages.dev", | |
| "elapsed": round(time.time() - t0, 1), | |
| } | |
| steps.append(f"⚠️ workflow_dispatch CF Pages: HTTP {wf_r.status_code} — controlla billing/token GitHub") | |
| except Exception as e: | |
| steps.append(f"⚠️ GitHub Actions dispatch: {str(e)[:80]}") | |
| elif not body.skip_cf: | |
| steps.append("ℹ️ CF Pages: GITHUB_TOKEN non configurato — impossibile triggerare Actions") | |
| # Path 2: HF Space sync backend (Python puro — sempre disponibile su Railway) | |
| if not body.skip_hf and _HF_TOKEN: | |
| try: | |
| backend_dir = pathlib.Path(__file__).parent.parent | |
| files = [] | |
| for p in backend_dir.rglob("*"): | |
| if p.is_file() and "__pycache__" not in str(p) and not str(p).endswith(".pyc"): | |
| try: | |
| files.append({ | |
| "path": str(p.relative_to(backend_dir)), | |
| "content": p.read_text(errors="replace"), | |
| }) | |
| except Exception as _exc: | |
| _logger.debug("[deploy] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| payload = json.dumps({ | |
| "summary": f"deploy: {body.reason} — {time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}", | |
| "files": files, | |
| }).encode("utf-8") | |
| hf_r = await c.post( | |
| f"https://huggingface.co/api/spaces/{HF_SPACE_ID}/commit/main", | |
| headers={"Authorization": f"Bearer {_HF_TOKEN}", "Content-Type": "application/json"}, | |
| content=payload, timeout=90, | |
| ) | |
| if hf_r.status_code in (200, 201): | |
| steps.append("✅ Backend HuggingFace sincronizzato") | |
| else: | |
| steps.append(f"⚠️ HF sync: HTTP {hf_r.status_code}") | |
| except Exception as e: | |
| steps.append(f"⚠️ HF sync: {str(e)[:80]}") | |
| ok = any("✅" in s for s in steps) | |
| return { | |
| "ok": ok, "mode": "direct", | |
| "steps": steps, | |
| "url": f"https://huggingface.co/spaces/{HF_SPACE_ID}", | |
| "elapsed": round(time.time() - t0, 1), | |
| } | |
| # ── Quick Preview (S754-C) ───────────────────────────────────────────────────── | |
| # Serve una preview HTML/CSS/JS dal backend HF Space in modo istantaneo. | |
| # File salvati in /data/previews/ (volume persistente HF Spaces, fallback /tmp/). | |
| # Nessun deploy esterno: URL pubblico disponibile in < 1 secondo. | |
| def _resolve_preview_dir() -> pathlib.Path: | |
| for candidate in [pathlib.Path('/data/previews'), pathlib.Path('/tmp/previews')]: | |
| try: | |
| candidate.mkdir(parents=True, exist_ok=True) | |
| return candidate | |
| except (PermissionError, OSError): | |
| continue | |
| return pathlib.Path('/tmp/previews') | |
| _PREVIEW_DIR = _resolve_preview_dir() | |
| _MAX_PREVIEWS = 20 # LRU: mantieni solo le ultime 20 preview | |
| _MAX_FILE_BYTES = 524_288 # 512 KB per file | |
| _MAX_FILES = 50 # max file per singola preview request | |
| class QuickPreviewRequest(BaseModel): | |
| files: dict[str, str] # filename → contenuto (HTML/CSS/JS/ecc.) | |
| title: str = "preview" | |
| def _cleanup_old_previews_sync() -> None: | |
| """Rimuove le preview più vecchie oltre _MAX_PREVIEWS (versione sync per asyncio.to_thread).""" | |
| try: | |
| import shutil as _sh | |
| dirs = sorted( | |
| (p for p in _PREVIEW_DIR.iterdir() if p.is_dir()), | |
| key=lambda p: p.stat().st_mtime, | |
| ) | |
| while len(dirs) > _MAX_PREVIEWS: | |
| _sh.rmtree(dirs.pop(0), ignore_errors=True) | |
| except Exception as _exc: | |
| _logger.debug("[deploy] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| async def _cleanup_old_previews() -> None: | |
| """ | |
| Rimuove le preview più vecchie oltre _MAX_PREVIEWS. | |
| Eseguito in thread separato per non bloccare l'event loop durante l'iterazione su disco. | |
| """ | |
| try: | |
| await asyncio.to_thread(_cleanup_old_previews_sync) | |
| except Exception as _exc: | |
| _logger.debug("[deploy] silenced %s", type(_exc).__name__) # noqa: BLE001 | |
| def _get_base_url(request: Request) -> str: | |
| """ | |
| Determina l'URL pubblico del backend (HF Space o Railway). | |
| IMPORTANTE — NON usare Origin o Host header: | |
| - Origin: è l'URL del CALLER (es. 'https://agente-ai.pages.dev') — non del backend. | |
| - Host: è l'hostname interno del container, non il dominio pubblico. | |
| Usare Origin come URL del backend genera URL pubblici che puntano a CF Pages | |
| invece che al backend HF/Railway → 404 garantito in produzione. | |
| Priorità corretta: | |
| 1. HF_SPACE_URL env var — config esplicita, raccomandata (es. 'https://arjanit98-terminal.hf.space') | |
| 2. SPACE_HOST env var — iniettata da HF Spaces automaticamente (es. 'arjanit98-terminal.hf.space') | |
| 3. X-Forwarded-Host — iniettato da proxy/ingress Railway o Traefik | |
| 4. '' (stringa vuota) — nessuna fonte disponibile → quick_preview risponde con errore strutturato | |
| """ | |
| # 1. Variabile esplicita: HF_SPACE_URL=https://arjanit98-terminal.hf.space | |
| explicit = os.getenv('HF_SPACE_URL', '').rstrip('/') | |
| if explicit: | |
| return explicit | |
| # 2. SPACE_HOST — iniettata da HF Spaces (es. 'arjanit98-terminal.hf.space') | |
| space_host = os.getenv('SPACE_HOST', '') | |
| if space_host: | |
| return f'https://{space_host}' | |
| # 3. X-Forwarded-Host — iniettato da proxy/ingress come Railway o Traefik | |
| fwd_host = request.headers.get('x-forwarded-host', '') | |
| if fwd_host: | |
| proto = request.headers.get('x-forwarded-proto', 'https') | |
| return f'{proto}://{fwd_host}' | |
| # NON usare Origin (URL del caller) né Host (hostname interno del container) | |
| return '' | |
| # Header condivisi per le route /preview/* — CORS aperto + no-cache | |
| _PREVIEW_HEADERS = { | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'GET, OPTIONS', | |
| 'Access-Control-Allow-Headers': 'Content-Type', | |
| 'Cache-Control': 'no-store, no-cache', | |
| 'X-Content-Type-Options': 'nosniff', | |
| } | |
| async def quick_preview(body: QuickPreviewRequest, request: Request): | |
| """ | |
| S754-C: Deploy rapido su backend HF Space. | |
| Salva i file in /data/previews/<id>/ → restituisce URL pubblico < 1s. | |
| Input: {files: {"index.html": "<html>...", "style.css": "..."}, title: "..."} | |
| Output: {ok: true, url: "https://<space>/preview/<id>/", preview_id: "...", ...} | |
| oppure {ok: false, error: "...", hint: "..."} se URL backend non rilevabile. | |
| """ | |
| if not body.files: | |
| raise HTTPException(400, 'Nessun file fornito') | |
| if len(body.files) > _MAX_FILES: | |
| raise HTTPException(400, f'Troppi file: max {_MAX_FILES} per preview') | |
| preview_id = _prv_secrets.token_urlsafe(10) | |
| preview_dir = _PREVIEW_DIR / preview_id | |
| preview_dir.mkdir(parents=True, exist_ok=True) | |
| saved: list[str] = [] | |
| for raw_name, content in body.files.items(): | |
| # Sanitizza: no path traversal, niente nomi nascosti | |
| safe_name = pathlib.Path(raw_name).name | |
| if not safe_name or safe_name.startswith('.'): | |
| safe_name = 'index.html' | |
| # Tronca file troppo grandi | |
| encoded = content.encode('utf-8', errors='replace') | |
| if len(encoded) > _MAX_FILE_BYTES: | |
| content = encoded[:_MAX_FILE_BYTES].decode('utf-8', errors='replace') | |
| (preview_dir / safe_name).write_text(content, encoding='utf-8', errors='replace') | |
| saved.append(safe_name) | |
| # Cleanup asincrono — non blocca l'event loop (iterazione su disco in thread separato) | |
| # GAP-P4: add_done_callback per loggare eccezioni inattese (fire-and-forget safety). | |
| def _log_cleanup_error(task: asyncio.Task) -> None: | |
| if not task.cancelled() and task.exception() is not None: | |
| import logging as _log | |
| _log.getLogger('agente_ai').warning('preview cleanup error: %s', task.exception()) | |
| _cleanup_task = asyncio.create_task(_cleanup_old_previews()) | |
| _cleanup_task.add_done_callback(_log_cleanup_error) | |
| base_url = _get_base_url(request) | |
| if not base_url: | |
| # URL backend non rilevabile — risposta strutturata con hint per il debug | |
| return { | |
| 'ok': False, | |
| 'preview_id': preview_id, | |
| 'url': None, | |
| 'files': saved, | |
| 'error': 'URL backend non rilevabile: nessun env var HF_SPACE_URL / SPACE_HOST trovato.', | |
| 'hint': ( | |
| 'Imposta la variabile d\'ambiente HF_SPACE_URL=https://arjanit98-terminal.hf.space ' | |
| 'nelle impostazioni del backend (Railway → Variables o HF Space → Settings → Variables). ' | |
| 'I file della preview sono stati salvati con ID: ' + preview_id | |
| ), | |
| } | |
| preview_url = f'{base_url}/preview/{preview_id}/' | |
| return { | |
| 'ok': True, | |
| 'preview_id': preview_id, | |
| 'url': preview_url, | |
| 'files': saved, | |
| 'ephemeral': True, | |
| 'note': 'URL live mentre il backend è attivo.', | |
| } | |
| async def serve_preview_index(preview_id: str): | |
| """Serve index.html della preview.""" | |
| preview_dir = _PREVIEW_DIR / preview_id | |
| for candidate in ('index.html', 'index.htm'): | |
| f = preview_dir / candidate | |
| if f.exists(): | |
| return _HtmlResp( | |
| content=f.read_text(encoding='utf-8', errors='replace'), | |
| headers=_PREVIEW_HEADERS, | |
| ) | |
| raise HTTPException(404, f'Preview {preview_id!r} non trovata o scaduta.') | |
| async def serve_preview_file(preview_id: str, filename: str): | |
| """Serve file statici della preview (CSS, JS, immagini, ecc.).""" | |
| preview_dir = _PREVIEW_DIR / preview_id | |
| # Sicurezza: usa solo il nome file, no traversal | |
| safe_name = pathlib.Path(filename).name | |
| file_path = preview_dir / safe_name | |
| if not file_path.exists(): | |
| raise HTTPException(404, f'File {safe_name!r} non trovato nella preview.') | |
| content_type, _ = _prv_mimetypes.guess_type(str(file_path)) | |
| return _BinResp( | |
| content=file_path.read_bytes(), | |
| media_type=content_type or 'application/octet-stream', | |
| headers=_PREVIEW_HEADERS, | |
| ) | |