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
+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
// 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) {
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');