- backend/user_home.py : ensure_user_home()/release_user_home() idempotents (mkdir, vault, watcher, octroi dans user.vaults), rappelés à la création d'un compte et au démarrage (migration + réparation + sweep des vaults home-* orphelins), dossier conservé à la suppression. - backend/indexer.py : data/vaults.json, registre persistant des vaults ajoutés à la volée, fusionné par load_vault_config() — ils disparaissaient au premier reindex complet et au redémarrage. reload_single_vault remet vault_config[name] après remove_vault_from_index. - backend/auth/router.py + routers/vaults.py : appels ensure/release + persistance à l'ajout/retrait d'un vault. - docker-compose.yml : montage unique /NFS/OBSIDIAN_DOC/Home:/vaults/Home + OBSIGATE_HOME_ROOT (absente = fonctionnalité inactive). - Tests : tests/test_user_home.py (12) + tests/test_auth_api.py::TestUserHome (2).
895 lines
38 KiB
Python
895 lines
38 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.")
|
|
|
|
# #194 : dossier personnel par utilisateur — réparation/migration
|
|
# (mkdir manquant, registre perdu, user créé hors API). Après le
|
|
# watcher pour que les vaults ajoutés soient surveillés.
|
|
from backend.user_home import ensure_all_user_homes
|
|
await ensure_all_user_homes()
|
|
|
|
logger.info("Background startup complete.")
|
|
|
|
asyncio.create_task(_background_startup())
|
|
|
|
async def _scheduler_loop():
|
|
"""Background tick for scheduled tasks (#170) — every 60 s, best effort."""
|
|
from backend.scheduler import tick
|
|
|
|
await asyncio.sleep(60)
|
|
while True:
|
|
try:
|
|
outcomes = await asyncio.to_thread(tick)
|
|
if outcomes:
|
|
logger.info(f"Scheduler tick: {len(outcomes)} task(s) executed")
|
|
except Exception as e:
|
|
logger.warning(f"Scheduler tick failed: {e}")
|
|
await asyncio.sleep(60)
|
|
|
|
if os.environ.get("OBSIGATE_SCHEDULER", "1") != "0":
|
|
asyncio.create_task(_scheduler_loop())
|
|
logger.info("Scheduler loop started (#170, 60 s tick).")
|
|
|
|
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.duplicates import router as duplicates_router
|
|
from backend.routers.file_chat import router as file_chat_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.notify import router as notify_router
|
|
from backend.routers.realtime import router as realtime_router
|
|
from backend.routers.scheduler import router as scheduler_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(file_chat_router) # ROADMAP #169 — Chat par fichier
|
|
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
|
|
app.include_router(duplicates_router) # ROADMAP #166 — Doublons
|
|
app.include_router(notify_router) # ROADMAP #168 — Notifications externes
|
|
app.include_router(scheduler_router) # ROADMAP #170 — Tâches planifiées
|
|
|
|
# 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")
|