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:
@@ -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"))]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
334
backend/app/cli.py
Normal 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())
|
||||
@@ -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()]
|
||||
|
||||
@@ -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)
|
||||
|
||||
67
backend/app/db/migrations.py
Normal file
67
backend/app/db/migrations.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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
|
||||
|
||||
39
backend/app/services/client_ip.py
Normal file
39
backend/app/services/client_ip.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
124
backend/app/services/rate_limit.py
Normal file
124
backend/app/services/rate_limit.py
Normal 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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user