OpenMind v1 : cartes mentales libres, local-first et dockerisees
Premiere version fonctionnelle, demarrable avec `docker compose up -d --build` sur http://localhost:8080. Architecture - Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react, etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre. - Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les fichiers statiques du frontend compile. - Image Docker multi-stage, un seul service, bind mount ./data pour SQLite. Local-first Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur demande explicite de partage. Partage Deux secrets distincts : share_token public en lecture seule dans l'URL, et management_token conserve uniquement cote client, transmis en en-tete pour les modifications et stocke hashe en base. Fonctionnalites Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis, branches repliables, recherche), 9 themes, 4 dispositions automatiques, panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression, page publique en lecture seule, PWA. Qualite 25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur, TypeScript strict sans erreur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
11
backend/app/api/__init__.py
Normal file
11
backend/app/api/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Agregation des routeurs sous le prefixe /api."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import health, shares
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(shares.router)
|
||||
|
||||
__all__ = ["api_router"]
|
||||
29
backend/app/api/deps.py
Normal file
29
backend/app/api/deps.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Dependances communes aux routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db.session import get_db
|
||||
from app.services.errors import ShareForbiddenError
|
||||
|
||||
MANAGEMENT_TOKEN_HEADER = "X-Management-Token"
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
AppSettings = Annotated[Settings, Depends(get_settings)]
|
||||
|
||||
|
||||
def require_management_token(
|
||||
x_management_token: Annotated[str | None, Header(alias=MANAGEMENT_TOKEN_HEADER)] = None,
|
||||
) -> str:
|
||||
"""Recupere le secret de gestion depuis l'en-tete (jamais depuis l'URL)."""
|
||||
if not x_management_token:
|
||||
raise ShareForbiddenError("Jeton de gestion manquant.")
|
||||
return x_management_token
|
||||
|
||||
|
||||
ManagementToken = Annotated[str, Depends(require_management_token)]
|
||||
25
backend/app/api/health.py
Normal file
25
backend/app/api/health.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Endpoints de sante et de configuration publique."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import AppSettings
|
||||
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
|
||||
@router.get("/health", summary="Etat du service")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/config", summary="Configuration publique exposee au frontend")
|
||||
def public_config(settings: AppSettings) -> dict[str, object]:
|
||||
"""Aucun secret ici : uniquement ce dont l'interface a besoin."""
|
||||
return {
|
||||
"appName": settings.app_name,
|
||||
"maxNodes": settings.max_nodes,
|
||||
"maxMapSizeMb": settings.max_map_size_mb,
|
||||
"sharingEnabled": True,
|
||||
}
|
||||
98
backend/app/api/shares.py
Normal file
98
backend/app/api/shares.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""API REST de partage de cartes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
|
||||
from app.api.deps import DbSession, ManagementToken
|
||||
from app.schemas.share import (
|
||||
ShareCreatedOut,
|
||||
ShareCreateIn,
|
||||
SharePublicOut,
|
||||
ShareUpdatedOut,
|
||||
)
|
||||
from app.services import shares as share_service
|
||||
|
||||
router = APIRouter(prefix="/shares", tags=["shares"])
|
||||
|
||||
|
||||
def _share_path(share_token: str) -> str:
|
||||
return f"/s/{share_token}"
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ShareCreatedOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Publier un snapshot de carte et obtenir un lien non repertorie",
|
||||
)
|
||||
def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
|
||||
share, management_token = share_service.create_share(
|
||||
session, title=payload.title, document=payload.map
|
||||
)
|
||||
return ShareCreatedOut(
|
||||
share_token=share.share_token,
|
||||
management_token=management_token,
|
||||
share_path=_share_path(share.share_token),
|
||||
title=share.title,
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{share_token}",
|
||||
response_model=SharePublicOut,
|
||||
summary="Lire une carte partagee (public, lecture seule)",
|
||||
)
|
||||
def read_share(share_token: str, session: DbSession) -> SharePublicOut:
|
||||
share = share_service.get_share(session, share_token)
|
||||
return SharePublicOut(
|
||||
share_token=share.share_token,
|
||||
title=share.title,
|
||||
map_version=share.map_version,
|
||||
map=share_service.load_map_data(share),
|
||||
created_at=share.created_at,
|
||||
updated_at=share.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{share_token}",
|
||||
response_model=ShareUpdatedOut,
|
||||
summary="Mettre a jour le snapshot partage (necessite le jeton de gestion)",
|
||||
)
|
||||
def update_share(
|
||||
share_token: str,
|
||||
payload: ShareCreateIn,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
) -> ShareUpdatedOut:
|
||||
share = share_service.update_share(
|
||||
session,
|
||||
share_token,
|
||||
management_token=management_token,
|
||||
title=payload.title,
|
||||
document=payload.map,
|
||||
)
|
||||
return ShareUpdatedOut(
|
||||
share_token=share.share_token,
|
||||
share_path=_share_path(share.share_token),
|
||||
title=share.title,
|
||||
updated_at=share.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{share_token}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Supprimer le partage (necessite le jeton de gestion)",
|
||||
)
|
||||
def delete_share(
|
||||
share_token: str,
|
||||
session: DbSession,
|
||||
management_token: ManagementToken,
|
||||
) -> Response:
|
||||
share_service.delete_share(session, share_token, management_token=management_token)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user