Skip to content

Commit

Permalink
Captcha (#45)
Browse files Browse the repository at this point in the history
Install ReCAPTCHA to minimize spam submissions.
  • Loading branch information
mplewis authored Nov 20, 2024
1 parent 3916d40 commit b3ce6a7
Show file tree
Hide file tree
Showing 15 changed files with 152 additions and 25 deletions.
2 changes: 1 addition & 1 deletion NOTES.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
- [ ] Captcha
- [ ] Discard changes
- [ ] Field length enforcement
- [ ] Fix disabled tests now that Markdown works
Expand Down Expand Up @@ -41,6 +40,7 @@
- [ ] Add pretty error messages for 404s (e.g. clicked an expired/tidied link)
- [ ] Redirect old slugs on slug change
- [ ] Site-wide announcement feature
- [x] Captcha
- [x] Hold RSVP locally with cookie
- [x] **Scheduler engine**
- [x] Send reminders
Expand Down
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,23 @@ Alias `rw` to make it easier to run commands: `alias rw='yarn redwood'`

Example values are provided in [.env.example](.env.example). Make a copy of that file named `.env`, then edit those values to set up your local development environment.

| Name | Type | Required? | Description |
| ------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| API_URL | string | | URL to the API server. When running locally, this should be `/.redwood/functions`. `redwood.toml` sets this to the Netlify value if unset. |
| DATABASE_URL | string | yes | The DB connection string which includes protocol, username, password, port, DB name, and options |
| DISCORD_WEBHOOK_URL | string | | If provided, send notifications for server events to this Discord channel via webhook |
| FROM_EMAIL | string | yes | The “from” address on outgoing emails |
| FROM_NAME | string | yes | The human-readable “from” name on outgoing emails |
| LOCAL_CHROMIUM | string | | Path to the Chromium binary, used to generate Open Graph event preview images |
| SENTRY_DSN | string | yes | DSN URL for your Sentry project, where errors are reported |
| SENTRY_ENV | string | no | Custom name reported for the environment for frontend Sentry errors. If unset, defaults to `process.env.NODE_ENV`. |
| SITE_HOST | string | yes | The hostname of your Freevite instance, used in absolute URLs (e.g. email content) |
| SMTP_HOST | string | yes | Hostname for your SMTP outgoing mail server |
| SMTP_PASS | string | yes | Password for your SMTP outgoing mail server |
| SMTP_USER | string | yes | Username for your SMTP outgoing mail server |
| TEST_DATABASE_URL | string | | The connection string for the DB instance used when running tests. If not provided, defaults to `./.redwood/test.db`. |
| Name | Type | Required? | Description |
| -------------------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| API_URL | string | | URL to the API server. When running locally, this should be `/.redwood/functions`. `redwood.toml` sets this to the Netlify value if unset. |
| DATABASE_URL | string | yes | The DB connection string which includes protocol, username, password, port, DB name, and options |
| DISCORD_WEBHOOK_URL | string | | If provided, send notifications for server events to this Discord channel via webhook |
| FROM_EMAIL | string | yes | The “from” address on outgoing emails |
| FROM_NAME | string | yes | The human-readable “from” name on outgoing emails |
| LOCAL_CHROMIUM | string | | Path to the Chromium binary, used to generate Open Graph event preview images |
| RECAPTCHA_SERVER_KEY | string | yes | [ReCAPTCHA](https://www.google.com/recaptcha) site key for the backend |
| REDWOOD_ENV_RECAPTCHA_CLIENT_KEY | string | yes | [ReCAPTCHA](https://www.google.com/recaptcha) site key for the frontend |
| REDWOOD_ENV_SENTRY_ENV | string | | Custom name reported for the environment for frontend Sentry errors. If unset, defaults to `process.env.NODE_ENV`. |
| SENTRY_DSN | string | yes | DSN URL for your Sentry project, where errors are reported |
| SITE_HOST | string | yes | The hostname of your Freevite instance, used in absolute URLs (e.g. email content) |
| SMTP_HOST | string | yes | Hostname for your SMTP outgoing mail server |
| SMTP_PASS | string | yes | Password for your SMTP outgoing mail server |
| SMTP_USER | string | yes | Username for your SMTP outgoing mail server |
| TEST_DATABASE_URL | string | | The connection string for the DB instance used when running tests. If not provided, defaults to `./.redwood/test.db`. |

# Contributions

Expand Down
1 change: 1 addition & 0 deletions api/src/graphql/events.sdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const schema = gql`
ownerEmail: String!
title: String!
responseConfig: ResponseConfig!
captchaResponse: String!
}
input UpdateEventInput {
Expand Down
1 change: 1 addition & 0 deletions api/src/graphql/responses.sdl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const schema = gql`
headCount: Int!
comment: String!
remindPriorSec: Int
captchaResponse: String!
}
input UpdateResponseInput {
Expand Down
26 changes: 26 additions & 0 deletions api/src/lib/captcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import querystring from 'querystring'

import { CI, RECAPTCHA_SERVER_KEY } from 'src/app.config'

import { logger } from './logger'

const RECAPTCHA_ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify'

/** Return true if the given captcha token was valid, false otherwise. */
export async function validateCaptcha(token: string): Promise<boolean> {
if (CI) return true
try {
const data = { secret: RECAPTCHA_SERVER_KEY, response: token }
const res = await fetch(RECAPTCHA_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: querystring.stringify(data),
})
const json = await res.json()
logger.debug({ json }, 'reCAPTCHA response')
return json.success
} catch (err) {
logger.error({ err }, 'Failed to validate reCAPTCHA')
return false
}
}
1 change: 1 addition & 0 deletions api/src/services/events/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe('events', () => {
ownerEmail: '[email protected]',
title: 'My birthday party',
responseConfig: 'DISABLED',
captchaResponse: 'some-captcha-response',
},
})
expect(result.ownerEmail).toEqual('[email protected]')
Expand Down
13 changes: 11 additions & 2 deletions api/src/services/events/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import type {
PublicResponse,
} from 'types/graphql'

import { validate } from '@redwoodjs/api'
import { RedwoodError, validate } from '@redwoodjs/api'

import { validateCaptcha } from 'src/lib/captcha'
import { db } from 'src/lib/db'
import { sendEventDetails } from 'src/lib/email/template/event'
import { notifyEventCreated, notifyEventUpdated } from 'src/lib/notify/event'
Expand Down Expand Up @@ -96,8 +97,16 @@ export const eventByPreviewToken: QueryResolvers['eventByPreviewToken'] =
async ({ previewToken }) => db.event.findUnique({ where: { previewToken } })

export const createEvent: MutationResolvers['createEvent'] = async ({
input,
input: _input,
}) => {
const { captchaResponse, ...input } = _input

const valid = await validateCaptcha(captchaResponse)
if (!valid)
throw new RedwoodError(
'Could not validate reCAPTCHA. Please refresh the page and try again.'
)

validate(input.ownerEmail, 'email', { email: true })
validate(input.title, 'title', {
custom: {
Expand Down
2 changes: 2 additions & 0 deletions api/src/services/responses/responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe('responses', () => {
email: '[email protected]',
headCount: 1,
comment: "I'll be there!",
captchaResponse: 'some-captcha-response',
},
})

Expand All @@ -59,6 +60,7 @@ describe('responses', () => {
headCount: 1,
comment: "I'll be there!",
remindPriorSec: dayjs.duration(1, 'day').asSeconds(),
captchaResponse: 'some-captcha-response',
},
})
const event = await db.event.findUnique({ where: { id: result.eventId } })
Expand Down
12 changes: 11 additions & 1 deletion api/src/services/responses/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type {
Reminder,
} from 'types/graphql'

import { RedwoodError } from '@redwoodjs/api'

import { validateCaptcha } from 'src/lib/captcha'
import { db } from 'src/lib/db'
import {
sendNewResponseReceived,
Expand Down Expand Up @@ -91,11 +94,18 @@ export const responseByEditToken: QueryResolvers['responseByEditToken'] =

export const createResponse: MutationResolvers['createResponse'] = async ({
eventId,
input,
input: _input,
}) => {
const event = await db.event.findUnique({ where: { id: eventId } })
if (!event) throw new Error(`Event not found: ${eventId}`)

const { captchaResponse, ...input } = _input
const valid = await validateCaptcha(captchaResponse)
if (!valid)
throw new RedwoodError(
'Could not validate reCAPTCHA. Please refresh the page and try again.'
)

const reminders: { sendAt: Date }[] = []
if (input.remindPriorSec) {
const sendAt = dayjs(event.start)
Expand Down
3 changes: 3 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ export const LOCALHOST = SITE_HOST.startsWith('localhost:')
export const S3_REGION = process.env.S3_REGION
export const S3_BUCKET = process.env.S3_BUCKET
export const S3_NAMESPACE = process.env.S3_NAMESPACE

export const RECAPTCHA_SERVER_KEY = process.env.RECAPTCHA_SERVER_KEY
export const RECAPTCHA_CLIENT_KEY = process.env.REDWOOD_ENV_RECAPTCHA_CLIENT_KEY
2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@
"jotai": "^2.10.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-google-recaptcha": "^3.1.0",
"react-select": "^5.8.2",
"react-svg-spinners": "^0.3.1"
},
"devDependencies": {
"@redwoodjs/vite": "8.4.0",
"@types/react-google-recaptcha": "^2.1.9",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"postcss-loader": "^8.1.1",
Expand Down
4 changes: 3 additions & 1 deletion web/src/components/NewResponseForm/NewResponseForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const NewResponseForm = ({ event, responseToken }: Props) => {
defaultValues: { headCount: 1 },
})

const [captchaResponse, setCaptchaResponse] = useState<string | null>(null)
const [createdForEmail, setCreatedForEmail] = useState<string | null>(null)

const [create, { loading, error }] = useMutation<
Expand Down Expand Up @@ -90,9 +91,10 @@ const NewResponseForm = ({ event, responseToken }: Props) => {
loading={loading}
error={error}
formMethods={formMethods}
onCaptchaResponse={setCaptchaResponse}
onSubmit={(data: FormValues) => {
if (!data.email) throw new Error('Email is required')
const input = { ...data, email: data.email }
const input = { ...data, email: data.email, captchaResponse }
create({ variables: { eventId: event.id, input } })
}}
/>
Expand Down
21 changes: 20 additions & 1 deletion web/src/components/ResponseForm/ResponseForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'

import { ApolloError } from '@apollo/client/errors'
import ReCAPTCHA from 'react-google-recaptcha'
import { SetOptional } from 'type-fest'
import { PublicEvent, UpdatableResponse } from 'types/graphql'

Expand All @@ -15,6 +16,7 @@ import {
UseFormReturn,
} from '@redwoodjs/forms'

import { RECAPTCHA_CLIENT_KEY } from 'src/app.config'
import FormField from 'src/components/FormField/FormField'
import Typ from 'src/components/Typ/Typ'
import { isEmail } from 'src/logic/validation'
Expand All @@ -30,6 +32,7 @@ export type Props = {
error?: ApolloError
formMethods: UseFormReturn<FormValues>
onChange?: React.FormEventHandler<HTMLFormElement>
onCaptchaResponse?: (response: string | null) => void
onSubmit: (data: FormValues) => void
}

Expand All @@ -49,7 +52,16 @@ function random<T>(choices: T[]): T {
}

const ResponseForm = (props: Props) => {
const { mode, event, error, loading, formMethods, onChange, onSubmit } = props
const {
mode,
event,
error,
loading,
formMethods,
onChange,
onCaptchaResponse,
onSubmit,
} = props
const { formState } = formMethods

const [exampleName, setExampleName] = useState('')
Expand Down Expand Up @@ -151,6 +163,13 @@ const ResponseForm = (props: Props) => {
</SelectField>
</FormField>

{mode === 'CREATE' && (
<ReCAPTCHA
sitekey={RECAPTCHA_CLIENT_KEY}
onChange={onCaptchaResponse}
/>
)}

<Submit
className="button is-success mt-3"
disabled={loading || !formState.isValid || !formState.isDirty}
Expand Down
18 changes: 16 additions & 2 deletions web/src/pages/NewEventPage/NewEventPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react'

import ReCAPTCHA from 'react-google-recaptcha'
import {
CreateEventMutation,
CreateEventMutationVariables,
Expand All @@ -18,6 +19,7 @@ import {
import { navigate, routes } from '@redwoodjs/router'
import { useMutation } from '@redwoodjs/web'

import { RECAPTCHA_CLIENT_KEY } from 'src/app.config'
import FormField from 'src/components/FormField/FormField'
import PageHead from 'src/components/PageHead/PageHead'
import Typ from 'src/components/Typ/Typ'
Expand Down Expand Up @@ -67,6 +69,7 @@ const NewEventPage = () => {
const { formState } = formMethods

const [redirecting, setRedirecting] = useState(false)
const [captchaResponse, setCaptchaResponse] = useState<string | null>(null)

const [create, { loading, error }] = useMutation<
CreateEventMutation,
Expand All @@ -89,7 +92,11 @@ const NewEventPage = () => {
<Form
className="mt-3"
formMethods={formMethods}
onSubmit={(input: FormValues) => create({ variables: { input } })}
onSubmit={(input: FormValues) =>
create({
variables: { input: { ...input, captchaResponse } },
})
}
>
<FormField name="ownerEmail" text="Email Address*">
<Typ x="labelDetails">
Expand Down Expand Up @@ -135,9 +142,16 @@ const NewEventPage = () => {
</SelectField>
</FormField>

<ReCAPTCHA
sitekey={RECAPTCHA_CLIENT_KEY}
onChange={setCaptchaResponse}
/>

<Submit
className="button is-success mt-3"
disabled={loading || redirecting || !formState.isValid}
disabled={
loading || redirecting || !formState.isValid || !captchaResponse
}
>
{loading || redirecting ? 'Creating...' : 'Create New Event'}
</Submit>
Expand Down
Loading

0 comments on commit b3ce6a7

Please sign in to comment.