diff --git a/package.json b/package.json index 95b4cad..9c31caa 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "lucide-react": "^0.309.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.49.3", "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", @@ -38,6 +39,7 @@ "@types/node": "^20.11.2", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", + "@types/react-google-recaptcha": "^2.1.8", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 555e685..942f0d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-google-recaptcha: + specifier: ^3.1.0 + version: 3.1.0(react@18.2.0) react-hook-form: specifier: ^7.49.3 version: 7.49.3(react@18.2.0) @@ -76,6 +79,9 @@ devDependencies: '@types/react-dom': specifier: ^18.2.17 version: 18.2.17 + '@types/react-google-recaptcha': + specifier: ^2.1.8 + version: 2.1.8 '@typescript-eslint/eslint-plugin': specifier: ^6.14.0 version: 6.14.0(@typescript-eslint/parser@6.14.0)(eslint@8.55.0)(typescript@5.2.2) @@ -2658,6 +2664,12 @@ packages: dependencies: '@types/react': 18.2.43 + /@types/react-google-recaptcha@2.1.8: + resolution: {integrity: sha512-nYI3ZDoteZ0g4FYusyKWqz7AZqRdu70R3wDkosCcN0peb2WLn57i0Alm4IPiCRIx59yTUVPTiOELZH08gV1wXA==} + dependencies: + '@types/react': 18.2.43 + dev: true + /@types/react@18.2.43: resolution: {integrity: sha512-nvOV01ZdBdd/KW6FahSbcNplt2jCJfyWdTos61RYHV+FVv5L/g9AOX1bmbVcWcLFL8+KHQfh1zVIQrud6ihyQA==} dependencies: @@ -3521,6 +3533,12 @@ packages: dependencies: function-bind: 1.1.2 + /hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 + dev: false + /ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} dev: true @@ -3978,6 +3996,14 @@ packages: hasBin: true dev: true + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + dev: false + /pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} dev: true @@ -3990,6 +4016,16 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /react-async-script@1.2.0(react@18.2.0): + resolution: {integrity: sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==} + peerDependencies: + react: '>=16.4.1' + dependencies: + hoist-non-react-statics: 3.3.2 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -3999,6 +4035,16 @@ packages: react: 18.2.0 scheduler: 0.23.0 + /react-google-recaptcha@3.1.0(react@18.2.0): + resolution: {integrity: sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==} + peerDependencies: + react: '>=16.4.1' + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-async-script: 1.2.0(react@18.2.0) + dev: false + /react-hook-form@7.49.3(react@18.2.0): resolution: {integrity: sha512-foD6r3juidAT1cOZzpmD/gOKt7fRsDhXXZ0y28+Al1CHgX+AY1qIN9VSIIItXRq1dN68QrRwl1ORFlwjBaAqeQ==} engines: {node: '>=18', pnpm: '8'} @@ -4008,6 +4054,10 @@ packages: react: 18.2.0 dev: false + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: false + /react-remove-scroll-bar@2.3.4(@types/react@18.2.43)(react@18.2.0): resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} engines: {node: '>=10'} diff --git a/service/config/config.go b/service/config/config.go index a025d7c..b399851 100644 --- a/service/config/config.go +++ b/service/config/config.go @@ -5,8 +5,9 @@ import ( ) var ( - Port = getEnvWithFallback("PORT", "3333") - Dsn = getEnvWithFallback("DB_CONNECTION_STRING", "adb_user:adbpassword@tcp(localhost:3306)/campaign_mailer") + Port = getEnvWithFallback("PORT", "3333") + Dsn = getEnvWithFallback("DB_CONNECTION_STRING", "adb_user:adbpassword@tcp(localhost:3306)/campaign_mailer") + RecaptchaSecret = getEnvWithFallback("RECAPTCHA_SECRET", "") ) func getEnvWithFallback(key string, fallback string) string { diff --git a/service/handlers.go b/service/handlers.go index 55c0592..44dc22f 100644 --- a/service/handlers.go +++ b/service/handlers.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/dxe/helptheducks.com/service/config" "github.com/dxe/helptheducks.com/service/model" "net/http" "net/mail" @@ -17,7 +18,7 @@ type CreateMessageInput struct { Zip string `json:"zip,omitempty"` City string `json:"city,omitempty"` Message string `json:"message"` - // TODO: add captcha. + Token string `json:"token"` } func createMessageHandler(w http.ResponseWriter, r *http.Request) { @@ -29,6 +30,21 @@ func createMessageHandler(w http.ResponseWriter, r *http.Request) { return } + if config.RecaptchaSecret == "" { + fmt.Println("Recaptcha secret not set, skipping verification") + } else { + ok, err := verifyRecaptcha(body.Token) + if err != nil { + fmt.Printf("error verifying recaptcha: %v\n", err) + http.Error(w, "error verifying recaptcha", http.StatusInternalServerError) + return + } + if !ok { + http.Error(w, "invalid captcha", http.StatusForbidden) + return + } + } + _, err = mail.ParseAddress(body.Email) if err != nil { http.Error(w, fmt.Sprintf("invalid email address: %v", err), http.StatusBadRequest) diff --git a/service/recaptcha.go b/service/recaptcha.go new file mode 100644 index 0000000..df56123 --- /dev/null +++ b/service/recaptcha.go @@ -0,0 +1,30 @@ +package main + +import ( + "encoding/json" + "github.com/dxe/helptheducks.com/service/config" + "net/http" + "net/url" +) + +type RecaptchaResponse struct { + Success bool `json:"success"` +} + +const verifyUrl = "https://www.google.com/recaptcha/api/siteverify" + +func verifyRecaptcha(token string) (bool, error) { + resp, err := http.PostForm(verifyUrl, url.Values{ + "secret": {config.RecaptchaSecret}, + "response": {token}, + }) + if err != nil { + return false, err + } + defer resp.Body.Close() + var result RecaptchaResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false, err + } + return result.Success, nil +} diff --git a/src/components/petition.tsx b/src/components/petition.tsx index 577682d..2aec3ec 100644 --- a/src/components/petition.tsx +++ b/src/components/petition.tsx @@ -5,7 +5,7 @@ import { } from "../data/petition.ts"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Form, FormControl, @@ -31,10 +31,11 @@ import { import ky from "ky"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert.tsx"; import { LoaderIcon, MailCheckIcon } from "lucide-react"; +import ReCAPTCHA from "react-google-recaptcha"; const PETITION_API_URL = "https://petitions-229503.appspot.com/api/sign"; -const CAMPAIGN_MAILER_API_URL = "https://helptheducks.dxe.io/message/create"; +const CAMPAIGN_MAILER_API_URL = `${import.meta.env.PROD ? "https://helptheducks.dxe.io" : "http://localhost:3333"}/message/create`; declare global { interface Window { @@ -42,6 +43,8 @@ declare global { } } +const CAPTCHA_SITE_KEY = "6LdiglcpAAAAAM9XE_TNnAiZ22NR9nSRxHMOFn8E"; + export const Petition = () => { const form = useForm({ resolver: zodResolver(PetitionFormSchema), @@ -70,10 +73,21 @@ export const Petition = () => { const onSubmit = useMemo( () => handleSubmit(async (data) => { - setIsSubmitting(true); window.dataLayer?.push({ event: "form_submitted", }); + setIsSubmitting(true); + if (!recaptchaRef.current) { + alert("Error loading captcha. Please refresh the page & try again."); + setIsSubmitting(false); + return; + } + const token = await recaptchaRef.current.executeAsync(); + if (!token) { + alert("Captcha error. Please refresh the page & try again."); + setIsSubmitting(false); + return; + } // We purposefully do these one at a time. If the first one fails, // we don't want to submit the second one. This allows the user to // resubmit the form without causing duplicate emails to be sent. @@ -107,6 +121,7 @@ export const Petition = () => { ...(data.zip && { zip: data.zip }), ...(data.city && { city: data.city }), message: data.message, + token, }, headers: { "Content-Type": "application/json", @@ -163,6 +178,8 @@ export const Petition = () => { [dirtyFields.message, resetField], ); + const recaptchaRef = useRef(null); + return isSubmitted ? ( @@ -335,6 +352,12 @@ export const Petition = () => { )} Submit +

By signing, you agree to receive email messages from Direct Action Everywhere. You may unsubscribe at any time. diff --git a/src/components/svg/play-icon.tsx b/src/components/svg/play-icon.tsx index 93ec983..36e8e1e 100644 --- a/src/components/svg/play-icon.tsx +++ b/src/components/svg/play-icon.tsx @@ -1,7 +1,6 @@ export const PlayIcon = ({ className }: { className?: string }) => (