Queue-North-Website/server/index.js

428 lines
14 KiB
JavaScript

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()
})
app.use(express.static(path.join(__dirname, '../dist')))
// --- 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[^>]*>.*?<\/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) => {
res.json({ status: 'ok', 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' })
}
})
// --- 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}`)
})