Initial commit
This commit is contained in:
248
templates/turnstile-hono-route.ts
Normal file
248
templates/turnstile-hono-route.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Turnstile + Hono Route Handlers
|
||||
*
|
||||
* Complete examples for integrating Turnstile validation
|
||||
* with Hono API routes in Cloudflare Workers
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { validateTurnstile, type TurnstileResponse } from './turnstile-server-validation'
|
||||
|
||||
/**
|
||||
* Environment Bindings
|
||||
*/
|
||||
type Bindings = {
|
||||
TURNSTILE_SECRET_KEY: string
|
||||
TURNSTILE_SITE_KEY: string
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>()
|
||||
|
||||
/**
|
||||
* Example 1: Simple Contact Form
|
||||
*/
|
||||
app.post('/api/contact', async (c) => {
|
||||
try {
|
||||
const body = await c.req.formData()
|
||||
const token = body.get('cf-turnstile-response')
|
||||
|
||||
if (!token) {
|
||||
return c.text('Missing Turnstile token', 400)
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const verifyFormData = new FormData()
|
||||
verifyFormData.append('secret', c.env.TURNSTILE_SECRET_KEY)
|
||||
verifyFormData.append('response', token.toString())
|
||||
verifyFormData.append('remoteip', c.req.header('CF-Connecting-IP') || '')
|
||||
|
||||
const verifyResult = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: verifyFormData,
|
||||
}
|
||||
)
|
||||
|
||||
const outcome = await verifyResult.json<{ success: boolean }>()
|
||||
|
||||
if (!outcome.success) {
|
||||
return c.text('Invalid Turnstile token', 401)
|
||||
}
|
||||
|
||||
// Process contact form
|
||||
const email = body.get('email')?.toString()
|
||||
const message = body.get('message')?.toString()
|
||||
|
||||
console.log('Contact form submitted:', { email, message })
|
||||
|
||||
// Your business logic here (send email, save to DB, etc.)
|
||||
|
||||
return c.json({ message: 'Contact form submitted successfully' })
|
||||
} catch (error) {
|
||||
console.error('Contact form error:', error)
|
||||
return c.text('Internal server error', 500)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 2: Login with Turnstile
|
||||
*/
|
||||
app.post('/api/auth/login', async (c) => {
|
||||
try {
|
||||
const { username, password, 'cf-turnstile-response': token } = await c.req.json()
|
||||
|
||||
// Validate Turnstile token first
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
c.env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: c.req.header('CF-Connecting-IP'),
|
||||
expectedAction: 'login',
|
||||
expectedHostname: new URL(c.req.url).hostname,
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Invalid Turnstile token',
|
||||
codes: result['error-codes'],
|
||||
},
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
// Validate credentials (example - use proper auth in production)
|
||||
if (!username || !password) {
|
||||
return c.json({ error: 'Missing credentials' }, 400)
|
||||
}
|
||||
|
||||
// Check credentials against database
|
||||
// const user = await db.query('SELECT * FROM users WHERE username = ?', [username])
|
||||
|
||||
// Create session token
|
||||
// const sessionToken = await createSession(user.id)
|
||||
|
||||
return c.json({
|
||||
message: 'Login successful',
|
||||
// token: sessionToken,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return c.json({ error: 'Login failed' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 3: Signup with Turnstile + Rate Limiting
|
||||
*/
|
||||
app.post('/api/auth/signup', async (c) => {
|
||||
try {
|
||||
const { email, password, 'cf-turnstile-response': token } = await c.req.json()
|
||||
|
||||
// Validate Turnstile
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
c.env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: c.req.header('CF-Connecting-IP'),
|
||||
expectedAction: 'signup',
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Bot detection failed' }, 401)
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return c.json({ error: 'Missing required fields' }, 400)
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
// const existingUser = await db.query('SELECT id FROM users WHERE email = ?', [email])
|
||||
// if (existingUser) {
|
||||
// return c.json({ error: 'User already exists' }, 409)
|
||||
// }
|
||||
|
||||
// Create user
|
||||
// const hashedPassword = await hashPassword(password)
|
||||
// await db.query('INSERT INTO users (email, password) VALUES (?, ?)', [email, hashedPassword])
|
||||
|
||||
return c.json({
|
||||
message: 'Signup successful',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Signup error:', error)
|
||||
return c.json({ error: 'Signup failed' }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 4: Middleware for Turnstile Validation
|
||||
*/
|
||||
async function turnstileMiddleware(c: any, next: () => Promise<void>) {
|
||||
const contentType = c.req.header('Content-Type')
|
||||
|
||||
let token: string | null = null
|
||||
|
||||
// Get token from FormData or JSON
|
||||
if (contentType?.includes('multipart/form-data') || contentType?.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await c.req.formData()
|
||||
token = formData.get('cf-turnstile-response')?.toString() || null
|
||||
} else if (contentType?.includes('application/json')) {
|
||||
const body = await c.req.json()
|
||||
token = body['cf-turnstile-response'] || null
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: 'Missing Turnstile token' }, 400)
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const result = await validateTurnstile(
|
||||
token,
|
||||
c.env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: c.req.header('CF-Connecting-IP'),
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({
|
||||
error: 'Turnstile validation failed',
|
||||
codes: result['error-codes'],
|
||||
}, 401)
|
||||
}
|
||||
|
||||
// Store result in context for route handler
|
||||
c.set('turnstileResult', result)
|
||||
|
||||
await next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 5: Using Middleware
|
||||
*/
|
||||
app.post('/api/protected/action', turnstileMiddleware, async (c) => {
|
||||
const turnstileResult = c.get('turnstileResult') as TurnstileResponse
|
||||
|
||||
console.log('Turnstile validated:', turnstileResult)
|
||||
|
||||
// Your protected action here
|
||||
return c.json({ message: 'Action completed successfully' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 6: Get Sitekey Endpoint (for frontend)
|
||||
*/
|
||||
app.get('/api/turnstile/sitekey', (c) => {
|
||||
return c.json({
|
||||
sitekey: c.env.TURNSTILE_SITE_KEY,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 7: Health Check (without Turnstile)
|
||||
*/
|
||||
app.get('/health', (c) => {
|
||||
return c.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
/**
|
||||
* Example 8: CORS for Turnstile
|
||||
*/
|
||||
import { cors } from 'hono/cors'
|
||||
|
||||
app.use('/api/*', cors({
|
||||
origin: ['https://yourdomain.com', 'http://localhost:5173'],
|
||||
allowMethods: ['POST', 'GET', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type'],
|
||||
credentials: true,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Export
|
||||
*/
|
||||
export default app
|
||||
353
templates/turnstile-react-component.tsx
Normal file
353
templates/turnstile-react-component.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Turnstile React Component
|
||||
*
|
||||
* Uses @marsidev/react-turnstile (Cloudflare recommended)
|
||||
* npm install @marsidev/react-turnstile
|
||||
*/
|
||||
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Basic Example: Contact Form with Turnstile
|
||||
*/
|
||||
export function ContactForm() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!token) {
|
||||
setError('Please complete the verification')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(undefined)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
formData.append('cf-turnstile-response', token)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
setError(`Submission failed: ${errorText}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Success
|
||||
alert('Message sent successfully!')
|
||||
e.currentTarget.reset()
|
||||
setToken(undefined)
|
||||
} catch (err) {
|
||||
setError(`Network error: ${err.message}`)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message">Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onError={() => setError('Verification failed')}
|
||||
onExpire={() => setToken(undefined)}
|
||||
/>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<button type="submit" disabled={!token || isSubmitting}>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: With Ref for Manual Control
|
||||
*/
|
||||
export function AdvancedTurnstileForm() {
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
function handleReset() {
|
||||
// Reset the Turnstile widget
|
||||
turnstileRef.current?.reset()
|
||||
setToken(undefined)
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
// Completely remove the widget
|
||||
turnstileRef.current?.remove()
|
||||
setToken(undefined)
|
||||
}
|
||||
|
||||
function handleExecute() {
|
||||
// Manually trigger challenge (execution: 'execute' mode only)
|
||||
turnstileRef.current?.execute()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onError={(error) => console.error('Turnstile error:', error)}
|
||||
options={{
|
||||
theme: 'auto',
|
||||
size: 'normal',
|
||||
execution: 'render', // or 'execute' for manual trigger
|
||||
action: 'login',
|
||||
retry: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button onClick={handleReset}>Reset Widget</button>
|
||||
<button onClick={handleRemove}>Remove Widget</button>
|
||||
<button onClick={handleExecute}>Execute Challenge</button>
|
||||
</div>
|
||||
|
||||
{token && <div>Token: {token}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Next.js App Router Example (Client Component)
|
||||
*/
|
||||
'use client'
|
||||
|
||||
export function LoginForm() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [formError, setFormError] = useState<string>()
|
||||
|
||||
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!token) {
|
||||
setFormError('Please complete the challenge')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
'cf-turnstile-response': token,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
setFormError(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect on success
|
||||
window.location.href = '/dashboard'
|
||||
} catch (err) {
|
||||
setFormError(`Login failed: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLogin}>
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
|
||||
<Turnstile
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onSuccess={setToken}
|
||||
onError={() => setFormError('Verification failed')}
|
||||
onExpire={() => setToken(undefined)}
|
||||
options={{
|
||||
theme: 'auto',
|
||||
action: 'login',
|
||||
}}
|
||||
/>
|
||||
|
||||
{formError && <div className="error">{formError}</div>}
|
||||
|
||||
<button type="submit" disabled={!token}>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Testing Example: Mock for Jest
|
||||
*
|
||||
* Add to jest.setup.ts:
|
||||
*/
|
||||
/*
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
// Auto-solve with dummy token
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [])
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment-Aware Sitekey
|
||||
*
|
||||
* Use dummy keys for development/testing
|
||||
*/
|
||||
export function useT turnstileSiteKey() {
|
||||
// Development/Test: Use dummy sitekey
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
return '1x00000000000000000000AA' // Always passes
|
||||
}
|
||||
|
||||
// Production: Use real sitekey
|
||||
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
}
|
||||
|
||||
/**
|
||||
* Example with Environment-Aware Sitekey
|
||||
*/
|
||||
export function SmartTurnstileForm() {
|
||||
const siteKey = useTurnstileSiteKey()
|
||||
const [token, setToken] = useState<string>()
|
||||
|
||||
return (
|
||||
<form>
|
||||
{/* Form fields here */}
|
||||
|
||||
<Turnstile
|
||||
siteKey={siteKey}
|
||||
onSuccess={setToken}
|
||||
onError={(error) => console.error(error)}
|
||||
/>
|
||||
|
||||
<button type="submit" disabled={!token}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Hook: useTurnstile
|
||||
*/
|
||||
export function useTurnstile() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
|
||||
const reset = () => {
|
||||
turnstileRef.current?.reset()
|
||||
setToken(undefined)
|
||||
setError(undefined)
|
||||
}
|
||||
|
||||
const TurnstileWidget = () => (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={useTurnstileSiteKey()}
|
||||
onSuccess={(token) => {
|
||||
setToken(token)
|
||||
setIsReady(true)
|
||||
setError(undefined)
|
||||
}}
|
||||
onError={(err) => {
|
||||
setError(err)
|
||||
setIsReady(false)
|
||||
}}
|
||||
onExpire={() => {
|
||||
setToken(undefined)
|
||||
setIsReady(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return {
|
||||
token,
|
||||
isReady,
|
||||
error,
|
||||
reset,
|
||||
TurnstileWidget,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage of useTurnstile Hook
|
||||
*/
|
||||
export function FormWithHook() {
|
||||
const { token, isReady, error, reset, TurnstileWidget } = useTurnstile()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
// Use token for submission
|
||||
console.log('Token:', token)
|
||||
// Reset after submission
|
||||
reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Form fields */}
|
||||
|
||||
<TurnstileWidget />
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<button type="submit" disabled={!isReady}>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
240
templates/turnstile-server-validation.ts
Normal file
240
templates/turnstile-server-validation.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Turnstile Server-Side Validation
|
||||
*
|
||||
* CRITICAL: Server-side validation is MANDATORY.
|
||||
* Client-side widget alone does NOT provide security.
|
||||
*
|
||||
* Tokens:
|
||||
* - Expire after 5 minutes (300 seconds)
|
||||
* - Are single-use only
|
||||
* - Can be forged by attackers (must validate on server)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Siteverify API Response
|
||||
*/
|
||||
export interface TurnstileResponse {
|
||||
success: boolean
|
||||
challenge_ts?: string // ISO 8601 timestamp
|
||||
hostname?: string // Hostname where challenge was solved
|
||||
'error-codes'?: string[]
|
||||
action?: string // Custom action if specified
|
||||
cdata?: string // Custom data if specified
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation Options
|
||||
*/
|
||||
export interface ValidationOptions {
|
||||
remoteip?: string
|
||||
idempotency_key?: string
|
||||
expectedAction?: string
|
||||
expectedHostname?: string
|
||||
timeout?: number // milliseconds (default: 5000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Turnstile Token
|
||||
*
|
||||
* @param token - The token from cf-turnstile-response
|
||||
* @param secretKey - Your Turnstile secret key (from environment variable)
|
||||
* @param options - Optional validation parameters
|
||||
* @returns Promise<TurnstileResponse>
|
||||
*/
|
||||
export async function validateTurnstile(
|
||||
token: string,
|
||||
secretKey: string,
|
||||
options?: ValidationOptions
|
||||
): Promise<TurnstileResponse> {
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['missing-input-response'],
|
||||
}
|
||||
}
|
||||
|
||||
if (!secretKey) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['missing-input-secret'],
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare request body
|
||||
const formData = new FormData()
|
||||
formData.append('secret', secretKey)
|
||||
formData.append('response', token)
|
||||
|
||||
if (options?.remoteip) {
|
||||
formData.append('remoteip', options.remoteip)
|
||||
}
|
||||
|
||||
if (options?.idempotency_key) {
|
||||
formData.append('idempotency_key', options.idempotency_key)
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), options?.timeout || 5000)
|
||||
|
||||
try {
|
||||
// Call Siteverify API
|
||||
const response = await fetch(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
|
||||
const result = await response.json<TurnstileResponse>()
|
||||
|
||||
// Additional validation checks
|
||||
if (result.success) {
|
||||
// Validate action if specified
|
||||
if (options?.expectedAction && result.action !== options.expectedAction) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['action-mismatch'],
|
||||
}
|
||||
}
|
||||
|
||||
// Validate hostname if specified
|
||||
if (options?.expectedHostname && result.hostname !== options.expectedHostname) {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['hostname-mismatch'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['timeout'],
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Turnstile validation error:', error)
|
||||
return {
|
||||
success: false,
|
||||
'error-codes': ['internal-error'],
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Workers Example
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response('Method not allowed', { status: 405 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const token = formData.get('cf-turnstile-response')
|
||||
|
||||
if (!token) {
|
||||
return new Response('Missing Turnstile token', { status: 400 })
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const result = await validateTurnstile(
|
||||
token.toString(),
|
||||
env.TURNSTILE_SECRET_KEY,
|
||||
{
|
||||
remoteip: request.headers.get('CF-Connecting-IP') || undefined,
|
||||
expectedHostname: new URL(request.url).hostname,
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Turnstile validation failed:', result['error-codes'])
|
||||
return new Response('Invalid Turnstile token', {
|
||||
status: 401,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Token is valid - process the form
|
||||
const email = formData.get('email')
|
||||
const message = formData.get('message')
|
||||
|
||||
// Your business logic here
|
||||
console.log('Form submitted:', { email, message })
|
||||
|
||||
return new Response('Success!', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Request handling error:', error)
|
||||
return new Response('Internal server error', { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: Validation with Retry Logic
|
||||
*/
|
||||
export async function validateWithRetry(
|
||||
token: string,
|
||||
secretKey: string,
|
||||
options?: ValidationOptions,
|
||||
maxRetries: number = 3
|
||||
): Promise<TurnstileResponse> {
|
||||
let lastError: TurnstileResponse | null = null
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const result = await validateTurnstile(token, secretKey, options)
|
||||
|
||||
if (result.success) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Don't retry on permanent errors
|
||||
const permanentErrors = [
|
||||
'missing-input-secret',
|
||||
'invalid-input-secret',
|
||||
'missing-input-response',
|
||||
'invalid-input-response',
|
||||
'action-mismatch',
|
||||
'hostname-mismatch',
|
||||
]
|
||||
|
||||
if (
|
||||
result['error-codes']?.some((code) => permanentErrors.includes(code))
|
||||
) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Retry on transient errors
|
||||
lastError = result
|
||||
if (attempt < maxRetries - 1) {
|
||||
// Exponential backoff
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)))
|
||||
}
|
||||
}
|
||||
|
||||
return lastError || { success: false, 'error-codes': ['max-retries-exceeded'] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Type Definitions for Cloudflare Workers
|
||||
*/
|
||||
export interface Env {
|
||||
TURNSTILE_SECRET_KEY: string
|
||||
TURNSTILE_SITE_KEY: string
|
||||
// Add other environment variables here
|
||||
}
|
||||
311
templates/turnstile-test-config.ts
Normal file
311
templates/turnstile-test-config.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Turnstile Testing Configuration
|
||||
*
|
||||
* Use dummy sitekeys and secret keys for automated testing
|
||||
* to avoid hitting rate limits and ensure predictable behavior.
|
||||
*
|
||||
* Official Docs: https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dummy Sitekeys (Client-Side)
|
||||
*
|
||||
* These can be used from any domain, including localhost.
|
||||
* Production secret keys will reject dummy tokens.
|
||||
*/
|
||||
export const TEST_SITEKEYS = {
|
||||
/** Always passes - visible widget */
|
||||
ALWAYS_PASS: '1x00000000000000000000AA',
|
||||
|
||||
/** Always blocks - visible widget */
|
||||
ALWAYS_BLOCK: '2x00000000000000000000AB',
|
||||
|
||||
/** Always passes - invisible widget */
|
||||
ALWAYS_PASS_INVISIBLE: '1x00000000000000000000BB',
|
||||
|
||||
/** Always blocks - invisible widget */
|
||||
ALWAYS_BLOCK_INVISIBLE: '2x00000000000000000000BB',
|
||||
|
||||
/** Forces an interactive challenge - visible widget */
|
||||
FORCE_INTERACTIVE: '3x00000000000000000000FF',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Dummy Secret Keys (Server-Side)
|
||||
*
|
||||
* These only accept the dummy token XXXX.DUMMY.TOKEN.XXXX
|
||||
* Real tokens will fail validation with these keys.
|
||||
*/
|
||||
export const TEST_SECRET_KEYS = {
|
||||
/** Always returns success: true */
|
||||
ALWAYS_PASS: '1x0000000000000000000000000000000AA',
|
||||
|
||||
/** Always returns success: false */
|
||||
ALWAYS_FAIL: '2x0000000000000000000000000000000AA',
|
||||
|
||||
/** Returns "token already spent" error */
|
||||
TOKEN_SPENT: '3x0000000000000000000000000000000AA',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Dummy Token
|
||||
*
|
||||
* Generated by test sitekeys.
|
||||
* Only valid with test secret keys.
|
||||
*/
|
||||
export const DUMMY_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
|
||||
|
||||
/**
|
||||
* Environment Detection
|
||||
*
|
||||
* Helper to determine if we're in a test environment
|
||||
*/
|
||||
export function isTestEnvironment(request: Request): boolean {
|
||||
// Check for test headers
|
||||
if (request.headers.get('x-test-environment') === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for localhost/test IPs
|
||||
const ip = request.headers.get('CF-Connecting-IP') || ''
|
||||
const testIPs = ['127.0.0.1', '::1', 'localhost']
|
||||
if (testIPs.includes(ip)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for test query parameter
|
||||
const url = new URL(request.url)
|
||||
if (url.searchParams.get('test') === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Test Credentials
|
||||
*
|
||||
* Returns test or production credentials based on environment
|
||||
*/
|
||||
export function getTurnstileCredentials(
|
||||
request: Request,
|
||||
env: {
|
||||
TURNSTILE_SITE_KEY?: string
|
||||
TURNSTILE_SECRET_KEY?: string
|
||||
}
|
||||
) {
|
||||
if (isTestEnvironment(request)) {
|
||||
return {
|
||||
sitekey: TEST_SITEKEYS.ALWAYS_PASS,
|
||||
secretKey: TEST_SECRET_KEYS.ALWAYS_PASS,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sitekey: env.TURNSTILE_SITE_KEY || '',
|
||||
secretKey: env.TURNSTILE_SECRET_KEY || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright Test Example
|
||||
*/
|
||||
/*
|
||||
// playwright.config.ts
|
||||
export default {
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
extraHTTPHeaders: {
|
||||
'x-test-environment': 'true',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// test/turnstile.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('form submission with Turnstile', async ({ page }) => {
|
||||
await page.goto('/contact')
|
||||
|
||||
// Fill form
|
||||
await page.fill('input[name="email"]', 'test@example.com')
|
||||
await page.fill('textarea[name="message"]', 'Test message')
|
||||
|
||||
// Turnstile auto-solves with dummy token in test mode
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
// Verify success
|
||||
await expect(page.locator('.success-message')).toBeVisible()
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cypress Test Example
|
||||
*/
|
||||
/*
|
||||
// cypress/e2e/turnstile.cy.ts
|
||||
describe('Turnstile Form', () => {
|
||||
beforeEach(() => {
|
||||
// Set test header
|
||||
cy.intercept('**', (req) => {
|
||||
req.headers['x-test-environment'] = 'true'
|
||||
})
|
||||
})
|
||||
|
||||
it('submits form successfully', () => {
|
||||
cy.visit('/contact')
|
||||
|
||||
cy.get('input[name="email"]').type('test@example.com')
|
||||
cy.get('textarea[name="message"]').type('Test message')
|
||||
|
||||
// Turnstile auto-solves in test mode
|
||||
cy.get('button[type="submit"]').click()
|
||||
|
||||
cy.contains('Success').should('be.visible')
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Jest Mock Example
|
||||
*/
|
||||
/*
|
||||
// jest.setup.ts
|
||||
jest.mock('@marsidev/react-turnstile', () => ({
|
||||
Turnstile: ({ onSuccess }: { onSuccess: (token: string) => void }) => {
|
||||
// Auto-solve with dummy token
|
||||
React.useEffect(() => {
|
||||
onSuccess('XXXX.DUMMY.TOKEN.XXXX')
|
||||
}, [onSuccess])
|
||||
return <div data-testid="turnstile-mock" />
|
||||
},
|
||||
}))
|
||||
|
||||
// component.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ContactForm } from './ContactForm'
|
||||
|
||||
test('submits form with Turnstile', async () => {
|
||||
render(<ContactForm />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), {
|
||||
target: { value: 'test@example.com' },
|
||||
})
|
||||
|
||||
// Turnstile auto-solves
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
|
||||
expect(await screen.findByText('Success')).toBeInTheDocument()
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Server-Side Test Example (Vitest)
|
||||
*/
|
||||
/*
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { validateTurnstile } from './turnstile-server-validation'
|
||||
import { TEST_SECRET_KEYS, DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Turnstile Validation', () => {
|
||||
it('validates dummy token with test secret', async () => {
|
||||
const result = await validateTurnstile(
|
||||
DUMMY_TOKEN,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects real token with test secret', async () => {
|
||||
const realToken = 'some-real-token-from-production'
|
||||
|
||||
const result = await validateTurnstile(
|
||||
realToken,
|
||||
TEST_SECRET_KEYS.ALWAYS_PASS
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* Environment-Aware Component (React)
|
||||
*/
|
||||
/*
|
||||
import { Turnstile } from '@marsidev/react-turnstile'
|
||||
import { TEST_SITEKEYS } from './turnstile-test-config'
|
||||
|
||||
function MyForm() {
|
||||
const sitekey = process.env.NODE_ENV === 'test'
|
||||
? TEST_SITEKEYS.ALWAYS_PASS
|
||||
: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!
|
||||
|
||||
return (
|
||||
<form>
|
||||
<Turnstile
|
||||
siteKey={sitekey}
|
||||
onSuccess={(token) => console.log('Token:', token)}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cloudflare Workers Test Example
|
||||
*/
|
||||
/*
|
||||
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import worker from '../src/index'
|
||||
import { DUMMY_TOKEN } from './turnstile-test-config'
|
||||
|
||||
describe('Worker with Turnstile', () => {
|
||||
it('validates test token', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('email', 'test@example.com')
|
||||
formData.append('cf-turnstile-response', DUMMY_TOKEN)
|
||||
|
||||
const request = new Request('http://localhost/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'x-test-environment': 'true',
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
const response = await worker.fetch(request, env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
/**
|
||||
* CI/CD Environment Variables
|
||||
*
|
||||
* Set these in your CI/CD pipeline:
|
||||
*/
|
||||
/*
|
||||
# GitHub Actions
|
||||
env:
|
||||
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY: 1x0000000000000000000000000000000AA
|
||||
|
||||
# GitLab CI
|
||||
variables:
|
||||
TURNSTILE_SITE_KEY: "1x00000000000000000000AA"
|
||||
TURNSTILE_SECRET_KEY: "1x0000000000000000000000000000000AA"
|
||||
*/
|
||||
|
||||
export default {
|
||||
TEST_SITEKEYS,
|
||||
TEST_SECRET_KEYS,
|
||||
DUMMY_TOKEN,
|
||||
isTestEnvironment,
|
||||
getTurnstileCredentials,
|
||||
}
|
||||
262
templates/turnstile-widget-explicit.ts
Normal file
262
templates/turnstile-widget-explicit.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Turnstile Widget - Explicit Rendering
|
||||
*
|
||||
* Use explicit rendering when you need programmatic control over:
|
||||
* - When the widget renders
|
||||
* - Widget lifecycle (reset, remove)
|
||||
* - Multiple widgets on the same page
|
||||
* - Dynamic UI / Single Page Applications
|
||||
*/
|
||||
|
||||
declare const turnstile: {
|
||||
render: (container: string | HTMLElement, options: TurnstileOptions) => string
|
||||
reset: (widgetId: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
execute: (widgetId: string) => void
|
||||
getResponse: (widgetId: string) => string | undefined
|
||||
isExpired: (widgetId: string) => boolean
|
||||
}
|
||||
|
||||
interface TurnstileOptions {
|
||||
sitekey: string
|
||||
callback?: (token: string) => void
|
||||
'error-callback'?: (error: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'timeout-callback'?: () => void
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'flexible' | 'compact'
|
||||
execution?: 'render' | 'execute'
|
||||
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||
retry?: 'auto' | 'never'
|
||||
'retry-interval'?: number
|
||||
action?: string
|
||||
cdata?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TurnstileManager - Lifecycle management wrapper
|
||||
*/
|
||||
export class TurnstileManager {
|
||||
private widgetId: string | null = null
|
||||
private sitekey: string
|
||||
|
||||
constructor(sitekey: string) {
|
||||
this.sitekey = sitekey
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Turnstile widget
|
||||
*/
|
||||
render(
|
||||
containerId: string,
|
||||
callbacks: {
|
||||
onSuccess: (token: string) => void
|
||||
onError: (error: string) => void
|
||||
onExpired?: () => void
|
||||
},
|
||||
options?: Partial<TurnstileOptions>
|
||||
): string {
|
||||
// Reset if already rendered
|
||||
if (this.widgetId !== null) {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
this.widgetId = turnstile.render(containerId, {
|
||||
sitekey: this.sitekey,
|
||||
callback: callbacks.onSuccess,
|
||||
'error-callback': callbacks.onError,
|
||||
'expired-callback': callbacks.onExpired || (() => this.reset()),
|
||||
theme: options?.theme || 'auto',
|
||||
size: options?.size || 'normal',
|
||||
execution: options?.execution || 'render',
|
||||
appearance: options?.appearance || 'always',
|
||||
retry: options?.retry || 'auto',
|
||||
action: options?.action,
|
||||
cdata: options?.cdata,
|
||||
})
|
||||
|
||||
return this.widgetId
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the widget (clears current state)
|
||||
*/
|
||||
reset(): void {
|
||||
if (this.widgetId !== null) {
|
||||
turnstile.reset(this.widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the widget completely
|
||||
*/
|
||||
remove(): void {
|
||||
if (this.widgetId !== null) {
|
||||
turnstile.remove(this.widgetId)
|
||||
this.widgetId = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger challenge (execution: 'execute' mode only)
|
||||
*/
|
||||
execute(): void {
|
||||
if (this.widgetId !== null) {
|
||||
turnstile.execute(this.widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token
|
||||
*/
|
||||
getToken(): string | undefined {
|
||||
if (this.widgetId === null) return undefined
|
||||
return turnstile.getResponse(this.widgetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
isExpired(): boolean {
|
||||
if (this.widgetId === null) return true
|
||||
return turnstile.isExpired(this.widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage Example
|
||||
*/
|
||||
export function initializeTurnstile() {
|
||||
const SITE_KEY = 'YOUR_SITE_KEY' // Replace with actual sitekey
|
||||
|
||||
const manager = new TurnstileManager(SITE_KEY)
|
||||
|
||||
// Render widget when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
manager.render(
|
||||
'#turnstile-container',
|
||||
{
|
||||
onSuccess: (token) => {
|
||||
console.log('Turnstile success:', token)
|
||||
// Enable submit button
|
||||
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
|
||||
if (submitBtn) submitBtn.disabled = false
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Turnstile error:', error)
|
||||
// Show error message
|
||||
const errorDiv = document.querySelector('#error-message')
|
||||
if (errorDiv) {
|
||||
errorDiv.textContent = 'Verification failed. Please try again.'
|
||||
}
|
||||
},
|
||||
onExpired: () => {
|
||||
console.warn('Turnstile token expired')
|
||||
// Disable submit button
|
||||
const submitBtn = document.querySelector('#submit-btn') as HTMLButtonElement
|
||||
if (submitBtn) submitBtn.disabled = true
|
||||
},
|
||||
},
|
||||
{
|
||||
theme: 'auto',
|
||||
size: 'normal',
|
||||
action: 'login', // Optional: track action in analytics
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Example: Reset on form submission
|
||||
const form = document.querySelector('#myForm') as HTMLFormElement
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const token = manager.getToken()
|
||||
if (!token || manager.isExpired()) {
|
||||
alert('Please complete the verification')
|
||||
return
|
||||
}
|
||||
|
||||
// Submit form with token
|
||||
const formData = new FormData(form)
|
||||
formData.append('cf-turnstile-response', token)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
alert('Success!')
|
||||
form.reset()
|
||||
manager.reset() // Reset Turnstile for next submission
|
||||
} else {
|
||||
alert('Submission failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error)
|
||||
alert('Network error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: Manual Execution Mode
|
||||
*/
|
||||
export function manualExecutionExample() {
|
||||
const manager = new TurnstileManager('YOUR_SITE_KEY')
|
||||
|
||||
// Render in manual execution mode
|
||||
manager.render(
|
||||
'#turnstile-container',
|
||||
{
|
||||
onSuccess: (token) => {
|
||||
console.log('Challenge complete:', token)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Challenge failed:', error)
|
||||
},
|
||||
},
|
||||
{
|
||||
execution: 'execute', // Manual trigger
|
||||
appearance: 'interaction-only', // Show only when needed
|
||||
}
|
||||
)
|
||||
|
||||
// Trigger challenge when user clicks submit
|
||||
document.querySelector('#submit-btn')?.addEventListener('click', () => {
|
||||
manager.execute()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced Example: Multiple Widgets
|
||||
*/
|
||||
export function multipleWidgetsExample() {
|
||||
const loginManager = new TurnstileManager('YOUR_SITE_KEY')
|
||||
const signupManager = new TurnstileManager('YOUR_SITE_KEY')
|
||||
|
||||
// Login widget
|
||||
loginManager.render(
|
||||
'#login-turnstile',
|
||||
{
|
||||
onSuccess: (token) => console.log('Login token:', token),
|
||||
onError: (error) => console.error('Login error:', error),
|
||||
},
|
||||
{
|
||||
action: 'login',
|
||||
}
|
||||
)
|
||||
|
||||
// Signup widget
|
||||
signupManager.render(
|
||||
'#signup-turnstile',
|
||||
{
|
||||
onSuccess: (token) => console.log('Signup token:', token),
|
||||
onError: (error) => console.error('Signup error:', error),
|
||||
},
|
||||
{
|
||||
action: 'signup',
|
||||
}
|
||||
)
|
||||
}
|
||||
163
templates/turnstile-widget-implicit.html
Normal file
163
templates/turnstile-widget-implicit.html
Normal file
@@ -0,0 +1,163 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Turnstile Example - Implicit Rendering</title>
|
||||
|
||||
<!-- CRITICAL: Load from Cloudflare CDN only - never proxy or cache -->
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
|
||||
<!-- Optional: CSP headers if needed -->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
script-src 'self' https://challenges.cloudflare.com;
|
||||
frame-src 'self' https://challenges.cloudflare.com;
|
||||
connect-src 'self' https://challenges.cloudflare.com;
|
||||
">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
background: #0070f3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0051cc;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.success {
|
||||
color: green;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Contact Form with Turnstile</h1>
|
||||
|
||||
<form id="contactForm" action="/api/contact" method="POST">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="5" required></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Turnstile Widget - Implicit Rendering -->
|
||||
<div class="cf-turnstile"
|
||||
data-sitekey="YOUR_SITE_KEY"
|
||||
data-callback="onTurnstileSuccess"
|
||||
data-error-callback="onTurnstileError"
|
||||
data-expired-callback="onTurnstileExpired"
|
||||
data-theme="auto"
|
||||
data-size="normal"></div>
|
||||
|
||||
<button type="submit" id="submitButton">Submit</button>
|
||||
|
||||
<div id="message"></div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
let turnstileToken = null;
|
||||
|
||||
function onTurnstileSuccess(token) {
|
||||
console.log('Turnstile success:', token);
|
||||
turnstileToken = token;
|
||||
document.getElementById('submitButton').disabled = false;
|
||||
}
|
||||
|
||||
function onTurnstileError(error) {
|
||||
console.error('Turnstile error:', error);
|
||||
showMessage('Verification failed. Please try again.', 'error');
|
||||
document.getElementById('submitButton').disabled = true;
|
||||
}
|
||||
|
||||
function onTurnstileExpired() {
|
||||
console.warn('Turnstile token expired');
|
||||
turnstileToken = null;
|
||||
document.getElementById('submitButton').disabled = true;
|
||||
showMessage('Verification expired. Please try again.', 'error');
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById('message');
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.className = type;
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('contactForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!turnstileToken) {
|
||||
showMessage('Please complete the verification.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
// Token is automatically added as 'cf-turnstile-response' by Turnstile
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showMessage('Message sent successfully!', 'success');
|
||||
e.target.reset();
|
||||
turnstileToken = null;
|
||||
// Reset Turnstile widget
|
||||
turnstile.reset();
|
||||
} else {
|
||||
const error = await response.text();
|
||||
showMessage(`Error: ${error}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(`Network error: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Disable submit button initially
|
||||
document.getElementById('submitButton').disabled = true;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
36
templates/wrangler-turnstile-config.jsonc
Normal file
36
templates/wrangler-turnstile-config.jsonc
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "my-turnstile-app",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-10-22",
|
||||
|
||||
// Public sitekey - safe to commit to version control
|
||||
// Use dummy keys for development, real keys for production
|
||||
"vars": {
|
||||
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA" // Test key - always passes
|
||||
// Production: Replace with your real sitekey from https://dash.cloudflare.com/?to=/:account/turnstile
|
||||
},
|
||||
|
||||
// Secret key - NEVER commit to version control
|
||||
// Set using: wrangler secret put TURNSTILE_SECRET_KEY
|
||||
"secrets": ["TURNSTILE_SECRET_KEY"],
|
||||
|
||||
// Optional: Environment-specific configuration
|
||||
"env": {
|
||||
"production": {
|
||||
"vars": {
|
||||
"TURNSTILE_SITE_KEY": "<YOUR_PRODUCTION_SITE_KEY>"
|
||||
}
|
||||
},
|
||||
"staging": {
|
||||
"vars": {
|
||||
"TURNSTILE_SITE_KEY": "<YOUR_STAGING_SITE_KEY>"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"vars": {
|
||||
// Use test sitekey for development (always passes)
|
||||
"TURNSTILE_SITE_KEY": "1x00000000000000000000AA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user