chore: add cloudflare turnstile protection to support and account deletion forms

This commit is contained in:
isra el
2025-12-07 20:44:56 +03:00
parent 7308ab6721
commit 30691e2e0d
7 changed files with 147 additions and 21 deletions

View File

@@ -39,6 +39,10 @@ export class CreateSupportMessageDto {
@IsString()
message: string
@IsNotEmpty()
@IsString()
turnstileToken: string
@IsOptional()
@IsString()
ip?: string

View File

@@ -7,10 +7,14 @@ import { SupportService } from './support.service'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Request } from 'express'
import { OptionalAuthGuard } from '../auth/guards/optional-auth.guard'
import { TurnstileService } from '../common/turnstile.service'
@Controller('support')
export class SupportController {
constructor(private readonly supportService: SupportService) {}
constructor(
private readonly supportService: SupportService,
private readonly turnstileService: TurnstileService,
) {}
@UseGuards(OptionalAuthGuard)
@Post('customer-support')
@@ -18,6 +22,8 @@ export class SupportController {
@Body() createSupportMessageDto: CreateSupportMessageDto,
@Req() req: Request,
) {
await this.turnstileService.verify(createSupportMessageDto.turnstileToken)
const ip = req.ip || (req.headers['x-forwarded-for'] as string)
const userAgent = req.headers['user-agent'] as string
@@ -36,9 +42,11 @@ export class SupportController {
@UseGuards(JwtAuthGuard)
@Post('request-account-deletion')
async requestAccountDeletion(
@Body() body: { message: string },
@Body() body: { message: string; turnstileToken: string },
@Req() req: Request,
) {
await this.turnstileService.verify(body.turnstileToken)
const ip = req.ip || (req.headers['x-forwarded-for'] as string)
const userAgent = req.headers['user-agent'] as string
const user = req.user
@@ -51,6 +59,7 @@ export class SupportController {
message: body.message,
ip,
userAgent,
turnstileToken: body.turnstileToken,
}
return this.supportService.requestAccountDeletion(createSupportMessageDto)

View File

@@ -10,15 +10,19 @@ import { UsersModule } from 'src/users/users.module'
import { BillingModule } from 'src/billing/billing.module'
import { MailModule } from 'src/mail/mail.module'
import { AuthModule } from 'src/auth/auth.module'
import { Subscription, SubscriptionSchema } from 'src/billing/schemas/subscription.schema'
import { CommonModule } from '../common/common.module'
@Module({
imports: [
MongooseModule.forFeature([
{ name: SupportMessage.name, schema: SupportMessageSchema },
{ name: Subscription.name, schema: SubscriptionSchema },
]),
UsersModule,
BillingModule,
MailModule,
AuthModule,
CommonModule,
],
controllers: [SupportController],
providers: [SupportService],

View File

@@ -2,6 +2,8 @@ import {
Injectable,
ConflictException,
NotFoundException,
HttpException,
HttpStatus,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { isValidObjectId, Model, Types } from 'mongoose'
@@ -28,20 +30,33 @@ export class SupportService {
async createSupportMessage(
createSupportMessageDto: CreateSupportMessageDto,
): Promise<{ message: string }> {
const { turnstileToken, ...sanitizedDto } = createSupportMessageDto
try {
// Check rate limit: max 3 requests per 24 hours
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const recentRequestsCount = await this.supportMessageModel.countDocuments({
email: sanitizedDto.email,
createdAt: { $gte: twentyFourHoursAgo },
})
if (recentRequestsCount >= 3) {
throw new HttpException(
'Too many support requests. Please try again later.',
HttpStatus.TOO_MANY_REQUESTS,
)
}
// Create and save the support message
const createdMessage = new this.supportMessageModel(
createSupportMessageDto,
)
const createdMessage = new this.supportMessageModel(sanitizedDto)
const savedMessage = await createdMessage.save()
// Determine if the user is registered
let user = null
if (
createSupportMessageDto.user &&
isValidObjectId(createSupportMessageDto.user)
sanitizedDto.user &&
isValidObjectId(sanitizedDto.user)
) {
user = await this.userModel.findById(createSupportMessageDto.user)
user = await this.userModel.findById(sanitizedDto.user)
}
// Send confirmation email to user
@@ -53,10 +68,10 @@ export class SupportService {
template: 'customer-support-confirmation',
context: {
name: createSupportMessageDto.name,
email: createSupportMessageDto.email,
phone: createSupportMessageDto.phone || 'Not provided',
category: createSupportMessageDto.category,
message: createSupportMessageDto.message,
email: sanitizedDto.email,
phone: sanitizedDto.phone || 'Not provided',
category: sanitizedDto.category,
message: sanitizedDto.message,
appLogoUrl:
process.env.APP_LOGO_URL || 'https://textbee.dev/logo.png',
currentYear: new Date().getFullYear(),
@@ -74,16 +89,17 @@ export class SupportService {
async requestAccountDeletion(
createSupportMessageDto: CreateSupportMessageDto,
): Promise<{ message: string }> {
const { turnstileToken, ...sanitizedDto } = createSupportMessageDto
try {
// Check if user exists
if (
!createSupportMessageDto.user ||
!isValidObjectId(createSupportMessageDto.user)
!sanitizedDto.user ||
!isValidObjectId(sanitizedDto.user)
) {
throw new NotFoundException('User not found')
}
const userId = new Types.ObjectId(createSupportMessageDto.user.toString())
const userId = new Types.ObjectId(sanitizedDto.user.toString())
const user = await this.userModel.findById(userId)
if (!user) {
@@ -104,7 +120,7 @@ export class SupportService {
// Create and save the support message
const createdMessage = new this.supportMessageModel(
createSupportMessageDto,
sanitizedDto,
)
const savedMessage = await createdMessage.save()
@@ -113,7 +129,7 @@ export class SupportService {
{ _id: userId },
{
accountDeletionRequestedAt: new Date(),
accountDeletionReason: createSupportMessageDto.message || null,
accountDeletionReason: sanitizedDto.message || null,
},
)
@@ -127,7 +143,7 @@ export class SupportService {
context: {
name: user.name,
email: user.email,
message: createSupportMessageDto.message || 'No reason provided',
message: sanitizedDto.message || 'No reason provided',
appLogoUrl:
process.env.APP_LOGO_URL || 'https://textbee.dev/logo.png',
currentYear: new Date().getFullYear(),

View File

@@ -13,4 +13,5 @@ MAIL_USER=
MAIL_PASS=
MAIL_FROM=
ADMIN_EMAIL=NEXT_PUBLIC_TURNSTILE_SITE_KEY=
ADMIN_EMAIL=
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA #test

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -18,11 +18,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useTurnstile } from '@/lib/turnstile'
export default function DeleteAccountForm() {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [deleteConfirmEmail, setDeleteConfirmEmail] = useState('')
const [deleteReason, setDeleteReason] = useState('')
const [turnstileError, setTurnstileError] = useState<string | null>(null)
const { toast } = useToast()
const { data: currentUser } = useQuery({
@@ -33,6 +35,26 @@ export default function DeleteAccountForm() {
.then((res) => res.data?.data),
})
const {
containerRef: turnstileRef,
token: turnstileToken,
error: turnstileHookError,
} = useTurnstile({
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
})
useEffect(() => {
if (turnstileToken) {
setTurnstileError(null)
}
}, [turnstileToken])
useEffect(() => {
if (turnstileHookError) {
setTurnstileError(turnstileHookError)
}
}, [turnstileHookError])
const handleDeleteAccount = () => {
if (deleteConfirmEmail !== currentUser?.email) {
toast({
@@ -44,6 +66,9 @@ export default function DeleteAccountForm() {
title: 'Please enter a reason for deletion',
})
return
} else if (!turnstileToken) {
setTurnstileError('Please complete the bot verification')
return
}
requestAccountDeletion()
}
@@ -57,6 +82,7 @@ export default function DeleteAccountForm() {
mutationFn: () =>
httpBrowserClient.post(ApiEndpoints.support.requestAccountDeletion(), {
message: deleteReason,
turnstileToken,
}),
onSuccess: () => {
toast({
@@ -122,6 +148,16 @@ export default function DeleteAccountForm() {
onChange={(e) => setDeleteConfirmEmail(e.target.value)}
/>
<div className='mt-4 space-y-2'>
<div
ref={turnstileRef}
className='min-h-[65px] w-full flex justify-center'
/>
{turnstileError && (
<p className='text-sm text-destructive'>{turnstileError}</p>
)}
</div>
{requestAccountDeletionError && (
<p className='text-sm text-destructive'>
{(requestAccountDeletionError as any).response?.data

View File

@@ -19,7 +19,7 @@ import {
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { AlertTriangle, Check, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -28,6 +28,7 @@ import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useTurnstile } from '@/lib/turnstile'
const SupportFormSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
@@ -37,6 +38,9 @@ const SupportFormSchema = z.object({
message: 'Support category is required',
}),
message: z.string().min(1, { message: 'Message is required' }),
turnstileToken: z
.string()
.min(1, { message: 'Please complete the bot verification' }),
})
export default function SupportForm() {
@@ -55,13 +59,49 @@ export default function SupportForm() {
phone: session?.user?.phone || '',
category: 'general',
message: '',
turnstileToken: '',
},
})
const {
containerRef: turnstileRef,
token: turnstileToken,
error: turnstileError,
} = useTurnstile({
siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
onToken: (token) =>
form.setValue('turnstileToken', token, { shouldValidate: true }),
onError: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
onExpire: (message) =>
form.setError('turnstileToken', { type: 'manual', message }),
})
useEffect(() => {
if (turnstileToken) {
form.clearErrors('turnstileToken')
}
}, [turnstileToken, form])
useEffect(() => {
if (turnstileError) {
form.setError('turnstileToken', { type: 'manual', message: turnstileError })
}
}, [turnstileError, form])
const onSubmit = async (data: any) => {
setIsSubmitting(true)
setErrorMessage(null)
if (!data.turnstileToken) {
form.setError('turnstileToken', {
type: 'manual',
message: 'Please complete the bot verification',
})
setIsSubmitting(false)
return
}
try {
// Use the existing httpBrowserClient to call the NestJS endpoint
const response = await httpBrowserClient.post(
@@ -185,6 +225,22 @@ export default function SupportForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name='turnstileToken'
disabled={isSubmitting}
render={() => (
<FormItem>
<FormControl>
<div
ref={turnstileRef}
className='min-h-[65px] w-full flex justify-center'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isSubmitSuccessful && (
<div className='flex items-center gap-2 text-green-500'>
<Check className='h-4 w-4' /> We have received your message, we will