Files
mabibli/MaBibli.Api/Services/Couvertures/ServiceCouvertures.cs
T
mathieuandClaude Opus 5 1d35dc06f4 Donne aux numéros de revue leur couverture et leur une, et un tiret à l'ISSN
L'ISSN est désormais rangé sous sa forme à tiret, et pas seulement affiché ainsi.
C'est le seul endroit où le projet s'écarte de « la valeur stockée reste nue »,
et il y a une raison : le code-barres produit déjà un ISSN à tiret, et la BnF
interroge `bib.issn` avec le tiret. Un ISSN tapé « 24666718 » ne se rapprochait
donc de rien, et le scan suivant créait une seconde fiche à côté. La canonisation
a lieu avant la recherche, pas seulement à l'écriture. Les champs de saisie, eux,
gardent la valeur tapée : découper à la frappe se battrait avec le curseur.

`ServiceRenormalisation` rattrape les ISSN existants — par un passage dédié, le
mécanisme générique confondant tous les NULL en une seule clé, alors que
l'unicité est ici un index partiel. Une fiche dont la forme canonique est déjà
prise garde la sienne : le serveur doit démarrer.

La couverture d'un numéro est une URL collée, jamais une photo : aucune source ne
peut la fournir, l'ISSN désignant la revue et non la parution, et le projet ne
stocke aucun fichier. Le garde du relais de couvertures a donc été étendu aux
numéros — l'oublier n'aurait produit aucune erreur visible, seulement une image
présente en ligne et absente hors-ligne.

Les articles à la une vivent dans une table à part et non dans `Theme` : un thème
est un vocabulaire qu'on réutilise, un titre d'article est unique à sa parution.
Rien ne se partage, donc pas de n-n — une simple clé étrangère vers le numéro.
Ils se saisissent au point-virgule seul : « Ukraine, deux ans après » serait
coupé en deux par la virgule.

Un PUT sur le numéro était indispensable : on note un numéro le jour où on le
range et on en recopie le sommaire plus tard. Sans lui, couverture et une
n'auraient existé qu'à la création, c'est-à-dire jamais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:25:29 +02:00

158 lines
6.4 KiB
C#

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, 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à.
/// </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>
/// <remarks>
/// ⚠️ <b>Les numéros de revue en font partie</b> 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.
/// </remarks>
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)
|| await db.NumerosRevue.AnyAsync(n => n.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();
}
}