From f226ea918b96f97f88478d1846889a626f3f9315 Mon Sep 17 00:00:00 2001 From: Rachit Date: Sun, 1 Nov 2026 02:39:41 +0100 Subject: [PATCH 01/57] feat(web): contact form that hides the address and turns bots away Write to Rachit section before the footer; /api/contact sends through Resend so the address stays a server variable. Same-site check, Vercel BotID (Vercel builds only), honeypot, minimum fill time, per-visitor limit and a daily cap. Contact link in the footer only, so the keyboard paths keep their budgets. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + THIRD_PARTY.md | 2 + apps/web/app/(marketing)/page.tsx | 18 +++ apps/web/app/api/contact/route.ts | 74 ++++++++++++ apps/web/app/api/voice/transcribe/route.ts | 2 +- apps/web/components/ui/ContactForm.tsx | 125 +++++++++++++++++++++ apps/web/components/ui/Footer.tsx | 4 +- apps/web/e2e/lib.spec.ts | 36 ++++++ apps/web/e2e/sections.spec.ts | 44 ++++++++ apps/web/e2e/site.spec.ts | 45 +++++++- apps/web/i18n/dictionaries/en.ts | 29 ++++- apps/web/instrumentation-client.ts | 9 ++ apps/web/lib/contact.ts | 35 ++++++ apps/web/lib/voice-guard.ts | 11 +- apps/web/package.json | 1 + docs/privacy.md | 4 +- docs/verification.md | 16 +++ pnpm-lock.yaml | 19 ++++ 18 files changed, 467 insertions(+), 10 deletions(-) create mode 100644 apps/web/app/api/contact/route.ts create mode 100644 apps/web/components/ui/ContactForm.tsx create mode 100644 apps/web/instrumentation-client.ts create mode 100644 apps/web/lib/contact.ts diff --git a/README.md b/README.md index cf278c3..e03f3ad 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,9 @@ Measured, dated, and recorded in `docs/verification.md`. Nothing below is estima rate-limited per visitor by a Vercel firewall rule (`voice-transcribe`, via `@vercel/firewall`), has a daily cap (`VOICE_DAILY_CAP`, default 300), and can be switched off at once with `VOICE_DEMO=off`. The preset questions always work. +- The contact form sends messages through the email service Resend, so no address appears on the site. It takes only + the site's own pages, checks with Vercel BotID that a browser sent it, has a hidden trap field, a minimum fill time, + a per-visitor limit and a daily cap (`CONTACT_DAILY_CAP`, default 20), and turns off with `CONTACT_FORM=off`. - Everything also runs locally from the steps above. ### 6. Environment variables diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 1c4255a..79c9fee 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -41,6 +41,8 @@ Direct dependencies only. Licences below are as published by each project. |---|---|---| | Next.js | Web framework | MIT | | `@vercel/firewall` 1.2.6 | The voice demo route asks Vercel's firewall rule `voice-transcribe` whether a visitor is over the limit | Apache-2.0 (npm `license` field, checked 2026-11-01). Used as an npm dependency. | +| `botid` 1.5.11 | Vercel BotID: an invisible check that the landing page's contact form is sent by a browser (`apps/web/instrumentation-client.ts`, `app/api/contact/route.ts`) | MIT, per the package README (the npm `license` field is empty and no LICENSE file ships; checked 2026-11-01). Used as an npm dependency. | +| Resend (email service) | Delivers the contact form's messages to the developer's inbox; called over HTTPS from `apps/web/app/api/contact/route.ts`, no SDK | Service, used under Resend's terms; not a model. | | React | UI | MIT | | three.js (`three` 0.186.1) | Point-cloud rendering in the 3D viewer | MIT | | react-three-fiber (`@react-three/fiber` 9.8.2) | React renderer for three.js (`apps/web/components/viewer/`) | MIT | diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index d95c7df..cb1c758 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -6,6 +6,7 @@ import { ModelCards } from "@/components/scroll/ModelCards"; import { RevealSection } from "@/components/scroll/RevealSection"; import { StoryVideo } from "@/components/scroll/story/StoryVideo"; import { Devices } from "@/components/ui/Devices"; +import { ContactForm } from "@/components/ui/ContactForm"; import { Faq } from "@/components/ui/Faq"; import { getDictionary } from "@/i18n"; @@ -79,6 +80,23 @@ export default function LandingPage() { + + +
+

+ {t.contact.eyebrow} +

+

+ {t.contact.title} +

+

+ {t.contact.intro} +

+
+ +
+
+
); } diff --git a/apps/web/app/api/contact/route.ts b/apps/web/app/api/contact/route.ts new file mode 100644 index 0000000..95d7a93 --- /dev/null +++ b/apps/web/app/api/contact/route.ts @@ -0,0 +1,74 @@ +/** + * The landing page's contact form. Sends the message to Rachit through Resend, so his address (CONTACT_TO) stays on + * the server and never appears in the page. Errors are codes (see `contact.errors` in i18n/dictionaries). + * + * Request format from the Resend docs (checked 2026-11-01): POST https://api.resend.com/emails, Authorization: Bearer + * , JSON { from, to, subject, text, reply_to }. Without a verified domain, `onboarding@resend.dev` can send only + * to the Resend account's own address, which is all this needs. + * + * Bots are turned away by this site's pages only, Vercel BotID (instrumentation-client.ts), a hidden honeypot field, + * a minimum fill time, a per-visitor limit and a daily cap. The Hobby plan's one firewall rate-limit rule is used by + * the voice route, so the limits here are this instance's own count (approximate across instances). + */ + +import { checkBotId } from "botid/server"; +import { contactDisabled, contactMessage, looksAutomated, subjectFor } from "@/lib/contact"; +import { clientKey, DailyCap, dailyCapFrom, sameSite, WindowLimit } from "@/lib/voice-guard"; + +const ENDPOINT = "https://api.resend.com/emails"; +const ON_VERCEL = process.env.VERCEL === "1"; +const perVisitor = new WindowLimit(3, 10 * 60_000); +const dailyCap = new DailyCap(dailyCapFrom(process.env, "CONTACT_DAILY_CAP", 20)); + +/** BotID only runs on Vercel; elsewhere (local, tests) the other checks still apply. */ +async function isBot(): Promise { + if (!ON_VERCEL) return false; + try { + return (await checkBotId()).isBot; + } catch { + return true; // the check couldn't run: refuse rather than let a bot through + } +} + +export async function POST(request: Request) { + if (contactDisabled(process.env)) return Response.json({ error: "disabled" }, { status: 503 }); + if (!sameSite(request.headers)) return Response.json({ error: "forbidden" }, { status: 403 }); + if (await isBot()) return Response.json({ error: "forbidden" }, { status: 403 }); + + const parsed = contactMessage.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return Response.json({ error: "invalid" }, { status: 400 }); + const msg = parsed.data; + // a bot that filled the trap is told it worked, so it doesn't try again + if (looksAutomated(msg)) return Response.json({ ok: true }); + + const key = process.env.RESEND_API_KEY; + const to = process.env.CONTACT_TO; + if (!key || !to) return Response.json({ error: "not_configured" }, { status: 503 }); + if (perVisitor.over(clientKey(request.headers, ON_VERCEL))) return Response.json({ error: "rate_limited" }, { status: 429 }); + if (dailyCap.reached()) return Response.json({ error: "daily_limit" }, { status: 429 }); + + let res: Response; + try { + res = await fetch(ENDPOINT, { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + from: process.env.CONTACT_FROM || "Recall ", + to: [to], + reply_to: msg.email, + subject: subjectFor(msg.name), + // plain text only: nothing in the message can become markup + text: `${msg.message}\n\n— ${msg.name} <${msg.email}>, via the Recall website`, + }), + signal: AbortSignal.timeout(8_000), + }); + } catch { + return Response.json({ error: "failed" }, { status: 502 }); + } + if (!res.ok) { + // log the status only; never the message or the sender's address + console.error(`[contact] Resend failed: HTTP ${res.status}`); + return Response.json({ error: "failed" }, { status: 502 }); + } + return Response.json({ ok: true }); +} diff --git a/apps/web/app/api/voice/transcribe/route.ts b/apps/web/app/api/voice/transcribe/route.ts index 81ace75..10caa11 100644 --- a/apps/web/app/api/voice/transcribe/route.ts +++ b/apps/web/app/api/voice/transcribe/route.ts @@ -7,7 +7,7 @@ * "audio" part; Authorization: Bearer ; model muse-voice-transcribe-1.0. * * It spends money, so it is guarded (lib/voice-guard.ts): an off switch, this site's pages only, the Vercel firewall - * rule `voice-transcribe` (5 a minute per visitor, set in the dashboard), and a daily cap. + * rule `voice-transcribe` (10 a minute per visitor, set in the dashboard), and a daily cap. */ import { checkRateLimit } from "@vercel/firewall"; diff --git a/apps/web/components/ui/ContactForm.tsx b/apps/web/components/ui/ContactForm.tsx new file mode 100644 index 0000000..c2d2210 --- /dev/null +++ b/apps/web/components/ui/ContactForm.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useT } from "@/i18n/I18nProvider"; + +type ContactError = keyof ReturnType["contact"]["errors"]; +type Status = "idle" | "sending" | "sent" | ContactError; + +const field = + "mt-1 block w-full min-h-11 rounded-lg border border-fb-line bg-white px-3 py-2 text-[1.02rem] text-ink focus:border-fb-blue"; + +/** + * Write to Rachit without his address ever reaching the page: the form posts to /api/contact, which sends the email. + * Sent with fetch, not a native form post, so Vercel BotID can check it (instrumentation-client.ts). + */ +export function ContactForm() { + const t = useT(); + const [status, setStatus] = useState("idle"); + const startedAt = useRef(0); + + // when the form appeared: one sent within seconds was filled by a bot + useEffect(() => { + startedAt.current = Date.now(); + }, []); + + const send = async (event: FormEvent) => { + event.preventDefault(); + if (status === "sending") return; + const form = event.currentTarget; + const data = new FormData(form); + setStatus("sending"); + try { + const res = await fetch("/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: data.get("name"), + email: data.get("email"), + message: data.get("message"), + website: data.get("website") ?? "", + startedAt: startedAt.current, + }), + }); + const body = (await res.json().catch(() => ({}))) as { error?: ContactError }; + if (res.ok) { + form.reset(); + setStatus("sent"); + } else { + setStatus(body.error && body.error in t.contact.errors ? body.error : "failed"); + } + } catch { + setStatus("failed"); + } + }; + + const error = status !== "idle" && status !== "sending" && status !== "sent" ? status : null; + + return ( +
+
+ + +
+