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();
}
}