← back to Quadrille Showroom

server.js

390 lines

const express = require('express');
const helmet = require('helmet');
const compression = require('compression');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');

const app = express();
app.use(helmet({ contentSecurityPolicy: false }));
const PORT = process.env.PORT || 7690;
const AUTH_USER = process.env.SHOWROOM_USER || 'admin';
const AUTH_PASS = process.env.SHOWROOM_PASS || 'DWSecure2024!';

// ============================================================
// DATA — file-backed (built by scripts/build-showroom-data.js)
// ============================================================
const DATA_DIR = path.join(__dirname, 'data');
let PRODUCTS = [];
let META = { brands: {}, count: 0 };
function loadProducts() {
  try {
    const raw = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'showroom-products.json'), 'utf8'));
    PRODUCTS = (raw.products || []).filter(p => p && p.image);
    META = { brands: raw.brands || {}, count: PRODUCTS.length, distinct_room_images: raw.distinct_room_images };
    // If gen-assets produced seamless tiles / room renders, prefer them per-SKU.
    const tilesDir = path.join(DATA_DIR, 'gen-tiles');
    const roomsDir = path.join(DATA_DIR, 'gen-rooms');
    PRODUCTS.forEach(p => {
      const tile = path.join(tilesDir, `${p.sku}.png`);
      if (fs.existsSync(tile)) { p.tile = `/gen-tiles/${p.sku}.png`; }
      const room = path.join(roomsDir, `${p.sku}.jpg`);
      if (fs.existsSync(room)) { p.room = `/gen-rooms/${p.sku}.jpg`; }
    });
    console.log(`[data] loaded ${PRODUCTS.length} wallcoverings`, META.brands);
  } catch (e) {
    console.error('[data] load failed:', e.message, '— run: node scripts/build-showroom-data.js');
    PRODUCTS = [];
  }
}
loadProducts();

// ============================================================
// MIDDLEWARE
// ============================================================
app.use(compression());
app.use(express.json({ limit: '32kb' }));  // cap body — sample-request is the only POST

// Basic auth (gates everything — standalone, like dw-showroom).
// Localhost is exempt: this is a local-only tool (DTD verdict C), so direct
// 127.0.0.1 access needs no prompt. NOTE: if ever deployed behind nginx (Option A),
// re-gate at the proxy — a reverse proxy would arrive as localhost and bypass this.
app.use((req, res, next) => {
  const ip = req.ip || (req.socket && req.socket.remoteAddress) || '';
  if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return next();
  const auth = req.headers.authorization;
  if (!auth || !auth.startsWith('Basic ')) {
    res.setHeader('WWW-Authenticate', 'Basic realm="Quadrille Showroom"');
    return res.status(401).send('Authentication required');
  }
  const [user, pass] = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':');
  if (user !== AUTH_USER || pass !== AUTH_PASS) return res.status(403).send('Forbidden');
  next();
});

// Static
app.use(express.static(path.join(__dirname, 'public')));
app.use('/gen-tiles', express.static(path.join(DATA_DIR, 'gen-tiles')));
app.use('/gen-rooms', express.static(path.join(DATA_DIR, 'gen-rooms')));

// ============================================================
// SORTING — modes that work with the China Seas data (no width/hue)
// ============================================================
function sortProducts(list, mode) {
  const a = list.slice();
  switch (mode) {
    case 'title': a.sort((x, y) => (x.title || '').localeCompare(y.title || '')); break;
    case 'sku': a.sort((x, y) => (x.sku || '').localeCompare(y.sku || '')); break;
    case 'book': a.sort((x, y) => (x.collection || '').localeCompare(y.collection || '') || (x.title || '').localeCompare(y.title || '')); break;
    case 'newest': a.sort((x, y) => String(y.published_at || '').localeCompare(String(x.published_at || ''))); break;
    default: break; // natural (as-built) order
  }
  return a;
}

// ============================================================
// ROUTES
// ============================================================
const sendShowroom = (req, res) => res.sendFile(path.join(__dirname, 'public', 'showroom.html'));
app.get('/', sendShowroom);
app.get('/showroom', sendShowroom);

app.get('/api/health', (req, res) => res.json({ ok: true, count: PRODUCTS.length, time: new Date().toISOString() }));

// Windowed products — engine renders a 50-wing window into the full set.
app.get('/api/showroom/products', (req, res) => {
  const brand = req.query.brand && req.query.brand !== 'all' ? req.query.brand : null;
  const sort = req.query.sort || 'natural';
  const offset = Math.max(0, parseInt(req.query.offset) || 0);
  const limit = Math.min(120, Math.max(1, parseInt(req.query.limit) || 50));

  let list = brand ? PRODUCTS.filter(p => p.vendor === brand) : PRODUCTS;
  list = sortProducts(list, sort);
  const total = list.length;
  const window = list.slice(offset, offset + limit);
  res.json({ products: window, total, offset, limit, brand: brand || 'all', sort });
});

// Vendor/brand list derived from the data.
app.get('/api/showroom/vendors', (req, res) => {
  const palette = ['#1a5276', '#922b21', '#a04000', '#1e8449', '#7d3c98', '#b9770e'];
  const vendors = Object.keys(META.brands).map((name, i) => ({
    id: name.toLowerCase().replace(/[^a-z0-9]+/g, '_'),
    name,
    bookCount: META.brands[name],
    color: palette[i % palette.length],
  }));
  res.json({ vendors, total: META.count });
});

// ============================================================
// SAMPLE REQUEST — the tray submit flow. Age rides along here.
// Local-only kiosk tool (see auth note above); v1 captures PII, so treat records as
// PII-bearing. Storage = append-only JSONL (source of truth). Email via George is gated
// behind SAMPLE_REQUEST_EMAIL (default off) so this ships capturing-to-file immediately;
// see SAMPLE-REQUEST-SPEC.md steps 4–5 for the gated pieces (consent copy + recipient).
// ============================================================
const SAMPLE_REQ_FILE = path.join(DATA_DIR, 'sample-requests.jsonl');
const EMAIL_QUEUE_FILE = path.join(DATA_DIR, 'sample-request-emails.jsonl');

const clampStr = (v, max) =>
  typeof v === 'string' ? v.replace(/[\x00-\x1F\x7F]/g, '').trim().slice(0, max) : '';
// Like clampStr but keeps newlines (\n) + tabs (\t) — for the multi-line notes field.
const clampMulti = (v, max) =>
  typeof v === 'string' ? v.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '').trim().slice(0, max) : '';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// Gated email hook — writes a pending-send record to a queue file ONLY when the env flag is
// on. It does NOT send: a George picker turns queued rows into real mail once Steve enables
// it + sets the recipient. Best-effort; never fails the request.
function maybeQueueEmail(record) {
  if (String(process.env.SAMPLE_REQUEST_EMAIL || 'off').toLowerCase() !== 'on') return;
  try {
    fs.appendFileSync(EMAIL_QUEUE_FILE, JSON.stringify({
      queued_at: new Date().toISOString(), sent: false,
      to: process.env.SAMPLE_REQUEST_EMAIL_TO || null, request: record,
    }) + '\n');
  } catch (e) { console.error('[sample-request] email queue failed:', e.message); }
}

app.post('/api/sample-request', (req, res) => {
  const b = req.body || {};

  // samples — required, 1..50, each with a string sku
  if (!Array.isArray(b.samples) || b.samples.length < 1 || b.samples.length > 50) {
    return res.status(400).json({ ok: false, error: 'samples must be a 1..50 array' });
  }
  const samples = [];
  for (const s of b.samples) {
    if (!s || typeof s.sku !== 'string' || !s.sku.trim()) {
      return res.status(400).json({ ok: false, error: 'each sample needs a sku' });
    }
    samples.push({
      sku: clampStr(s.sku, 64),
      pattern_name: clampStr(s.pattern_name, 200),
      color: clampStr(s.color, 80),
    });
  }

  // age — null OR integer 0..120 (mirrors the client clamp)
  let age = null;
  if (b.age !== null && b.age !== undefined && b.age !== '') {
    const n = parseInt(b.age, 10);
    if (!Number.isFinite(n) || n < 0 || n > 120) {
      return res.status(400).json({ ok: false, error: 'age must be 0..120 or null' });
    }
    age = n;
  }

  // contact — all optional; email format-checked if present
  const contact = {
    name: clampStr(b.contact && b.contact.name, 120),
    email: clampStr(b.contact && b.contact.email, 160),
    phone: clampStr(b.contact && b.contact.phone, 40),
  };
  if (contact.email && !EMAIL_RE.test(contact.email)) {
    return res.status(400).json({ ok: false, error: 'invalid email' });
  }
  const notes = clampMulti(b.notes, 2000);

  const created_at = new Date().toISOString();
  const id = 'sr_' + crypto.createHash('sha1')
    .update(created_at + JSON.stringify(samples)).digest('hex').slice(0, 12);
  const record = { id, created_at, ip: req.ip || null, age, samples, contact, notes };

  try {
    fs.appendFileSync(SAMPLE_REQ_FILE, JSON.stringify(record) + '\n');
  } catch (e) {
    console.error('[sample-request] persist failed:', e.message);
    return res.status(500).json({ ok: false, error: 'persist failed' });
  }
  maybeQueueEmail(record);  // best-effort, gated
  res.json({ ok: true, id, created_at });
});

// Admin read-back — newest first. Gated by the same auth as everything else.
app.get('/api/sample-requests', (req, res) => {
  let rows = [];
  try {
    const raw = fs.readFileSync(SAMPLE_REQ_FILE, 'utf8');
    rows = raw.split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
  } catch (e) { /* no file yet → empty list */ }
  rows.reverse();
  res.json({ requests: rows, total: rows.length });
});
app.get('/admin/sample-requests', (req, res) =>
  res.sendFile(path.join(__dirname, 'public', 'admin', 'sample-requests.html')));

// Hardened image proxy — whitelist hosts (defends SSRF + handles no-CORS S3/quadrillefabrics).
const ALLOWED_HOSTS = new Set([
  'cdn.shopify.com',
  'quadrillefabrics.com',
  'quadrillefabrics.s3.us-east-2.amazonaws.com',
  'www.designerwallcoverings.com',
  'designerwallcoverings.com',
]);
app.get('/api/proxy/image', async (req, res) => {
  const url = req.query.url;
  if (!url) return res.status(400).send('Missing url');
  let parsed;
  try { parsed = new URL(url); } catch { return res.status(400).send('Bad url'); }
  if (parsed.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsed.hostname)) {
    return res.status(403).send('Host not allowed');
  }
  try {
    const { buf, contentType } = await fetchUpstreamBuffered(url);   // shared cap+retry path
    res.setHeader('Content-Type', contentType || 'image/jpeg');
    res.setHeader('Cache-Control', 'public, max-age=86400');
    return res.end(buf);
  } catch (e) {
    // Never silently mask — LOG every fallback so a dead upstream leaves a trail a canary can
    // grep (permanent 4xx flagged distinctly from a transient blip). The hero still degrades
    // to a quiet placeholder rather than a broken-image icon + console 502.
    const permanent = e.status && e.status < 500;
    console.warn(`[proxy] upstream ${permanent ? 'PERMANENT ' + e.status : 'transient'} for ${url} — ${e.message}`);
    if (sharp) { try { res.setHeader('X-Proxy-Fallback', '1'); return res.end(await placeholderThumb(512)); } catch (e2) {} }
    return res.status(502).send('Proxy error');
  }
});

// ============================================================
// SLIVER THUMBNAIL — downscaled real-design strips for the CLOSED wingboards (V1).
// The PJ photo shows a thin sliver of EVERY real design in the packed arc. Painting
// the full assets onto closed boards is wasteful (local gen-tiles are 2000²+ 12 MB
// PNGs; remote heroes are 125 KB). This serves a tiny JPEG (default 128 px wide) of any
// allowed source, downscaled with sharp and disk-cached, so 24 closed slivers cost a
// few KB each instead of multiple MB. Used ONLY for closed slivers; the open hero still
// loads the full asset via /gen-tiles or the proxy.
//   ?sku=<DW SKU>   → downscale the local gen-tile for that SKU (preferred, crisp + local)
//   ?url=<https…>   → downscale a whitelisted remote image
//   ?w=<px>         → target width (clamped 48..256, default 128)
// ============================================================
let sharp = null;
try { sharp = require('sharp'); } catch (e) { console.warn('[thumb] sharp not available — sliver thumbs fall back to proxy'); }
const THUMB_CACHE = path.join(DATA_DIR, 'thumb-cache');
try { fs.mkdirSync(THUMB_CACHE, { recursive: true }); } catch (e) {}

// Cap concurrent UPSTREAM image fetches. A cold-cache page load fires ~24 sliver thumbs at
// once; hitting the CDN with 24 simultaneous connections trips transient rate-limit 502s.
// The disk cache makes every repeat load free, so throttling only the first cold burst
// costs a beat, not UX. Root-cause fix for the 5x-caught cold-burst 502s.
const UPSTREAM_MAX = 6;
let _upstreamActive = 0; const _upstreamQ = [];
function acquireUpstream() {
  return new Promise(resolve => {
    const run = () => { if (_upstreamActive < UPSTREAM_MAX) { _upstreamActive++; resolve(); } else _upstreamQ.push(run); };
    run();
  });
}
function releaseUpstream() { _upstreamActive--; if (_upstreamQ.length) _upstreamQ.shift()(); }

// Neutral placeholder for a decorative closed sliver whose upstream fetch transiently fails.
// Fail-safe (matches the no-sharp 503 philosophy): serve a quiet tone at 200 rather than a
// console-erroring 502 on a 4"-visible sliver. Cached per width. All catalog URLs are verified
// healthy, so this only smooths transient CDN blips — it is not hiding a dead-link problem.
const _placeholderThumbs = new Map();
async function placeholderThumb(w) {
  if (_placeholderThumbs.has(w)) return _placeholderThumbs.get(w);
  const buf = await sharp({ create: { width: w, height: Math.round(w * 1.25), channels: 3, background: { r: 26, g: 26, b: 34 } } }).jpeg({ quality: 60 }).toBuffer();
  _placeholderThumbs.set(w, buf);
  return buf;
}

// SHARED upstream image fetch — the single reliability path for BOTH /api/proxy/image (open
// hero) and /api/thumb (closed slivers). Whitelist is checked by the caller. Buffered so the
// concurrency cap + retry apply uniformly (hero heroes are ~125KB; buffering is cheap and
// lets both routes share the same hardening — no more "fixed the tested endpoint, left its
// twin bleeding"). Retries TRANSIENT only (network err / 5xx / timeout); a real 4xx (dead
// object) throws immediately with .status set so the caller can log it as PERMANENT, not
// silently mask it. Returns { buf, contentType }.
function fetchUpstreamBuffered(url, { tries = 3, timeoutMs = 8000 } = {}) {
  const https = require('https');
  const once = (u) => new Promise((resolve, reject) => {
    const req = https.get(u, (pr) => {
      if (pr.statusCode >= 400) { reject(Object.assign(new Error('status ' + pr.statusCode), { status: pr.statusCode })); pr.resume(); return; }
      const chunks = []; pr.on('data', c => chunks.push(c));
      pr.on('end', () => resolve({ buf: Buffer.concat(chunks), contentType: pr.headers['content-type'] }));
    });
    req.on('error', reject);
    req.setTimeout(timeoutMs, () => req.destroy(new Error('upstream timeout')));
  });
  return (async () => {
    await acquireUpstream();
    try {
      for (let attempt = 0; ; attempt++) {
        try { return await once(url); }
        catch (e) {
          const transient = !e.status || e.status >= 500;
          if (attempt >= tries - 1 || !transient) throw e;
          await new Promise(r => setTimeout(r, 250 * (attempt + 1)));
        }
      }
    } finally { releaseUpstream(); }
  })();
}

function sendThumb(res, buf, key) {
  res.setHeader('Content-Type', 'image/jpeg');
  res.setHeader('Cache-Control', 'public, max-age=604800');
  res.end(buf);
}

app.get('/api/thumb', async (req, res) => {
  if (!sharp) {
    // FAIL-SAFE (not fail-heavy): without sharp we can't downscale; redirecting to the full
    // 125KB proxy or — worse — the 12MB gen-tile PNG would invert this optimization into an
    // FPS disaster on the rack. 503 → the client keeps the cheap synthetic-pool sliver and
    // logs, rather than shovelling heavy assets at the GPU. (Contrarian 2026-06-28)
    return res.status(503).send('thumb unavailable (no sharp); client uses pool fallback');
  }
  const w = Math.max(48, Math.min(256, parseInt(req.query.w) || 128));
  const sku = (req.query.sku || '').replace(/[^A-Za-z0-9_-]/g, '');
  const url = req.query.url;

  // Cache key by source + width
  const ck = crypto.createHash('sha1').update((sku || url || '') + '|' + w).digest('hex').slice(0, 24);
  const cacheFile = path.join(THUMB_CACHE, ck + '.jpg');
  try {
    if (fs.existsSync(cacheFile)) return sendThumb(res, fs.readFileSync(cacheFile));
  } catch (e) {}

  const downscale = async (inputBuf) => {
    const out = await sharp(inputBuf).resize({ width: w, withoutEnlargement: true })
      .jpeg({ quality: 72 }).toBuffer();
    try { fs.writeFileSync(cacheFile, out); } catch (e) {}
    return out;
  };

  try {
    if (sku) {
      const tile = path.join(DATA_DIR, 'gen-tiles', sku + '.png');
      if (!fs.existsSync(tile)) return res.status(404).send('No tile');
      return sendThumb(res, await downscale(fs.readFileSync(tile)));
    }
    if (url) {
      let parsed;
      try { parsed = new URL(url); } catch { return res.status(400).send('Bad url'); }
      if (parsed.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsed.hostname)) return res.status(403).send('Host not allowed');
      try {
        const { buf } = await fetchUpstreamBuffered(url);   // shared cap+retry path (same as proxy)
        return sendThumb(res, await downscale(buf));
      } catch (e) {
        // Never silently mask — LOG the fallback (permanent 4xx flagged distinctly) so a dead
        // sliver URL leaves a trail. Decorative closed sliver still degrades to a quiet 200
        // placeholder rather than a console 502.
        const permanent = e.status && e.status < 500;
        console.warn(`[thumb] upstream ${permanent ? 'PERMANENT ' + e.status : 'transient'} for ${url} — ${e.message}`);
        res.setHeader('X-Thumb-Fallback', '1');
        return sendThumb(res, await placeholderThumb(w));
      }
    }
    return res.status(400).send('Missing source');
  } catch (e) {
    return res.status(502).send('Thumb error: ' + e.message);
  }
});

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Quadrille House Wallcovering Showroom on http://0.0.0.0:${PORT}  (${PRODUCTS.length} wings)`);
});