d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
196 lines
7.0 KiB
TypeScript
196 lines
7.0 KiB
TypeScript
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 })
|
|
}
|
|
}
|
|
)
|