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:
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
35
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user