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