Skip to content

Vaynerov Technologies

We don't just develop — we conjure every line of code & pixel.

All articlesBuild stories

A Museum for Anything with a URL

I wanted to walk through my auction watchlist like a gallery instead of scrolling it like a spreadsheet. What that took was a parser with no dependencies, an SSRF guard with a documented residual risk, an image proxy that forwards nothing, and a museum that never touches a server.

Edward AmirainFounder, Vaynerov Technologies
Published 11 min read
On this page
  1. Paste a link, get an exhibit
  2. SSRF protection: a URL-fetching server is a loaded gun
  3. The image proxy that forwards nothing
  4. The sites that hide from robots
  5. localStorage privacy: your museum is yours
  6. A procedural 3D gallery that never moves
  7. Rate-limiting ourselves, and other lessons

A watchlist is the least dignified way to look at a beautiful object. Three hundred pixels wide, cropped square by someone in a hurry, stacked next to a countdown clock. I have spent more evenings than I'd like to admit squinting at a brass instrument through that keyhole. Four days ago we shipped the opposite: Vaynerov Project Reliquary, a museum that builds itself around anything you can hand it a link to.

The pitch is one sentence — a museum for anything with a URL — and the product is that sentence taken literally. You paste a link. Seconds later you're in a lobby, and the thing you pasted is on the wall under a warm light, framed at a size chosen by its own aspect ratio. None of it is stored on our servers. That was the easy promise; everything upstream of it was the work.

0
bytes stored server-side
60
exhibits per museum
8
images kept per exhibit
4
concurrent texture loads

The Reliquary's operating limits. The first number is the product; the rest are what it takes to make the first one survivable.

The client normalizes what you paste — a bare host becomes https:// — then POSTs it to /api/museum/parse. The route validates the URL before spending any rate-limit budget: a malformed paste shouldn't cost you one of your eight parses a minute. Then a 10-second deadline, a 3 MB ceiling, and a fetch far more paranoid than it looks.

The spec had two constraints written at the top: no new npm dependencies, no binary assets. Both shaped this product more than any feature did. The first means the parser is pure string and regex work — no DOM, no cheerio, no headless anything. Mostly a hair shirt; also a parse path with no attack surface I didn't write myself.

Extraction runs as a cascade, and the order is the opinion: JSON-LD, then OpenGraph and Twitter cards, then <title> and the description meta, then per-site enrichers. JSON-LD wins wherever it exists, because it is the page's own structured claim about itself rather than something inferred from its furniture. The walker reads every application/ld+json block, descends into @graph members and nested mainEntity, and understands SaleEvent because auction catalogs describe themselves that way. Price comes from offers, but only when the amount is positive and the currency is shaped like an ISO-4217 code.

OpenGraph needed a different trick. Its image metadata is positional: og:image:width and og:image:height refine whichever og:image came last, and og:image:secure_url replaces the src before it. A naive key-value sweep flattens that into a blurred average of the biggest and smallest picture on the page. Ours walks the tags in document order and keeps the association, which is why a listing's hero photo reliably becomes the painting on the wall.

Then hygiene, which is most of the code. Image URLs are absolutized against the final URL after redirects, non-http(s) schemes dropped, SVGs binned — they never survive the image proxy downstream, so keeping one would only hang a frame that fails later. Anything under 50 declared pixels goes too, as does anything whose URL smells like a beacon.

// A 1×1 tracking GIF is technically an image. It is not an exhibit.
const TRACKING_HINT = /1x1|pixel|spacer|blank|beacon|tracker/i;

Survivors are deduped and capped at eight per exhibit. A page yielding no title or no image gets an honest nothing_found rather than an empty frame.

And when a pasted fragment offers more than one distinct (currency, amount) pair, the price is omitted entirely — guessing which of three numbers you meant is the small confident lie that makes a tool untrustworthy.

One path from a URL to a wall. The server's output is sanitized again on the client before anything mounts — we treat our own API response as untrusted, because on the way back it passed through a page we don't control.

SSRF protection: a URL-fetching server is a loaded gun

Here is the uncomfortable shape of this feature. A stranger types a string, and my server makes an HTTP request to it, from inside my infrastructure, with whatever network position that infrastructure has. Classic server-side request forgery — and the payoff isn't http://localhost, it's http://169.254.169.254, the link-local address where cloud providers park instance metadata and, historically, credentials. So safe-fetch.ts refuses in layers:

  • Scheme http/https only; userinfo rejected outright; port must be 80 or 443.
  • Host suffixeslocalhost, *.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. An address that won't parse is refused, not allowed.
  • IPv6 — zone index stripped, then ::, ::1, fc00::/7, fe80::/10, ff00::/8, the 2001:db8 documentation block and the 64:ff9b NAT64 prefix.
  • DNS{ all: true, verbatim: true }; an empty result is refused, and every returned address must be public. One private answer poisons the whole hostname.

The IPv6 rules carry an ordering requirement that's easy to get backwards. An address like ::ffff:169.254.169.254 has a dotted-quad tail, so a validator reading the tail first waves through a public-looking quad on a private prefix. Prefix classification happens first; only then are IPv4-mapped tails handed to the IPv4 validator.

Redirects are where guards usually die, so ours are manual: no automatic following, four hops maximum, every hop re-parsed and re-run through the full validation. A permissive public URL that 302s to link-local is the oldest trick in this genre. One AbortController deadline covers the whole chain instead of each hop, so five slow hops can't multiply into a minute. The byte cap is enforced twice — against content-length, then by a reader loop that cancels on overflow, because content-length is a claim made by someone else.

What we don't defend against is written in the file too: DNS rebinding. Between our lookup and the real connection, a hostile resolver can change its answer. Closing that means pinning to the validated IP, which means a custom undici dispatcher — a dependency surface we judged worse than the residual risk on a read-only fetch returning parsed metadata. I'd rather name a limit than pretend it isn't there. One more confession from the same file: requests go out with a Chrome 137 user agent, because several marketplaces hard-block default fetch UAs. It's the politest lie in the codebase, and it doesn't work often enough.

The image proxy that forwards nothing

Auction and marketplace CDNs almost never send Access-Control-Allow-Origin. Load one of their images straight into a WebGL texture and the canvas taints or the load fails, so the client always goes through /api/museum/image — a second URL-fetching endpoint, paranoid all over again in a different key, with a ^https?:// gate on top of the zod cap because data: and file: must never get near a fetch.

Content types come off an allowlist: jpeg, png, webp, gif, avif, bmp. SVG is deliberately absent, and not out of raster purity — an SVG can execute script when navigated to directly, and a proxy serving you same-origin SVG is a stored-XSS delivery service with extra steps. Nothing streams through: the body is read under a 10 MB cap and re-emitted as a fresh Buffer with headers we write ourselves, nothing forwarded from upstream but the validated content type. Then nosniff, default-src 'none'; sandbox, same-origin CORP — and the caching line I initially skipped, max-age=3600, s-maxage=86400, stale-while-revalidate=604800. Without it, every re-acquisition re-downloaded every texture through the rate-limited proxy.

The sites that hide from robots

Some of the pages people most want on a wall belong to sites that will not talk to a server. eBay returns 403 on /itm/* even to curl carrying byte-identical Chrome headers — an edge wall, not a bug in our fetch, and I spent a while proving that to myself before accepting it. LiveAuctioneers is sneakier: category pages serve full HTML, while an item page returns a 960-byte Incapsula challenge at HTTP 200. A status-code check calls that a success and parses a captcha into your museum. So the sniff has two narrow triggers: HTML under 8 KB containing a challenge marker, or a 403/429 from a domain we've confirmed runs a wall.

const CHALLENGE = /Incapsula|DataDome|captcha-delivery|PerimeterX|px-captcha|_pxhd|cf_chl|challenge-platform/i;

Paired with an under-8 KB body, these markers mean we fetched a bouncer rather than a page. Detection returns 422 bot_walled — a signposted detour, not a dead end.

This site hides from robots — it turned our curator away at the door, but your browser walks right in.

That's the copy the client shows, and it's also the design. Your browser is already past the wall. Select all, copy, paste, and a second route takes over. /api/museum/parse-html performs zero network requests — the URL you supply is attribution and a base for relative links, never fetched. The pasted HTML is never stored, never rendered, never echoed back as markup. Caps of 4 MB of characters and 16 MB on the wire are enforced by a capped reader before request.json() runs, because parsing a hostile body to discover it's too big is how one request knocks you over.

Paste a whole catalog page and you get a whole wing — up to thirty lots, found by a structural heuristic that ignores any repeating pattern confined to under 30% of the document, since that's page furniture rather than the page. Hostile input gets the same treatment as hostile networks: the JSON-LD walker is an index-walked queue with no spreads and a 20,000-node ceiling, because shift() goes quadratic on a huge payload and push(...hugeArray) overflows the stack.

localStorage privacy: your museum is yours

There is one storage key, vaynerov:reliquary:v1, and it lives in your browser. No account, no sync, no row in a table with your name on it. A museum of the things you're quietly obsessed with is nobody's business — and a product with no user data has no user data to lose.

The read path treats that key as hostile input, because a visitor can hand-edit it. Every exhibit must present an id, source URL, domain and title or it's dropped; image lists are re-filtered from scratch; duplicates collapse by id and the collection caps at 60. Batch imports mount in one state object, so a thirty-lot catalog triggers one floorplan rebuild and one save rather than thirty of each — and colliding ids get fresh UUIDs, because a visitor may genuinely want the same lot hanging twice.

Images are stored as their original remote URLs and wrapped with the proxy only at render time, so a saved museum stays independent of how we fetch pictures this month. The honest cost: clear your site data and the building empties, and it doesn't follow you to your phone. I'd make the same trade again.

The layout is a pure module: exhibits in, floorplan out, in meters. No React, no three.js. It emits an 11-by-10-meter lobby and a chain of galleries marching down −Z, and holds one contract above all others: appending exhibit N+1 never moves exhibits 1 through N. A museum that rearranges itself when you add to it isn't a museum, it's a feed.

Everything that might drift is derived instead. Frame size comes from a hash of the exhibit id plus the image's own aspect ratio, so a reload reproduces the identical museum from localStorage down to the centimeter. Room capacity is 6 + ((i × 3 + 2) % 5), and every room is built at the length its full capacity needs, so a filling room never resizes around you. Frames alternate right wall, left wall, at a 3.4 m pitch.

Deterministic by construction. Room lengths are sized for full capacity before the first frame hangs, so exhibit sixty finds the same wall it would have found on a fresh load.

Light was the hard part. There is exactly one real spotlight in the building — intensity 10, a warm #ffeccb — and it lerps from room to room as you walk, because a hard light jump on crossing a doorway reads as a render hitch rather than as architecture. Every per-artwork spot you think you see is a faked additive gradient cone. Under it: ambient at 0.82, ACES filmic tone mapping at 1.22 exposure, and a warm brown exponential fog so the next room reads as a dim hall instead of a black void.

No binary assets means every surface is drawn with a canvas at runtime — plaster walls reused as their own bump map, walnut skirting, stone jamb pilasters, gilt frames. Against all that amber, teal appears only as a scalpel: a barely-there portal sheet, and a baseboard line with falloff at both ends so it dies at the corners instead of ringing every shot in neon piping.

You move at 3.2 m/s with drag-look and no pointer lock, because locking the pointer turns clicking a painting into a fight. Click one and the camera stands back by clamp(longSide × 1.3, 1.5, 3.4) meters, shifted half a meter camera-right so the artwork reads clear of the details panel.

Rate-limiting ourselves, and other lessons

Entering a wing used to fire twenty-plus simultaneous image requests and walk straight into our own proxy's rate limit — my server telling my client to calm down. The fix is a small semaphore: four concurrent loads, 60 ms apart. Auto-walking to a newly hung exhibit routes through two waypoints per doorway, so the camera never flies through a wall on its way to show you something.

The best afternoon's work was the least architectural. Marketplace CDNs serve thumbnails by URL convention, and the conventions are guessable: LiveAuctioneers takes ?height=1600&quality=85, eBay's s-l sizes go to s-l1600, Etsy's il_NNNxNNN becomes il_fullxfull. Same asset, measured by hand: 21 KB in, 412 KB out. That's the difference between a smear on the wall and a thing worth standing in front of.

The Reliquary shipped in the same release as the shared 3D engine behind the Lab and the site's expansion to six languages — the museum's own interface is 87 keys, and all six locales carry every one of them. One string stays English on purpose: the lettering over the lobby arch reads VAYNEROV PROJECT RELIQUARY in every locale, the way a building's name doesn't change when you do.

What I didn't expect is how much the constraints did the design work. No dependencies forced a parser I fully understand. No binary assets forced procedural materials, which is why a whole museum downloads in the weight of a web page. I set out to look at a brass sextant properly and ended up with a building.