"""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") @router.get("/api/ci/status") 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) ────────────────────────────── @router.get("/api/deploy/status") 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 @router.post("/api/deploy/auto") 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), } @router.post("/api/deploy-trigger") 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', } @router.post('/api/preview/quick') async def quick_preview(body: QuickPreviewRequest, request: Request): """ S754-C: Deploy rapido su backend HF Space. Salva i file in /data/previews// → restituisce URL pubblico < 1s. Input: {files: {"index.html": "...", "style.css": "..."}, title: "..."} Output: {ok: true, url: "https:///preview//", 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.', } @router.get('/preview/{preview_id}') @router.get('/preview/{preview_id}/') 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.') @router.get('/preview/{preview_id}/{filename:path}') 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, )