mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
302 lines
9.0 KiB
TypeScript
302 lines
9.0 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { chat } from '@sim/db/schema'
|
|
import { eq } from 'drizzle-orm'
|
|
import type { NextRequest } from 'next/server'
|
|
import { z } from 'zod'
|
|
import { renderOTPEmail } from '@/components/emails/render-email'
|
|
import { sendEmail } from '@/lib/email/mailer'
|
|
import { createLogger } from '@/lib/logs/console/logger'
|
|
import { getRedisClient, markMessageAsProcessed, releaseLock } from '@/lib/redis'
|
|
import { generateRequestId } from '@/lib/utils'
|
|
import { addCorsHeaders, setChatAuthCookie } from '@/app/api/chat/utils'
|
|
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
|
|
|
const logger = createLogger('ChatOtpAPI')
|
|
|
|
function generateOTP() {
|
|
return Math.floor(100000 + Math.random() * 900000).toString()
|
|
}
|
|
|
|
// OTP storage utility functions using Redis
|
|
// We use 15 minutes (900 seconds) expiry for OTPs
|
|
const OTP_EXPIRY = 15 * 60
|
|
|
|
// Store OTP in Redis
|
|
async function storeOTP(email: string, chatId: string, otp: string): Promise<void> {
|
|
const key = `otp:${email}:${chatId}`
|
|
const redis = getRedisClient()
|
|
|
|
if (redis) {
|
|
// Use Redis if available
|
|
await redis.set(key, otp, 'EX', OTP_EXPIRY)
|
|
} else {
|
|
// Use the existing function as fallback to mark that an OTP exists
|
|
await markMessageAsProcessed(key, OTP_EXPIRY)
|
|
|
|
// For the fallback case, we need to handle storing the OTP value separately
|
|
// since markMessageAsProcessed only stores "1"
|
|
const valueKey = `${key}:value`
|
|
try {
|
|
// Access the in-memory cache directly - hacky but works for fallback
|
|
const inMemoryCache = (global as any).inMemoryCache
|
|
if (inMemoryCache) {
|
|
const fullKey = `processed:${valueKey}`
|
|
const expiry = OTP_EXPIRY ? Date.now() + OTP_EXPIRY * 1000 : null
|
|
inMemoryCache.set(fullKey, { value: otp, expiry })
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error storing OTP in fallback cache:', error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get OTP from Redis
|
|
async function getOTP(email: string, chatId: string): Promise<string | null> {
|
|
const key = `otp:${email}:${chatId}`
|
|
const redis = getRedisClient()
|
|
|
|
if (redis) {
|
|
// Use Redis if available
|
|
return await redis.get(key)
|
|
}
|
|
// Use the existing function as fallback - check if it exists
|
|
const exists = await new Promise((resolve) => {
|
|
try {
|
|
// Check the in-memory cache directly - hacky but works for fallback
|
|
const inMemoryCache = (global as any).inMemoryCache
|
|
const fullKey = `processed:${key}`
|
|
const cacheEntry = inMemoryCache?.get(fullKey)
|
|
resolve(!!cacheEntry)
|
|
} catch {
|
|
resolve(false)
|
|
}
|
|
})
|
|
|
|
if (!exists) return null
|
|
|
|
// Try to get the value key
|
|
const valueKey = `${key}:value`
|
|
try {
|
|
const inMemoryCache = (global as any).inMemoryCache
|
|
const fullKey = `processed:${valueKey}`
|
|
const cacheEntry = inMemoryCache?.get(fullKey)
|
|
return cacheEntry?.value || null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Delete OTP from Redis
|
|
async function deleteOTP(email: string, chatId: string): Promise<void> {
|
|
const key = `otp:${email}:${chatId}`
|
|
const redis = getRedisClient()
|
|
|
|
if (redis) {
|
|
// Use Redis if available
|
|
await redis.del(key)
|
|
} else {
|
|
// Use the existing function as fallback
|
|
await releaseLock(`processed:${key}`)
|
|
await releaseLock(`processed:${key}:value`)
|
|
}
|
|
}
|
|
|
|
const otpRequestSchema = z.object({
|
|
email: z.string().email('Invalid email address'),
|
|
})
|
|
|
|
const otpVerifySchema = z.object({
|
|
email: z.string().email('Invalid email address'),
|
|
otp: z.string().length(6, 'OTP must be 6 digits'),
|
|
})
|
|
|
|
// Send OTP endpoint
|
|
export async function POST(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ identifier: string }> }
|
|
) {
|
|
const { identifier } = await params
|
|
const requestId = generateRequestId()
|
|
|
|
try {
|
|
logger.debug(`[${requestId}] Processing OTP request for identifier: ${identifier}`)
|
|
|
|
// Parse request body
|
|
let body
|
|
try {
|
|
body = await request.json()
|
|
const { email } = otpRequestSchema.parse(body)
|
|
|
|
// Find the chat deployment
|
|
const deploymentResult = await db
|
|
.select({
|
|
id: chat.id,
|
|
authType: chat.authType,
|
|
allowedEmails: chat.allowedEmails,
|
|
title: chat.title,
|
|
})
|
|
.from(chat)
|
|
.where(eq(chat.identifier, identifier))
|
|
.limit(1)
|
|
|
|
if (deploymentResult.length === 0) {
|
|
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
|
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
|
}
|
|
|
|
const deployment = deploymentResult[0]
|
|
|
|
// Verify this is an email-protected chat
|
|
if (deployment.authType !== 'email') {
|
|
return addCorsHeaders(
|
|
createErrorResponse('This chat does not use email authentication', 400),
|
|
request
|
|
)
|
|
}
|
|
|
|
const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
|
|
? deployment.allowedEmails
|
|
: []
|
|
|
|
const isEmailAllowed =
|
|
allowedEmails.includes(email) ||
|
|
allowedEmails.some((allowed: string) => {
|
|
if (allowed.startsWith('@')) {
|
|
const domain = email.split('@')[1]
|
|
return domain && allowed === `@${domain}`
|
|
}
|
|
return false
|
|
})
|
|
|
|
if (!isEmailAllowed) {
|
|
return addCorsHeaders(
|
|
createErrorResponse('Email not authorized for this chat', 403),
|
|
request
|
|
)
|
|
}
|
|
|
|
const otp = generateOTP()
|
|
|
|
await storeOTP(email, deployment.id, otp)
|
|
|
|
const emailHtml = await renderOTPEmail(
|
|
otp,
|
|
email,
|
|
'email-verification',
|
|
deployment.title || 'Chat'
|
|
)
|
|
|
|
const emailResult = await sendEmail({
|
|
to: email,
|
|
subject: `Verification code for ${deployment.title || 'Chat'}`,
|
|
html: emailHtml,
|
|
})
|
|
|
|
if (!emailResult.success) {
|
|
logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
|
|
return addCorsHeaders(
|
|
createErrorResponse('Failed to send verification email', 500),
|
|
request
|
|
)
|
|
}
|
|
|
|
// Add a small delay to ensure Redis has fully processed the operation
|
|
// This helps with eventual consistency in distributed systems
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
|
|
logger.info(`[${requestId}] OTP sent to ${email} for chat ${deployment.id}`)
|
|
return addCorsHeaders(createSuccessResponse({ message: 'Verification code sent' }), request)
|
|
} catch (error: any) {
|
|
if (error instanceof z.ZodError) {
|
|
return addCorsHeaders(
|
|
createErrorResponse(error.errors[0]?.message || 'Invalid request', 400),
|
|
request
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
} catch (error: any) {
|
|
logger.error(`[${requestId}] Error processing OTP request:`, error)
|
|
return addCorsHeaders(
|
|
createErrorResponse(error.message || 'Failed to process request', 500),
|
|
request
|
|
)
|
|
}
|
|
}
|
|
|
|
// Verify OTP endpoint
|
|
export async function PUT(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ identifier: string }> }
|
|
) {
|
|
const { identifier } = await params
|
|
const requestId = generateRequestId()
|
|
|
|
try {
|
|
logger.debug(`[${requestId}] Verifying OTP for identifier: ${identifier}`)
|
|
|
|
// Parse request body
|
|
let body
|
|
try {
|
|
body = await request.json()
|
|
const { email, otp } = otpVerifySchema.parse(body)
|
|
|
|
// Find the chat deployment
|
|
const deploymentResult = await db
|
|
.select({
|
|
id: chat.id,
|
|
authType: chat.authType,
|
|
})
|
|
.from(chat)
|
|
.where(eq(chat.identifier, identifier))
|
|
.limit(1)
|
|
|
|
if (deploymentResult.length === 0) {
|
|
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
|
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
|
}
|
|
|
|
const deployment = deploymentResult[0]
|
|
|
|
// Check if OTP exists and is valid
|
|
const storedOTP = await getOTP(email, deployment.id)
|
|
if (!storedOTP) {
|
|
return addCorsHeaders(
|
|
createErrorResponse('No verification code found, request a new one', 400),
|
|
request
|
|
)
|
|
}
|
|
|
|
// Check if OTP matches
|
|
if (storedOTP !== otp) {
|
|
return addCorsHeaders(createErrorResponse('Invalid verification code', 400), request)
|
|
}
|
|
|
|
// OTP is valid, clean up
|
|
await deleteOTP(email, deployment.id)
|
|
|
|
// Create success response with auth cookie
|
|
const response = addCorsHeaders(createSuccessResponse({ authenticated: true }), request)
|
|
|
|
// Set authentication cookie
|
|
setChatAuthCookie(response, deployment.id, deployment.authType)
|
|
|
|
return response
|
|
} catch (error: any) {
|
|
if (error instanceof z.ZodError) {
|
|
return addCorsHeaders(
|
|
createErrorResponse(error.errors[0]?.message || 'Invalid request', 400),
|
|
request
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
} catch (error: any) {
|
|
logger.error(`[${requestId}] Error verifying OTP:`, error)
|
|
return addCorsHeaders(
|
|
createErrorResponse(error.message || 'Failed to process request', 500),
|
|
request
|
|
)
|
|
}
|
|
}
|