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)