Phase 2.1 : preparation au deploiement public

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>
This commit is contained in:
2026-09-10 09:22:17 +02:00
parent e3c530348e
commit 49870dcd56
51 changed files with 3341 additions and 110 deletions

View File

@@ -0,0 +1,39 @@
"""Identification du client pour la limitation de debit.
Le point sensible : `X-Forwarded-For` est un en-tete que n'importe quel client
peut ecrire. Le croire sans condition permettrait a un client direct de changer
d'identite a chaque requete et de contourner toute limite.
La confiance est donc explicite et desactivee par defaut
(`TRUST_PROXY_HEADERS`). Quand elle est activee, on lit la DERNIERE valeur de
`X-Forwarded-For` : un reverse proxy correctement configure ajoute l'adresse
qu'il a reellement vue a la fin de la liste, tandis que les valeurs precedentes
peuvent avoir ete forgees par le client. Cette lecture suppose un seul proxy
directement devant OpenMind, ce qui correspond au deploiement documente.
"""
from __future__ import annotations
from starlette.requests import Request
FORWARDED_FOR = "x-forwarded-for"
REAL_IP = "x-real-ip"
UNKNOWN_CLIENT = "unknown"
def resolve_client_ip(request: Request, *, trust_proxy_headers: bool) -> str:
if trust_proxy_headers:
forwarded = request.headers.get(FORWARDED_FOR)
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[-1]
real_ip = request.headers.get(REAL_IP)
if real_ip and real_ip.strip():
return real_ip.strip()
client = request.client
if client and client.host:
return client.host
return UNKNOWN_CLIENT

View File

@@ -17,6 +17,8 @@ class ShareError(Exception):
super().__init__(message or self.message)
if message:
self.message = message
# En-tetes additionnels de la reponse (Retry-After, par exemple).
self.headers: dict[str, str] = {}
class ShareNotFoundError(ShareError):
@@ -32,3 +34,40 @@ class ShareForbiddenError(ShareError):
class SharePayloadTooLargeError(ShareError):
status_code = 413
message = "La carte depasse la taille maximale autorisee."
class ShareExpiredError(ShareError):
"""Le partage a existe mais sa date d'expiration est passee.
Statut distinct du 404 : c'est ce qui permet a la page publique d'afficher
"Ce partage a expire" plutot qu'un message d'absence. Rien d'autre n'est
revele : ni le titre, ni le contenu, ni la date de creation.
"""
status_code = 410
message = "Ce partage a expire."
class SharingDisabledError(ShareError):
status_code = 503
message = "Le partage est desactive sur cette instance."
class ShareCapacityError(ShareError):
status_code = 503
message = "Cette instance a atteint sa capacite maximale de partages."
class PermanentShareNotAllowedError(ShareError):
status_code = 400
message = "Cette instance n'autorise pas les partages sans expiration."
class RateLimitedError(ShareError):
status_code = 429
message = "Trop de requetes. Reessayez dans un instant."
def __init__(self, retry_after: int, message: str | None = None) -> None:
super().__init__(message)
self.retry_after = retry_after
self.headers["Retry-After"] = str(retry_after)

View File

@@ -0,0 +1,124 @@
"""Limitation de debit en memoire, par client et par famille d'endpoint.
Volontairement sans dependance externe ni Redis : OpenMind n'a qu'un seul
service applicatif, et l'objectif de cette limitation est d'empecher qu'une
instance publique soit triviale a saturer, pas de construire un quota
distribue.
Consequence assumee et documentee : les compteurs vivent dans le processus.
Un redemarrage du conteneur les remet a zero, et plusieurs workers uvicorn
auraient chacun les leurs. C'est acceptable pour l'usage vise.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
_WINDOW_SECONDS = {
"second": 1,
"sec": 1,
"s": 1,
"minute": 60,
"min": 60,
"m": 60,
"hour": 3600,
"h": 3600,
"day": 86400,
"d": 86400,
}
# Garde-fou memoire : au-dela, les entrees les plus anciennes sont oubliees.
MAX_TRACKED_CLIENTS = 20000
@dataclass(frozen=True)
class RateRule:
"""Nombre d'appels autorises sur une fenetre glissante."""
limit: int
window_seconds: int
def describe(self) -> str:
return f"{self.limit} requete(s) par {self.window_seconds} seconde(s)"
def parse_rate(spec: str) -> RateRule | None:
"""Lit une regle "10/hour". Retourne None si la limite est desactivee.
Une valeur vide, "0", ou une syntaxe non reconnue desactive la limite :
une configuration erronee ne doit jamais rendre l'instance inutilisable.
"""
text = (spec or "").strip().lower()
if not text or text in {"0", "none", "off", "disabled"}:
return None
number, _, unit = text.partition("/")
try:
limit = int(number.strip())
except ValueError:
return None
if limit <= 0:
return None
window = _WINDOW_SECONDS.get(unit.strip() or "minute")
if window is None:
return None
return RateRule(limit=limit, window_seconds=window)
class RateLimiter:
"""Fenetre glissante par cle, protegee par un verrou."""
def __init__(self) -> None:
self._hits: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def check(self, key: str, rule: RateRule | None, *, now: float | None = None) -> int | None:
"""Enregistre un appel.
Retourne `None` si l'appel est autorise, sinon le nombre de secondes a
attendre avant le prochain essai (valeur de l'en-tete Retry-After).
"""
if rule is None:
return None
moment = time.monotonic() if now is None else now
floor = moment - rule.window_seconds
with self._lock:
hits = self._hits.get(key)
if hits is None:
if len(self._hits) >= MAX_TRACKED_CLIENTS:
self._forget_oldest_locked(floor)
hits = deque()
self._hits[key] = hits
while hits and hits[0] <= floor:
hits.popleft()
if len(hits) >= rule.limit:
wait = rule.window_seconds - (moment - hits[0])
return max(1, int(wait) + 1)
hits.append(moment)
return None
def _forget_oldest_locked(self, floor: float) -> None:
"""Nettoie les cles dont toutes les traces sont hors fenetre."""
stale = [key for key, hits in self._hits.items() if not hits or hits[-1] <= floor]
for key in stale:
del self._hits[key]
if not stale:
# Aucune cle expiree : on repart de zero plutot que de grossir sans fin.
self._hits.clear()
def reset(self) -> None:
"""Remet tous les compteurs a zero. Utilise par les tests."""
with self._lock:
self._hits.clear()
limiter = RateLimiter()

View File

@@ -2,16 +2,21 @@
from __future__ import annotations
import hashlib
import json
import logging
from datetime import timedelta
from sqlalchemy import select
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,
@@ -25,6 +30,20 @@ from app.services.tokens import (
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."""
@@ -36,15 +55,52 @@ def serialize_map(document: MapDocumentIn) -> str:
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_map_size_bytes:
if size > settings.max_share_size_bytes:
raise SharePayloadTooLargeError(
f"La carte fait {size / (1024 * 1024):.1f} Mo, "
f"la limite est de {settings.max_map_size_mb} 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()
@@ -53,13 +109,29 @@ def _get_by_token(session: Session, share_token: str) -> SharedMap:
return share
def create_share(session: Session, *, title: str, document: MapDocumentIn) -> tuple[SharedMap, str]:
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(
@@ -68,17 +140,60 @@ def create_share(session: Session, *, title: str, document: MapDocumentIn) -> tu
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_token=%s nodes=%s", share.share_token, len(document.nodes))
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_by_token(session, share_token)
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:
@@ -86,7 +201,7 @@ def load_map_data(share: SharedMap) -> dict:
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)
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.")
@@ -103,7 +218,7 @@ def update_share(
) -> 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)
logger.warning("Tentative de mise a jour refusee share=%s", share_ref(share_token))
raise ShareForbiddenError()
payload = serialize_map(document)
@@ -116,16 +231,16 @@ def update_share(
session.commit()
session.refresh(share)
logger.info("Partage mis a jour share_token=%s", share_token)
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_token=%s", share_token)
logger.warning("Tentative de suppression refusee share=%s", share_ref(share_token))
raise ShareForbiddenError()
session.delete(share)
session.commit()
logger.info("Partage supprime share_token=%s", share_token)
logger.info("Partage supprime share=%s", share_ref(share_token))

View File

@@ -14,8 +14,10 @@ from __future__ import annotations
import hashlib
import secrets
# 12 octets -> 16 caracteres url-safe, ~96 bits d'entropie : non enumerable.
SHARE_TOKEN_BYTES = 12
# 16 octets -> 22 caracteres url-safe, 128 bits d'entropie.
# Les jetons plus courts emis avant ce reglage restent valides : la colonne
# accepte n'importe quelle longueur et la recherche se fait par valeur exacte.
SHARE_TOKEN_BYTES = 16
# 32 octets -> ~256 bits d'entropie pour le secret de gestion.
MANAGEMENT_TOKEN_BYTES = 32