← back to Professional Directory

agents/preview-agent/server.js

82 lines

#!/usr/bin/env node
/**
 * Public preview server — pd-preview.
 *
 * The ONLY service in the pd-* fleet intended to face the public internet
 * (via Cloudflare tunnel or similar). It exposes exactly two routes:
 *
 *   GET  /preview/:id      — proxies to pd-api at http://127.0.0.1:9874/preview/:id
 *                            (numeric id only)
 *   GET  /favicon.ico      — empty 204
 *
 * Everything else → 404. No DB access, no admin, no API surface beyond the
 * single read-through. pd-api stays private on 127.0.0.1:9874 so its
 * professional/org/email APIs and admin dashboard never leave the box.
 *
 * Run:
 *   PORT=9875 node agents/preview-agent/server.js
 *   (autostarted under pm2 as pd-preview via ecosystem.config.js)
 */
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const express = require('express');
const { fetch } = require('undici');

const PORT = Number(process.env.PORT_PREVIEW || process.env.PORT || 9875);
const UPSTREAM = process.env.PD_API_BASE || 'http://127.0.0.1:9874';

const app = express();
app.disable('x-powered-by');

// Light request logging: log path + status + ms — useful when this is the
// thing actually facing the public internet.
app.use((req, res, next) => {
  const t = Date.now();
  res.on('finish', () => {
    console.log(`[pd-preview] ${req.method} ${req.path} ${res.statusCode} ${Date.now() - t}ms ${req.ip}`);
  });
  next();
});

// Cap requests to prevent ridiculous URLs / bot probing.
app.use((req, res, next) => {
  if (req.path.length > 200) return res.status(414).type('text').send('URI too long');
  next();
});

app.get('/health', (_req, res) => res.json({ ok: true, agent: 'pd-preview', upstream: UPSTREAM }));

app.get('/favicon.ico', (_req, res) => res.status(204).end());

app.get('/preview/:id', async (req, res) => {
  const id = String(req.params.id);
  if (!/^\d+$/.test(id)) return res.status(404).type('text').send('Not found');
  try {
    const upstream = await fetch(`${UPSTREAM}/preview/${id}`, {
      headers: { 'User-Agent': 'pd-preview/0.1' },
      signal: AbortSignal.timeout(8_000),
    });
    if (upstream.status === 404) return res.status(404).type('text').send('Not found');
    if (!upstream.ok)            return res.status(502).type('text').send('Upstream error');
    res.status(200);
    res.setHeader('Content-Type', upstream.headers.get('content-type') || 'text/html; charset=utf-8');
    // Serve through CDN cache for 5 minutes — these pages are deterministic
    // for a given org and Steve will rarely change underlying data per-org.
    res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=600');
    // Allow embedding (the dashboard pitch modal iframes this page).
    res.setHeader('X-Frame-Options', 'SAMEORIGIN');
    const body = await upstream.text();
    res.end(body);
  } catch (e) {
    res.status(504).type('text').send('Upstream timeout');
  }
});

// Catch-all → 404 plain.
app.use((_req, res) => res.status(404).type('text').send('Not found'));

const HOST = process.env.PD_PREVIEW_BIND || '127.0.0.1';
app.listen(PORT, HOST, () => {
  console.log(`[pd-preview] listening on http://${HOST}:${PORT}  upstream=${UPSTREAM}`);
});