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, d'une envie ou d'un /// numéro de revue. 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. /// /// /// ⚠️ Les numéros de revue en font partie depuis qu'ils portent une couverture. Les /// oublier ici aurait produit exactement le défaut que ce service existe pour corriger : /// une image qui s'affiche en ligne et jamais hors-ligne, l'appel de mise en cache étant /// refusé par le relais. /// 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) || await db.NumerosRevue.AnyAsync(n => n.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(); } }