← back to Dw Domain Fleet

server.js

210 lines

/**
 * server.js — shared parameterized microsite server for the DW domain fleet.
 *
 * One process serves ONE domain. Which domain = SITE env var (the config key
 * under sites/). pm2 runs 66 instances of this file, one per domain, each on
 * its own port. nginx vhosts proxy domain → port.
 *
 *   SITE=grassclothwallcovering PORT=9901 node server.js
 */
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const path = require('path');
const fs = require('fs');

const SITE = process.env.SITE;
if (!SITE) { console.error('FATAL: SITE env var required'); process.exit(1); }

const cfgPath = path.join(__dirname, 'sites', SITE + '.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
catch (e) { console.error(`FATAL: cannot load sites/${SITE}.json — ${e.message}`); process.exit(1); }

const PORT = process.env.PORT || cfg.port || 9900;
const catalog = require('./shared/catalog');
const { sortProducts } = require('./shared/sort');
const render = require('./shared/render');

// ---- niche slice for this site (built once at boot) ----
const NICHE = catalog.nicheSlice({
  pos: cfg.niche.pos || [],
  neg: cfg.niche.neg || [],
  types: cfg.niche.types || [],
  limit: 6000
});
// fallback: if a niche is too thin, broaden to all clean wallcoverings
const POOL = NICHE.length >= 24 ? NICHE
  : catalog.nicheSlice({ pos: [], neg: cfg.niche.neg || [], types: ['Wallcovering'], limit: 6000 });
// Per-site catalog overlay (data/extras/<slug>.json) — site-exclusive products
// prepended so they read as newest. No-op for sites without an extras file.
const EXTRAS = catalog.siteExtras(SITE);
if (EXTRAS.length) { POOL.unshift(...EXTRAS); console.log(`[${SITE}] + ${EXTRAS.length} site extras`); }
console.log(`[${SITE}] niche pool: ${NICHE.length} (serving ${POOL.length})`);

// Hero images come from the precomputed global allocation manifest, which
// guarantees NO image is reused on any two sites in the fleet (see
// scripts/gen-hero-allocation.js). Fall back to the newest niche slice only if
// the manifest is missing this site.
let HERO_ALLOC = {};
try { HERO_ALLOC = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'hero-allocation.json'), 'utf8')); }
catch (e) { console.warn(`[${SITE}] hero-allocation.json missing — using niche fallback`); }
const HERO_IMGS = (Array.isArray(HERO_ALLOC[SITE]) && HERO_ALLOC[SITE].length >= 3)
  ? HERO_ALLOC[SITE]
  : sortProducts(POOL, 'newest').slice(0, 14).map(p => p.image_url).filter(Boolean);
const FEATURED = sortProducts(POOL, 'newest').slice(0, 20);

// ---- vendor-neutral image proxy map (built once at boot) ----
// The raw Shopify CDN image_url embeds the 3rd-party vendor name in the
// filename; render.js now emits /img/<token> instead. We resolve token -> real
// CDN url here (render.imgToken is the same pure hash render uses) and the
// /img/:token route streams the bytes, so the vendor never appears in the DOM
// OR the browser network tab (DTD 2026-06-03 verdict A — streaming proxy).
const IMG_MAP = new Map();
function registerImg(url) { if (url) IMG_MAP.set(render.imgToken(url), url); }
for (const p of POOL) registerImg(p.image_url);   // every card + PDP image
for (const u of HERO_IMGS) registerImg(u);        // homepage hero slides
console.log(`[${SITE}] image-proxy map: ${IMG_MAP.size} urls`);

// Small per-process LRU of fetched bytes so a hot image isn't re-fetched from
// the CDN on every request. Capped to keep memory flat across 44+ procs.
const IMG_CACHE = new Map();          // token -> { buf, type, exp }
const IMG_CACHE_MAX = 256;
const IMG_TTL_MS = 6 * 60 * 60 * 1000;

const app = express();
// Behind nginx (single reverse-proxy hop). Without this, express-rate-limit sees
// the nginx-set X-Forwarded-For with trust-proxy OFF and throws
// ERR_ERL_UNEXPECTED_X_FORWARDED_FOR on every request (all 9 dwf-* apps logged it).
// trust proxy = 1 trusts exactly the first hop (nginx), so req.ip is the real client.
app.set('trust proxy', 1);
app.disable('x-powered-by');
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'"],
      styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
      imgSrc: ["'self'", 'data:', 'https:'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
      connectSrc: ["'self'"],
      frameAncestors: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"],
      formAction: ["'self'"], upgradeInsecureRequests: []
    }
  },
  strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
  next();
});
app.use(rateLimit({ windowMs: 60000, max: 240, standardHeaders: 'draft-7', legacyHeaders: false }));

const PER_PAGE = 60;

app.get('/', (req, res) => {
  res.type('html').send(render.homePage(cfg, HERO_IMGS, FEATURED));
});

app.get('/catalog', (req, res) => {
  const q = String(req.query.q || '').slice(0, 120).toLowerCase().trim();
  const sort = String(req.query.sort || 'newest').slice(0, 20);
  let page = parseInt(req.query.page, 10) || 1;
  let list = POOL;
  if (q) {
    const toks = q.split(/\s+/).filter(Boolean);
    list = list.filter(p => {
      const blob = ((p.title || '') + ' ' + (p.sku || '') + ' ' + (p.tags || []).join(' ')).toLowerCase();
      return toks.every(t => blob.includes(t));
    });
  }
  list = sortProducts(list, sort);
  const total = list.length;
  const pages = Math.max(1, Math.ceil(total / PER_PAGE));
  page = Math.min(Math.max(1, page), pages);
  const slice = list.slice((page - 1) * PER_PAGE, page * PER_PAGE);
  res.type('html').send(render.catalogPage(cfg, slice, q, sort, page, pages, total));
});

app.get('/product/:handle', (req, res) => {
  const key = String(req.params.handle || '').slice(0, 200);
  // Dual-key resolve: the real Shopify handle takes priority, then the fleet's
  // own "wallpaper"-free slug. Serving both keeps any old handle-based URL
  // working while new links use the clean slug.
  const p = POOL.find(x => x.handle === key)
         || POOL.find(x => render.productSlug(x) === key);
  if (!p) return res.status(404).type('html').send(render.aboutPage(cfg));
  const related = sortProducts(POOL.filter(x => x !== p), 'newest').slice(0, 10);
  res.type('html').send(render.productPage(cfg, p, related));
});

// Buy-sample redirect: the quick-view CTA links here with the CLEAN productSlug
// (data-handle no longer carries the raw vendor-laden Shopify handle — settlement
// vendor-confidentiality, DTD verdict B). We resolve the real handle SERVER-SIDE
// and 302 to the DW store, so the vendor token never reaches the rendered DOM.
// Closed redirect — target host is hardcoded, slug is validated, 404 on miss.
app.get('/buy/:slug', (req, res) => {
  const key = String(req.params.slug || '').slice(0, 200);
  const p = POOL.find(x => render.productSlug(x) === key)
         || POOL.find(x => x.handle === key);
  if (!p || !p.handle) return res.status(404).type('html').send(render.aboutPage(cfg));
  // Site-extras (AI-generated, not on the DW Shopify store) carry their own
  // sample CTA target so the buy link never 404s on the main store.
  if (p.buy_url) return res.redirect(302, p.buy_url);
  res.redirect(302, 'https://designerwallcoverings.com/products/'
    + encodeURIComponent(p.handle) + '#sample');
});

// Vendor-neutral image proxy. token is a pure hash of the real CDN url (see
// render.imgToken); we resolve it from the boot map and stream the bytes so the
// vendor filename never reaches the client. Closed proxy — only urls that were
// registered at boot (this site's own catalog + hero images) can be fetched, so
// it can't be abused as an open relay. 404 on unknown token, 502 on upstream
// failure. Long Cache-Control + a tiny in-proc LRU keep CDN hits rare.
app.get('/img/:token', async (req, res) => {
  const token = String(req.params.token || '').slice(0, 64);
  const url = IMG_MAP.get(token);
  if (!url) return res.status(404).end();
  const now = Date.now();
  const hit = IMG_CACHE.get(token);
  if (hit && hit.exp > now) {
    res.setHeader('Content-Type', hit.type);
    res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
    res.setHeader('X-Img-Cache', 'HIT');
    return res.end(hit.buf);
  }
  try {
    const upstream = await fetch(url);
    if (!upstream.ok) return res.status(502).end();
    const type = upstream.headers.get('content-type') || 'image/jpeg';
    const buf = Buffer.from(await upstream.arrayBuffer());
    if (IMG_CACHE.size >= IMG_CACHE_MAX) IMG_CACHE.delete(IMG_CACHE.keys().next().value);
    IMG_CACHE.set(token, { buf, type, exp: now + IMG_TTL_MS });
    res.setHeader('Content-Type', type);
    res.setHeader('Cache-Control', 'public, max-age=86400, immutable');
    res.setHeader('X-Img-Cache', 'MISS');
    return res.end(buf);
  } catch (e) {
    return res.status(502).end();
  }
});

app.get('/about', (req, res) => {
  res.type('html').send(render.aboutPage(cfg));
});

app.get('/info', (req, res) => {
  res.type('html').send(render.infoPage(cfg));
});

app.get('/health', (req, res) => {
  res.json({ ok: true, site: SITE, domain: cfg.domain, niche: NICHE.length, serving: POOL.length });
});

app.use((req, res) => {
  res.status(404).type('html').send(render.aboutPage(cfg));
});

app.listen(PORT, () => console.log(`[${SITE}] ${cfg.domain} live on :${PORT}`));