[object Object]

← back to Wallco

Fix dead Color/Style sort options, harden HTML escaping, add API + global error handling

67bf8cb95940606eeb0ef75d8e70fefcff449615 · 2026-05-18 20:30:08 -0700 · Steve Abrams

Files touched

Diff

commit 67bf8cb95940606eeb0ef75d8e70fefcff449615
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 18 20:30:08 2026 -0700

    Fix dead Color/Style sort options, harden HTML escaping, add API + global error handling
---
 server.js | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++-------------
 1 file changed, 69 insertions(+), 17 deletions(-)

diff --git a/server.js b/server.js
index bf53010..059ea4e 100644
--- a/server.js
+++ b/server.js
@@ -532,16 +532,22 @@ app.get('/', (_req, res) => {
           .catch(function(err){ status.innerHTML = '<div class="error">Upload failed: '+err.message+'</div>'; });
       });
 
+      function he(s) {
+        return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
+          return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c];
+        });
+      }
+      function safeHex(h) { return /^#?[0-9a-fA-F]{3,8}$/.test(String(h||'')) ? String(h) : '#ccc'; }
       function render(j) {
         if (!j || !j.ok) {
-          status.innerHTML = '<div class="error">'+(j && j.message ? j.message : 'Something went wrong')+'</div>';
+          status.innerHTML = '<div class="error">'+he(j && j.message ? j.message : 'Something went wrong')+'</div>';
           return;
         }
         status.innerHTML = '';
-        var swatches = (j.analysis.colors||[]).map(function(c){ return '<div class="swatch"><div class="chip" style="background:'+(c.hex||'#ccc')+'"></div>'+(c.name||c.hex||'')+'</div>'; }).join('');
-        var styles = (j.analysis.styles||[]).map(function(s){ return '<div class="tag style">'+s+'</div>'; }).join('');
-        var keywords = (j.analysis.keywords||[]).map(function(k){ return '<div class="tag">'+k+'</div>'; }).join('');
-        var notice = j.analysis_ok ? '' : '<div class="error" style="margin-bottom:14px">'+(j.notice||'AI read unavailable; showing curated picks.')+'</div>';
+        var swatches = (j.analysis.colors||[]).map(function(c){ return '<div class="swatch"><div class="chip" style="background:'+safeHex(c.hex)+'"></div>'+he(c.name||c.hex||'')+'</div>'; }).join('');
+        var styles = (j.analysis.styles||[]).map(function(s){ return '<div class="tag style">'+he(s)+'</div>'; }).join('');
+        var keywords = (j.analysis.keywords||[]).map(function(k){ return '<div class="tag">'+he(k)+'</div>'; }).join('');
+        var notice = j.analysis_ok ? '' : '<div class="error" style="margin-bottom:14px">'+he(j.notice||'AI read unavailable; showing curated picks.')+'</div>';
         result.innerHTML =
           notice +
           '<div class="preview">' +
@@ -580,24 +586,46 @@ app.get('/', (_req, res) => {
           var savedCm = localStorage.getItem('wallco-cm');
           if (savedCm) { cm.value = savedCm; grid.style.setProperty('--card-min', savedCm+'px'); }
         } catch(e) {}
+        var COLOR_RX = [
+          ['1black', /\\b(black|noir|onyx|jet|midnight)\\b/i],
+          ['2white', /\\b(white|ivory|cream|alabaster|chalk)\\b/i],
+          ['3gray', /\\b(gray|grey|charcoal|graphite|ash|smoke|silver)\\b/i],
+          ['4brown', /\\b(brown|tan|taupe|sand|khaki|camel|chocolate|caramel|sepia|beige)\\b/i],
+          ['5red', /\\b(red|crimson|scarlet|ruby|brick|oxblood|cinnabar)\\b/i],
+          ['6orange', /\\b(orange|terracotta|copper|rust|amber|tangerine)\\b/i],
+          ['7yellow', /\\b(yellow|gold|ochre|saffron|honey|mustard|citrine)\\b/i],
+          ['8green', /\\b(green|sage|olive|moss|emerald|jade|fern|forest|seafoam|seaglass)\\b/i],
+          ['9blue', /\\b(blue|navy|cobalt|indigo|sapphire|aqua|teal|turquoise|aegean|sky)\\b/i],
+          ['Apurple', /\\b(purple|violet|lavender|plum|aubergine|mauve|lilac)\\b/i],
+          ['Bpink', /\\b(pink|rose|blush|coral|fuchsia|magenta)\\b/i]
+        ];
+        function bucketKey(p, rx) {
+          var hay = (p.title||'') + ' ' + (p.product_type||'');
+          for (var i=0;i<rx.length;i++) if (rx[i][1].test(hay)) return rx[i][0];
+          return 'Zother';
+        }
         sort.addEventListener('change', function(){
           try{localStorage.setItem('wallco-sort', sort.value);}catch(e){}
           var arr = orig.slice();
           if (sort.value === 'sku') arr.sort(function(a,b){ return (a.dw_sku||'').localeCompare(b.dw_sku||''); });
           else if (sort.value === 'title') arr.sort(function(a,b){ return (a.title||'').localeCompare(b.title||''); });
-          else if (sort.value === 'newest') arr = orig.slice();
+          else if (sort.value === 'color') arr.sort(function(a,b){ return bucketKey(a,COLOR_RX).localeCompare(bucketKey(b,COLOR_RX)) || (a.title||'').localeCompare(b.title||''); });
+          else if (sort.value === 'style') arr.sort(function(a,b){ return (a.product_type||'~').localeCompare(b.product_type||'~') || (a.title||'').localeCompare(b.title||''); });
+          else arr = orig.slice(); // match score / newest = server order
           grid.innerHTML = arr.map(cardHtml).join('');
         });
         cm.addEventListener('input', function(){ grid.style.setProperty('--card-min', cm.value+'px'); try{localStorage.setItem('wallco-cm', cm.value);}catch(e){} });
         try { gtag('event','room_searched',{ matches:(j.matches||[]).length, mood:j.analysis.mood||'' }); } catch(e){}
       }
       function cardHtml(p) {
-        var memo = '${DW_SHOPIFY}/products/' + (p.handle||'') + '#sample';
-        var view = '${DW_SHOPIFY}/products/' + (p.handle||'');
+        var handle = he(p.handle||'');
+        var memo = '${DW_SHOPIFY}/products/' + handle + '#sample';
+        var view = '${DW_SHOPIFY}/products/' + handle;
+        var img = p.image_url ? '<img loading="lazy" src="'+he(p.image_url)+'" alt="'+he(p.title)+'">' : '';
         return '<article class="card">' +
-          '<a class="thumb" href="'+view+'" target="_blank" rel="noopener">' + (p.image_url ? '<img loading="lazy" src="'+p.image_url+'" alt="'+(p.title||'').replace(/"/g,'&quot;')+'">' : '') + '</a>' +
-          '<div class="info"><div class="t">'+(p.title||'').replace(/</g,'&lt;')+'</div>' +
-          '<div class="sub">'+(p.dw_sku||'')+(p.product_type?(' · '+p.product_type):'')+'</div></div>' +
+          '<a class="thumb" href="'+view+'" target="_blank" rel="noopener">' + img + '</a>' +
+          '<div class="info"><div class="t">'+he(p.title)+'</div>' +
+          '<div class="sub">'+he(p.dw_sku)+(p.product_type?(' · '+he(p.product_type)):'')+'</div></div>' +
           '<div class="actions">' +
           '<a href="'+view+'" target="_blank" rel="noopener">View</a>' +
           '<a class="cta" href="'+memo+'" target="_blank" rel="noopener">Order Memo</a>' +
@@ -652,9 +680,15 @@ app.get('/about', (_req, res) => {
 app.post('/api/search', async (req, res) => {
   const prose = String(req.body?.q || '').trim();
   if (!prose) return res.status(400).json({ ok: false, message: 'No description provided.' });
-  const analysis = await geminiAnalyzeText(prose);
-  const signal = buildSignal(analysis, prose);
-  const matches = topMatches(signal, 24);
+  let analysis, signal, matches;
+  try {
+    analysis = await geminiAnalyzeText(prose);
+    signal = buildSignal(analysis, prose);
+    matches = topMatches(signal, 24);
+  } catch (err) {
+    console.error('search failed:', err);
+    return res.status(500).json({ ok: false, message: 'Search failed. Please try again.' });
+  }
   res.set('Cache-Control', 'no-store').json({
     ok: true,
     analysis_ok: analysis.ok,
@@ -675,9 +709,15 @@ app.post('/api/search', async (req, res) => {
 
 app.post('/api/match-photo', upload.single('photo'), async (req, res) => {
   if (!req.file) return res.status(400).json({ ok: false, message: 'No photo uploaded.' });
-  const analysis = await geminiAnalyzeImage(req.file.buffer, req.file.mimetype);
-  const signal = buildSignal(analysis, '');
-  const matches = topMatches(signal, 24);
+  let analysis, signal, matches;
+  try {
+    analysis = await geminiAnalyzeImage(req.file.buffer, req.file.mimetype);
+    signal = buildSignal(analysis, '');
+    matches = topMatches(signal, 24);
+  } catch (err) {
+    console.error('match-photo failed:', err);
+    return res.status(500).json({ ok: false, message: 'Photo match failed. Please try again.' });
+  }
   res.set('Cache-Control', 'no-store').json({
     ok: true,
     analysis_ok: analysis.ok,
@@ -701,6 +741,18 @@ app.get('/health', (_req, res) => res.json({
   ga: GA_ID,
 }));
 
+// Global error handler — catches multer file-size rejections and any
+// uncaught route error so requests fail cleanly instead of hanging.
+app.use((err, req, res, _next) => {
+  if (res.headersSent) return;
+  const tooLarge = err && err.code === 'LIMIT_FILE_SIZE';
+  console.error('request error:', err && err.message ? err.message : err);
+  res.status(tooLarge ? 413 : 500).json({
+    ok: false,
+    message: tooLarge ? 'Image is too large (15 MB max).' : 'Server error. Please try again.',
+  });
+});
+
 app.listen(PORT, BIND, () => {
   console.log(`wallco.ai on http://${BIND}:${PORT} · ${PRODUCTS.length} products · ga=${GA_ID} · gemini_key=${!!GEMINI_API_KEY}`);
 });

← 1946431 initial scaffold + GA gtag (G-H47K8H8G5J) — wallco.ai AI sea  ·  back to Wallco  ·  scrub vendor name leaks from products.json source (176 produ 0cb038c →