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

@@ -4,12 +4,14 @@ from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Header
from fastapi import Depends, Header, Request
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.db.session import get_db
from app.services.errors import ShareForbiddenError
from app.services.client_ip import resolve_client_ip
from app.services.errors import RateLimitedError, ShareForbiddenError, SharingDisabledError
from app.services.rate_limit import limiter, parse_rate
MANAGEMENT_TOKEN_HEADER = "X-Management-Token"
@@ -27,3 +29,41 @@ def require_management_token(
ManagementToken = Annotated[str, Depends(require_management_token)]
def require_sharing_enabled(settings: AppSettings) -> None:
"""Coupe toute l'API de partage quand l'instance est privee.
Les cartes locales continuent de fonctionner : seul le service serveur est
indisponible, et il le dit clairement.
"""
if not settings.sharing_enabled:
raise SharingDisabledError()
SharingEnabled = Annotated[None, Depends(require_sharing_enabled)]
def _rate_limit(bucket: str, spec_name: str): # type: ignore[no-untyped-def]
"""Fabrique une dependance de limitation pour une famille d'endpoints.
La limitation est posee sur les routes concernees, jamais en middleware
global : /api/health et /api/ready ne peuvent donc pas etre bloques, ce qui
garantit que le healthcheck du conteneur reste fiable sous charge.
"""
def dependency(request: Request, settings: AppSettings) -> None:
rule = parse_rate(getattr(settings, spec_name))
if rule is None:
return
client = resolve_client_ip(request, trust_proxy_headers=settings.trust_proxy_headers)
retry_after = limiter.check(f"{bucket}:{client}", rule)
if retry_after is not None:
raise RateLimitedError(retry_after)
return dependency
RateLimitCreate = Annotated[None, Depends(_rate_limit("share:create", "share_create_rate_limit"))]
RateLimitRead = Annotated[None, Depends(_rate_limit("share:read", "share_read_rate_limit"))]
RateLimitWrite = Annotated[None, Depends(_rate_limit("share:write", "share_write_rate_limit"))]

View File

@@ -1,19 +1,48 @@
"""Endpoints de sante et de configuration publique."""
"""Endpoints de sante et de configuration publique.
Aucune limitation de debit n'est posee ici : le healthcheck du conteneur doit
rester fiable meme quand une instance subit un afflux de requetes.
"""
from __future__ import annotations
from fastapi import APIRouter
import logging
from app.api.deps import AppSettings
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from sqlalchemy import text
from app.api.deps import AppSettings, DbSession
logger = logging.getLogger(__name__)
router = APIRouter(tags=["system"])
# Durees proposees dans l'interface. `None` = permanent.
SHARE_EXPIRY_OPTIONS: list[int | None] = [1, 7, 30, 90, None]
@router.get("/health", summary="Etat du service")
def health() -> dict[str, str]:
return {"status": "ok"}
@router.get("/ready", summary="Le service peut-il repondre : base de donnees joignable")
def ready(session: DbSession): # type: ignore[no-untyped-def]
"""Complete /health : verifie que SQLite repond reellement.
Utile derriere un orchestrateur qui doit distinguer "processus vivant" de
"capable de servir", par exemple quand le volume de donnees est absent ou
en lecture seule.
"""
try:
session.execute(text("SELECT 1"))
except Exception:
logger.exception("Base de donnees injoignable")
return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ready"}
@router.get("/config", summary="Configuration publique exposee au frontend")
def public_config(settings: AppSettings) -> dict[str, object]:
"""Aucun secret ici : uniquement ce dont l'interface a besoin."""
@@ -21,5 +50,9 @@ def public_config(settings: AppSettings) -> dict[str, object]:
"appName": settings.app_name,
"maxNodes": settings.max_nodes,
"maxMapSizeMb": settings.max_map_size_mb,
"sharingEnabled": True,
"maxShareSizeMb": settings.max_share_size_mb_effective,
"sharingEnabled": settings.sharing_enabled,
"allowPermanentShares": settings.allow_permanent_shares,
"defaultShareExpiryDays": settings.default_share_expiry_days,
"shareExpiryOptions": SHARE_EXPIRY_OPTIONS,
}

View File

@@ -4,10 +4,19 @@ from __future__ import annotations
from fastapi import APIRouter, Response, status
from app.api.deps import DbSession, ManagementToken
from app.api.deps import (
DbSession,
ManagementToken,
RateLimitCreate,
RateLimitRead,
RateLimitWrite,
SharingEnabled,
)
from app.schemas.share import (
ShareCreatedOut,
ShareCreateIn,
ShareExpiryIn,
ShareOwnerOut,
SharePublicOut,
ShareUpdatedOut,
)
@@ -26,9 +35,21 @@ def _share_path(share_token: str) -> str:
status_code=status.HTTP_201_CREATED,
summary="Publier un snapshot de carte et obtenir un lien non repertorie",
)
def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
def create_share(
payload: ShareCreateIn,
session: DbSession,
_sharing: SharingEnabled,
_rate: RateLimitCreate,
) -> ShareCreatedOut:
# `permanent` distingue "aucune preference" (defaut de l'instance) de
# "explicitement sans expiration", que le JSON seul ne permet pas de separer.
explicit = payload.permanent or payload.expires_in_days is not None
share, management_token = share_service.create_share(
session, title=payload.title, document=payload.map
session,
title=payload.title,
document=payload.map,
expires_in_days=None if payload.permanent else payload.expires_in_days,
expiry_explicit=explicit,
)
return ShareCreatedOut(
share_token=share.share_token,
@@ -37,6 +58,7 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
)
@@ -45,7 +67,12 @@ def create_share(payload: ShareCreateIn, session: DbSession) -> ShareCreatedOut:
response_model=SharePublicOut,
summary="Lire une carte partagee (public, lecture seule)",
)
def read_share(share_token: str, session: DbSession) -> SharePublicOut:
def read_share(
share_token: str,
session: DbSession,
_sharing: SharingEnabled,
_rate: RateLimitRead,
) -> SharePublicOut:
share = share_service.get_share(session, share_token)
return SharePublicOut(
share_token=share.share_token,
@@ -54,6 +81,7 @@ def read_share(share_token: str, session: DbSession) -> SharePublicOut:
map=share_service.load_map_data(share),
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
)
@@ -67,6 +95,8 @@ def update_share(
payload: ShareCreateIn,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareUpdatedOut:
share = share_service.update_share(
session,
@@ -80,6 +110,64 @@ def update_share(
share_path=_share_path(share.share_token),
title=share.title,
updated_at=share.updated_at,
expires_at=share.expires_at,
)
@router.patch(
"/{share_token}",
response_model=ShareOwnerOut,
summary="Modifier l'expiration du partage (necessite le jeton de gestion)",
)
def update_expiry(
share_token: str,
payload: ShareExpiryIn,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareOwnerOut:
share = share_service.set_expiry(
session,
share_token,
management_token=management_token,
expires_in_days=None if payload.permanent else payload.expires_in_days,
)
return ShareOwnerOut(
share_token=share.share_token,
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
)
@router.post(
"/{share_token}/verify",
response_model=ShareOwnerOut,
summary="Verifier qu'une cle de gestion correspond a ce partage",
)
def verify_share(
share_token: str,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> ShareOwnerOut:
"""Permet de reimporter une cle de gestion depuis un autre navigateur.
Ne renvoie aucune donnee de carte : seulement de quoi reconstituer la fiche
de partage locale.
"""
share = share_service.verify_management_token(
session, share_token, management_token=management_token
)
return ShareOwnerOut(
share_token=share.share_token,
title=share.title,
created_at=share.created_at,
updated_at=share.updated_at,
expires_at=share.expires_at,
)
@@ -93,6 +181,8 @@ def delete_share(
share_token: str,
session: DbSession,
management_token: ManagementToken,
_sharing: SharingEnabled,
_rate: RateLimitWrite,
) -> Response:
share_service.delete_share(session, share_token, management_token=management_token)
return Response(status_code=status.HTTP_204_NO_CONTENT)

334
backend/app/cli.py Normal file
View File

@@ -0,0 +1,334 @@
"""Administration locale d'une instance OpenMind.
Volontairement une CLI et non une interface web : il n'existe aucune page
/admin exposee sur Internet, et ces commandes ne s'utilisent qu'avec un acces
au conteneur.
docker compose exec openmind python -m app.cli stats
docker compose exec openmind python -m app.cli shares list --limit 20
docker compose exec openmind python -m app.cli shares delete <share-token>
docker compose exec openmind python -m app.cli shares purge-expired --dry-run
docker compose exec openmind python -m app.cli backup /app/data/backups/openmind.db
Aucune commande n'affiche ni le jeton de gestion, ni son hash, ni le contenu
des cartes.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from sqlalchemy import func, select
from app.config import get_settings
from app.db.session import SessionLocal
from app.models.share import SharedMap, utcnow
_SQLITE_PREFIX = "sqlite:///"
def database_path() -> Path | None:
"""Chemin du fichier SQLite, ou None si la base n'est pas un fichier."""
url = get_settings().database_url
if not url.startswith(_SQLITE_PREFIX):
return None
raw = url[len(_SQLITE_PREFIX) :]
if not raw or raw == ":memory:":
return None
return Path(raw)
def _format_size(num_bytes: int) -> str:
value = float(num_bytes)
for unit in ("o", "Ko", "Mo", "Go"):
if value < 1024 or unit == "Go":
return f"{value:.1f} {unit}" if unit != "o" else f"{int(value)} {unit}"
value /= 1024
return f"{value:.1f} Go"
def _format_date(moment: datetime | None) -> str:
if moment is None:
return "jamais"
return moment.strftime("%Y-%m-%d %H:%M")
def _mask(token: str) -> str:
"""N'affiche jamais un jeton en entier : il autorise la lecture."""
if len(token) <= 8:
return f"{token[:2]}...{token[-2:]}"
return f"{token[:4]}...{token[-4:]}"
def _confirm(question: str, assume_yes: bool) -> bool:
if assume_yes:
return True
if not sys.stdin.isatty():
print("Confirmation impossible (entree non interactive). Utilisez --yes.")
return False
answer = input(f"{question} [o/N] ").strip().lower()
return answer in {"o", "oui", "y", "yes"}
def cmd_stats(_args: argparse.Namespace) -> int:
now = utcnow()
with SessionLocal() as session:
total = session.execute(select(func.count()).select_from(SharedMap)).scalar_one()
expired = session.execute(
select(func.count())
.select_from(SharedMap)
.where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now)
).scalar_one()
oldest = session.execute(
select(SharedMap.created_at).order_by(SharedMap.created_at.asc()).limit(1)
).scalar_one_or_none()
newest = session.execute(
select(SharedMap.created_at).order_by(SharedMap.created_at.desc()).limit(1)
).scalar_one_or_none()
settings = get_settings()
path = database_path()
size = 0
if path and path.exists():
size = path.stat().st_size
for suffix in ("-wal", "-shm"):
side = path.with_name(path.name + suffix)
if side.exists():
size += side.stat().st_size
limit = settings.max_shared_maps
print(f"Partages : {total}")
print(f" actifs : {total - expired}")
print(f" expires : {expired}")
print(f"Capacite : {'illimitee' if limit <= 0 else limit}")
print(f"Partage active : {'oui' if settings.sharing_enabled else 'non'}")
print(f"Base SQLite : {path or 'non applicable'}")
print(f"Taille (WAL comprise) : {_format_size(size)}")
print(f"Plus ancien : {_format_date(oldest)}")
print(f"Plus recent : {_format_date(newest)}")
return 0
def cmd_shares_list(args: argparse.Namespace) -> int:
now = utcnow()
stmt = select(SharedMap).order_by(SharedMap.created_at.desc())
if args.expired:
stmt = stmt.where(SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now)
elif args.active:
stmt = stmt.where((SharedMap.expires_at.is_(None)) | (SharedMap.expires_at > now))
stmt = stmt.limit(args.limit)
with SessionLocal() as session:
rows = list(session.execute(stmt).scalars())
if not rows:
print("Aucun partage.")
return 0
header = f"{'JETON':<14} {'TITRE':<28} {'CREE':<17} {'MAJ':<17} {'EXPIRE':<17} {'NOEUDS':>7}"
print(header)
print("-" * len(header))
for share in rows:
# `parentId` apparait exactement une fois par noeud, contrairement a
# `id` que portent aussi les liens et les relations.
nodes = share.map_data.count('"parentId"')
title = (share.title or "(sans titre)")[:27]
marker = " (expire)" if share.is_expired(now=now) else ""
print(
f"{_mask(share.share_token):<14} {title:<28} "
f"{_format_date(share.created_at):<17} {_format_date(share.updated_at):<17} "
f"{_format_date(share.expires_at) + marker:<17} {nodes:>7}"
)
return 0
def cmd_shares_delete(args: argparse.Namespace) -> int:
"""Suppression administrative : n'utilise pas le jeton de gestion.
Elle est reservee a qui a deja un acces au conteneur, donc a la base.
"""
with SessionLocal() as session:
share = session.execute(
select(SharedMap).where(SharedMap.share_token == args.share_token)
).scalar_one_or_none()
if share is None:
print("Aucun partage ne correspond a ce jeton.")
return 1
print(f"Partage : {share.title or '(sans titre)'} ({_mask(share.share_token)})")
print(f" cree le : {_format_date(share.created_at)}")
print(f" expire le : {_format_date(share.expires_at)}")
if not _confirm("Supprimer definitivement ce partage ?", args.yes):
print("Annule.")
return 1
session.delete(share)
session.commit()
print("1 partage supprime.")
return 0
def cmd_shares_purge(args: argparse.Namespace) -> int:
now = utcnow()
with SessionLocal() as session:
rows = list(
session.execute(
select(SharedMap).where(
SharedMap.expires_at.is_not(None), SharedMap.expires_at <= now
)
).scalars()
)
if not rows:
print("0 partage(s) supprime(s) : aucun partage expire.")
return 0
print(f"{len(rows)} partage(s) expire(s) :")
for share in rows:
print(
f" {_mask(share.share_token)} {(share.title or '(sans titre)')[:40]}"
f" expire le {_format_date(share.expires_at)}"
)
if args.dry_run:
print("Mode simulation : rien n'a ete supprime.")
return 0
if not _confirm(f"Supprimer ces {len(rows)} partage(s) ?", args.yes):
print("Annule.")
return 1
for share in rows:
session.delete(share)
session.commit()
print(f"{len(rows)} partage(s) supprime(s).")
return 0
def _sqlite_backup(source: Path, destination: Path) -> None:
"""Copie coherente via l'API de sauvegarde SQLite.
Un simple `cp` du fichier n'est pas sur en mode WAL : les ecritures
recentes vivent dans le fichier -wal, et la copie obtenue peut etre
incoherente. L'API backup, elle, produit une base complete et utilisable
pendant que l'application continue de tourner.
"""
destination.parent.mkdir(parents=True, exist_ok=True)
with (
sqlite3.connect(f"file:{source}?mode=ro", uri=True) as origin,
sqlite3.connect(destination) as copy,
):
origin.backup(copy)
def cmd_backup(args: argparse.Namespace) -> int:
source = database_path()
if source is None or not source.exists():
print("Aucun fichier SQLite a sauvegarder.")
return 1
destination = Path(args.destination)
if destination.exists() and not args.yes:
print(f"{destination} existe deja. Utilisez --yes pour l'ecraser.")
return 1
_sqlite_backup(source, destination)
print(f"Sauvegarde ecrite : {destination} ({_format_size(destination.stat().st_size)})")
return 0
def cmd_restore(args: argparse.Namespace) -> int:
"""Restauration prudente : verification, copie de securite, puis remplacement.
Le remplacement passe lui aussi par l'API backup de SQLite, qui ecrit dans
la base cible sous son propre verrou plutot que d'echanger le fichier sous
les pieds d'un processus qui l'a ouvert. Arreter le conteneur avant reste
la facon la plus sure de proceder.
"""
source = Path(args.source)
target = database_path()
if target is None:
print("La base configuree n'est pas un fichier SQLite.")
return 1
if not source.exists():
print(f"Fichier introuvable : {source}")
return 1
try:
with sqlite3.connect(f"file:{source}?mode=ro", uri=True) as check:
tables = {row[0] for row in check.execute("SELECT name FROM sqlite_master")}
except sqlite3.DatabaseError:
print("Ce fichier n'est pas une base SQLite lisible.")
return 1
if "shared_maps" not in tables:
print("Cette base ne contient pas de table shared_maps : restauration refusee.")
return 1
print(f"Source : {source}")
print(f"Destination : {target}")
if not _confirm("Remplacer le contenu de la base actuelle ?", args.yes):
print("Annule.")
return 1
if target.exists():
safety = target.parent / "backups" / f"pre-restore-{utcnow():%Y%m%d-%H%M%S}.db"
_sqlite_backup(target, safety)
print(f"Sauvegarde de securite : {safety}")
_sqlite_backup(source, target)
print("Restauration terminee. Redemarrez le conteneur pour repartir proprement.")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m app.cli",
description="Administration locale d'OpenMind (partages, sauvegarde).",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("stats", help="Compteurs et taille de la base").set_defaults(func=cmd_stats)
shares = sub.add_parser("shares", help="Gestion des partages")
shares_sub = shares.add_subparsers(dest="shares_command", required=True)
listing = shares_sub.add_parser("list", help="Lister les partages")
listing.add_argument("--limit", type=int, default=50, help="Nombre maximum de lignes")
group = listing.add_mutually_exclusive_group()
group.add_argument("--expired", action="store_true", help="Uniquement les partages expires")
group.add_argument("--active", action="store_true", help="Uniquement les partages actifs")
listing.set_defaults(func=cmd_shares_list)
delete = shares_sub.add_parser("delete", help="Supprimer un partage")
delete.add_argument("share_token", help="Jeton public du partage")
delete.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
delete.set_defaults(func=cmd_shares_delete)
purge = shares_sub.add_parser("purge-expired", help="Supprimer les partages expires")
purge.add_argument("--dry-run", action="store_true", help="Afficher sans supprimer")
purge.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
purge.set_defaults(func=cmd_shares_purge)
backup = sub.add_parser("backup", help="Sauvegarder la base pendant que l'app tourne")
backup.add_argument("destination", help="Chemin du fichier de sauvegarde")
backup.add_argument("--yes", action="store_true", help="Ecraser un fichier existant")
backup.set_defaults(func=cmd_backup)
restore = sub.add_parser("restore", help="Restaurer une sauvegarde (prudent)")
restore.add_argument("source", help="Fichier de sauvegarde a restaurer")
restore.add_argument("--yes", action="store_true", help="Ne pas demander confirmation")
restore.set_defaults(func=cmd_restore)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover - point d'entree
raise SystemExit(main())

View File

@@ -28,6 +28,32 @@ class Settings(BaseSettings):
max_nodes: int = Field(default=5000, gt=0)
max_title_length: int = Field(default=200, gt=0)
# --- Exploitation publique -------------------------------------------
# Instance entierement privee : le partage serveur peut etre coupe sans
# rien changer au fonctionnement local des cartes.
sharing_enabled: bool = True
# Limites de debit, au format "<nombre>/<fenetre>" : 10/hour, 120/minute,
# 5/second. Une valeur vide ou "0" desactive la limite concernee.
share_create_rate_limit: str = "10/hour"
share_read_rate_limit: str = "120/minute"
share_write_rate_limit: str = "60/hour"
# Nombre total de partages heberges. 0 = illimite.
max_shared_maps: int = Field(default=10000, ge=0)
# Taille maximale d'un partage. 0 = on retombe sur max_map_size_mb.
max_share_size_mb: float = Field(default=0.0, ge=0)
# Duree de vie par defaut d'un nouveau partage, en jours. 0 = permanent.
default_share_expiry_days: int = Field(default=30, ge=0)
# Autorise l'option "Jamais" dans l'interface et via l'API.
allow_permanent_shares: bool = True
# A n'activer que derriere un reverse proxy de confiance : sinon un client
# direct pourrait falsifier son adresse et contourner les limites de debit.
trust_proxy_headers: bool = False
# Liste d'origines autorisees, separees par des virgules.
# Vide = aucune origine externe (l'app est servie par ce meme serveur).
cors_origins: str = ""
@@ -38,6 +64,17 @@ class Settings(BaseSettings):
def max_map_size_bytes(self) -> int:
return int(self.max_map_size_mb * 1024 * 1024)
@property
def max_share_size_bytes(self) -> int:
"""Limite propre aux partages, bornee par la limite HTTP globale."""
if self.max_share_size_mb <= 0:
return self.max_map_size_bytes
return min(int(self.max_share_size_mb * 1024 * 1024), self.max_map_size_bytes)
@property
def max_share_size_mb_effective(self) -> float:
return self.max_share_size_bytes / (1024 * 1024)
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]

View File

@@ -1,8 +1,8 @@
"""Initialisation du schema SQLite.
"""Mise a niveau du schema SQLite au demarrage.
La V1 n'utilise pas Alembic : le schema tient dans une seule table et
`create_all` suffit. Toute evolution future du schema devra soit passer par
Alembic, soit par une migration explicite ajoutee ici (voir README).
Depuis la Phase 2.1, le schema est gere par Alembic : `init_db` se contente de
declencher les migrations. Il n'existe volontairement aucun mecanisme de
migration maison en parallele.
"""
from __future__ import annotations
@@ -11,16 +11,12 @@ import logging
from sqlalchemy import Engine
from app.db.base import Base
from app.db.migrations import run_migrations
from app.db.session import engine as default_engine
from app.models import share as _share_model # noqa: F401 (enregistre la table)
logger = logging.getLogger(__name__)
SCHEMA_VERSION = 1
def init_db(engine: Engine | None = None) -> None:
target = engine or default_engine
Base.metadata.create_all(bind=target)
logger.info("Schema SQLite initialise (version %s)", SCHEMA_VERSION)
run_migrations(engine or default_engine)

View File

@@ -0,0 +1,67 @@
"""Application des migrations Alembic au demarrage.
Deux situations coexistent et doivent toutes deux fonctionner sans
intervention :
* **installation neuve** : la base est vide, toutes les revisions s'appliquent ;
* **base existante** creee avant Alembic par `create_all` : la table est deja
la, mais `alembic_version` n'existe pas. On estampille alors la revision
initiale (aucune modification de donnees) avant de derouler la suite.
Aucune operation destructive n'est effectuee : la seule migration de cette
phase ajoute une colonne nullable.
"""
from __future__ import annotations
import logging
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
from app.config import get_settings
from app.db.session import engine as default_engine
logger = logging.getLogger(__name__)
BACKEND_ROOT = Path(__file__).resolve().parents[2]
ALEMBIC_INI = BACKEND_ROOT / "alembic.ini"
INITIAL_REVISION = "0001_initial_schema"
MAIN_TABLE = "shared_maps"
def build_config(database_url: str | None = None, *, configure_logger: bool = False) -> Config:
config = Config(str(ALEMBIC_INI))
config.set_main_option("script_location", str(BACKEND_ROOT / "migrations"))
config.set_main_option("sqlalchemy.url", database_url or get_settings().database_url)
# Les migrations tournent dans le processus applicatif : elles ne doivent pas
# reconfigurer les logs deja en place.
config.attributes["configure_logger"] = configure_logger
return config
def run_migrations(engine=None, database_url: str | None = None) -> None: # type: ignore[no-untyped-def]
target = engine or default_engine
config = build_config(database_url)
with target.connect() as connection:
tables = set(inspect(connection).get_table_names())
legacy = MAIN_TABLE in tables and "alembic_version" not in tables
if legacy:
# Base d'avant Alembic : on declare l'etat initial deja atteint plutot
# que de rejouer une creation de table qui echouerait.
logger.info("Base existante detectee : estampillage de %s", INITIAL_REVISION)
command.stamp(config, INITIAL_REVISION)
command.upgrade(config, "head")
logger.info("Migrations appliquees (head)")
if __name__ == "__main__": # pragma: no cover - appele par l'entrypoint Docker
import sys
logging.basicConfig(level="INFO", format="%(levelname)-8s | %(message)s", stream=sys.stdout)
run_migrations()

View File

@@ -44,6 +44,10 @@ def create_db_engine(database_url: str | None = None) -> Engine:
# WAL : meilleure resistance aux arrets brutaux du conteneur.
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
# Attend au lieu d'echouer immediatement si une ecriture concurrente
# tient le verrou : une sauvegarde en cours ne doit pas produire
# d'erreur "database is locked" cote utilisateur.
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
return engine

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
import sys
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
@@ -11,6 +12,32 @@ _DATE_FORMAT = "%Y-%m-%dT%H:%M:%S"
# Champs qui ne doivent jamais apparaitre dans les logs.
REDACTED_KEYS = frozenset({"management_token", "managementToken", "authorization"})
# Un share token est une capacite de lecture : le laisser dans les logs d'acces
# revient a y ecrire le lien complet. On le remplace, sans toucher au reste du
# chemin pour que les autres routes restent diagnosticables.
_TOKEN_PATHS = re.compile(r"(?P<prefix>/(?:s|api/shares)/)(?P<token>[A-Za-z0-9_-]{6,})")
REDACTED_TOKEN = "[REDACTED]"
def redact_tokens(text: str) -> str:
return _TOKEN_PATHS.sub(lambda m: f"{m.group('prefix')}{REDACTED_TOKEN}", text)
class TokenRedactingFilter(logging.Filter):
"""Retire les jetons de partage des messages avant ecriture.
Applique aux logs d'acces uvicorn, ou le chemin complet est journalise.
"""
def filter(self, record: logging.LogRecord) -> bool:
if isinstance(record.args, tuple):
record.args = tuple(
redact_tokens(arg) if isinstance(arg, str) else arg for arg in record.args
)
if isinstance(record.msg, str):
record.msg = redact_tokens(record.msg)
return True
def configure_logging(level: str = "INFO") -> None:
handler = logging.StreamHandler(sys.stdout)
@@ -21,11 +48,16 @@ def configure_logging(level: str = "INFO") -> None:
root.addHandler(handler)
root.setLevel(level.upper())
redactor = TokenRedactingFilter()
handler.addFilter(redactor)
# Uvicorn possede ses propres handlers : on les aligne sur les notres.
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name)
logger.handlers.clear()
logger.propagate = True
logger.filters.clear()
logger.addFilter(redactor)
def safe_extra(data: dict[str, object]) -> dict[str, object]:

View File

@@ -80,7 +80,11 @@ def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(ShareError)
async def _share_error(_request: Request, exc: ShareError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.message},
headers=exc.headers or None,
)
@app.exception_handler(RequestValidationError)
async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:

View File

@@ -35,11 +35,39 @@ SECURITY_HEADERS = {
}
# Chemins qui ne doivent jamais etre indexes : ils sont derriere un lien non
# repertorie, ou ne sont pas des pages.
NOINDEX_PREFIXES = ("/s/", "/api/")
ROBOTS_TAG = "noindex, nofollow, noarchive"
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""En-tetes de securite communs.
`Referrer-Policy: no-referrer` est applique a tout le site, pas seulement
aux pages de partage : une URL /s/<token> est une capacite de lecture, et
elle ne doit jamais partir dans l'en-tete Referer d'un site externe sur
lequel le lecteur cliquerait.
"""
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
response: Response = await call_next(request)
for header, value in SECURITY_HEADERS.items():
response.headers.setdefault(header, value)
path = request.url.path
if path.startswith("/assets/"):
# Les fichiers produits par Vite portent un hash de contenu dans leur
# nom : ils ne changent jamais sous une meme URL. index.html et sw.js,
# eux, restent en no-cache (voir web.py) pour qu'une mise a jour de
# l'application soit prise en compte au rechargement suivant.
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
if path.startswith(NOINDEX_PREFIXES):
# robots.txt reste une simple convention : c'est le jeton qui
# autorise la lecture. Cet en-tete evite seulement qu'un lien
# partage se retrouve dans un index public.
response.headers.setdefault("X-Robots-Tag", ROBOTS_TAG)
return response

View File

@@ -37,5 +37,21 @@ class SharedMap(Base):
DateTime(timezone=True), default=utcnow, onupdate=utcnow
)
# NULL = partage permanent. Les partages crees avant l'introduction de
# l'expiration sont migres a NULL et restent donc valides.
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None, index=True
)
def is_expired(self, *, now: datetime | None = None) -> bool:
if self.expires_at is None:
return False
moment = now or utcnow()
deadline = self.expires_at
# SQLite rend des datetime naifs : on les considere en UTC.
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=UTC)
return deadline <= moment
def __repr__(self) -> str: # pragma: no cover - aide au debug uniquement
return f"<SharedMap id={self.id} share_token={self.share_token!r}>"

View File

@@ -16,6 +16,19 @@ class ShareCreateIn(BaseModel):
title: str = Field(default="", max_length=255)
map: MapDocumentIn
# Absent = valeur par defaut de l'instance ; null explicite = permanent.
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
# Distingue "champ absent" de "null explicite", que Pydantic ne separe pas.
permanent: bool = False
class ShareExpiryIn(BaseModel):
"""Corps de PATCH /api/shares/{token} : uniquement l'expiration."""
model_config = ConfigDict(extra="forbid")
expires_in_days: int | None = Field(default=None, ge=1, le=3650)
permanent: bool = False
class ShareCreatedOut(BaseModel):
@@ -31,6 +44,7 @@ class ShareCreatedOut(BaseModel):
title: str
created_at: datetime
updated_at: datetime
expires_at: datetime | None = None
class ShareUpdatedOut(BaseModel):
@@ -38,6 +52,17 @@ class ShareUpdatedOut(BaseModel):
share_path: str
title: str
updated_at: datetime
expires_at: datetime | None = None
class ShareOwnerOut(BaseModel):
"""Vue reservee au detenteur du jeton de gestion. Aucune donnee de carte."""
share_token: str
title: str
created_at: datetime
updated_at: datetime
expires_at: datetime | None = None
class SharePublicOut(BaseModel):
@@ -49,3 +74,4 @@ class SharePublicOut(BaseModel):
map: dict
created_at: datetime
updated_at: datetime
expires_at: datetime | None = None

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