Files
CompanySite/src/pages/api/contact.ts
T
Clintchiz 0614ae6f85
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
Deploy to Production / Build & Verify (push) Failing after 13s
Ping Search Engines / Notify Search Engines (push) Successful in 3s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 1s
E2E Test Suite / Smoke Tests (P0) (push) Failing after 9m36s
E2E Test Suite / Form Interaction Tests (push) Failing after 12m6s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 11m46s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 9m31s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 11m5s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 15m24s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 6s
E2E Test Suite / Mobile Device Tests (push) Failing after 3h12m28s
Uptime Monitor / Health & Response Time (push) Successful in 5s
Uptime Monitor / SSL Certificate (push) Successful in 3s
Uptime Monitor / Send Alerts (push) Has been skipped
Uptime Monitor / Record Uptime Success (push) Successful in 2s
Latest Updated Pages
2026-03-22 14:37:17 +05:30

342 lines
12 KiB
TypeScript

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<string, RateLimitEntry>();
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<void> {
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<string, string> = {
'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: `
<h2>New Contact Form Submission</h2>
<table style="border-collapse:collapse;width:100%;max-width:600px">
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Name</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.name)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Email</td><td style="padding:8px;border-bottom:1px solid #eee"><a href="mailto:${escapeHtml(data.email)}">${escapeHtml(data.email)}</a></td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Phone</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(data.phone || 'Not provided')}</td></tr>
<tr><td style="padding:8px;font-weight:bold;border-bottom:1px solid #eee">Subject</td><td style="padding:8px;border-bottom:1px solid #eee">${escapeHtml(subjectLabels[data.subject] ?? data.subject)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;vertical-align:top">Message</td><td style="padding:8px;white-space:pre-wrap">${escapeHtml(data.message)}</td></tr>
</table>
<p style="color:#888;font-size:12px;margin-top:16px">Submitted at: ${new Date().toISOString()}</p>
`,
});
} 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ============================================================
// CORS headers helper
// ============================================================
const ALLOWED_ORIGINS = ['https://workroot.in', 'https://www.workroot.in'];
function corsHeaders(requestOrigin?: string | null): HeadersInit {
if (!import.meta.env.PROD) {
return {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
}
// Reflect the request origin if it is in the allowlist, otherwise default to primary domain
const origin = requestOrigin && ALLOWED_ORIGINS.includes(requestOrigin)
? requestOrigin
: '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',
'Vary': 'Origin',
};
}
// ============================================================
// OPTIONS preflight handler
// ============================================================
export const OPTIONS: APIRoute = async ({ request }) => {
return new Response(null, { status: 204, headers: corsHeaders(request.headers.get('origin')) });
};
// ============================================================
// POST handler
// ============================================================
export const POST: APIRoute = async ({ request, clientAddress }) => {
const ip = clientAddress ?? 'unknown';
const startTime = Date.now();
const requestOrigin = request.headers.get('origin');
// Rate limiting check
const rateLimit = checkRateLimit(ip);
const rateLimitHeaders = {
...corsHeaders(requestOrigin),
'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<string, unknown>;
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 }
);
};