Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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=0x4AAAAAAEKG6l3R_rWeoGYT
TURNSTILE_SECRET=

# Fund-specific webhook secrets
BTCPAY_WEBHOOK_SECRET_GENERAL=generated_by_btcpay
BTCPAY_WEBHOOK_SECRET_NOSTR=generated_by_btcpay
Expand Down
36 changes: 33 additions & 3 deletions components/grant-application/MultiStepApplicationForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode, useRef, useState } from 'react'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/router'
import { DefaultValues, useForm } from 'react-hook-form'
import {
Expand All @@ -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,19 @@ export default function MultiStepApplicationForm({
}
}

const submitDisabled = !!submitRequiresChecked?.some((name) => !watch(name))
const isLastStep = currentStep === steps.length - 1

// Widget unmounts when leaving the last step; clear readiness so submit
// cannot stay enabled on a stale token when the user returns.
useEffect(() => {
if (!isLastStep) {
setTurnstileReady(false)
}
}, [isLastStep])

const submitDisabled =
!!submitRequiresChecked?.some((name) => !watch(name)) ||
(isLastStep && !turnstileReady)

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onSubmit = async (data: any) => {
Expand All @@ -94,7 +109,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 +124,8 @@ export default function MultiStepApplicationForm({
? `${baseMessage} ${SUBMISSION_CONTACT}`
: baseMessage
)
setTurnstileReady(false)
turnstileRef.current?.reset()
} finally {
setLoading(false)
}
Expand Down Expand Up @@ -132,6 +153,15 @@ export default function MultiStepApplicationForm({

{steps[currentStep].render(stepProps)}

{isLastStep && (
<div className="my-8 flex flex-col items-center py-2">
<TurnstileWidget
ref={turnstileRef}
onTokenChange={(token) => setTurnstileReady(!!token)}
/>
</div>
)}

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

const TURNSTILE_SCRIPT =
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
const CALLBACK_NAME = '__opensatsTurnstileCallback'
const EXPIRED_CALLBACK_NAME = '__opensatsTurnstileExpired'
const ERROR_CALLBACK_NAME = '__opensatsTurnstileError'

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
ready?: (callback: () => void) => void
}
[CALLBACK_NAME]?: (token: string) => void
[EXPIRED_CALLBACK_NAME]?: () => void
[ERROR_CALLBACK_NAME]?: () => void
}
}

export interface TurnstileWidgetHandle {
getToken: () => string | undefined
waitForToken: () => Promise<string>
/** Clear the current token and ask Turnstile for a new challenge. */
reset: () => void
}

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

/**
* Explicit-render Turnstile widget so remounting the last apply step
* creates a fresh challenge (implicit render only mounts once).
*/
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 onTokenChangeRef = useRef(onTokenChange)
onTokenChangeRef.current = onTokenChange

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

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)
}
}, [setToken])

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

useEffect(() => {
window[CALLBACK_NAME] = (token: string) => setToken(token)
window[EXPIRED_CALLBACK_NAME] = () => setToken(undefined)
window[ERROR_CALLBACK_NAME] = () => setToken(undefined)

return () => {
delete window[CALLBACK_NAME]
delete window[EXPIRED_CALLBACK_NAME]
delete window[ERROR_CALLBACK_NAME]
setToken(undefined)
}
}, [setToken])

const mountWidget = useCallback(() => {
if (!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: string) => window[CALLBACK_NAME]?.(token),
'expired-callback': () => window[EXPIRED_CALLBACK_NAME]?.(),
'error-callback': () => window[ERROR_CALLBACK_NAME]?.(),
})
}, [])

useEffect(() => {
if (!SITE_KEY) return

let cancelled = false

const tryMount = () => {
if (cancelled) return
if (window.turnstile?.render) {
mountWidget()
return
}
window.turnstile?.ready?.(() => {
if (!cancelled) mountWidget()
})
}

tryMount()

return () => {
cancelled = true
if (widgetIdRef.current && window.turnstile?.remove) {
window.turnstile.remove(widgetIdRef.current)
}
widgetIdRef.current = undefined
setToken(undefined)
}
}, [mountWidget, 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}
strategy="afterInteractive"
onLoad={mountWidget}
/>
<div ref={containerRef} />
</>
)
}
)

export default TurnstileWidget
4 changes: 2 additions & 2 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
// You might need to insert additional domains in script-src if you are using external services
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app youtube.com http://www.youtube.com https://www.youtube.com cdn.usefathom.com;
script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app youtube.com http://www.youtube.com https://www.youtube.com cdn.usefathom.com challenges.cloudflare.com https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src * blob: data:;
media-src 'self' youtube.com https://www.youtube.com;
connect-src *;
font-src 'self';
frame-src youtube.com https://www.youtube.com;
frame-src youtube.com https://www.youtube.com challenges.cloudflare.com https://challenges.cloudflare.com;
`

const securityHeaders = [
Expand Down
14 changes: 14 additions & 0 deletions pages/api/github.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next/types'
import { sendApplicationEmails } from '@/utils/application-emails'
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 +14,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 Expand Up @@ -186,6 +192,14 @@ ${contactFooter}`
labels: issueLabels,
})

// Best-effort: GitHub record is the source of truth; email failure must
// not block a successful submit (avoids a second Turnstile mid-flight).
try {
await sendApplicationEmails(req.body)
} catch (emailErr) {
console.error('Application emails failed after issue create:', emailErr)
}

res.status(200).json({ message: 'success' })
} catch (err) {
res.status(500).json({ statusCode: 500, message: (err as Error).message })
Expand Down
Loading
Loading