jurix-api / app /services /document_storage.py
rahim237cm's picture
Claude Opus 5 (1M context)
feat: deployment on free tiers, object-storage documents, and email
6429ad3
Raw History Blame Contribute Delete
7.85 kB
"""
Accès aux documents d'origine (PDF), sur disque local ou sur un magasin HTTPS.
POURQUOI CE MODULE EXISTE. Les cinq routes qui servent un PDF lisaient le
fichier dans `./data/uploads`. Sur un hébergeur au disque éphémère — c'est le
cas de toutes les offres gratuites — les lignes en base survivent au
redéploiement, pas les fichiers : le visualiseur et le téléchargement
répondraient 404 sur chaque loi. Ce module déplace la résolution derrière une
indirection réglée par `DOCUMENTS_BASE_URL`.
vide → disque local, comportement historique, développement inchangé
non vide → récupération HTTPS, avec cache disque
Le magasin est donc interchangeable sans toucher une ligne de code : Supabase
Storage, un dépôt public, un stockage objet — une variable d'environnement.
LA GARDE À NE JAMAIS RETIRER. `_FILE_ID_RE` protégeait un chemin de fichier ;
ici elle protège une URL. Interpoler un `file_id` non validé dans
`DOCUMENTS_BASE_URL` permettrait à une valeur contenant `../` ou
`@evil.example/` de faire sortir la requête du magasin — c'est-à-dire de
transformer le serveur en relais de requêtes (SSRF). La valeur vient de la base
et non de la requête HTTP, donc ce n'est pas exploitable aujourd'hui ; c'est
précisément pour cela que la faille apparaîtrait sans bruit le jour où une route
écrira un `file_id` d'après une entrée utilisateur. On valide d'abord, on
construit l'URL ensuite.
"""
import asyncio
import logging
import os
from pathlib import Path
import httpx
from app.core.config import settings
from app.utils.file_utils import _FILE_ID_RE, resolve_upload_path
logger = logging.getLogger(__name__)
# Extensions sondées, dans l'ordre. Miroir exact de `resolve_upload_path` : les
# deux modes doivent résoudre le même identifiant vers le même document.
EXTENSIONS = (".pdf", ".docx")
# Taille des tranches de téléchargement. Le plus gros document du corpus pèse
# 26 Mo ; en flux, il ne passe jamais en mémoire.
TAILLE_TRANCHE = 65536
class IdentifiantInvalide(ValueError):
"""`file_id` hors motif. Ni chemin ni URL n'est construit."""
class DocumentIntrouvable(Exception):
"""Le document n'existe pas — 404 en amont, ou absent du disque."""
class DocumentInaccessible(Exception):
"""
L'amont a échoué : 5xx, délai dépassé, réseau.
Distinct de `DocumentIntrouvable` à dessein. Répondre 404 quand c'est notre
magasin qui est en panne accuserait l'utilisateur d'un défaut qui n'est pas
le sien, et ferait disparaître le document de l'interface au lieu de
signaler une panne passagère.
"""
def stockage_distant() -> bool:
"""Vrai si les documents sont servis en HTTPS plutôt que depuis le disque."""
return bool(settings.DOCUMENTS_BASE_URL)
def url_publique(file_id: str, extension: str = ".pdf") -> str:
"""
URL du document dans le magasin distant.
Raises:
IdentifiantInvalide: le motif n'est pas respecté. Voir l'avertissement
SSRF en tête de module — cette vérification vient AVANT la
construction de l'URL, jamais après.
"""
if not _FILE_ID_RE.match(file_id or ""):
raise IdentifiantInvalide(f"Identifiant de fichier invalide : {file_id!r}")
return f"{settings.DOCUMENTS_BASE_URL.rstrip('/')}/{file_id}{extension}"
# Un verrou par identifiant : deux rendus de page simultanés sur la même loi ne
# doivent télécharger qu'une fois. Légitime en mémoire de processus parce que
# l'image tourne avec `--workers 1` (registre WebSocket en processus).
_verrous: dict[str, asyncio.Lock] = {}
def _verrou(file_id: str) -> asyncio.Lock:
verrou = _verrous.get(file_id)
if verrou is None:
verrou = _verrous[file_id] = asyncio.Lock()
return verrou
def _repertoire_du_cache() -> Path:
chemin = Path(settings.DOCUMENTS_CACHE_DIR)
chemin.mkdir(parents=True, exist_ok=True)
return chemin
def _elaguer_le_cache(repertoire: Path) -> None:
"""
Ramène le cache sous son plafond, en supprimant les moins récemment lus.
Sans elle, `/tmp` grossit jusqu'à saturer le conteneur — et un conteneur
dont le disque est plein ne redémarre pas proprement.
"""
plafond = settings.DOCUMENTS_CACHE_MAX_MB * 1024 * 1024
if plafond <= 0:
return
fichiers = []
total = 0
for f in repertoire.iterdir():
if not f.is_file():
continue
stat = f.stat()
fichiers.append((stat.st_atime, stat.st_size, f))
total += stat.st_size
if total <= plafond:
return
for _, taille, f in sorted(fichiers):
try:
f.unlink()
except OSError:
continue
total -= taille
if total <= plafond:
return
async def _telecharger(file_id: str, destination: Path) -> Path:
"""
Télécharge le document dans le cache, en flux et de façon atomique.
L'écriture passe par un fichier `.part` renommé à la fin. Sans cela, un
téléchargement interrompu laisserait un PDF tronqué que le cache servirait
ensuite indéfiniment, sans jamais retenter.
"""
partiel = destination.with_suffix(destination.suffix + ".part")
delai = httpx.Timeout(settings.DOCUMENTS_FETCH_TIMEOUT_S)
for extension in EXTENSIONS:
url = url_publique(file_id, extension)
try:
async with httpx.AsyncClient(timeout=delai, follow_redirects=True) as client:
async with client.stream("GET", url) as reponse:
if reponse.status_code == 404:
continue
if reponse.status_code >= 500:
raise DocumentInaccessible(
f"Le magasin a repondu {reponse.status_code} pour {file_id}"
)
if reponse.status_code >= 400:
raise DocumentIntrouvable(
f"Le magasin a refuse {file_id} ({reponse.status_code})"
)
with partiel.open("wb") as sortie:
async for tranche in reponse.aiter_bytes(TAILLE_TRANCHE):
sortie.write(tranche)
except (httpx.HTTPError, OSError) as exc:
partiel.unlink(missing_ok=True)
raise DocumentInaccessible(f"Recuperation de {file_id} impossible : {exc}") from exc
cible = destination.with_suffix(extension)
os.replace(partiel, cible)
_elaguer_le_cache(cible.parent)
return cible
raise DocumentIntrouvable(file_id)
async def chemin_local(file_id: str) -> Path:
"""
Chemin d'un document sur le disque local, quel que soit le mode.
En mode local, c'est `resolve_upload_path`. En mode distant, le document est
téléchargé dans le cache si nécessaire, puis son chemin est rendu — les
bibliothèques qui rendent les pages (`pdf2image`, `pypdf`) prennent un
chemin de fichier et ne savent pas consommer une URL.
Raises:
IdentifiantInvalide, DocumentIntrouvable, DocumentInaccessible
"""
if not stockage_distant():
try:
return resolve_upload_path(file_id)
except ValueError as exc:
raise IdentifiantInvalide(str(exc)) from exc
except FileNotFoundError as exc:
raise DocumentIntrouvable(file_id) from exc
if not _FILE_ID_RE.match(file_id or ""):
raise IdentifiantInvalide(f"Identifiant de fichier invalide : {file_id!r}")
repertoire = _repertoire_du_cache()
async with _verrou(file_id):
for extension in EXTENSIONS:
candidat = repertoire / f"{file_id}{extension}"
if candidat.is_file():
return candidat
return await _telecharger(file_id, repertoire / file_id)