Музей для всего, у чего есть ссылка
Мне хотелось ходить по своему списку отслеживаемых лотов как по галерее, а не листать его как таблицу. Для этого понадобились парсер без зависимостей, SSRF-защита с задокументированным остаточным риском, прокси изображений, который ничего не пересылает, и музей, который вообще не трогает сервер.
На этой странице
- Вставьте ссылку — получите экспонат
- Защита от SSRF: сервер, ходящий по чужим ссылкам, — заряженное ружьё
- Прокси изображений, который ничего не пересылает
- Сайты, которые прячутся от роботов
- Приватность в localStorage: ваш музей — ваш
- Процедурная 3D-галерея, которая никогда не двигается
- Как мы ограничивали сами себя, и другие уроки
Список отслеживаемых лотов — наименее достойный способ смотреть на красивый предмет. Триста пикселей в ширину, обрезано в квадрат кем-то торопливым, рядом таймер обратного отсчёта. Я провёл больше вечеров, чем готов признать, щурясь на латунный прибор через эту замочную скважину. Четыре дня назад мы выпустили обратное: Vaynerov Project Reliquary — музей, который строит себя вокруг всего, на что вы можете дать ссылку.
Питч — одно предложение, музей для всего, у чего есть ссылка, — и продукт есть это предложение, понятое буквально. Вы вставляете ссылку. Через несколько секунд вы в вестибюле, а вставленное висит на стене под тёплым светом, в раме того размера, который выбрало его собственное соотношение сторон. Ничего из этого не хранится на наших серверах. Это было лёгкое обещание; вся работа лежала выше по течению.
- 0
- байт хранится на сервере
- 60
- экспонатов на музей
- 8
- изображений на экспонат
- 4
- одновременные загрузки текстур
Рабочие пределы Reliquary. Первое число — это продукт; остальные — то, что нужно, чтобы первое оказалось выполнимым.
Вставьте ссылку — получите экспонат
Клиент нормализует вставленное — голый хост становится https:// — и отправляет POST на /api/museum/parse. Маршрут проверяет URL до того, как потратит бюджет рейт-лимита: кривая вставка не должна стоить вам одного из восьми разборов в минуту. Дальше — дедлайн в 10 секунд, потолок 3 МБ и запрос куда более параноидальный, чем кажется.
В спецификации сверху были записаны два ограничения: никаких новых npm-зависимостей, никаких бинарных ассетов. Оба повлияли на этот продукт сильнее любой функции. Первое означает, что парсер — чистая работа со строками и регулярками: ни DOM, ни cheerio, ни headless-чего-либо. По большей части власяница; но заодно путь разбора без единой поверхности атаки, написанной не мной.
Извлечение работает каскадом, и порядок здесь — это мнение: JSON-LD, затем OpenGraph и карточки Twitter, затем <title> и мета-описание, затем обогатители под конкретные сайты. JSON-LD побеждает везде, где он есть, потому что это собственное структурированное заявление страницы о себе, а не вывод из её обстановки. Обходчик читает каждый блок application/ld+json, спускается в элементы @graph и вложенные mainEntity и понимает SaleEvent, потому что аукционные каталоги описывают себя именно так. Цена берётся из offers, но только если сумма положительна, а валюта по форме похожа на код ISO-4217.
С OpenGraph понадобился другой приём. Его метаданные об изображениях позиционны: og:image:width и og:image:height уточняют тот og:image, который шёл последним, а og:image:secure_url заменяет src перед собой. Наивный сбор «ключ — значение» схлопывает это в размытое среднее между самой большой и самой маленькой картинкой на странице. Наш обходит теги в порядке документа и сохраняет связь — поэтому главное фото объявления надёжно становится картиной на стене.
Дальше гигиена, а это большая часть кода. Адреса изображений приводятся к абсолютным относительно финального URL после редиректов, схемы не http(s) отбрасываются, SVG в корзину — они всё равно не переживут прокси изображений ниже по течению, так что оставить такой значило бы повесить раму, которая упадёт позже. Всё меньше 50 заявленных пикселей уходит туда же, как и всё, чей адрес пахнет трекером.
// A 1×1 tracking GIF is technically an image. It is not an exhibit.
const TRACKING_HINT = /1x1|pixel|spacer|blank|beacon|tracker/i;Выжившие дедуплицируются и обрезаются до восьми на экспонат. Страница, не давшая ни заголовка, ни изображения, получает честный nothing_found, а не пустую раму.
А когда вставленный фрагмент предлагает больше одной различной пары (валюта, сумма), цена опускается целиком — угадывать, какое из трёх чисел вы имели в виду, и есть та маленькая уверенная ложь, из-за которой инструменту перестают доверять.
Защита от SSRF: сервер, ходящий по чужим ссылкам, — заряженное ружьё
Вот неудобная форма этой функции. Незнакомый человек набирает строку, а мой сервер делает по ней HTTP-запрос изнутри моей инфраструктуры, с тем сетевым положением, которое у неё есть. Классическая подделка запроса на стороне сервера — и добыча здесь не http://localhost, а http://169.254.169.254, тот самый link-local адрес, где облачные провайдеры держат метаданные инстанса, а исторически и учётные данные. Поэтому safe-fetch.ts отказывает слоями:
- Схема — только http/https; userinfo — отказ сразу; порт — только 80 или 443.
- Суффиксы хоста —
localhost,*.localhost,*.local,*.internal. - IPv4 — 0/8, 10/8, 100.64/10 (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. Адрес, который не разбирается, отвергается, а не пропускается.
- IPv6 — индекс зоны срезается, дальше
::,::1,fc00::/7,fe80::/10,ff00::/8, документационный блок2001:db8и префикс NAT6464:ff9b. - DNS —
{ all: true, verbatim: true }; пустой результат отвергается, и каждый возвращённый адрес обязан быть публичным. Один приватный ответ отравляет всё имя хоста.
У правил IPv6 есть требование к порядку, которое легко перевернуть. У адреса вроде ::ffff:169.254.169.254 есть хвост в точечной записи, поэтому валидатор, читающий хвост первым, пропустит публично выглядящую четвёрку на приватном префиксе. Сначала классифицируется префикс, и только потом IPv4-mapped хвосты отдаются валидатору IPv4.
Редиректы — место, где защиты обычно и умирают, поэтому у нас они ручные: никакого автоследования, максимум четыре перехода, каждый переход заново разбирается и заново проходит полную проверку. Разрешительный публичный URL, который отдаёт 302 на link-local, — старейший трюк жанра. Один дедлайн AbortController покрывает всю цепочку, а не каждый переход, так что пять медленных переходов не умножатся в минуту. Потолок по байтам проверяется дважды — сначала по content-length, затем циклом чтения, который отменяет всё при переполнении, потому что content-length — это заявление постороннего.
От чего мы не защищаемся, тоже написано в этом файле: DNS rebinding. Между нашим резолвом и настоящим соединением враждебный резолвер может сменить ответ. Закрыть это — значит прибиться к проверенному IP, а значит завести собственный диспетчер undici: поверхность зависимостей, которую мы сочли хуже остаточного риска на запросе только для чтения, возвращающем разобранные метаданные. Я лучше назову ограничение, чем сделаю вид, что его нет. Ещё одно признание из того же файла: запросы уходят с user-agent от Chrome 137, потому что несколько маркетплейсов наглухо блокируют дефолтные UA. Это самая вежливая ложь в кодовой базе, и работает она недостаточно часто.
Прокси изображений, который ничего не пересылает
CDN аукционов и маркетплейсов почти никогда не отдают Access-Control-Allow-Origin. Загрузите их картинку прямо в WebGL-текстуру — и canvas испортится или загрузка упадёт, поэтому клиент всегда ходит через /api/museum/image — второй эндпоинт, который ходит по URL, снова параноидальный, но в другой тональности, с проверкой ^https?:// поверх ограничения zod, потому что data: и file: не должны приближаться к fetch.
Типы контента берутся из allowlist: jpeg, png, webp, gif, avif, bmp. SVG отсутствует намеренно, и дело не в растровой чистоте: SVG может исполнить скрипт, если открыть его напрямую, а прокси, отдающий вам SVG с того же origin, — это служба доставки хранимого XSS с лишними шагами. Ничего не стримится: тело читается под потолком 10 МБ и переиздаётся свежим Buffer с заголовками, которые пишем мы сами; сверху не пересылается ничего, кроме проверенного типа контента. Затем nosniff, default-src 'none'; sandbox, CORP same-origin — и строка кеширования, которую я поначалу пропустил: max-age=3600, s-maxage=86400, stale-while-revalidate=604800. Без неё каждое повторное открытие заново скачивало все текстуры через прокси с рейт-лимитом.
Сайты, которые прячутся от роботов
Некоторые страницы, которые люди больше всего хотят повесить на стену, принадлежат сайтам, не желающим разговаривать с сервером. eBay возвращает 403 на /itm/* даже curl с побайтно совпадающими заголовками Chrome — это стена на периферии, а не баг в нашем запросе, и я потратил некоторое время, доказывая это себе, прежде чем принять. LiveAuctioneers хитрее: страницы категорий отдают полный HTML, а страница лота возвращает 960-байтный челлендж Incapsula со статусом HTTP 200. Проверка по коду статуса засчитает это за успех и разберёт капчу прямо в ваш музей. Поэтому у нюха два узких триггера: HTML меньше 8 КБ, содержащий маркер челленджа, либо 403/429 с домена, про который мы уже подтвердили, что у него стена.
const CHALLENGE = /Incapsula|DataDome|captcha-delivery|PerimeterX|px-captcha|_pxhd|cf_chl|challenge-platform/i;В паре с телом меньше 8 КБ эти маркеры означают, что мы скачали вышибалу, а не страницу. Детект возвращает 422 bot_walled — указатель на объезд, а не тупик.
Этот сайт прячется от роботов — нашего куратора он развернул у дверей, но ваш браузер заходит беспрепятственно.
Это и есть текст, который показывает клиент, и одновременно замысел. Ваш браузер уже прошёл стену. Выделить всё, скопировать, вставить — и дальше работает второй маршрут. /api/museum/parse-html не делает ни одного сетевого запроса: адрес, который вы указываете, нужен как атрибуция и база для относительных ссылок, но за ним никто не ходит. Вставленный HTML не хранится, не рендерится и не возвращается обратно как разметка. Ограничения в 4 МБ символов и 16 МБ на проводе проверяются ограниченным читателем до того, как отработает request.json(), потому что разобрать враждебное тело и лишь потом узнать, что оно слишком велико, — ровно тот способ упасть с одного запроса.
Вставьте страницу каталога целиком — получите целое крыло: до тридцати лотов, найденных структурной эвристикой, которая игнорирует любой повторяющийся паттерн, занимающий меньше 30% документа, поскольку это обстановка страницы, а не сама страница. С враждебным вводом обращаются так же, как с враждебными сетями: обходчик JSON-LD — это очередь с обходом по индексу, без спредов и с потолком в 20,000 узлов, потому что shift() на огромной нагрузке становится квадратичным, а push(...hugeArray) переполняет стек.
Приватность в localStorage: ваш музей — ваш
Ключ хранения ровно один, vaynerov:reliquary:v1, и живёт он в вашем браузере. Ни аккаунта, ни синхронизации, ни строки в таблице с вашим именем. Музей вещей, которыми вы тихо одержимы, не касается никого — а продукту без пользовательских данных нечего терять.
Путь чтения относится к этому ключу как к враждебному вводу, потому что посетитель может отредактировать его руками. Каждый экспонат обязан предъявить id, исходный URL, домен и заголовок, иначе он отбрасывается; списки изображений фильтруются заново; дубликаты схлопываются по id, а коллекция обрезается на 60. Пакетный импорт монтируется одним объектом состояния, поэтому каталог из тридцати лотов вызывает одну перестройку планировки и одно сохранение, а не тридцать того и другого, — а совпавшие id получают свежие UUID, потому что посетитель может искренне хотеть повесить один и тот же лот дважды.
Изображения хранятся своими исходными удалёнными адресами и оборачиваются в прокси только при отрисовке, поэтому сохранённый музей не зависит от того, как мы получаем картинки в этом месяце. Честная цена: очистите данные сайта — и здание опустеет, и оно не поедет за вами в телефон. Я сделал бы тот же выбор снова.
Процедурная 3D-галерея, которая никогда не двигается
Раскладка — чистый модуль: на вход экспонаты, на выход планировка, в метрах. Ни React, ни three.js. Он выдаёт вестибюль 11 на 10 метров и цепочку залов, уходящую по −Z, и держит один контракт превыше всех остальных: добавление экспоната N+1 никогда не сдвигает экспонаты с 1 по N. Музей, который перестраивается, когда вы в него что-то добавляете, — не музей, а лента.
Всё, что могло бы поплыть, вместо этого вычисляется. Размер рамы берётся из хеша id экспоната и собственного соотношения сторон изображения, поэтому перезагрузка воспроизводит из localStorage тот же самый музей с точностью до сантиметра. Вместимость зала — 6 + ((i × 3 + 2) % 5), и каждый зал строится сразу той длины, которая нужна его полной вместимости, так что заполняющийся зал не меняет размер вокруг вас. Рамы чередуются: правая стена, левая стена, с шагом 3.4 м.
Свет оказался самой трудной частью. В здании ровно один настоящий прожектор — интенсивность 10, тёплый #ffeccb — и он плавно перетекает из зала в зал, пока вы идёте, потому что жёсткий скачок света на пересечении дверного проёма читается как рывок рендера, а не как архитектура. Каждый персональный софит над работой, который вам кажется, — подделанный аддитивный градиентный конус. Под всем этим: ambient 0.82, тональная компрессия ACES filmic с экспозицией 1.22 и тёплый коричневый экспоненциальный туман, чтобы следующий зал читался как сумрачная анфилада, а не как чёрная пустота.
«Никаких бинарных ассетов» означает, что каждая поверхность рисуется на canvas в рантайме: оштукатуренные стены, переиспользованные как собственная карта рельефа, ореховый плинтус, каменные пилястры откосов, золочёные рамы. На фоне всего этого янтаря бирюза появляется только как скальпель: едва заметное полотно портала и линия плинтуса со спадом на обоих концах, чтобы она умирала в углах, а не обводила каждый кадр неоновой трубкой.
Вы двигаетесь со скоростью 3.2 м/с, обзор — мышью с зажатой кнопкой и без захвата указателя, потому что захват указателя превращает клик по картине в борьбу. Кликните по работе — и камера отойдёт на clamp(longSide × 1.3, 1.5, 3.4) метра, сместившись на полметра вправо, чтобы работа читалась в стороне от панели с деталями.
Как мы ограничивали сами себя, и другие уроки
Вход в крыло раньше выстреливал двадцать с лишним одновременных запросов за картинками и упирался в рейт-лимит нашего же прокси — мой сервер просил мой клиент успокоиться. Лечение — маленький семафор: четыре одновременные загрузки с интервалом 60 мс. Автопроход к только что повешенному экспонату идёт через две путевые точки на каждый дверной проём, чтобы камера по дороге не пролетала сквозь стену.
Лучшая работа одного вечера оказалась наименее архитектурной. CDN маркетплейсов отдают превью по соглашению об адресах, и соглашения угадываются: LiveAuctioneers принимает ?height=1600&quality=85, размеры s-l у eBay доходят до s-l1600, il_NNNxNNN у Etsy превращается в il_fullxfull. Тот же ассет, измерено руками: 21 КБ на входе, 412 КБ на выходе. Это разница между мазнёй на стене и вещью, перед которой стоит постоять.
Reliquary вышел тем же релизом, что и общий 3D-движок под Лабораторией, и расширение сайта до шести языков: собственный интерфейс музея — это 87 ключей, и все шесть локалей несут каждый из них. Одна строка остаётся английской намеренно: надпись над аркой вестибюля читается как VAYNEROV PROJECT RELIQUARY в любой локали — имя здания не меняется от того, что меняетесь вы.
Чего я не ожидал, так это того, сколько дизайнерской работы сделали за меня ограничения. Отсутствие зависимостей заставило написать парсер, который я понимаю целиком. Отсутствие бинарных ассетов заставило делать процедурные материалы, и поэтому целый музей скачивается весом с веб-страницу. Я собирался как следует рассмотреть латунный секстант, а получил здание.