[object Object]

← back to 1900swallpaper

step2 vendor-leak fix: dual-key /sample resolver + handle_display hrefs

27debb60c6b442f06802e4d04f9199f42db36cc9 · 2026-06-09 13:47:43 -0700 · Steve

Files touched

Diff

commit 27debb60c6b442f06802e4d04f9199f42db36cc9
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 9 13:47:43 2026 -0700

    step2 vendor-leak fix: dual-key /sample resolver + handle_display hrefs
---
 public/index.html | 12 ++++++------
 server.js         | 35 ++++++++++++++++++++++++++++++++++-
 2 files changed, 40 insertions(+), 7 deletions(-)

diff --git a/public/index.html b/public/index.html
index 551333e..e1bdb18 100644
--- a/public/index.html
+++ b/public/index.html
@@ -588,9 +588,9 @@ function cardHTML(p) {
   return '<img loading="' + (eager ? 'eager' : 'lazy') + '"' + (eager ? ' fetchpriority="high"' : '') + ' src="' + escAttr(safeImg(p.image_url)) + '" alt="' + escAttr(p.title) + '">'
     + '<div class="overlay">'
     + '<div class="pat">' + escAttr(p.pattern_name || p.title) + '</div>'
-    + '<div class="ven">Designer Wallcoverings' + (cleanSku(p.sku || p.handle) ? ' · ' + escAttr(cleanSku(p.sku || p.handle)) : '') + '</div>'
+    + '<div class="ven">Designer Wallcoverings' + (cleanSku(p.sku || p.handle_display || p.handle) ? ' · ' + escAttr(cleanSku(p.sku || p.handle_display || p.handle)) : '') + '</div>'
     + '<div class="actions">'
-    + '<button class="sample-btn" onclick="event.stopPropagation();dwmOpen(\'Sample\',' + JSON.stringify({sku:p.sku||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'&quot;') + ')">Sample</button>'
+    + '<button class="sample-btn" onclick="event.stopPropagation();dwmOpen(\'Sample\',' + JSON.stringify({sku:p.sku||p.handle_display||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'&quot;') + ')">Sample</button>'
     + '</div></div>';
 }
 
@@ -602,7 +602,7 @@ function rowHTML(p) {
   const aes = LABELS[p.aesthetic] || p.aesthetic || '—';
   return '<img loading="lazy" src="' + escAttr(safeImg(p.image_url)) + '" alt="' + escAttr(p.title) + '">'
     + '<div class="r-pat">' + escAttr(p.pattern_name || p.title) + '</div>'
-    + '<div class="r-col r-sku">' + escAttr(cleanSku(p.sku || p.handle) || '—') + '</div>'
+    + '<div class="r-col r-sku">' + escAttr(cleanSku(p.sku || p.handle_display || p.handle) || '—') + '</div>'
     + '<div class="r-col r-aes">' + escAttr(String(aes).replace(/-/g, ' ')) + '</div>'
     + '<div class="r-col r-price">' + price + '</div>';
 }
@@ -612,7 +612,7 @@ function rowHTML(p) {
 // chips, and CTAs for sample + Big Red chat. The modal NEVER reveals vendor.
 function openDetails(p) {
   const safeP = {
-    sku: p.sku || p.handle || '',
+    sku: p.sku || p.handle_display || p.handle || '',
     title: p.title || '',
     pattern: p.pattern_name || p.title || '',
     image_url: safeImg(p.image_url),
@@ -1141,7 +1141,7 @@ window.IDEA_RAIL_TAGS = ["art nouveau","botanical","floral","arts & crafts"];
     function cardOne(p) {
       const url = (p.image_url || '').replace(/(_\d+x\d*)(\.(jpg|jpeg|png|webp|gif))(\?|$)/i, '$2$4');
       const title = ((p.title || '').split('|')[0] || '').trim().replace(/"/g, '&quot;');
-      return '<a class="rail-card" href="/sample/' + encodeURIComponent(p.handle || p.sku) + '">' +
+      return '<a class="rail-card" href="/sample/' + encodeURIComponent(p.handle_display || p.handle || p.sku) + '">' +
         '<img src="' + url + '" alt="' + title + '" loading="lazy">' +
         '<div class="rc-title">' + title + '</div>' +
         '<div class="rc-meta">Designer Wallcoverings</div>' +
@@ -1181,7 +1181,7 @@ window.IDEA_RAIL_TAGS = ["art nouveau","botanical","floral","arts & crafts"];
     const curated = items.filter((_, i) => i % 5 === 3).slice(0, 12);
     function card(p) {
       const url = (p.image_url || '').replace(/(_\d+x\d*)(\.(jpg|jpeg|png|webp|gif))(\?|$)/i, '$2$4');
-      return '<a class="rail-card" href="/sample/' + encodeURIComponent(p.handle || p.sku) + '">' +
+      return '<a class="rail-card" href="/sample/' + encodeURIComponent(p.handle_display || p.handle || p.sku) + '">' +
         '<img src="' + url + '" alt="' + (p.title || '').replace(/"/g, '&quot;') + '" loading="lazy">' +
         '<div class="rc-title">' + ((p.title || '').split('|')[0] || '').trim() + '</div>' +
         '<div class="rc-meta">' + (p.vendor || '') + '</div>' +
diff --git a/server.js b/server.js
index 92c1706..dda68dd 100644
--- a/server.js
+++ b/server.js
@@ -220,8 +220,40 @@ app.get('/api/facets', (req, res) => {
 
 app.get('/api/health', (req, res) => res.json({ status: 'ok', count: PRODUCTS_NICHE.length, dropped: DROPPED }));
 
+// Step 2 of the vendor handle/sku text-leak fix (2026-06-09): the client's
+// /sample/ hrefs now point at the vendor-scrubbed `handle_display` slug
+// (emitted by _shared/api-vendor-redact since Step 1), so the raw vendor-bearing
+// handle never rides in the URL bar. This route is DUAL-KEY — it resolves the raw
+// handle/sku FIRST (old bookmarks + buy path, byte-identical behavior), then falls
+// back to a redacted->product map built after catalog load. If two raw handles
+// redact to the same display slug, that slug is logged and left raw-only.
+const { redactVendorText } = require('../_shared/vendor-text-redact');
+let SAMPLE_DISPLAY = new Map(); // redacted display slug -> product
+function buildSampleDisplayMap() {
+  const map = new Map();
+  const collided = new Set();
+  for (const p of PRODUCTS_NICHE) {
+    for (const key of [p.handle, p.sku]) {
+      if (!key) continue;
+      const d = redactVendorText(String(key));
+      if (!d || d === key) continue;           // nothing redacted — raw route already matches
+      const prev = map.get(d);
+      if (prev && prev !== p) { collided.add(d); continue; }
+      map.set(d, p);
+    }
+  }
+  for (const d of collided) {
+    map.delete(d);
+    console.warn(`[1900swallpaper] /sample display-slug collision — keeping raw-only for "${d}"`);
+  }
+  SAMPLE_DISPLAY = map;
+  console.log(`[1900swallpaper] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
+}
+
 app.get('/sample/:handle', (req, res) => {
-  const p = PRODUCTS_NICHE.find(x => x.handle === req.params.handle || x.sku === req.params.handle);
+  const key = req.params.handle;
+  const p = PRODUCTS_NICHE.find(x => x.handle === key || x.sku === key) // raw first — old behavior intact
+    || SAMPLE_DISPLAY.get(key);                                        // then the redacted display form
   if (!p) return res.status(404).send('Not found');
   res.redirect(302, p.product_url || `${DW_SHOPIFY}/products/${encodeURIComponent(p.handle)}#sample`);
 });
@@ -268,6 +300,7 @@ if (catalog) catalog.mount(app, { siteSlug: SITE_SLUG, rails: SITE_RAILS });
   }
   PRODUCTS_NICHE = PRODUCTS.filter(nicheFit);
   console.log(`[${__SITE}] niche filter: kept ${PRODUCTS_NICHE.length} of ${PRODUCTS.length}`);
+  buildSampleDisplayMap(); // Step 2: redacted->raw /sample resolver keys
   app.listen(PORT, '127.0.0.1', () => {
     console.log(`1900swallpaper listening on http://127.0.0.1:${PORT}`);
   });

← f243a17 snapshot before step2 codemod  ·  back to 1900swallpaper  ·  Recompute aesthetic from tags via classifyAesthetic (was deg 301fc32 →