← back to Dw Domain Fleet

server.js

317 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');
// Canonical showroom-vendor primitive (list + logic in fix-live-board/config). TK-11186.
// Deploy-safe: fix-live-board lives on Mac2, not on Kamatera — fall back to the
// in-repo showroom list (config/showroom-vendors.json) so the fleet boots anywhere.
let isShowroomVendor;
try {
  ({ isShowroomVendor } = require(path.join(process.env.HOME, 'Projects/fix-live-board/config/showroom-vendor.cjs')));
} catch (e) {
  let _showroom = [];
  try { _showroom = JSON.parse(fs.readFileSync(path.join(__dirname, 'config', 'showroom-vendors.json'), 'utf8')); } catch (_) {}
  const _norm = s => String(s == null ? '' : s).trim().toLowerCase();
  const _set = new Set(_showroom.map(_norm));
  isShowroomVendor = v => _set.has(_norm(v));
  console.log(`[${SITE}] showroom-vendor: using in-repo fallback (${_set.size} vendors)`);
}

// Monetize (parked/for-sale content) sites are standalone — they never serve the
// DW catalog/funnel, so skip the entire catalog + hero + image-proxy build for
// them. This saves ~one catalog's worth of RAM per monetize process (the whole
// point once the fleet runs 40+ procs on one box). Funnel sites build as before.
const MONETIZE = cfg.monetize === true || cfg.adsense === true;
let POOL = [], HERO_IMGS = [], FEATURED = [], PATTERNS = [];
const IMG_MAP = new Map();   // token -> real CDN url (funnel /img proxy only)
function registerImg(url) { if (url) IMG_MAP.set(render.imgToken(url), url); }
if (!MONETIZE) {
  // ---- 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
  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.
  const EXTRAS = catalog.siteExtras(SITE);
  if (EXTRAS.length) { POOL.unshift(...EXTRAS); console.log(`[${SITE}] + ${EXTRAS.length} site extras`); }
  // Defense in depth (TK-11186): showroom-only vendors must never appear in any grid/hero.
  const _preShowroom = POOL.length;
  POOL.splice(0, POOL.length, ...POOL.filter(p => !isShowroomVendor(p.vendor)));
  if (POOL.length !== _preShowroom) console.log(`[${SITE}] - ${_preShowroom - POOL.length} showroom-only products suppressed`);
  console.log(`[${SITE}] niche pool: ${NICHE.length} (serving ${POOL.length})`);
  // Hero images from the precomputed no-reuse allocation manifest; niche fallback.
  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`); }
  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);
  FEATURED = sortProducts(POOL, 'newest').slice(0, 20);
  // vendor-neutral image proxy map (funnel /img route)
  for (const p of POOL) registerImg(p.image_url);
  for (const u of HERO_IMGS) registerImg(u);
  if (cfg.heroImage) registerImg(cfg.heroImage);   // og:image proxy resolves (no vendor leak)
  console.log(`[${SITE}] image-proxy map: ${IMG_MAP.size} urls`);
} else {
  // Monetize sites skip the catalog, but still show a 20-pattern grid on the home
  // from the precomputed data/pattern-grid.json (name + img). Register each image
  // for the /img proxy so the grid + hero are vendor-neutral (TK-11322).
  let GRID = {};
  try { GRID = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pattern-grid.json'), 'utf8')); }
  catch (e) { console.warn(`[${SITE}] pattern-grid.json missing — home grid will be empty`); }
  PATTERNS = Array.isArray(GRID[SITE]) ? GRID[SITE] : [];
  for (const t of PATTERNS) registerImg(t.img);
  if (cfg.heroImage) registerImg(cfg.heroImage);   // proxy the hero bg too (no vendor leak)
  console.log(`[${SITE}] monetize mode — ${PATTERNS.length} grid patterns, image-proxy map: ${IMG_MAP.size}`);
}

// 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');
// AdSense Auto Ads pull scripts, frames, images and beacons from Google's ad
// hosts — a monetize site must widen the CSP for those or every ad is blocked
// (and AdSense review fails). Non-monetize funnel sites keep the tight CSP.
const AD_SCRIPT = MONETIZE ? [
  'https://pagead2.googlesyndication.com', 'https://*.googlesyndication.com',
  'https://partner.googleadservices.com', 'https://tpc.googlesyndication.com',
  'https://www.google.com', 'https://adservice.google.com'
] : [];
const AD_FRAME = MONETIZE ? [
  'https://googleads.g.doubleclick.net', 'https://tpc.googlesyndication.com',
  'https://www.google.com'
] : [];
const AD_CONNECT = MONETIZE ? [
  'https://pagead2.googlesyndication.com', 'https://*.googlesyndication.com',
  'https://googleads.g.doubleclick.net', 'https://*.g.doubleclick.net',
  'https://www.google.com'
] : [];
app.use(helmet({
  contentSecurityPolicy: {
    // useDefaults:false so what we READ here is exactly what we SERVE — with
    // useDefaults on, helmet silently merges its own default directives (e.g.
    // script-src-attr 'none') that appear in the response header but nowhere in
    // source. That is TK-11537: a directive nobody wrote. Every directive we
    // want MUST now be listed explicitly below. Verified byte-identical served
    // CSP header before/after (scratchpad/csp-repro.js).
    useDefaults: false,
    directives: {
      defaultSrc: ["'self'"],
      // 'unsafe-inline' required by GTM and inline theme/promo scripts.
      // www.googletagmanager.com serves gtm.js + gtag/js (GA4 + GTM).
      scriptSrc: ["'self'", "'unsafe-inline'", 'https://www.googletagmanager.com', ...AD_SCRIPT],
      // LOAD-BEARING: blocks inline event-handler attributes (onclick=…). Was
      // supplied implicitly by helmet's default; now explicit. Keep 'none' — it
      // is one of the two things defanging TK-11535's unescaped pager(); do NOT
      // relax it until pager() output is escaped (see TK-11535, TK-11537).
      scriptSrcAttr: ["'none'"],
      styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
      imgSrc: ["'self'", 'data:', 'https:'],
      fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
      connectSrc: ["'self'", 'https://www.google-analytics.com', 'https://analytics.google.com', ...AD_CONNECT],
      // GTM noscript <iframe src="https://www.googletagmanager.com/ns.html?id=...">
      frameSrc: ['https://www.googletagmanager.com', ...AD_FRAME],
      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;

// ---- MONETIZE MODE (parked / for-sale SEO domain) ----------------------------
// A cfg.monetize site is a standalone content site (no DW catalog / buy funnel):
// topical home + About + Privacy + AdSense Auto Ads + ads.txt + for-sale banner.
// We register only these routes and stop (top-level return skips the funnel
// routes below). The catalog pool above builds but is never served here.
if (MONETIZE) {
  app.get('/ads.txt', (req, res) => res.type('text/plain')
    .send('google.com, pub-5278231299883833, DIRECT, f08c47fec0942fa0\n'));
  app.get('/', (req, res) => res.type('html').send(render.monetizeHome(cfg, PATTERNS)));
  app.get('/img/:token', serveImg);   // vendor-neutral image proxy for the pattern grid + hero
  app.get('/about', (req, res) => res.type('html').send(render.monetizeAbout(cfg)));
  app.get('/privacy', (req, res) => res.type('html').send(render.privacyPage(cfg)));
  app.get('/guide', (req, res) => res.type('html').send(render.guidePage(cfg)));
  app.get('/contact', (req, res) => res.type('html').send(render.contactPage(cfg)));
  // Long-form article hub + per-article pages (content DEPTH — TK-11322).
  const ARTICLES = (cfg.content && Array.isArray(cfg.content.articles)) ? cfg.content.articles : [];
  if (ARTICLES.length) {
    app.get('/articles', (req, res) => res.type('html').send(render.articlesIndex(cfg)));
    app.get('/articles/:slug', (req, res) => {
      const key = String(req.params.slug || '').slice(0, 120);
      const a = ARTICLES.find(x => x.slug === key);
      if (!a) return res.redirect(301, '/articles');
      const others = ARTICLES.filter(x => x.slug !== key);
      res.type('html').send(render.articlePage(cfg, a, others));
    });
  }
  // robots.txt — allow full crawl, point at the sitemap (SEO + AdSense discovery)
  app.get('/robots.txt', (req, res) => res.type('text/plain')
    .send(`User-agent: *\nAllow: /\nSitemap: https://${cfg.domain}/sitemap.xml\n`));
  // sitemap.xml — the monetize site's real pages
  app.get('/sitemap.xml', (req, res) => {
    const today = new Date().toISOString().slice(0, 10);
    const artPaths = (cfg.content && Array.isArray(cfg.content.articles) && cfg.content.articles.length)
      ? ['/articles', ...cfg.content.articles.map(a => '/articles/' + a.slug)] : [];
    const paths = ['/', '/guide', '/about', '/privacy', '/contact', ...artPaths];
    const urls = paths.map(p =>
      `  <url><loc>https://${cfg.domain}${p}</loc><lastmod>${today}</lastmod></url>`).join('\n');
    res.type('application/xml').send(
      `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`);
  });
  app.get('/health', (req, res) =>
    res.json({ ok: true, site: SITE, domain: cfg.domain, mode: 'monetize' }));
  // No DW catalog on a for-sale domain — legacy funnel paths 301 home.
  app.get(['/catalog', '/info', '/product/:handle', '/buy/:slug'], (req, res) => res.redirect(301, '/'));
  app.use((req, res) => res.status(404).type('html').send(render.monetizeAbout(cfg)));
  app.listen(PORT, () => console.log(`[${SITE}] ${cfg.domain} live on :${PORT} (monetize)`));
  return;
}

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.
async function serveImg(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('/img/:token', serveImg);

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: POOL.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}`));