[object Object]

← back to Prestige Car Wash

Harden booking form: honeypot + validation + dupe-guard + resilient client

7c66f9d1a3d049a4441e59aad40bb8fb3bf2f98e · 2026-07-26 21:30:19 -0700 · Steve Abrams

DTD unanimous 5/5 (B). POST /api/contact: honeypot (non-demographic field name so autofill
can't false-drop a real lead; drops are logged for audit), human-readable validation
(name/phone/email), and a 120s duplicate-submit guard. contact.html: double-submit lock,
client validation mirror, network-failure handling, consistent red/green result states.
Contrarian FIX-FIRST resolved: renamed honeypot company->b_confirm (autofill-safe),
console.warn on drop (observability), fixed error-state background tint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7c66f9d1a3d049a4441e59aad40bb8fb3bf2f98e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 21:30:19 2026 -0700

    Harden booking form: honeypot + validation + dupe-guard + resilient client
    
    DTD unanimous 5/5 (B). POST /api/contact: honeypot (non-demographic field name so autofill
    can't false-drop a real lead; drops are logged for audit), human-readable validation
    (name/phone/email), and a 120s duplicate-submit guard. contact.html: double-submit lock,
    client validation mirror, network-failure handling, consistent red/green result states.
    Contrarian FIX-FIRST resolved: renamed honeypot company->b_confirm (autofill-safe),
    console.warn on drop (observability), fixed error-state background tint.
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/contact.html | 28 ++++++++++++++++++++++------
 server.js           | 32 +++++++++++++++++++++++++++++---
 2 files changed, 51 insertions(+), 9 deletions(-)

diff --git a/public/contact.html b/public/contact.html
index 0ee7f51..fadaac8 100644
--- a/public/contact.html
+++ b/public/contact.html
@@ -17,6 +17,8 @@
   .two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
   @media(max-width:520px){.two{grid-template-columns:1fr}}
   .ok{background:rgba(58,210,159,.12);border:1px solid var(--good);color:var(--good);padding:12px;border-radius:10px;margin-bottom:14px;display:none}
+  /* Honeypot: off-screen, non-tabbable, hidden from AT — only bots fill it. */
+  .hp{position:absolute!important;left:-9999px!important;top:auto;width:1px;height:1px;opacity:0;overflow:hidden}
 </style>
 </head>
 <body>
@@ -34,6 +36,7 @@
   <div id="waitchip" style="display:none;margin:0 0 18px;padding:8px 15px;border:1px solid var(--line);border-radius:999px;font-size:14px;width:fit-content" title=""></div>
   <div class="ok" id="ok"></div>
   <form id="f">
+    <div class="hp" aria-hidden="true"><label>Leave this field empty<input type="text" name="b_confirm" tabindex="-1" autocomplete="off"></label></div>
     <div class="two">
       <div class="fld"><label>Name *</label><input name="name" required></div>
       <div class="fld"><label>Phone *</label><input name="phone" type="tel"></div>
@@ -88,14 +91,27 @@ fetch('/api/services?sort=featured').then(r=>r.json()).then(list=>{
     }
   }
 });
+let _submitting=false;
 document.getElementById('f').addEventListener('submit',async e=>{
   e.preventDefault();
-  const data=Object.fromEntries(new FormData(e.target).entries());
-  const r=await fetch('/api/contact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
-  const j=await r.json();
-  const ok=document.getElementById('ok');
-  if(j.ok){ok.textContent=j.message;ok.style.display='block';e.target.reset();window.scrollTo({top:0,behavior:'smooth'});}
-  else{ok.textContent=j.error||'Something went wrong.';ok.style.borderColor='var(--bad)';ok.style.color='var(--bad)';ok.style.display='block';}
+  if(_submitting) return;                       // duplicate-submit guard (double-click / Enter-mash)
+  const form=e.target, ok=document.getElementById('ok');
+  const data=Object.fromEntries(new FormData(form).entries());
+  const showErr=m=>{ok.textContent=m;ok.style.borderColor='var(--bad)';ok.style.color='var(--bad)';ok.style.background='rgba(255,107,107,.10)';ok.style.display='block';};
+  // Client-side validation (the server re-validates — this is just a fast, friendly first pass).
+  if(!data.name||data.name.trim().length<2){showErr('Please enter your name.');return;}
+  const phoneOk=data.phone&&data.phone.replace(/\D/g,'').length>=7;
+  const emailOk=data.email&&/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email);
+  if(!phoneOk&&!emailOk){showErr('Please leave a phone number or email so we can reach you.');return;}
+  const btn=form.querySelector('button[type=submit]');
+  _submitting=true; if(btn){btn.disabled=true;btn.dataset.t=btn.textContent;btn.textContent='Sending…';}
+  try{
+    const r=await fetch('/api/contact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
+    const j=await r.json();
+    if(j.ok){ok.textContent=j.message;ok.style.borderColor='var(--good)';ok.style.color='var(--good)';ok.style.background='rgba(58,210,159,.12)';ok.style.display='block';form.reset();window.scrollTo({top:0,behavior:'smooth'});}
+    else{showErr(j.error||'Something went wrong — please try again.');}
+  }catch{ showErr('Could not reach the server — please check your connection and try again.'); }
+  finally{ _submitting=false; if(btn){btn.disabled=false;btn.textContent=btn.dataset.t||'Request booking';} }
 });
 </script>
 </body>
diff --git a/server.js b/server.js
index 365dbb1..45302b6 100644
--- a/server.js
+++ b/server.js
@@ -283,13 +283,39 @@ const cap = (s, n) => String(s || '').slice(0, n);
 // without it the lead is captured in the admin Leads tab. No mass email (that stays gated).
 app.post('/api/contact', rateLimit(5, 10 * 60 * 1000), (req, res) => {
   const b = req.body || {};
-  if (!b.name || !(b.phone || b.email)) return res.status(400).json({ ok: false, error: 'name + phone/email required' });
+  const OK_MSG = "Thanks! We'll follow up to confirm your booking — usually within a few hours.";
+  // Honeypot: a hidden field real users never see. Field name is a NON-demographic token
+  // (b_confirm) so password managers / mobile autofill won't populate it for a real user and
+  // silently drop their lead. If it's filled it's a bot — fake success (no retry), drop it,
+  // but LOG the drop so a false-positive is auditable instead of an invisible lost booking.
+  if (b.b_confirm) { console.warn('[honeypot] dropped submission ip=%s name=%s', req.ip, cap(b.name, 60)); return res.json({ ok: true, message: OK_MSG }); }
+  const name = cap(b.name, 120).trim();
+  const phone = cap(b.phone, 40).trim();
+  const email = cap(b.email, 160).trim();
+  const emailOk = !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+  const phoneOk = !phone || phone.replace(/\D/g, '').length >= 7;
+  // Validate with human-readable errors so a real lead is never silently lost to a bad submit.
+  if (name.length < 2) return res.status(400).json({ ok: false, error: 'Please enter your name.' });
+  if (!phone && !email) return res.status(400).json({ ok: false, error: 'Please leave a phone number or email so we can reach you.' });
+  if (!emailOk) return res.status(400).json({ ok: false, error: 'That email address doesn’t look right — please double-check it.' });
+  if (!phoneOk) return res.status(400).json({ ok: false, error: 'That phone number looks too short — please double-check it.' });
   const lead = {
     id: 'ld_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
-    name: cap(b.name, 120), phone: cap(b.phone, 40), email: cap(b.email, 160), vehicle: cap(b.vehicle, 120),
-    service: cap(b.service, 120), preferred: cap(b.preferred, 120), message: cap(b.message, 1000),
+    name, phone, email, vehicle: cap(b.vehicle, 120), service: cap(b.service, 120),
+    preferred: cap(b.preferred, 120), message: cap(b.message, 1000),
     created_at: new Date().toISOString(), source: 'web-form'
   };
+  // Duplicate-submit guard: if the same person (name + phone + email + message) already
+  // landed within the last 2 minutes, treat it as a double-click — succeed without saving twice.
+  try {
+    const lp = path.join(__dirname, 'reports', 'leads.jsonl');
+    if (fs.existsSync(lp)) {
+      const recent = fs.readFileSync(lp, 'utf8').trim().split('\n').slice(-25);
+      const now = Date.now();
+      const dup = recent.some(l => { try { const o = JSON.parse(l); return (now - new Date(o.created_at).getTime() < 120000) && o.name === name && (o.phone || '') === phone && (o.email || '') === email && (o.message || '') === lead.message; } catch { return false; } });
+      if (dup) return res.json({ ok: true, message: "Thanks — we’ve already got your request and we’ll follow up shortly." });
+    }
+  } catch { /* if the dedupe read fails, fall through and save (never block a real lead) */ }
   try {
     const dir = path.join(__dirname, 'reports');
     fs.mkdirSync(dir, { recursive: true });

← 61afb84 Path B: self-canonicalize to prestige.agentabrams.com (stop  ·  back to Prestige Car Wash  ·  Admin: lead worklist (click-to-call/email + follow-up-first 2062a31 →