turnstile added

This commit is contained in:
2026-05-28 10:24:23 -04:00
parent 8b6551330f
commit fbc8215410
6 changed files with 165 additions and 10 deletions

View File

@@ -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
// ============================================================

4
src/env/client.ts vendored
View File

@@ -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 = (

4
src/env/server.ts vendored
View File

@@ -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<typeof serverEnvSchema>;

View File

@@ -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<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
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 */}
<div class="mb-4 flex justify-center">
<div
class="turnstile-widget"
data-sitekey={env.TURNSTILE_SITE_KEY}
data-theme="dark"
id="turnstile-widget-1"
></div>
</div>
<div class="flex w-full flex-col justify-evenly">
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
<Input

View File

@@ -19,9 +19,10 @@ import {
fetchWithRetry,
NetworkError,
TimeoutError,
APIError
APIError,
verifyTurnstileToken
} from "~/server/fetch-utils";
import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG } from "~/config";
import { NETWORK_CONFIG, COOLDOWN_TIMERS, VALIDATION_CONFIG, TURNSTILE_CONFIG } from "~/config";
const assets: Record<string, string> = {
"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;

View File

@@ -135,3 +135,56 @@ export async function fetchWithRetry<T>(
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<boolean> {
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;
}
}