Ein Museum für alles, was eine URL hat
Ich wollte durch meine Auktions-Merkliste laufen wie durch eine Galerie, statt sie wie eine Tabelle zu scrollen. Dafür brauchte es einen Parser ohne Abhängigkeiten, einen SSRF-Schutz mit dokumentiertem Restrisiko, einen Bildproxy, der nichts weiterreicht, und ein Museum, das nie einen Server anfasst.
Auf dieser Seite
- Link einfügen, Exponat bekommen
- SSRF-Schutz: ein URL-abrufender Server ist eine geladene Waffe
- Der Bildproxy, der nichts weiterreicht
- Die Seiten, die sich vor Robotern verstecken
- localStorage-Privatsphäre: Ihr Museum gehört Ihnen
- Eine prozedurale 3D-Galerie, die sich nie verschiebt
- Uns selbst ausbremsen, und andere Lektionen
Eine Merkliste ist die würdeloseste Art, ein schönes Objekt anzusehen. Dreihundert Pixel breit, von jemandem in Eile quadratisch beschnitten, neben einer Countdown-Uhr gestapelt. Ich habe mehr Abende, als mir lieb ist, damit verbracht, durch dieses Schlüsselloch auf ein Messinginstrument zu blinzeln. Vor vier Tagen haben wir das Gegenteil ausgeliefert: Vaynerov Project Reliquary, ein Museum, das sich um alles herum aufbaut, wozu man ihm einen Link geben kann.
Der Pitch ist ein Satz – ein Museum für alles, was eine URL hat – und das Produkt ist dieser Satz, wörtlich genommen. Sie fügen einen Link ein. Sekunden später stehen Sie in einer Lobby, und das Eingefügte hängt unter warmem Licht an der Wand, gerahmt in einer Größe, die sein eigenes Seitenverhältnis bestimmt hat. Nichts davon liegt auf unseren Servern. Das war das leichte Versprechen; alles davor war die Arbeit.
- 0
- serverseitig gespeicherte Bytes
- 60
- Exponate pro Museum
- 8
- Bilder pro Exponat
- 4
- gleichzeitige Texturladungen
Die Betriebsgrenzen des Reliquary. Die erste Zahl ist das Produkt; die übrigen sind das, was nötig ist, damit die erste überlebt.
Link einfügen, Exponat bekommen
Der Client normalisiert, was Sie einfügen – ein nackter Host bekommt sein https:// – und schickt es per POST an /api/museum/parse. Die Route validiert die URL, bevor sie Rate-Limit-Budget ausgibt: Ein fehlerhafter Einfügeversuch soll keinen Ihrer acht Parses pro Minute kosten. Dann eine Frist von 10 Sekunden, eine Obergrenze von 3 MB und ein Fetch, der weit paranoider ist, als er aussieht.
Ganz oben in der Spezifikation standen zwei Beschränkungen: keine neuen npm-Abhängigkeiten, keine Binärassets. Beide haben dieses Produkt stärker geformt als jedes Feature. Die erste bedeutet, dass der Parser reine String- und Regex-Arbeit ist – kein DOM, kein cheerio, nichts Headless. Größtenteils Selbstkasteiung; zugleich ein Parse-Pfad ohne Angriffsfläche, die ich nicht selbst geschrieben habe.
Die Extraktion läuft als Kaskade, und die Reihenfolge ist die Meinung: JSON-LD, dann OpenGraph und Twitter Cards, dann <title> und das Description-Meta, dann seitenspezifische Anreicherer. JSON-LD gewinnt überall dort, wo es existiert, denn es ist die strukturierte Selbstaussage der Seite und nicht etwas, das aus ihrem Mobiliar erschlossen wurde. Der Walker liest jeden application/ld+json-Block, steigt in @graph-Mitglieder und verschachtelte mainEntity hinab und versteht SaleEvent, weil Auktionskataloge sich so beschreiben. Der Preis kommt aus offers, aber nur, wenn der Betrag positiv und die Währung wie ein ISO-4217-Code geformt ist.
OpenGraph brauchte einen anderen Kniff. Seine Bild-Metadaten sind positionsabhängig: og:image:width und og:image:height präzisieren das zuletzt genannte og:image, und og:image:secure_url ersetzt die src davor. Ein naiver Schlüssel-Wert-Durchlauf plättet das zu einem verwaschenen Mittel aus dem größten und dem kleinsten Bild der Seite. Unserer geht die Tags in Dokumentreihenfolge durch und behält die Zuordnung, und deshalb wird aus dem Hauptfoto eines Angebots verlässlich das Bild an der Wand.
Dann Hygiene, und das ist der größte Teil des Codes. Bild-URLs werden gegen die finale URL nach allen Weiterleitungen absolut gemacht, Nicht-http(s)-Schemata fliegen raus, SVGs ebenfalls – sie überstehen den nachgelagerten Bildproxy ohnehin nicht, eines zu behalten würde also nur einen Rahmen aufhängen, der später scheitert. Alles unter 50 deklarierten Pixeln geht auch, ebenso alles, dessen URL nach einem Zählpixel riecht.
// A 1×1 tracking GIF is technically an image. It is not an exhibit.
const TRACKING_HINT = /1x1|pixel|spacer|blank|beacon|tracker/i;Die Überlebenden werden dedupliziert und bei acht pro Exponat gedeckelt. Eine Seite ohne Titel oder ohne Bild bekommt ein ehrliches nothing_found statt eines leeren Rahmens.
Und wenn ein eingefügtes Fragment mehr als ein eindeutiges Paar aus Währung und Betrag hergibt, wird der Preis ganz weggelassen – zu raten, welche von drei Zahlen gemeint war, ist die kleine selbstsichere Lüge, die ein Werkzeug unglaubwürdig macht.
SSRF-Schutz: ein URL-abrufender Server ist eine geladene Waffe
Hier ist die unangenehme Form dieses Features. Ein Fremder tippt eine Zeichenkette, und mein Server stellt daraufhin eine HTTP-Anfrage, aus meiner Infrastruktur heraus, mit welcher Netzwerkposition diese Infrastruktur eben hat. Klassische Server-Side Request Forgery – und der Gewinn ist nicht http://localhost, sondern http://169.254.169.254, die Link-local-Adresse, an der Cloud-Anbieter ihre Instanz-Metadaten und historisch auch Zugangsdaten parken. Also verweigert safe-fetch.ts in Schichten:
- Schema nur http/https; Userinfo rundweg abgelehnt; Port muss 80 oder 443 sein.
- Host-Endungen –
localhost,*.localhost,*.local,*.internal. - IPv4 – 0/8, 10/8, 100.64/10 (Carrier NAT), 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. Eine Adresse, die sich nicht parsen lässt, wird abgelehnt, nicht durchgelassen.
- IPv6 – Zone-Index abgeschnitten, dann
::,::1,fc00::/7,fe80::/10,ff00::/8, der Dokumentationsblock2001:db8und das NAT64-Präfix64:ff9b. - DNS –
{ all: true, verbatim: true }; ein leeres Ergebnis wird abgelehnt, und jede zurückgegebene Adresse muss öffentlich sein. Eine private Antwort vergiftet den ganzen Hostnamen.
Die IPv6-Regeln tragen eine Reihenfolgeanforderung, die man leicht falsch herum baut. Eine Adresse wie ::ffff:169.254.169.254 hat ein punktiertes Vierergruppen-Ende, ein Validator, der dieses Ende zuerst liest, winkt also eine öffentlich aussehende Vierergruppe auf einem privaten Präfix durch. Die Präfix-Klassifikation kommt zuerst; erst danach werden IPv4-gemappte Enden an den IPv4-Validator gegeben.
An Weiterleitungen sterben Schutzmechanismen üblicherweise, also sind unsere manuell: kein automatisches Folgen, höchstens vier Hops, jeder Hop neu geparst und komplett neu validiert. Eine großzügige öffentliche URL, die per 302 auf Link-local zeigt, ist der älteste Trick dieses Genres. Eine einzige AbortController-Frist deckt die ganze Kette statt jeden Hop, damit fünf langsame Hops sich nicht zu einer Minute multiplizieren. Die Byte-Obergrenze wird zweimal durchgesetzt – gegen content-length, danach durch eine Leseschleife, die bei Überschreitung abbricht, denn content-length ist eine Behauptung von jemand anderem.
Wogegen wir uns nicht wehren, steht ebenfalls in der Datei: DNS-Rebinding. Zwischen unserem Lookup und der eigentlichen Verbindung kann ein feindlicher Resolver seine Antwort ändern. Das zu schließen hieße, an die validierte IP zu binden, also einen eigenen undici-Dispatcher – eine Abhängigkeitsfläche, die wir schlimmer fanden als das Restrisiko eines lesenden Abrufs, der geparste Metadaten zurückgibt. Ich benenne eine Grenze lieber, als so zu tun, als gäbe es sie nicht. Noch ein Geständnis aus derselben Datei: Anfragen gehen mit einem Chrome-137-User-Agent raus, weil mehrere Marktplätze Standard-Fetch-UAs hart blockieren. Es ist die höflichste Lüge im Code, und sie funktioniert nicht oft genug.
Der Bildproxy, der nichts weiterreicht
Auktions- und Marktplatz-CDNs senden so gut wie nie Access-Control-Allow-Origin. Lädt man eines ihrer Bilder direkt in eine WebGL-Textur, wird das Canvas kontaminiert oder der Ladevorgang scheitert, also geht der Client immer über /api/museum/image – ein zweiter URL-abrufender Endpunkt, in anderer Tonart erneut paranoid, mit einem ^https?://-Gatter über der zod-Obergrenze, denn data: und file: dürfen einem Fetch nie nahekommen.
Content-Types kommen aus einer Allowlist: jpeg, png, webp, gif, avif, bmp. SVG fehlt mit Absicht, und nicht aus Rasterreinheit – ein SVG kann Skript ausführen, wenn man es direkt aufruft, und ein Proxy, der Ihnen SVG von der eigenen Origin ausliefert, ist ein Auslieferungsdienst für gespeichertes XSS mit Zwischenschritten. Nichts wird durchgestreamt: Der Body wird unter einer 10-MB-Grenze gelesen und als frischer Buffer neu ausgegeben, mit Headern, die wir selbst schreiben, nichts von oben durchgereicht außer dem validierten Content-Type. Dann nosniff, default-src 'none'; sandbox, CORP auf same-origin – und die Caching-Zeile, die ich anfangs übersprungen hatte: max-age=3600, s-maxage=86400, stale-while-revalidate=604800. Ohne sie lud jede erneute Aufnahme jede Textur erneut durch den ratenbegrenzten Proxy.
Die Seiten, die sich vor Robotern verstecken
Manche der Seiten, die Menschen am liebsten an einer Wand hätten, gehören Anbietern, die mit keinem Server reden. eBay antwortet auf /itm/* selbst gegenüber curl mit bytegleichen Chrome-Headern mit 403 – eine Edge-Mauer, kein Fehler in unserem Fetch, und ich habe eine Weile gebraucht, um mir das selbst zu beweisen. LiveAuctioneers ist hinterhältiger: Kategorieseiten liefern vollständiges HTML, während eine Artikelseite eine 960 Byte große Incapsula-Prüfung zurückgibt, und zwar mit HTTP 200. Eine Statuscode-Prüfung nennt das Erfolg und parst ein Captcha in Ihr Museum. Der Schnüffler hat deshalb zwei enge Auslöser: HTML unter 8 KB, das einen Challenge-Marker enthält, oder ein 403/429 von einer Domain, bei der wir eine Mauer bestätigt haben.
const CHALLENGE = /Incapsula|DataDome|captcha-delivery|PerimeterX|px-captcha|_pxhd|cf_chl|challenge-platform/i;Zusammen mit einem Body unter 8 KB heißen diese Marker: Wir haben einen Türsteher abgerufen, keine Seite. Die Erkennung liefert 422 bot_walled – ein ausgeschilderter Umweg, keine Sackgasse.
Diese Seite versteckt sich vor Robotern – unseren Kurator hat sie an der Tür abgewiesen, aber Ihr Browser geht einfach hinein.
Das ist der Text, den der Client zeigt, und es ist zugleich der Entwurf. Ihr Browser ist bereits hinter der Mauer. Alles markieren, kopieren, einfügen – und eine zweite Route übernimmt. /api/museum/parse-html stellt null Netzwerkanfragen – die URL, die Sie angeben, dient der Zuordnung und als Basis für relative Links, abgerufen wird sie nie. Das eingefügte HTML wird nie gespeichert, nie gerendert, nie als Markup zurückgespiegelt. Grenzen von 4 MB Zeichen und 16 MB auf der Leitung werden von einem gedeckelten Reader durchgesetzt, bevor request.json() läuft, denn einen feindlichen Body erst zu parsen, um festzustellen, dass er zu groß ist, ist der Weg, wie eine einzige Anfrage einen umwirft.
Fügen Sie eine ganze Katalogseite ein, bekommen Sie einen ganzen Flügel – bis zu dreißig Lose, gefunden durch eine strukturelle Heuristik, die jedes sich wiederholende Muster ignoriert, das auf unter 30 % des Dokuments beschränkt bleibt, weil das Seitenmobiliar ist und nicht die Seite. Feindliche Eingaben werden behandelt wie feindliche Netze: Der JSON-LD-Walker ist eine indexgeführte Queue ohne Spreads und mit einer Decke von 20,000 Knoten, denn shift() wird bei riesigen Payloads quadratisch und push(...hugeArray) sprengt den Stack.
localStorage-Privatsphäre: Ihr Museum gehört Ihnen
Es gibt einen Storage-Schlüssel, vaynerov:reliquary:v1, und er liegt in Ihrem Browser. Kein Account, kein Sync, keine Zeile in einer Tabelle mit Ihrem Namen. Ein Museum der Dinge, von denen man still besessen ist, geht niemanden etwas an – und ein Produkt ohne Nutzerdaten hat keine Nutzerdaten zu verlieren.
Der Lesepfad behandelt diesen Schlüssel als feindliche Eingabe, weil ein Besucher ihn von Hand editieren kann. Jedes Exponat muss ID, Quell-URL, Domain und Titel vorweisen, sonst fliegt es raus; Bildlisten werden von Grund auf neu gefiltert; Duplikate fallen über die ID zusammen, und die Sammlung deckelt bei 60. Batch-Importe mounten in einem einzigen State-Objekt, sodass ein Katalog mit dreißig Losen einen Grundriss-Neubau und einen Speichervorgang auslöst statt dreißig von beidem – und kollidierende IDs bekommen frische UUIDs, denn jemand will dasselbe Los vielleicht ganz bewusst zweimal aufhängen.
Bilder werden als ihre ursprünglichen entfernten URLs gespeichert und erst beim Rendern in den Proxy gewickelt, sodass ein gespeichertes Museum unabhängig davon bleibt, wie wir diesen Monat Bilder abrufen. Der ehrliche Preis: Löschen Sie Ihre Websitedaten, steht das Gebäude leer, und auf Ihr Telefon folgt es nicht. Ich würde denselben Tausch wieder machen.
Eine prozedurale 3D-Galerie, die sich nie verschiebt
Das Layout ist ein reines Modul: Exponate rein, Grundriss raus, in Metern. Kein React, kein three.js. Es erzeugt eine Lobby von 11 mal 10 Metern und eine Kette von Galerien, die entlang −Z marschieren, und es hält einen Vertrag über alle anderen: Das Anhängen von Exponat N+1 verschiebt die Exponate 1 bis N nie. Ein Museum, das sich beim Hinzufügen umsortiert, ist kein Museum, sondern ein Feed.
Alles, was driften könnte, wird stattdessen abgeleitet. Die Rahmengröße kommt aus einem Hash der Exponat-ID plus dem Seitenverhältnis des Bildes, sodass ein Neuladen aus localStorage dasselbe Museum auf den Zentimeter genau reproduziert. Die Raumkapazität ist 6 + ((i × 3 + 2) % 5), und jeder Raum wird in der Länge gebaut, die seine volle Kapazität braucht, damit ein sich füllender Raum nicht um einen herum wächst. Rahmen wechseln zwischen rechter und linker Wand, im Abstand von 3.4 m.
Das Licht war der schwere Teil. Es gibt genau einen echten Scheinwerfer im Gebäude – Intensität 10, ein warmes #ffeccb – und er lerpt beim Gehen von Raum zu Raum, denn ein harter Lichtsprung beim Durchschreiten einer Tür liest sich als Renderaussetzer und nicht als Architektur. Jeder Spot pro Kunstwerk, den Sie zu sehen glauben, ist ein gefälschter additiver Gradientenkegel. Darunter: Ambient bei 0.82, ACES-Filmic-Tonemapping bei 1.22 Belichtung und ein warmbrauner exponentieller Nebel, damit der nächste Raum als dämmriger Saal liest statt als schwarze Leere.
Keine Binärassets heißt, dass jede Oberfläche zur Laufzeit auf ein Canvas gezeichnet wird – Putzwände, die zugleich ihre eigene Bump-Map sind, Nussbaumsockel, steinerne Türpilaster, vergoldete Rahmen. Gegen all das Bernstein tritt Türkis nur als Skalpell auf: eine kaum vorhandene Portalfläche und eine Sockellinie mit Abfall an beiden Enden, damit sie an den Ecken ausläuft, statt jede Aufnahme mit Neonrohr einzufassen.
Man bewegt sich mit 3.2 m/s, umschaut per Ziehen und ohne Pointer Lock, denn ein gefangener Zeiger macht aus dem Anklicken eines Bildes einen Kampf. Klickt man eines an, tritt die Kamera um clamp(longSide × 1.3, 1.5, 3.4) Meter zurück, einen halben Meter nach kamerarechts versetzt, damit das Werk frei neben dem Detailpanel steht.
Uns selbst ausbremsen, und andere Lektionen
Das Betreten eines Flügels feuerte früher zwanzig und mehr gleichzeitige Bildanfragen ab und lief direkt in das Rate-Limit unseres eigenen Proxys – mein Server sagte meinem Client, er solle sich beruhigen. Die Lösung ist eine kleine Semaphore: vier gleichzeitige Ladevorgänge, 60 ms auseinander. Das automatische Hinlaufen zu einem frisch aufgehängten Exponat führt über zwei Wegpunkte pro Türdurchgang, damit die Kamera auf dem Weg nie durch eine Wand fliegt.
Der beste Nachmittag war der am wenigsten architektonische. Marktplatz-CDNs liefern Vorschaubilder nach URL-Konvention aus, und die Konventionen sind erratbar: LiveAuctioneers nimmt ?height=1600&quality=85, eBays s-l-Größen gehen bis s-l1600, Etsys il_NNNxNNN wird zu il_fullxfull. Dasselbe Asset, von Hand gemessen: 21 KB rein, 412 KB raus. Das ist der Unterschied zwischen einem Schmierfleck an der Wand und einem Ding, vor dem zu stehen sich lohnt.
Das Reliquary kam im selben Release wie die gemeinsame 3D-Engine hinter dem Lab und die Erweiterung der Website auf sechs Sprachen – die Oberfläche des Museums selbst umfasst 87 Schlüssel, und alle sechs Sprachen tragen jeden einzelnen davon. Ein String bleibt absichtlich englisch: Über dem Lobbybogen steht in jeder Sprache VAYNEROV PROJECT RELIQUARY, so wie sich der Name eines Gebäudes nicht ändert, wenn Sie sich ändern.
Womit ich nicht gerechnet hatte, ist, wie viel Designarbeit die Beschränkungen erledigt haben. Keine Abhängigkeiten erzwangen einen Parser, den ich vollständig verstehe. Keine Binärassets erzwangen prozedurale Materialien, weshalb ein ganzes Museum im Gewicht einer Webseite lädt. Ich wollte einen Messing-Sextanten anständig ansehen und bin bei einem Gebäude gelandet.