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:
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()
|
||||
Reference in New Issue
Block a user