[object Object]

← back to Customdigitalmurals

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

c0e645f7f36b635f531f52f62acd9131c320e589 · 2026-05-29 22:19:53 -0700 · Steve Abrams

Files touched

Diff

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

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

diff --git a/server.js b/server.js
index 04fba2c..fa6db38 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,36 @@ const fs      = require('fs');
 const PORT = process.env.PORT || 9896;
 const DATA_FILE = path.join(__dirname, 'data', 'products.json');
 
+/* Vendor-slug redaction — copied verbatim from dw-domain-fleet/shared/render.js
+ * (commit ee30c6a). The product handle carries 3rd-party vendor tokens
+ * (e.g. "…-arte-international"); these must never reach a customer-facing URL.
+ * redactVendorSlug strips any hyphen-delimited segment matching a denylisted
+ * vendor. The /product/:handle route dual-keys (raw handle OR redacted slug) so
+ * both old and new URLs resolve. The real handle is KEPT on main-store
+ * (designerwallcoverings.com) links so they still resolve there. */
+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 module — read-path from dw_unified.microsite_products + admin CRUD.
 // 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
@@ -103,7 +133,11 @@ app.get('/api/products', (req, res) => {
 
   const total = results.length;
   const pages = Math.ceil(total / per);
-  const slice = results.slice((page - 1) * per, page * per);
+  // Expose a vendor-redacted `slug` for the client to build the /product/ href
+  // with (the raw handle leaks vendor tokens). The real handle stays on the
+  // object for the designerwallcoverings.com sample link the card also renders.
+  const slice = results.slice((page - 1) * per, page * per)
+    .map(p => ({ ...p, slug: redactVendorSlug(p.handle || '') }));
 
   res.json({ total, pages, page, per, products: slice });
 });
@@ -112,7 +146,9 @@ app.get('/api/products', (req, res) => {
 app.get('/api/sliders', (_req, res) => {
   const out = [];
   for (const a of SITE_RAILS) {
-    const items = ALL.filter(p => String(p.aesthetic || '').toLowerCase() === a.toLowerCase()).slice(0, 12);
+    const items = ALL.filter(p => String(p.aesthetic || '').toLowerCase() === a.toLowerCase())
+      .slice(0, 12)
+      .map(p => ({ ...p, slug: redactVendorSlug(p.handle || '') }));   // vendor-redacted href key for the client
     if (items.length >= 4) out.push({ aesthetic: a, items });
   }
   res.json({ rails: out });
@@ -173,7 +209,10 @@ app.get('/api/facets', (req, res) => {
 
 // Single product page
 app.get('/product/:handle', (req, res) => {
-  const p = ALL.find(x => x.handle === req.params.handle);
+  // Dual-key: resolve by the RAW handle (old URLs) OR the vendor-redacted slug
+  // (new vendor-clean URLs the cards/rails now link to). Both round-trip.
+  const key = req.params.handle;
+  const p = ALL.find(x => x.handle === key) || ALL.find(x => redactVendorSlug(x.handle) === key);
   if (!p) return res.status(404).send(page404());
   res.send(productPage(p));
 });
@@ -183,7 +222,7 @@ app.get('/', (_req, res) => res.send(homePage()));
 app.get('/sitemap.xml', (_req, res) => {
   res.set('Content-Type', 'application/xml');
   const urls = ALL.map(p =>
-    `<url><loc>https://customdigitalmurals.com/product/${p.handle}</loc><changefreq>monthly</changefreq></url>`
+    `<url><loc>https://customdigitalmurals.com/product/${redactVendorSlug(p.handle)}</loc><changefreq>monthly</changefreq></url>`
   ).join('\n');
   res.send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`);
 });
@@ -531,7 +570,7 @@ function load(){
     grid.innerHTML=data.products.map(function(p){
       var img=p.images&&p.images[0]?'<img src="'+esc(p.images[0])+'" alt="'+esc(p.title)+'" loading="lazy">':'<div style="height:75%;background:var(--line)"></div>';
       var price=parseFloat(p.price||0)>0?'$'+parseFloat(p.price).toFixed(2)+' / roll':'';
-      return '<div class="product-card" onclick="location.href=\\'/product/'+esc(p.handle)+'\\'">'+
+      return '<div class="product-card" onclick="location.href=\\'/product/'+esc(p.slug||p.handle)+'\\'">'+
         img+
         '<div class="card-body">'+
           '<div class="card-vendor">Designer Wallcoverings</div>'+
@@ -599,7 +638,7 @@ function renderRailSections(rails){
   host.innerHTML = rails.slice(0,4).map(function(r){
     var cards = (r.items||[]).slice(0,8).map(function(p){
       var img = p.images && p.images[0] ? p.images[0] : '';
-      var href = '/product/'+encodeURIComponent(p.handle||'');
+      var href = '/product/'+encodeURIComponent(p.slug||p.handle||'');
       return '<a class="rail-card" href="'+esc(href)+'">'+(img?'<img src="'+esc(img)+'" alt="'+esc(p.title||'')+'" loading="lazy">':'')+'<span class="rail-card-label">'+esc(p.title||'')+'</span></a>';
     }).join('');
     var label = r.aesthetic.charAt(0).toUpperCase()+r.aesthetic.slice(1);
@@ -651,7 +690,7 @@ ${headerHtml('')}
   "brand": { "@type": "Brand", "name": "Designer Wallcoverings" },
   "offers": { "@type": "Offer", "priceCurrency": "USD", "price": p.price || "0", "availability": "https://schema.org/InStock" },
   "image": mainImg,
-  "url": `https://customdigitalmurals.com/product/${p.handle}`
+  "url": `https://customdigitalmurals.com/product/${redactVendorSlug(p.handle)}`
 })}</script>
 
 <div class="product-layout">

← c8239a8 scrub vendor name leaks from products.json source (3132 prod  ·  back to Customdigitalmurals  ·  reconcile: merge prod sort+density toolbar upgrade with the 301d03d →