1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-04-26 04:45:22 -04:00

switch to cloudflare turnstile

This commit is contained in:
Jake Jarvis 2024-07-22 17:53:35 -04:00
parent 00165a5871
commit 5fe420913e
7 changed files with 59 additions and 77 deletions

View File

@ -22,12 +22,12 @@ RESEND_API_KEY=
RESEND_DOMAIN=
# required for production. falls back to testing keys if not set or in dev environment:
# https://docs.hcaptcha.com/#integration-testing-test-keys
# https://developers.cloudflare.com/turnstile/troubleshooting/testing/
# site key must be prefixed with NEXT_PUBLIC_ since it is used to embed the actual captcha widget.
# https://dashboard.hcaptcha.com/sites
NEXT_PUBLIC_HCAPTCHA_SITE_KEY=
# https://developers.cloudflare.com/turnstile/
NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# used only for backend validation of contact form submissions on /api/contact.
HCAPTCHA_SECRET_KEY=
TURNSTILE_SECRET_KEY=
# optional. sets the site ID of the fathom analytics script.
# https://app.usefathom.com/sites

View File

@ -1,36 +1,21 @@
import HCaptcha from "@hcaptcha/react-hcaptcha";
import Turnstile from "react-turnstile";
import useHasMounted from "../../hooks/useHasMounted";
import useTheme from "../../hooks/useTheme";
import type { ComponentPropsWithoutRef } from "react";
export type CaptchaProps = {
size?: "normal" | "compact" | "invisible";
theme?: "light" | "dark";
export type CaptchaProps = Omit<ComponentPropsWithoutRef<typeof Turnstile>, "sitekey"> & {
className?: string;
// callbacks pulled verbatim from node_modules/@hcaptcha/react-hcaptcha/types/index.d.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
onExpire?: () => any;
onOpen?: () => any;
onClose?: () => any;
onChalExpired?: () => any;
onError?: (event: string) => any;
onVerify?: (token: string, ekey: string) => any;
onLoad?: () => any;
/* eslint-enable @typescript-eslint/no-explicit-any */
};
const Captcha = ({ size = "normal", theme, className, ...rest }: CaptchaProps) => {
const Captcha = ({ theme, className, ...rest }: CaptchaProps) => {
const hasMounted = useHasMounted();
const { activeTheme } = useTheme();
return (
<div className={className}>
{hasMounted && (
<HCaptcha
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001"}
reCaptchaCompat={false}
tabIndex={0}
size={size}
<Turnstile
sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
theme={theme || (activeTheme === "dark" ? activeTheme : "light")}
{...rest}
/>

View File

@ -55,7 +55,7 @@ const MarkdownTipIcon = styled(SiMarkdown, {
marginRight: "0.25em",
});
const HCaptcha = styled(Captcha, {
const Turnstile = styled(Captcha, {
margin: "1em 0",
});
@ -133,7 +133,7 @@ type FormValues = {
name: string;
email: string;
message: string;
"h-captcha-response": string;
"cf-turnstile-response": string;
};
export type ContactFormProps = {
@ -195,7 +195,7 @@ const ContactForm = ({ className }: ContactFormProps) => {
name: "",
email: "",
message: "",
"h-captcha-response": "",
"cf-turnstile-response": "",
}}
validate={(values: FormValues) => {
const errors: Partial<Record<keyof FormValues, boolean>> = {};
@ -203,9 +203,9 @@ const ContactForm = ({ className }: ContactFormProps) => {
errors.name = !values.name;
errors.email = !values.email; // also loosely validated that it's email-like via browser (not foolproof)
errors.message = !values.message;
errors["h-captcha-response"] = !values["h-captcha-response"];
errors["cf-turnstile-response"] = !values["cf-turnstile-response"];
if (!errors.name && !errors.email && !errors.message && !errors["h-captcha-response"]) {
if (!errors.name && !errors.email && !errors.message && !errors["cf-turnstile-response"]) {
setFeedback("");
return {};
} else {
@ -267,7 +267,7 @@ const ContactForm = ({ className }: ContactFormProps) => {
](https://jarv.is), and <code>`code`</code>.
</MarkdownTip>
<HCaptcha onVerify={(token) => setFieldValue("h-captcha-response", token)} />
<Turnstile onVerify={(token) => setFieldValue("cf-turnstile-response", token)} />
<ActionRow>
<SubmitButton

View File

@ -21,7 +21,6 @@
},
"dependencies": {
"@giscus/react": "^3.0.0",
"@hcaptcha/react-hcaptcha": "^1.10.1",
"@libsql/client": "^0.6.2",
"@novnc/novnc": "1.4.0",
"@octokit/graphql": "^8.1.1",
@ -59,6 +58,7 @@
"react-is": "18.3.1",
"react-player": "^2.16.0",
"react-textarea-autosize": "^8.5.3",
"react-turnstile": "^1.1.3",
"react-tweet": "^3.2.1",
"rehype-prism-plus": "^2.0.0",
"rehype-sanitize": "^6.0.0",

View File

@ -1,10 +1,15 @@
import nodemailer from "nodemailer";
import queryString from "query-string";
import fetcher from "../../lib/helpers/fetcher";
import config from "../../lib/config";
import type { NextApiHandler } from "next";
const handler: NextApiHandler = async (req, res) => {
const handler: NextApiHandler<
{
success?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error?: any;
} | null
> = async (req, res) => {
// only allow POST requests, otherwise return a 405 Method Not Allowed
if (req.method !== "POST") {
return res.status(405).send(null);
@ -20,7 +25,13 @@ const handler: NextApiHandler = async (req, res) => {
// all fields are required
throw new Error("missing_data");
}
if (!data["h-captcha-response"] || !(await validateCaptcha(data["h-captcha-response"]))) {
if (
!data["cf-turnstile-response"] ||
!(await validateCaptcha(
data["cf-turnstile-response"],
(req.headers["x-forwarded-for"] as string) || (req.headers["x-real-ip"] as string) || ""
))
) {
// either the captcha is wrong or completely missing
throw new Error("invalid_captcha");
}
@ -44,17 +55,17 @@ const handler: NextApiHandler = async (req, res) => {
}
};
const validateCaptcha = async (formResponse: unknown): Promise<unknown> => {
const response = await fetcher("https://hcaptcha.com/siteverify", {
const validateCaptcha = async (formResponse: unknown, ip: string): Promise<unknown> => {
const response = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Type": "application/json",
},
body: queryString.stringify({
body: JSON.stringify({
// fallback to dummy secret for testing: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
secret: process.env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA",
response: formResponse,
// fallback to dummy secret for testing: https://docs.hcaptcha.com/#integration-testing-test-keys
sitekey: process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001",
secret: process.env.HCAPTCHA_SECRET_KEY || "0x0000000000000000000000000000000000000000",
remoteip: ip,
}),
});

View File

@ -106,34 +106,27 @@ const Privacy = () => {
</ListItem>
</UnorderedList>
<H2 id="hcaptcha">Fighting Spam</H2>
<H2 id="spam">Fighting Spam</H2>
<p>
Using{" "}
<Link href="https://www.hcaptcha.com/">
<strong>hCaptcha</strong>
<Link href="https://www.cloudflare.com/products/turnstile/">
<strong>Cloudflare Turnstile</strong>
</Link>{" "}
to fight bot spam on the <Link href="/contact/">contact form</Link> was an easy choice over seemingly
unavoidable alternatives like <Link href="https://developers.google.com/recaptcha/">reCAPTCHA</Link>.
</p>
<p>
You can refer to hCaptcha's <Link href="https://www.hcaptcha.com/privacy">privacy policy</Link> and{" "}
<Link href="https://www.hcaptcha.com/terms">terms of service</Link> for more details. While some information
is sent to the hCaptcha API about your behavior <strong>(on the contact page only)</strong>, at least you
won't be helping a certain internet conglomerate{" "}
You can refer to Cloudflare's <Link href="https://www.cloudflare.com/privacypolicy/">privacy policy</Link> and{" "}
<Link href="https://www.cloudflare.com/website-terms/">terms of service</Link> for more details. While some
information is sent to the Turnstile API about your behavior <strong>(on the contact page only)</strong>, at
least you won't be helping a certain internet conglomerate{" "}
<Link href="https://blog.cloudflare.com/moving-from-recaptcha-to-hcaptcha/">
train their self-driving cars
</Link>
. 🚗
</p>
<p>
I also enabled the setting to donate 100% of my{" "}
<Link href="https://humanprotocol.org/?lng=en-US">HMT token</Link> earnings to the{" "}
<Link href="https://wikimediafoundation.org/">Wikimedia Foundation</Link>, for what it's worth. (A few cents,
probably... 💰)
</p>
</Content>
</>
);

35
pnpm-lock.yaml generated
View File

@ -11,9 +11,6 @@ importers:
'@giscus/react':
specifier: ^3.0.0
version: 3.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@hcaptcha/react-hcaptcha':
specifier: ^1.10.1
version: 1.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@libsql/client':
specifier: ^0.6.2
version: 0.6.2
@ -125,6 +122,9 @@ importers:
react-textarea-autosize:
specifier: ^8.5.3
version: 8.5.3(@types/react@18.3.3)(react@18.3.1)
react-turnstile:
specifier: ^1.1.3
version: 1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-tweet:
specifier: ^3.2.1
version: 3.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -293,15 +293,6 @@ packages:
react: ^16 || ^17 || ^18
react-dom: ^16 || ^17 || ^18
'@hcaptcha/loader@1.2.4':
resolution: {integrity: sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw==}
'@hcaptcha/react-hcaptcha@1.10.1':
resolution: {integrity: sha512-P0en4gEZAecah7Pt3WIaJO2gFlaLZKkI0+Tfdg8fNqsDxqT9VytZWSkH4WAkiPRULK1QcGgUZK+J56MXYmPifw==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@ -2627,6 +2618,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-turnstile@1.1.3:
resolution: {integrity: sha512-nWgsnN2IgDSj91BK2iF/9GMVRJK0KPuDDxgnhs4o/7zfIRfyZG/ALWs+JJ8unW84MtFXpcEiPsookkd/FIb4aw==}
peerDependencies:
react: '>= 16.13.1'
react-dom: '>= 16.13.1'
react-tweet@3.2.1:
resolution: {integrity: sha512-dktP3RMuwRB4pnSDocKpSsW5Hq1IXRW6fONkHhxT5EBIXsKZzdQuI70qtub1XN2dtZdkJWWxfBm/Q+kN+vRYFA==}
peerDependencies:
@ -3308,15 +3305,6 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@hcaptcha/loader@1.2.4': {}
'@hcaptcha/react-hcaptcha@1.10.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@babel/runtime': 7.24.7
'@hcaptcha/loader': 1.2.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@ -6275,6 +6263,11 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
react-turnstile@1.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-tweet@3.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@swc/helpers': 0.5.11