diff --git a/CLAUDE.md b/CLAUDE.md
index ec5a1b5..5703be1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1814,6 +1814,97 @@ déduire qu'une page suivante ne contient plus de date récente ; au-delà de 10
un extrait signalé par `Tronquee`, sans boucle ni appels indéfinis. Une source muette conserve
les mêmes `EtatSourceBibliographie` et motifs que la bibliographie normale.
+## Relais de couvertures — `GET /api/couvertures` (2026-08-20)
+
+Le cache hors-ligne des couvertures (lot A5) ne fonctionnait **que pour OpenLibrary**, et ce
+n'était pas un défaut d'implémentation mais une limite non vue : il lit les octets par
+`fetch()`, ce qui exige un en-tête `Access-Control-Allow-Origin`. Or le formulaire livre offre un
+champ **« URL de couverture »** libre — ces images s'affichaient parfaitement (une `
` n'a
+que faire du CORS) sans jamais pouvoir être mises en cache.
+
+⚠️ **Le symptôme visible n'était pas le bon** : des erreurs CORS en console, avalées par le
+`catch`, donc réputées bénignes. La vraie conséquence est ailleurs, en deux temps : couverture
+absente hors-ligne, **et** fetch retenté à *chaque* affichage puisque rien n'était jamais rangé.
+
+**Décidé avec l'utilisateur le 2026-08-20 : relais côté serveur** plutôt que renoncer. L'image
+passe par `GET /api/couvertures?url=…`, devient de **même origine**, et la question du CORS
+disparaît pour tous les hébergeurs.
+
+### ⚠️ Un proxy est une surface SSRF — deux verrous, aucun ne suffit seul
+
+Le service écoute sur `127.0.0.1` et cohabite avec les autres applications du serveur YunoHost.
+Un relais non borné permettrait de faire lire au serveur ce que l'appelant ne peut pas atteindre
+lui-même — c'est-à-dire de contourner la contrainte d'écoute que ce fichier pose comme dure.
+
+| Verrou | Ce qu'il empêche | Ce qu'il ne suffit pas à empêcher |
+|---|---|---|
+| **L'URL doit déjà être en base** (`Livres.CoverUrl` ou `LivresSouhaites.CoverUrl`, égalité exacte) | qu'on fasse chercher une URL choisie au moment de l'appel | qu'une URL interne ait été enregistrée dans une fiche |
+| **La connexion ne s'ouvre que vers une adresse publiquement routable** (`GardeAdresses`) | tout le reste, y compris le cas ci-contre | — |
+
+⚠️ **Le garde agit dans `SocketsHttpHandler.ConnectCallback`, pas avant la requête.** Résoudre le
+nom d'abord puis laisser `HttpClient` résoudre à nouveau laisserait passer un **DNS rebinding** :
+un nom qui répond une adresse publique à la vérification et `127.0.0.1` à la connexion. Ici la
+socket se connecte **aux adresses déjà validées**, et à aucune autre.
+
+⚠️ La liste des plages est **négative**, contrairement aux filtres de type de document du projet
+qui sont positifs : ici, oublier une plage est une faille, alors qu'en écarter une de trop ne
+coûte qu'une couverture non mise en cache. Sont refusés bouclage, `10/8`, `172.16/12`,
+`192.168/16`, `169.254/16` (métadonnées d'hébergeur), `100.64/10`, `0/8`, multicast, `fe80::/10`,
+`fc00::/7`, et **les IPv4 encapsulées en IPv6** (`::ffff:127.0.0.1`) — ce dernier cas est
+exactement celui qu'on oublie.
+
+⚠️ **Tout refus répond 404**, sans distinguer « pas en base » de « injoignable » ou « adresse
+interdite ». Autrement le point d'entrée serait un **oracle** : on y lirait quelles URL
+l'application connaît, et quelles adresses répondent depuis le serveur.
+
+### ⚠️ Les redirections sont SUIVIES — la première version avait tort
+
+Elles avaient d'abord été coupées, au motif qu'un 302 emmène vers une URL que rien n'a validée.
+Mesuré : `covers.openlibrary.org` répond **302**, deux fois, avant d'aboutir sur `archive.org`.
+Les couper refusait donc **les couvertures les plus courantes du projet**.
+
+Les suivre reste sûr précisément parce que le garde est **à la connexion** : il s'applique à
+chaque saut, cible de redirection comprise. C'est la position du garde, et non l'interdiction de
+rediriger, qui ferme le SSRF. Bornées à 3 sauts.
+
+### L'ordre des tentatives, côté client
+
+`couvertureMettreEnCache` tente **d'abord l'URL directe** — elle profite du cache HTTP du
+navigateur (même URL que l'`
` déjà chargée) et n'impose rien à notre serveur — et ne
+retombe sur le relais que si le CORS a bloqué. Une réponse opaque (`mode: 'no-cors'`) ne
+conviendrait pas : son corps est illisible, donc impossible à ranger en IndexedDB.
+
+Corollaire agréable : une fois le blob rangé, le `getKey` en tête de fonction court-circuite
+tout. **L'erreur CORS n'apparaît donc qu'une fois par couverture et par appareil**, au lieu d'à
+chaque affichage.
+
+### Deux pièges rencontrés, et un troisième à ne pas oublier
+
+- ⚠️ **`.Produces(200, contentType: "image/*")` fait échouer le DÉMARRAGE du serveur**, pas la
+ compilation : l'annotation OpenAPI refuse les jokers. Le type varie avec l'image, il n'est
+ donc pas annoté.
+- ⚠️ **Le `Content-Length` ne borne rien à lui seul** : absent en *chunked*, et rien n'oblige un
+ serveur distant à dire la vérité. La lecture est bornée pendant qu'elle se fait (5 Mio).
+- ⚠️ **Un `dotnet run` déjà en cours garde le port** : le second démarre, échoue à écouter, et
+ l'on mesure sans le savoir le binaire d'avant. Arrivé ici, et cela a produit trois résultats
+ faux avant d'être vu.
+
+### Vérifié en exécution
+
+| Cas | Réponse |
+|---|---|
+| URL en base, hôte sans CORS | **200 image/jpeg, 15 777 o** |
+| URL en base, OpenLibrary (2 redirections) | **200 image/jpeg, 49 153 o** |
+| URL inconnue de la base | 404 |
+| `http://` | 404 |
+| `https://127.0.0.1:5030/favicon.png` | 404 |
+| `https://169.254.169.254/…` | 404 |
+| sans paramètre | 404 |
+
+Et dans le navigateur : le magasin IndexedDB `couvertures` contient désormais **les deux**
+images, dont celle de l'hébergeur sans CORS — impossible auparavant. Au rechargement suivant,
+**aucune** requête vers cet hôte ni vers le relais. 479 tests au vert.
+
## Installation YunoHost — éprouvée en production le 2026-08-18
Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
diff --git a/MaBibli.Api/Endpoints/CouverturesEndpoints.cs b/MaBibli.Api/Endpoints/CouverturesEndpoints.cs
new file mode 100644
index 0000000..54fa7f8
--- /dev/null
+++ b/MaBibli.Api/Endpoints/CouverturesEndpoints.cs
@@ -0,0 +1,40 @@
+using MaBibli.Api.Services.Couvertures;
+
+namespace MaBibli.Api.Endpoints;
+
+public static class CouverturesEndpoints
+{
+ ///
+ /// Relais d'images de couverture, pour la mise en cache hors-ligne.
+ ///
+ ///
+ /// ⚠️ Tout refus répond 404, qu'il s'agisse d'une URL inconnue de la base, d'un schéma
+ /// interdit, d'une adresse non routable ou d'un hôte injoignable. Distinguer ces cas ferait du
+ /// point d'entrée un oracle : on y lirait quelles URL l'application connaît, et quelles
+ /// adresses répondent depuis le serveur. Voir .
+ ///
+ public static IEndpointRouteBuilder MapCouverturesEndpoints(this IEndpointRouteBuilder routes)
+ {
+ routes.MapGet("/api/couvertures", async (
+ string? url,
+ IServiceCouvertures service,
+ CancellationToken ct) =>
+ {
+ var resultat = await service.ObtenirAsync(url, ct);
+
+ return resultat.Trouvee
+ ? Results.File(resultat.Octets!, resultat.TypeContenu!)
+ : Results.NotFound();
+ })
+ .WithName("RelayerCouverture")
+ .WithTags("Couvertures")
+ .WithSummary("Relaie une couverture déjà enregistrée, en même origine.")
+ // ⚠️ Pas de `contentType` ici : il varie avec l'image (image/jpeg, image/png…), et
+ // l'annotation OpenAPI REFUSE les jokers — « image/* » fait échouer le démarrage du
+ // serveur, pas la compilation. Constaté en exécution.
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status404NotFound);
+
+ return routes;
+ }
+}
diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs
index b6f9c3e..bfe30f6 100644
--- a/MaBibli.Api/Program.cs
+++ b/MaBibli.Api/Program.cs
@@ -1,6 +1,7 @@
using MaBibli.Api.Data;
using MaBibli.Api.Endpoints;
using MaBibli.Api.Services.Catalogue;
+using MaBibli.Api.Services.Couvertures;
using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Isbn;
using MaBibli.Api.Services.Prets;
@@ -50,6 +51,31 @@ builder.Services.AddHttpClient(http =>
builder.Services.AddScoped();
+// Relais de couvertures : rend une image de MÊME ORIGINE, seule façon de la mettre en cache
+// hors-ligne quand son hébergeur n'envoie pas d'en-tête CORS (le formulaire livre accepte
+// n'importe quelle URL de couverture).
+//
+// ⚠️ Les deux réglages du handler ne sont PAS des détails de confort :
+// - ConnectCallback n'ouvre la socket que vers une adresse publiquement routable, ce qui
+// ferme le SSRF y compris par DNS rebinding (voir GardeAdresses) ;
+// - les redirections sont SUIVIES, mais bornées à 3. Elles avaient d'abord été coupées, à
+// tort : `covers.openlibrary.org` répond 302 (mesuré), et beaucoup de CDN font de même —
+// les couper refusait donc les couvertures les plus courantes. Les suivre reste sûr parce
+// que le garde agit à la CONNEXION : il s'applique à chaque saut, cible de redirection
+// comprise. C'est la position du garde, et non l'interdiction des redirections, qui ferme
+// le SSRF.
+builder.Services.AddHttpClient(http =>
+{
+ http.Timeout = timeoutSources;
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("MaBibli/0.1 (bibliotheque personnelle auto-hebergee)");
+})
+.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
+{
+ AllowAutoRedirect = true,
+ MaxAutomaticRedirections = 3,
+ ConnectCallback = GardeAdresses.ConnecterAsync,
+});
+
var app = builder.Build();
// Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost.
@@ -98,6 +124,7 @@ app.MapSeriesEndpoints();
app.MapRevuesEndpoints();
app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints();
+app.MapCouverturesEndpoints();
// ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées
// ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA,
diff --git a/MaBibli.Api/Services/Couvertures/GardeAdresses.cs b/MaBibli.Api/Services/Couvertures/GardeAdresses.cs
new file mode 100644
index 0000000..fd6c673
--- /dev/null
+++ b/MaBibli.Api/Services/Couvertures/GardeAdresses.cs
@@ -0,0 +1,115 @@
+using System.Net;
+using System.Net.Sockets;
+
+namespace MaBibli.Api.Services.Couvertures;
+
+///
+/// Garde-fou SSRF : n'autorise la connexion qu'à des adresses IP publiquement routables.
+///
+///
+///
+/// ⚠️ Ce garde est la raison pour laquelle le proxy de couvertures est acceptable. Le
+/// service .NET écoute sur 127.0.0.1 et vit sur un serveur YunoHost qui héberge d'autres
+/// applications, souvent sur des ports locaux. Un proxy qui irait chercher n'importe quelle URL
+/// permettrait de faire lire au serveur ce que le client ne peut pas atteindre lui-même —
+/// c'est-à-dire exactement ce que la contrainte « écouter sur 127.0.0.1 uniquement » cherche à
+/// empêcher.
+///
+///
+/// ⚠️ La vérification est faite au moment de CONNECTER, pas avant la requête. Résoudre le
+/// nom d'abord puis laisser HttpClient résoudre à nouveau laisserait passer un
+/// DNS rebinding : un nom qui répond une adresse publique à la vérification et
+/// 127.0.0.1 à la connexion. Ici, la socket se connecte aux adresses déjà validées,
+/// et à aucune autre.
+///
+///
+public static class GardeAdresses
+{
+ ///
+ /// Rappel de connexion pour .
+ ///
+ public static async ValueTask ConnecterAsync(
+ SocketsHttpConnectionContext contexte, CancellationToken ct)
+ {
+ var adresses = await Dns.GetHostAddressesAsync(contexte.DnsEndPoint.Host, ct);
+ var permises = adresses.Where(EstPubliquementRoutable).ToArray();
+
+ if (permises.Length == 0)
+ {
+ // Message volontairement muet sur ce qui a été résolu : l'appelant n'a pas à
+ // apprendre la topologie du réseau du serveur.
+ throw new HttpRequestException("Adresse non autorisée.");
+ }
+
+ var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
+
+ try
+ {
+ await socket.ConnectAsync(permises, contexte.DnsEndPoint.Port, ct);
+ return new NetworkStream(socket, ownsSocket: true);
+ }
+ catch
+ {
+ socket.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Vrai si l'adresse est publiquement routable, c'est-à-dire hors de tout ce qui pourrait
+ /// désigner le serveur lui-même ou son réseau local.
+ ///
+ ///
+ /// Liste négative, contrairement à la plupart des filtres du projet : ici, laisser
+ /// passer une plage oubliée est une faille, alors qu'en écarter une de trop ne coûte qu'une
+ /// couverture non mise en cache.
+ ///
+ public static bool EstPubliquementRoutable(IPAddress adresse)
+ {
+ // Une adresse IPv4 encapsulée en IPv6 (::ffff:127.0.0.1) doit être jugée sur son IPv4,
+ // sans quoi le bouclage passerait déguisé.
+ if (adresse.IsIPv4MappedToIPv6)
+ {
+ adresse = adresse.MapToIPv4();
+ }
+
+ if (IPAddress.IsLoopback(adresse))
+ {
+ return false;
+ }
+
+ if (adresse.AddressFamily == AddressFamily.InterNetwork)
+ {
+ var o = adresse.GetAddressBytes();
+
+ return o[0] switch
+ {
+ 0 => false, // 0.0.0.0/8 « ce réseau »
+ 10 => false, // 10.0.0.0/8 privé
+ 127 => false, // bouclage (déjà couvert, par sûreté)
+ 169 when o[1] == 254 => false, // 169.254.0.0/16 lien-local (métadonnées cloud)
+ 172 when o[1] >= 16 && o[1] <= 31 => false, // 172.16.0.0/12 privé
+ 192 when o[1] == 168 => false, // 192.168.0.0/16 privé
+ 192 when o[1] == 0 && o[2] == 0 => false, // 192.0.0.0/24 usages protocolaires
+ 100 when o[1] >= 64 && o[1] <= 127 => false, // 100.64.0.0/10 CGNAT
+ >= 224 => false, // multicast et réservé
+ _ => true,
+ };
+ }
+
+ if (adresse.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ if (adresse.IsIPv6LinkLocal || adresse.IsIPv6SiteLocal || adresse.IsIPv6Multicast
+ || adresse.Equals(IPAddress.IPv6Any))
+ {
+ return false;
+ }
+
+ // fc00::/7 : adresses locales uniques (l'équivalent IPv6 du privé).
+ return (adresse.GetAddressBytes()[0] & 0xFE) != 0xFC;
+ }
+
+ // Ni IPv4 ni IPv6 : on ne sait pas juger, donc on refuse.
+ return false;
+ }
+}
diff --git a/MaBibli.Api/Services/Couvertures/ServiceCouvertures.cs b/MaBibli.Api/Services/Couvertures/ServiceCouvertures.cs
new file mode 100644
index 0000000..3b9c1b9
--- /dev/null
+++ b/MaBibli.Api/Services/Couvertures/ServiceCouvertures.cs
@@ -0,0 +1,149 @@
+using MaBibli.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MaBibli.Api.Services.Couvertures;
+
+/// Résultat d'une demande de couverture : les octets, ou rien.
+public sealed record ResultatCouverture(byte[]? Octets, string? TypeContenu)
+{
+ public static readonly ResultatCouverture Refusee = new(null, null);
+
+ public bool Trouvee => Octets is not null && TypeContenu is not null;
+}
+
+public interface IServiceCouvertures
+{
+ Task ObtenirAsync(string? url, CancellationToken ct);
+}
+
+///
+/// Relaie une image de couverture en même origine, pour qu'elle puisse être mise en cache
+/// hors-ligne quel que soit son hébergeur.
+///
+///
+///
+/// Pourquoi ce service existe. Le cache hors-ligne des couvertures (lot A5) lit les octets
+/// par fetch(), ce qui exige un en-tête Access-Control-Allow-Origin. OpenLibrary
+/// l'envoie, mais le formulaire livre accepte n'importe quelle URL de couverture : ces
+/// images s'affichaient bien (une balise <img> n'a que faire du CORS) sans jamais
+/// pouvoir être mises en cache. Relayées par notre propre serveur, elles deviennent de même
+/// origine, et la question du CORS disparaît.
+///
+///
+/// ⚠️ Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous
+/// indépendants — les deux sont nécessaires, aucun ne suffit :
+///
+///
+/// -
+/// L'URL doit déjà exister en base comme couverture d'un livre ou d'une envie. Le proxy
+/// ne va donc jamais chercher une URL choisie au moment de l'appel : seulement une valeur que
+/// l'application détient déjà.
+///
+/// -
+/// La connexion n'est ouverte que vers une adresse publiquement routable
+/// (). C'est ce second verrou qui tient si quelqu'un enregistre
+/// d'abord une fiche portant une URL interne : la valeur serait en base, la connexion serait
+/// refusée quand même.
+///
+///
+///
+/// ⚠️ Tout refus est indistinct côté appelant (voir l'endpoint) : distinguer « pas en
+/// base » de « injoignable » ferait du proxy un oracle qui renseigne sur le réseau du serveur.
+///
+///
+public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures
+{
+ ///
+ /// Plafond de lecture. Une couverture de livre pèse quelques dizaines de kilo-octets ; ce
+ /// plafond n'est pas là pour trier les images mais pour qu'une URL pointant sur un très gros
+ /// fichier ne puisse pas remplir la mémoire du serveur.
+ ///
+ public const int TailleMaximale = 5 * 1024 * 1024;
+
+ public async Task ObtenirAsync(string? url, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(url)
+ || !Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ || uri.Scheme != Uri.UriSchemeHttps)
+ {
+ // https uniquement : l'application est servie en https par YunoHost, une image en
+ // clair y serait de toute façon bloquée par le navigateur (contenu mixte).
+ return ResultatCouverture.Refusee;
+ }
+
+ if (!await EstConnueAsync(url, ct))
+ {
+ return ResultatCouverture.Refusee;
+ }
+
+ try
+ {
+ using var reponse = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, ct);
+
+ // Les redirections sont suivies (3 au plus, voir Program.cs) : OpenLibrary répond
+ // 302 sur ses couvertures. Chaque saut repasse par le garde d'adresses, qui agit à
+ // la connexion — c'est lui qui borne, pas l'interdiction de rediriger.
+ if (!reponse.IsSuccessStatusCode)
+ {
+ return ResultatCouverture.Refusee;
+ }
+
+ var type = reponse.Content.Headers.ContentType?.MediaType;
+ if (type is null || !type.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
+ {
+ return ResultatCouverture.Refusee;
+ }
+
+ if (reponse.Content.Headers.ContentLength > TailleMaximale)
+ {
+ return ResultatCouverture.Refusee;
+ }
+
+ var octets = await LireBorneAsync(reponse.Content, ct);
+
+ return octets is null ? ResultatCouverture.Refusee : new ResultatCouverture(octets, type);
+ }
+ catch (Exception)
+ {
+ // Hôte injoignable, adresse refusée par le garde, délai dépassé : une couverture non
+ // relayée reste un agrément perdu, jamais une erreur à propager.
+ return ResultatCouverture.Refusee;
+ }
+ }
+
+ ///
+ /// L'URL est-elle déjà enregistrée comme couverture ? Comparaison exacte : c'est la
+ /// valeur stockée qui autorise, pas une ressemblance.
+ ///
+ private async Task EstConnueAsync(string url, CancellationToken ct) =>
+ await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
+ || await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct);
+
+ ///
+ /// Lit au plus octets, et rend null si le corps dépasse.
+ ///
+ ///
+ /// ⚠️ Le Content-Length vérifié plus haut ne suffit pas : il est absent d'une
+ /// réponse en chunked, et rien n'oblige un serveur distant à dire la vérité.
+ ///
+ private static async Task LireBorneAsync(HttpContent contenu, CancellationToken ct)
+ {
+ await using var flux = await contenu.ReadAsStreamAsync(ct);
+ using var tampon = new MemoryStream();
+
+ var morceau = new byte[81920];
+ int lus;
+
+ while ((lus = await flux.ReadAsync(morceau, ct)) > 0)
+ {
+ if (tampon.Length + lus > TailleMaximale)
+ {
+ return null;
+ }
+
+ tampon.Write(morceau, 0, lus);
+ }
+
+ return tampon.ToArray();
+ }
+}
diff --git a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js
index b3c0c98..38ec462 100644
--- a/MaBibli.Client/wwwroot/js/cache-hors-ligne.js
+++ b/MaBibli.Client/wwwroot/js/cache-hors-ligne.js
@@ -99,6 +99,17 @@ export function surveiller(reference) {
//
// OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal
// suffit, pas besoin du contournement `no-cors`/réponse opaque.
+//
+// ⚠️ Mais le formulaire livre accepte N'IMPORTE QUELLE URL de couverture, et la plupart des
+// hébergeurs n'envoient aucun en-tête CORS. Ces images s'affichaient bien — une
n'a que
+// faire du CORS — sans jamais pouvoir être mises en cache, et le fetch repartait à CHAQUE
+// affichage puisque rien n'était jamais rangé. D'où le repli par `/api/couvertures`, qui relaie
+// l'image en MÊME ORIGINE (voir ServiceCouvertures côté serveur, et ses deux verrous anti-SSRF).
+//
+// ⚠️ L'ordre compte : on tente d'abord l'URL directe, qui profite du cache HTTP du navigateur
+// (même URL que l'
déjà chargée) et n'impose rien à notre serveur. Le relais n'entre en jeu
+// que pour ce que le CORS a bloqué. Une réponse opaque (`mode: 'no-cors'`) ne conviendrait pas :
+// son corps est illisible, donc impossible à ranger en IndexedDB.
export async function couvertureMettreEnCache(url) {
try {
const base = await ouvrir();
@@ -106,8 +117,20 @@ export async function couvertureMettreEnCache(url) {
const existe = await attendre(tx.objectStore(MAGASIN_COUVERTURES).getKey(url), tx);
if (existe !== undefined) return; // Déjà en cache : pas de re-téléchargement.
- const reponse = await fetch(url, { cache: 'force-cache' });
- if (!reponse.ok) return; // 404/502 intermittent (CLAUDE.md) : rien à ranger.
+ let reponse = null;
+
+ try {
+ reponse = await fetch(url, { cache: 'force-cache' });
+ } catch (eDirect) {
+ // Bloqué par le CORS, ou hôte injoignable : on ne sait pas lequel, et peu importe.
+ }
+
+ if (!reponse || !reponse.ok) {
+ reponse = await fetch('/api/couvertures?url=' + encodeURIComponent(url));
+ }
+
+ // 404/502 intermittent d'OpenLibrary (CLAUDE.md), ou relais refusé : rien à ranger.
+ if (!reponse.ok) return;
const blob = await reponse.blob();
const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite');
diff --git a/MaBibli.Tests/ServiceCouverturesTests.cs b/MaBibli.Tests/ServiceCouverturesTests.cs
new file mode 100644
index 0000000..7230a2d
--- /dev/null
+++ b/MaBibli.Tests/ServiceCouverturesTests.cs
@@ -0,0 +1,183 @@
+using System.Net;
+using MaBibli.Api.Data;
+using MaBibli.Api.Services.Couvertures;
+using MaBibli.Shared.Entites;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+namespace MaBibli.Tests;
+
+///
+/// Relais de couvertures : ce qui est testé ici, ce n'est pas qu'une image arrive, c'est
+/// tout ce que le proxy refuse d'aller chercher.
+///
+///
+/// ⚠️ Un proxy dont le bornage se relâche ne « marche moins bien » pas : il devient un moyen de
+/// faire lire au serveur ce que l'appelant ne peut pas atteindre lui-même. Le service écoute sur
+/// 127.0.0.1 et cohabite avec les autres applications YunoHost du serveur — c'est
+/// exactement ce que la contrainte d'écoute cherche à empêcher.
+///
+public class ServiceCouverturesTests : IDisposable
+{
+ private readonly SqliteConnection _connexion;
+ private readonly MaBibliDbContext _db;
+
+ private const string UrlConnue = "https://editions-exemple.fr/couverture.jpg";
+
+ public ServiceCouverturesTests()
+ {
+ _connexion = new SqliteConnection("Data Source=:memory:");
+ _connexion.Open();
+
+ _db = new MaBibliDbContext(new DbContextOptionsBuilder()
+ .UseSqlite(_connexion).Options);
+ _db.Database.EnsureCreated();
+
+ var livre = new Livre { Titre = "Un livre", CoverUrl = UrlConnue };
+ livre.RecalculerFormes();
+ _db.Livres.Add(livre);
+ _db.SaveChanges();
+ }
+
+ ///
+ /// Client qui échoue à tout coup : ces tests portent sur le bornage, et un refus doit
+ /// tomber avant tout appel réseau. Si l'un d'eux passait jusqu'ici, il échouerait —
+ /// c'est précisément ce qu'on veut constater.
+ ///
+ private ServiceCouvertures Service() =>
+ new(new HttpClient(new HandlerQuiRefuse()), _db);
+
+ private sealed class HandlerQuiRefuse : HttpMessageHandler
+ {
+ public bool AEteAppele { get; private set; }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ AEteAppele = true;
+ throw new HttpRequestException("Aucun appel réseau ne devrait avoir lieu.");
+ }
+ }
+
+ // ── Verrou nº1 : l'URL doit déjà être en base ────────────────────────────
+
+ [Fact]
+ public async Task Une_url_inconnue_de_la_base_est_refusee()
+ {
+ var handler = new HandlerQuiRefuse();
+ var service = new ServiceCouvertures(new HttpClient(handler), _db);
+
+ var resultat = await service.ObtenirAsync("https://ailleurs.example/image.jpg", default);
+
+ Assert.False(resultat.Trouvee);
+
+ // Le point important : le refus tombe AVANT la requête. Sans ce verrou, le proxy irait
+ // chercher n'importe quelle URL passée en paramètre.
+ Assert.False(handler.AEteAppele);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("pas une url")]
+ [InlineData("file:///etc/passwd")]
+ [InlineData("http://editions-exemple.fr/couverture.jpg")] // https uniquement
+ public async Task Les_schemas_et_les_saisies_invalides_sont_refuses(string? url)
+ {
+ Assert.False((await Service().ObtenirAsync(url, default)).Trouvee);
+ }
+
+ [Fact]
+ public async Task Une_url_connue_franchit_le_bornage_et_va_jusqu_au_reseau()
+ {
+ var handler = new HandlerQuiRefuse();
+ var service = new ServiceCouvertures(new HttpClient(handler), _db);
+
+ // Le réseau échoue (handler), donc pas de couverture — mais l'appel a bien eu lieu :
+ // c'est ce qui distingue « refusé par le bornage » de « injoignable ».
+ var resultat = await service.ObtenirAsync(UrlConnue, default);
+
+ Assert.False(resultat.Trouvee);
+ Assert.True(handler.AEteAppele);
+ }
+
+ [Fact]
+ public async Task Une_couverture_d_ENVIE_autorise_aussi_le_relais()
+ {
+ // Les deux tables comptent : une envie porte sa couverture comme un livre, et elle
+ // s'affiche dans la liste d'envies — donc elle doit pouvoir se mettre en cache.
+ const string urlEnvie = "https://editions-exemple.fr/envie.jpg";
+
+ var souhait = new LivreSouhaite
+ {
+ Utilisateur = "mathieu",
+ Titre = "Une envie",
+ Auteur = "Quelqu'un",
+ CoverUrl = urlEnvie,
+ };
+ souhait.RecalculerFormes();
+ _db.LivresSouhaites.Add(souhait);
+ await _db.SaveChangesAsync();
+
+ var handler = new HandlerQuiRefuse();
+ var service = new ServiceCouvertures(new HttpClient(handler), _db);
+
+ await service.ObtenirAsync(urlEnvie, default);
+
+ Assert.True(handler.AEteAppele);
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ _connexion.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
+
+///
+/// Verrou nº2 : la liste des adresses vers lesquelles une connexion peut s'ouvrir.
+///
+///
+/// C'est le verrou qui tient même si une URL interne a été enregistrée en base dans une
+/// fiche : la valeur serait connue, la connexion serait refusée quand même.
+///
+public class GardeAdressesTests
+{
+ [Theory]
+ [InlineData("127.0.0.1")] // bouclage : le service lui-même
+ [InlineData("127.1.2.3")]
+ [InlineData("0.0.0.0")]
+ [InlineData("10.0.0.5")] // privé
+ [InlineData("172.16.0.1")]
+ [InlineData("172.31.255.254")]
+ [InlineData("192.168.1.1")]
+ [InlineData("169.254.169.254")] // lien-local : le point de métadonnées des hébergeurs
+ [InlineData("100.64.0.1")] // CGNAT
+ [InlineData("224.0.0.1")] // multicast
+ [InlineData("::1")] // bouclage IPv6
+ [InlineData("::")]
+ [InlineData("fe80::1")] // lien-local IPv6
+ [InlineData("fc00::1")] // adresses locales uniques
+ [InlineData("fd12:3456::1")]
+ [InlineData("::ffff:127.0.0.1")] // bouclage déguisé en IPv6
+ [InlineData("::ffff:192.168.0.1")]
+ public void Les_adresses_internes_sont_refusees(string adresse)
+ {
+ Assert.False(GardeAdresses.EstPubliquementRoutable(IPAddress.Parse(adresse)));
+ }
+
+ [Theory]
+ [InlineData("1.1.1.1")]
+ [InlineData("172.15.0.1")] // juste HORS de 172.16.0.0/12
+ [InlineData("172.32.0.1")] // juste au-dessus
+ [InlineData("100.63.255.255")] // juste sous le CGNAT
+ [InlineData("100.128.0.1")] // juste au-dessus
+ [InlineData("192.167.0.1")]
+ [InlineData("2606:4700:4700::1111")]
+ public void Les_adresses_publiques_sont_acceptees(string adresse)
+ {
+ Assert.True(GardeAdresses.EstPubliquementRoutable(IPAddress.Parse(adresse)));
+ }
+}