const express = require('express'); const fs = require('fs'); const path = require('path'); const dns = require('dns').promises; const net = require('net'); const crypto = require('crypto'); const Handlebars = require('handlebars'); const { chromium } = require('playwright'); const app = express(); app.use(express.json({ limit: '2mb' })); const API_KEY = process.env.API_KEY; const RAPIDAPI_PROXY_SECRET = process.env.RAPIDAPI_PROXY_SECRET; let browser; // Built-in templates: every .html in src/templates is registered by filename. const TEMPLATES_DIR = path.join(__dirname, 'templates'); const TEMPLATES = {}; if (fs.existsSync(TEMPLATES_DIR)) { for (const f of fs.readdirSync(TEMPLATES_DIR)) { if (f.endsWith('.html')) TEMPLATES[path.basename(f, '.html')] = fs.readFileSync(path.join(TEMPLATES_DIR, f), 'utf8'); } } // --- Auth ------------------------------------------------------------- function safeEqual(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false; const ab = Buffer.from(a); const bb = Buffer.from(b); return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); } function authorized(req) { // No auth configured => allow (local/dev only). if (!API_KEY && !RAPIDAPI_PROXY_SECRET) return true; if (RAPIDAPI_PROXY_SECRET && safeEqual(req.get('x-rapidapi-proxy-secret'), RAPIDAPI_PROXY_SECRET)) return true; if (API_KEY && safeEqual(req.get('x-api-key'), API_KEY)) return true; return false; } // --- SSRF guard ------------------------------------------------------- function isPrivateIp(ip) { const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip; if (net.isIPv4(v4)) { const [a, b] = v4.split('.').map(Number); return ( a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || // link-local + cloud metadata (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) // CGNAT ); } const v6 = ip.toLowerCase(); return v6 === '::1' || v6 === '::' || v6.startsWith('fe80') || v6.startsWith('fc') || v6.startsWith('fd'); } async function hostIsPrivate(hostname) { if (net.isIP(hostname)) return isPrivateIp(hostname); const records = await dns.lookup(hostname, { all: true }); return records.some((r) => isPrivateIp(r.address)); } // Validate EVERY request the page makes (initial nav, redirects, sub-resources). // NOTE: a determined attacker could still attempt DNS rebinding between this // check and the actual fetch; pin the resolved IP if you need more hardening. async function installSsrfGuard(page) { await page.route('**/*', async (route) => { let u; try { u = new URL(route.request().url()); } catch { return route.abort(); } if (u.protocol === 'data:' || u.protocol === 'blob:' || u.protocol === 'about:') return route.continue(); if (u.protocol !== 'http:' && u.protocol !== 'https:') return route.abort(); try { return (await hostIsPrivate(u.hostname)) ? route.abort() : route.continue(); } catch { return route.abort(); } }); } // --- Routes ----------------------------------------------------------- // Health check is public (no auth) so platforms can probe it. app.get('/health', (_req, res) => res.json({ status: 'ok' })); // Public landing page (info only). app.get('/', (_req, res) => res.type('html').send( `
HTML/URL to PDF & screenshot API, with a built-in invoice template.
GET /health — status (public)POST /pdf — HTML / URL / template → PDFPOST /screenshot — HTML / URL → PNG or JPEGProtected endpoints require an API key. Available on RapidAPI.
` ) ); app.use((req, res, next) => (authorized(req) ? next() : res.status(401).json({ error: 'Unauthorized' }))); // Shared: validate input, SSRF pre-check, open page, navigate, then `produce` output. async function render(req, res, produce) { let { html, url, template, templateName, data } = req.body || {}; if (templateName) { if (!TEMPLATES[templateName]) { return res.status(400).json({ error: 'Unknown templateName. Available: ' + (Object.keys(TEMPLATES).join(', ') || 'none') }); } template = TEMPLATES[templateName]; } if (template) { try { html = Handlebars.compile(String(template))(data || {}); } catch (e) { return res.status(400).json({ error: 'Template error: ' + e.message }); } } if (!html && !url) return res.status(400).json({ error: 'Provide "html", "url", "template", or "templateName".' }); // Fast pre-check for a clean error before launching a page. if (url) { try { const u = new URL(url); if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Only http/https URLs are allowed.'); if (await hostIsPrivate(u.hostname)) throw new Error('Target address is not allowed.'); } catch (e) { return res.status(400).json({ error: e.message }); } } const page = await browser.newPage(); try { await installSsrfGuard(page); await (url ? page.goto(url, { waitUntil: 'networkidle' }) : page.setContent(html, { waitUntil: 'networkidle' })); await produce(page); } catch (err) { res.status(500).json({ error: 'Render failed', detail: err.message }); } finally { await page.close(); } } // POST /pdf body: { html?, url?, format?, landscape? } -> application/pdf app.post('/pdf', (req, res) => render(req, res, async (page) => { const { format = 'A4', landscape = false } = req.body; const pdf = await page.pdf({ format, landscape, printBackground: true }); res.type('application/pdf').send(pdf); })); // POST /screenshot body: { html?, url?, fullPage?, type?, width?, height? } -> image/png|jpeg app.post('/screenshot', (req, res) => render(req, res, async (page) => { const { fullPage = false, type, width, height } = req.body; if (width && height) await page.setViewportSize({ width: Number(width), height: Number(height) }); const jpeg = type === 'jpeg'; const img = await page.screenshot({ fullPage: !!fullPage, type: jpeg ? 'jpeg' : 'png' }); res.type(jpeg ? 'image/jpeg' : 'image/png').send(img); })); const PORT = process.env.PORT || 8080; chromium.launch({ args: ['--no-sandbox'] }).then((b) => { browser = b; if (!API_KEY && !RAPIDAPI_PROXY_SECRET) { console.warn('WARNING: no API_KEY or RAPIDAPI_PROXY_SECRET set — API is OPEN. Do not expose publicly.'); } app.listen(PORT, () => console.log(`PDF API listening on :${PORT}`)); });