"""API FastAPI de Kazeia-central (étapes 0-1 : lecture de flotte). Sert l'UI web locale et expose la lecture des endpoints provider existants par tablette. Les écritures (config/presets/profils/RAG) et l'export conversations seront ajoutés quand les méthodes call() base64-JSON seront livrées côté app patiente (docs/PROVIDER_RPC_SPEC.md). Lancer : `uvicorn kazeia_central.api.app:app --reload` """ from __future__ import annotations import base64 import os import secrets import time from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from starlette.responses import Response from ..adb import Adb, AdbError from ..provider import ProviderClient from ..store import BadPassword, Store, StoreLocked _WEB_DIR = Path(__file__).resolve().parent.parent / "web" # Base chiffrée locale. Défaut sous data/ (ignoré par .gitignore — jamais commité). _STORE_PATH = Path(os.environ.get("KAZEIA_STORE", Path.cwd() / "data" / "central.db")) # Identité opérateur pour la piste d'audit (= user d'auth réseau si défini). _OPERATOR = os.environ.get("KAZEIA_USER") or "local" def _now_ms() -> int: return int(time.time() * 1000) class _Password(BaseModel): password: str class _Label(BaseModel): label: str def _install_basic_auth(app: FastAPI) -> None: """Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour sécuriser une exposition réseau (0.0.0.0) — sans elle, l'app reste ouverte.""" user = os.environ.get("KAZEIA_USER") pwd = os.environ.get("KAZEIA_PASS") if not (user and pwd): return @app.middleware("http") async def _basic_auth(request, call_next): hdr = request.headers.get("authorization", "") ok = False if hdr.startswith("Basic "): try: u, _, p = base64.b64decode(hdr[6:]).decode("utf-8").partition(":") ok = secrets.compare_digest(u, user) and secrets.compare_digest(p, pwd) except Exception: ok = False if not ok: return Response("Authentification requise", status_code=401, headers={"WWW-Authenticate": 'Basic realm="Kazeia-central"'}) return await call_next(request) def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI: adb = adb or Adb() if store is None: _STORE_PATH.parent.mkdir(parents=True, exist_ok=True) store = Store(_STORE_PATH) app = FastAPI(title="Kazeia-central", version="0.0.1") _install_basic_auth(app) def client(serial: str) -> ProviderClient: return ProviderClient(adb, serial) def _guard(fn): try: return fn() except AdbError as e: raise HTTPException(status_code=502, detail=str(e)) from e except StoreLocked as e: raise HTTPException(status_code=423, detail=str(e)) from e except IndexError as e: # endpoint a renvoyé 0 ligne raise HTTPException(status_code=404, detail="aucune donnée") from e # ---- store opérateur (déverrouillage, labels patients, audit) --------- @app.get("/api/lock-status") def lock_status(): return {"initialized": store.is_initialized, "unlocked": store.is_unlocked} @app.post("/api/unlock") def unlock(body: _Password): first = not store.is_initialized try: store.unlock(body.password) except BadPassword as e: raise HTTPException(status_code=401, detail="mot de passe incorrect") from e store.audit("store_init" if first else "store_unlock", actor=_OPERATOR, now=_now_ms()) return {"unlocked": True, "initialized_now": first} @app.post("/api/lock") def lock(): store.lock() return {"unlocked": False} @app.get("/api/audit") def audit(limit: int = 200): return _guard(lambda: store.audit_log(limit)) @app.get("/api/health") def health(): return {"ok": True, "version": app.version} @app.get("/api/devices") def devices(): devs = [d.__dict__ for d in _guard(adb.devices)] # Enrichit avec le label patient si le store est déverrouillé. if store.is_unlocked: labels = store.device_labels() for d in devs: d["label"] = labels.get(d["serial"]) return devs @app.put("/api/devices/{serial}/label") def set_label(serial: str, body: _Label): _guard(lambda: store.set_device_label(serial, body.label, now=_now_ms())) # NB : ne PAS mettre le label (PII) dans audit.detail (colonne en clair). store.audit("set_label", actor=_OPERATOR, target=serial, now=_now_ms()) return {"serial": serial, "label": body.label} @app.get("/api/devices/{serial}/state") def state(serial: str): return _guard(lambda: client(serial).state()).model_dump() @app.get("/api/devices/{serial}/turns") def turns(serial: str): return [t.model_dump() for t in _guard(lambda: client(serial).turns())] @app.get("/api/devices/{serial}/crashes") def crashes(serial: str): return [c.model_dump() for c in _guard(lambda: client(serial).crashes())] @app.get("/api/devices/{serial}/models") def models(serial: str): return [x.model_dump() for x in _guard(lambda: client(serial).models())] @app.get("/api/devices/{serial}/voices") def voices(serial: str): return [v.model_dump() for v in _guard(lambda: client(serial).voices())] @app.get("/api/devices/{serial}/rag/status") def rag_status(serial: str): return _guard(lambda: client(serial).rag_status()).model_dump() @app.get("/api/devices/{serial}/rag/index") def rag_index(serial: str): return [d.model_dump() for d in _guard(lambda: client(serial).rag_index())] @app.get("/api/devices/{serial}/updates") def updates(serial: str): return _guard(lambda: client(serial).updates()).model_dump() @app.get("/api/devices/{serial}/profiles") def profiles(serial: str): return [p.model_dump() for p in _guard(lambda: client(serial).profiles())] @app.get("/api/devices/{serial}/conversations") def sessions(serial: str, profile_id: str | None = None): return [s.model_dump() for s in _guard(lambda: client(serial).sessions(profile_id))] # UI web locale servie à la racine — APRÈS les routes /api pour ne pas les # masquer. `html=True` → sert index.html sur "/". if _WEB_DIR.is_dir(): app.mount("/", StaticFiles(directory=str(_WEB_DIR), html=True), name="web") return app app = create_app()