Rend une instance exposee sur Internet exploitable sans la transformer en infrastructure : le produit reste local-first, sans compte et sans telemetrie. Protection du service : limitation de debit en memoire par client et par famille d'endpoint (health et ready jamais limites), interrupteur SHARING_ENABLED, capacite maximale de partages, taille propre aux partages. X-Forwarded-For n'est cru que si TRUST_PROXY_HEADERS l'autorise, et on lit alors la derniere valeur, celle ajoutee par le proxy. Expiration facultative des partages (24 h a 90 jours, ou jamais) : un lien expire repond 410 et la page publique le dit, sans autre detail. Le proprietaire peut prolonger ; aucune purge automatique. Protection des liens non repertories : robots.txt, X-Robots-Tag et meta robots sur /s/ et /api/, Referrer-Policy no-referrer sur tout le site, redaction des jetons dans les journaux d'acces, share_token porte a 128 bits (les anciens restent valides). Schema gere par Alembic, applique au demarrage. Une base creee avant Alembic est estampillee puis migree : verifie sur la base reelle de developpement, 4 partages inchanges et expires_at NULL. Administration par CLI locale au conteneur (stats, liste, suppression, purge, sauvegarde a chaud via l'API SQLite, restauration prudente) : aucune interface /admin exposee. Le jeton de gestion peut enfin etre exporte dans un fichier .openmind-share puis reimporte depuis un autre navigateur, apres verification aupres du serveur. Il n'est toujours jamais ajoute a un export .mmap. Ajoute une page /privacy dont le texte est centralise, et corrige un bug trouve en observant les logs : Alembic, lance dans le processus applicatif, reappliquait la configuration de logs d'alembic.ini et desactivait les loggers existants, ce qui faisait disparaitre les journaux d'acces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
8.1 KiB
Python
247 lines
8.1 KiB
Python
"""Logique metier du partage de cartes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
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
|
|
|
|
|
|
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)
|
|
_guard_capacity(session)
|
|
|
|
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),
|
|
)
|
|
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))
|