Files
ObsiGate/backend/main.py
T
bruno d6fa8c7bb1
CI / lint (push) Successful in 2m46s
CI / security (push) Successful in 2m1s
CI / test (push) Successful in 4m40s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 15m57s
fix: injecte le nonce CSP dans les pages /docs et /redoc générées par FastAPI (BUG-106)
2026-10-02 22:04:25 -04:00

863 lines
37 KiB
Python

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):
from backend.csp import inject_csp_nonce, new_nonce
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
# 'unsafe-inline' est conservé jusqu'en T5c (bascule avec validation E2E).
nonce = new_nonce()
request.state.csp_nonce = nonce
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:
# #87 T5c : `script-src` sans 'unsafe-inline' — seuls les scripts
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
# `style=` et `el.style` omniprésents — chantier séparé).
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' 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';"
)
# BUG-106 : /docs et /redoc sont générés par FastAPI, hors de nos
# routes qui appellent inject_csp_nonce — leur balisage inline
# restait sans nonce et était bloqué par script-src (page blanche).
# On injecte ici avec le même helper, seul le HTML est retouché.
if request.url.path.startswith(("/docs", "/redoc")) and "text/html" in (
response.headers.get("Content-Type") or ""
):
import gzip
# La compression (GZipMiddleware) est plus proche de la route :
# le corps arrive déjà gunzippé, on recomprime à l'identique.
raw = b"".join([chunk async for chunk in response.body_iterator])
is_gz = "gzip" in (response.headers.get("Content-Encoding") or "")
if is_gz:
raw = gzip.decompress(raw)
patched = inject_csp_nonce(raw.decode("utf-8", "replace"), nonce).encode("utf-8")
if is_gz:
patched = gzip.compress(patched)
response.headers["Content-Length"] = str(len(patched))
async def _docs_body():
yield patched
response.body_iterator = _docs_body()
# 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()
# #87 T3/T8 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair). Avec
# `OBSIGATE_SECURE_COOKIES=auto` (défaut) + `OBSIGATE_TRUST_PROXY=true`,
# le flag suit `X-Forwarded-Proto` : pas d'avertissement, le https du
# reverse proxy est honoré.
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
from backend.auth.router import is_secure_cookies
from backend.services.net import is_trusted_proxy
secure_mode = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
proxy_secure = secure_mode == "auto" and is_trusted_proxy()
if (is_auth_enabled() and not is_secure_cookies()
and not is_loopback_host(bind_host_from_argv()) and not proxy_secure):
logger.warning(
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
)
# 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": ...}``).
``code`` and ``details`` travel with the message so the client can react to
a specific case instead of parsing prose (#153 A1 : ``xlsx_lossy_content``
asks the viewer to confirm before forcing a lossy write).
"""
return JSONResponse(
status_code=exc.status,
content={
"detail": exc.message,
"code": exc.code,
"details": exc.details,
},
)
# 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.middleware.cors import CORSMiddleware
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)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus
# seulement l'absence de middleware). Sûr pour tous les clients : web
# (same-origin), desktop Tauri (la webview est redirigée same-origin sur
# http://127.0.0.1:<port>, voir frontend/js/desktop.js) et API directe
# (curl/scripts, CORS non appliqué hors navigateur). Ajouté en dernier :
# le plus externe, les preflights court-circuitent avant tout le reste.
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# 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
# ---------------------------------------------------------------------------
def _html_with_nonce(request: Request, name: str) -> str:
"""Read a frontend HTML file and inject the per-response CSP nonce (#87 T5b)."""
from backend.csp import inject_csp_nonce
return inject_csp_nonce(
(FRONTEND_DIR / name).read_text(encoding="utf-8"),
request.state.csp_nonce,
)
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(request: Request, 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=_html_with_nonce(request, "popout.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Popout template not found")
@app.get("/editor-poc")
async def serve_editor_poc(request: Request):
"""Serve the standalone Editor POC page (multi-zone toolbar demo)."""
poc_file = FRONTEND_DIR / "editor-poc.html"
if poc_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "editor-poc.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Editor POC not found")
@app.get("/excalidraw-editor.html", include_in_schema=False)
async def serve_excalidraw_editor(request: Request):
"""Serve the Excalidraw editor with CSP nonce (#87 T5b).
Remplace l'accès direct via ``/static/`` (utilisé par l'iframe du
viewer) : sans injection, les scripts inline seraient bloqués dès
le retrait de ``'unsafe-inline'`` (T5c).
"""
exca_file = FRONTEND_DIR / "excalidraw-editor.html"
if exca_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "excalidraw-editor.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Excalidraw editor not found")
@app.get("/admin.html", response_class=HTMLResponse)
async def serve_admin_page(request: Request, _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=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Admin page not found")
@app.get("/{full_path:path}")
async def serve_spa(request: Request, 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=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")