Terminal / api /deploy.py
Pulka
fix: _logger at correct module level in 6 backend files (retry) — crash fix
0423856 verified
Raw
History Blame
30.3 kB
"""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/<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.',
}
@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,
)