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