← 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
M public/contact.htmlM server.js
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 →