Spaces:
Runtime error
Runtime error
Claude Opus 5 (1M context)
feat: deployment on free tiers, object-storage documents, and email
6429ad3 Download app/services/document_storage.py from rahim237cm/jurix-api: direct link, hf CLI and curl.
- Browser
- Download file 7.85 kB
-
https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/app/services/document_storage.py
- Command line
-
hf download hf://spaces/rahim237cm/jurix-api/app/services/document_storage.py
-
curl -L -o document_storage.py https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/app/services/document_storage.py
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) | |