947 lines
33 KiB
JavaScript
947 lines
33 KiB
JavaScript
import express from 'express'
|
|
import path from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import { existsSync, mkdirSync, readFileSync } 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()
|
|
|
|
const loadLocalEnv = () => {
|
|
const envPath = path.resolve(process.cwd(), '.env')
|
|
if (!existsSync(envPath)) return
|
|
|
|
const envFile = readFileSync(envPath, 'utf8')
|
|
for (const line of envFile.split(/\r?\n/)) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed || trimmed.startsWith('#')) continue
|
|
|
|
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/)
|
|
if (!match) continue
|
|
|
|
const [, key, rawValue] = match
|
|
if (process.env[key] !== undefined) continue
|
|
|
|
process.env[key] = rawValue.replace(/^(['"])(.*)\1$/, '$2')
|
|
}
|
|
}
|
|
|
|
loadLocalEnv()
|
|
|
|
// 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://crm.zohopublic.com', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'],
|
|
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
|
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
|
|
imgSrc: ["'self'", 'data:'],
|
|
connectSrc: isDev
|
|
? ["'self'", 'ws://localhost:*', 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/']
|
|
: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
|
|
frameSrc: ["'self'", 'https://crm.zoho.com', 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
formAction: ["'self'", 'https://crm.zoho.com'],
|
|
}
|
|
|
|
// 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[^>]*>.*?<\/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
|
|
const ZOHO_FORWARDING_MODE = (process.env.ZOHO_FORWARDING_MODE || 'api').toLowerCase()
|
|
const ZOHO_WEBTOLEAD_ENABLED = process.env.ZOHO_WEBTOLEAD_ENABLED === 'true'
|
|
const ZOHO_WEBTOLEAD_URL = process.env.ZOHO_WEBTOLEAD_URL || 'https://crm.zoho.com/crm/WebToLeadForm'
|
|
const ZOHO_WEBTOLEAD_XNQSJSDP = process.env.ZOHO_WEBTOLEAD_XNQSJSDP || null
|
|
const ZOHO_WEBTOLEAD_XMIWTLD = process.env.ZOHO_WEBTOLEAD_XMIWTLD || null
|
|
const ZOHO_WEBTOLEAD_ACTION_TYPE = process.env.ZOHO_WEBTOLEAD_ACTION_TYPE || 'TGVhZHM='
|
|
const ZOHO_WEBTOLEAD_RETURN_URL = process.env.ZOHO_WEBTOLEAD_RETURN_URL || 'null'
|
|
const ZOHO_WEBTOLEAD_ZC_GAD = process.env.ZOHO_WEBTOLEAD_ZC_GAD || ''
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
async function forwardToZohoWebToLead(leadData) {
|
|
if (!ZOHO_WEBTOLEAD_ENABLED) return
|
|
|
|
if (!ZOHO_WEBTOLEAD_XNQSJSDP || !ZOHO_WEBTOLEAD_XMIWTLD) {
|
|
log.warn('[Zoho WebToLead] Skipping forwarding - hidden form tokens are not configured')
|
|
return
|
|
}
|
|
|
|
const descriptionParts = []
|
|
if (leadData.message) descriptionParts.push(leadData.message)
|
|
if (leadData.service_interest) descriptionParts.push(`Service Interest: ${leadData.service_interest}`)
|
|
|
|
const payload = new URLSearchParams({
|
|
xnQsjsdp: ZOHO_WEBTOLEAD_XNQSJSDP,
|
|
zc_gad: ZOHO_WEBTOLEAD_ZC_GAD,
|
|
xmIwtLD: ZOHO_WEBTOLEAD_XMIWTLD,
|
|
actionType: ZOHO_WEBTOLEAD_ACTION_TYPE,
|
|
returnURL: ZOHO_WEBTOLEAD_RETURN_URL,
|
|
aG9uZXlwb3Q: '',
|
|
Company: leadData.company || '',
|
|
'Last Name': leadData.name || 'Unknown',
|
|
Email: leadData.email || '',
|
|
Phone: leadData.phone || '',
|
|
'Zip Code': leadData.zip || '',
|
|
Description: descriptionParts.join('\n\n'),
|
|
})
|
|
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), ZOHO_TIMEOUT_MS)
|
|
|
|
try {
|
|
const response = await fetch(ZOHO_WEBTOLEAD_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: payload.toString(),
|
|
signal: controller.signal,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
log.error(`[Zoho WebToLead] Forwarding failed (${response.status}):`, text.slice(0, 500))
|
|
return
|
|
}
|
|
|
|
log.info('[Zoho WebToLead] Lead forwarded successfully')
|
|
} catch (fetchErr) {
|
|
if (fetchErr.name === 'AbortError') {
|
|
log.warn('[Zoho WebToLead] Lead forwarding timed out after', ZOHO_TIMEOUT_MS, 'ms')
|
|
} else {
|
|
log.error('[Zoho WebToLead] Forwarding error:', fetchErr.message)
|
|
}
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
}
|
|
|
|
function forwardLeadToZoho(leadData) {
|
|
if (ZOHO_FORWARDING_MODE === 'webtolead') {
|
|
return forwardToZohoWebToLead(leadData)
|
|
}
|
|
|
|
return forwardToZoho(leadData)
|
|
}
|
|
|
|
// --- 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)
|
|
forwardLeadToZoho(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
|
|
forwardLeadToZoho(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`)
|
|
log.info(`Zoho lead forwarding mode: ${ZOHO_FORWARDING_MODE}`)
|
|
if (ZOHO_FORWARDING_MODE === 'webtolead') {
|
|
log.info(`Zoho WebToLead forwarding: ${ZOHO_WEBTOLEAD_ENABLED ? 'ENABLED' : 'DISABLED'}`)
|
|
} else if (ZOHO_ENABLED) {
|
|
log.info(`Zoho CRM API 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 API 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}`)
|
|
})
|