feat: PDF export for ASN reports and head-to-head comparisons

- GET /api/export/pdf?asn=X&format=report|executive|technical
  → Generates multi-page PDF with cover, score ring, health checks, RPKI,
    BGP topology, recommendations — MAGATAMA CI styling (indigo #6366f1)
- GET /api/export/pdf/compare?asn1=X&asn2=Y
  → Side-by-side comparison PDF with score rings, metrics table, head-to-head
    health check grid
- 5-minute in-memory cache (X-Cache: HIT on second request)
- Lazy puppeteer browser pool (launched on first request, reused)
- Graceful fallback: returns 503 JSON if puppeteer unavailable
- ~300KB PDF, 4 pages, ~12s first render / 8ms cached
This commit is contained in:
Rene Fichtmueller 2026-04-29 23:03:28 +02:00
parent e655289719
commit 3515244d27
3 changed files with 1328 additions and 12 deletions

765
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,7 @@
"cheerio": "^1.0.0",
"node-whois": "^2.1.3",
"ollama": "^0.5.12",
"puppeteer": "^24.42.0",
"zod": "^3.24.0"
},
"devDependencies": {

574
server.js
View File

@ -3,6 +3,51 @@ const http = require("http");
const https = require("https");
const crypto = require("crypto");
// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
let _puppeteerBrowser = null;
let _puppeteerLoading = false;
async function getPuppeteerBrowser() {
if (_puppeteerBrowser) return _puppeteerBrowser;
if (_puppeteerLoading) {
// Wait for in-flight launch
await new Promise(r => setTimeout(r, 2000));
return _puppeteerBrowser;
}
_puppeteerLoading = true;
try {
const puppeteer = require("puppeteer");
_puppeteerBrowser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
});
console.log("[PDF] Puppeteer browser launched");
_puppeteerBrowser.on("disconnected", () => {
console.log("[PDF] Browser disconnected — will relaunch on next request");
_puppeteerBrowser = null;
_puppeteerLoading = false;
});
} catch (e) {
console.error("[PDF] Puppeteer unavailable:", e.message);
_puppeteerBrowser = null;
}
_puppeteerLoading = false;
return _puppeteerBrowser;
}
// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
const pdfCache = new Map();
const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
function pdfCacheGet(key) {
const e = pdfCache.get(key);
if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
return null;
}
function pdfCacheSet(key, buf) {
if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
pdfCache.set(key, { buf, ts: Date.now() });
}
// Load .env file
const envPath = "/opt/peercortex-app/.env";
try {
@ -535,6 +580,428 @@ const CITY_COORDS = {
"scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883],
};
// ============================================================
// FEATURE 2: PDF Export — template generators + puppeteer renderer
// ============================================================
/**
* Generate a health score ring SVG (inline, no external deps)
* @param {number} score 0100
* @param {string} color CSS color
*/
function scoreRingSvg(score, color) {
const r = 54, cx = 64, cy = 64;
const circ = 2 * Math.PI * r;
const fill = circ * (1 - score / 100);
const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
return `<svg viewBox="0 0 128 128" width="128" height="128" xmlns="http://www.w3.org/2000/svg">
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#e5e7eb" stroke-width="12"/>
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${clr}" stroke-width="12"
stroke-dasharray="${circ}" stroke-dashoffset="${fill}"
stroke-linecap="round" transform="rotate(-90 ${cx} ${cy})"/>
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
font-family="system-ui,sans-serif" font-size="26" font-weight="700" fill="${clr}">${score}</text>
<text x="${cx}" y="${cy + 20}" text-anchor="middle"
font-family="system-ui,sans-serif" font-size="10" fill="#6b7280">/100</text>
</svg>`;
}
/** Shared CSS for all PDF templates */
function pdfBaseCSS() {
return `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
@page { size: A4; margin: 18mm 16mm; }
.page { page-break-after: always; min-height: 247mm; padding: 0; }
.page:last-child { page-break-after: avoid; }
h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
p { margin-bottom: 0.5em; }
table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
tr:nth-child(even) td { background: #f5f3ff; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
.badge-green { background: #dcfce7; color: #166534; }
.badge-red { background: #fee2e2; color: #991b1b; }
.badge-yellow{ background: #fef9c3; color: #854d0e; }
.badge-gray { background: #f3f4f6; color: #374151; }
.badge-indigo{ background: #e0e7ff; color: #3730a3; }
.cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
.cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
.cover-logo span { color: #1e1b4b; }
.cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
.cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
.cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
.cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
.cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
.metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
.metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
.metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
.metric-card .unit { font-size: 8pt; color: #9ca3af; }
.score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
.check-list { list-style: none; padding: 0; }
.check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
.check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
.check-pass { color: #16a34a; }
.check-fail { color: #dc2626; }
.check-warn { color: #d97706; }
.section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
`;
}
/**
* Build the PDF HTML for a single ASN.
* @param {object} data Full /api/validate response
* @param {object} aspa /api/aspa response (may be null)
* @param {string} format 'report' | 'executive' | 'technical'
*/
function buildPdfHtml(data, aspa, format) {
const asn = data.asn || '?';
const name = data.name || 'Unknown Network';
const score = Math.round(data.health_score || 0);
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
const checks = (data.score_breakdown || []);
const rel = data.relationships || {};
const counts = rel.counts || {};
// validations is an object keyed by check name; rpki details live in rpki_completeness
const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
const rpkiDetails = (validations.rpki_completeness?.details || []);
const totalPfx = data.meta?.total_prefixes || 0;
// Derived stats — use rpki_completeness for RPKI metrics
const rpki = validations.rpki_completeness || {};
const rpkiCoverage = rpki.coverage_pct ?? 0;
const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not-found' || v.status === 'unknown').length;
const passedChecks = checks.filter(c => c.status === 'pass').length;
const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
const aspaDirect = aspa?.direct?.has_aspa ?? null;
const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
// Check list HTML
function checkIcon(passed) {
if (passed) return '<svg class="check-icon check-pass" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
return '<svg class="check-icon check-fail" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
}
const checksHtml = checks.map(c => {
const isPassed = c.status === 'pass';
const isInfo = c.status === 'info';
const icon = checkIcon(isPassed || isInfo);
const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
return `
<li>
${icon}
<div>
<span style="font-weight:600">${escHtml(c.check || '')}</span>
<span class="badge ${statusBadge}" style="margin-left:6px">${statusLabel}</span>
${c.earned !== undefined ? `<span class="badge badge-indigo" style="margin-left:4px">${c.earned}/${c.weight}pt</span>` : ''}
</div>
</li>`;
}).join('');
// Upstream providers table
const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
<tr><td>AS${u.asn}</td><td>${escHtml(u.name || '')}</td><td>${u.power ?? '-'}</td></tr>
`).join('');
// Prefix validations table (sample from rpki_completeness.details)
const pfxRows = rpkiDetails.slice(0, 20).map(v => {
const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
return `<tr><td>${escHtml(v.prefix)}</td>
<td><span class="badge ${badge}">${v.status}</span></td>
<td>${v.validating_roas ?? '-'}</td>
<td></td></tr>`;
}).join('');
// Recommendations
const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
const recHtml = failedItems.length === 0
? '<p style="color:#16a34a;font-weight:500">✓ No critical issues found. Network health is excellent.</p>'
: failedItems.map((c, i) => `
<div style="margin:8px 0; padding:10px 14px; background:#fef2f2; border-left:3px solid #ef4444; border-radius:4px;">
<div style="font-weight:600;color:#991b1b">${i+1}. ${escHtml(c.check || '')}</div>
${c.detail ? `<div style="font-size:9pt;color:#6b7280;margin-top:3px">${escHtml(c.detail)}</div>` : ''}
</div>`).join('');
// ── Executive pages (always present) ───────────────────────────────────────
const coverPage = `
<div class="page cover">
<div class="cover-stripe"></div>
<div class="cover-logo"> PeerCortex <span>Network Intelligence</span></div>
<div class="cover-asn">AS${asn}</div>
<div class="cover-name">${escHtml(name)}</div>
<div class="cover-sub">${formatLabel(format)} Report</div>
<div style="margin:20px 0; display:flex; align-items:center; gap:16px;">
${scoreRingSvg(score)}
<div>
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Health Score</div>
<div style="font-size:26pt;font-weight:700;color:${scoreColor}">${score}/100</div>
<div style="font-size:9pt;color:#6b7280">${passedChecks} passed · ${failedChecks} failed</div>
</div>
</div>
<div class="cover-meta">
Generated by PeerCortex · ${ts} · peercortex.org
&nbsp;&nbsp;|&nbsp;&nbsp; ${totalPfx} prefixes analysed
&nbsp;&nbsp;|&nbsp;&nbsp; RPKI coverage ${rpkiCoverage}%
</div>
</div>`;
const summaryPage = `
<div class="page">
<h2>Executive Summary</h2>
<p class="section-intro">Key performance indicators for AS${asn} (${escHtml(name)}).</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">Health Score</div><div class="value" style="color:${scoreColor}">${score}</div><div class="unit">out of 100</div></div>
<div class="metric-card"><div class="label">RPKI Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid}/${rpkiTotal} prefixes</div></div>
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">providers</div></div>
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP sessions</div></div>
<div class="metric-card"><div class="label">ASPA Status</div>
<div class="value" style="font-size:12pt;margin-top:4px">
<span class="badge ${aspaDirect === true ? 'badge-green' : aspaDirect === false ? 'badge-red' : 'badge-gray'}">
${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
</span>
</div>
<div class="unit">${aspaProviders} providers</div>
</div>
</div>
<h3>Status Overview</h3>
<ul class="check-list">${checksHtml}</ul>
</div>`;
// ── Recommendations page ────────────────────────────────────────────────────
const recsPage = `
<div class="page">
<h2>Recommendations</h2>
<p class="section-intro">Action items to improve network health and security posture.</p>
${recHtml}
${failedItems.length > 0 ? `
<h3 style="margin-top:1.5em">Remediation Priority</h3>
<table>
<tr><th>#</th><th>Check</th><th>Impact</th><th>Effort</th></tr>
${failedItems.map((c, i) => `
<tr>
<td>${i+1}</td>
<td>${escHtml(c.check || '')}</td>
<td><span class="badge badge-red">High</span></td>
<td><span class="badge badge-yellow">Medium</span></td>
</tr>`).join('')}
</table>` : ''}
</div>`;
// ── Detail pages (included for 'report' and 'technical') ───────────────────
const rpkiPage = `
<div class="page">
<h2>RPKI Compliance</h2>
<p class="section-intro">Route Origin Authorization (ROA) validation results for announced prefixes.</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">Coverage</div><div class="value">${rpkiCoverage}%</div><div class="unit">${rpkiValid} valid ROAs</div></div>
<div class="metric-card"><div class="label">Invalid</div><div class="value" style="color:#dc2626">${rpkiInvalid}</div><div class="unit">prefix${rpkiInvalid !== 1 ? 'es' : ''}</div></div>
<div class="metric-card"><div class="label">Not Found</div><div class="value" style="color:#d97706">${rpkiNotFound}</div><div class="unit">no ROA</div></div>
</div>
<h3>ASPA (Autonomous System Provider Authorization)</h3>
<table>
<tr><th>Attribute</th><th>Value</th></tr>
<tr><td>ASPA Object Present</td><td><span class="badge ${aspaDirect ? 'badge-green' : 'badge-red'}">${aspaDirect ? 'Yes' : 'No'}</span></td></tr>
<tr><td>Provider Count</td><td>${aspaProviders}</td></tr>
<tr><td>Providers</td><td>${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || ''}</td></tr>
</table>
${pfxRows ? `
<h3>Prefix Validation Sample (first 20)</h3>
<table>
<tr><th>Prefix</th><th>Status</th><th>Max Length</th><th>ROA Origin</th></tr>
${pfxRows}
</table>` : ''}
</div>`;
const bgpPage = `
<div class="page">
<h2>BGP Topology</h2>
<p class="section-intro">AS relationship overview and upstream connectivity analysis.</p>
<div class="metric-grid">
<div class="metric-card"><div class="label">Upstreams</div><div class="value">${counts.upstreams || 0}</div><div class="unit">transit providers</div></div>
<div class="metric-card"><div class="label">Downstreams</div><div class="value">${counts.downstreams || 0}</div><div class="unit">customers</div></div>
<div class="metric-card"><div class="label">Peers</div><div class="value">${counts.peers || 0}</div><div class="unit">BGP peers</div></div>
</div>
${upstreamRows ? `
<h3>Top Upstream Providers</h3>
<table>
<tr><th>ASN</th><th>Name</th><th>Power Score</th></tr>
${upstreamRows}
</table>` : '<p style="color:#9ca3af">No upstream data available.</p>'}
</div>`;
const footer = `
<footer>
<span>PeerCortex · AS${asn} · ${escHtml(name)}</span>
<span>Generated ${ts} · peercortex.org</span>
</footer>`;
// ── Compose pages by format ────────────────────────────────────────────────
let bodyContent;
if (format === 'executive') {
bodyContent = coverPage + summaryPage + recsPage;
} else if (format === 'technical') {
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + recsPage;
} else {
// 'report' — full
bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + recsPage;
}
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>PeerCortex AS${asn} ${formatLabel(format)}</title>
<style>${pdfBaseCSS()}</style>
</head>
<body>
${bodyContent}
${footer}
</body>
</html>`;
}
/**
* Build comparison PDF HTML for two ASNs.
*/
function buildComparisonPdfHtml(d1, aspa1, d2, aspa2) {
const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
function col(d, aspa) {
const asn = d.asn || '?';
const name = d.name || 'Unknown';
const score = Math.round(d.health_score || 0);
const rel = d.relationships || {};
const counts= rel.counts || {};
const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
const rpki = vals.rpki_completeness || {};
const rpkiV = rpki.with_roa || 0;
const rpkiT = rpki.total_checked || 0;
const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
const aspaDep = aspa?.direct?.has_aspa ?? null;
const checks= (d.score_breakdown || []);
const passC = checks.filter(c => c.status === 'pass').length;
const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
return `
<td style="width:50%;padding:0 10px;vertical-align:top;border-right:2px solid #e0e7ff;">
<div style="text-align:center;padding:16px 0 12px;">
${scoreRingSvg(score, sc)}
<div style="font-size:10pt;color:#6b7280;margin-top:4px">AS${asn}</div>
<div style="font-size:13pt;font-weight:700;color:#1e1b4b">${escHtml(name)}</div>
</div>
<table>
<tr><th colspan="2" style="background:#4f46e5">Key Metrics</th></tr>
<tr><td>Health Score</td><td style="font-weight:700;color:${sc}">${score}/100</td></tr>
<tr><td>RPKI Coverage</td><td>${rpkiC}% (${rpkiV}/${rpkiT})</td></tr>
<tr><td>Upstreams</td><td>${counts.upstreams || 0}</td></tr>
<tr><td>Downstreams</td><td>${counts.downstreams || 0}</td></tr>
<tr><td>Peers</td><td>${counts.peers || 0}</td></tr>
<tr><td>ASPA</td><td><span class="badge ${aspaDep ? 'badge-green' : 'badge-red'}">${aspaDep ? 'Deployed' : 'Not Deployed'}</span></td></tr>
<tr><td>Checks Passed</td><td style="color:#16a34a;font-weight:600">${passC} / ${checks.length}</td></tr>
<tr><td>Checks Failed</td><td style="color:#dc2626;font-weight:600">${failC}</td></tr>
<tr><td>Total Prefixes</td><td>${d.meta?.total_prefixes || 0}</td></tr>
</table>
</td>`;
}
const colHtml = col(d1, aspa1) + col(d2, aspa2);
// Side-by-side check comparison
const allChecks = [...new Set([
...(d1.score_breakdown || []).map(c => c.check),
...(d2.score_breakdown || []).map(c => c.check),
])];
const checkRows = allChecks.map(checkName => {
const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
const isP1 = c1?.status === 'pass';
const isP2 = c2?.status === 'pass';
const icon = p => p ? '✓' : '✗';
const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
return `<tr>
<td>${escHtml(checkName)}</td>
<td style="${st(isP1)}">${c1 ? icon(isP1) : '—'}</td>
<td style="${st(isP2)}">${c2 ? icon(isP2) : '—'}</td>
</tr>`;
}).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>PeerCortex AS${d1.asn} vs AS${d2.asn} Comparison</title>
<style>${pdfBaseCSS()}</style>
</head>
<body>
<div class="page cover">
<div class="cover-stripe"></div>
<div class="cover-logo"> PeerCortex <span>Network Intelligence</span></div>
<div style="margin-top:40px">
<div style="font-size:9pt;color:#6b7280;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">ASN Comparison Report</div>
<div style="font-size:24pt;font-weight:700;color:#1e1b4b">AS${d1.asn} vs AS${d2.asn}</div>
<div style="font-size:13pt;color:#4f46e5;margin-top:4px">${escHtml(d1.name)} vs ${escHtml(d2.name)}</div>
</div>
<table style="margin-top:40px"><tr>${colHtml}</tr></table>
<div class="cover-meta" style="margin-top:auto;padding-top:16px">Generated by PeerCortex · ${ts} · peercortex.org</div>
</div>
<div class="page">
<h2>Head-to-Head Health Checks</h2>
<table>
<tr><th>Check</th><th>AS${d1.asn}</th><th>AS${d2.asn}</th></tr>
${checkRows}
</table>
</div>
<footer>
<span>PeerCortex · Comparison Report</span>
<span>Generated ${ts} · peercortex.org</span>
</footer>
</body>
</html>`;
}
function formatLabel(fmt) {
return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
}
function escHtml(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/**
* Render HTML to PDF buffer using puppeteer.
* Returns Buffer or throws.
*/
async function renderHtmlToPdf(html) {
const browser = await getPuppeteerBrowser();
if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
const page = await browser.newPage();
try {
await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
const buf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
});
return buf;
} finally {
await page.close();
}
}
// ============================================================
// Task 6: In-memory cache with TTL + Rate Limiting
// ============================================================
@ -5460,11 +5927,116 @@ ${html}
}
}
// ============================================================
// FEATURE 2: PDF Export
// GET /api/export/pdf?asn=X&format=report|executive|technical
// GET /api/export/pdf/compare?asn1=X&asn2=Y
// ============================================================
// Helper: fetch JSON from internal endpoint
async function fetchInternal(path) {
return new Promise((resolve, reject) => {
const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' };
const req2 = http.request(opts, res2 => {
let body = '';
res2.on('data', c => body += c);
res2.on('end', () => {
try { resolve(JSON.parse(body)); }
catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); }
});
});
req2.on('error', reject);
req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); });
req2.end();
});
}
if (reqPath === '/api/export/pdf' && req.method === 'GET') {
const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format'))
? url.searchParams.get('format') : 'report';
if (!rawAsn) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'Missing asn parameter' }));
}
const cacheKey = `pdf:${rawAsn}:${format}`;
const cached = pdfCacheGet(cacheKey);
if (cached) {
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
return res.end(cached);
}
try {
const [validateData, aspaData] = await Promise.all([
fetchInternal(`/api/validate?asn=${rawAsn}`),
fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null),
]);
if (validateData.error) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: validateData.error }));
}
const html = buildPdfHtml(validateData, aspaData, format);
const pdfBuf = await renderHtmlToPdf(html);
pdfCacheSet(cacheKey, pdfBuf);
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length });
return res.end(pdfBuf);
} catch (err) {
console.error('[PDF] Error:', err.message);
res.writeHead(503, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
}
}
if (reqPath === '/api/export/pdf/compare' && req.method === 'GET') {
const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, '');
const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, '');
if (!asn1 || !asn2) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' }));
}
if (asn1 === asn2) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' }));
}
const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`;
const cached = pdfCacheGet(cacheKey);
if (cached) {
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
return res.end(cached);
}
try {
const [v1, a1, v2, a2] = await Promise.all([
fetchInternal(`/api/validate?asn=${asn1}`),
fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
fetchInternal(`/api/validate?asn=${asn2}`),
fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
]);
if (v1.error || v2.error) {
res.writeHead(400, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: v1.error || v2.error }));
}
const html = buildComparisonPdfHtml(v1, a1, v2, a2);
const pdfBuf = await renderHtmlToPdf(html);
pdfCacheSet(cacheKey, pdfBuf);
res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });
return res.end(pdfBuf);
} catch (err) {
console.error('[PDF] Error:', err.message);
res.writeHead(503, {'Content-Type':'application/json'});
return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
}
}
// CORS preflight for PDF export (already handled globally above, belt-and-suspenders)
if (reqPath.startsWith('/api/export/') && req.method === 'OPTIONS') {
res.writeHead(204);
return res.end();
}
// 404
res.writeHead(404);
res.end(
JSON.stringify({
error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X",
error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y",
})
);
});