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>
125 lines
3.7 KiB
Python
125 lines
3.7 KiB
Python
"""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()
|