← back to 4square Agentabrams
server.js
351 lines
// 4square.agentabrams.com — 2x2 design-showroom layout tool.
// Drag tiles from the rail into one of 4 squares; layout persists in localStorage.
// Image source is The Set Decorator catalog (proxied so no CORS).
const express = require('express');
const path = require('path');
const http = require('http');
const https = require('https');
const PORT = parseInt(process.env.PORT || '9702', 10);
const AUTH_USER = process.env.BASIC_AUTH_USER || 'admin';
const AUTH_PASS = process.env.BASIC_AUTH_PASS || 'DWShowroom2026';
const UPSTREAM = (process.env.CATALOG_UPSTREAM || 'https://thesetdecorator.com').replace(/\/$/, '');
const app = express();
// ── /health (NOT auth-gated, used by deploy.sh smoke test) ────────────────
app.get('/health', (_req, res) => res.json({ ok: true, ts: new Date().toISOString() }));
// ── Basic Auth gate (everything below this) ───────────────────────────────
app.use((req, res, next) => {
const hdr = req.headers.authorization || '';
if (!hdr.startsWith('Basic ')) {
res.set('WWW-Authenticate', 'Basic realm="4Square Agent Abrams"');
return res.status(401).send('Authentication required');
}
let decoded;
try { decoded = Buffer.from(hdr.slice(6), 'base64').toString('utf8'); }
catch { return res.status(400).send('Bad auth header'); }
const idx = decoded.indexOf(':');
if (idx < 0) return res.status(400).send('Bad auth header');
const u = decoded.slice(0, idx);
const p = decoded.slice(idx + 1);
// Constant-time compare via crypto.timingSafeEqual when lengths match.
const crypto = require('crypto');
const uOK = u.length === AUTH_USER.length && crypto.timingSafeEqual(Buffer.from(u), Buffer.from(AUTH_USER));
const pOK = p.length === AUTH_PASS.length && crypto.timingSafeEqual(Buffer.from(p), Buffer.from(AUTH_PASS));
if (uOK && pOK) return next();
res.set('WWW-Authenticate', 'Basic realm="4Square Agent Abrams"');
return res.status(401).send('Invalid credentials');
});
// ── Catalog proxy → TSD ───────────────────────────────────────────────────
// Frontend calls /api/catalog/search?... → we forward to thesetdecorator.com.
// We also proxy /img/?path=<upstream-path> for image bytes so the browser
// loads everything from this domain (no third-party-cookie / CORS surprises).
function proxyJson(req, res, upstreamPath) {
const url = new URL(UPSTREAM + upstreamPath);
const lib = url.protocol === 'https:' ? https : http;
const upstream = lib.request({
method: 'GET',
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
headers: { 'User-Agent': '4square-agentabrams/0.1', 'Accept': 'application/json' },
}, (r) => {
res.status(r.statusCode || 502);
// Pass content-type through; default to JSON.
res.set('Content-Type', r.headers['content-type'] || 'application/json');
r.pipe(res);
});
upstream.on('error', (e) => res.status(502).json({ ok: false, error: 'upstream ' + e.message }));
upstream.end();
}
app.get('/api/catalog/search', (req, res) => {
const q = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
proxyJson(req, res, '/api/catalog/search' + q);
});
app.get('/api/catalog/artists', (req, res) => {
proxyJson(req, res, '/api/catalog/artists');
});
// ── Generic site-products proxy: any DW microsite wired in sites.json ─────
// /api/site-products?site=<domain>&q=<query>&limit=<n>
// site MUST appear in public/data/sites.json with a source.endpoint and a
// wallcovering/dw-shape kind. Validates against the allowlist baked at boot.
const fs = require('fs');
const sitesPath = path.join(__dirname, 'public', 'data', 'sites.json');
let wiredSites = {};
function loadWiredSites() {
try {
const data = JSON.parse(fs.readFileSync(sitesPath, 'utf8'));
wiredSites = {};
for (const s of (data.sites || [])) {
if (s.source && s.source.endpoint) {
wiredSites[s.domain] = s.source;
}
}
console.log('[4square] loaded wired sites:', Object.keys(wiredSites).length);
} catch (e) {
console.error('[4square] failed to load sites.json:', e.message);
}
}
loadWiredSites();
fs.watchFile(sitesPath, { interval: 5000 }, loadWiredSites);
app.get('/api/site-products', (req, res) => {
const site = String(req.query.site || '');
const cfg = wiredSites[site];
if (!cfg) return res.status(400).json({ ok: false, error: 'site not in wired allowlist: ' + site });
// TSD goes through its own proxy (different shape).
if (cfg.kind === 'tsd-catalog') {
const params = new URLSearchParams();
if (req.query.q) params.set('q', req.query.q);
if (req.query.limit) params.set('limit', req.query.limit);
return proxyJson(req, res, cfg.endpoint + '?' + params.toString());
}
// DW microsite: hit https://<site>/api/products?q=&limit=
const params = new URLSearchParams();
if (req.query.q) params.set('q', req.query.q);
params.set('limit', req.query.limit || '120');
const url = new URL('https://' + site + cfg.endpoint + '?' + params.toString());
const lib = url.protocol === 'https:' ? https : http;
const upstream = lib.request({
method: 'GET',
hostname: url.hostname,
port: 443,
path: url.pathname + url.search,
headers: { 'User-Agent': '4square-agentabrams/0.1', 'Accept': 'application/json' },
}, (r) => {
res.status(r.statusCode || 502);
res.set('Content-Type', r.headers['content-type'] || 'application/json');
r.pipe(res);
});
upstream.on('error', (e) => res.status(502).json({ ok: false, error: 'upstream ' + e.message }));
upstream.end();
});
// Image bytes — explicit path param so we don't accidentally proxy arbitrary URLs.
app.get('/img', (req, res) => {
const p = String(req.query.path || '');
// Allow ONLY upstream-relative paths under /api/art-archive/photos/<digit>/image/<size>.
if (!/^\/api\/art-archive\/photos\/\d+\/image\/(thumb|medium|preview)$/.test(p)) {
return res.status(400).send('bad path');
}
const url = new URL(UPSTREAM + p);
const lib = url.protocol === 'https:' ? https : http;
const upstream = lib.request({
method: 'GET',
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
headers: { 'User-Agent': '4square-agentabrams/0.1' },
}, (r) => {
res.status(r.statusCode || 502);
if (r.headers['content-type']) res.set('Content-Type', r.headers['content-type']);
res.set('Cache-Control', 'public, max-age=86400');
r.pipe(res);
});
upstream.on('error', (e) => res.status(502).send('upstream ' + e.message));
upstream.end();
});
// ── Hero 4-grid read/write for a wired site ───────────────────────────────
// Domain → on-disk path. Prefers /var/www/<domain>/public/ (Kamatera prod),
// falls back to ~/Projects/<basename>/public/ for Mac2 local dev.
function heroGridPath(domain) {
const safe = String(domain || '').replace(/[^a-z0-9.-]/gi, '');
if (!/^[a-z0-9-]+\.[a-z.]+$/i.test(safe)) return null;
const prod = `/var/www/${safe}/public/hero-4grid.json`;
if (fs.existsSync(`/var/www/${safe}`)) return prod;
const proj = path.join(process.env.HOME || '/root', 'Projects', safe.replace(/\.com$/, ''), 'public', 'hero-4grid.json');
return proj;
}
function defaultGridFromProducts(products) {
const items = (products || []).filter(p => p.image_url && /^https/.test(p.image_url));
const cells = [0,1,2,3].map(i => {
const p = items[i];
return {
kind: i % 2 === 0 ? 'room' : 'pattern',
sku: p?.sku || null,
image_url: p?.image_url || null,
title: p?.title || null,
};
});
const rotation_pool = items.slice(0, 16).map(p => ({ sku: p.sku, image_url: p.image_url, title: p.title }));
return { cells, rotation_pool, autocycle_seconds: 6, updated_at: null };
}
app.get('/api/site/:domain/hero-grid', async (req, res) => {
const domain = req.params.domain;
const cfg = wiredSites[domain];
if (!cfg) return res.status(404).json({ error: 'not wired: ' + domain });
const gp = heroGridPath(domain);
// Try to read existing grid
try {
const buf = fs.readFileSync(gp, 'utf8');
return res.json({ ok: true, grid: JSON.parse(buf), path: gp });
} catch (e) {
// No grid yet — synthesize from /api/products
try {
const url = new URL('https://' + domain + cfg.endpoint + '?limit=120');
const lib = url.protocol === 'https:' ? https : http;
const upstream = lib.request({
method: 'GET', hostname: url.hostname, port: 443,
path: url.pathname + url.search,
headers: { 'User-Agent': '4square/0.2', 'Accept': 'application/json' },
}, (r) => {
let raw = '';
r.on('data', (c) => raw += c);
r.on('end', () => {
let products = [];
try {
const parsed = JSON.parse(raw);
products = Array.isArray(parsed) ? parsed : (parsed.items || []);
} catch (e2) { /* ignore */ }
const grid = defaultGridFromProducts(products);
res.json({ ok: true, grid, path: gp, synthesized: true });
});
});
upstream.on('error', (e2) => res.status(502).json({ ok: false, error: 'upstream ' + e2.message }));
upstream.end();
} catch (e2) {
res.status(500).json({ ok: false, error: e2.message });
}
}
});
app.post('/api/site/:domain/hero-grid', express.json({ limit: '2mb' }), (req, res) => {
const domain = req.params.domain;
const cfg = wiredSites[domain];
if (!cfg) return res.status(404).json({ error: 'not wired: ' + domain });
const grid = req.body || {};
if (!grid.cells || !Array.isArray(grid.cells)) return res.status(400).json({ error: 'invalid grid (missing cells[])' });
grid.updated_at = new Date().toISOString();
const gp = heroGridPath(domain);
try {
const dir = path.dirname(gp);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(gp, JSON.stringify(grid, null, 2));
return res.json({ ok: true, path: gp, bytes: fs.statSync(gp).size });
} catch (e) {
return res.status(500).json({ ok: false, error: e.message, path: gp });
}
});
// ── /api/all-hero-grids ── discovers every hero-4grid.json on disk and
// returns one entry per site. Sources scanned (in order):
// 1. /var/www/*.com/public/hero-4grid.json (Kamatera prod layout)
// 2. /root/Projects/*/public/hero-4grid.json (the 9 dynamic DW sites)
// 3. ~/Projects/*/public/hero-4grid.json (Mac2 local dev fallback)
// This is broader than wiredSites — wiredSites tracks which sites have a
// product API for drag-publish; all_hero_grids tracks any site that has a
// 2×2 hero JSON deployed, even if drag-publish isn't wired yet.
app.get('/api/all-hero-grids', async (req, res) => {
const seen = new Map(); // domain → { domain, kind, name, grid, source }
const tossedPath = path.join(process.env.HOME || '/root', 'Projects', '_shared', 'data', 'dw-tossed-domains.json');
let tossed = new Set();
try {
const tj = JSON.parse(fs.readFileSync(tossedPath, 'utf8'));
tossed = new Set((tj.tossed || []).map(t => t.domain));
} catch (e) {
// Try the prod path
try {
const tj2 = JSON.parse(fs.readFileSync('/root/Projects/_shared/data/dw-tossed-domains.json', 'utf8'));
tossed = new Set((tj2.tossed || []).map(t => t.domain));
} catch (e2) { /* none */ }
}
// Site metadata lookup from sites.json (kind, name)
let sitesMeta = {};
try {
const sjson = JSON.parse(fs.readFileSync(sitesPath, 'utf8'));
for (const s of (sjson.sites || [])) sitesMeta[s.domain] = s;
} catch (e) { /* no metadata */ }
function tryAdd(domain, gridFile) {
if (seen.has(domain) || tossed.has(domain)) return;
try {
const buf = fs.readFileSync(gridFile, 'utf8');
const grid = JSON.parse(buf);
const meta = sitesMeta[domain] || {};
seen.set(domain, {
domain,
kind: meta.kind || (domain.endsWith('.com') ? 'wallcovering' : 'other'),
name: meta.name || domain,
grid,
source: 'file',
wired: !!(meta.source && meta.source.endpoint),
path: gridFile,
});
} catch (e) { /* skip unreadable */ }
}
// 1. /var/www/<domain>/public/hero-4grid.json
try {
const wwwRoots = fs.readdirSync('/var/www', { withFileTypes: true });
for (const e of wwwRoots) {
if (!e.isDirectory()) continue;
if (!/^[a-z0-9-]+\.[a-z.]+$/i.test(e.name)) continue;
tryAdd(e.name, '/var/www/' + e.name + '/public/hero-4grid.json');
}
} catch (e) { /* no /var/www on Mac2 */ }
// 2. /root/Projects/<site>/public/hero-4grid.json
try {
const rootProj = fs.readdirSync('/root/Projects', { withFileTypes: true });
for (const e of rootProj) {
if (!e.isDirectory()) continue;
const dom = e.name + '.com';
tryAdd(dom, '/root/Projects/' + e.name + '/public/hero-4grid.json');
}
} catch (e) { /* no /root/Projects on Mac2 */ }
// 3. ~/Projects/<site>/public/hero-4grid.json (Mac2 dev)
try {
const homeProj = path.join(process.env.HOME || '/root', 'Projects');
const localProj = fs.readdirSync(homeProj, { withFileTypes: true });
for (const e of localProj) {
if (!e.isDirectory()) continue;
const dom = e.name + '.com';
tryAdd(dom, path.join(homeProj, e.name, 'public', 'hero-4grid.json'));
}
} catch (e) { /* ignore */ }
// ALSO include any wiredSites that DON'T have a hero-4grid yet, so the
// wall shows them as "needs hero" rather than hiding them entirely.
for (const dom of Object.keys(wiredSites)) {
if (seen.has(dom) || tossed.has(dom)) continue;
const meta = sitesMeta[dom] || {};
seen.set(dom, {
domain: dom,
kind: meta.kind || 'unknown',
name: meta.name || dom,
grid: { cells: [null,null,null,null], rotation_pool: [] },
source: 'missing',
wired: true,
});
}
const out = Array.from(seen.values());
res.json({ ok: true, count: out.length, sites: out, ts: Date.now() });
});
// ── 404-guard: never serve snapshot files (.bak / .bak.* / .pre-*) ───────
// Belt-and-suspenders even if such files never land in public/ — keeps
// accidental backups from being publicly served if a deploy ever drops one.
app.use((req, res, next) => {
if (/(\.bak(\.|$)|\.pre-)/i.test(req.path)) {
return res.status(404).send('Not found');
}
next();
});
// ── Static frontend ───────────────────────────────────────────────────────
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
app.listen(PORT, () => console.log(`[4square-agentabrams] http://127.0.0.1:${PORT}`));