← back to Raffiawallcoverings

_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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) + ' &lt;' + escapeHtml(b.email) + '&gt;</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) + ' &lt;' + escapeHtml(b.email) + '&gt;</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'
      + '})();'
    );
  });
};