"""Logique metier du partage de cartes.""" from __future__ import annotations import hashlib import json import logging import threading from datetime import timedelta from sqlalchemy import func, select from sqlalchemy.orm import Session from app.config import get_settings from app.models.share import SharedMap, utcnow from app.schemas.map import MapDocumentIn from app.services.errors import ( PermanentShareNotAllowedError, ShareCapacityError, ShareExpiredError, ShareForbiddenError, ShareNotFoundError, SharePayloadTooLargeError, ) from app.services.tokens import ( generate_management_token, generate_share_token, hash_token, verify_token, ) logger = logging.getLogger(__name__) # Duree maximale acceptee pour une expiration explicite (10 ans). MAX_EXPIRY_DAYS = 3650 # Serialise "compter puis inserer" pour que deux creations simultanees ne # franchissent pas ensemble la derniere place. Suffisant pour l'architecture # d'OpenMind : un seul processus applicatif devant un SQLite local. Avec # plusieurs workers, la limite redeviendrait un garde-fou approximatif (voir # README, limitations connues). _capacity_lock = threading.Lock() def share_ref(share_token: str) -> str: """Reference courte et non reversible d'un partage, pour les journaux. Le share token est une capacite de lecture : le recopier dans les logs reviendrait a y ecrire le lien complet. On journalise donc une empreinte qui permet de correler des lignes entre elles sans permettre d'ouvrir la carte. """ return hashlib.sha256(share_token.encode("utf-8")).hexdigest()[:12] def serialize_map(document: MapDocumentIn) -> str: """Serialise le document carte en JSON compact et deterministe.""" return json.dumps( document.model_dump(mode="json"), ensure_ascii=False, separators=(",", ":"), ) def _guard_payload_size(payload: str) -> None: """Limite propre aux partages, distincte de la limite HTTP globale.""" settings = get_settings() size = len(payload.encode("utf-8")) if size > settings.max_share_size_bytes: raise SharePayloadTooLargeError( f"La carte fait {size / (1024 * 1024):.1f} Mo, " f"la limite de partage est de {settings.max_share_size_mb_effective:.0f} Mo." ) def _guard_capacity(session: Session) -> None: """Refuse une creation quand l'instance est pleine. Aucun partage existant n'est supprime pour faire de la place : une suppression silencieuse ferait disparaitre le lien de quelqu'un d'autre. La mise a jour d'un partage existant reste possible. """ limit = get_settings().max_shared_maps if limit <= 0: return total = session.execute(select(func.count()).select_from(SharedMap)).scalar_one() if total >= limit: raise ShareCapacityError() def resolve_expiry(expires_in_days: int | None, *, explicit: bool): # type: ignore[no-untyped-def] """Traduit une duree en date d'expiration absolue. `explicit=False` signifie que le client n'a rien demande : on applique alors la valeur par defaut de l'instance. """ settings = get_settings() if not explicit: days = settings.default_share_expiry_days return None if days <= 0 else utcnow() + timedelta(days=days) if expires_in_days is None: if not settings.allow_permanent_shares: raise PermanentShareNotAllowedError() return None days = max(1, min(int(expires_in_days), MAX_EXPIRY_DAYS)) return utcnow() + timedelta(days=days) def _get_by_token(session: Session, share_token: str) -> SharedMap: stmt = select(SharedMap).where(SharedMap.share_token == share_token) share = session.execute(stmt).scalar_one_or_none() if share is None: raise ShareNotFoundError() return share def _get_live(session: Session, share_token: str) -> SharedMap: """Comme `_get_by_token`, mais un partage expire est considere indisponible.""" share = _get_by_token(session, share_token) if share.is_expired(): raise ShareExpiredError() return share def create_share( session: Session, *, title: str, document: MapDocumentIn, expires_in_days: int | None = None, expiry_explicit: bool = False, ) -> tuple[SharedMap, str]: """Cree un partage et retourne (entite, management_token en clair). Le management token en clair n'est renvoye qu'ici, une seule fois. """ payload = serialize_map(document) _guard_payload_size(payload) management_token = generate_management_token() share = SharedMap( share_token=generate_share_token(), management_token_hash=hash_token(management_token), title=(title or document.name or "Carte sans titre")[: get_settings().max_title_length], map_version=document.version, map_data=payload, expires_at=resolve_expiry(expires_in_days, explicit=expiry_explicit), ) with _capacity_lock: _guard_capacity(session) session.add(share) session.commit() session.refresh(share) logger.info( "Partage cree share=%s noeuds=%s", share_ref(share.share_token), len(document.nodes) ) return share, management_token def get_share(session: Session, share_token: str) -> SharedMap: return _get_live(session, share_token) def set_expiry( session: Session, share_token: str, *, management_token: str, expires_in_days: int | None, ) -> SharedMap: """Change la date d'expiration. Reserve au detenteur du jeton de gestion. Volontairement applicable a un partage deja expire : c'est ce qui permet a son proprietaire de le reactiver plutot que de devoir tout republier. """ share = _get_by_token(session, share_token) if not verify_token(management_token, share.management_token_hash): logger.warning("Changement d'expiration refuse share=%s", share_ref(share_token)) raise ShareForbiddenError() share.expires_at = resolve_expiry(expires_in_days, explicit=True) share.updated_at = utcnow() session.commit() session.refresh(share) logger.info("Expiration mise a jour share=%s", share_ref(share_token)) return share def verify_management_token( session: Session, share_token: str, *, management_token: str ) -> SharedMap: """Verifie qu'un jeton de gestion correspond bien a ce partage. Sert a la reimportation d'une cle de gestion depuis un autre navigateur. Ne renvoie aucune donnee de carte. """ share = _get_by_token(session, share_token) if not verify_token(management_token, share.management_token_hash): logger.warning("Verification de cle refusee share=%s", share_ref(share_token)) raise ShareForbiddenError("Cette cle de gestion ne correspond pas a ce partage.") return share def load_map_data(share: SharedMap) -> dict: """Deserialise le snapshot stocke. Ne fait qu'un json.loads : aucun code execute.""" try: data = json.loads(share.map_data) except json.JSONDecodeError: # pragma: no cover - donnee corrompue logger.exception("Snapshot illisible pour share=%s", share_ref(share.share_token)) raise ShareNotFoundError("Le contenu de ce partage est illisible.") from None if not isinstance(data, dict): # pragma: no cover - donnee corrompue raise ShareNotFoundError("Le contenu de ce partage est illisible.") return data def update_share( session: Session, share_token: str, *, management_token: str, title: str, document: MapDocumentIn, ) -> SharedMap: share = _get_by_token(session, share_token) if not verify_token(management_token, share.management_token_hash): logger.warning("Tentative de mise a jour refusee share=%s", share_ref(share_token)) raise ShareForbiddenError() payload = serialize_map(document) _guard_payload_size(payload) share.title = (title or document.name or share.title)[: get_settings().max_title_length] share.map_version = document.version share.map_data = payload share.updated_at = utcnow() session.commit() session.refresh(share) logger.info("Partage mis a jour share=%s", share_ref(share_token)) return share def delete_share(session: Session, share_token: str, *, management_token: str) -> None: share = _get_by_token(session, share_token) if not verify_token(management_token, share.management_token_hash): logger.warning("Tentative de suppression refusee share=%s", share_ref(share_token)) raise ShareForbiddenError() session.delete(share) session.commit() logger.info("Partage supprime share=%s", share_ref(share_token))