Um museu para tudo o que tenha um URL
Queria percorrer a minha lista de leilões como uma galeria em vez de a rolar como uma folha de cálculo. Para isso foi preciso um parser sem dependências, uma proteção SSRF com risco residual documentado, um proxy de imagens que não reencaminha nada e um museu que nunca toca num servidor.
Nesta página
Uma lista de favoritos é a forma menos digna de olhar para um objeto bonito. Trezentos píxeis de largura, cortado a quadrado por alguém com pressa, encostado a um relógio de contagem decrescente. Já passei mais noites do que gosto de admitir a semicerrar os olhos para um instrumento de latão através dessa fechadura. Há quatro dias lançámos o contrário: o Vaynerov Project Reliquary, um museu que se constrói à volta de qualquer coisa a que consiga dar um link.
A promessa cabe numa frase — um museu para tudo o que tenha um URL — e o produto é essa frase levada à letra. Cola um link. Segundos depois está num átrio, e a coisa que colou está na parede sob uma luz quente, emoldurada num tamanho escolhido pelo seu próprio rácio de aspeto. Nada disto fica guardado nos nossos servidores. Essa era a promessa fácil; tudo o que estava a montante dela é que foi trabalho.
- 0
- bytes guardados no servidor
- 60
- peças por museu
- 8
- imagens mantidas por peça
- 4
- texturas carregadas em paralelo
Os limites operacionais do Reliquary. O primeiro número é o produto; os restantes são o que é preciso para tornar o primeiro sustentável.
Cole um link, receba uma peça
O cliente normaliza o que colou — um anfitrião nu passa a https:// — e depois envia-o por POST para /api/museum/parse. A rota valida o URL antes de gastar orçamento de limite de tráfego: uma colagem malformada não lhe deve custar uma das suas oito análises por minuto. Depois um prazo de 10 segundos, um teto de 3 MB e um fetch bastante mais paranoico do que parece.
A especificação tinha duas restrições escritas logo no topo: nenhuma dependência npm nova, nenhum ficheiro binário. Ambas moldaram este produto mais do que qualquer funcionalidade. A primeira significa que o parser é puro trabalho de strings e expressões regulares — sem DOM, sem cheerio, sem nada headless. Sobretudo um cilício; também um caminho de análise sem superfície de ataque que eu próprio não tenha escrito.
A extração corre em cascata, e a ordem é a opinião: JSON-LD, depois OpenGraph e cartões do Twitter, depois <title> e a meta de descrição, depois enriquecedores por site. O JSON-LD ganha sempre que existe, porque é a declaração estruturada que a própria página faz sobre si mesma e não algo inferido do mobiliário. O walker lê todos os blocos application/ld+json, desce aos membros de @graph e aos mainEntity aninhados, e percebe SaleEvent porque é assim que os catálogos de leilões se descrevem. O preço vem de offers, mas só quando o montante é positivo e a moeda tem a forma de um código ISO-4217.
O OpenGraph exigiu outro truque. Os metadados de imagem são posicionais: og:image:width e og:image:height refinam a og:image que veio por último, e og:image:secure_url substitui o src anterior. Uma varredura ingénua de chave-valor achata isso numa média difusa entre a maior e a menor imagem da página. A nossa percorre as etiquetas pela ordem do documento e mantém a associação, e é por isso que a foto principal de um anúncio se torna, com fiabilidade, o quadro na parede.
Depois a higiene, que é a maior parte do código. Os URLs de imagem são absolutizados contra o URL final, depois dos redirecionamentos, os esquemas que não sejam http(s) são descartados, os SVG vão para o lixo — nunca sobrevivem ao proxy de imagens mais à frente, portanto guardar um só serviria para pendurar uma moldura que falha mais tarde. Também sai tudo o que declare menos de 50 píxeis, tal como tudo cujo URL cheire a baliza de rastreio.
// A 1×1 tracking GIF is technically an image. It is not an exhibit.
const TRACKING_HINT = /1x1|pixel|spacer|blank|beacon|tracker/i;Os sobreviventes são desduplicados e limitados a oito por peça. Uma página que não dê título nem imagem recebe um honesto nothing_found em vez de uma moldura vazia.
E quando um fragmento colado oferece mais do que um par distinto (moeda, montante), o preço é omitido por completo — adivinhar qual dos três números era o seu é a pequena mentira confiante que torna uma ferramenta indigna de confiança.
Proteção SSRF: um servidor que busca URLs é uma arma carregada
Esta é a forma incómoda desta funcionalidade. Um desconhecido escreve uma string e o meu servidor faz-lhe um pedido HTTP, a partir de dentro da minha infraestrutura, com a posição de rede que essa infraestrutura tiver. Falsificação de pedidos do lado do servidor, clássica — e o prémio não é http://localhost, é http://169.254.169.254, o endereço link-local onde os fornecedores de nuvem estacionam os metadados da instância e, historicamente, credenciais. Por isso o safe-fetch.ts recusa por camadas:
- Esquema apenas http/https; userinfo rejeitado à cabeça; a porta tem de ser 80 ou 443.
- Sufixos de anfitrião —
localhost,*.localhost,*.local,*.internal. - IPv4 — 0/8, 10/8, 100.64/10 (NAT de operador), 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. Um endereço que não se consiga analisar é recusado, não permitido.
- IPv6 — índice de zona removido e depois
::,::1,fc00::/7,fe80::/10,ff00::/8, o bloco de documentação2001:db8e o prefixo NAT6464:ff9b. - DNS —
{ all: true, verbatim: true }; um resultado vazio é recusado, e todos os endereços devolvidos têm de ser públicos. Uma resposta privada envenena o nome de anfitrião inteiro.
As regras de IPv6 trazem uma exigência de ordem que é fácil trocar. Um endereço como ::ffff:169.254.169.254 tem uma cauda em notação decimal, portanto um validador que leia a cauda primeiro deixa passar um quarteto de aspeto público sobre um prefixo privado. A classificação do prefixo acontece primeiro; só depois as caudas IPv4-mapeadas são entregues ao validador de IPv4.
Os redirecionamentos são onde as proteções costumam morrer, portanto os nossos são manuais: nada de seguir automaticamente, quatro saltos no máximo, cada salto reanalisado e submetido de novo à validação completa. Um URL público permissivo que faz 302 para link-local é o truque mais antigo do género. Um único prazo de AbortController cobre a cadeia inteira em vez de cada salto, para que cinco saltos lentos não se multipliquem num minuto. O teto de bytes é imposto duas vezes — contra o content-length e depois por um ciclo de leitura que cancela ao transbordar, porque o content-length é uma afirmação feita por outra pessoa.
Aquilo contra o qual não nos defendemos também está escrito no ficheiro: DNS rebinding. Entre a nossa consulta e a ligação real, um resolvedor hostil pode mudar a resposta. Fechar isso significa fixar o IP validado, o que significa um dispatcher undici à medida — uma superfície de dependências que julgámos pior do que o risco residual num fetch só de leitura que devolve metadados analisados. Prefiro nomear um limite a fingir que ele não existe. Mais uma confissão do mesmo ficheiro: os pedidos saem com um user agent de Chrome 137, porque vários marketplaces bloqueiam à partida os UA por omissão do fetch. É a mentira mais educada da base de código, e não funciona vezes suficientes.
O proxy de imagens que não reencaminha nada
Os CDN de leilões e marketplaces quase nunca enviam Access-Control-Allow-Origin. Carregue uma das imagens deles diretamente para uma textura WebGL e o canvas fica contaminado ou o carregamento falha, por isso o cliente passa sempre por /api/museum/image — um segundo ponto de acesso que vai buscar URLs, paranoico outra vez mas noutro registo, com uma barreira ^https?:// por cima do teto de zod, porque data: e file: nunca podem chegar perto de um fetch.
Os tipos de conteúdo saem de uma lista de permissões: jpeg, png, webp, gif, avif, bmp. O SVG está deliberadamente ausente, e não por purismo de raster — um SVG pode executar script quando é aberto diretamente, e um proxy que lhe serve SVG na mesma origem é um serviço de entrega de XSS armazenado com passos extra. Nada é transmitido em fluxo: o corpo é lido sob um teto de 10 MB e reemitido como um Buffer novo com cabeçalhos escritos por nós, sem nada reencaminhado de montante a não ser o tipo de conteúdo validado. Depois nosniff, default-src 'none'; sandbox, CORP de mesma origem — e a linha de cache que inicialmente saltei, max-age=3600, s-maxage=86400, stale-while-revalidate=604800. Sem ela, cada reaquisição voltava a descarregar todas as texturas através do proxy limitado.
Os sites que se escondem dos robôs
Algumas das páginas que as pessoas mais querem numa parede pertencem a sites que não falam com um servidor. O eBay devolve 403 em /itm/* mesmo a um curl que leve cabeçalhos de Chrome idênticos ao byte — um muro na berma da rede, não um defeito no nosso fetch, e passei um bom bocado a prová-lo a mim mesmo antes de o aceitar. A LiveAuctioneers é mais manhosa: as páginas de categoria servem HTML completo, enquanto uma página de item devolve um desafio Incapsula de 960 bytes com HTTP 200. Uma verificação de código de estado chama àquilo um sucesso e analisa um captcha para dentro do seu museu. Por isso a deteção tem dois gatilhos estreitos: HTML com menos de 8 KB contendo um marcador de desafio, ou um 403/429 vindo de um domínio que confirmámos ter um muro.
const CHALLENGE = /Incapsula|DataDome|captcha-delivery|PerimeterX|px-captcha|_pxhd|cf_chl|challenge-platform/i;Em par com um corpo de menos de 8 KB, estes marcadores querem dizer que fomos buscar um segurança em vez de uma página. A deteção devolve 422 bot_walled — um desvio sinalizado, não um beco sem saída.
Este site esconde-se dos robôs — pôs o nosso curador na rua, mas o seu navegador entra à vontade.
É esse o texto que o cliente mostra, e é também o desenho. O seu navegador já está do outro lado do muro. Selecionar tudo, copiar, colar, e uma segunda rota assume o comando. /api/museum/parse-html não faz um único pedido de rede — o URL que fornece serve de atribuição e de base para os links relativos, nunca é procurado. O HTML colado nunca é guardado, nunca é renderizado, nunca é devolvido como marcação. Os tetos de 4 MB de caracteres e 16 MB à passagem pela rede são impostos por um leitor limitado antes de request.json() correr, porque analisar um corpo hostil para descobrir que é grande de mais é como um só pedido o deita abaixo.
Cole uma página inteira de catálogo e recebe uma ala inteira — até trinta lotes, encontrados por uma heurística estrutural que ignora qualquer padrão repetido confinado a menos de 30% do documento, já que isso é mobiliário da página e não a página. A entrada hostil recebe o mesmo tratamento que as redes hostis: o walker de JSON-LD é uma fila percorrida por índice, sem spreads e com um teto de 20 000 nós, porque shift() fica quadrático num payload enorme e push(...hugeArray) estoira a pilha.
Privacidade no localStorage: o museu é seu
Há uma única chave de armazenamento, vaynerov:reliquary:v1, e ela vive no seu navegador. Sem conta, sem sincronização, sem uma linha numa tabela com o seu nome. Um museu das coisas por que anda discretamente obcecado não é da conta de ninguém — e um produto sem dados de utilizador não tem dados de utilizador para perder.
O caminho de leitura trata essa chave como entrada hostil, porque um visitante pode editá-la à mão. Cada peça tem de apresentar id, URL de origem, domínio e título, ou é descartada; as listas de imagens são refiltradas de raiz; os duplicados colapsam por id e a coleção fica limitada a 60. As importações em lote montam num único objeto de estado, portanto um catálogo de trinta lotes desencadeia uma reconstrução de planta e um gravar, em vez de trinta de cada — e os ids que colidem recebem UUID novos, porque um visitante pode genuinamente querer o mesmo lote pendurado duas vezes.
As imagens são guardadas como os seus URLs remotos originais e embrulhadas no proxy apenas no momento de desenhar, portanto um museu guardado mantém-se independente da forma como vamos buscar imagens este mês. O custo honesto: limpe os dados do site e o edifício esvazia-se, e ele não o acompanha até ao telemóvel. Voltava a fazer a mesma troca.
Uma galeria 3D procedimental que nunca se mexe
O layout é um módulo puro: entram peças, sai uma planta, em metros. Sem React, sem three.js. Emite um átrio de 11 por 10 metros e uma cadeia de galerias a marchar por −Z, e cumpre um contrato acima de todos os outros: acrescentar a peça N+1 nunca move as peças 1 a N. Um museu que se reorganiza quando lhe acrescentamos coisas não é um museu, é um feed.
Tudo o que possa derivar é, em vez disso, derivado. O tamanho da moldura vem de um hash do id da peça mais o rácio de aspeto da própria imagem, portanto um recarregamento reproduz o museu idêntico a partir do localStorage, até ao centímetro. A capacidade de cada sala é 6 + ((i × 3 + 2) % 5), e cada sala é construída com o comprimento de que a sua capacidade total precisa, para que uma sala a encher nunca se redimensione à sua volta. As molduras alternam parede direita, parede esquerda, com um passo de 3.4 m.
A luz foi a parte difícil. Existe exatamente um holofote real no edifício — intensidade 10, um #ffeccb quente — e ele interpola de sala em sala enquanto anda, porque um salto brusco de luz ao atravessar uma porta lê-se como um soluço do renderizador e não como arquitetura. Todos os focos por obra que julga ver são cones de gradiente aditivo falsificados. Por baixo disso: ambiente a 0.82, mapeamento tonal ACES filmic com exposição 1.22, e um nevoeiro exponencial castanho-quente para que a sala seguinte se leia como um corredor em penumbra em vez de um vazio negro.
Não haver ficheiros binários significa que cada superfície é desenhada num canvas em tempo de execução — paredes de estuque reaproveitadas como o seu próprio mapa de relevo, rodapé em nogueira, pilastras de pedra nas ombreiras, molduras douradas. Contra todo aquele âmbar, o azul-petróleo aparece apenas como bisturi: uma folha de portal quase impercetível e uma linha de rodapé com atenuação nas duas pontas, para morrer nos cantos em vez de contornar cada plano com néon.
Anda-se a 3.2 m/s com olhar por arrasto e sem bloqueio de ponteiro, porque bloquear o ponteiro transforma clicar num quadro numa luta. Clique num e a câmara recua clamp(longSide × 1.3, 1.5, 3.4) metros, deslocada meio metro para a direita da câmara, para a obra ficar visível fora do painel de detalhes.
Limitar-nos a nós próprios, e outras lições
Entrar numa ala disparava mais de vinte pedidos de imagem em simultâneo e ia direito ao limite de tráfego do nosso próprio proxy — o meu servidor a dizer ao meu cliente que se acalmasse. A correção é um pequeno semáforo: quatro carregamentos em paralelo, com 60 ms de intervalo. A caminhada automática até uma peça acabada de pendurar passa por dois pontos de passagem em cada porta, para que a câmara nunca atravesse uma parede a caminho de lhe mostrar alguma coisa.
O melhor trabalho de uma tarde foi o menos arquitetónico. Os CDN dos marketplaces servem miniaturas por convenção de URL, e as convenções são adivinháveis: a LiveAuctioneers aceita ?height=1600&quality=85, os tamanhos s-l do eBay vão até s-l1600, o il_NNNxNNN da Etsy passa a il_fullxfull. O mesmo recurso, medido à mão: 21 KB à entrada, 412 KB à saída. É essa a diferença entre uma mancha na parede e uma coisa diante da qual vale a pena ficar de pé.
O Reliquary saiu na mesma versão que o motor 3D partilhado por trás do Lab e que a expansão do site para seis línguas — a interface do museu tem 87 chaves e todas as seis línguas trazem todas elas. Uma linha de texto fica em inglês de propósito: as letras sobre o arco do átrio leem-se VAYNEROV PROJECT RELIQUARY em qualquer língua, tal como o nome de um edifício não muda quando você muda.
O que não esperava foi quanto trabalho de design as restrições fizeram por mim. Não ter dependências forçou um parser que percebo por inteiro. Não ter ficheiros binários forçou materiais procedimentais, e é por isso que um museu inteiro se descarrega no peso de uma página web. Comecei por querer olhar como deve ser para um sextante de latão e acabei com um edifício.