pdf-api / src /index.js
MistLiquit's picture
Add landing page
6e1cb05 verified
Raw
History Blame Contribute Delete
6.93 kB
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(
`<!doctype html><meta charset="utf-8"><title>PDF API</title>
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:60px auto;padding:0 20px;color:#1f2937;line-height:1.6}code{background:#f3f4f6;padding:2px 6px;border-radius:4px}h1{margin-bottom:0}.muted{color:#6b7280}</style>
<h1>πŸ“„ PDF API</h1>
<p class="muted">HTML/URL to PDF &amp; screenshot API, with a built-in invoice template.</p>
<h3>Endpoints</h3>
<ul>
<li><code>GET /health</code> β€” status (public)</li>
<li><code>POST /pdf</code> β€” HTML / URL / template &rarr; PDF</li>
<li><code>POST /screenshot</code> β€” HTML / URL &rarr; PNG or JPEG</li>
</ul>
<p class="muted">Protected endpoints require an API key. Available on RapidAPI.</p>`
)
);
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}`));
});