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