[object Object]

← back to Dw Photo Capture

yolo hardening (8 fixes from adversarial review): C1 visual bind requires confirm before live push · C2 no auto-bind on low-confidence · W1 escape all $$ fields in staging SQL · W2 downscale onerror+timeout (no HEIC hang) · W3 create-item/photo/photos drain+413 · W4 session token guards stale async rebind · S1 calibration viewport check · S2 poll waits for all expected videos

1570febca380598df7e7d9b553f9ec9946df8488 · 2026-07-06 22:05:57 -0700 · Steve Abrams

Files touched

Diff

commit 1570febca380598df7e7d9b553f9ec9946df8488
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 6 22:05:57 2026 -0700

    yolo hardening (8 fixes from adversarial review): C1 visual bind requires confirm before live push · C2 no auto-bind on low-confidence · W1 escape all $$ fields in staging SQL · W2 downscale onerror+timeout (no HEIC hang) · W3 create-item/photo/photos drain+413 · W4 session token guards stale async rebind · S1 calibration viewport check · S2 poll waits for all expected videos
---
 public/index.html | 50 ++++++++++++++++++++++++++++++--------------------
 server.js         | 16 +++++++++++-----
 2 files changed, 41 insertions(+), 25 deletions(-)

diff --git a/public/index.html b/public/index.html
index e25f8d0..069f5f2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -757,12 +757,14 @@ function lazyThumbs(){
 }
 function downscale(file,max=2000){return new Promise((res)=>{
   const img=new Image(), url=URL.createObjectURL(file);
+  let done=false; const finish=v=>{ if(done)return; done=true; try{URL.revokeObjectURL(url);}catch(e){} res(v); };
   img.onload=()=>{
     let{width:w,height:h}=img; const s=Math.min(1,max/Math.max(w,h)); // never upscale
     const c=document.createElement('canvas'); c.width=Math.round(w*s); c.height=Math.round(h*s);
-    c.getContext('2d').drawImage(img,0,0,c.width,c.height); URL.revokeObjectURL(url);
-    res(c.toDataURL('image/jpeg',0.9));
+    c.getContext('2d').drawImage(img,0,0,c.width,c.height); finish(c.toDataURL('image/jpeg',0.9));
   };
+  img.onerror=()=>finish(null);                          // undecodable (HEIC/corrupt) → null, never hang
+  setTimeout(()=>finish(null),15000);                    // hard timeout safety net
   img.src=url;
 });}
 // Capture → open the editor (review + enhance) instead of uploading immediately.
@@ -1474,7 +1476,7 @@ $('#fbGo').addEventListener('click',fbIdentify);
 const MAX_PHOTOS=10, MAX_VIDEOS=6;
 let _addPhoto=null,_vendorsLoaded=false,_addExtracted={};
 let _media=[];                                    // {type:'photo'|'video', dataUrl, name}
-let _addMode='add', _updatePid=null, _updateSku=null, _extracted=false;
+let _addMode='add', _updatePid=null, _updateSku=null, _extracted=false, _addSession=0;
 function mediaCounts(){ return { p:_media.filter(m=>m.type==='photo').length, v:_media.filter(m=>m.type==='video').length }; }
 function renderMedia(){ const c=mediaCounts();
   $('#cPhoto').textContent=c.p+'/'+MAX_PHOTOS; $('#cVideo').textContent=c.v+'/'+MAX_VIDEOS;
@@ -1487,13 +1489,14 @@ function addResetMedia(){ _media=[]; _addPhoto=null; _addExtracted={}; _extracte
 function fileToDataUrl(f){ return new Promise(r=>{ const fr=new FileReader(); fr.onload=()=>r(fr.result); fr.readAsDataURL(f); }); }
 async function addMediaAdd(type,file){ if(!file)return; const c=mediaCounts();
   if(type==='photo'){ if(c.p>=MAX_PHOTOS){ toast('Max '+MAX_PHOTOS+' photos'); return; }
-    const url=await downscale(file,1400); _media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia();
+    const url=await downscale(file,1400); if(!url){ toast('Could not read that photo — try again or use a JPEG'); return; }
+    _media.push({type:'photo',dataUrl:url}); if(!_addPhoto)_addPhoto=url; renderMedia();
     if(!_extracted){ _extracted=true; addExtract(); }             // first photo = the scan → read specs
   } else { if(c.v>=MAX_VIDEOS){ toast('Max '+MAX_VIDEOS+' videos'); return; }
     if(file.size>150*1024*1024){ toast('Video too big (>150MB)'); return; }
     const dataUrl=await fileToDataUrl(file); _media.push({type:'video',dataUrl,name:file.name||'clip.mp4'}); renderMedia(); } }
 async function loadVendors(){ if(_vendorsLoaded) return; try{ const r=await(await fetch('/api/vendors')).json(); $('#addVendor').innerHTML='<option value="">— pick vendor —</option>'+(r.vendors||[]).map(v=>`<option value="${v.vendor}" data-vid="${v.vid||''}">${v.vendor}${v.vid?(' ('+v.vid+')'):''}</option>`).join(''); _vendorsLoaded=true; }catch(e){} }
-function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; addResetMedia();
+function openAddModal(pref,opts){ opts=opts||{}; _addMode=opts.mode||'add'; _addSession++; addResetMedia();
   ['addMfr','addVid','addName','addColor','addPrice'].forEach(id=>$('#'+id).value=''); $('#addNote').textContent=''; $('#addResult').innerHTML='';
   $('#addTitle').textContent = _addMode==='update' ? 'Update SKU on Shopify' : 'Add new item';
   $('#addTarget').textContent = _addMode==='update' ? 'Shoot the label to find the SKU, then add photos + videos.' : '';
@@ -1511,14 +1514,15 @@ async function uploadVideos(product_id, dwSku){ const vids=addVideosList(); let
     try{ const r=await(await fetch('/api/video',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id,dw_sku:dwSku,dataUrl:vids[i].dataUrl})})).json(); if(r.ok)ok++; else failed++; }catch(e){ failed++; } }
   return { ok, failed }; }
 // Shopify transcodes video async — poll until every clip is READY (or a couple minutes pass).
-async function pollVideoStatus(product_id, baseMsg){ const started=Date.now();
+async function pollVideoStatus(product_id, baseMsg, expected){ const started=Date.now(); expected=expected||1;
   while(Date.now()-started < 150000){
     await new Promise(r=>setTimeout(r,5000));
     let s; try{ s=await(await fetch('/api/media-status?product_id='+encodeURIComponent(product_id))).json(); }catch(e){ continue; }
     if(!s || !s.ok) continue;
-    if(s.all_ready){ $('#addNote').innerHTML=`${baseMsg} · 🎥 ${s.ready} video(s) ready ✓`; toast('🎥 Videos ready on Shopify'); return; }
     if(s.failed){ $('#addNote').innerHTML=`${baseMsg} · ⚠ ${s.failed} video(s) failed to process`; return; }
-    $('#addNote').innerHTML=`${baseMsg} · 🎥 transcoding ${s.ready}/${s.total}…`;
+    // don't declare "ready" until ALL expected clips have registered AND finished (a 2nd clip can lag)
+    if(s.all_ready && s.total>=expected){ $('#addNote').innerHTML=`${baseMsg} · 🎥 ${s.ready} video(s) ready ✓`; toast('🎥 Videos ready on Shopify'); return; }
+    $('#addNote').innerHTML=`${baseMsg} · 🎥 transcoding ${s.ready}/${Math.max(s.total,expected)}…`;
   }
   $('#addNote').innerHTML=`${baseMsg} · 🎥 still transcoding — check Shopify shortly.`;
 }
@@ -1539,7 +1543,7 @@ async function addCommit(){ const b=$('#addCommit'); b.disabled=true; $('#addNot
   if(hadVideos && r.product_id){ vup=await uploadVideos(r.product_id, r.dw_sku); vmsg=` + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:''); }
   const baseMsg=`✅ Created DRAFT · ${r.dw_sku}${vmsg}`;
   $('#addNote').innerHTML=`${baseMsg} <small>— not live; publish in Shopify when ready</small>`; toast('✅ Draft created: '+r.dw_sku);
-  if(hadVideos && r.product_id) pollVideoStatus(r.product_id, baseMsg);
+  if(vup.ok && r.product_id) pollVideoStatus(r.product_id, baseMsg, vup.ok);
 }
 // UPDATE mode: resolve the existing product from the scanned mfr#, then push media LIVE.
 // resolve an mfr# / sku to a live Shopify product_id and bind it as the Update target
@@ -1557,17 +1561,20 @@ async function visualIdentify(){ const photos=addPhotosList(); if(!photos.length
   let r; try{ r=await(await fetch('/api/identify-multi',{method:'POST',headers:{'Content-Type':'application/json'},
     body:JSON.stringify({back:photos[0], front:photos[photos.length-1]})})).json(); }catch(e){ $('#addTarget').textContent='Visual ID failed.'; return; }
   const cands=(r.visual||[]).slice(0,4), prim=r.primary; let bound=null;
-  if(prim) bound=await bindProduct(prim.mfr_sku, prim.dw_sku);
-  if(!bound) for(const c of cands){ bound=await bindProduct(c.mfr_sku, c.dw_sku); if(bound)break; }
-  if(bound){ if(prim&&prim.mfr_sku&&!$('#addMfr').value)$('#addMfr').value=prim.mfr_sku;
-    $('#addTarget').innerHTML=`🔍 Visually matched <b>${bound.title||_updateSku}</b> <small>(${r.confidence||'match'} · ${_updateSku})</small>`; }
+  // AUTO-bind ONLY a confident primary. Low-confidence or candidate matches require an explicit
+  // tap — never silently bind a live customer product off an ambiguous pattern photo.
+  if(prim && r.confidence && r.confidence!=='low') bound=await bindProduct(prim.mfr_sku, prim.dw_sku);
+  if(bound){ if(prim.mfr_sku&&!$('#addMfr').value)$('#addMfr').value=prim.mfr_sku;
+    $('#addTarget').innerHTML=`🔍 Visually matched <b>${bound.title||_updateSku}</b> <small>(${r.confidence} · ${_updateSku})</small>`; }
   else if(cands.length){ _vidCands=cands;
-    $('#addTarget').innerHTML='🔍 Closest patterns — tap to use:<div class="vid-cands">'+cands.map((c,i)=>`<button class="vid-chip" data-i="${i}">${((c.pattern||c.mfr_sku||'?')+'').slice(0,20)}</button>`).join('')+'</div>';
+    $('#addTarget').innerHTML='🔍 Closest patterns — <b>tap to confirm</b>:<div class="vid-cands">'+cands.map((c,i)=>`<button class="vid-chip" data-i="${i}">${((c.pattern||c.mfr_sku||'?')+'').slice(0,20)}</button>`).join('')+'</div>';
     document.querySelectorAll('#addTarget .vid-chip').forEach(b=>b.addEventListener('click',async()=>{ const c=_vidCands[+b.dataset.i]; const bd=await bindProduct(c.mfr_sku,c.dw_sku); if(bd){ if(c.mfr_sku)$('#addMfr').value=c.mfr_sku; $('#addTarget').innerHTML=`🔍 Using <b>${bd.title||_updateSku}</b>`; } else $('#addTarget').innerHTML='That pattern has no live product — use Add New SKU.'; })); }
-  else $('#addTarget').innerHTML='No visual match — fix the mfr# or use Add New SKU.'; }
+  else $('#addTarget').innerHTML='No confident match — fix the mfr# or use Add New SKU.'; }
 let _vidCands=[];
 async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
-  if(!_updatePid) await visualIdentify();   // last resort: match by the pattern itself
+  if(!_updatePid){ await visualIdentify();   // last resort: match by the pattern itself
+    if(_updatePid){   // a bind just happened silently during Push → REQUIRE a 2nd press to confirm the LIVE write
+      $('#addNote').innerHTML=`⚠ Matched <b>${_updateSku}</b> by pattern — tap Push again to confirm the LIVE update.`; return; } }
   if(!_updatePid){ $('#addNote').textContent='No product to update — scan the label/pattern or fix the mfr#.'; return; }
   const photos=addPhotosList(); if(!photos.length && !addVideosList().length){ $('#addNote').textContent='Add at least one photo or video.'; return; }
   $('#addNote').textContent='⬆ Pushing to Shopify (LIVE)…'; let added=0;
@@ -1576,7 +1583,7 @@ async function addUpdate(){ if(!_updatePid) await addResolveUpdate();
   const vup=hadVideos ? await uploadVideos(_updatePid,_updateSku) : {ok:0,failed:0};
   const baseMsg=`✅ LIVE: ${added} photo(s) + ${vup.ok} video(s)`+(vup.failed?` (${vup.failed} failed)`:'')+` → ${_updateSku}`;
   $('#addNote').innerHTML=baseMsg; toast('✅ Updated '+_updateSku+' live');
-  if(hadVideos && vup.ok) pollVideoStatus(_updatePid, baseMsg);
+  if(vup.ok) pollVideoStatus(_updatePid, baseMsg, vup.ok);
 }
 $('#addBtn').addEventListener('click',()=>openAddModal());
 $('#addClose').addEventListener('click',()=>{ $('#addModal').hidden=true; document.body.style.overflow=''; });
@@ -1588,8 +1595,9 @@ $('#addVideoInput').addEventListener('change',e=>{ const files=[...e.target.file
 $('#addPreview').addEventListener('click',addPreview);
 // import AS MUCH INFO AS POSSIBLE from the label photo → prefill every field, then VOICE ONLY as
 // needed: if a required field is still blank, the app speaks the prompt + listens (dropdown stays).
-async function addExtract(){ if(!_addPhoto)return; $('#addNote').textContent='📷 Reading label…';
+async function addExtract(){ if(!_addPhoto)return; const mySession=_addSession; $('#addNote').textContent='📷 Reading label…';
   try{ const r=await(await fetch('/api/extract',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl:_addPhoto})})).json();
+    if(_addSession!==mySession) return;   // modal was closed/reopened mid-request — discard this stale result
     const f=r.fields||{}; _addExtracted=f; const setIf=(id,v)=>{ if(v&&!$('#'+id).value) $('#'+id).value=v; };
     setIf('addMfr',f.mfr_sku); setIf('addName',f.pattern_name); setIf('addColor',f.color); setIf('addPrice',String(f.price||'').replace(/[^0-9.]/g,''));
     if(r.vendor_matched){ await loadVendors(); const opt=[...$('#addVendor').options].find(o=>o.value.toLowerCase()===r.vendor_matched.toLowerCase()); if(opt){ $('#addVendor').value=opt.value; if(opt.dataset.vid&&!$('#addVid').value)$('#addVid').value=opt.dataset.vid; } }
@@ -1656,8 +1664,10 @@ async function openMeasure(){
   Object.assign($('#mCard').style,{left:'26px',top:'96px',width:'135px',height:'85px'});
   Object.assign($('#mSample').style,{left:'56px',top:'200px',width:'210px',height:'270px'});
   const cal=calGet();
-  if(cal&&cal.ppi){ _ppi=cal.ppi; mSetMode('measure'); }   // reuse saved calibration — no card
-  else mSetMode('calibrate'); }                             // first time on this device → calibrate
+  // reuse the saved calibration only if the viewport matches — ppi is in CSS px, so a different
+  // screen width (rotation / different device) would make the stored ppi wrong.
+  if(cal&&cal.ppi&&(!cal.sw||Math.abs(cal.sw-window.innerWidth)<=2)){ _ppi=cal.ppi; mSetMode('measure'); }
+  else { if(cal&&cal.ppi) toast('Screen changed — quick one-time recalibrate'); mSetMode('calibrate'); } }                             // first time on this device → calibrate
 function closeMeasure(){ if(_mStream){ _mStream.getTracks().forEach(t=>t.stop()); _mStream=null; } $('#measureModal').hidden=true; document.body.style.overflow=''; }
 mMakeBox($('#mCard')); mMakeBox($('#mSample'));
 $('#addMeasure').addEventListener('click',openMeasure);
diff --git a/server.js b/server.js
index 8264ebb..0144eae 100644
--- a/server.js
+++ b/server.js
@@ -1164,8 +1164,9 @@ const appHandler = (req, res) => {
   // Add a NEW item → Shopify DRAFT + dw_unified staging. dryRun (default) PREVIEWS; commit:true writes.
   // Never auto-published (going live is a separate gated step); dedups on mfr#.
   if (u.pathname === '/api/create-item' && req.method === 'POST') {
-    let body = ''; req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });
+    let body = '', _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 25 * 1024 * 1024) _big = true; });
     req.on('end', async () => {
+      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
       if (!TOKEN) return send(res, 200, { ok: false, err: 'no Shopify token' });
       const strip = s => s ? s.replace(/^data:image\/\w+;base64,/, '') : null;
@@ -1489,8 +1490,9 @@ const appHandler = (req, res) => {
 
   if (u.pathname === '/api/photo' && req.method === 'POST') {
     let body = '';
-    req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });
+    let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 25 * 1024 * 1024) _big = true; });
     req.on('end', async () => {
+      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
       const { dw_sku, product_id, dataUrl, push, keep_images, meta } = p;
       if (!dw_sku || !dataUrl) return send(res, 400, { err: 'dw_sku + dataUrl required' });
@@ -1537,8 +1539,9 @@ const appHandler = (req, res) => {
   // Body: { dw_sku, product_id, dataUrls:[...], meta }
   if (u.pathname === '/api/photos' && req.method === 'POST') {
     let body = '';
-    req.on('data', c => { body += c; if (body.length > 60 * 1024 * 1024) req.destroy(); });
+    let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 60 * 1024 * 1024) _big = true; });
     req.on('end', async () => {
+      if (_big) return send(res, 413, { ok: false, err: 'body too large' });
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
       const { dw_sku, product_id, dataUrls, meta } = p;
       if (!dw_sku || !product_id || !Array.isArray(dataUrls) || !dataUrls.length)
@@ -1733,9 +1736,12 @@ async function createNewItem(p, b64, dryRun) {
     const specsObj = { material, collection: p.collection || '', width: p.width || '', roll_length: p.roll_length || '',
       repeat: p.repeat || '', pattern_match: p.pattern_match || '', substrate: p.substrate || '',
       how_sold: p.how_sold || '', price_code: p.price_code || '' };
-    const specsJson = JSON.stringify(specsObj).replace(/\$\$/g, '$');   // keep dollar-quoting safe
+    // $$-dollar-quoting: a stray "$$" in ANY value would break the quote → strip it from EVERY
+    // interpolated string (not just specs) so a vendor/mfr like "Foo $$ Bar" can't malform the SQL.
+    const esc = s => String(s == null ? '' : s).replace(/\$\$/g, '$');
+    const specsJson = esc(JSON.stringify(specsObj));
     const stageSQL = `insert into new_items_staging (dw_sku, mfr_sku, vendor, vid, pattern_name, color, price, specs, shopify_product_id, created_via)
-      values ($$${dwsku}$$,$$${mfr}$$,$$${vendor}$$,$$${p.vid || ''}$$,$$${name}$$,$$${color}$$,${price || 'NULL'},$$${specsJson}$$::jsonb,${pid || 'NULL'},$$scan$$) on conflict do nothing`;
+      values ($$${esc(dwsku)}$$,$$${esc(mfr)}$$,$$${esc(vendor)}$$,$$${esc(p.vid || '')}$$,$$${esc(name)}$$,$$${esc(color)}$$,${price || 'NULL'},$$${specsJson}$$::jsonb,${pid || 'NULL'},$$scan$$) on conflict do nothing`;
     execFile(PSQL, ['-d', DW_DB, '-c', 'create table if not exists new_items_staging (id bigserial primary key, dw_sku text, mfr_sku text, vendor text, vid text, pattern_name text, color text, price numeric, shopify_product_id bigint, created_via text, created_at timestamptz default now()); alter table new_items_staging add column if not exists specs jsonb; ' + stageSQL], { timeout: 8000 }, () => {});
     if (pid) CATALOG.push({ product_id: pid, title, status: 'DRAFT', dw_sku: dwsku, mfr, price, image: null, done: false });
     return { ok: true, product_id: pid, dw_sku: dwsku, title, status: 'draft', preview };

← a2500f8 Calibrate-once measurement: card sizes px/in for the device  ·  back to Dw Photo Capture  ·  Contrarian gate fix: _updateNeedsConfirm invariant — ANY amb 5673cad →