Spaces:
Sleeping
Sleeping
| 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 & 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 → PDF</li> | |
| <li><code>POST /screenshot</code> β HTML / URL → 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}`)); | |
| }); | |