Spaces:
Runtime error
Runtime error
File size: 7,850 Bytes
6429ad3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | """
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)
|