import type { APIRoute } from 'astro'; import { logger, logApiRequest } from '../../utils/logger'; import { captureException } from '../../utils/sentry'; // ============================================================ // In-memory rate limiter (sliding window, per IP) // ============================================================ interface RateLimitEntry { timestamps: number[]; } const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour const RATE_LIMIT_MAX_REQUESTS = 5; // max 5 contact submissions per hour per IP function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetAt: number } { const now = Date.now(); const windowStart = now - RATE_LIMIT_WINDOW_MS; const entry = rateLimitStore.get(ip) ?? { timestamps: [] }; // Remove timestamps outside the window entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart); const remaining = RATE_LIMIT_MAX_REQUESTS - entry.timestamps.length; const resetAt = entry.timestamps.length > 0 ? entry.timestamps[0] + RATE_LIMIT_WINDOW_MS : now + RATE_LIMIT_WINDOW_MS; if (entry.timestamps.length >= RATE_LIMIT_MAX_REQUESTS) { rateLimitStore.set(ip, entry); return { allowed: false, remaining: 0, resetAt }; } entry.timestamps.push(now); rateLimitStore.set(ip, entry); return { allowed: true, remaining: remaining - 1, resetAt }; } // ============================================================ // Input validation // ============================================================ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const PHONE_REGEX = /^[\+]?[(]?[0-9]{1,3}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,4}[-\s\.]?[0-9]{1,9}$/; const VALID_SUBJECTS = [ 'web-development', 'mobile-development', 'cloud-services', 'ai-ml', 'consulting', 'support', 'other', ]; interface ContactFormData { name: string; email: string; phone?: string; subject: string; message: string; website?: string; // honeypot } function validateContactForm(data: ContactFormData): string | null { if (!data.name || data.name.trim().length < 2) { return 'Name must be at least 2 characters.'; } if (data.name.trim().length > 100) { return 'Name must be under 100 characters.'; } if (!data.email || !EMAIL_REGEX.test(data.email.trim())) { return 'A valid email address is required.'; } if (data.email.trim().length > 254) { return 'Email address is too long.'; } if (data.phone && data.phone.trim() !== '' && !PHONE_REGEX.test(data.phone.trim())) { return 'Phone number format is invalid.'; } if (!data.subject || !VALID_SUBJECTS.includes(data.subject)) { return 'Please select a valid subject.'; } if (!data.message || data.message.trim().length < 10) { return 'Message must be at least 10 characters.'; } if (data.message.trim().length > 5000) { return 'Message must be under 5000 characters.'; } return null; } // ============================================================ // Email sending via SMTP (nodemailer) or no-op in development // ============================================================ async function sendContactEmail(data: ContactFormData): Promise { const smtpHost = import.meta.env.SMTP_HOST; const smtpUser = import.meta.env.SMTP_USER; const smtpPass = import.meta.env.SMTP_PASS; const smtpPort = parseInt(import.meta.env.SMTP_PORT ?? '587', 10); const recipientEmail = import.meta.env.CONTACT_EMAIL ?? import.meta.env.SMTP_USER; if (!smtpHost || !smtpUser || !smtpPass || !recipientEmail) { // SMTP not configured - log and continue (graceful degradation) logger.info('api.contact', 'SMTP not configured — form submission logged only', { email: data.email, subject: data.subject, name: data.name, }); return; } // Dynamic import to avoid bundling issues when nodemailer isn't installed try { const nodemailer = await import('nodemailer'); const transporter = nodemailer.default.createTransport({ host: smtpHost, port: smtpPort, secure: smtpPort === 465, auth: { user: smtpUser, pass: smtpPass }, }); const subjectLabels: Record = { 'web-development': 'Web Development', 'mobile-development': 'Mobile Development', 'cloud-services': 'Cloud Services', 'ai-ml': 'AI & Machine Learning', 'consulting': 'IT Consulting', 'support': 'Technical Support', 'other': 'Other', }; await transporter.sendMail({ from: `"WorkRoot Contact Form" <${smtpUser}>`, to: recipientEmail, replyTo: data.email, subject: `[Contact Form] ${subjectLabels[data.subject] ?? data.subject} - ${data.name}`, text: [ `New contact form submission`, ``, `Name: ${data.name}`, `Email: ${data.email}`, `Phone: ${data.phone || 'Not provided'}`, `Subject: ${subjectLabels[data.subject] ?? data.subject}`, ``, `Message:`, data.message, ``, `---`, `Submitted at: ${new Date().toISOString()}`, ].join('\n'), html: `

New Contact Form Submission

Name${escapeHtml(data.name)}
Email${escapeHtml(data.email)}
Phone${escapeHtml(data.phone || 'Not provided')}
Subject${escapeHtml(subjectLabels[data.subject] ?? data.subject)}
Message${escapeHtml(data.message)}

Submitted at: ${new Date().toISOString()}

`, }); } catch (err) { // If nodemailer isn't installed, treat as graceful degradation (log only) if (err instanceof Error && err.message.includes('Cannot find module')) { logger.warn('api.contact', 'nodemailer not available — form submission logged only', { email: data.email, subject: data.subject, }); return; } await captureException(err, { scope: 'api.contact', extra: { email: data.email, subject: data.subject }, }); throw new Error('Email delivery failed'); } } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ============================================================ // CORS headers helper // ============================================================ function corsHeaders(): HeadersInit { const origin = import.meta.env.PROD ? 'https://workroot.in' : '*'; return { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; } // ============================================================ // OPTIONS preflight handler // ============================================================ export const OPTIONS: APIRoute = async () => { return new Response(null, { status: 204, headers: corsHeaders() }); }; // ============================================================ // POST handler // ============================================================ export const POST: APIRoute = async ({ request, clientAddress }) => { const ip = clientAddress ?? 'unknown'; const startTime = Date.now(); // Rate limiting check const rateLimit = checkRateLimit(ip); const rateLimitHeaders = { ...corsHeaders(), 'X-RateLimit-Limit': String(RATE_LIMIT_MAX_REQUESTS), 'X-RateLimit-Remaining': String(rateLimit.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimit.resetAt / 1000)), }; if (!rateLimit.allowed) { logApiRequest({ scope: 'api.contact', method: 'POST', path: '/api/contact', status: 429, ip }); return new Response( JSON.stringify({ success: false, error: 'Too many requests. Please try again later.', }), { status: 429, headers: rateLimitHeaders } ); } // Reject oversized payloads (max 16KB) const contentLength = parseInt(request.headers.get('content-length') ?? '0', 10); if (contentLength > 16384) { return new Response( JSON.stringify({ success: false, error: 'Request body too large.' }), { status: 413, headers: rateLimitHeaders } ); } // Parse request body let body: Record; try { const contentType = request.headers.get('content-type') ?? ''; if (contentType.includes('application/json')) { body = await request.json(); } else { const formData = await request.formData(); body = Object.fromEntries(formData.entries()); } } catch { return new Response( JSON.stringify({ success: false, error: 'Invalid request body.' }), { status: 400, headers: rateLimitHeaders } ); } // Honeypot check (spam bot detection) // Bots typically fill all fields; the honeypot field should always be empty if (body.website && String(body.website).trim() !== '') { // Silently succeed to not reveal spam detection return new Response( JSON.stringify({ success: true, message: 'Message received. Thank you!' }), { status: 200, headers: rateLimitHeaders } ); } // Build and validate form data const formData: ContactFormData = { name: String(body.name ?? '').trim(), email: String(body.email ?? '').trim(), phone: body.phone ? String(body.phone).trim() : undefined, subject: String(body.subject ?? '').trim(), message: String(body.message ?? '').trim(), }; const validationError = validateContactForm(formData); if (validationError) { return new Response( JSON.stringify({ success: false, error: validationError }), { status: 422, headers: rateLimitHeaders } ); } // Send email try { await sendContactEmail(formData); } catch { logApiRequest({ scope: 'api.contact', method: 'POST', path: '/api/contact', status: 500, ip, durationMs: Date.now() - startTime, }); return new Response( JSON.stringify({ success: false, error: 'Failed to send message. Please try again or email us directly.', }), { status: 500, headers: rateLimitHeaders } ); } logApiRequest({ scope: 'api.contact', method: 'POST', path: '/api/contact', status: 200, ip, durationMs: Date.now() - startTime, }); return new Response( JSON.stringify({ success: true, message: "Message sent successfully! We'll get back to you within 24 hours.", }), { status: 200, headers: rateLimitHeaders } ); };