← back to Dw Domain Viewer

server.js

156 lines

#!/usr/bin/env node
// dw-domain-viewer — left-panel list of every *.designerwallcoverings.com site,
// right-panel view of the live site. Pattern cloned from agentabrams-viewer (:9783).
// Zero-dependency Node http server. Basic auth admin/DW2024! (internal-viewer standard).
// The right panel loads /site/<domain>/… — a reverse proxy that injects the unified
// admin credential upstream (browsers refuse to basic-auth cross-origin iframes) and
// strips X-Frame-Options/CSP so every fleet site renders inside the panel.
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');

const PORT = process.env.PORT || 9784;
const ROOT = __dirname;
const AUTH = 'Basic ' + Buffer.from(process.env.BASIC_AUTH || 'admin:DW2024!').toString('base64');
// Credential injected into upstream fleet sites (the unified admin pw).
const UPSTREAM_AUTH = 'Basic ' + Buffer.from(process.env.UPSTREAM_AUTH || 'admin:DW2024!').toString('base64');

const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml' };

function loadDomains() {
  return JSON.parse(fs.readFileSync(path.join(ROOT, 'domains.json'), 'utf8'));
}

// Server-side health probe — the browser can't check cross-origin status itself.
// Gated sites get a second HEAD with the unified credential so a dead password
// (authFail) is distinguishable from a healthy auth gate.
function head(domain, withAuth) {
  return new Promise((resolve) => {
    const headers = withAuth ? { authorization: UPSTREAM_AUTH } : {};
    const req = https.request({ host: domain, method: 'HEAD', path: '/', headers, timeout: 6000, rejectUnauthorized: true }, (res) => {
      res.resume();
      resolve(res.statusCode);
    });
    req.on('timeout', () => { req.destroy(); resolve(0); });
    req.on('error', () => resolve(0));
    req.end();
  });
}

async function probe(domain) {
  const started = Date.now();
  const plain = await head(domain, false);
  let gated = plain === 401, authFail = false, effective = plain;
  if (gated) {
    effective = await head(domain, true);
    authFail = effective === 401;
  }
  return {
    domain,
    status: plain,
    ms: Date.now() - started,
    gated,
    authFail,
    up: effective !== 0 && effective !== 401 && effective < 500,
  };
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://x');

  if (url.pathname === '/healthz') { res.writeHead(200); return res.end('ok'); }

  if (req.headers.authorization !== AUTH) {
    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="dw-domain-viewer"' });
    return res.end('auth required');
  }

  if (url.pathname === '/api/domains') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(loadDomains()));
  }

  if (url.pathname === '/api/status') {
    const domains = loadDomains();
    // Probe in batches of 16 so 180 HEADs don't stampede.
    const out = [];
    for (let i = 0; i < domains.length; i += 16) {
      out.push(...await Promise.all(domains.slice(i, i + 16).map(probe)));
    }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(out));
  }

  // Reverse proxy: /site/<domain>/<path> → https://<domain>/<path> with the
  // unified admin credential injected. Only domains in domains.json are proxied.
  if (url.pathname.startsWith('/site/')) {
    const rest = url.pathname.slice('/site/'.length);
    const slash = rest.indexOf('/');
    const domain = slash < 0 ? rest : rest.slice(0, slash);
    if (slash < 0) { res.writeHead(302, { Location: `/site/${domain}/` }); return res.end(); }
    const domains = loadDomains();
    if (!domains.includes(domain)) { res.writeHead(404); return res.end('unknown domain'); }
    const upstreamPath = rest.slice(slash) + url.search;
    const headers = { ...req.headers, host: domain, authorization: UPSTREAM_AUTH };
    delete headers.connection;
    const preq = https.request({ host: domain, method: req.method, path: upstreamPath, headers, timeout: 20000, rejectUnauthorized: true }, (pres) => {
      const h = { ...pres.headers };
      // The whole point of the proxy: let everything render in the panel.
      delete h['x-frame-options'];
      delete h['content-security-policy'];
      delete h['strict-transport-security'];
      // The root-relative asset escape hatch below routes by Referer; an upstream
      // no-referrer policy would blind it and every /assets/* request would 404.
      delete h['referrer-policy'];
      delete h.connection;
      if (h.location) {
        try {
          const loc = new URL(h.location, `https://${domain}`);
          if (domains.includes(loc.host)) h.location = `/site/${loc.host}${loc.pathname}${loc.search}`;
        } catch {}
      }
      if (h['set-cookie']) {
        h['set-cookie'] = h['set-cookie'].map((c) => {
          // A Domain= attribute means the cookie is cross-subdomain — scope it to
          // /site/ so it follows the user across every proxied domain.
          const fleetWide = /;\s*Domain=/i.test(c);
          return c
            .replace(/;\s*Domain=[^;]+/gi, '')
            // Viewer is served over plain http on loopback; Safari drops Secure cookies there.
            .replace(/;\s*Secure/gi, '')
            .replace(/;\s*Path=\/([^;]*)/i, fleetWide ? '; Path=/site/' : `; Path=/site/${domain}/$1`);
        });
      }
      res.writeHead(pres.statusCode, h);
      pres.pipe(res);
    });
    preq.on('timeout', () => preq.destroy(new Error('upstream timeout')));
    preq.on('error', (e) => { if (res.writableEnded) return; if (!res.headersSent) res.writeHead(502); res.end('proxy error: ' + e.message); });
    req.pipe(preq);
    return;
  }

  let file = url.pathname === '/' ? '/index.html' : url.pathname;
  const fp = path.join(ROOT, 'public', path.normalize(file).replace(/^(\.\.[\/\\])+/, ''));
  if (!fp.startsWith(path.join(ROOT, 'public'))) { res.writeHead(403); return res.end(); }

  // Root-relative escape hatch: a proxied page requesting "/app.js" lands here.
  // If the request came from inside /site/<domain>/, bounce it back into the proxy.
  if (!fs.existsSync(fp)) {
    const m = (req.headers.referer || '').match(/\/site\/([^/]+)\//);
    if (m && loadDomains().includes(m[1])) {
      res.writeHead(302, { Location: `/site/${m[1]}${url.pathname}${url.search}` });
      return res.end();
    }
  }

  fs.readFile(fp, (err, data) => {
    if (err) { res.writeHead(404); return res.end('not found'); }
    res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
    res.end(data);
  });
});

server.listen(PORT, () => console.log(`dw-domain-viewer on http://127.0.0.1:${PORT}`));