import asyncio import html as html_mod import json as _json import logging import os import re import secrets import string from contextlib import asynccontextmanager from pathlib import Path import mistune from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from starlette.middleware.base import BaseHTTPMiddleware from backend.collab import authenticate_websocket, collab_manager from backend.image_processor import preprocess_images from backend.indexer import ( build_index, find_file_in_index, get_vault_data, 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 from backend.services.sanitizer import sanitize_html logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", ) logger = logging.getLogger("obsigate") # --------------------------------------------------------------------------- # Pydantic response models : voir backend.schemas (vaults/history : #85 T8) # --------------------------------------------------------------------------- # Filesystem mutation + search / suggest / graph models : voir backend.schemas (#85 T5, T6b) class BackupEntry(BaseModel): """A single backup version of a file.""" timestamp: int = Field(description="Unix timestamp of when the backup was created") datetime: str = Field(description="ISO 8601 datetime string") size: int = Field(description="File size in bytes") filename: str = Field(description="Backup filename on disk") class BackupListResponse(BaseModel): """Response listing all available backups for a file.""" vault: str = Field(description="Vault name") path: str = Field(description="Relative file path") backups: list[BackupEntry] = Field(description="Available backups, newest first") class DiffRequest(BaseModel): """Request parameters for generating a diff.""" version: int = Field(description="Timestamp of the backup version to compare") compare_with: int | None = Field(default=None, description="Timestamp of another backup version. If omitted, compares with the current file.") # --------------------------------------------------------------------------- # 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 ( check_vault_access, require_admin, require_auth, ) 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.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.secret_redactor import redact_file_content 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(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 (singleton renderer) # --------------------------------------------------------------------------- import unicodedata def _heading_slugify(text: str) -> str: """Generate a URL-safe slug from heading text. Matches the JavaScript slugify algorithm exactly using Unicode-aware character classification: 1. Strip HTML tags (e.g. wikilink spans rendered inside headings) 2. Decode HTML entities (e.g. ``&`` → ``&``) 3. Lowercase 4. NFD normalize + strip combining marks 5. Keep only Unicode letters, numbers, spaces, hyphens 6. Replace spaces with hyphens, collapse multiple hyphens Args: text: The heading text content (may contain inline HTML). Returns: A URL-safe slug string. """ # Strip any inline HTML so it does not pollute the slug text = re.sub(r"<[^>]+>", "", text) # Decode HTML entities so & becomes & before slugification text = html_mod.unescape(text) text = text.lower() text = unicodedata.normalize("NFD", text) text = "".join(ch for ch in text if not unicodedata.combining(ch)) # Unicode-aware: keep letters (L*), numbers (N*), spaces, and hyphens cleaned = [] for ch in text: cat = unicodedata.category(ch) if cat.startswith('L') or cat.startswith('N') or ch in (' ', '-'): cleaned.append(ch) text = "".join(cleaned) text = re.sub(r"\s+", "-", text) text = re.sub(r"-+", "-", text) result = text.strip("-") return result if result else "heading" def _add_heading_ids(html: str) -> str: """Post-process rendered HTML to add IDs to heading tags. Adds an ``id`` attribute to every ``