← back to Purelymail Fleet Viewer

server.js

175 lines

#!/usr/bin/env node
/* Purelymail fleet remediation — live web viewer (zero dependencies).
 *
 *   node server.js          # http://127.0.0.1:9779
 *
 * /api/status merges:
 *   - email-deliverability-agent/output/domain-health.json  (live fleet state, updated by the DNS agent)
 *   - data/remediation-status.json                          (Fix A/B + receive-test progress)
 */
'use strict';
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');

const PORT = 9779;
const HEALTH = path.join(os.homedir(), 'Projects/email-deliverability-agent/output/domain-health.json');
const STATUS = path.join(__dirname, 'data/remediation-status.json');

const readJSON = (p, fb) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fb; } };

function buildStatus() {
  const health = readJSON(HEALTH, null);
  const remediation = readJSON(STATUS, null);
  return {
    generatedAt: new Date().toISOString(),
    healthFound: !!health,
    health: health || { totals: {}, domains: [], dualSpfDomains: [], deadDomains: [], noRoutingDomains: [] },
    remediation: remediation || { stage: 'unknown', fixA: {}, fixB: {}, receiveTest: {} },
  };
}

// --- embed-reachability check ----------------------------------------------
// Server-side probe: is the site reachable, and does it allow being framed?
// (X-Frame-Options / CSP frame-ancestors). Cached 15 min so the table fills
// fast on reload and we don't hammer 200+ sites.
const embedCache = new Map();
const EMBED_TTL = 15 * 60 * 1000;

async function embedCheck(domain) {
  const hit = embedCache.get(domain);
  if (hit && Date.now() - hit.ts < EMBED_TTL) return hit.result;
  let result;
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), 9000);
  try {
    const r = await fetch('https://' + domain + '/', {
      method: 'GET', redirect: 'follow', signal: ac.signal,
      headers: { 'User-Agent': 'Mozilla/5.0 (purelymail-fleet-viewer embed-check)' },
    });
    const xfo = (r.headers.get('x-frame-options') || '').toLowerCase();
    const csp = (r.headers.get('content-security-policy') || '').toLowerCase();
    const fa = (csp.match(/frame-ancestors([^;]*)/) || [])[1];
    const blockedByXFO = xfo.includes('deny') || xfo.includes('sameorigin');
    const blockedByCSP = fa != null && !fa.includes('*');
    const reachable = r.status > 0 && r.status < 500;
    result = {
      domain, reachable, status: r.status,
      embeddable: reachable && !blockedByXFO && !blockedByCSP,
      reason: !reachable ? ('HTTP ' + r.status)
        : blockedByXFO ? 'blocks embedding (X-Frame-Options)'
        : blockedByCSP ? 'blocks embedding (CSP frame-ancestors)' : 'ok',
    };
  } catch (e) {
    result = { domain, reachable: false, status: 0, embeddable: false,
      reason: e.name === 'AbortError' ? 'timeout / no response' : (e.cause && e.cause.code) || e.code || 'unreachable' };
  } finally { clearTimeout(timer); }
  embedCache.set(domain, { ts: Date.now(), result });
  return result;
}

// --- website screenshot service --------------------------------------------
// ~half the fleet blocks iframe embedding (X-Frame-Options / CSP). Screenshots
// always render, so the grid + table previews show every domain reliably.
// Captured via headless Chromium, disk-cached 24h, concurrency-limited to 3.
const { chromium } = require('playwright');
const SHOT_DIR = path.join(__dirname, 'data/shots');
const SHOT_TTL = 24 * 60 * 60 * 1000;
try { fs.mkdirSync(SHOT_DIR, { recursive: true }); } catch {}

let browserP = null;
const getBrowser = () => (browserP = browserP || chromium.launch({ args: ['--no-sandbox'] }));

let active = 0;
const shotQueue = [];
function runQueue() {
  while (active < 3 && shotQueue.length) {
    const job = shotQueue.shift();
    active++;
    captureShot(job.domain).then(job.resolve, () => job.resolve({ ok: false }))
      .finally(() => { active--; runQueue(); });
  }
}
const queueShot = domain => new Promise(resolve => { shotQueue.push({ domain, resolve }); runQueue(); });

async function captureShot(domain) {
  const file = path.join(SHOT_DIR, domain + '.png');
  try {
    const st = fs.statSync(file);
    if (Date.now() - st.mtimeMs < SHOT_TTL && st.size > 0) return { ok: true, file };
  } catch {}
  let page;
  try {
    page = await (await getBrowser()).newPage({ viewport: { width: 1280, height: 800 } });
    await page.goto('https://' + domain + '/', { waitUntil: 'domcontentloaded', timeout: 15000 });
    await page.waitForTimeout(1800);
    await page.screenshot({ path: file, clip: { x: 0, y: 0, width: 1280, height: 800 } });
    return { ok: true, file };
  } catch (e) {
    return { ok: false, error: e.message };
  } finally {
    if (page) await page.close().catch(() => {});
  }
}

// 404-guard: never serve editor/scratch/backup detritus (.bak, .pre-*, etc.)
// even if a stray file slips into a static-served directory. Matched against
// the URL pathname BEFORE any file lookup.
const JUNK_PATH_RE = /\.(bak(\..*)?|pre-[^/]*|orig|rej|swp|swo)$|~$/i;

const server = http.createServer((req, res) => {
  const pathname = (req.url || '').split('?')[0];
  if (JUNK_PATH_RE.test(pathname)) {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('not found');
    return;
  }
  if (req.url.startsWith('/api/shot')) {
    const domain = (new URL(req.url, 'http://x').searchParams.get('domain') || '')
      .replace(/[^a-z0-9.\-]/gi, '');
    if (!domain) { res.writeHead(400); res.end(); return; }
    queueShot(domain).then(r => {
      if (r && r.ok) {
        res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=3600' });
        fs.createReadStream(r.file).pipe(res);
      } else {
        res.writeHead(502, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: (r && r.error) || 'capture failed' }));
      }
    });
    return;
  }
  if (req.url === '/api/status') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(buildStatus()));
    return;
  }
  if (req.url.startsWith('/api/embedcheck')) {
    const domain = (new URL(req.url, 'http://x').searchParams.get('domain') || '')
      .replace(/[^a-z0-9.\-]/gi, '');
    if (!domain) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{}'); return; }
    embedCheck(domain).then(r => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(r));
    }).catch(() => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ domain, reachable: false, embeddable: false, reason: 'check failed' }));
    });
    return;
  }
  try {
    const html = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf8');
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(html);
  } catch {
    res.writeHead(404); res.end('index.html missing');
  }
});

// Bind 0.0.0.0 so the viewer is reachable over Steve's tailnet (e.g. from the
// MacBook), not just localhost on this machine. Non-sensitive status data.
server.listen(PORT, '0.0.0.0', () => {
  console.log(`purelymail fleet viewer -> http://0.0.0.0:${PORT} (tailnet: 100.65.187.120:${PORT})`);
});