Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ ZAPRITE_USER_UUID=
NEXT_PUBLIC_GOOGLE_DOC_ID=
NEXT_PUBLIC_GOOGLE_API_KEY=

# Cloudflare Turnstile (grant application forms)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAEKByBDYEcxiPW-n
TURNSTILE_SECRET=

# Fund-specific webhook secrets
BTCPAY_WEBHOOK_SECRET_GENERAL=generated_by_btcpay
BTCPAY_WEBHOOK_SECRET_NOSTR=generated_by_btcpay
Expand Down
23 changes: 21 additions & 2 deletions components/grant-application/MultiStepApplicationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../../utils/application-submission'
import StepIndicator from './StepIndicator'
import StepNavigation from './StepNavigation'
import TurnstileWidget, { TurnstileWidgetHandle } from './TurnstileWidget'
import { FormValues, StepProps } from './types'

export interface StepConfig {
Expand Down Expand Up @@ -38,7 +39,9 @@ export default function MultiStepApplicationForm({
const [loading, setLoading] = useState(false)
const [failureReason, setFailureReason] = useState<string>()
const [failedAttempts, setFailedAttempts] = useState(0)
const [turnstileReady, setTurnstileReady] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
const router = useRouter()

const {
Expand Down Expand Up @@ -84,7 +87,10 @@ export default function MultiStepApplicationForm({
}
}

const submitDisabled = !!submitRequiresChecked?.some((name) => !watch(name))
const isLastStep = currentStep === steps.length - 1
const submitDisabled =
!!submitRequiresChecked?.some((name) => !watch(name)) ||
(isLastStep && !turnstileReady)
Comment thread
dergigi marked this conversation as resolved.

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onSubmit = async (data: any) => {
Expand All @@ -94,7 +100,11 @@ export default function MultiStepApplicationForm({
setFailureReason(undefined)

try {
await submitApplication(data)
const turnstile = turnstileRef.current
if (!turnstile) {
throw new Error('Please complete the bot verification challenge.')
}
await submitApplication(data, undefined, turnstile)
await router.push('/submitted')
} catch (e) {
const nextFailures = failedAttempts + 1
Expand All @@ -105,6 +115,8 @@ export default function MultiStepApplicationForm({
? `${baseMessage} ${SUBMISSION_CONTACT}`
: baseMessage
)
setTurnstileReady(false)
void turnstileRef.current?.reset()
} finally {
setLoading(false)
}
Expand Down Expand Up @@ -132,6 +144,13 @@ export default function MultiStepApplicationForm({

{steps[currentStep].render(stepProps)}

{isLastStep && (
<TurnstileWidget
ref={turnstileRef}
onTokenChange={(token) => setTurnstileReady(!!token)}
/>
)}

<StepNavigation
currentStep={currentStep}
totalSteps={steps.length}
Expand Down
137 changes: 137 additions & 0 deletions components/grant-application/TurnstileWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import Script from 'next/script'
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'

const TURNSTILE_SCRIPT = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY

declare global {
interface Window {
turnstile?: {
render: (
element: HTMLElement,
options: {
sitekey: string
action?: string
callback?: (token: string) => void
'expired-callback'?: () => void
'error-callback'?: () => void
}
) => string
reset: (widgetId?: string) => void
remove: (widgetId?: string) => void
}
}
}

export interface TurnstileWidgetHandle {
getToken: () => string | undefined
waitForToken: () => Promise<string>
reset: () => Promise<string>
}

interface TurnstileWidgetProps {
onTokenChange?: (token: string | undefined) => void
}

const TurnstileWidget = forwardRef<TurnstileWidgetHandle, TurnstileWidgetProps>(
function TurnstileWidget({ onTokenChange }, ref) {
const containerRef = useRef<HTMLDivElement>(null)
const widgetIdRef = useRef<string>()
const tokenRef = useRef<string>()
const waitersRef = useRef<Array<(token: string) => void>>([])
const [scriptReady, setScriptReady] = useState(false)

const setToken = useCallback(
(token: string | undefined) => {
tokenRef.current = token
onTokenChange?.(token)
if (token) {
const waiters = waitersRef.current
waitersRef.current = []
waiters.forEach((resolve) => resolve(token))
}
},
[onTokenChange]
)

const waitForToken = useCallback(() => {
if (tokenRef.current) return Promise.resolve(tokenRef.current)
return new Promise<string>((resolve) => {
waitersRef.current.push(resolve)
})
}, [])

const reset = useCallback(() => {
setToken(undefined)
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current)
}
return waitForToken()
}, [setToken, waitForToken])
Comment thread
dergigi marked this conversation as resolved.
Outdated

useImperativeHandle(
ref,
() => ({
getToken: () => tokenRef.current,
waitForToken,
reset,
}),
[reset, waitForToken]
)

useEffect(() => {
if (!scriptReady || !SITE_KEY || !containerRef.current || !window.turnstile) {
return
}
if (widgetIdRef.current) return

widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: SITE_KEY,
action: 'turnstile-spin-v2',
callback: (token) => setToken(token),
'expired-callback': () => setToken(undefined),
'error-callback': () => setToken(undefined),
})

return () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current)
widgetIdRef.current = undefined
}
}
}, [scriptReady, setToken])

if (!SITE_KEY) {
return (
<p className="text-sm text-red-600">
Turnstile is not configured (`NEXT_PUBLIC_TURNSTILE_SITE_KEY`).
</p>
)
}

return (
<>
<Script
src={`${TURNSTILE_SCRIPT}?render=explicit`}
strategy="afterInteractive"
onLoad={() => setScriptReady(true)}
/>
<div
ref={containerRef}
className="cf-turnstile"
data-sitekey={SITE_KEY}
data-action="turnstile-spin-v2"
/>
</>
)
}
)

export default TurnstileWidget
8 changes: 8 additions & 0 deletions pages/api/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { NextApiRequest, NextApiResponse } from 'next/types'
import {
assertTurnstile,
TURNSTILE_FAILURE_MESSAGE,
} from '@/utils/turnstile'

const GH_ACCESS_TOKEN = process.env.GH_ACCESS_TOKEN
const GH_ORG = process.env.GH_ORG
Expand All @@ -12,6 +16,10 @@ export default async function handler(
res: NextApiResponse
) {
if (req.method === 'POST') {
if (!(await assertTurnstile(req))) {
return res.status(403).json({ message: TURNSTILE_FAILURE_MESSAGE })
}

if (!GH_ACCESS_TOKEN || !GH_ORG || !GH_APP_REPO) {
throw new Error('Env misconfigured')
}
Expand Down
10 changes: 10 additions & 0 deletions pages/api/sendgrid.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { NextApiRequest, NextApiResponse } from 'next/types'
import sgMail from '@sendgrid/mail'
import { marked } from 'marked'
import {
assertTurnstile,
TURNSTILE_FAILURE_MESSAGE,
TURNSTILE_TOKEN_FIELD,
} from '@/utils/turnstile'

const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY
const TO_ADDRESS = process.env.SENDGRID_RECIPIENT
Expand Down Expand Up @@ -263,11 +268,16 @@ export default async function handler(
return res.status(405).end('Method Not Allowed')
}

if (!(await assertTurnstile(req))) {
return res.status(403).json({ message: TURNSTILE_FAILURE_MESSAGE })
}

if (!SENDGRID_API_KEY || !TO_ADDRESS || !FROM_ADDRESS) {
throw new Error('Env misconfigured')
}

const body = Object.entries(req.body)
.filter(([key]) => key !== TURNSTILE_TOKEN_FIELD)
.map(
([key, value]) => `<h3>${escapeHtml(key)}</h3><p>${escapeHtml(value)}</p>`
)
Expand Down
97 changes: 97 additions & 0 deletions tests/api/github.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/** @jest-environment node */
/* eslint-env jest, node */

process.env.GH_ACCESS_TOKEN = 'test-token'
process.env.GH_ORG = 'OpenSats'
process.env.GH_APP_REPO = 'applications'
process.env.TURNSTILE_SECRET = 'test-turnstile-secret'

jest.mock('@octokit/rest', () => {
const create = jest.fn()
return {
Octokit: jest.fn().mockImplementation(() => ({
rest: { issues: { create } },
})),
__create: create,
}
})

jest.mock('@/utils/turnstile', () => {
const actual = jest.requireActual('@/utils/turnstile')
return {
...actual,
assertTurnstile: jest.fn(),
}
})

const { __create: createIssue } = require('@octokit/rest')
const { assertTurnstile } = require('@/utils/turnstile')
const handler = require('../../pages/api/github.ts').default

function responseMock() {
return {
statusCode: undefined,
payload: undefined,
headers: {},
status(code) {
this.statusCode = code
return this
},
json(payload) {
this.payload = payload
return this
},
setHeader(name, value) {
this.headers[name] = value
},
end(payload) {
this.payload = payload
return this
},
}
}

const validApplication = {
project_name: 'Test project',
your_name: 'Applicant',
short_description: 'Description',
potential_impact: 'Impact',
proposed_budget: '1 BTC',
main_focus: 'nostr',
'cf-turnstile-response': 'test-token',
}

describe('/api/github', () => {
beforeEach(() => {
createIssue.mockReset()
createIssue.mockResolvedValue({ data: { number: 1 } })
assertTurnstile.mockReset()
assertTurnstile.mockResolvedValue(true)
jest.spyOn(console, 'log').mockImplementation(() => undefined)
})

afterEach(() => {
jest.restoreAllMocks()
})

it('creates an issue when Turnstile verification succeeds', async () => {
const response = responseMock()

await handler({ method: 'POST', body: validApplication }, response)

expect(response.statusCode).toBe(200)
expect(response.payload).toEqual({ message: 'success' })
expect(createIssue).toHaveBeenCalled()
})

it('rejects requests that fail Turnstile verification', async () => {
assertTurnstile.mockResolvedValue(false)
const response = responseMock()

await handler({ method: 'POST', body: validApplication }, response)

expect(response.statusCode).toBe(403)
expect(response.payload.message).not.toBe('success')
expect(createIssue).not.toHaveBeenCalled()
})
})
Loading
Loading