diff --git a/src/config.ts b/src/config.ts index 0d04906..4592e6b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -244,6 +244,15 @@ export const TEXT_EDITOR_CONFIG = { SCROLL_TO_CHANGE_DELAY_MS: 100 } as const; +// ============================================================ +// CLOUDFLARE TURNSTILE (BOT PROTECTION) +// ============================================================ + +export const TURNSTILE_CONFIG = { + VERIFY_URL: "https://challenges.cloudflare.com/turnstile/v0/siteverify", + RESPONSE_TIMEOUT_MS: 10000 +} as const; + // ============================================================ // VALIDATION // ============================================================ diff --git a/src/env/client.ts b/src/env/client.ts index 7081d74..ed6ec6c 100644 --- a/src/env/client.ts +++ b/src/env/client.ts @@ -7,6 +7,7 @@ export interface ClientEnv { VITE_GITHUB_CLIENT_ID: string; VITE_WEBSOCKET: string; VITE_INFILL_ENDPOINT: string; + VITE_TURNSTILE_SITE_KEY: string } const requiredKeys: (keyof ClientEnv)[] = [ @@ -17,7 +18,8 @@ const requiredKeys: (keyof ClientEnv)[] = [ "VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE", "VITE_GITHUB_CLIENT_ID", "VITE_WEBSOCKET", - "VITE_INFILL_ENDPOINT" + "VITE_INFILL_ENDPOINT", + "VITE_TURNSTILE_SITE_KEY" ]; export const validateClientEnv = ( diff --git a/src/env/server.ts b/src/env/server.ts index 07ff220..78dabc8 100644 --- a/src/env/server.ts +++ b/src/env/server.ts @@ -33,7 +33,9 @@ const serverEnvSchema = z.object({ REDIS_URL: z.string().min(1), NESSA_DB_URL: z.string().min(1), NESSA_DB_TOKEN: z.string().min(1), - NESSA_JWT_SECRET: z.string().min(1) + NESSA_JWT_SECRET: z.string().min(1), + VITE_TURNSTILE_SITE_KEY: z.string().min(1), + TURNSTILE_SECRET_KEY: z.string().min(1) }); export type ServerEnv = z.infer; diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx index 41f12ed..9405f58 100644 --- a/src/routes/contact.tsx +++ b/src/routes/contact.tsx @@ -26,13 +26,15 @@ import { fetchWithRetry, NetworkError, TimeoutError, - APIError + APIError, + verifyTurnstileToken } from "~/server/fetch-utils"; import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG, - COUNTDOWN_CONFIG + COUNTDOWN_CONFIG, + TURNSTILE_CONFIG } from "~/config"; const getContactData = query(async () => { @@ -53,6 +55,7 @@ const sendContactEmail = action(async (formData: FormData) => { const name = formData.get("name") as string; const email = formData.get("email") as string; const message = formData.get("message") as string; + const turnstileToken = formData.get("cf-turnstile-response") as string; const schema = z.object({ name: z.string().min(1, "Name is required"), @@ -71,6 +74,18 @@ const sendContactEmail = action(async (formData: FormData) => { ); } + // Verify Cloudflare Turnstile token + const turnstileValid = await verifyTurnstileToken( + turnstileToken, + env.TURNSTILE_SECRET_KEY, + TURNSTILE_CONFIG.VERIFY_URL, + TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS + ); + + if (!turnstileValid) { + return redirect("/contact?error=Security verification failed. Please refresh and try again."); + } + const contactExp = getCookie("contactRequestSent"); if (contactExp) { const expires = new Date(contactExp); @@ -164,12 +179,32 @@ export default function ContactPage() { const [loading, setLoading] = createSignal(false); const [user, setUser] = createSignal(null); const [jsEnabled, setJsEnabled] = createSignal(false); + const [turnstileToken, setTurnstileToken] = createSignal(""); const { remainingTime, startCountdown, setRemainingTime } = useCountdown(); onMount(() => { setJsEnabled(true); + // Load Cloudflare Turnstile script + const script = document.createElement("script"); + script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; + script.async = true; + script.defer = true; + document.head.appendChild(script); + + // Get Turnstile token after widget renders + setTimeout(() => { + if (typeof window !== "undefined" && (window as any).turnstile) { + const widgetEl = document.getElementById("turnstile-widget-1"); + if (widgetEl) { + const widgetId = widgetEl.getAttribute("data-widget-id"); + const token = (window as any).turnstile.getResponse(widgetId || ""); + if (token) setTurnstileToken(token); + } + } + }, 1000); + api.user.getProfile .query() .then((userData) => { @@ -206,13 +241,29 @@ export default function ContactPage() { if (!jsEnabled()) return; e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); + const form = e.target as unknown as HTMLFormElement; + const formData = new FormData(form); const name = formData.get("name") as string; const email = formData.get("email") as string; const message = formData.get("message") as string; if (name && email && message) { + // Get fresh Turnstile token + let currentToken = turnstileToken(); + if (!currentToken && typeof window !== "undefined" && (window as any).turnstile) { + const widgetEl = document.querySelector(".turnstile-widget"); + if (widgetEl) { + currentToken = (window as any).turnstile.getResponse(""); + } + } + + if (!currentToken || currentToken.trim() === "") { + setError("Please complete the security check."); + setLoading(false); + return; + } + setLoading(true); setError(""); setEmailSent(false); @@ -221,13 +272,23 @@ export default function ContactPage() { const res = await api.misc.sendContactRequest.mutate({ name, email, - message + message, + turnstileToken: currentToken }); if (res.message === "email sent") { setEmailSent(true); setError(""); - (e.target as HTMLFormElement).reset(); + form.reset(); + + // Reset Turnstile widget + if (typeof window !== "undefined" && (window as any).turnstile) { + const widgetEl = document.getElementById("turnstile-widget-1"); + if (widgetEl) { + (window as any).turnstile.reset(widgetEl.getAttribute("data-widget-id") || ""); + } + } + setTurnstileToken(""); // Set countdown directly - cookie might not be readable immediately const expirationTime = new Date( @@ -354,6 +415,16 @@ export default function ContactPage() { action={sendContactEmail} class="w-full" > + {/* Cloudflare Turnstile Widget */} +
+
+
+
= { "shapes-with-abigail": "shapes-with-abigail.apk", "magic-delve": "magic-delve.apk", @@ -304,10 +305,27 @@ export const miscRouter = createTRPCRouter({ message: z .string() .min(1) - .max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH) + .max(VALIDATION_CONFIG.MAX_CONTACT_MESSAGE_LENGTH), + turnstileToken: z.string().min(1, "Please complete the security check") }) ) .mutation(async ({ input }) => { + // Verify Cloudflare Turnstile token + const turnstileValid = await verifyTurnstileToken( + input.turnstileToken, + env.TURNSTILE_SECRET_KEY, + TURNSTILE_CONFIG.VERIFY_URL, + TURNSTILE_CONFIG.RESPONSE_TIMEOUT_MS + ); + + if (!turnstileValid) { + console.error("Turnstile verification failed for contact form submission"); + throw new TRPCError({ + code: "FORBIDDEN", + message: "Security verification failed. Please refresh the page and try again." + }); + } + const contactExp = getCookie("contactRequestSent"); let remaining = 0; diff --git a/src/server/fetch-utils.ts b/src/server/fetch-utils.ts index 855e231..a740bb1 100644 --- a/src/server/fetch-utils.ts +++ b/src/server/fetch-utils.ts @@ -135,3 +135,56 @@ export async function fetchWithRetry( throw lastError; } + +// ============================================================ +// CLOUDFLARE TURNSTILE VERIFICATION +// ============================================================ + +interface TurnstileResponse { + success: boolean; + "challenge-ts"?: string; + action?: string; + cdata?: string; + "error-codes"?: string[]; +} + +export async function verifyTurnstileToken( + token: string, + secretKey: string, + verifyUrl: string = "https://challenges.cloudflare.com/turnstile/v0/siteverify", + timeoutMs: number = 10000 +): Promise { + if (!token || token.trim() === "") { + return false; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const response = await fetch(verifyUrl, { + method: "POST", + body: new URLSearchParams({ + secret: secretKey, + response: token, + }), + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + console.error(`Turnstile verification failed with status ${response.status}`); + return false; + } + + const data = (await response.json()) as TurnstileResponse; + return data.success === true; + } catch (error) { + console.error("Turnstile verification error:", error); + return false; + } +}