[object Object]

← back to Carmelwallpapers

fix: strip vendor tokens from product slug (redactVendorSlug) + dual-key route resolve — closes standalone slug vendor leak

24afde416cff3a2ec8e3bca8c9ea1ac20733d957 · 2026-05-29 22:19:58 -0700 · Steve Abrams

Files touched

Diff

commit 24afde416cff3a2ec8e3bca8c9ea1ac20733d957
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 22:19:58 2026 -0700

    fix: strip vendor tokens from product slug (redactVendorSlug) + dual-key route resolve — closes standalone slug vendor leak
---
 server.js | 45 ++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 42 insertions(+), 3 deletions(-)

diff --git a/server.js b/server.js
index ee0fbec..74451e6 100644
--- a/server.js
+++ b/server.js
@@ -7,6 +7,38 @@ const path = require('path');
 const PORT = process.env.PORT || 9832;
 const DATA_PATH = path.join(__dirname, 'data', 'products.json');
 
+// ── Vendor-slug redactor (canonical: dw-domain-fleet shared/render.js @ee30c6a) ─
+// Vendor names leak into the product HANDLE, which surfaces in /p/<slug> hrefs.
+// redactVendorSlug strips any hyphen-delimited segment carrying a 3rd-party vendor
+// token. Used to derive each product's `slug` (the /p/ route key) + the dual-key
+// resolver below. The REAL p.handle is left intact for the main-store buy/sample
+// links (they legitimately point at designerwallcoverings.com/products/<handle>).
+// House brands carry no denylist token, so they pass through untouched. Longer
+// vendor names precede their prefixes ("lee jofa modern" before "lee jofa") so the
+// longer match strips first.
+const VENDORS_DENYLIST = [
+  'wolf gordon', 'nina campbell', 'jeffrey stevens', 'versace',
+  'arte international', 'lee jofa modern', 'lee jofa', 'innovations usa',
+  'innovations', 'koroseal', 'schumacher', 'candice olson', 'thibaut',
+  'maya romanoff', 'scalamandre', 'dedar', 'carnegie', 'cole and son',
+  'kravet', 'clarke and clarke', 'brunschwig', 'gp j baker', 'sandberg',
+  'designers guild', 'graham and brown', 'harlequin', 'andrew martin',
+  'ronald redding', 'blithfield', 'coordonne', 'fentucci', 'sister parish',
+  'phillip jeffries', 'fromental', 'westport', 'breegan', 'les ensembliers',
+  'roger thomas', 'stacy garcia', 'de gournay', 'romo', 'farrow and ball',
+  'colefax and fowler', 'sanderson', 'morris and co', 'osborne and little',
+];
+const VENDOR_SLUG_RES = VENDORS_DENYLIST.map(v => {
+  const s = String(v).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+  return new RegExp('(^|-)' + s + '(?=-|$)', 'gi');
+});
+function redactVendorSlug(slug) {
+  let out = String(slug == null ? '' : slug);
+  for (let i = 0; i < VENDOR_SLUG_RES.length; i++) out = out.replace(VENDOR_SLUG_RES[i], '$1');
+  out = out.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
+  return out || String(slug == null ? '' : slug);   // never empty (route key must be non-empty)
+}
+
 // ── Shared catalog: products live in dw_unified.microsite_products ───────────
 // Admin catalog (PG-backed) is opt-in via MICROSITE_ADMIN_ENABLED=true. Prod
 // stays static-only (data/products.json) so Kamatera doesn't need `pg` or the
@@ -33,6 +65,10 @@ function shapeForSite(p) {
     sample_url: p.sample_url || `${productUrl}#sample`,
     images: Array.isArray(p.images) && p.images.length ? p.images
             : (p.image_url ? [p.image_url] : []),
+    // Vendor-redacted /p/ route key. Client card templates link via p.slug (not
+    // p.handle) so vendor tokens never reach the browser URL; the REAL p.handle
+    // stays on the object for the dual-key resolver + main-store buy links.
+    slug: redactVendorSlug(p.handle),
   };
 }
 
@@ -359,7 +395,10 @@ app.get('/health', (req, res) => {
 
 // ── Product detail ────────────────────────────────────────────────────────────
 app.get('/p/:handle', (req, res) => {
-  const p = PRODUCTS.find(x => x.handle === req.params.handle);
+  // Dual-key: new redacted-slug URLs AND legacy real-handle URLs both resolve.
+  const key = req.params.handle;
+  const p = PRODUCTS.find(x => x.handle === key)
+         || PRODUCTS.find(x => redactVendorSlug(x.handle) === key);
   if (!p) return res.status(404).send('<h1>Not found</h1>');
 
   const title = `${p.title} — Carmel Wallpapers | Free Memo Samples, Trade Service`;
@@ -586,7 +625,7 @@ function renderCard(p) {
   div.className = 'product-card';
   div.setAttribute('role','listitem');
   div.innerHTML = \`
-    <a href="/p/\${p.handle}" class="card-link" aria-label="\${p.title.replace(/"/g,'&quot;')}">
+    <a href="/p/\${p.slug || p.handle}" class="card-link" aria-label="\${p.title.replace(/"/g,'&quot;')}">
       <div class="card-img-wrap">
         <img src="\${p.image_url}" alt="\${p.title.replace(/"/g,'&quot;')}" loading="lazy" class="card-img">
       </div>
@@ -662,7 +701,7 @@ async function loadSliders() {
         </div>
         <div class="rail-track" id="\${trackId}">
           \${rail.items.map(p=>\`
-            <a href="/p/\${p.handle}" class="rail-card" aria-label="\${(p.title||'').replace(/"/g,'&quot;')}">
+            <a href="/p/\${p.slug || p.handle}" class="rail-card" aria-label="\${(p.title||'').replace(/"/g,'&quot;')}">
               <div class="rail-img-wrap"><img src="\${p.image_url}" alt="\${(p.title||'').replace(/"/g,'&quot;')}" loading="lazy" class="rail-img"></div>
               <p class="rail-card-title">\${p.title}</p>
               <p class="rail-card-vendor">Designer Wallcoverings</p>

← 607c2d8 scrub vendor name leaks from products.json source (568 produ  ·  back to Carmelwallpapers  ·  fix: strip bare '-arte' (Arte International abbrev) from pro f922bba →