From 7308ab6721c199dc90a77a86ed8f3c25f261c51a Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 7 Dec 2025 20:11:09 +0300 Subject: [PATCH 1/2] chore(web): remove bot verification from form titles --- web/app/(app)/(auth)/(components)/login-form.tsx | 1 - web/app/(app)/(auth)/(components)/register-form.tsx | 1 - .../(app)/(auth)/(components)/request-password-reset-form.tsx | 1 - 3 files changed, 3 deletions(-) diff --git a/web/app/(app)/(auth)/(components)/login-form.tsx b/web/app/(app)/(auth)/(components)/login-form.tsx index d6d071c..2b13832 100644 --- a/web/app/(app)/(auth)/(components)/login-form.tsx +++ b/web/app/(app)/(auth)/(components)/login-form.tsx @@ -145,7 +145,6 @@ export default function LoginForm() { name='turnstileToken' render={() => ( - Bot verification
( - Bot verification
( - Bot verification
Date: Sun, 7 Dec 2025 20:44:56 +0300 Subject: [PATCH 2/2] chore: add cloudflare turnstile protection to support and account deletion forms --- .../support/dto/create-support-message.dto.ts | 4 ++ api/src/support/support.controller.ts | 13 ++++- api/src/support/support.module.ts | 4 ++ api/src/support/support.service.ts | 48 ++++++++++----- web/.env.example | 3 +- .../(components)/delete-account-form.tsx | 38 +++++++++++- .../dashboard/(components)/support-form.tsx | 58 ++++++++++++++++++- 7 files changed, 147 insertions(+), 21 deletions(-) diff --git a/api/src/support/dto/create-support-message.dto.ts b/api/src/support/dto/create-support-message.dto.ts index c1dbbba..cb33d8b 100644 --- a/api/src/support/dto/create-support-message.dto.ts +++ b/api/src/support/dto/create-support-message.dto.ts @@ -39,6 +39,10 @@ export class CreateSupportMessageDto { @IsString() message: string + @IsNotEmpty() + @IsString() + turnstileToken: string + @IsOptional() @IsString() ip?: string diff --git a/api/src/support/support.controller.ts b/api/src/support/support.controller.ts index 5a3fd9c..08b585f 100644 --- a/api/src/support/support.controller.ts +++ b/api/src/support/support.controller.ts @@ -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) diff --git a/api/src/support/support.module.ts b/api/src/support/support.module.ts index cc47894..1d0fbf5 100644 --- a/api/src/support/support.module.ts +++ b/api/src/support/support.module.ts @@ -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], diff --git a/api/src/support/support.service.ts b/api/src/support/support.service.ts index 39ba366..2dfeb69 100644 --- a/api/src/support/support.service.ts +++ b/api/src/support/support.service.ts @@ -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(), diff --git a/web/.env.example b/web/.env.example index 70a554c..2604758 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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 diff --git a/web/app/(app)/dashboard/(components)/delete-account-form.tsx b/web/app/(app)/dashboard/(components)/delete-account-form.tsx index 6475fa3..04467bc 100644 --- a/web/app/(app)/dashboard/(components)/delete-account-form.tsx +++ b/web/app/(app)/dashboard/(components)/delete-account-form.tsx @@ -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(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)} /> +
+
+ {turnstileError && ( +

{turnstileError}

+ )} +
+ {requestAccountDeletionError && (

{(requestAccountDeletionError as any).response?.data diff --git a/web/app/(app)/dashboard/(components)/support-form.tsx b/web/app/(app)/dashboard/(components)/support-form.tsx index a5d34fc..d899d74 100644 --- a/web/app/(app)/dashboard/(components)/support-form.tsx +++ b/web/app/(app)/dashboard/(components)/support-form.tsx @@ -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() { )} /> + ( + + +

+ + + + )} + /> {isSubmitSuccessful && (
We have received your message, we will