diff --git a/README.md b/README.md index 46c46e4..2e6b2e0 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,10 @@ Scripts: `npm run build`, `npm start`, `npm run typecheck`, `npm test`. `/contact` posts to `app/api/contact/route.ts`, which validates the submission (honeypot, time-to-submit, rate limit, consent) and emails it over -SMTP with Nodemailer, the same way 2060.io-website does. Recipients come from +SMTP with Nodemailer, the same way 2060.io-website does. A submission dropped +by the honeypot or the timing check still answers success to the browser, but +the pod log says why (`[contact] dropped: ...`); a delivered inquiry logs +`[contact] inquiry emailed`. Recipients come from `CONTACT_TO` with per-topic overrides (`CONTACT_TO_CAREERS`, ...). Career applications attach the PDF the candidate uploads. With `MAIL_HOST` unset the form accepts and logs submissions without delivering them. diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts index 8ee77be..3e9dbc5 100644 --- a/app/api/contact/route.ts +++ b/app/api/contact/route.ts @@ -14,6 +14,7 @@ export const dynamic = "force-dynamic"; const MIN_MESSAGE = 50; const MAX_MESSAGE = 4000; const MAX_CV_BYTES = 10 * 1024 * 1024; +const MIN_ELAPSED_MS = 2500; // Naive in-memory rate limit: the deployment runs a single replica, so a // per-process map is a sufficient best-effort guard (same as 2060.io-website). @@ -83,12 +84,21 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: false, error: "bad_request" }, { status: 400 }); } - // Honeypot: a filled hidden field means a bot. Pretend success, do nothing. - if ((data.website_hp ?? "").trim() !== "") return NextResponse.json({ ok: true }); + // Honeypot: a filled hidden field means a bot. Pretend success, do nothing, + // but say so in the log: a silent drop once hid a real problem. + if ((data.hp_check ?? "").trim() !== "") { + console.info("[contact] dropped: honeypot filled"); + return NextResponse.json({ ok: true }); + } - // Time-to-submit: human submissions take more than a couple of seconds. - const renderedAt = Number(data.rendered_at); - if (Number.isFinite(renderedAt) && Date.now() - renderedAt < 2500) return NextResponse.json({ ok: true }); + // Time-to-submit: human submissions take more than a couple of seconds. The + // browser reports its own elapsed time; comparing a browser timestamp with + // this server's clock dropped every visitor whose clock ran ahead. + const elapsed = Number(data.elapsed_ms); + if (Number.isFinite(elapsed) && elapsed >= 0 && elapsed < MIN_ELAPSED_MS) { + console.info(`[contact] dropped: submitted after ${Math.round(elapsed)} ms`); + return NextResponse.json({ ok: true }); + } const topic = (data.topic ?? "").trim(); const name = (data.name ?? "").trim(); diff --git a/app/components/ContactForm.tsx b/app/components/ContactForm.tsx index 0cf0cc3..059564b 100644 --- a/app/components/ContactForm.tsx +++ b/app/components/ContactForm.tsx @@ -38,11 +38,6 @@ export default function ContactForm({ defaultTopic = "", careers = false }: { de e.preventDefault(); const form = e.currentTarget; - const honeypot = (form.elements.namedItem("website_hp") as HTMLInputElement)?.value; - if (honeypot) { - setStatus("success"); - return; - } if (!form.checkValidity() || message.trim().length < MIN_MESSAGE) { form.reportValidity(); setErrorMsg(t("form.validation", { min: MIN_MESSAGE })); @@ -58,7 +53,8 @@ export default function ContactForm({ defaultTopic = "", careers = false }: { de } const fd = new FormData(form); - fd.set("rendered_at", renderedAt); + // Time on the page, measured here so the server never compares two clocks. + fd.set("elapsed_ms", String(Date.now() - Number(renderedAt))); fd.set("locale", locale); fd.set("consent", (form.elements.namedItem("consent") as HTMLInputElement)?.checked ? "true" : "false"); @@ -97,10 +93,12 @@ export default function ContactForm({ defaultTopic = "", careers = false }: { de )} + {/* Honeypot: off screen, never autofilled. The name avoids every word + browser autofill matches (website, url, company...), and the field + is only inspected by the API, which also logs the drop. */} -