From efdd95dc99fc5dae0c956d74efe3483ba9e78db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20=C3=81vila=20Rangel?= Date: Tue, 13 Oct 2026 09:44:45 -0400 Subject: [PATCH] feat(website): protect contact form with Cloudflare Turnstile The contact form was getting a lot of bot submissions that the honeypot didn't catch. The form now renders a Turnstile widget and sends its token with the submission; /api/contact verifies it with Cloudflare's siteverify endpoint (passing the client IP) before sending anything through Resend, and returns 403 when the token is missing, invalid, or reused. The widget is reset after a failed submit since tokens are single-use. Requires NEXT_PUBLIC_TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY. Without the secret, verification is skipped in development and fails closed in production. Co-Authored-By: Claude Opus 5.5 (1M context) --- components/contact/ContactPage.tsx | 82 ++++++++++++++++++++++++- site-tests/contact-integration.test.mjs | 12 ++++ src/app/api/contact/route.ts | 42 +++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/components/contact/ContactPage.tsx b/components/contact/ContactPage.tsx index 6afcbcab..f1289fa2 100644 --- a/components/contact/ContactPage.tsx +++ b/components/contact/ContactPage.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import Script from "next/script"; import { ArrowUpRight, CheckCircle2, MapPin, Phone } from "lucide-react"; import { Button } from "../Button"; import { @@ -14,12 +15,67 @@ import { HeroBackground } from "../HeroBackground"; type Status = "idle" | "loading" | "success" | "error"; +type Turnstile = { + render: ( + container: HTMLElement, + options: { + sitekey: string; + theme?: "light" | "dark" | "auto"; + callback?: (token: string) => void; + "expired-callback"?: () => void; + "error-callback"?: () => void; + } + ) => string; + reset: (widgetId: string) => void; + remove: (widgetId: string) => void; +}; + +declare global { + interface Window { + turnstile?: Turnstile; + } +} + +const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + const EMPTY_FORM = { name: "", email: "", subject: "", message: "", website: "" }; export function ContactPage() { const [form, setForm] = useState(EMPTY_FORM); const [status, setStatus] = useState("idle"); const [message, setMessage] = useState(""); + const [turnstileReady, setTurnstileReady] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(""); + const turnstileRef = useRef(null); + const widgetIdRef = useRef(null); + + useEffect(() => { + const container = turnstileRef.current; + const turnstile = window.turnstile; + if (!TURNSTILE_SITE_KEY || !turnstileReady || !container || !turnstile) { + return; + } + + const widgetId = turnstile.render(container, { + sitekey: TURNSTILE_SITE_KEY, + theme: "dark", + callback: setTurnstileToken, + "expired-callback": () => setTurnstileToken(""), + "error-callback": () => setTurnstileToken(""), + }); + widgetIdRef.current = widgetId; + + return () => { + turnstile.remove(widgetId); + widgetIdRef.current = null; + }; + }, [turnstileReady]); + + function resetTurnstile() { + // Tokens are single-use, so a failed submit needs a fresh one. + setTurnstileToken(""); + if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current); + } function update(field: keyof typeof EMPTY_FORM) { return ( @@ -31,6 +87,12 @@ export function ContactPage() { e.preventDefault(); if (status === "loading") return; + if (TURNSTILE_SITE_KEY && !turnstileToken) { + setStatus("error"); + setMessage("Please wait for the verification to finish."); + return; + } + setStatus("loading"); setMessage(""); @@ -38,7 +100,7 @@ export function ContactPage() { const res = await fetch("/api/contact", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(form), + body: JSON.stringify({ ...form, turnstileToken }), }); const data = await res.json(); @@ -49,10 +111,12 @@ export function ContactPage() { } else { setStatus("error"); setMessage(data.message || "Something went wrong. Please try again."); + resetTurnstile(); } } catch { setStatus("error"); setMessage("Something went wrong. Please try again."); + resetTurnstile(); } } @@ -60,6 +124,13 @@ export function ContactPage() { return ( <> + {TURNSTILE_SITE_KEY && ( +