turnstile added
This commit is contained in:
@@ -244,6 +244,15 @@ export const TEXT_EDITOR_CONFIG = {
|
|||||||
SCROLL_TO_CHANGE_DELAY_MS: 100
|
SCROLL_TO_CHANGE_DELAY_MS: 100
|
||||||
} as const;
|
} 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
|
// VALIDATION
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
4
src/env/client.ts
vendored
4
src/env/client.ts
vendored
@@ -7,6 +7,7 @@ export interface ClientEnv {
|
|||||||
VITE_GITHUB_CLIENT_ID: string;
|
VITE_GITHUB_CLIENT_ID: string;
|
||||||
VITE_WEBSOCKET: string;
|
VITE_WEBSOCKET: string;
|
||||||
VITE_INFILL_ENDPOINT: string;
|
VITE_INFILL_ENDPOINT: string;
|
||||||
|
VITE_TURNSTILE_SITE_KEY: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const requiredKeys: (keyof ClientEnv)[] = [
|
const requiredKeys: (keyof ClientEnv)[] = [
|
||||||
@@ -17,7 +18,8 @@ const requiredKeys: (keyof ClientEnv)[] = [
|
|||||||
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
|
"VITE_GOOGLE_CLIENT_ID_MAGIC_DELVE",
|
||||||
"VITE_GITHUB_CLIENT_ID",
|
"VITE_GITHUB_CLIENT_ID",
|
||||||
"VITE_WEBSOCKET",
|
"VITE_WEBSOCKET",
|
||||||
"VITE_INFILL_ENDPOINT"
|
"VITE_INFILL_ENDPOINT",
|
||||||
|
"VITE_TURNSTILE_SITE_KEY"
|
||||||
];
|
];
|
||||||
|
|
||||||
export const validateClientEnv = (
|
export const validateClientEnv = (
|
||||||
|
|||||||
4
src/env/server.ts
vendored
4
src/env/server.ts
vendored
@@ -33,7 +33,9 @@ const serverEnvSchema = z.object({
|
|||||||
REDIS_URL: z.string().min(1),
|
REDIS_URL: z.string().min(1),
|
||||||
NESSA_DB_URL: z.string().min(1),
|
NESSA_DB_URL: z.string().min(1),
|
||||||
NESSA_DB_TOKEN: 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>;
|
export type ServerEnv = z.infer<typeof serverEnvSchema>;
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ import {
|
|||||||
fetchWithRetry,
|
fetchWithRetry,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
TimeoutError,
|
TimeoutError,
|
||||||
APIError
|
APIError,
|
||||||
|
verifyTurnstileToken
|
||||||
} from "~/server/fetch-utils";
|
} from "~/server/fetch-utils";
|
||||||
import {
|
import {
|
||||||
NETWORK_CONFIG,
|
NETWORK_CONFIG,
|
||||||
COOLDOWN_TIMERS,
|
COOLDOWN_TIMERS,
|
||||||
VALIDATION_CONFIG,
|
VALIDATION_CONFIG,
|
||||||
COUNTDOWN_CONFIG
|
COUNTDOWN_CONFIG,
|
||||||
|
TURNSTILE_CONFIG
|
||||||
} from "~/config";
|
} from "~/config";
|
||||||
|
|
||||||
const getContactData = query(async () => {
|
const getContactData = query(async () => {
|
||||||
@@ -53,6 +55,7 @@ const sendContactEmail = action(async (formData: FormData) => {
|
|||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
const message = formData.get("message") as string;
|
const message = formData.get("message") as string;
|
||||||
|
const turnstileToken = formData.get("cf-turnstile-response") as string;
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
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");
|
const contactExp = getCookie("contactRequestSent");
|
||||||
if (contactExp) {
|
if (contactExp) {
|
||||||
const expires = new Date(contactExp);
|
const expires = new Date(contactExp);
|
||||||
@@ -164,12 +179,32 @@ export default function ContactPage() {
|
|||||||
const [loading, setLoading] = createSignal<boolean>(false);
|
const [loading, setLoading] = createSignal<boolean>(false);
|
||||||
const [user, setUser] = createSignal<UserProfile | null>(null);
|
const [user, setUser] = createSignal<UserProfile | null>(null);
|
||||||
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
|
||||||
|
const [turnstileToken, setTurnstileToken] = createSignal<string>("");
|
||||||
|
|
||||||
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
|
const { remainingTime, startCountdown, setRemainingTime } = useCountdown();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setJsEnabled(true);
|
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
|
api.user.getProfile
|
||||||
.query()
|
.query()
|
||||||
.then((userData) => {
|
.then((userData) => {
|
||||||
@@ -206,13 +241,29 @@ export default function ContactPage() {
|
|||||||
if (!jsEnabled()) return;
|
if (!jsEnabled()) return;
|
||||||
|
|
||||||
e.preventDefault();
|
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 name = formData.get("name") as string;
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
const message = formData.get("message") as string;
|
const message = formData.get("message") as string;
|
||||||
|
|
||||||
if (name && email && message) {
|
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);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
setEmailSent(false);
|
setEmailSent(false);
|
||||||
@@ -221,13 +272,23 @@ export default function ContactPage() {
|
|||||||
const res = await api.misc.sendContactRequest.mutate({
|
const res = await api.misc.sendContactRequest.mutate({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
message
|
message,
|
||||||
|
turnstileToken: currentToken
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.message === "email sent") {
|
if (res.message === "email sent") {
|
||||||
setEmailSent(true);
|
setEmailSent(true);
|
||||||
setError("");
|
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
|
// Set countdown directly - cookie might not be readable immediately
|
||||||
const expirationTime = new Date(
|
const expirationTime = new Date(
|
||||||
@@ -354,6 +415,16 @@ export default function ContactPage() {
|
|||||||
action={sendContactEmail}
|
action={sendContactEmail}
|
||||||
class="w-full"
|
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="flex w-full flex-col justify-evenly">
|
||||||
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
|
<div class="mx-auto w-full justify-evenly md:flex md:flex-row">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import {
|
|||||||
fetchWithRetry,
|
fetchWithRetry,
|
||||||
NetworkError,
|
NetworkError,
|
||||||
TimeoutError,
|
TimeoutError,
|
||||||
APIError
|
APIError,
|
||||||
|
verifyTurnstileToken
|
||||||
} from "~/server/fetch-utils";
|
} 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> = {
|
const assets: Record<string, string> = {
|
||||||
"shapes-with-abigail": "shapes-with-abigail.apk",
|
"shapes-with-abigail": "shapes-with-abigail.apk",
|
||||||
"magic-delve": "magic-delve.apk",
|
"magic-delve": "magic-delve.apk",
|
||||||
@@ -304,10 +305,27 @@ export const miscRouter = createTRPCRouter({
|
|||||||
message: z
|
message: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.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 }) => {
|
.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");
|
const contactExp = getCookie("contactRequestSent");
|
||||||
let remaining = 0;
|
let remaining = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -135,3 +135,56 @@ export async function fetchWithRetry<T>(
|
|||||||
|
|
||||||
throw lastError;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user