[object Object]

← back to Dw Domain Fleet

Vendor-neutral image proxy: render emits /img/<token>, server.js streams CDN bytes (closed proxy + LRU). Kills image_url vendor-name leak in card/PDP/hero/JSON-LD. DTD-A. Proven local: 0 leaks, /img 200.

bd7303f6e853819a13b30e31cfed36b0a5cbac21 · 2026-06-03 14:14:34 -0700 · Steve Abrams

Files touched

Diff

commit bd7303f6e853819a13b30e31cfed36b0a5cbac21
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 3 14:14:34 2026 -0700

    Vendor-neutral image proxy: render emits /img/<token>, server.js streams CDN bytes (closed proxy + LRU). Kills image_url vendor-name leak in card/PDP/hero/JSON-LD. DTD-A. Proven local: 0 leaks, /img 200.
---
 server.js        | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 shared/render.js | 42 ++++++++++++++++++++++++++++++++++++------
 2 files changed, 88 insertions(+), 6 deletions(-)

diff --git a/server.js b/server.js
index 1cd8fc5..6916029 100644
--- a/server.js
+++ b/server.js
@@ -54,6 +54,24 @@ const HERO_IMGS = (Array.isArray(HERO_ALLOC[SITE]) && HERO_ALLOC[SITE].length >=
   : sortProducts(POOL, 'newest').slice(0, 14).map(p => p.image_url).filter(Boolean);
 const FEATURED = sortProducts(POOL, 'newest').slice(0, 20);
 
+// ---- vendor-neutral image proxy map (built once at boot) ----
+// The raw Shopify CDN image_url embeds the 3rd-party vendor name in the
+// filename; render.js now emits /img/<token> instead. We resolve token -> real
+// CDN url here (render.imgToken is the same pure hash render uses) and the
+// /img/:token route streams the bytes, so the vendor never appears in the DOM
+// OR the browser network tab (DTD 2026-06-03 verdict A — streaming proxy).
+const IMG_MAP = new Map();
+function registerImg(url) { if (url) IMG_MAP.set(render.imgToken(url), url); }
+for (const p of POOL) registerImg(p.image_url);   // every card + PDP image
+for (const u of HERO_IMGS) registerImg(u);        // homepage hero slides
+console.log(`[${SITE}] image-proxy map: ${IMG_MAP.size} urls`);
+
+// Small per-process LRU of fetched bytes so a hot image isn't re-fetched from
+// the CDN on every request. Capped to keep memory flat across 44+ procs.
+const IMG_CACHE = new Map();          // token -> { buf, type, exp }
+const IMG_CACHE_MAX = 256;
+const IMG_TTL_MS = 6 * 60 * 60 * 1000;
+
 const app = express();
 // Behind nginx (single reverse-proxy hop). Without this, express-rate-limit sees
 // the nginx-set X-Forwarded-For with trust-proxy OFF and throws
@@ -138,6 +156,40 @@ app.get('/buy/:slug', (req, res) => {
     + encodeURIComponent(p.handle) + '#sample');
 });
 
+// Vendor-neutral image proxy. token is a pure hash of the real CDN url (see
+// render.imgToken); we resolve it from the boot map and stream the bytes so the
+// vendor filename never reaches the client. Closed proxy — only urls that were
+// registered at boot (this site's own catalog + hero images) can be fetched, so
+// it can't be abused as an open relay. 404 on unknown token, 502 on upstream
+// failure. Long Cache-Control + a tiny in-proc LRU keep CDN hits rare.
+app.get('/img/:token', async (req, res) => {
+  const token = String(req.params.token || '').slice(0, 64);
+  const url = IMG_MAP.get(token);
+  if (!url) return res.status(404).end();
+  const now = Date.now();
+  const hit = IMG_CACHE.get(token);
+  if (hit && hit.exp > now) {
+    res.setHeader('Content-Type', hit.type);
+    res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
+    res.setHeader('X-Img-Cache', 'HIT');
+    return res.end(hit.buf);
+  }
+  try {
+    const upstream = await fetch(url);
+    if (!upstream.ok) return res.status(502).end();
+    const type = upstream.headers.get('content-type') || 'image/jpeg';
+    const buf = Buffer.from(await upstream.arrayBuffer());
+    if (IMG_CACHE.size >= IMG_CACHE_MAX) IMG_CACHE.delete(IMG_CACHE.keys().next().value);
+    IMG_CACHE.set(token, { buf, type, exp: now + IMG_TTL_MS });
+    res.setHeader('Content-Type', type);
+    res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
+    res.setHeader('X-Img-Cache', 'MISS');
+    return res.end(buf);
+  } catch (e) {
+    return res.status(502).end();
+  }
+});
+
 app.get('/about', (req, res) => {
   res.type('html').send(render.aboutPage(cfg));
 });
diff --git a/shared/render.js b/shared/render.js
index 8c45e34..fcae9c5 100644
--- a/shared/render.js
+++ b/shared/render.js
@@ -2,8 +2,33 @@
  * render.js — server-rendered HTML for a fleet microsite.
  * All pages share one template; per-domain config drives theme/copy/hero.
  */
+const crypto = require('crypto');
 const { dominantHex } = require('./sort');
 
+/* imgToken / proxyImg — vendor-neutral image URL.
+ * The raw Shopify CDN image_url embeds the 3rd-party vendor name in the
+ * filename (e.g. WolfGordonWallcovering_DWWG_em9505.jpg). Emitting it in a card
+ * data-img / background-image / hero / JSON-LD leaks the vendor (settlement
+ * vendor-confidentiality). The fix is a server-side image proxy: render emits
+ * /img/<token>, and server.js resolves token -> real CDN url from a boot map and
+ * streams the bytes, so the vendor never appears in the DOM OR the network tab
+ * (DTD 2026-06-03 verdict A — streaming proxy, vote 2/2, Qwen non-vote).
+ *
+ * imgToken is a PURE deterministic hash of the url string — render.js stays
+ * stateless (no POOL access). server.js builds its token->url map with this same
+ * function so the two sides agree. sha1 here is an opaque short id, NOT a
+ * security primitive; 20 hex chars = 80 bits, ample for a per-site catalog. */
+function imgToken(url) {
+  if (!url) return '';
+  const ext = (String(url).match(/\.(jpe?g|png|webp|gif|avif)(?:[?#]|$)/i) || [null, 'jpg'])[1].toLowerCase();
+  const h = crypto.createHash('sha1').update(String(url)).digest('hex').slice(0, 20);
+  return h + '.' + (ext === 'jpeg' ? 'jpg' : ext);
+}
+function proxyImg(url) {
+  const t = imgToken(url);
+  return t ? '/img/' + t : '';
+}
+
 function esc(s) {
   return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
     ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
@@ -349,12 +374,13 @@ function card(p) {
             || scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
             || 'Designer Wallcovering';
   const href = '/product/' + encodeURIComponent(productSlug(p));
+  const img = proxyImg(p.image_url);   // vendor-neutral /img/<token> (see imgToken)
   return `<div class="card" data-handle="${esc(productSlug(p))}"
     data-title="${esc(t)}" data-sku="${esc(scrubVendor(p.sku||p.handle))}"
-    data-img="${esc(p.image_url)}" data-tags="${esc(scrubTags(p.tags).slice(0,12).join(', '))}"
+    data-img="${esc(img)}" data-tags="${esc(scrubTags(p.tags).slice(0,12).join(', '))}"
     data-price="${esc(p.price)}" data-hex="${esc(hx)}">
   <a class="card-link" href="${esc(href)}" aria-label="${esc(t)}">
-    <div class="card-img" style="background-image:url('${esc(p.image_url)}');--card-bg:url('${esc(p.image_url)}')"></div>
+    <div class="card-img" style="background-image:url('${esc(img)}');--card-bg:url('${esc(img)}')"></div>
     <div class="card-body">
       <div class="card-title">${esc(t)}</div>
       <div class="card-meta"><span class="dot" style="background:${hx}"></span>
@@ -368,7 +394,9 @@ function card(p) {
 function homePage(cfg, heroImgs, featured) {
   const title = `${cfg.siteName} | ${cfg.tagline}`;
   const desc = clean(cfg.metaDesc);
-  const slides = heroImgs.slice(0, 8);
+  // Hero images are raw Shopify CDN urls (vendor in filename) — route them
+  // through the same /img proxy so the homepage hero doesn't leak the vendor.
+  const slides = heroImgs.slice(0, 8).map(proxyImg).filter(Boolean);
   const heroHtml = slides.length >= 3
     ? slides.map((s,i)=>`<div class="cinema-slide${i===0?' on':''}" style="background-image:url('${esc(s)}')"></div>`).join('')
     : `<div class="cinema-grid">${slides.concat(slides).slice(0,4).map(s=>`<div style="background-image:url('${esc(s)}')"></div>`).join('')}</div>`;
@@ -692,6 +720,8 @@ function productPage(cfg, p, related) {
             || scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
             || 'Designer Wallcovering';
   const sku = scrubVendor(p.sku || p.handle);   // display-only; raw p.handle still used for DW-store links
+  const imgProxy = proxyImg(p.image_url);                                   // /img/<token> — vendor-neutral
+  const imgAbs = imgProxy ? `https://${cfg.domain}${imgProxy}` : '';        // absolute for JSON-LD image
   // Self-canonical on the CLEAN slug (vendor-free). Was ${DW}/products/<rawHandle>,
   // which leaked 3rd-party vendor names in the rendered <link>/JSON-LD. Buy + main-store
   // links now route through /buy/:slug (server resolves the real handle + 302-redirects),
@@ -705,7 +735,7 @@ function productPage(cfg, p, related) {
     .map(t => `<span>${esc(t)}</span>`).join('');
   const productLd = {
     '@context':'https://schema.org','@type':'Product',
-    name, sku, image: p.image_url, brand:{ '@type':'Brand', name:'Designer Wallcoverings' },
+    name, sku, image: imgAbs, brand:{ '@type':'Brand', name:'Designer Wallcoverings' },
     url: canonical
   };
   return head(cfg, title, desc, '/product/' + encodeURIComponent(productSlug(p)), canonical)
@@ -787,7 +817,7 @@ function productPage(cfg, p, related) {
 <div class="wrap">
   <div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;padding:28px 0 10px"
        class="pdp-grid">
-    <div class="card-img" style="background-image:url('${esc(p.image_url)}');
+    <div class="card-img" style="background-image:url('${esc(imgProxy)}');
        border-radius:6px;min-height:clamp(360px,52vw,640px)"></div>
     <div>
       <div style="font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)">
@@ -820,4 +850,4 @@ ${script(cfg)}
 </body></html>`;
 }
 
-module.exports = { homePage, catalogPage, aboutPage, infoPage, productPage, clean, esc, productSlug, scrubVendor };
+module.exports = { homePage, catalogPage, aboutPage, infoPage, productPage, clean, esc, productSlug, scrubVendor, imgToken, proxyImg };

← 7c6386c Per-site catalog extras overlay (data/extras/<slug>.json) —  ·  back to Dw Domain Fleet  ·  fix(fleet): route scrubTags through canonical isVendorTag + 45f2fce →