← 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)`);
});