import express from 'express' import path from 'path' import { fileURLToPath } from 'url' import { existsSync, mkdirSync, chmodSync } 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 }) // Try to set writable permissions, ignore if running as non-root try { chmodSync(dbDir, 0o755) } catch (e) {} } // --- 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 = parseInt(process.env.RATE_LIMIT_PER_MINUTE || '5', 10) 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 cspDirectives = { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], fontSrc: ["'self'", 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:'], connectSrc: ["'self'"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], } 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') // --- CORS Configuration --- const corsOrigin = process.env.CORS_ORIGIN || '*' // Default to * for development 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 app.use(express.json({ limit: '1mb' })) 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 = () => { // Leads table 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, 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 return sanitized.substring(0, maxLength) } 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().trim().max(10, 'ZIP code must be 10 characters or less').optional().or(z.literal('').transform(() => undefined)), 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)), }) 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)), }) // --- Zoho CRM Forwarding (best-effort, fire-and-forget) --- const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true' const ZOHO_API_DOMAIN = process.env.ZOHO_API_DOMAIN || 'https://www.zohoapis.com' const ZOHO_CLIENT_ID = process.env.ZOHO_CLIENT_ID || '' const ZOHO_CLIENT_SECRET = process.env.ZOHO_CLIENT_SECRET || '' const ZOHO_REFRESH_TOKEN = process.env.ZOHO_REFRESH_TOKEN || '' const ZOHO_REDIRECT_URI = process.env.ZOHO_REDIRECT_URI || '' // In-memory access token cache let zohoAccessToken = null let zohoTokenExpiry = 0 async function getZohoAccessToken() { // Return cached token if still valid (with 60s buffer) if (zohoAccessToken && Date.now() < zohoTokenExpiry - 60000) { return zohoAccessToken } try { const url = `${ZOHO_API_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, redirect_uri: ZOHO_REDIRECT_URI, }) const response = await fetch(`${url}?${params.toString()}`, { method: 'POST' }) 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 try { const accessToken = await getZohoAccessToken() if (!accessToken) { log.warn('[Zoho] No access token available, skipping lead forwarding') return } const url = `${ZOHO_API_DOMAIN}/crm/v8/Leads` const payload = { data: [ { Company: leadData.company || '', Last_Name: leadData.name || 'Unknown', Email: leadData.email || '', Phone: leadData.phone || '', Zip_Code: leadData.zip || '', Description: leadData.message || '', Service_Interest: leadData.service_interest || '', }, ], } const response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Zoho-oauthtoken ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) if (response.ok) { const result = await response.json() log.info('[Zoho] Lead forwarded successfully:', result.data?.[0]?.details?.id || 'no id returned') } else { const text = await response.text() log.error(`[Zoho] Lead forwarding failed (${response.status}):`, text) } } catch (err) { log.error('[Zoho] Forwarding error:', err.message) } } // --- 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', (req, res) => { 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, }) } // Sanitize parsed data before insert (trim, strip tags, truncate) const 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) res.json({ success: true, message: "Thanks! We'll be in touch shortly." }) } catch (err) { log.error('Error submitting lead:', err) res.status(500).json({ error: 'Failed to submit lead' }) } }) // Submit support request app.post('/api/support', (req, res) => { 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, }) } // 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})`) 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' }) } }) // --- 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 (falls through to SPA router) app.use(express.static(path.join(__dirname, '../dist'))) // --- 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() } app.use(timeoutMiddleware) // --- 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 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 (domain: ${ZOHO_API_DOMAIN})`) } 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}`) })