mirror of
https://github.com/vernu/textbee.git
synced 2026-05-18 21:35:12 -04:00
chore: add cloudflare turnstile protection to support and account deletion forms
This commit is contained in:
@@ -39,6 +39,10 @@ export class CreateSupportMessageDto {
|
||||
@IsString()
|
||||
message: string
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
turnstileToken: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ip?: string
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user