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