"""FastAPI application and route registration.""" from __future__ import annotations # Ensure hosting_panel/ is in sys.path for HF Spaces container execution. # When uvicorn starts from /app/ (repo root), bare imports like # ``from config import PANEL_CONFIG`` fail because hosting_panel/ is not # on sys.path. This patch is idempotent and must run BEFORE any other # imports from the panel package. import os as _os, sys as _sys _PANEL_DIR = _os.path.dirname(_os.path.abspath(__file__)) if _PANEL_DIR not in _sys.path: _sys.path.insert(0, _PANEL_DIR) import asyncio import logging import os import time from contextlib import asynccontextmanager from datetime import datetime, timezone from typing import Any, Optional from fastapi import FastAPI, Request, Query from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse from fastapi.staticfiles import StaticFiles from config import PANEL_CONFIG from database import init_db from storage import ( PANEL_STORAGE_COORD, PanelStorage, audit_log_prune_loop, ) from bot_runner import resource_watchdog_loop from alerts import evaluate_rules_loop from routes import router from templates_setup import templates from i18n import available_languages, gettext, ngettext, _detect_lang, _CATALOG_LANGS logging.basicConfig( level=logging.INFO if not PANEL_CONFIG.debug else logging.DEBUG, format="%(asctime)s %(levelname)-7s %(name)s | %(message)s", ) log = logging.getLogger("hosting-panel") # --------------------------------------------------------------------------- # Signal handlers — flush panel state synchronously on SIGTERM/SIGINT # --------------------------------------------------------------------------- # # HF Spaces sends SIGTERM before stopping / rebuilding the container and # gives the process ~30 s to clean up. The async lifespan shutdown # already calls ``PANEL_STORAGE_COORD.shutdown()`` (which awaits # ``flush_now``), but uvicorn's lifespan exit only runs if the signal is # propagated through the ASGI server — if the container is killed # forcefully or the event loop is already busy, queued DB changes never # reach the HF Dataset mirror and the next ``storage.pull`` restores an # older snapshot (effectively rolling the panel back). # # Installing a sync signal handler that calls ``PanelStorage._sync_push`` # directly is defensive: it runs regardless of whether uvicorn gets to # its lifespan-exit path. The handler is idempotent (multiple SIGTERMs # just re-push the same state) and exits fast (~50 ms on the hot path). def _sigterm_sync_push(signum, frame): try: log.warning("signal %d received — synchronous panel-state push", signum) storage = PanelStorage() if storage.enabled and storage.safe_to_push: storage._sync_push(message=f"signal {signum}") except Exception as exc: log.warning("signal %d push failed: %r", signum, exc) try: import signal as _signal _signal.signal(_signal.SIGTERM, _sigterm_sync_push) _signal.signal(_signal.SIGINT, _sigterm_sync_push) except Exception: # Windows / restricted runtimes may reject signal() — non-fatal, # the async lifespan shutdown still runs. pass # --------------------------------------------------------------------------- # Inline column-level migrations (runs from lifespan startup) # --------------------------------------------------------------------------- # # Background: HF Spaces' Docker SDK lets ``app_file`` point at any module # (the README on ItsBounvy/bot_host says ``app:app``). The previous # approach — invoking the migration from ``run_panel.py`` — relied on # that exact file being the entry point, and silently skipped every # other invocation path. To make it bulletproof we ship the migration # logic embedded in ``app.py`` itself and call it from ``lifespan`` # before ``init_db`` reads the schema. The script ``migrate_spaces_tiers.py`` # remains as a CLI entry point for operators who want to run it # manually (``python migrate_spaces_tiers.py /path/to/db``). def _run_pending_migrations_inline() -> None: """Apply any pending column-level migrations in-process. Idempotent: each ALTER catches ``sqlite3.OperationalError`` with a "duplicate column" message and continues. Only runs when the configured DB URL is SQLite; non-SQLite deployments would use a real migration tool. """ # Read the effective URL from PANEL_CONFIG, not os.getenv — on HF # Spaces the env var ``PANEL_DATABASE_URL`` is NOT always set and # the panel falls back to ``config.py``'s default. Reading from # PANEL_CONFIG gives us the *actual* URL the runtime will use. db_url = (PANEL_CONFIG.database_url or "").strip() if not db_url.startswith("sqlite"): return import sqlite3 # Resolve the file path the same way the production code does. # ``sqlite+aiosqlite:///`` -> file path is the part after ``///``. path = "" if ":///" in db_url: path = db_url.split(":///", 1)[1] elif "://" in db_url: path = db_url.split("://", 1)[1] if not path: log.info("migrations: cannot resolve DB file from %r; skipping", db_url) return from pathlib import Path as _Path db_file = _Path(path) if not db_file.is_file(): log.info( "migrations: %s does not exist yet; create_all() will handle the schema.", db_file, ) return add_columns = [ ("users", "plan_tier", "VARCHAR(16)", "'free'"), ("host_spaces", "tier", "VARCHAR(16)", "'free'"), ("host_spaces", "requested_at", "DATETIME", None), ("host_spaces", "approved_at", "DATETIME", None), ("host_spaces", "approved_by_user_id", "INTEGER", None), ("host_spaces", "requested_by_user_id", "INTEGER", None), ("host_spaces", "operator_note", "TEXT", None), ("host_spaces", "baseline_cpu_reserved", "FLOAT", "0.1"), ("host_spaces", "baseline_ram_reserved_mb", "INTEGER", "128"), ("bot_instances", "space_state", "VARCHAR(32)", "'on_dataset'"), ] conn = sqlite3.connect(str(db_file)) try: cur = conn.cursor() cur.execute("PRAGMA table_info(users)") users_cols = {row[1] for row in cur.fetchall()} cur.execute("PRAGMA table_info(host_spaces)") spaces_cols = {row[1] for row in cur.fetchall()} cur.execute("PRAGMA table_info(bot_instances)") bot_cols = {row[1] for row in cur.fetchall()} applied = 0 for tbl, col, sql_type, default in add_columns: existing = { "users": users_cols, "host_spaces": spaces_cols, "bot_instances": bot_cols, }.get(tbl, set()) if col in existing: continue default_clause = f" DEFAULT {default}" if default is not None else "" try: cur.execute( f"ALTER TABLE {tbl} ADD COLUMN {col} {sql_type}{default_clause}" ) log.info( "migrations: +%s.%s %s%s", tbl, col, sql_type, default_clause, ) applied += 1 except sqlite3.OperationalError as exc: msg = str(exc).lower() if "duplicate column" in msg or "already exists" in msg: continue raise conn.commit() if applied: log.info("migrations: applied %d column(s) to %s", applied, db_file) else: log.info("migrations: schema already up to date at %s", db_file) finally: conn.close() # --------------------------------------------------------------------------- # Keep-alive self-ping (anti-sleep for HF Space free tier) # --------------------------------------------------------------------------- # # HF Spaces on the free tier sleep after ~15 min of no external HTTP # traffic. Subprocess bots and the whole panel go away with them. # We schedule a background task that pings either: # # 1. ``PANEL_CONFIG.keepalive_url`` if explicit; # 2. The Space's own public URL derived from ``SPACE_ID`` + # ``SPACE_AUTHOR_NAME`` (HF provides both — Space URL is # ``https://-.hf.space``); # 3. ``localhost`` as a last-resort fallback (HF still counts the # request, depends on platform). # # External cron (UptimeRobot, GitHub Actions, cron-job.org) hitting # ``/api/health`` is the recommended approach — the self-ping is # a best-effort belt under the suspenders. import httpx def _resolve_keepalive_url() -> str: """Pick a URL to ping periodically so the HF Space doesn't sleep. Resolution order: 1. Explicit ``KEEPALIVE_URL`` env var (the documented override). 2. Auto-detect from HF env vars. HuggingFace sets: - ``SPACE_AUTHOR_NAME`` = the owner namespace, e.g. ``ItsBounvy`` - ``SPACE_ID`` = the full repo id, e.g. ``ItsBounvy/bot_host`` - ``SPACE_REPO_NAME`` = just the repo name (newer Spaces), e.g. ``bot_host`` The runtime URL is ``https://-.hf.space`` (slash in the repo id becomes a hyphen). Naively concatenating ``{author}-{space_id}`` produces ``ItsBounvy-ItsBounvy/bot_host`` which is invalid — the Space then keeps trying to ping a URL that doesn't exist, fails to keep itself awake, and after ~15 min of real idleness goes to sleep on the free tier, rejecting every external request. The previous version of this function had exactly that bug. 3. Empty string — caller treats that as "disable self-ping". """ explicit = PANEL_CONFIG.keepalive_url.strip() if explicit: return explicit author = os.getenv("SPACE_AUTHOR_NAME", "").strip() # Prefer SPACE_REPO_NAME when available — it's just the bare repo # name with no namespace prefix. repo_name = os.getenv("SPACE_REPO_NAME", "").strip() if not repo_name: # Fall back to stripping the namespace from SPACE_ID. space_id = os.getenv("SPACE_ID", "").strip() if space_id and "/" in space_id: repo_name = space_id.split("/", 1)[1] elif space_id: repo_name = space_id if author and repo_name: # HF runtime URL convention: namespace + "-" + repo_name. # Two normalisations matter here: # 1. Underscore -> hyphen. HF issues TLS certs with the # repo name's underscores replaced by hyphens # (``ItsBounvy/bot_host`` becomes # ``ItsBounvy-bot-host.hf.space`` in the cert SAN). # Leaving the underscore in the URL produces # ``[SSL: CERTIFICATE_VERIFY_FAILED] Hostname mismatch`` # because the cert is for the hyphenated form. # 2. Lowercase. Cert SANs are issued in lowercase; # Python's ssl module lowercases for comparison anyway, # but using lowercase in the URL also avoids surprises # in proxy headers, logs and external monitors. host = f"{author}-{repo_name}".replace("_", "-").lower() return f"https://{host}.hf.space/health" # Last-resort: hit ourselves on localhost. Keeps the event loop # active and is a useful health probe even when HF can't be reached. return "" async def _keepalive_loop(url: str, interval_sec: int) -> None: log.info("keep-alive loop running for %s every %ds", url, interval_sec) # Send ``Connection: close`` so each iteration opens a fresh TCP # socket; keeps the loop robust against any half-open keep-alive # sockets (esp. on Windows where asyncio + httpx can wedge on # idle pooled connections). headers = {"Connection": "close", "User-Agent": "MMaxAI-Panel-KeepAlive/1.0"} while True: try: await asyncio.sleep(interval_sec) try: timeout = httpx.Timeout(15.0) async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: t0 = time.time() resp = await client.get(url) elapsed_ms = int((time.time() - t0) * 1000) if resp.status_code >= 500: log.warning( "keep-alive: %s returned %d (%dms)", url, resp.status_code, elapsed_ms, ) else: log.debug( "keep-alive: %s -> %d (%dms)", url, resp.status_code, elapsed_ms, ) except Exception as exc: log.warning("keep-alive ping failed for %s: %s", url, exc) except asyncio.CancelledError: return except Exception as exc: log.warning("keep-alive loop error: %s", exc) # --------------------------------------------------------------------------- # Security headers middleware # --------------------------------------------------------------------------- # # Defence in depth: the panel renders user-controlled data (bot names, slugs, # env-var values, log lines) into HTML. Jinja auto-escapes by default, but # inline JS in panel.js consumes JSON from the API -- if a bot name contains # , a naive json.dumps would break the # page's JS context. Adding CSP blocks the inline payload entirely; X-Frame # prevents clickjacking; X-Content-Type-Options prevents MIME sniffing; the # Referrer-Policy stops leaking paths to external CDNs. _CSP = ( "default-src 'self'; " "img-src 'self' data: https:; " "style-src 'self' 'unsafe-inline'; " "script-src 'self' 'unsafe-inline'; " "font-src 'self' data:; " "connect-src 'self' https://huggingface.co https://*.hf.space " "https://integrate.api.nvidia.com; " # HF Spaces embeds the running app inside an