Spaces:
Sleeping
chore(web): cookie samesite=strict + nettoyage des références "Sprint X"
Browse files**1. Cookie de langue : ``samesite='strict'``**
``routers/system.py:set_lang`` posait le cookie de session avec
``samesite="lax"``. ``httponly=False`` est volontaire (le frontend
lit la valeur en JS pour adapter l'UI sans round-trip serveur),
mais aucun usage légitime ne demande que ce cookie soit envoyé
depuis une navigation cross-site. Resserré à ``samesite="strict"``
pour réduire la surface CSRF — sans coût UX.
**2. Nettoyage des références "Sprint X" en docstrings**
L'audit a relevé 36 occurrences de "Sprint 24/25/26/28" dans
``picarones/web/`` qui pollutent la documentation sans valeur
actionnable pour un futur mainteneur. Beaucoup étaient de simples
balises chronologiques ("Sprint 26 — émet une ligne…") qu'on peut
remplacer par la description directe ("Émet une ligne…"). Quelques
références à des décisions architecturales restent explicitement
(par ex. la persistance SQLite, les Last-Event-ID).
Modules nettoyés : ``state.py``, ``benchmark_utils.py``, ``app.py``,
``security.py``, ``routers/{corpus,config,history,benchmark,home}.py``.
Pytest : 3354 passed, 2 skipped, 0 failed. Ruff : All checks passed.
https://claude.ai/code/session_01Hsd7kL8yeCbXn1mA7GQK9L
|
@@ -60,7 +60,7 @@ _logger = logging.getLogger(__name__)
|
|
| 60 |
async def _lifespan(app: FastAPI):
|
| 61 |
"""Hook de démarrage : marque les jobs orphelins comme ``interrupted``.
|
| 62 |
|
| 63 |
-
|
| 64 |
encore en statut ``pending`` ou ``running`` en base sont
|
| 65 |
forcément orphelins (le processus précédent est mort sans les
|
| 66 |
finir). On les bascule en ``interrupted`` pour ne pas laisser
|
|
@@ -98,7 +98,7 @@ app = FastAPI(
|
|
| 98 |
lifespan=_lifespan,
|
| 99 |
)
|
| 100 |
|
| 101 |
-
#
|
| 102 |
app.middleware("http")(csp_middleware)
|
| 103 |
|
| 104 |
|
|
|
|
| 60 |
async def _lifespan(app: FastAPI):
|
| 61 |
"""Hook de démarrage : marque les jobs orphelins comme ``interrupted``.
|
| 62 |
|
| 63 |
+
Au démarrage d'un nouveau processus, tous les jobs
|
| 64 |
encore en statut ``pending`` ou ``running`` en base sont
|
| 65 |
forcément orphelins (le processus précédent est mort sans les
|
| 66 |
finir). On les bascule en ``interrupted`` pour ne pas laisser
|
|
|
|
| 98 |
lifespan=_lifespan,
|
| 99 |
)
|
| 100 |
|
| 101 |
+
# Middleware CSP + en-têtes durcis (X-Frame-Options, etc.)
|
| 102 |
app.middleware("http")(csp_middleware)
|
| 103 |
|
| 104 |
|
|
@@ -3,7 +3,7 @@
|
|
| 3 |
API publique
|
| 4 |
------------
|
| 5 |
- ``sse_format`` : sérialisation d'un événement Server-Sent Events
|
| 6 |
-
avec ``Last-Event-ID``
|
| 7 |
- ``run_benchmark_thread`` / ``run_benchmark_thread_v2`` : workers
|
| 8 |
threadés qui exécutent le benchmark, émettent des événements SSE
|
| 9 |
via le ``BenchmarkJob``, génèrent le rapport HTML final.
|
|
@@ -36,7 +36,7 @@ from picarones.web.state import BenchmarkJob, iso_now
|
|
| 36 |
def sse_format(event_type: str, data: Any, seq: Optional[int] = None) -> str:
|
| 37 |
"""Format Server-Sent Events.
|
| 38 |
|
| 39 |
-
|
| 40 |
C'est la valeur que le navigateur renvoie automatiquement dans
|
| 41 |
``Last-Event-ID`` à la prochaine connexion (cf.
|
| 42 |
https://html.spec.whatwg.org/multipage/server-sent-events.html).
|
|
|
|
| 3 |
API publique
|
| 4 |
------------
|
| 5 |
- ``sse_format`` : sérialisation d'un événement Server-Sent Events
|
| 6 |
+
avec ``Last-Event-ID``.
|
| 7 |
- ``run_benchmark_thread`` / ``run_benchmark_thread_v2`` : workers
|
| 8 |
threadés qui exécutent le benchmark, émettent des événements SSE
|
| 9 |
via le ``BenchmarkJob``, génèrent le rapport HTML final.
|
|
|
|
| 36 |
def sse_format(event_type: str, data: Any, seq: Optional[int] = None) -> str:
|
| 37 |
"""Format Server-Sent Events.
|
| 38 |
|
| 39 |
+
Émet une ligne ``id: <seq>`` quand le ``seq`` est connu.
|
| 40 |
C'est la valeur que le navigateur renvoie automatiquement dans
|
| 41 |
``Last-Event-ID`` à la prochaine connexion (cf.
|
| 42 |
https://html.spec.whatwg.org/multipage/server-sent-events.html).
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Router de sauvegarde / chargement des configs utilisateur
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -20,7 +20,7 @@ router = APIRouter()
|
|
| 20 |
async def api_config_save(payload: dict) -> Response:
|
| 21 |
"""Sérialise un dict de config en JSON téléchargeable.
|
| 22 |
|
| 23 |
-
|
| 24 |
Le client envoie sa config courante (engines, profil, options),
|
| 25 |
le serveur retourne un fichier JSON à télécharger ; un autre
|
| 26 |
utilisateur peut le réimporter via ``/api/config/load``.
|
|
|
|
| 1 |
+
"""Router de sauvegarde / chargement des configs utilisateur."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 20 |
async def api_config_save(payload: dict) -> Response:
|
| 21 |
"""Sérialise un dict de config en JSON téléchargeable.
|
| 22 |
|
| 23 |
+
Supprime la friction *« reconfigurer chaque session »*.
|
| 24 |
Le client envoie sa config courante (engines, profil, options),
|
| 25 |
le serveur retourne un fichier JSON à télécharger ; un autre
|
| 26 |
utilisateur peut le réimporter via ``/api/config/load``.
|
|
@@ -22,7 +22,7 @@ router = APIRouter()
|
|
| 22 |
_logger = logging.getLogger(__name__)
|
| 23 |
|
| 24 |
|
| 25 |
-
#
|
| 26 |
# défaut restreint en mode public, défaut historique en mode dev.
|
| 27 |
_BROWSE_ROOTS = compute_browse_roots(UPLOADS_DIR)
|
| 28 |
|
|
@@ -97,7 +97,7 @@ async def api_corpus_upload(files: list[UploadFile] = File(...)) -> dict:
|
|
| 97 |
payloads: list[tuple[str, bytes]] = []
|
| 98 |
for uf in files:
|
| 99 |
filename = uf.filename or "upload"
|
| 100 |
-
#
|
| 101 |
# depuis le client (multipart). On garde uniquement le basename.
|
| 102 |
safe_name = Path(filename).name
|
| 103 |
data = await uf.read()
|
|
@@ -147,7 +147,7 @@ def _write_payloads_and_analyze(
|
|
| 147 |
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
| 148 |
flatten_zip_to_dir(zf, corpus_dir)
|
| 149 |
elif suffix in IMAGE_EXTS:
|
| 150 |
-
#
|
| 151 |
# taille max, rejet des bombes de décompression).
|
| 152 |
validate_image_safe(data, filename=safe_name)
|
| 153 |
(corpus_dir / safe_name).write_bytes(data)
|
|
|
|
| 22 |
_logger = logging.getLogger(__name__)
|
| 23 |
|
| 24 |
|
| 25 |
+
# Racines configurables via PICARONES_BROWSE_ROOTS, sinon
|
| 26 |
# défaut restreint en mode public, défaut historique en mode dev.
|
| 27 |
_BROWSE_ROOTS = compute_browse_roots(UPLOADS_DIR)
|
| 28 |
|
|
|
|
| 97 |
payloads: list[tuple[str, bytes]] = []
|
| 98 |
for uf in files:
|
| 99 |
filename = uf.filename or "upload"
|
| 100 |
+
# Empêcher la traversée via le nom de fichier reçu
|
| 101 |
# depuis le client (multipart). On garde uniquement le basename.
|
| 102 |
safe_name = Path(filename).name
|
| 103 |
data = await uf.read()
|
|
|
|
| 147 |
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
| 148 |
flatten_zip_to_dir(zf, corpus_dir)
|
| 149 |
elif suffix in IMAGE_EXTS:
|
| 150 |
+
# Valider l'image avant écriture (Pillow.verify,
|
| 151 |
# taille max, rejet des bombes de décompression).
|
| 152 |
validate_image_safe(data, filename=safe_name)
|
| 153 |
(corpus_dir / safe_name).write_bytes(data)
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
"""Router des régressions détectées dans l'historique longitudinal
|
| 2 |
|
| 3 |
-
Surface de l'infrastructure ``BenchmarkHistory``
|
| 4 |
limitée au CLI ``picarones history --regression``. Le rapport HTML
|
| 5 |
peut désormais consommer cet endpoint pour afficher un encart
|
| 6 |
*« ⚠ Tesseract a régressé de 0,8 pp depuis le 12 janvier »* en tête.
|
|
|
|
| 1 |
+
"""Router des régressions détectées dans l'historique longitudinal.
|
| 2 |
|
| 3 |
+
Surface de l'infrastructure ``BenchmarkHistory`` qui était
|
| 4 |
limitée au CLI ``picarones history --regression``. Le rapport HTML
|
| 5 |
peut désormais consommer cet endpoint pour afficher un encart
|
| 6 |
*« ⚠ Tesseract a régressé de 0,8 pp depuis le 12 janvier »* en tête.
|
|
@@ -42,11 +42,16 @@ async def api_set_lang(lang_code: str, response: Response) -> dict:
|
|
| 42 |
f"Disponibles : {', '.join(SUPPORTED_LANGS)}"
|
| 43 |
),
|
| 44 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
response.set_cookie(
|
| 46 |
key=LANG_COOKIE,
|
| 47 |
value=lang_code,
|
| 48 |
max_age=60 * 60 * 24 * 365, # 1 an
|
| 49 |
httponly=False,
|
| 50 |
-
samesite="
|
| 51 |
)
|
| 52 |
return {"lang": lang_code, "message": f"Langue définie : {lang_code}"}
|
|
|
|
| 42 |
f"Disponibles : {', '.join(SUPPORTED_LANGS)}"
|
| 43 |
),
|
| 44 |
)
|
| 45 |
+
# ``httponly=False`` est volontaire : le frontend lit ce cookie en
|
| 46 |
+
# JS pour adapter l'UI sans round-trip serveur. ``samesite="strict"``
|
| 47 |
+
# car aucun usage légitime ne demande que ce cookie soit envoyé
|
| 48 |
+
# depuis une navigation cross-site — on resserre le cran qu'on
|
| 49 |
+
# peut sans casser l'UX.
|
| 50 |
response.set_cookie(
|
| 51 |
key=LANG_COOKIE,
|
| 52 |
value=lang_code,
|
| 53 |
max_age=60 * 60 * 24 * 365, # 1 an
|
| 54 |
httponly=False,
|
| 55 |
+
samesite="strict",
|
| 56 |
)
|
| 57 |
return {"lang": lang_code, "message": f"Langue définie : {lang_code}"}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Garde-fous sécurité pour l'interface web
|
| 2 |
|
| 3 |
Ce module centralise quatre durcissements pour rendre Picarones déployable
|
| 4 |
sur un Space HuggingFace public ou un serveur d'institution sans donner
|
|
|
|
| 1 |
+
"""Garde-fous sécurité pour l'interface web.
|
| 2 |
|
| 3 |
Ce module centralise quatre durcissements pour rendre Picarones déployable
|
| 4 |
sur un Space HuggingFace public ou un serveur d'institution sans donner
|
|
@@ -86,17 +86,17 @@ def enforce_rate_limit(request: Request) -> None:
|
|
| 86 |
# ──────────────────────────────────────────────────────────────────────────
|
| 87 |
|
| 88 |
RATE_LIMITER = RateLimiter(max_per_hour=get_rate_limit_per_hour())
|
| 89 |
-
"""Rate limiter global (no-op si non public ou quota = 0).
|
| 90 |
|
| 91 |
JOBS_SEMAPHORE = threading.Semaphore(get_max_concurrent_jobs())
|
| 92 |
-
"""Sémaphore qui borne le nombre de benchmarks concurrents.
|
| 93 |
|
| 94 |
JOB_STORE: JobStore = get_default_store()
|
| 95 |
-
"""Store SQLite singleton injecté dans chaque ``BenchmarkJob``.
|
| 96 |
|
| 97 |
|
| 98 |
# ──────────────────────────────────────────────────────────────────────────
|
| 99 |
-
# Modèle de job (avec persistance
|
| 100 |
# ──────────────────────────────────────────────────────────────────────────
|
| 101 |
|
| 102 |
@dataclass
|
|
@@ -107,7 +107,7 @@ class BenchmarkJob:
|
|
| 107 |
un flux d'événements consommé via SSE. La persistance est gérée
|
| 108 |
par un ``JobStore`` SQLite optionnel — si présent, chaque
|
| 109 |
événement est sérialisé en base avant d'être diffusé aux abonnés
|
| 110 |
-
SSE, ce qui permet la reprise via ``Last-Event-ID``
|
| 111 |
"""
|
| 112 |
|
| 113 |
job_id: str
|
|
@@ -169,7 +169,7 @@ class BenchmarkJob:
|
|
| 169 |
)
|
| 170 |
|
| 171 |
def set_status(self, status: str, error: str = "") -> None:
|
| 172 |
-
"""Met à jour le statut + persiste vers le store
|
| 173 |
self.status = status
|
| 174 |
if error:
|
| 175 |
self.error = error
|
|
|
|
| 86 |
# ──────────────────────────────────────────────────────────────────────────
|
| 87 |
|
| 88 |
RATE_LIMITER = RateLimiter(max_per_hour=get_rate_limit_per_hour())
|
| 89 |
+
"""Rate limiter global (no-op si non public ou quota = 0)."""
|
| 90 |
|
| 91 |
JOBS_SEMAPHORE = threading.Semaphore(get_max_concurrent_jobs())
|
| 92 |
+
"""Sémaphore qui borne le nombre de benchmarks concurrents."""
|
| 93 |
|
| 94 |
JOB_STORE: JobStore = get_default_store()
|
| 95 |
+
"""Store SQLite singleton injecté dans chaque ``BenchmarkJob``."""
|
| 96 |
|
| 97 |
|
| 98 |
# ──────────────────────────────────────────────────────────────────────────
|
| 99 |
+
# Modèle de job (avec persistance SQLite)
|
| 100 |
# ──────────────────────────────────────────────────────────────────────────
|
| 101 |
|
| 102 |
@dataclass
|
|
|
|
| 107 |
un flux d'événements consommé via SSE. La persistance est gérée
|
| 108 |
par un ``JobStore`` SQLite optionnel — si présent, chaque
|
| 109 |
événement est sérialisé en base avant d'être diffusé aux abonnés
|
| 110 |
+
SSE, ce qui permet la reprise via ``Last-Event-ID``.
|
| 111 |
"""
|
| 112 |
|
| 113 |
job_id: str
|
|
|
|
| 169 |
)
|
| 170 |
|
| 171 |
def set_status(self, status: str, error: str = "") -> None:
|
| 172 |
+
"""Met à jour le statut + persiste vers le store."""
|
| 173 |
self.status = status
|
| 174 |
if error:
|
| 175 |
self.error = error
|