1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-11-30 15:23:50 -05:00

switch to cloudflare turnstile

This commit is contained in:
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= RESEND_DOMAIN=
# required for production. falls back to testing keys if not set or in dev environment: # 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. # site key must be prefixed with NEXT_PUBLIC_ since it is used to embed the actual captcha widget.
# https://dashboard.hcaptcha.com/sites # https://developers.cloudflare.com/turnstile/
NEXT_PUBLIC_HCAPTCHA_SITE_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# used only for backend validation of contact form submissions on /api/contact. # 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. # optional. sets the site ID of the fathom analytics script.
# https://app.usefathom.com/sites # 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 useHasMounted from "../../hooks/useHasMounted";
import useTheme from "../../hooks/useTheme"; import useTheme from "../../hooks/useTheme";
import type { ComponentPropsWithoutRef } from "react";
export type CaptchaProps = { export type CaptchaProps = Omit<ComponentPropsWithoutRef<typeof Turnstile>, "sitekey"> & {
size?: "normal" | "compact" | "invisible";
theme?: "light" | "dark";
className?: string; 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 hasMounted = useHasMounted();
const { activeTheme } = useTheme(); const { activeTheme } = useTheme();
return ( return (
<div className={className}> <div className={className}>
{hasMounted && ( {hasMounted && (
<HCaptcha <Turnstile
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001"} sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"}
reCaptchaCompat={false}
tabIndex={0}
size={size}
theme={theme || (activeTheme === "dark" ? activeTheme : "light")} theme={theme || (activeTheme === "dark" ? activeTheme : "light")}
{...rest} {...rest}
/> />

View File

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

View File

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

View File

@@ -1,10 +1,15 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import queryString from "query-string";
import fetcher from "../../lib/helpers/fetcher"; import fetcher from "../../lib/helpers/fetcher";
import config from "../../lib/config"; import config from "../../lib/config";
import type { NextApiHandler } from "next"; 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 // only allow POST requests, otherwise return a 405 Method Not Allowed
if (req.method !== "POST") { if (req.method !== "POST") {
return res.status(405).send(null); return res.status(405).send(null);
@@ -20,7 +25,13 @@ const handler: NextApiHandler = async (req, res) => {
// all fields are required // all fields are required
throw new Error("missing_data"); 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 // either the captcha is wrong or completely missing
throw new Error("invalid_captcha"); throw new Error("invalid_captcha");
} }
@@ -44,17 +55,17 @@ const handler: NextApiHandler = async (req, res) => {
} }
}; };
const validateCaptcha = async (formResponse: unknown): Promise<unknown> => { const validateCaptcha = async (formResponse: unknown, ip: string): Promise<unknown> => {
const response = await fetcher("https://hcaptcha.com/siteverify", { const response = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST", method: "POST",
headers: { 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, response: formResponse,
// fallback to dummy secret for testing: https://docs.hcaptcha.com/#integration-testing-test-keys remoteip: ip,
sitekey: process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY || "10000000-ffff-ffff-ffff-000000000001",
secret: process.env.HCAPTCHA_SECRET_KEY || "0x0000000000000000000000000000000000000000",
}), }),
}); });

View File

@@ -106,34 +106,27 @@ const Privacy = () => {
</ListItem> </ListItem>
</UnorderedList> </UnorderedList>
<H2 id="hcaptcha">Fighting Spam</H2> <H2 id="spam">Fighting Spam</H2>
<p> <p>
Using{" "} Using{" "}
<Link href="https://www.hcaptcha.com/"> <Link href="https://www.cloudflare.com/products/turnstile/">
<strong>hCaptcha</strong> <strong>Cloudflare Turnstile</strong>
</Link>{" "} </Link>{" "}
to fight bot spam on the <Link href="/contact/">contact form</Link> was an easy choice over seemingly 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>. unavoidable alternatives like <Link href="https://developers.google.com/recaptcha/">reCAPTCHA</Link>.
</p> </p>
<p> <p>
You can refer to hCaptcha's <Link href="https://www.hcaptcha.com/privacy">privacy policy</Link> and{" "} You can refer to Cloudflare's <Link href="https://www.cloudflare.com/privacypolicy/">privacy policy</Link> and{" "}
<Link href="https://www.hcaptcha.com/terms">terms of service</Link> for more details. While some information <Link href="https://www.cloudflare.com/website-terms/">terms of service</Link> for more details. While some
is sent to the hCaptcha API about your behavior <strong>(on the contact page only)</strong>, at least you information is sent to the Turnstile API about your behavior <strong>(on the contact page only)</strong>, at
won't be helping a certain internet conglomerate{" "} least you won't be helping a certain internet conglomerate{" "}
<Link href="https://blog.cloudflare.com/moving-from-recaptcha-to-hcaptcha/"> <Link href="https://blog.cloudflare.com/moving-from-recaptcha-to-hcaptcha/">
train their self-driving cars train their self-driving cars
</Link> </Link>
. 🚗 . 🚗
</p> </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> </Content>
</> </>
); );

35
pnpm-lock.yaml generated
View File

@@ -11,9 +11,6 @@ importers:
'@giscus/react': '@giscus/react':
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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': '@libsql/client':
specifier: ^0.6.2 specifier: ^0.6.2
version: 0.6.2 version: 0.6.2
@@ -125,6 +122,9 @@ importers:
react-textarea-autosize: react-textarea-autosize:
specifier: ^8.5.3 specifier: ^8.5.3
version: 8.5.3(@types/react@18.3.3)(react@18.3.1) 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: react-tweet:
specifier: ^3.2.1 specifier: ^3.2.1
version: 3.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.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: ^16 || ^17 || ^18
react-dom: ^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': '@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@@ -2627,6 +2618,12 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 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: react-tweet@3.2.1:
resolution: {integrity: sha512-dktP3RMuwRB4pnSDocKpSsW5Hq1IXRW6fONkHhxT5EBIXsKZzdQuI70qtub1XN2dtZdkJWWxfBm/Q+kN+vRYFA==} resolution: {integrity: sha512-dktP3RMuwRB4pnSDocKpSsW5Hq1IXRW6fONkHhxT5EBIXsKZzdQuI70qtub1XN2dtZdkJWWxfBm/Q+kN+vRYFA==}
peerDependencies: peerDependencies:
@@ -3308,15 +3305,6 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(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': '@humanwhocodes/config-array@0.11.14':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
@@ -6275,6 +6263,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@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): react-tweet@3.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@swc/helpers': 0.5.11 '@swc/helpers': 0.5.11