"""Logique metier du partage de cartes.""" from __future__ import annotations import json import logging from sqlalchemy import 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 ( ShareForbiddenError, ShareNotFoundError, SharePayloadTooLargeError, ) from app.services.tokens import ( generate_management_token, generate_share_token, hash_token, verify_token, ) logger = logging.getLogger(__name__) 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: settings = get_settings() size = len(payload.encode("utf-8")) if size > settings.max_map_size_bytes: raise SharePayloadTooLargeError( f"La carte fait {size / (1024 * 1024):.1f} Mo, " f"la limite est de {settings.max_map_size_mb} Mo." ) 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 create_share(session: Session, *, title: str, document: MapDocumentIn) -> 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, ) session.add(share) session.commit() session.refresh(share) logger.info("Partage cree share_token=%s nodes=%s", share.share_token, len(document.nodes)) return share, management_token def get_share(session: Session, share_token: str) -> SharedMap: return _get_by_token(session, share_token) 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_token=%s", 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_token=%s", 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_token=%s", 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_token=%s", share_token) raise ShareForbiddenError() session.delete(share) session.commit() logger.info("Partage supprime share_token=%s", share_token)