Un musée pour tout ce qui a une URL
Je voulais parcourir ma liste de suivi d'enchères comme une galerie plutôt que la faire défiler comme un tableur. Il a fallu un parseur sans la moindre dépendance, une garde SSRF au risque résiduel documenté, un proxy d'images qui ne transmet rien, et un musée qui ne touche jamais un serveur.
Sur cette page
- Collez un lien, obtenez une œuvre
- Protection SSRF : un serveur qui va chercher des URL est une arme chargée
- Le proxy d'images qui ne transmet rien
- Les sites qui se cachent des robots
- Confidentialité localStorage : votre musée est à vous
- Une galerie 3D procédurale qui ne bouge jamais
- Nous limiter nous-mêmes, et autres leçons
Une liste de suivi est la façon la moins digne de regarder un bel objet. Trois cents pixels de large, recadré au carré par quelqu'un de pressé, empilé à côté d'un compte à rebours. J'ai passé plus de soirées que je ne voudrais l'avouer à plisser les yeux sur un instrument de laiton à travers ce trou de serrure. Il y a quatre jours, nous avons livré le contraire : Vaynerov Project Reliquary, un musée qui se construit autour de tout ce dont vous pouvez lui donner le lien.
L'accroche tient en une phrase — un musée pour tout ce qui a une URL — et le produit est cette phrase prise au pied de la lettre. Vous collez un lien. Quelques secondes plus tard, vous êtes dans un hall, et la chose que vous avez collée est au mur sous une lumière chaude, encadrée à une taille choisie par son propre rapport d'aspect. Rien de tout cela n'est stocké sur nos serveurs. C'était la promesse facile ; tout ce qui se trouve en amont, c'était le travail.
- 0
- octet stocké côté serveur
- 60
- œuvres par musée
- 8
- images conservées par œuvre
- 4
- chargements de texture simultanés
Les limites de fonctionnement du Reliquary. Le premier chiffre est le produit ; les autres sont ce qu'il faut pour rendre le premier tenable.
Collez un lien, obtenez une œuvre
Le client normalise ce que vous collez — un hôte nu devient https:// — puis l'envoie en POST à /api/museum/parse. La route valide l'URL avant de dépenser le moindre budget de limitation de débit : un collage mal formé ne devrait pas vous coûter l'une de vos huit analyses par minute. Ensuite, un délai de 10 secondes, un plafond de 3 Mo, et un fetch bien plus paranoïaque qu'il n'en a l'air.
Les spécifications portaient deux contraintes écrites en tête : aucune nouvelle dépendance npm, aucun asset binaire. Les deux ont façonné ce produit davantage que n'importe quelle fonctionnalité. La première signifie que le parseur est du pur travail de chaînes et d'expressions régulières — pas de DOM, pas de cheerio, rien qui soit headless. Surtout une chemise de crin ; mais aussi un chemin d'analyse dont je n'ai pas écrit moi-même la moindre surface d'attaque.
L'extraction fonctionne en cascade, et l'ordre est l'opinion : JSON-LD, puis OpenGraph et les Twitter cards, puis <title> et la balise meta description, puis des enrichisseurs par site. Le JSON-LD l'emporte partout où il existe, parce que c'est la déclaration structurée que la page fait sur elle-même plutôt qu'une déduction tirée de son mobilier. Le parcoureur lit chaque bloc application/ld+json, descend dans les membres @graph et les mainEntity imbriqués, et comprend SaleEvent parce que les catalogues d'enchères se décrivent ainsi. Le prix vient de offers, mais seulement quand le montant est positif et que la devise a la forme d'un code ISO-4217.
OpenGraph demandait une autre astuce. Ses métadonnées d'image sont positionnelles : og:image:width et og:image:height précisent la dernière og:image rencontrée, et og:image:secure_url remplace le src qui la précède. Un balayage clé-valeur naïf aplatit tout cela en une moyenne floue entre la plus grande et la plus petite image de la page. Le nôtre parcourt les balises dans l'ordre du document et conserve l'association, et c'est pour cela que la photo principale d'une annonce devient de façon fiable le tableau accroché au mur.
Puis l'hygiène, qui représente l'essentiel du code. Les URL d'images sont absolutisées par rapport à l'URL finale après redirections, les schémas non http(s) sont écartés, les SVG mis au rebut — ils ne survivent de toute façon jamais au proxy d'images en aval, donc en garder un ne ferait qu'accrocher un cadre qui échouera plus tard. Tout ce qui fait moins de 50 pixels déclarés y passe aussi, comme tout ce dont l'URL sent le mouchard.
// A 1×1 tracking GIF is technically an image. It is not an exhibit.
const TRACKING_HINT = /1x1|pixel|spacer|blank|beacon|tracker/i;Les survivantes sont dédupliquées et plafonnées à huit par œuvre. Une page qui ne donne ni titre ni image reçoit un honnête nothing_found plutôt qu'un cadre vide.
Et quand un fragment collé propose plus d'un couple (devise, montant) distinct, le prix est entièrement omis — deviner lequel des trois chiffres vous vouliez dire, c'est le petit mensonge assuré qui rend un outil peu fiable.
Protection SSRF : un serveur qui va chercher des URL est une arme chargée
Voici la forme inconfortable de cette fonctionnalité. Un inconnu tape une chaîne, et mon serveur émet une requête HTTP vers elle, depuis l'intérieur de mon infrastructure, avec la position réseau qu'a cette infrastructure. Le server-side request forgery classique — et le gros lot n'est pas http://localhost, c'est http://169.254.169.254, l'adresse link-local où les fournisseurs cloud garent les métadonnées d'instance et, historiquement, des identifiants. safe-fetch.ts refuse donc par couches :
- Schéma http/https uniquement ; userinfo rejeté d'emblée ; port obligatoirement 80 ou 443.
- Suffixes d'hôte —
localhost,*.localhost,*.local,*.internal. - IPv4 — 0/8, 10/8, 100.64/10 (NAT opérateur), 127/8, 169.254/16, 172.16/12, 192.0.0/24, 192.0.2/24, 192.168/16, 198.18/15, 198.51.100/24, 203.0.113/24, 224/4, 240/4. Une adresse qui refuse de s'analyser est rejetée, pas autorisée.
- IPv6 — index de zone retiré, puis
::,::1,fc00::/7,fe80::/10,ff00::/8, le bloc de documentation2001:db8et le préfixe NAT6464:ff9b. - DNS —
{ all: true, verbatim: true }; un résultat vide est rejeté, et chaque adresse renvoyée doit être publique. Une seule réponse privée empoisonne tout le nom d'hôte.
Les règles IPv6 imposent un ordre facile à prendre à l'envers. Une adresse comme ::ffff:169.254.169.254 a une queue en quatre octets : un validateur qui lit la queue en premier laisse passer un quadruplet d'apparence publique posé sur un préfixe privé. La classification du préfixe passe d'abord ; ce n'est qu'ensuite que les queues IPv4 mappées sont confiées au validateur IPv4.
C'est sur les redirections que meurent d'habitude les gardes, alors les nôtres sont manuelles : aucun suivi automatique, quatre sauts au maximum, chaque saut ré-analysé et repassé dans la validation complète. Une URL publique permissive qui renvoie un 302 vers du link-local est le plus vieux tour du genre. Un seul délai d'AbortController couvre toute la chaîne au lieu de chaque saut, pour que cinq sauts lents ne se multiplient pas en une minute. Le plafond d'octets est appliqué deux fois — contre content-length, puis par une boucle de lecture qui annule en cas de dépassement, parce que content-length est une affirmation faite par quelqu'un d'autre.
Ce contre quoi nous ne nous défendons pas est écrit dans le fichier lui aussi : le DNS rebinding. Entre notre résolution et la vraie connexion, un résolveur hostile peut changer sa réponse. Refermer cela suppose d'épingler l'IP validée, donc un dispatcher undici maison — une surface de dépendance que nous avons jugée pire que le risque résiduel sur un fetch en lecture seule renvoyant des métadonnées analysées. Je préfère nommer une limite plutôt que faire comme si elle n'existait pas. Un autre aveu tiré du même fichier : les requêtes partent avec un user agent Chrome 137, parce que plusieurs places de marché bloquent d'office les UA par défaut de fetch. C'est le mensonge le plus poli de la base de code, et il ne marche pas assez souvent.
Le proxy d'images qui ne transmet rien
Les CDN des places de marché et des salles des ventes n'envoient presque jamais d'Access-Control-Allow-Origin. Chargez une de leurs images directement dans une texture WebGL et le canvas se contamine ou le chargement échoue : le client passe donc toujours par /api/museum/image — un second point d'entrée qui va chercher des URL, paranoïaque à nouveau mais sur un autre registre, avec un filtre ^https?:// par-dessus le plafond zod, parce que data: et file: ne doivent jamais approcher d'un fetch.
Les types de contenu viennent d'une liste blanche : jpeg, png, webp, gif, avif, bmp. Le SVG en est délibérément absent, et pas par pureté matricielle — un SVG peut exécuter du script quand on y navigue directement, et un proxy qui vous sert du SVG en même origine est un service de livraison de XSS stocké avec quelques étapes en plus. Rien ne transite en flux : le corps est lu sous un plafond de 10 Mo et réémis dans un Buffer neuf avec des en-têtes que nous écrivons nous-mêmes, rien n'étant repris de l'amont sinon le type de contenu validé. Puis nosniff, default-src 'none'; sandbox, un CORP en same-origin — et la ligne de cache que j'avais d'abord sautée, max-age=3600, s-maxage=86400, stale-while-revalidate=604800. Sans elle, chaque nouvelle acquisition retéléchargeait toutes les textures à travers le proxy limité en débit.
Les sites qui se cachent des robots
Certaines des pages que les gens veulent le plus accrocher au mur appartiennent à des sites qui refusent de parler à un serveur. eBay renvoie 403 sur /itm/* même à curl portant des en-têtes Chrome identiques à l'octet près — un mur en périphérie, pas un bug de notre fetch, et j'ai passé un moment à me le prouver avant de l'accepter. LiveAuctioneers est plus sournois : les pages de catégorie servent du HTML complet, tandis qu'une page d'article renvoie une épreuve Incapsula de 960 octets en HTTP 200. Une vérification du code de statut appelle ça un succès et analyse un captcha jusque dans votre musée. Le renifleur a donc deux déclencheurs étroits : du HTML de moins de 8 Ko contenant un marqueur d'épreuve, ou un 403/429 venant d'un domaine dont nous avons confirmé qu'il exploite un mur.
const CHALLENGE = /Incapsula|DataDome|captcha-delivery|PerimeterX|px-captcha|_pxhd|cf_chl|challenge-platform/i;Associés à un corps de moins de 8 Ko, ces marqueurs signifient que nous avons récupéré un videur plutôt qu'une page. La détection renvoie 422 bot_walled — un détour signalé, pas une impasse.
Ce site se cache des robots — il a refoulé notre conservateur à la porte, mais votre navigateur, lui, entre sans problème.
C'est le texte que montre le client, et c'est aussi le design. Votre navigateur est déjà passé de l'autre côté du mur. Tout sélectionner, copier, coller, et une seconde route prend le relais. /api/museum/parse-html n'émet aucune requête réseau — l'URL que vous fournissez sert d'attribution et de base pour les liens relatifs, elle n'est jamais appelée. Le HTML collé n'est jamais stocké, jamais rendu, jamais renvoyé sous forme de balisage. Des plafonds de 4 Mo de caractères et de 16 Mo sur le fil sont appliqués par un lecteur plafonné avant que request.json() ne s'exécute, parce qu'analyser un corps hostile pour découvrir qu'il est trop gros, c'est ainsi qu'une seule requête vous met à terre.
Collez une page entière de catalogue et vous obtenez une aile entière — jusqu'à trente lots, trouvés par une heuristique structurelle qui ignore tout motif répétitif confiné à moins de 30 % du document, puisque c'est du mobilier de page et non la page. Les entrées hostiles reçoivent le même traitement que les réseaux hostiles : le parcoureur JSON-LD est une file parcourue par index, sans spread et avec un plafond de 20 000 nœuds, parce que shift() devient quadratique sur une charge énorme et que push(...hugeArray) fait déborder la pile.
Confidentialité localStorage : votre musée est à vous
Il y a une seule clé de stockage, vaynerov:reliquary:v1, et elle vit dans votre navigateur. Pas de compte, pas de synchronisation, pas de ligne dans une table à votre nom. Un musée des choses dont vous êtes discrètement obsédé ne regarde personne — et un produit sans données utilisateur n'a aucune donnée utilisateur à perdre.
Le chemin de lecture traite cette clé comme une entrée hostile, parce qu'un visiteur peut la modifier à la main. Chaque œuvre doit présenter un identifiant, une URL source, un domaine et un titre, sans quoi elle est écartée ; les listes d'images sont refiltrées de zéro ; les doublons se replient par identifiant et la collection est plafonnée à 60. Les imports par lot montent dans un seul objet d'état, si bien qu'un catalogue de trente lots déclenche une reconstruction de plan et une sauvegarde, plutôt que trente de chaque — et les identifiants en collision reçoivent de nouveaux UUID, parce qu'un visiteur peut sincèrement vouloir accrocher deux fois le même lot.
Les images sont conservées sous leur URL distante d'origine et enveloppées par le proxy uniquement au moment du rendu, si bien qu'un musée sauvegardé reste indépendant de la façon dont nous allons chercher les images ce mois-ci. Le coût, en toute honnêteté : effacez les données du site et le bâtiment se vide, et il ne vous suit pas jusqu'à votre téléphone. Je referais le même échange.
Une galerie 3D procédurale qui ne bouge jamais
La mise en plan est un module pur : des œuvres en entrée, un plan en sortie, en mètres. Pas de React, pas de three.js. Il produit un hall de 11 sur 10 mètres et une enfilade de galeries qui descend le long des −Z, et il tient un contrat au-dessus de tous les autres : ajouter l'œuvre N+1 ne déplace jamais les œuvres 1 à N. Un musée qui se réorganise quand on y ajoute quelque chose n'est pas un musée, c'est un fil d'actualité.
Tout ce qui pourrait dériver est dérivé à la place. La taille d'un cadre vient d'un hachage de l'identifiant de l'œuvre combiné au rapport d'aspect de l'image, de sorte qu'un rechargement reproduit à partir du localStorage un musée identique au centimètre près. La capacité d'une salle vaut 6 + ((i × 3 + 2) % 5), et chaque salle est bâtie à la longueur qu'exige sa capacité complète, si bien qu'une salle en train de se remplir ne se redimensionne jamais autour de vous. Les cadres alternent mur droit, mur gauche, à un pas de 3,4 m.
La lumière a été le point difficile. Il y a exactement un seul vrai projecteur dans le bâtiment — intensité 10, un #ffeccb chaud — et il s'interpole d'une salle à l'autre pendant que vous marchez, parce qu'un saut de lumière brutal au passage d'une porte se lit comme un à-coup de rendu plutôt que comme de l'architecture. Chaque spot par œuvre que vous croyez voir est un cône de dégradé additif simulé. En dessous : ambiante à 0,82, tone mapping ACES filmique à 1,22 d'exposition, et un brouillard exponentiel brun chaud pour que la salle suivante se lise comme une pièce sombre plutôt que comme un vide noir.
Aucun asset binaire signifie que chaque surface est dessinée sur un canvas à l'exécution — murs de plâtre réutilisés comme leur propre carte de relief, plinthes en noyer, pilastres de pierre, cadres dorés. Sur tout cet ambre, le turquoise n'apparaît que comme un scalpel : une feuille de portail à peine présente, et un liseré de plinthe dont l'intensité décroît aux deux extrémités, pour qu'il meure dans les angles au lieu d'entourer chaque plan d'un tube au néon.
Vous vous déplacez à 3,2 m/s, la vue au glisser et sans verrouillage du pointeur, parce que verrouiller le pointeur transforme le clic sur un tableau en combat. Cliquez sur l'un d'eux et la caméra recule de clamp(longSide × 1.3, 1.5, 3.4) mètres, décalée d'un demi-mètre vers la droite de la caméra pour que l'œuvre se lise hors du panneau de détails.
Nous limiter nous-mêmes, et autres leçons
Entrer dans une aile déclenchait autrefois plus de vingt requêtes d'images simultanées et fonçait droit dans la limite de débit de notre propre proxy — mon serveur disant à mon client de se calmer. Le correctif est un petit sémaphore : quatre chargements simultanés, espacés de 60 ms. La marche automatique vers une œuvre fraîchement accrochée passe par deux points de passage à chaque porte, pour que la caméra ne traverse jamais un mur en allant vous montrer quelque chose.
Le meilleur après-midi de travail a été le moins architectural. Les CDN des places de marché servent leurs vignettes par convention d'URL, et ces conventions sont devinables : LiveAuctioneers accepte ?height=1600&quality=85, les tailles s-l d'eBay montent jusqu'à s-l1600, le il_NNNxNNN d'Etsy devient il_fullxfull. Même image, mesuré à la main : 21 Ko à l'entrée, 412 Ko à la sortie. C'est toute la différence entre une bouillie au mur et une chose devant laquelle on a envie de rester.
Le Reliquary est sorti dans la même livraison que le moteur 3D partagé derrière le Labo et que l'extension du site à six langues — l'interface du musée compte à elle seule 87 clés, et les six langues les portent toutes. Une chaîne reste en anglais, exprès : les lettres au-dessus de l'arche du hall affichent VAYNEROV PROJECT RELIQUARY dans chaque langue, comme le nom d'un bâtiment, qui ne change pas quand vous changez.
Ce à quoi je ne m'attendais pas, c'est à quel point les contraintes ont fait le travail de conception. Aucune dépendance a imposé un parseur que je comprends entièrement. Aucun asset binaire a imposé des matériaux procéduraux, et c'est pour cela qu'un musée entier se télécharge pour le poids d'une page web. Je voulais regarder correctement un sextant de laiton et je me suis retrouvé avec un bâtiment.