import asyncio import logging import os import secrets import string from contextlib import asynccontextmanager from pathlib import Path from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware from backend.collab import collab_manager from backend.indexer import ( build_index, handle_file_move, remove_single_file, update_single_file, ) from backend.openapi_docs import ( API_DESCRIPTION, TAGS_METADATA, enrich_openapi_schema, render_api_landing, ) from backend.search import ( init_inverted_index, ) from backend.semantic_search import init_semantic_index from backend.services.backups import get_backup_dir as service_get_backup_dir from backend.services.errors import ServiceError logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", ) logger = logging.getLogger("obsigate") # --------------------------------------------------------------------------- # Pydantic models : voir backend.schemas (#85 T5→T9) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # SSE Manager — voir backend.sse (ROADMAP #85 T4, instance partagée) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- from backend.search_executor import ( get_search_executor, init_search_executor, shutdown_search_executor, ) from backend.sse import sse_manager # --------------------------------------------------------------------------- # Application lifespan (replaces deprecated on_event) # --------------------------------------------------------------------------- from backend.watcher import VaultWatcher from backend.watcher_state import get_watcher, set_watcher # File watcher : handle partagé via backend.watcher_state (ROADMAP #85 T8). async def _on_vault_change(events: list): """Callback invoked by VaultWatcher when files change in watched vaults. Processes each event (create/modify/delete/move) and updates the index incrementally, then broadcasts SSE notifications. """ updated_vaults = set() changes = [] for event in events: vault_name = event["vault"] event_type = event["type"] src = event["src"] dest = event.get("dest") try: if event_type in ("created", "modified"): result = await update_single_file(vault_name, src) if result: changes.append({"action": "updated", "vault": vault_name, "path": result["path"]}) updated_vaults.add(vault_name) elif event_type == "deleted": result = await remove_single_file(vault_name, src) if result: changes.append({"action": "deleted", "vault": vault_name, "path": result["path"]}) updated_vaults.add(vault_name) elif event_type == "moved": result = await handle_file_move(vault_name, src, dest) if result: changes.append({"action": "moved", "vault": vault_name, "path": result["path"]}) updated_vaults.add(vault_name) except Exception as e: logger.error(f"Error processing {event_type} event for {src}: {e}") if changes: await sse_manager.broadcast("index_updated", { "vaults": list(updated_vaults), "changes": changes, "total_changes": len(changes), }) logger.info(f"Hot-reload: {len(changes)} change(s) in {list(updated_vaults)}") # --------------------------------------------------------------------------- # Authentication bootstrap # --------------------------------------------------------------------------- def bootstrap_admin(): """Create the initial admin account if no users exist. Reads OBSIGATE_ADMIN_USER and OBSIGATE_ADMIN_PASSWORD from environment. If no password is set, generates a random one and logs it ONCE. Only runs when auth is enabled and no users.json exists yet. """ from backend.auth.middleware import is_auth_enabled from backend.auth.user_store import create_user, has_users if not is_auth_enabled(): return if has_users(): return # Users already exist, skip admin_user = os.environ.get("OBSIGATE_ADMIN_USER", "admin") admin_pass = os.environ.get("OBSIGATE_ADMIN_PASSWORD", "") if not admin_pass: # Generate a random password and display it ONCE in logs admin_pass = "".join( secrets.choice(string.ascii_letters + string.digits) for _ in range(16) ) logger.warning("=" * 60) logger.warning("PREMIER DÉMARRAGE — Compte admin créé automatiquement") logger.warning(f" Utilisateur : {admin_user}") logger.warning(f" Mot de passe : {admin_pass}") logger.warning("CHANGEZ CE MOT DE PASSE dès la première connexion !") logger.warning("=" * 60) try: create_user(admin_user, admin_pass, role="admin", vaults=["*"]) logger.info(f"Admin '{admin_user}' créé avec succès") except PermissionError as e: logger.critical("=" * 60) logger.critical("DÉMARRAGE IMPOSSIBLE : Erreur de permission sur le dossier 'data'") logger.critical("L'indexation et l'authentification ne peuvent pas fonctionner.") logger.critical("FIX : Vérifiez les droits du volume /app/data sur l'hôte.") logger.critical("Exemple : sudo chown -R 1000:1000 /DOCKER_CONFIG/ObsiGate/data") logger.critical("=" * 60) raise e # --------------------------------------------------------------------------- # Security headers middleware # --------------------------------------------------------------------------- class SecurityHeadersMiddleware(BaseHTTPMiddleware): """Add security headers to all HTTP responses.""" async def dispatch(self, request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "SAMEORIGIN" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" # A route may set a stricter per-response policy (e.g. ``sandbox`` for # standalone SVG, #108-B3); keep it instead of overwriting it. if "Content-Security-Policy" not in response.headers: response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; " "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; " "img-src 'self' data: blob:; " "connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; " "font-src 'self' data: https://fonts.gstatic.com https://esm.sh; " "worker-src 'self' blob:; " "frame-src 'self' blob:; " "object-src 'none'; " "base-uri 'self'; " "form-action 'self'; " "frame-ancestors 'self';" ) # Static assets are NOT content-hashed, so they must revalidate: # ``immutable``/long max-age made Cloudflare and mobile browsers serve # a stale build for a year (the service worker cache compounded it). # ``no-cache`` keeps caching but forces revalidation (ETag/Last-Modified). if request.url.path.startswith("/static/"): response.headers["Cache-Control"] = "no-cache" return response def _guard_insecure_auth() -> None: """Warn or refuse to start when authentication is disabled (BUG-037). With ``OBSIGATE_AUTH_ENABLED=false`` every request is served as an anonymous admin. That is convenient for local use but dangerous when the process is reachable from a network. Binding to a non-loopback host without the explicit ``OBSIGATE_ALLOW_INSECURE=true`` opt-in is refused. """ from backend.auth.middleware import ( bind_host_from_argv, is_auth_enabled, is_insecure_mode_allowed, is_loopback_host, ) if is_auth_enabled(): return if is_insecure_mode_allowed(): logger.warning( "Authentication is DISABLED and OBSIGATE_ALLOW_INSECURE=true: every request " "is treated as an anonymous administrator. Do not expose this instance." ) return host = bind_host_from_argv() if not is_loopback_host(host): raise RuntimeError( "Refusing to start: authentication is disabled (OBSIGATE_AUTH_ENABLED=false) " f"while binding to a non-loopback address ('{host}'). This would expose an " "unauthenticated instance with admin access. Enable authentication, or set " "OBSIGATE_ALLOW_INSECURE=true if you really know what you are doing." ) logger.warning( "Authentication is DISABLED (OBSIGATE_AUTH_ENABLED=false): every request is " "treated as an anonymous administrator. This is only safe on a trusted, " "loopback-only deployment." ) @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan: build index on startup, cleanup on shutdown.""" # Thread pool for offloading CPU-bound search from the event loop. # Sized to 2 workers so concurrent searches don't starve other requests. init_search_executor() # BUG-037: refuse to expose an unauthenticated instance on a public bind. _guard_insecure_auth() # Bootstrap admin account if needed bootstrap_admin() logger.info("ObsiGate starting — building index in background...") async def _progress_cb(event_type: str, data: dict): await sse_manager.broadcast("index_" + event_type, data) async def _background_startup(): logger.info("Background indexing started") await build_index(_progress_cb) # Build inverted index in a thread pool to avoid blocking the event loop. # The inverted index rebuild is CPU-bound (tokenization, indexing) and # would freeze HTTP responses if run in the async event loop. loop = asyncio.get_running_loop() await loop.run_in_executor(get_search_executor(), init_inverted_index) # Build the semantic (embedding) index in the same background thread pool. await loop.run_in_executor(get_search_executor(), init_semantic_index) # BUG-040: extract the PDF text deferred during the scan now that the # index and inverted index are queryable (keeps startup non-blocking). from backend.indexer import enrich_pdf_texts await enrich_pdf_texts() # Scan for plugins in all vaults logger.info("Scanning for plugins...") from backend.indexer import vault_config from backend.plugins import get_plugin_registry registry = get_plugin_registry() for vault_name, cfg in vault_config.items(): vault_path = cfg.get("path") if vault_path: try: plugins = registry.scan_vault(vault_name, vault_path) logger.info(f"Vault '{vault_name}': found {len(plugins)} plugin(s)") from backend.plugins import emit_vault_mounted emit_vault_mounted(vault_name, vault_path) except Exception as e: logger.warning(f"Plugin scan failed for vault '{vault_name}': {e}") # Start file watcher (handle partagé : voir backend.watcher_state) config = _load_config() watcher_enabled = config.get("watcher_enabled", True) if watcher_enabled: use_polling = config.get("watcher_use_polling", False) polling_interval = config.get("watcher_polling_interval", 5.0) debounce = config.get("watcher_debounce", 2.0) watcher = VaultWatcher( on_file_change=_on_vault_change, debounce_seconds=debounce, use_polling=use_polling, polling_interval=polling_interval, ) from backend.indexer import vault_config vaults_to_watch = {name: cfg["path"] for name, cfg in vault_config.items()} await watcher.start(vaults_to_watch) set_watcher(watcher) logger.info("File watcher started in background.") else: logger.info("File watcher disabled by configuration.") logger.info("Background startup complete.") asyncio.create_task(_background_startup()) logger.info("ObsiGate ready (listening for requests while indexing).") yield # Shutdown await collab_manager.stop() watcher = get_watcher() if watcher: await watcher.stop() set_watcher(None) shutdown_search_executor() from backend.version import get_version app = FastAPI( title="ObsiGate API", version=get_version(), lifespan=lifespan, description=API_DESCRIPTION.strip(), openapi_tags=TAGS_METADATA, docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", contact={"name": "ObsiGate", "url": "https://git.dracodev.net/Projets/ObsiGate"}, license_info={"name": "MIT"}, ) # Enrich the auto-generated OpenAPI 3.1 schema (#72): tags per category, # examples, security schemes and documented error responses. _original_openapi = app.openapi def _custom_openapi(): if app.openapi_schema: return app.openapi_schema schema = _original_openapi() app.openapi_schema = enrich_openapi_schema(schema) return app.openapi_schema app.openapi = _custom_openapi # type: ignore[method-assign] @app.exception_handler(ServiceError) async def _service_error_handler(request: Request, exc: ServiceError): """Map shared-layer domain errors to HTTP responses (``{"detail": ...}``).""" return JSONResponse(status_code=exc.status, content={"detail": exc.message}) # GZip compression — reduces bandwidth by ~70% for text responses # Custom wrapper: skip compression for SSE streams (/api/events) from fastapi.middleware.gzip import GZipMiddleware from starlette.types import Receive, Scope, Send class SSESafeGZipMiddleware(GZipMiddleware): """GZip middleware that skips SSE (Server-Sent Events) streams. GZip buffering breaks incremental streaming required by SSE. We detect SSE endpoints by path and bypass compression entirely. """ # SSE endpoints that must not be buffered by GZip. _SSE_PATHS = ( "/api/events", "/api/admin/stream", "/api/ai/bookslm/chat", "/api/ai/bookslm/agent", "/mcp", ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "http" and scope.get("path") in self._SSE_PATHS: # Bypass GZip: passthrough directly to the inner app await self.app(scope, receive, send) else: await super().__call__(scope, receive, send) app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000) # Security headers on all responses app.add_middleware(SecurityHeadersMiddleware) # Auth router # Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c). from backend.ai_routes import router as ai_router from backend.auth.middleware import ( require_admin, ) from backend.auth.router import router as auth_router from backend.bookslm_routes import router as bookslm_router from backend.routers.backups import router as backups_router from backend.routers.config import _load_config from backend.routers.config import router as config_router from backend.routers.conflicts import router as conflicts_router from backend.routers.files_media import router as files_media_router from backend.routers.files_read import router as files_read_router from backend.routers.files_write import router as files_write_router from backend.routers.health import router as health_router from backend.routers.history import router as history_router from backend.routers.realtime import router as realtime_router from backend.routers.search import router as search_router from backend.routers.sharing import router as sharing_router from backend.routers.vaults import router as vaults_router from backend.routers.webhooks import router as webhooks_router from backend.skills_routes import router as skills_router app.include_router(auth_router) app.include_router(ai_router) app.include_router(bookslm_router) app.include_router(skills_router) app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(history_router) # ROADMAP #85 T8 — History app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts app.include_router(config_router) # ROADMAP #85 T7 — Config app.include_router(files_read_router) # ROADMAP #85 T6a — Files read app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export app.include_router(files_write_router) # ROADMAP #85 T6b — Files write app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing app.include_router(vaults_router) # ROADMAP #85 T8 — Vaults # Admin Dashboard endpoints (system stats, audit logs, backups, stream) try: from backend.admin import router as admin_router app.include_router(admin_router) logger.info("Admin dashboard router mounted at /api/admin/*") except ImportError as e: logger.warning(f"Could not load admin dashboard router: {e}") # Push Notifications endpoints (Web Push API + VAPID) try: from backend.push import router as push_router app.include_router(push_router) logger.info("Push notifications router mounted at /api/push/*") except ImportError as e: logger.warning(f"Could not load push notifications router: {e}") # Plugins system endpoints try: from backend.plugins import router as plugins_router app.include_router(plugins_router) logger.info("Plugins router mounted at /api/plugins/*") except ImportError as e: logger.warning(f"Could not load plugins router: {e}") # MCP server (Streamable HTTP) for external clients (#79 phase E) try: from backend.mcp.server import McpMount, mcp_app app.router.routes.append(McpMount(mcp_app)) logger.info("MCP server mounted at /mcp") except Exception as e: # pragma: no cover - optional dependency logger.warning(f"Could not mount MCP server: {e}") # Resolve frontend path relative to this file FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" # --------------------------------------------------------------------------- # API documentation landing page (#72) # --------------------------------------------------------------------------- @app.get("/api", include_in_schema=False, response_class=HTMLResponse) @app.get("/api/", include_in_schema=False, response_class=HTMLResponse) async def api_docs_landing(): """Human-friendly API documentation landing page (links to /docs, /redoc).""" return HTMLResponse(render_api_landing(get_version())) # --------------------------------------------------------------------------- # Path safety helper : voir backend.routers.helpers (#85 T6a, T6c) # --------------------------------------------------------------------------- def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: """Resolve a relative path safely within the vault root. Thin wrapper around the shared :func:`backend.services.paths.resolve_safe_path` (single implementation used by both routes and tools). The raised :class:`ServiceError` is mapped to an ``HTTPException`` response by the global exception handler in this module. Args: vault_root: The vault's root directory (absolute). relative_path: The user-supplied relative path. Returns: Resolved absolute ``Path``. """ from backend.services.paths import resolve_safe_path as _service_resolve return _service_resolve(vault_root, relative_path) def _backup_file(file_path: Path, vault_name: str, relative_path: str): """Create a timestamped backup of a file before modification. Thin wrapper around :func:`backend.services.backups.create_backup` (single implementation used by both routes and tools). Backups are stored in ``{backup_root}/{vault}/{relative_path}.{timestamp}.bak``; the operation is best-effort and never blocks the caller. """ from backend.services.backups import create_backup create_backup(file_path, vault_name, relative_path) def _check_vault_writable(vault_root: Path) -> bool: """Check if a vault is writable (not mounted read-only). Args: vault_root: The vault's root directory (absolute). Returns: True if the vault is writable, False otherwise. """ return os.access(vault_root, os.W_OK) # --------------------------------------------------------------------------- # Markdown rendering helpers : voir backend.render (#85 T9) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # API Endpoints — System / health : voir backend.routers.health (#85 T1) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Vaults : voir backend.routers.vaults (#85 T8) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # History (recent / bookmarks / saved-searches) : voir backend.routers.history (#85 T8) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # File browse & read endpoints : voir backend.routers.files_read (#85 T6a) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # File PDF / export / guide / media endpoints : voir backend.routers.files_media (#85 T6c) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # File & directory mutations : voir backend.routers.files_write (#85 T6b) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # File creation and rename endpoints : voir backend.routers.files_write (#85 T6b) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # File creation and rename endpoints : voir backend.routers.files_write (#85 T6b) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Backup & Diff endpoints # --------------------------------------------------------------------------- def _get_backup_dir(vault_name: str, relative_path: str) -> Path: """Return the directory where backups for a specific file are stored. Thin wrapper around :func:`backend.services.backups.get_backup_dir` (single implementation used by both routes and tools). """ return service_get_backup_dir(vault_name, relative_path) # --------------------------------------------------------------------------- # File-level backup endpoints : voir backend.routers.backups (#85 T4) # --------------------------------------------------------------------------- # File backlinks + view endpoints : voir backend.routers.files_read (#85 T6a) # Range helper : voir backend.routers.helpers.stream_file_with_range (#85 T6c) # PDF stream/info endpoints : voir backend.routers.files_media (#85 T6c) # --------------------------------------------------------------------------- # Search / suggest / graph / index-reload : voir backend.routers.search (#85 T5) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # SSE endpoint : voir backend.routers.realtime (#85 T9) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Dynamic vault management endpoints : voir backend.routers.vaults (#85 T8) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Image / media / attachments / vault-settings : voir backend.routers.files_media (#85 T6c) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Vault Settings API — Display preferences : voir backend.routers.files_media (#85 T6c) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Backup Management API # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Global backup endpoints : voir backend.routers.backups (#85 T4) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Configuration API : voir backend.routers.config (#85 T7) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # AI API Keys : voir backend.routers.config (#85 T7) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Tool & connected-source keys (#103) : voir backend.routers.config (#85 T7) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # AI Models : voir backend.routers.config (#85 T7) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # AI Models : voir backend.routers.config (#85 T7) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Diagnostics API : voir backend.routers.config (#85 T7) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Dashboard endpoint (aggregated stats) : voir backend.routers.config (#85 T7) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Webhook CRUD endpoints : voir backend.routers.webhooks (#85 T2) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Share (public document) endpoints : voir backend.routers.sharing (#85 T3) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Syncthing conflict endpoints : voir backend.routers.conflicts (#85 T8) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Real-time collaboration — WebSocket endpoint : voir backend.routers.realtime (#85 T9, ROADMAP #62) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Static files & SPA fallback # --------------------------------------------------------------------------- if FRONTEND_DIR.exists(): # ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache). app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") @app.get("/sw.js") async def serve_service_worker(): """Serve the service worker for PWA support.""" sw_file = FRONTEND_DIR / "sw.js" if sw_file.exists(): return FileResponse( sw_file, media_type="application/javascript", headers={ "Cache-Control": "no-cache, no-store, must-revalidate", "Service-Worker-Allowed": "/" } ) raise HTTPException(status_code=404, detail="Service worker not found") @app.get("/manifest.json") async def serve_manifest(): """Serve the PWA manifest.""" manifest_file = FRONTEND_DIR / "manifest.json" if manifest_file.exists(): return FileResponse( manifest_file, media_type="application/manifest+json", headers={"Cache-Control": "no-cache"} ) raise HTTPException(status_code=404, detail="Manifest not found") @app.get("/popout/{vault_name}/{path:path}") async def serve_popout(vault_name: str, path: str): """Serve the minimalist popout page for a specific file.""" popout_file = FRONTEND_DIR / "popout.html" if popout_file.exists(): return HTMLResponse(content=popout_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Popout template not found") @app.get("/editor-poc") async def serve_editor_poc(): """Serve the standalone Editor POC page (multi-zone toolbar demo).""" poc_file = FRONTEND_DIR / "editor-poc.html" if poc_file.exists(): return HTMLResponse(content=poc_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Editor POC not found") @app.get("/admin.html", response_class=HTMLResponse) async def serve_admin_page(_current_user=Depends(require_admin)): """Serve the admin dashboard page (ROADMAP #71) — admin-gated. Must be declared BEFORE the SPA catch-all ``/{full_path:path}`` or the admin page would be shadowed by ``index.html`` (the reported bug: the Admin menu kept returning to the main page). """ admin_file = FRONTEND_DIR / "admin.html" if admin_file.exists(): return HTMLResponse(content=admin_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Admin page not found") @app.get("/{full_path:path}") async def serve_spa(full_path: str): """Serve the SPA index.html for all non-API routes.""" index_file = FRONTEND_DIR / "index.html" if index_file.exists(): return HTMLResponse(content=index_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Frontend not found")