1
0
mirror of https://github.com/XFox111/my-website.git synced 2026-04-22 07:28:01 +03:00

!feat: added Cloudflare Turnstile captcha to the contact form

feat: added contact form disclaimer
This commit is contained in:
2025-01-22 15:57:02 +00:00
parent 5d059fb7c4
commit 9d369ad4d2
8 changed files with 116 additions and 3 deletions
+3
View File
@@ -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) 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_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) 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)
@@ -93,5 +93,11 @@
} }
} }
} }
.disclaimer
{
text-align: right;
color: $colorNeutralForeground4;
}
} }
} }
+19 -1
View File
@@ -4,10 +4,12 @@ import Button from "@/_components/Button";
import SocialLinks from "@/_components/SocialLinks"; import SocialLinks from "@/_components/SocialLinks";
import contacts from "@/_data/contacts"; import contacts from "@/_data/contacts";
import FormStatusTracker from "@/_utils/FormStatusTracker"; 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 { useFormState } from "react-dom";
import Turnstile from "react-turnstile";
import sendInquiry, { FormStatus } from "../_utils/sendInquiry"; import sendInquiry, { FormStatus } from "../_utils/sendInquiry";
import cls from "./ContactSection.module.scss"; import cls from "./ContactSection.module.scss";
import { getSitekey } from "@/_utils/turnstile";
const defaultState: FormStatus = { status: "idle" }; const defaultState: FormStatus = { status: "idle" };
@@ -16,6 +18,7 @@ const ContactSection: React.FC = () =>
const [pending, setPending] = useState<boolean>(false); const [pending, setPending] = useState<boolean>(false);
const [{ status, message }, formAction] = useFormState<FormStatus, FormData>(sendInquiry, defaultState); const [{ status, message }, formAction] = useFormState<FormStatus, FormData>(sendInquiry, defaultState);
const { telephone: phone, email, socials } = contacts; const { telephone: phone, email, socials } = contacts;
const [cfSitekey, setCfSitekey] = useState<string | undefined | null>(null);
const sharedProps: InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> = useMemo(() => ({ const sharedProps: InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> = useMemo(() => ({
required: true, required: true,
@@ -23,6 +26,11 @@ const ContactSection: React.FC = () =>
readOnly: status === "success" readOnly: status === "success"
}), [status, pending]); }), [status, pending]);
useEffect(() =>
{
getSitekey().then(sitekey => setCfSitekey(sitekey));
}, []);
return ( return (
<section id="contacts" className={ cls.section }> <section id="contacts" className={ cls.section }>
<h2>Let's get in touch</h2> <h2>Let's get in touch</h2>
@@ -53,6 +61,16 @@ const ContactSection: React.FC = () =>
<input name="timezone" type="hidden" readOnly <input name="timezone" type="hidden" readOnly
value={ Intl.DateTimeFormat().resolvedOptions().timeZone } /> value={ Intl.DateTimeFormat().resolvedOptions().timeZone } />
<div>
{ cfSitekey &&
<Turnstile sitekey={ cfSitekey } size="flexible" action="contact_form" />
}
<p className={ cls.disclaimer }>
*Using this form does not guarantee I will respond to your request
</p>
</div>
<div className={ cls.formToolbar }> <div className={ cls.formToolbar }>
<div className={ `${cls.status} ${pending ? "" : cls[status]}` }> <div className={ `${cls.status} ${pending ? "" : cls[status]}` }>
{ pending && { pending &&
+27
View File
@@ -3,6 +3,7 @@
import { canonicalName } from "@/_data/metadata"; import { canonicalName } from "@/_data/metadata";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { z } from "zod"; import { z } from "zod";
import { verifyTurnstile } from "./turnstile";
const schema = z.object({ const schema = z.object({
email: z.string().email().max(60), email: z.string().email().max(60),
@@ -24,6 +25,32 @@ const mailClient = nodemailer.createTransport({
export default async function sendInquiry(_: FormStatus, formData: FormData): Promise<FormStatus> export default async function sendInquiry(_: FormStatus, formData: FormData): Promise<FormStatus>
{ {
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({ const { success, data } = schema.safeParse({
email: formData.get("email"), email: formData.get("email"),
subject: formData.get("subject"), subject: formData.get("subject"),
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { headers } from "next/headers";
export async function getSitekey(): Promise<string | undefined>
{
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<string, string>;
};
export type TurnstileErrorType =
"missing-input-secret" | "invalid-input-secret" | "missing-input-response" |
"invalid-input-response" | "bad-request" | "timeout-or-duplicate" | "internal-error";
+4 -2
View File
@@ -23,8 +23,8 @@ const nextConfig = {
contentSecurityPolicy: contentSecurityPolicy:
{ {
"script-src": isDev ? "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 https://*.cloudflare.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",
"connect-src": isDev ? "connect-src": isDev ?
"'self' https://*.clarity.ms https://c.bing.com webpack://*" : "'self' https://*.clarity.ms https://c.bing.com webpack://*" :
@@ -32,6 +32,8 @@ const nextConfig = {
"style-src": "'self' 'unsafe-inline'", "style-src": "'self' 'unsafe-inline'",
"frame-src": "https://*.cloudflare.com 'none'",
// @ts-ignore // @ts-ignore
"prefetch-src": false "prefetch-src": false
}, },
+1
View File
@@ -29,6 +29,7 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-social-icons": "^6.18.0", "react-social-icons": "^6.18.0",
"react-turnstile": "^1.1.4",
"sass": "^1.83.1", "sass": "^1.83.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"zod": "^3.24.1" "zod": "^3.24.1"
+5
View File
@@ -2759,6 +2759,11 @@ react-social-icons@^6.18.0:
react "^18.3.1" react "^18.3.1"
react-dom "^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: react@^18, react@^18.3.1:
version "18.3.1" version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"