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:
@@ -19,7 +19,8 @@ import {
|
|||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useTranslations, useLocale } from 'next-intl'
|
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() {
|
export default function Contact() {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -33,10 +34,37 @@ export default function Contact() {
|
|||||||
subject: '',
|
subject: '',
|
||||||
message: ''
|
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 [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [showSuccess, setShowSuccess] = useState(false)
|
const [showSuccess, setShowSuccess] = useState(false)
|
||||||
const [showError, setShowError] = 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>) => {
|
const handleInputChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...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) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
|
// Validate captcha answer is provided
|
||||||
|
if (!captcha.answer.trim()) {
|
||||||
|
setCaptchaError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -54,7 +97,11 @@ export default function Contact() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(formData)
|
body: JSON.stringify({
|
||||||
|
...formData,
|
||||||
|
captchaId: captcha.id,
|
||||||
|
captchaAnswer: captcha.answer
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
@@ -67,13 +114,19 @@ export default function Contact() {
|
|||||||
message: ''
|
message: ''
|
||||||
})
|
})
|
||||||
setShowSuccess(true)
|
setShowSuccess(true)
|
||||||
|
// Load new captcha
|
||||||
|
loadCaptcha()
|
||||||
} else {
|
} else {
|
||||||
console.error('Contact form error:', data.error)
|
console.error('Contact form error:', data.error)
|
||||||
setShowError(true)
|
setShowError(true)
|
||||||
|
// Reload captcha on error
|
||||||
|
loadCaptcha()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Contact form submission error:', error)
|
console.error('Contact form submission error:', error)
|
||||||
setShowError(true)
|
setShowError(true)
|
||||||
|
// Reload captcha on error
|
||||||
|
loadCaptcha()
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -171,6 +224,52 @@ export default function Contact() {
|
|||||||
variant="outlined"
|
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>
|
<Box>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
50
app/api/captcha/route.ts
Normal file
50
app/api/captcha/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { smtpService } from '@/lib/smtp'
|
import { smtpService } from '@/lib/smtp'
|
||||||
|
import { verifyCaptcha } from '@/lib/captcha'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const runtime = 'nodejs'
|
export const runtime = 'nodejs'
|
||||||
@@ -8,7 +9,9 @@ const contactSchema = z.object({
|
|||||||
name: z.string().min(1, 'Name is required').max(100),
|
name: z.string().min(1, 'Name is required').max(100),
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
subject: z.string().min(1, 'Subject is required').max(200),
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -25,21 +28,26 @@ export async function POST(request: NextRequest) {
|
|||||||
}, { status: 400 })
|
}, { 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
|
// Verify captcha
|
||||||
const spamIndicators = [
|
const isValidCaptcha = verifyCaptcha(captchaId, captchaAnswer)
|
||||||
message.includes('http://'),
|
|
||||||
message.includes('https://'),
|
|
||||||
message.includes('www.'),
|
|
||||||
message.includes('bitcoin'),
|
|
||||||
message.includes('cryptocurrency'),
|
|
||||||
message.length < 10,
|
|
||||||
name.length < 2
|
|
||||||
]
|
|
||||||
|
|
||||||
const spamScore = spamIndicators.filter(Boolean).length
|
if (!isValidCaptcha) {
|
||||||
if (spamScore >= 2) {
|
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({
|
return NextResponse.json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Message flagged as potential spam'
|
error: 'Message flagged as potential spam'
|
||||||
|
|||||||
93
lib/captcha.ts
Normal file
93
lib/captcha.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { randomInt } from 'crypto'
|
||||||
|
|
||||||
|
interface CaptchaChallenge {
|
||||||
|
answer: number
|
||||||
|
expires: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple in-memory store for captcha challenges
|
||||||
|
const captchaStore = new Map<string, CaptchaChallenge>()
|
||||||
|
|
||||||
|
// Clean up expired captchas every 5 minutes
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, value] of captchaStore.entries()) {
|
||||||
|
if (value.expires < now) {
|
||||||
|
captchaStore.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
|
|
||||||
|
function generateCaptchaId(): string {
|
||||||
|
return `captcha_${Date.now()}_${randomInt(10000, 99999)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptchaData {
|
||||||
|
captchaId: string
|
||||||
|
question: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCaptcha(): CaptchaData {
|
||||||
|
// Generate simple math problem
|
||||||
|
const num1 = randomInt(1, 20)
|
||||||
|
const num2 = randomInt(1, 20)
|
||||||
|
const operations = ['+', '-', '×'] as const
|
||||||
|
const operation = operations[randomInt(0, operations.length)]
|
||||||
|
|
||||||
|
let answer: number
|
||||||
|
let question: string
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case '+':
|
||||||
|
answer = num1 + num2
|
||||||
|
question = `${num1} + ${num2}`
|
||||||
|
break
|
||||||
|
case '-':
|
||||||
|
// Ensure positive result
|
||||||
|
const larger = Math.max(num1, num2)
|
||||||
|
const smaller = Math.min(num1, num2)
|
||||||
|
answer = larger - smaller
|
||||||
|
question = `${larger} - ${smaller}`
|
||||||
|
break
|
||||||
|
case '×':
|
||||||
|
// Use smaller numbers for multiplication
|
||||||
|
const small1 = randomInt(2, 10)
|
||||||
|
const small2 = randomInt(2, 10)
|
||||||
|
answer = small1 * small2
|
||||||
|
question = `${small1} × ${small2}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const captchaId = generateCaptchaId()
|
||||||
|
|
||||||
|
// Store captcha with 10 minute expiration
|
||||||
|
captchaStore.set(captchaId, {
|
||||||
|
answer,
|
||||||
|
expires: Date.now() + 10 * 60 * 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
captchaId,
|
||||||
|
question
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyCaptcha(captchaId: string, answer: string | number): boolean {
|
||||||
|
const stored = captchaStore.get(captchaId)
|
||||||
|
|
||||||
|
if (!stored) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored.expires < Date.now()) {
|
||||||
|
captchaStore.delete(captchaId)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = parseInt(answer.toString()) === stored.answer
|
||||||
|
|
||||||
|
// Delete captcha after verification (one-time use)
|
||||||
|
captchaStore.delete(captchaId)
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
"answers": {
|
"answers": {
|
||||||
"accurate": "Yes, our AI is trained on verified theological sources and reviewed by seminary professors and pastors to ensure Biblical accuracy.",
|
"accurate": "Yes, our AI is trained on verified theological sources and reviewed by seminary professors and pastors to ensure Biblical accuracy.",
|
||||||
"free": "Core features including Bible reading, AI chat, and basic search are completely free. Premium features are available for advanced users.",
|
"free": "Core features including Bible reading, AI chat, and basic search are completely free. Premium features are available for advanced users.",
|
||||||
"languages": "We support 25+ languages including English, Spanish, Portuguese, French, German, and many more with native speaker quality.",
|
"languages": "We support 8 languages including English, Spanish, Portuguese, French, German, and many more with native speaker quality.",
|
||||||
"offline": "Basic Bible reading is available offline. AI features and search require an internet connection for the best experience.",
|
"offline": "Basic Bible reading is available offline. AI features and search require an internet connection for the best experience.",
|
||||||
"privacy": "Your spiritual journey stays between you and God. We use industry-standard encryption and never share personal data.",
|
"privacy": "Your spiritual journey stays between you and God. We use industry-standard encryption and never share personal data.",
|
||||||
"versions": "We offer multiple Bible versions including NIV, ESV, NASB, King James, and translations in many languages."
|
"versions": "We offer multiple Bible versions including NIV, ESV, NASB, King James, and translations in many languages."
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
"answers": {
|
"answers": {
|
||||||
"accurate": "Da, AI-ul nostru este antrenat pe surse teologice verificate și revizuit de profesori de seminar și pastori pentru a asigura acuratețea biblică.",
|
"accurate": "Da, AI-ul nostru este antrenat pe surse teologice verificate și revizuit de profesori de seminar și pastori pentru a asigura acuratețea biblică.",
|
||||||
"free": "Funcțiile de bază incluzând citirea Bibliei, chat AI și căutarea de bază sunt complet gratuite. Funcții premium sunt disponibile pentru utilizatori avansați.",
|
"free": "Funcțiile de bază incluzând citirea Bibliei, chat AI și căutarea de bază sunt complet gratuite. Funcții premium sunt disponibile pentru utilizatori avansați.",
|
||||||
"languages": "Suportăm 25+ limbi incluzând română, engleză, spaniolă, portugheză, franceză, germană și multe altele cu calitate de vorbitor nativ.",
|
"languages": "Suportăm 8 limbi incluzând română, engleză, spaniolă, portugheză, franceză, germană și multe altele cu calitate de vorbitor nativ.",
|
||||||
"offline": "Citirea de bază a Bibliei este disponibilă offline. Funcțiile AI și căutarea necesită conexiune la internet pentru cea mai bună experiență.",
|
"offline": "Citirea de bază a Bibliei este disponibilă offline. Funcțiile AI și căutarea necesită conexiune la internet pentru cea mai bună experiență.",
|
||||||
"privacy": "Călătoria ta spirituală rămâne între tine și Dumnezeu. Folosim criptare standard în industrie și nu partajăm niciodată date personale.",
|
"privacy": "Călătoria ta spirituală rămâne între tine și Dumnezeu. Folosim criptare standard în industrie și nu partajăm niciodată date personale.",
|
||||||
"versions": "Oferim versiuni multiple ale Bibliei incluzând Cornilescu, Fidela, și traduceri în multe limbi."
|
"versions": "Oferim versiuni multiple ale Bibliei incluzând Cornilescu, Fidela, și traduceri în multe limbi."
|
||||||
|
|||||||
Reference in New Issue
Block a user