feat: add captcha verification to contact form

Added math-based captcha system to prevent spam on the contact form:
- Created captcha API endpoint with simple arithmetic questions
- Added captcha UI component with refresh functionality
- Integrated captcha verification into contact form submission
- Relaxed spam filters since captcha provides better protection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-10 17:53:56 +00:00
parent 9158ffa637
commit 989f231d5a
6 changed files with 268 additions and 18 deletions

View File

@@ -19,7 +19,8 @@ import {
} from '@mui/icons-material'
import { useRouter } from 'next/navigation'
import { useTranslations, useLocale } from 'next-intl'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import RefreshIcon from '@mui/icons-material/Refresh'
export default function Contact() {
const theme = useTheme()
@@ -33,10 +34,37 @@ export default function Contact() {
subject: '',
message: ''
})
const [captcha, setCaptcha] = useState<{
id: string
question: string
answer: string
}>({ id: '', question: '', answer: '' })
const [captchaError, setCaptchaError] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const [showError, setShowError] = useState(false)
const loadCaptcha = async () => {
try {
const response = await fetch('/api/captcha')
const data = await response.json()
if (data.success) {
setCaptcha({
id: data.captchaId,
question: data.question,
answer: ''
})
setCaptchaError(false)
}
} catch (error) {
console.error('Failed to load captcha:', error)
}
}
useEffect(() => {
loadCaptcha()
}, [])
const handleInputChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
@@ -44,8 +72,23 @@ export default function Contact() {
}))
}
const handleCaptchaChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setCaptcha(prev => ({
...prev,
answer: event.target.value
}))
setCaptchaError(false)
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
// Validate captcha answer is provided
if (!captcha.answer.trim()) {
setCaptchaError(true)
return
}
setIsSubmitting(true)
try {
@@ -54,7 +97,11 @@ export default function Contact() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
body: JSON.stringify({
...formData,
captchaId: captcha.id,
captchaAnswer: captcha.answer
})
})
const data = await response.json()
@@ -67,13 +114,19 @@ export default function Contact() {
message: ''
})
setShowSuccess(true)
// Load new captcha
loadCaptcha()
} else {
console.error('Contact form error:', data.error)
setShowError(true)
// Reload captcha on error
loadCaptcha()
}
} catch (error) {
console.error('Contact form submission error:', error)
setShowError(true)
// Reload captcha on error
loadCaptcha()
} finally {
setIsSubmitting(false)
}
@@ -171,6 +224,52 @@ export default function Contact() {
variant="outlined"
/>
{/* Captcha */}
<Box sx={{
p: 3,
bgcolor: 'grey.50',
borderRadius: 2,
border: captchaError ? '2px solid' : '1px solid',
borderColor: captchaError ? 'error.main' : 'grey.300'
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Security Check
</Typography>
<Button
size="small"
startIcon={<RefreshIcon />}
onClick={loadCaptcha}
disabled={isSubmitting}
sx={{ ml: 'auto' }}
>
New Question
</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Typography variant="body1" sx={{ fontWeight: 500, fontSize: '1.1rem' }}>
What is {captcha.question}?
</Typography>
<TextField
required
type="number"
value={captcha.answer}
onChange={handleCaptchaChange}
error={captchaError}
helperText={captchaError ? 'Please answer the math question' : ''}
placeholder="Your answer"
sx={{
width: 120,
'& input': { textAlign: 'center', fontSize: '1.1rem' }
}}
inputProps={{
min: 0,
step: 1
}}
/>
</Box>
</Box>
<Box>
<Button
type="submit"

50
app/api/captcha/route.ts Normal file
View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server'
import { generateCaptcha, verifyCaptcha } from '@/lib/captcha'
export const runtime = 'nodejs'
export async function GET() {
try {
const captchaData = generateCaptcha()
return NextResponse.json({
success: true,
captchaId: captchaData.captchaId,
question: captchaData.question
})
} catch (error) {
console.error('Captcha generation error:', error)
return NextResponse.json({
success: false,
error: 'Failed to generate captcha'
}, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const { captchaId, answer } = await request.json()
if (!captchaId || answer === undefined) {
return NextResponse.json({
success: false,
error: 'Missing captcha ID or answer'
}, { status: 400 })
}
const isValid = verifyCaptcha(captchaId, answer)
return NextResponse.json({
success: true,
valid: isValid
})
} catch (error) {
console.error('Captcha verification error:', error)
return NextResponse.json({
success: false,
error: 'Failed to verify captcha'
}, { status: 500 })
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { smtpService } from '@/lib/smtp'
import { verifyCaptcha } from '@/lib/captcha'
import { z } from 'zod'
export const runtime = 'nodejs'
@@ -8,7 +9,9 @@ const contactSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
subject: z.string().min(1, 'Subject is required').max(200),
message: z.string().min(10, 'Message must be at least 10 characters').max(5000)
message: z.string().min(10, 'Message must be at least 10 characters').max(5000),
captchaId: z.string().min(1, 'Captcha ID is required'),
captchaAnswer: z.string().min(1, 'Captcha answer is required')
})
export async function POST(request: NextRequest) {
@@ -25,21 +28,26 @@ export async function POST(request: NextRequest) {
}, { status: 400 })
}
const { name, email, subject, message } = validationResult.data
const { name, email, subject, message, captchaId, captchaAnswer } = validationResult.data
// Basic spam prevention - check for common spam indicators
const spamIndicators = [
message.includes('http://'),
message.includes('https://'),
message.includes('www.'),
message.includes('bitcoin'),
message.includes('cryptocurrency'),
message.length < 10,
name.length < 2
]
// Verify captcha
const isValidCaptcha = verifyCaptcha(captchaId, captchaAnswer)
const spamScore = spamIndicators.filter(Boolean).length
if (spamScore >= 2) {
if (!isValidCaptcha) {
return NextResponse.json({
success: false,
error: 'Invalid captcha answer. Please try again.'
}, { status: 400 })
}
// Basic spam prevention - only check for obvious spam
// Allow URLs in messages since users may want to share links
const isSpam = (
(message.includes('bitcoin') || message.includes('cryptocurrency')) &&
(message.includes('http://') || message.includes('https://'))
)
if (isSpam) {
return NextResponse.json({
success: false,
error: 'Message flagged as potential spam'