Relaie les couvertures en même origine, pour les mettre en cache hors-ligne

Le cache hors-ligne des couvertures ne marchait que pour OpenLibrary : il lit
les octets par fetch(), donc exige un en-tête CORS, alors que le formulaire
livre accepte n'importe quelle URL. Ces images s'affichaient (une <img> n'a
que faire du CORS) sans jamais pouvoir être rangées — et le fetch repartait à
chaque affichage puisque rien n'était stocké.

GET /api/couvertures relaie l'image depuis notre serveur. Un proxy est une
surface SSRF : il est borné par deux verrous indépendants — l'URL doit déjà
exister en base comme couverture, et la connexion ne s'ouvre que vers une
adresse publiquement routable. Ce second verrou vit dans le ConnectCallback,
pas dans une pré-vérification DNS, ce qui ferme aussi le DNS rebinding — et
c'est ce qui permet de suivre les redirections, indispensables puisque
covers.openlibrary.org répond 302.

Tout refus répond 404 : distinguer les cas ferait du point d'entrée un oracle
sur les URL connues et sur le réseau du serveur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-20 22:14:08 +02:00
co-authored by Claude Opus 5
parent e74d20adfa
commit b16e70913f
7 changed files with 630 additions and 2 deletions
+91
View File
@@ -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 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. 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 `<img>` 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'`<img>` 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 ## 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é, Le paquet vit dans le dépôt `mabibli_ynh` (voir « Deux dépôts distincts »). Installé,
@@ -0,0 +1,40 @@
using MaBibli.Api.Services.Couvertures;
namespace MaBibli.Api.Endpoints;
public static class CouverturesEndpoints
{
/// <summary>
/// Relais d'images de couverture, pour la mise en cache hors-ligne.
/// </summary>
/// <remarks>
/// ⚠️ <b>Tout refus répond 404</b>, 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 <see cref="ServiceCouvertures"/>.
/// </remarks>
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;
}
}
+27
View File
@@ -1,6 +1,7 @@
using MaBibli.Api.Data; using MaBibli.Api.Data;
using MaBibli.Api.Endpoints; using MaBibli.Api.Endpoints;
using MaBibli.Api.Services.Catalogue; using MaBibli.Api.Services.Catalogue;
using MaBibli.Api.Services.Couvertures;
using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Identite;
using MaBibli.Api.Services.Isbn; using MaBibli.Api.Services.Isbn;
using MaBibli.Api.Services.Prets; using MaBibli.Api.Services.Prets;
@@ -50,6 +51,31 @@ builder.Services.AddHttpClient<IOpenLibraryClient, OpenLibraryClient>(http =>
builder.Services.AddScoped<IIsbnLookupService, IsbnLookupService>(); builder.Services.AddScoped<IIsbnLookupService, IsbnLookupService>();
// 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<IServiceCouvertures, ServiceCouvertures>(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(); var app = builder.Build();
// Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost. // 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.MapRevuesEndpoints();
app.MapBibliographieEndpoints(); app.MapBibliographieEndpoints();
app.MapIdentiteEndpoints(); app.MapIdentiteEndpoints();
app.MapCouverturesEndpoints();
// ⚠️ Le fallback a son propre pipeline : il NE passe PAS par les StaticFileOptions posées // ⚠️ 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, // ci-dessus. Sans lui repasser les mêmes options, « / » — c'est-à-dire le start_url de la PWA,
@@ -0,0 +1,115 @@
using System.Net;
using System.Net.Sockets;
namespace MaBibli.Api.Services.Couvertures;
/// <summary>
/// Garde-fou SSRF : n'autorise la connexion qu'à des adresses IP publiquement routables.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <b>Ce garde est la raison pour laquelle le proxy de couvertures est acceptable.</b> Le
/// service .NET écoute sur <c>127.0.0.1</c> 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.
/// </para>
/// <para>
/// ⚠️ <b>La vérification est faite au moment de CONNECTER, pas avant la requête.</b> Résoudre le
/// nom d'abord puis laisser <c>HttpClient</c> résoudre à nouveau laisserait passer un
/// <i>DNS rebinding</i> : un nom qui répond une adresse publique à la vérification et
/// <c>127.0.0.1</c> à la connexion. Ici, la socket se connecte <b>aux adresses déjà validées</b>,
/// et à aucune autre.
/// </para>
/// </remarks>
public static class GardeAdresses
{
/// <summary>
/// Rappel de connexion pour <see cref="SocketsHttpHandler.ConnectCallback"/>.
/// </summary>
public static async ValueTask<Stream> 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;
}
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Liste <b>négative</b>, 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.
/// </remarks>
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;
}
}
@@ -0,0 +1,149 @@
using MaBibli.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Api.Services.Couvertures;
/// <summary>Résultat d'une demande de couverture : les octets, ou rien.</summary>
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<ResultatCouverture> ObtenirAsync(string? url, CancellationToken ct);
}
/// <summary>
/// Relaie une image de couverture <b>en même origine</b>, pour qu'elle puisse être mise en cache
/// hors-ligne quel que soit son hébergeur.
/// </summary>
/// <remarks>
/// <para>
/// <b>Pourquoi ce service existe.</b> Le cache hors-ligne des couvertures (lot A5) lit les octets
/// par <c>fetch()</c>, ce qui exige un en-tête <c>Access-Control-Allow-Origin</c>. OpenLibrary
/// l'envoie, mais le formulaire livre accepte <b>n'importe quelle URL</b> de couverture : ces
/// images s'affichaient bien (une balise <c>&lt;img&gt;</c> 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.
/// </para>
/// <para>
/// ⚠️ <b>Un proxy est une surface d'attaque (SSRF), et il est borné par DEUX verrous
/// indépendants</b> — les deux sont nécessaires, aucun ne suffit :
/// </para>
/// <list type="number">
/// <item>
/// <b>L'URL doit déjà exister en base</b> 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à.
/// </item>
/// <item>
/// <b>La connexion n'est ouverte que vers une adresse publiquement routable</b>
/// (<see cref="GardeAdresses"/>). 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.
/// </item>
/// </list>
/// <para>
/// ⚠️ <b>Tout refus est indistinct</b> 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.
/// </para>
/// </remarks>
public sealed class ServiceCouvertures(HttpClient http, MaBibliDbContext db) : IServiceCouvertures
{
/// <summary>
/// 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.
/// </summary>
public const int TailleMaximale = 5 * 1024 * 1024;
public async Task<ResultatCouverture> 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;
}
}
/// <summary>
/// L'URL est-elle déjà enregistrée comme couverture ? Comparaison <b>exacte</b> : c'est la
/// valeur stockée qui autorise, pas une ressemblance.
/// </summary>
private async Task<bool> EstConnueAsync(string url, CancellationToken ct) =>
await db.Livres.AnyAsync(l => l.CoverUrl == url, ct)
|| await db.LivresSouhaites.AnyAsync(s => s.CoverUrl == url, ct);
/// <summary>
/// Lit au plus <see cref="TailleMaximale"/> octets, et rend <c>null</c> si le corps dépasse.
/// </summary>
/// <remarks>
/// ⚠️ Le <c>Content-Length</c> vérifié plus haut ne suffit pas : il est <b>absent</b> d'une
/// réponse en <i>chunked</i>, et rien n'oblige un serveur distant à dire la vérité.
/// </remarks>
private static async Task<byte[]?> 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();
}
}
+25 -2
View File
@@ -99,6 +99,17 @@ export function surveiller(reference) {
// //
// OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal // OpenLibrary envoie `Access-Control-Allow-Origin: *` (vérifié) : un fetch cross-origin normal
// suffit, pas besoin du contournement `no-cors`/réponse opaque. // 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 <img> 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'<img> 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) { export async function couvertureMettreEnCache(url) {
try { try {
const base = await ouvrir(); const base = await ouvrir();
@@ -106,8 +117,20 @@ export async function couvertureMettreEnCache(url) {
const existe = await attendre(tx.objectStore(MAGASIN_COUVERTURES).getKey(url), tx); 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. if (existe !== undefined) return; // Déjà en cache : pas de re-téléchargement.
const reponse = await fetch(url, { cache: 'force-cache' }); let reponse = null;
if (!reponse.ok) return; // 404/502 intermittent (CLAUDE.md) : rien à ranger.
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 blob = await reponse.blob();
const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite'); const ecriture = base.transaction(MAGASIN_COUVERTURES, 'readwrite');
+183
View File
@@ -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;
/// <summary>
/// Relais de couvertures : ce qui est testé ici, ce n'est pas qu'une image arrive, c'est
/// <b>tout ce que le proxy refuse d'aller chercher</b>.
/// </summary>
/// <remarks>
/// ⚠️ 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
/// <c>127.0.0.1</c> et cohabite avec les autres applications YunoHost du serveur — c'est
/// exactement ce que la contrainte d'écoute cherche à empêcher.
/// </remarks>
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<MaBibliDbContext>()
.UseSqlite(_connexion).Options);
_db.Database.EnsureCreated();
var livre = new Livre { Titre = "Un livre", CoverUrl = UrlConnue };
livre.RecalculerFormes();
_db.Livres.Add(livre);
_db.SaveChanges();
}
/// <summary>
/// Client qui échoue à tout coup : ces tests portent sur le <b>bornage</b>, et un refus doit
/// tomber <i>avant</i> tout appel réseau. Si l'un d'eux passait jusqu'ici, il échouerait —
/// c'est précisément ce qu'on veut constater.
/// </summary>
private ServiceCouvertures Service() =>
new(new HttpClient(new HandlerQuiRefuse()), _db);
private sealed class HandlerQuiRefuse : HttpMessageHandler
{
public bool AEteAppele { get; private set; }
protected override Task<HttpResponseMessage> 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);
}
}
/// <summary>
/// Verrou nº2 : la liste des adresses vers lesquelles une connexion peut s'ouvrir.
/// </summary>
/// <remarks>
/// C'est le verrou qui tient <b>même si</b> une URL interne a été enregistrée en base dans une
/// fiche : la valeur serait connue, la connexion serait refusée quand même.
/// </remarks>
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)));
}
}