import express from 'express' import path from 'path' import { fileURLToPath } from 'url' import { existsSync, mkdirSync } from 'fs' import sqlite3 from 'better-sqlite3' import z from 'zod' import rateLimit from 'express-rate-limit' import helmet from 'helmet' import cors from 'cors' // --- Setup --- const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const app = express() // Trust first proxy (Docker/reverse proxy) for correct client IP in rate limiting app.set('trust proxy', 1) const dbPath = path.join(__dirname, '../db/queuenorth.db') const dbDir = path.dirname(dbPath) // Create db directory if it doesn't exist if (!existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }) } // --- Logger --- const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL?.toLowerCase()] ?? LOG_LEVELS.info const log = { info: (...args) => { if (currentLevel >= LOG_LEVELS.info) console.log(`[${new Date().toISOString()}] INFO `, ...args) }, warn: (...args) => { if (currentLevel >= LOG_LEVELS.warn) console.warn(`[${new Date().toISOString()}] WARN `, ...args) }, error: (...args) => { if (currentLevel >= LOG_LEVELS.error) console.error(`[${new Date().toISOString()}] ERROR`, ...args) }, debug: (...args) => { if (currentLevel >= LOG_LEVELS.debug) console.debug(`[${new Date().toISOString()}] DEBUG`, ...args) }, } // --- Rate Limiting --- const rateLimitWindowMs = 60 * 1000 // 1 minute const rateLimitMax = (() => { const val = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '5', 10) if (isNaN(val) || val < 1) { log.warn('[RateLimit] Invalid RATE_LIMIT_PER_MINUTE, defaulting to 5') return 5 } return val })() const apiLimiter = rateLimit({ windowMs: rateLimitWindowMs, max: rateLimitMax, standardHeaders: true, legacyHeaders: false, handler: (req, res) => { log.warn(`Rate limit exceeded for IP: ${req.ip}`) res.status(429).json({ error: 'Too Many Requests', message: 'Please try again later.', retryAfter: Math.ceil(rateLimitWindowMs / 1000), }) }, }) // --- Security Headers (Helmet) --- const isDev = process.env.NODE_ENV === 'development' const cspDirectives = { defaultSrc: ["'self'"], scriptSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'], styleSrc: ["'self'", 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:'], connectSrc: isDev ? ["'self'", 'ws://localhost:*'] : ["'self'"], frameSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], } // Note: connectSrc currently allows 'self' only. Zoho API calls are server-to-server // and are not affected by CSP. If client-side Zoho calls are added in the future, // add Zoho domains here (e.g., 'https://www.zohoapis.com', 'https://accounts.zoho.com') app.use(helmet({ contentSecurityPolicy: { directives: cspDirectives, }, crossOriginEmbedderPolicy: false, // Prevent CSP issues with embedded content crossOriginOpenerPolicy: false, crossOriginResourcePolicy: { policy: 'same-origin' }, dnsPrefetchControl: { allow: false }, frameguard: { action: 'deny' }, hidePoweredBy: true, hsts: { maxAge: 31536000, includeSubDomains: true }, ieNoOpen: true, noSniff: true, originAgentCluster: true, permittedCrossDomainPolicies: { permittedPolicies: 'none' }, referrerPolicy: { policy: 'same-origin' }, xssFilter: true, })) log.info('[Security] Helmet enabled with CSP configured') // Redirect HTTP to HTTPS in production if (process.env.NODE_ENV === 'production') { app.use((req, res, next) => { if (req.headers['x-forwarded-proto'] === 'http') { return res.redirect(301, `https://${req.headers.host}${req.url}`) } next() }) } // --- CORS Configuration --- const corsOrigin = process.env.CORS_ORIGIN || 'https://queuenorth.com' // Default to production domain const corsConfig = cors({ origin: corsOrigin === '*' ? corsOrigin : (corsOrigin === 'null' ? undefined : corsOrigin), methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], exposedHeaders: ['X-RateLimit-Remaining', 'X-RateLimit-Reset'], maxAge: 86400, // 24 hours credentials: true, }) app.use(corsConfig) log.info(`[CORS] Enabled with origin: ${corsOrigin}`) // Middleware — JSON body parsing only on POST routes (issue #14) app.use(express.urlencoded({ extended: true, limit: '1mb' })) // Rate limiting for API routes only app.use('/api', apiLimiter) // Request logging middleware app.use((req, res, next) => { const start = Date.now() res.on('finish', () => { const ms = Date.now() - start const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'info' log[level](`${req.method} ${req.originalUrl} ${res.statusCode} ${ms}ms`) }) next() }) // --- Database --- const db = sqlite3(dbPath) // Initialize schema const initSchema = () => { // Check if leads table exists and needs UNIQUE constraint migration const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='leads'").get() if (tableExists) { // Check if UNIQUE constraint already exists on email const pragma = db.prepare("PRAGMA table_info(leads)").all() const emailCol = pragma.find(col => col.name === 'email') if (emailCol && !emailCol.pk) { // UNIQUE constraint doesn't exist, need to add it via migration log.info('[DB] Adding UNIQUE constraint on leads.email via migration') // Migrate leads table to add UNIQUE constraint db.exec( [ 'CREATE TABLE IF NOT EXISTS leads_new (', ' id INTEGER PRIMARY KEY AUTOINCREMENT,', ' company TEXT NOT NULL,', ' name TEXT NOT NULL,', ' email TEXT NOT NULL UNIQUE,', ' phone TEXT,', ' zip TEXT,', ' message TEXT,', ' service_interest TEXT,', ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP', ')' ].join('\n') ) // Copy existing data (deduplicate - keep first occurrence per email) db.exec( [ 'INSERT OR IGNORE INTO leads_new (id, company, name, email, phone, zip, message, service_interest, created_at)', 'SELECT id, company, name, email, phone, zip, message, service_interest, created_at', 'FROM leads' ].join('\n') ) // Drop old table db.exec('DROP TABLE leads') // Rename new table db.exec('ALTER TABLE leads_new RENAME TO leads') log.info('[DB] UNIQUE constraint added on leads.email') } } // Leads table (now with UNIQUE constraint on email, either from migration or fresh) db.exec(` CREATE TABLE IF NOT EXISTS leads ( id INTEGER PRIMARY KEY AUTOINCREMENT, company TEXT NOT NULL, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, phone TEXT, zip TEXT, message TEXT, service_interest TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) // Support requests table db.exec(` CREATE TABLE IF NOT EXISTS support_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, company TEXT NOT NULL, email TEXT NOT NULL, phone TEXT, issue TEXT NOT NULL, priority TEXT DEFAULT 'medium', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) } initSchema() // --- Sanitization Helper --- const sanitizeString = (input, maxLength) => { if (typeof input !== 'string') return input // Trim whitespace let sanitized = input.trim() // Remove HTML/script tags to prevent XSS sanitized = sanitized.replace(/]*>.*?<\/script>/gi, '') sanitized = sanitized.replace(/<[^>]*>/g, '') // Truncate to max length sanitized = sanitized.substring(0, maxLength) // Convert empty strings to undefined so they become NULL in DB return sanitized === '' ? undefined : sanitized } const sanitizePayload = (data, fields) => { const result = { ...data } for (const [field, maxLength] of Object.entries(fields)) { if (result[field] !== undefined) { result[field] = sanitizeString(result[field], maxLength) } } return result } // --- Validation Schemas --- const leadSchema = z.object({ company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'), name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'), email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'), phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), zip: z.string({ required_error: 'ZIP code is required' }).trim().min(1, 'ZIP code is required').max(10, 'ZIP code must be 10 characters or less'), message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)), service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), recaptcha_token: z.string().max(4096, 'Security verification token is too long').optional().or(z.literal('').transform(() => undefined)), company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it }) const supportSchema = z.object({ name: z.string().min(1, 'Name is required').trim().max(100, 'Name must be 100 characters or less'), company: z.string().min(1, 'Company name is required').trim().max(200, 'Company name must be 200 characters or less'), email: z.string().email('Valid email is required').trim().max(254, 'Email must be 254 characters or less'), phone: z.string().trim().max(50, 'Phone must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), issue: z.string().min(10, 'Please provide at least 10 characters describing your issue').trim().max(5000, 'Issue description must be 5000 characters or less'), priority: z.enum(['low', 'medium', 'high'], { errorMap: () => ({ message: 'Priority must be low, medium, or high' }), }).transform((val) => val?.toLowerCase() ?? undefined).optional().or(z.literal('').transform(() => undefined)), recaptcha_token: z.string().max(4096, 'Security verification token is too long').optional().or(z.literal('').transform(() => undefined)), company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it }) // --- Google reCAPTCHA Verification --- const RECAPTCHA_ENABLED = process.env.RECAPTCHA_ENABLED === 'true' const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY || null const RECAPTCHA_MIN_SCORE = Number.parseFloat(process.env.RECAPTCHA_MIN_SCORE || '0.5') const RECAPTCHA_TIMEOUT_MS = 5000 async function verifyRecaptcha(token, req) { if (!RECAPTCHA_ENABLED) return { success: true } if (!RECAPTCHA_SECRET_KEY) { log.error('[reCAPTCHA] RECAPTCHA_ENABLED=true but RECAPTCHA_SECRET_KEY is not configured') return { success: false, message: 'Security verification is unavailable. Please try again later.' } } if (!token) { return { success: false, message: 'Security verification is required' } } const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), RECAPTCHA_TIMEOUT_MS) try { const params = new URLSearchParams({ secret: RECAPTCHA_SECRET_KEY, response: token, remoteip: req.ip, }) const response = await fetch('https://www.google.com/recaptcha/api/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), signal: controller.signal, }) if (!response.ok) { log.warn(`[reCAPTCHA] Verification request failed with status ${response.status}`) return { success: false, message: 'Security verification failed. Please try again.' } } const result = await response.json() const score = typeof result.score === 'number' ? result.score : null const scoreAccepted = score === null || score >= RECAPTCHA_MIN_SCORE if (!result.success || !scoreAccepted) { log.warn('[reCAPTCHA] Verification rejected', { success: result.success, score, action: result.action, errors: result['error-codes'], }) return { success: false, message: 'Security verification failed. Please try again.' } } return { success: true } } catch (err) { if (err.name === 'AbortError') { log.warn('[reCAPTCHA] Verification timed out') } else { log.error('[reCAPTCHA] Verification error:', err.message) } return { success: false, message: 'Security verification failed. Please try again.' } } finally { clearTimeout(timeoutId) } } // --- Zoho CRM Forwarding (best-effort, fire-and-forget) --- const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true' const ZOHO_CASES_ENABLED = process.env.ZOHO_CASES_ENABLED === 'true' const ZOHO_API_DOMAIN = process.env.ZOHO_API_DOMAIN || 'https://www.zohoapis.com' const ZOHO_ACCOUNTS_DOMAIN = process.env.ZOHO_ACCOUNTS_DOMAIN || 'https://accounts.zoho.com' const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || null const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || null const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || null // In-memory access token cache let zohoAccessToken = null let zohoTokenExpiry = 0 // 10 second timeout for all Zoho API calls const ZOHO_TIMEOUT_MS = 10000 async function getZohoAccessToken() { // Return cached token if still valid (with 60s buffer) if (zohoAccessToken && Date.now() < zohoTokenExpiry - 60000) { return zohoAccessToken } try { // Token endpoint is on the ACCOUNTS domain, NOT the API domain // US: accounts.zoho.com | EU: accounts.zoho.eu | IN: accounts.zoho.in const url = `${ZOHO_ACCOUNTS_DOMAIN}/oauth/v2/token` const params = new URLSearchParams({ grant_type: 'refresh_token', client_id: ZOHO_CLIENT_ID, client_secret: ZOHO_CLIENT_SECRET, refresh_token: ZOHO_REFRESH_TOKEN, }) const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS) let response try { response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString(), signal: controller.signal, }) } catch (err) { if (err.name === 'AbortError') { log.warn('[Zoho] Token fetch timed out after', ZOHO_TIMEOUT_MS, 'ms') } else { log.error('[Zoho] Token fetch error:', err.message) } clearTimeout(timeoutId) return null } finally { clearTimeout(timeoutId) } // Issue #3: Check response.ok before parsing JSON if (!response.ok) { const text = await response.text() log.error(`[Zoho] Token fetch failed (${response.status}):`, text) return null } const data = await response.json() if (data.access_token) { zohoAccessToken = data.access_token zohoTokenExpiry = Date.now() + (data.expires_in || 3600) * 1000 log.info('[Zoho] Access token acquired, expires in', data.expires_in || 3600, 'seconds') return zohoAccessToken } else { log.error('[Zoho] Token exchange failed:', JSON.stringify(data)) return null } } catch (err) { log.error('[Zoho] Token acquisition error:', err.message) return null } } async function forwardToZoho(leadData) { if (!ZOHO_ENABLED) return // Short-circuit if Zoho credentials are missing if (!ZOHO_CLIENT_ID || !ZOHO_CLIENT_SECRET || !ZOHO_REFRESH_TOKEN) { log.warn("[Zoho] Skipping forwarding - ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN not configured") return } let accessToken = await getZohoAccessToken() if (!accessToken) { // Retry once — token refresh can fail transiently log.warn('[Zoho] First token refresh failed, retrying...') // Clear cached token to force a fresh attempt zohoAccessToken = null zohoTokenExpiry = 0 accessToken = await getZohoAccessToken() if (!accessToken) { log.warn('[Zoho] No access token available after retry, skipping lead forwarding') return } } // Issue #8: Prevent double-slash in URL path // Use upsert to handle duplicates gracefully (insert new or update existing by email) const url = `${ZOHO_API_DOMAIN.replace(/\/$/, "")}/crm/v8/Leads/upsert` // Split full name into First_Name / Last_Name for Zoho // Zoho requires Last_Name (mandatory), First_Name is optional const nameParts = (leadData.name || '').trim().split(/\s+/) const lastName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : (nameParts[0] || 'Unknown') const firstName = nameParts.length > 1 ? nameParts[0] : '' // Build Description with service interest appended for Zoho visibility const descriptionParts = [] if (leadData.message) descriptionParts.push(leadData.message) if (leadData.service_interest) descriptionParts.push(`Service Interest: ${leadData.service_interest}`) const description = descriptionParts.join('\n\n') const payload = { data: [ { First_Name: firstName || undefined, Last_Name: lastName, Company: leadData.company || '', Email: leadData.email || '', Phone: leadData.phone || '', Zip_Code: leadData.zip || '', Description: description || '', Lead_Source: 'Website', }, ], duplicate_check_fields: ['Email'], trigger: ['workflow'], } const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS) try { const response = await fetch(url, { method: "POST", headers: { "Authorization": `Zoho-oauthtoken ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), signal: controller.signal, }) // Issue #3: Check response.ok before processing if (!response.ok) { const text = await response.text() log.error(`[Zoho] Lead forwarding failed (${response.status}):`, text) return } const result = await response.json() log.info("[Zoho] Lead forwarded successfully:", result.data?.[0]?.details?.id || "no id returned") } catch (fetchErr) { if (fetchErr.name === "AbortError") { log.warn("[Zoho] Lead forwarding timed out after", ZOHO_TIMEOUT_MS, "ms") } else { log.error("[Zoho] Forwarding error:", fetchErr.message) } } finally { clearTimeout(timeoutId) } } // --- Zoho Cases Forwarding (best-effort, fire-and-forget) --- async function forwardSupportToZoho(supportData) { if (!ZOHO_CASES_ENABLED) return // Short-circuit if Zoho credentials are missing if (!ZOHO_CLIENT_ID || !ZOHO_CLIENT_SECRET || !ZOHO_REFRESH_TOKEN) { log.warn("[Zoho Cases] Skipping forwarding - ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, or ZOHO_REFRESH_TOKEN not configured") return } let accessToken = await getZohoAccessToken() if (!accessToken) { // Retry once — token refresh can fail transiently log.warn('[Zoho Cases] First token refresh failed, retrying...') // Clear cached token to force a fresh attempt zohoAccessToken = null zohoTokenExpiry = 0 accessToken = await getZohoAccessToken() if (!accessToken) { log.warn('[Zoho Cases] No access token available after retry, skipping support forwarding') return } } // Map priority to Zoho format const priorityMap = { low: 'Low', medium: 'Medium', high: 'High', } const priority = priorityMap[supportData.priority] || 'Medium' // Build description with name and company since Cases don't have Company field directly const descriptionParts = [] descriptionParts.push(`Name: ${supportData.name}`) descriptionParts.push(`Company: ${supportData.company}`) if (supportData.phone) descriptionParts.push(`Phone: ${supportData.phone}`) descriptionParts.push(`\n${supportData.issue}`) const description = descriptionParts.join('\n') const payload = { data: [ { Subject: supportData.issue, Priority: priority, Email: supportData.email || '', Description: description || '', Case_Origin: 'Website', }, ], trigger: ['workflow'], } // Issue #8: Prevent double-slash in URL path const url = `${ZOHO_API_DOMAIN.replace(/\/$/, '')}/crm/v8/Cases` const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS) try { const response = await fetch(url, { method: "POST", headers: { "Authorization": `Zoho-oauthtoken ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), signal: controller.signal, }) // Issue #3: Check response.ok before processing if (!response.ok) { const text = await response.text() log.error(`[Zoho Cases] Support forwarding failed (${response.status}):`, text) return } const result = await response.json() log.info("[Zoho Cases] Support forwarded successfully:", result.data?.[0]?.details?.id || "no id returned") } catch (fetchErr) { if (fetchErr.name === "AbortError") { log.warn("[Zoho Cases] Support forwarding timed out after", ZOHO_TIMEOUT_MS, "ms") } else { log.error("[Zoho Cases] Forwarding error:", fetchErr.message) } } finally { clearTimeout(timeoutId) } } // --- API Routes --- // Health check app.get('/api/health', (req, res) => { try { // Verify DB connection by executing a simple query db.prepare('SELECT 1').get() res.json({ status: 'ok', db: 'ok', timestamp: new Date().toISOString() }) } catch (err) { log.error('Health check DB verification failed:', err.message) res.status(503).json({ error: 'Service unavailable', db: 'error', timestamp: new Date().toISOString() }) } }) // Submit lead app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => { // Honeypot check - if filled, it's a bot if (req.body.company_website) { log.info('[Spam] Honeypot triggered, ignoring submission') // Return success to bot so it doesn't retry return res.json({ success: true, message: "Thanks! We'll be in touch shortly." }) } let sanitized try { const parsed = leadSchema.safeParse(req.body) if (!parsed.success) { const fieldErrors = {} for (const issue of parsed.error.issues) { if (issue.path[0]) { fieldErrors[issue.path[0]] = issue.message } } return res.status(400).json({ error: 'Validation failed', fields: fieldErrors, }) } const recaptcha = await verifyRecaptcha(parsed.data.recaptcha_token, req) if (!recaptcha.success) { return res.status(400).json({ error: 'Validation failed', fields: { recaptcha_token: recaptcha.message }, }) } // Sanitize parsed data before insert (trim, strip tags, truncate) sanitized = sanitizePayload(parsed.data, { company: 200, name: 100, email: 254, phone: 50, zip: 10, message: 5000, service_interest: 50, }) const stmt = db.prepare(` INSERT INTO leads (company, name, email, phone, zip, message, service_interest) VALUES (?, ?, ?, ?, ?, ?, ?) `) const result = stmt.run( sanitized.company, sanitized.name, sanitized.email, sanitized.phone || null, sanitized.zip || null, sanitized.message || null, sanitized.service_interest || null ) log.info(`Lead submitted: ${sanitized.email} from ${sanitized.company} (id: ${result.lastInsertRowid})`) // Fire-and-forget Zoho forwarding (best-effort, non-blocking) forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message)) res.json({ success: true, message: "Thanks! We'll be in touch shortly." }) } catch (err) { // Issue #6: Handle duplicate email error with 409 Conflict const errorMsg = err.message?.toLowerCase() || '' if (errorMsg.includes('unique constraint') || errorMsg.includes('duplicate')) { log.warn(`Duplicate lead email: ${sanitized.email}`) // Still forward to Zoho (non-blocking) for existing leads forwardToZoho(sanitized).catch(err => log.error('[Zoho] Forwarding error:', err.message)) return res.status(409).json({ error: 'Duplicate lead', message: 'A lead with this email already exists' }) } log.error('Error submitting lead:', err) res.status(500).json({ error: 'Failed to submit lead' }) } }) // Submit support request app.post('/api/support', express.json({ limit: '1mb' }), async (req, res) => { // Honeypot check - if filled, it's a bot if (req.body.company_website) { log.info('[Spam] Honeypot triggered, ignoring submission') // Return success to bot so it doesn't retry return res.json({ success: true, message: "Thanks! We'll get back to you soon." }) } try { const parsed = supportSchema.safeParse(req.body) if (!parsed.success) { const fieldErrors = {} for (const issue of parsed.error.issues) { if (issue.path[0]) { fieldErrors[issue.path[0]] = issue.message } } return res.status(400).json({ error: 'Validation failed', fields: fieldErrors, }) } const recaptcha = await verifyRecaptcha(parsed.data.recaptcha_token, req) if (!recaptcha.success) { return res.status(400).json({ error: 'Validation failed', fields: { recaptcha_token: recaptcha.message }, }) } // Sanitize parsed data before insert (trim, strip tags, truncate) const sanitized = sanitizePayload(parsed.data, { name: 100, company: 200, email: 254, phone: 50, issue: 5000, priority: 10, }) const stmt = db.prepare(` INSERT INTO support_requests (name, company, email, phone, issue, priority) VALUES (?, ?, ?, ?, ?, ?) `) const result = stmt.run( sanitized.name, sanitized.company, sanitized.email, sanitized.phone || null, sanitized.issue, sanitized.priority || 'medium' ) log.info(`Support request submitted: ${sanitized.email} from ${sanitized.company} priority=${sanitized.priority || 'medium'} (id: ${result.lastInsertRowid})`) // Fire-and-forget Zoho Cases forwarding (best-effort, non-blocking) forwardSupportToZoho(sanitized).catch(err => log.error('[Zoho Cases] Forwarding error:', err.message)) res.json({ success: true, message: "Thanks! We'll get back to you soon." }) } catch (err) { log.error('Error submitting support request:', err) res.status(500).json({ error: 'Failed to submit support request' }) } }) // --- Request timeout middleware (30 seconds) --- const REQUEST_TIMEOUT_MS = 30000 const timeoutMiddleware = (req, res, next) => { const timeout = setTimeout(() => { if (!res.headersSent) { log.warn(`Request timeout: ${req.method} ${req.originalUrl}`) res.status(504).json({ error: 'Request timeout' }) } }, REQUEST_TIMEOUT_MS) res.on('finish', () => clearTimeout(timeout)) res.on('close', () => clearTimeout(timeout)) next() } // --- Global error handlers --- process.on('uncaughtException', (err) => { log.error('Uncaught exception:', err.message) log.error('Stack:', err.stack) log.error('Shutting down due to uncaught exception...') process.exit(1) }) process.on('unhandledRejection', (reason, promise) => { log.error('Unhandled rejection at:', promise) log.error('Reason:', reason) log.error('Shutting down due to unhandled rejection...') process.exit(1) }) // --- Start Server --- const PORT = process.env.SERVER_PORT || 3001 // Register timeout middleware BEFORE catch-all routes app.use(timeoutMiddleware) // --- 404 catch-all for API routes (must be after all API routes) --- app.use((req, res, next) => { if (req.path.startsWith('/api')) { log.warn(`API route not found: ${req.method} ${req.originalUrl}`) return res.status(404).json({ error: 'Not found' }) } next() }) // Static file serving for SPA app.use(express.static(path.join(__dirname, '../dist'))) // SPA catch-all — serve index.html for any non-API, non-asset route // This lets React Router handle client-side routing app.get('*', (req, res, next) => { // Skip API routes (already handled above) and requests for static assets if (req.path.startsWith('/api/') || req.path.includes('.')) { return next() } res.sendFile(path.join(__dirname, '../dist/index.html')) }) app.listen(PORT, () => { log.info(`Server running on http://localhost:${PORT}`) log.info(`Health check: http://localhost:${PORT}/api/health`) if (ZOHO_ENABLED) { log.info(`Zoho CRM forwarding: ENABLED`) log.info(`Zoho API domain: ${ZOHO_API_DOMAIN}`) log.info(`Zoho Accounts domain: ${ZOHO_ACCOUNTS_DOMAIN}`) log.info(`Zoho Cases forwarding: ${process.env.ZOHO_CASES_ENABLED === 'true' ? 'ENABLED' : 'DISABLED'}`) } else { log.info('Zoho CRM forwarding: DISABLED (set ZOHO_ENABLED=true to enable)') } log.info(`Rate limiting: ${rateLimitMax} requests per ${rateLimitWindowMs / 1000} seconds`) log.info(`Security headers: Helmet enabled with CSP configured`) log.info(`CORS origin: ${corsOrigin}`) })