← back to Selfadhesivewallpaper
_universal-contact.js
282 lines
// DW family universal contact module — drop-in for any sister site's server.js.
// Persists every lead to data/leads.jsonl (primary path) + emails best-effort via George.
// On Kamatera: GEORGE_BASE=http://100.107.67.67:9850 + GEORGE_AUTH from /root/.dw-george.env
// Endpoints: POST /api/send-inquiry · POST /api/send-sample · GET /zd-loader.js · GET /api/leads-count
//
// Security hardening applied 2026-05-05 (security-auditor P0s):
// - GEORGE_AUTH must come from env — no hardcoded fallback
// - replyTo email sanitized against header injection (CR/LF/control + RFC-5322 shape)
// - Per-IP + per-site rate limit (5 leads/IP/15min)
// - Per-field byte cap (name 200, email 200, phone 40, address 300, message 4000)
// - image_url whitelist (only DW Shopify CDN allowed in outbound HTML)
// - Filename-safe per-site lead path; leads dir outside /public
module.exports = function (app, opts) {
opts = opts || {};
const fs = require('fs');
const path = require('path');
const SITE = opts.siteName || 'DW Family';
// Per-site contact email — defaults to info@<sitename>.com unless overridden via opts/env.
// Note: the underlying mailboxes need MX/SPF/DKIM/DMARC set up at the registrar; this only
// controls which "to:" address George sends to and which contact appears in templates.
const SITE_DOMAIN = opts.domain || (SITE.toLowerCase().replace(/[^a-z0-9]/g, '') + '.com');
const SITE_EMAIL = process.env.SITE_EMAIL || opts.siteEmail || `info@${SITE_DOMAIN}`;
const GEORGE_BASE = process.env.GEORGE_BASE || 'http://localhost:9850';
// P0 fix: NO fallback creds. If env missing, email path fails closed but lead persistence still works.
const GEORGE_AUTH = process.env.GEORGE_AUTH || '';
const ZD_KEY = process.env.ZD_WIDGET_KEY || '';
const ZD_COLOR = opts.zdColor || '#C9A14B';
const ZD_POSITION = opts.zdPosition || 'right';
if (!GEORGE_AUTH) {
console.warn('[universal-contact] GEORGE_AUTH env missing — leads will persist but emails will not send');
}
const LEADS_DIR = path.join(__dirname, 'data');
const LEADS_FILE = path.join(LEADS_DIR, 'leads.jsonl');
try { fs.mkdirSync(LEADS_DIR, { recursive: true }); } catch (e) {}
// ---- input validation + sanitization ----
// RFC-friendly email regex with explicit deny on CR/LF/comma/semicolon (header-injection vectors)
const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
// image_url allowlist — only DW Shopify CDN + designerwallcoverings.com
const IMG_ALLOW_RE = /^https:\/\/(?:cdn\.shopify\.com|designerwallcoverings\.com)\//;
// Per-field byte caps (post-trim)
const CAPS = { name: 200, email: 200, phone: 40, company: 200, projectName: 300, address: 300, city: 80, state: 40, zip: 20, projectScope: 2000, message: 4000, sku: 200, title: 400, image_url: 600 };
function clean(s, maxLen) {
if (s == null) return '';
s = String(s).replace(/[\r\n\t\v\f -]+/g, ' ').trim();
return s.length > maxLen ? s.slice(0, maxLen) : s;
}
function isValidEmail(s) { return EMAIL_RE.test(s) && s.length <= 200; }
function isValidImageUrl(s) { return s && IMG_ALLOW_RE.test(s); }
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
// ---- rate limiting (in-memory; fleet-wide isolation per pm2 process) ----
const RL_WINDOW_MS = 15 * 60 * 1000; // 15 min
const RL_MAX = 5; // 5 leads/IP/window
const rlBuckets = new Map(); // ip → [timestamps]
function rateOK(req) {
const ip = (req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || req.ip || '').split(',')[0].trim() || 'unknown';
const now = Date.now();
const arr = (rlBuckets.get(ip) || []).filter(t => now - t < RL_WINDOW_MS);
if (arr.length >= RL_MAX) { rlBuckets.set(ip, arr); return false; }
arr.push(now);
rlBuckets.set(ip, arr);
// Periodic cleanup — keep map size bounded
if (rlBuckets.size > 5000) {
for (const [k, ts] of rlBuckets) if (!ts.length || now - ts[ts.length - 1] > RL_WINDOW_MS) rlBuckets.delete(k);
}
return true;
}
// ---- lead persistence ----
function persistLead(kind, payload) {
try {
const line = JSON.stringify({ ts: new Date().toISOString(), kind: kind, site: SITE, ...payload }) + '\n';
fs.appendFileSync(LEADS_FILE, line);
return true;
} catch (e) { console.error('persist lead failed:', e.message); return false; }
}
// ---- George email (best-effort) ----
function postToGeorge(payload) {
return new Promise((resolve, reject) => {
if (!GEORGE_AUTH) return reject(new Error('GEORGE_AUTH not configured'));
const u = new URL(GEORGE_BASE + '/api/send?account=info');
const data = JSON.stringify(payload);
const transport = require(u.protocol === 'https:' ? 'https' : 'http');
const req = transport.request({
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
'Authorization': GEORGE_AUTH
},
timeout: 5000
}, (res) => {
let body = '';
res.on('data', (c) => body += c);
res.on('end', () => {
if (res.statusCode < 300) { try { resolve(JSON.parse(body || '{}')); } catch (e) { resolve({ raw: body }); } }
else reject(new Error('George ' + res.statusCode));
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('George timeout')); });
req.write(data);
req.end();
});
}
// ---- POST /api/send-inquiry ----
app.post('/api/send-inquiry', async (req, res) => {
if (!rateOK(req)) return res.status(429).json({ error: 'rate_limited' });
const raw = req.body || {};
const b = {
name: clean(raw.name, CAPS.name),
email: clean(raw.email, CAPS.email),
phone: clean(raw.phone, CAPS.phone),
company: clean(raw.company, CAPS.company),
projectName: clean(raw.projectName, CAPS.projectName),
projectScope: clean(raw.projectScope, CAPS.projectScope),
message: clean(raw.message, CAPS.message),
};
if (!b.name || !isValidEmail(b.email)) return res.status(400).json({ error: 'name + valid email required' });
persistLead('inquiry', b);
const html = '<h3 style="font-family:Georgia,serif;font-style:italic">' + escapeHtml(SITE) + ' — Project Inquiry</h3>'
+ '<table style="font-family:-apple-system,sans-serif;font-size:13px;border-collapse:collapse">'
+ '<tr><td style="padding:6px 12px;color:#888">From:</td><td>' + escapeHtml(b.name) + ' <' + escapeHtml(b.email) + '></td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888">Phone:</td><td>' + escapeHtml(b.phone || '—') + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888">Company:</td><td>' + escapeHtml(b.company || '—') + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888">Project:</td><td>' + escapeHtml(b.projectName || '—') + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888;vertical-align:top">Scope:</td><td>' + escapeHtml(b.projectScope || '—').replace(/\n/g, '<br>') + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888;vertical-align:top">Notes:</td><td>' + escapeHtml(b.message || '—').replace(/\n/g, '<br>') + '</td></tr>'
+ '</table><p style="font-family:-apple-system,sans-serif;font-size:11px;color:#888;margin-top:24px">via ' + escapeHtml(SITE) + '</p>';
let emailed = false, messageId = null;
try {
const r = await postToGeorge({
to: SITE_EMAIL,
replyTo: b.email, // already sanitized via clean() + isValidEmail()
subject: SITE + ' Inquiry — ' + (b.projectName || b.name),
body: html, html: true
});
messageId = r.messageId || null; emailed = true;
} catch (e) { console.warn('inquiry email failed (lead persisted):', e.message); }
res.json({ ok: true, persisted: true, emailed: emailed, id: messageId });
});
// ---- POST /api/send-sample ----
app.post('/api/send-sample', async (req, res) => {
if (!rateOK(req)) return res.status(429).json({ error: 'rate_limited' });
const raw = req.body || {};
const b = {
name: clean(raw.name, CAPS.name),
email: clean(raw.email, CAPS.email),
company: clean(raw.company, CAPS.company),
address: clean(raw.address, CAPS.address),
city: clean(raw.city, CAPS.city),
state: clean(raw.state, CAPS.state),
zip: clean(raw.zip, CAPS.zip),
message: clean(raw.message, CAPS.message),
sku: clean(raw.sku, CAPS.sku),
title: clean(raw.title, CAPS.title),
image_url: clean(raw.image_url, CAPS.image_url),
};
if (!b.name || !isValidEmail(b.email) || !b.address) {
return res.status(400).json({ error: 'name, valid email, address required' });
}
persistLead('sample', b);
// Image URL: only render allow-listed; otherwise just show pattern title without preview
const previewHtml = isValidImageUrl(b.image_url)
? '<div style="display:flex;gap:14px;align-items:center;margin:18px 0;padding:12px;background:#fafaf6;font-family:-apple-system,sans-serif">'
+ '<img src="' + escapeHtml(b.image_url) + '" style="width:80px;height:80px;object-fit:cover">'
+ '<div><div style="font-family:Georgia,serif;font-style:italic;font-size:15px">' + escapeHtml(b.title || '') + '</div>'
+ '<div style="font-size:11px;color:#888;letter-spacing:0.12em;text-transform:uppercase;margin-top:4px">' + escapeHtml(b.sku || '') + '</div></div></div>'
: '';
const html = '<h3 style="font-family:Georgia,serif;font-style:italic">' + escapeHtml(SITE) + ' — Sample Request</h3>'
+ previewHtml
+ '<table style="font-family:-apple-system,sans-serif;font-size:13px;border-collapse:collapse">'
+ '<tr><td style="padding:6px 12px;color:#888">From:</td><td>' + escapeHtml(b.name) + ' <' + escapeHtml(b.email) + '></td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888">Company:</td><td>' + escapeHtml(b.company || '—') + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888;vertical-align:top">Ship to:</td><td>' + escapeHtml(b.address) + '<br>' + escapeHtml(b.city) + ', ' + escapeHtml(b.state) + ' ' + escapeHtml(b.zip) + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888;vertical-align:top">Notes:</td><td>' + escapeHtml(b.message || '—').replace(/\n/g, '<br>') + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888">Pattern:</td><td>' + escapeHtml(b.title || b.sku || '—') + '</td></tr>'
+ '<tr><td style="padding:6px 12px;color:#888">SKU/handle:</td><td>' + escapeHtml(b.sku || '—') + '</td></tr>'
+ '</table><p style="font-family:-apple-system,sans-serif;font-size:11px;color:#888;margin-top:24px">via ' + escapeHtml(SITE) + ' · sample request</p>';
let emailed = false, messageId = null;
try {
const r = await postToGeorge({
to: SITE_EMAIL,
replyTo: b.email,
subject: SITE + ' Sample — ' + (b.title || b.sku || b.name),
body: html, html: true
});
messageId = r.messageId || null; emailed = true;
} catch (e) { console.warn('sample email failed (lead persisted):', e.message); }
res.json({ ok: true, persisted: true, emailed: emailed, id: messageId });
});
// ---- Cache-Control on hot read paths (perf-audit P1, 2026-05-05) ----
// /api/products → 5 min (catalog is rebuilt weekly)
// /api/facets → 1 hr (computed from products.json)
// /api/sliders → 1 hr (computed from products.json)
// Mutating endpoints (/api/send-*) explicitly no-store.
app.use((req, res, next) => {
if (req.method === 'GET') {
if (req.path === '/api/products') res.set('Cache-Control', 'public, max-age=300');
else if (req.path === '/api/facets' || req.path === '/api/sliders') res.set('Cache-Control', 'public, max-age=3600');
}
next();
});
// ---- GET /api/leads-count (admin only) ----
app.get('/api/leads-count', (req, res) => {
// Minimal admin guard: require ?key=<env COMPLIANCE_KEY> match
const want = process.env.COMPLIANCE_KEY || '';
if (want && req.query.key !== want) return res.status(403).json({ error: 'forbidden' });
try {
const lines = fs.existsSync(LEADS_FILE) ? fs.readFileSync(LEADS_FILE, 'utf8').trim().split('\n').filter(Boolean) : [];
res.json({ count: lines.length, site: SITE });
} catch (e) { res.json({ count: 0 }); }
});
// ---- GET /zd-loader.js — per-site Zendesk widget loader ----
app.get('/zd-loader.js', (req, res) => {
const tag = Math.random().toString(36).slice(2);
const delay = 200 + Math.floor(Math.random() * 1000);
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
// P0 fix: JSON.stringify all values into JS context (proper escaping)
const sKey = JSON.stringify(ZD_KEY);
const sColor = JSON.stringify(ZD_COLOR);
const sPosition = JSON.stringify(ZD_POSITION);
const sSiteName = JSON.stringify(SITE);
const sSiteSlug = JSON.stringify(SITE.toLowerCase().replace(/[^a-z0-9]+/g, '-'));
res.type('application/javascript').send(
'/* ' + SITE.replace(/[^A-Za-z0-9 ·.-]/g, '') + ' customer-care · ' + tag + ' · ' + new Date().toISOString().slice(0, 10) + ' */\n'
+ '(function(){\n'
+ ' var key = ' + sKey + ';\n'
+ ' if (!key) return;\n'
+ ' function load() {\n'
+ " var s = document.createElement('script');\n"
+ " s.id = 'ze-snippet';\n"
+ ' s.async = true;\n'
+ " s.src = 'https://static.zdassets.com/ekr/snippet.js?key=' + encodeURIComponent(key);\n"
+ ' document.head.appendChild(s);\n'
+ ' window.zESettings = {\n'
+ ' webWidget: {\n'
+ ' color: { theme: ' + sColor + ' },\n'
+ ' position: { horizontal: ' + sPosition + ", vertical: 'bottom' },\n"
+ ' contactForm: { title: { "*": ' + sSiteName + ' }, suppress: false }\n'
+ ' }\n'
+ ' };\n'
+ ' window.zE = window.zE || function(){ (window.zE.q = window.zE.q || []).push(arguments) };\n'
+ " window.addEventListener('load', function() {\n"
+ " try { window.zE && window.zE('webWidget', 'updateSettings', { webWidget: { color: { theme: " + sColor + " } } }); } catch(e){}\n"
+ " try { window.zE && window.zE('webWidget', 'identify', { name: 'Visitor', tags: [" + sSiteSlug + ", 'dw-family'] }); } catch(e){}\n"
+ ' });\n'
+ ' }\n'
+ " if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function(){ setTimeout(load, " + delay + '); }); } else { setTimeout(load, ' + delay + '); }\n'
+ '})();'
);
});
};