Fail-fast sur la configuration. Une limite de debit illisible (10/hours, 10/banana, -1/hour) arrete desormais le demarrage avec un message nommant la variable, au lieu de desactiver silencieusement la protection : seules les valeurs 0, off, none, disabled et vide desactivent volontairement. Meme traitement pour MAX_SHARED_MAPS, MAX_SHARE_SIZE_MB, DEFAULT_SHARE_EXPIRY_DAYS et chaque entree de TRUSTED_PROXY_CIDRS. X-Forwarded-For n'est plus cru sur la seule foi de TRUST_PROXY_HEADERS : la connexion doit aussi provenir d'un reseau declare dans TRUSTED_PROXY_CIDRS (ipaddress, IPv4/IPv6/CIDR). Activer la confiance sans declarer de reseau est refuse au demarrage, faute de quoi la configuration semblerait active sans l'etre. L'adresse retenue est normalisee : les differentes ecritures d'une meme IPv6 ne donnent plus des compteurs distincts. MAX_SHARED_MAPS : comptage et insertion serialises par un verrou de processus, pour que deux creations simultanees ne franchissent pas ensemble la derniere place. Documente comme valable pour un service applicatif unique. Ajoute APP_BIND pour ne pas publier 8080 sur toutes les interfaces en production, une option backup --keep pour la retention, et documente Nginx, Traefik en override, cron et systemd. Corrige un bug de rendu du build local : mon edition avait reecrit docker-entrypoint.sh en CRLF, rendant le shebang introuvable dans le conteneur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
8.5 KiB
Python
257 lines
8.5 KiB
Python
"""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))
|