import { createLogger } from '@sim/logger' import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { renderOTPEmail } from '@/components/emails' import { requestPublicFileOtpContract, verifyPublicFileOtpContract, } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' import { isEmailAllowed, setDeploymentAuthCookie } from '@/lib/core/security/deployment' import { decodeOTPValue, deleteOTP, generateOTP, getOTP, incrementOTPAttempts, MAX_OTP_ATTEMPTS, OTP_EMAIL_RATE_LIMIT, OTP_IP_RATE_LIMIT, storeOTP, } from '@/lib/core/security/otp' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' export const dynamic = 'force-dynamic' const logger = createLogger('PublicFileOtpAPI') const rateLimiter = new RateLimiter() const SHARE_EMAIL_LABEL = 'a shared file' /** Allow-list for an email-gated share, read off the resolved row. */ function shareAllowedEmails(allowedEmails: unknown): string[] { return Array.isArray(allowedEmails) ? (allowedEmails as string[]) : [] } function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { const response = NextResponse.json( { error: 'Too many requests. Please try again later.' }, { status: 429 } ) response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) return response } /** * POST /api/files/public/[token]/otp * Sends a 6-digit verification code to an allow-listed email for an email-gated share. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { const requestId = generateRequestId() try { const ip = getClientIp(request) const ipRateLimit = await rateLimiter.checkRateLimitDirect( `file-otp:ip:${ip}`, OTP_IP_RATE_LIMIT ) if (!ipRateLimit.allowed) { logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`) return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs) } const parsed = await parseRequest(requestPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response const { token } = parsed.data.params // Normalize once so allow-list matching, OTP storage, and the verify lookup // all key off the same value (allow-list entries are stored lowercase). const email = normalizeEmail(parsed.data.body.email) const resolved = await resolveActiveShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } if (resolved.share.authType !== 'email') { return NextResponse.json( { error: 'This file does not use email authentication' }, { status: 400 } ) } if (!isEmailAllowed(email, shareAllowedEmails(resolved.share.allowedEmails))) { return NextResponse.json({ error: 'Email not authorized for this file' }, { status: 403 }) } const emailRateLimit = await rateLimiter.checkRateLimitDirect( `file-otp:email:${resolved.share.id}:${email}`, OTP_EMAIL_RATE_LIMIT ) if (!emailRateLimit.allowed) { logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`) return rateLimited(emailRateLimit.retryAfterMs, OTP_EMAIL_RATE_LIMIT.refillIntervalMs) } const otp = generateOTP() await storeOTP('file', resolved.share.id, email, otp) const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL) const emailResult = await sendEmail({ to: email, subject: `Verification code for ${SHARE_EMAIL_LABEL}`, html: emailHtml, }) if (!emailResult.success) { logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) return NextResponse.json({ error: 'Failed to send verification email' }, { status: 500 }) } logger.info(`[${requestId}] OTP sent for share ${resolved.share.id}`) return NextResponse.json({ message: 'Verification code sent' }) } catch (error) { logger.error(`[${requestId}] Error processing OTP request:`, error) return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) } } ) /** * PUT /api/files/public/[token]/otp * Verifies the code and, on success, sets the `file_auth_{shareId}` cookie. */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { const requestId = generateRequestId() try { const parsed = await parseRequest(verifyPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response const { token } = parsed.data.params const { otp } = parsed.data.body const email = normalizeEmail(parsed.data.body.email) const resolved = await resolveActiveShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } if (resolved.share.authType !== 'email') { return NextResponse.json( { error: 'This file does not use email authentication' }, { status: 400 } ) } const storedValue = await getOTP('file', resolved.share.id, email) if (!storedValue) { return NextResponse.json( { error: 'No verification code found, request a new one' }, { status: 400 } ) } const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) if (attempts >= MAX_OTP_ATTEMPTS) { await deleteOTP('file', resolved.share.id, email) return NextResponse.json( { error: 'Too many failed attempts. Please request a new code.' }, { status: 429 } ) } if (storedOTP !== otp) { const result = await incrementOTPAttempts('file', resolved.share.id, email, storedValue) if (result === 'locked') { return NextResponse.json( { error: 'Too many failed attempts. Please request a new code.' }, { status: 429 } ) } return NextResponse.json({ error: 'Invalid verification code' }, { status: 400 }) } await deleteOTP('file', resolved.share.id, email) const response = NextResponse.json({ authType: resolved.share.authType }) setDeploymentAuthCookie( response, 'file', resolved.share.id, resolved.share.authType, resolved.share.password ) logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`) return response } catch (error) { logger.error(`[${requestId}] Error verifying OTP:`, error) return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) } } )