Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions components/contact/ContactPage.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<Status>("idle");
const [message, setMessage] = useState("");
const [turnstileReady, setTurnstileReady] = useState(false);
const [turnstileToken, setTurnstileToken] = useState("");
const turnstileRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(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 (
Expand All @@ -31,14 +87,20 @@ 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("");

try {
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();

Expand All @@ -49,17 +111,26 @@ 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();
}
}

const disabled = status === "loading";

return (
<>
{TURNSTILE_SITE_KEY && (
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
strategy="afterInteractive"
onReady={() => setTurnstileReady(true)}
/>
)}
<HeroBackground />
<main className="contact-main relative z-10">
<header className="contact-header">
Expand Down Expand Up @@ -145,6 +216,9 @@ export function ContactPage() {
value={form.website}
onChange={update("website")}
/>
{TURNSTILE_SITE_KEY && (
<div ref={turnstileRef} className="contact-turnstile" />
)}
<div className="contact-actions">
<Button
type="submit"
Expand Down Expand Up @@ -312,6 +386,10 @@ export function ContactPage() {
opacity: 0;
}

.contact-turnstile {
min-height: 65px;
}

.contact-actions {
display: flex;
flex-wrap: wrap;
Expand Down
12 changes: 12 additions & 0 deletions site-tests/contact-integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,15 @@ test('posts the contact form to Resend and forwards it to the team', () => {
assert.match(route, /thomas@conceptatech\.com/)
assert.match(route, /replyTo: input\.email/)
})

test('protects the contact form with Cloudflare Turnstile', () => {
const page = readFileSync(join(root, 'components/contact/ContactPage.tsx'), 'utf8')
const route = readFileSync(join(root, 'src/app/api/contact/route.ts'), 'utf8')

assert.match(page, /challenges\.cloudflare\.com\/turnstile\/v0\/api\.js/)
assert.match(page, /NEXT_PUBLIC_TURNSTILE_SITE_KEY/)
assert.match(page, /turnstileToken/)
assert.match(route, /turnstile\/v0\/siteverify/)
assert.match(route, /TURNSTILE_SECRET_KEY/)
assert.match(route, /status: 403/)
})
42 changes: 42 additions & 0 deletions src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,38 @@ const contactSchema = z.object({
message: z.string().trim().min(1, "Please enter a message").max(5000),
// Honeypot: hidden from people, filled in by naive bots.
website: z.string().optional(),
// Cloudflare Turnstile response token (max 2048 chars per Cloudflare docs).
turnstileToken: z.string().max(2048).optional(),
});

type ContactInput = z.infer<typeof contactSchema>;

const TURNSTILE_VERIFY_URL =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";

async function verifyTurnstile(token: string | undefined, ip: string | null) {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) {
// Allow local development without Turnstile keys; never in production.
return process.env.NODE_ENV !== "production";
}
if (!token) return false;

const body = new URLSearchParams({ secret, response: token });
if (ip) body.set("remoteip", ip);

const res = await fetch(TURNSTILE_VERIFY_URL, { method: "POST", body });
const data = (await res.json()) as {
success: boolean;
"error-codes"?: string[];
};

if (!data.success) {
console.warn("[contact] turnstile rejected:", data["error-codes"]);
}
return data.success;
}

function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
Expand Down Expand Up @@ -65,6 +93,20 @@ export async function POST(request: NextRequest) {
);
}

const ip =
request.headers.get("cf-connecting-ip") ??
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
null;
if (!(await verifyTurnstile(input.turnstileToken, ip))) {
return NextResponse.json(
{
success: false,
message: "We couldn't verify you're human. Please try again.",
},
{ status: 403 }
);
}

if (!process.env.RESEND_API_KEY) {
if (process.env.NODE_ENV !== "production") {
console.warn(
Expand Down
Loading