/** * PdfService — renders a Pelagia PO export page to a real PDF via headless * Chromium (Playwright). Mirrors GstService/EpfoService: a tiny internal-only * Express proxy the Next app calls. * * POST /pdf { url } → application/pdf * * The app builds the PO export URL (its own /api/po/:id/export?format=pdf&pdf=1 * carrying a short-lived service token) and posts it here; we navigate to it and * return the printed PDF bytes. The app then stores the PDF (R2) and emails the * vendor a download link (issue #14). * * Safety: this renders arbitrary URLs, so it is internal-only and guards against * SSRF by (a) requiring a shared token when PDF_SERVICE_TOKEN is set and * (b) only navigating to URLs whose origin matches ALLOWED_ORIGIN when set. */ import express from "express"; import { chromium, type Browser } from "playwright"; const PORT = Number(process.env.PORT ?? 3005); const NAV_TIMEOUT_MS = Number(process.env.NAV_TIMEOUT_MS ?? 30_000); const TOKEN = process.env.PDF_SERVICE_TOKEN ?? ""; const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN ?? ""; // e.g. http://localhost:3000 function log(level: string, msg: string, ctx?: Record) { const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...ctx }); (level === "ERROR" || level === "WARN" ? process.stderr : process.stdout).write(line + "\n"); } // ── Browser (lazy singleton) ──────────────────────────────────────────────── let _browser: Browser | null = null; async function getBrowser(): Promise { if (_browser?.isConnected()) return _browser; _browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] }); _browser.on("disconnected", () => { _browser = null; }); return _browser; } function originOf(url: string): string | null { try { return new URL(url).origin; } catch { return null; } } const app = express(); app.use(express.json({ limit: "1mb" })); app.get("/health", (_req, res) => { res.json({ status: "ok", browser: _browser?.isConnected() ? "up" : "idle" }); }); app.post("/pdf", async (req, res) => { const started = Date.now(); const { url } = (req.body ?? {}) as { url?: string }; if (TOKEN && req.header("x-pdf-token") !== TOKEN) { return res.status(401).json({ error: "Unauthorized" }); } const origin = url ? originOf(url) : null; if (!url || !origin) return res.status(400).json({ error: "A valid url is required" }); if (ALLOWED_ORIGIN && origin !== ALLOWED_ORIGIN) { return res.status(403).json({ error: "URL origin not allowed" }); } let context; try { const browser = await getBrowser(); context = await browser.newContext(); const page = await context.newPage(); await page.goto(url, { waitUntil: "networkidle", timeout: NAV_TIMEOUT_MS }); const pdf = await page.pdf({ format: "A4", printBackground: true, preferCSSPageSize: true }); res.setHeader("Content-Type", "application/pdf"); res.send(pdf); log("INFO", "Rendered PDF", { origin, ms: Date.now() - started, bytes: pdf.length }); } catch (e) { log("ERROR", "POST /pdf failed", { err: String(e) }); res.status(502).json({ error: `PDF render failed: ${String(e)}` }); } finally { await context?.close().catch(() => {}); } }); app.listen(PORT, () => log("INFO", "PdfService listening", { port: PORT, allowedOrigin: ALLOWED_ORIGIN || "(any)" }));