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:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user