From 9d369ad4d244fa8ea029ce65fb361dbb0b3c0f8b Mon Sep 17 00:00:00 2001 From: Eugene Fox Date: Wed, 22 Jan 2025 15:57:02 +0000 Subject: [PATCH] !feat: added Cloudflare Turnstile captcha to the contact form feat: added contact form disclaimer --- .env.development | 3 ++ app/_page_sections/ContactSection.module.scss | 6 +++ app/_page_sections/ContactSection.tsx | 20 +++++++- app/_utils/sendInquiry.ts | 27 ++++++++++ app/_utils/turnstile.ts | 51 +++++++++++++++++++ next.config.js | 6 ++- package.json | 1 + yarn.lock | 5 ++ 8 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 app/_utils/turnstile.ts diff --git a/.env.development b/.env.development index 4aba1d3..2a77d2c 100644 --- a/.env.development +++ b/.env.development @@ -15,3 +15,6 @@ RESUME_HAS_REFS=false # Appends last page of the resume to a result PDF file ALERT_TEXT_URL=URL # URL of a txt file with urgent message to be displayed (see app/_components/AlertMessage.tsx) CLARITY_ID=string # Clarity Analytics ID (optional, remove to disable) CLARITY_CONSENT=1 # 1 if you need to request explicit consent from user, 0 if not (requires CLARITY_ID) + +CF_SITEKEY=3x00000000000000000000FF # Cloudflare Turnstile captcha sitekey for contact form (optional, remove to siable) +CF_SECRET=1x0000000000000000000000000000000AA # Secret for token validation (requries CF_SITEKEY) diff --git a/app/_page_sections/ContactSection.module.scss b/app/_page_sections/ContactSection.module.scss index 6e71237..e7b0ed0 100644 --- a/app/_page_sections/ContactSection.module.scss +++ b/app/_page_sections/ContactSection.module.scss @@ -93,5 +93,11 @@ } } } + + .disclaimer + { + text-align: right; + color: $colorNeutralForeground4; + } } } diff --git a/app/_page_sections/ContactSection.tsx b/app/_page_sections/ContactSection.tsx index d315635..e9e0a3b 100644 --- a/app/_page_sections/ContactSection.tsx +++ b/app/_page_sections/ContactSection.tsx @@ -4,10 +4,12 @@ import Button from "@/_components/Button"; import SocialLinks from "@/_components/SocialLinks"; import contacts from "@/_data/contacts"; import FormStatusTracker from "@/_utils/FormStatusTracker"; -import React, { InputHTMLAttributes, useMemo, useState } from "react"; +import React, { InputHTMLAttributes, useEffect, useMemo, useState } from "react"; import { useFormState } from "react-dom"; +import Turnstile from "react-turnstile"; import sendInquiry, { FormStatus } from "../_utils/sendInquiry"; import cls from "./ContactSection.module.scss"; +import { getSitekey } from "@/_utils/turnstile"; const defaultState: FormStatus = { status: "idle" }; @@ -16,6 +18,7 @@ const ContactSection: React.FC = () => const [pending, setPending] = useState(false); const [{ status, message }, formAction] = useFormState(sendInquiry, defaultState); const { telephone: phone, email, socials } = contacts; + const [cfSitekey, setCfSitekey] = useState(null); const sharedProps: InputHTMLAttributes = useMemo(() => ({ required: true, @@ -23,6 +26,11 @@ const ContactSection: React.FC = () => readOnly: status === "success" }), [status, pending]); + useEffect(() => + { + getSitekey().then(sitekey => setCfSitekey(sitekey)); + }, []); + return (

Let's get in touch

@@ -53,6 +61,16 @@ const ContactSection: React.FC = () => +
+ { cfSitekey && + + } + +

+ *Using this form does not guarantee I will respond to your request +

+
+
{ pending && diff --git a/app/_utils/sendInquiry.ts b/app/_utils/sendInquiry.ts index bac99b4..25edf9d 100644 --- a/app/_utils/sendInquiry.ts +++ b/app/_utils/sendInquiry.ts @@ -3,6 +3,7 @@ import { canonicalName } from "@/_data/metadata"; import nodemailer from "nodemailer"; import { z } from "zod"; +import { verifyTurnstile } from "./turnstile"; const schema = z.object({ email: z.string().email().max(60), @@ -24,6 +25,32 @@ const mailClient = nodemailer.createTransport({ export default async function sendInquiry(_: FormStatus, formData: FormData): Promise { + const cfToken = formData.get("cf-turnstile-response")?.toString(); + + if (!cfToken) + return { + status: "error", + message: "You must complete the challenge" + }; + + const [isValid, error] = await verifyTurnstile(cfToken); + + if (!isValid) + { + if (error === "timeout-or-duplicate") + return { + status: "error", + message: "Challenge has expired. Try again" + }; + + console.error(error); + + return { + status: "error", + message: "Something went wrong" + }; + } + const { success, data } = schema.safeParse({ email: formData.get("email"), subject: formData.get("subject"), diff --git a/app/_utils/turnstile.ts b/app/_utils/turnstile.ts new file mode 100644 index 0000000..1249a3f --- /dev/null +++ b/app/_utils/turnstile.ts @@ -0,0 +1,51 @@ +"use server"; + +import { headers } from "next/headers"; + +export async function getSitekey(): Promise +{ + return process.env.CF_SITEKEY; +} + +export async function verifyTurnstile(token: string): Promise<[false, TurnstileErrorType] | [true]> +{ + if (!process.env.CF_SECRET) + return [true]; + + const formData = new FormData(); + + console.log(headers().get("CF-Connecting-IP")); + + formData.append("secret", process.env.CF_SECRET); + formData.append("response", token); + formData.append("remoteip", headers().get("CF-Connecting-IP") ?? ""); + + const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + body: formData, + method: "POST" + } + ); + + const result: TurnstileValidationResponse = await response.json(); + + if (result.success) + return [result.success]; + else + return [result.success, result["error-codes"][0]]; +} + +export type TurnstileValidationResponse = + { + success: boolean; + challenge_ts: string; + hostname: string; + "error-codes": TurnstileErrorType[]; + action: string; + cdata: `sessionid-${string}`; + metadata: Record; + }; + +export type TurnstileErrorType = + "missing-input-secret" | "invalid-input-secret" | "missing-input-response" | + "invalid-input-response" | "bad-request" | "timeout-or-duplicate" | "internal-error"; diff --git a/next.config.js b/next.config.js index b49eaf5..5052455 100644 --- a/next.config.js +++ b/next.config.js @@ -23,8 +23,8 @@ const nextConfig = { contentSecurityPolicy: { "script-src": isDev ? - "'self' 'unsafe-inline' https://*.clarity.ms https://c.bing.com 'unsafe-eval'" : - "'self' 'unsafe-inline' https://*.clarity.ms https://c.bing.com", + "'self' 'unsafe-inline' https://*.clarity.ms https://c.bing.com https://*.cloudflare.com 'unsafe-eval'" : + "'self' 'unsafe-inline' https://*.clarity.ms https://c.bing.com https://*.cloudflare.com", "connect-src": isDev ? "'self' https://*.clarity.ms https://c.bing.com webpack://*" : @@ -32,6 +32,8 @@ const nextConfig = { "style-src": "'self' 'unsafe-inline'", + "frame-src": "https://*.cloudflare.com 'none'", + // @ts-ignore "prefetch-src": false }, diff --git a/package.json b/package.json index 3b3da08..d0b9f73 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react": "^18", "react-dom": "^18", "react-social-icons": "^6.18.0", + "react-turnstile": "^1.1.4", "sass": "^1.83.1", "sharp": "^0.33.5", "zod": "^3.24.1" diff --git a/yarn.lock b/yarn.lock index b80ee7a..51e3eea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2759,6 +2759,11 @@ react-social-icons@^6.18.0: react "^18.3.1" react-dom "^18.3.1" +react-turnstile@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/react-turnstile/-/react-turnstile-1.1.4.tgz#0c23b2f4b55f83b929407ae9bfbd211fbe5df362" + integrity sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ== + react@^18, react@^18.3.1: version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"