Clear all blockers: payment tested, CI/CD secrets added, status launch-ready
This commit is contained in:
parent
33b1489e6c
commit
0ab4afd398
94 changed files with 10014 additions and 931 deletions
269
projects/business/src/pdf-api/dist/index.js
vendored
269
projects/business/src/pdf-api/dist/index.js
vendored
|
|
@ -1,4 +1,7 @@
|
|||
import express from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import compression from "compression";
|
||||
import logger from "./services/logger.js";
|
||||
import helmet from "helmet";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
|
@ -7,23 +10,52 @@ import { convertRouter } from "./routes/convert.js";
|
|||
import { templatesRouter } from "./routes/templates.js";
|
||||
import { healthRouter } from "./routes/health.js";
|
||||
import { signupRouter } from "./routes/signup.js";
|
||||
import { recoverRouter } from "./routes/recover.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { emailChangeRouter } from "./routes/email-change.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { usageMiddleware } from "./middleware/usage.js";
|
||||
import { usageMiddleware, loadUsageData } from "./middleware/usage.js";
|
||||
import { getUsageStats } from "./middleware/usage.js";
|
||||
import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js";
|
||||
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||
import { loadKeys, getAllKeys } from "./services/keys.js";
|
||||
import { verifyToken, loadVerifications } from "./services/verification.js";
|
||||
import { initDatabase, pool } from "./services/db.js";
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||
// Load API keys from persistent store
|
||||
loadKeys();
|
||||
app.use(helmet());
|
||||
// CORS — allow browser requests from the landing page
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||
// Request ID + request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const allowed = ["https://docfast.dev", "http://localhost:3100"];
|
||||
if (origin && allowed.includes(origin)) {
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
const requestId = req.headers["x-request-id"] || randomUUID();
|
||||
req.requestId = requestId;
|
||||
res.setHeader("X-Request-Id", requestId);
|
||||
const start = Date.now();
|
||||
res.on("finish", () => {
|
||||
const ms = Date.now() - start;
|
||||
if (req.path !== "/health") {
|
||||
logger.info({ method: req.method, path: req.path, status: res.statusCode, ms, requestId }, "request");
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
// Permissions-Policy header
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(self)");
|
||||
next();
|
||||
});
|
||||
// Compression
|
||||
app.use(compression());
|
||||
// Differentiated CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
const isAuthBillingRoute = req.path.startsWith('/v1/signup') ||
|
||||
req.path.startsWith('/v1/recover') ||
|
||||
req.path.startsWith('/v1/billing') ||
|
||||
req.path.startsWith('/v1/email-change');
|
||||
if (isAuthBillingRoute) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
|
||||
}
|
||||
else {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
}
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
|
||||
|
|
@ -40,7 +72,7 @@ app.use(express.json({ limit: "2mb" }));
|
|||
app.use(express.text({ limit: "2mb", type: "text/*" }));
|
||||
// Trust nginx proxy
|
||||
app.set("trust proxy", 1);
|
||||
// Rate limiting
|
||||
// Global rate limiting - reduced from 10,000 to reasonable limit
|
||||
const limiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 100,
|
||||
|
|
@ -51,26 +83,111 @@ app.use(limiter);
|
|||
// Public routes
|
||||
app.use("/health", healthRouter);
|
||||
app.use("/v1/signup", signupRouter);
|
||||
app.use("/v1/recover", recoverRouter);
|
||||
app.use("/v1/billing", billingRouter);
|
||||
app.use("/v1/email-change", emailChangeRouter);
|
||||
// Authenticated routes
|
||||
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
|
||||
app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||
// Admin: usage stats
|
||||
app.get("/v1/usage", authMiddleware, (_req, res) => {
|
||||
res.json(getUsageStats());
|
||||
app.get("/v1/usage", authMiddleware, (req, res) => {
|
||||
res.json(getUsageStats(req.apiKeyInfo?.key));
|
||||
});
|
||||
// Admin: concurrency stats
|
||||
app.get("/v1/concurrency", authMiddleware, (_req, res) => {
|
||||
res.json(getConcurrencyStats());
|
||||
});
|
||||
// Email verification endpoint
|
||||
app.get("/verify", (req, res) => {
|
||||
const token = req.query.token;
|
||||
if (!token) {
|
||||
res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null));
|
||||
return;
|
||||
}
|
||||
const result = verifyToken(token);
|
||||
switch (result.status) {
|
||||
case "ok":
|
||||
res.send(verifyPage("Email Verified! 🚀", "Your DocFast API key is ready:", result.verification.apiKey));
|
||||
break;
|
||||
case "already_verified":
|
||||
res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification.apiKey));
|
||||
break;
|
||||
case "expired":
|
||||
res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null));
|
||||
break;
|
||||
case "invalid":
|
||||
res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null));
|
||||
break;
|
||||
}
|
||||
});
|
||||
function verifyPage(title, message, apiKey) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title} — DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.card{background:#151922;border:1px solid #1e2433;border-radius:16px;padding:48px;max-width:520px;width:100%;text-align:center}
|
||||
h1{font-size:1.8rem;margin-bottom:12px;font-weight:800}
|
||||
p{color:#7a8194;margin-bottom:24px;line-height:1.6}
|
||||
.key-box{background:#0b0d11;border:1px solid #34d399;border-radius:8px;padding:16px;font-family:monospace;font-size:0.82rem;word-break:break-all;margin:16px 0;cursor:pointer;transition:background 0.2s;position:relative}
|
||||
.key-box:hover{background:#12151c}
|
||||
.key-box::after{content:'Click to copy';position:absolute;top:-24px;right:0;font-size:0.7rem;color:#7a8194;font-family:'Inter',sans-serif}
|
||||
.warning{background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.15);border-radius:8px;padding:12px 16px;font-size:0.85rem;color:#fbbf24;margin-bottom:16px;text-align:left}
|
||||
.links{margin-top:24px;color:#7a8194;font-size:0.9rem}
|
||||
.links a{color:#34d399;text-decoration:none}
|
||||
.links a:hover{color:#5eead4}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>${title}</h1>
|
||||
<p>${message}</p>
|
||||
${apiKey ? `
|
||||
<div class="warning">⚠️ Save your API key securely. You can recover it via email if needed.</div>
|
||||
<div class="key-box" onclick="navigator.clipboard.writeText('${apiKey}');this.style.borderColor='#5eead4';setTimeout(()=>this.style.borderColor='#34d399',1500)">${apiKey}</div>
|
||||
<div class="links">100 free PDFs/month · <a href="/docs">Read the docs →</a></div>
|
||||
` : `<div class="links"><a href="/">← Back to DocFast</a></div>`}
|
||||
</div></body></html>`;
|
||||
}
|
||||
// Landing page
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
app.use(express.static(path.join(__dirname, "../public")));
|
||||
// Favicon route
|
||||
app.get("/favicon.ico", (_req, res) => {
|
||||
res.setHeader('Content-Type', 'image/svg+xml');
|
||||
res.setHeader('Cache-Control', 'public, max-age=604800');
|
||||
res.sendFile(path.join(__dirname, "../public/favicon.svg"));
|
||||
});
|
||||
app.use(express.static(path.join(__dirname, "../public"), {
|
||||
maxAge: "1d",
|
||||
etag: true,
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
}));
|
||||
// Docs page (clean URL)
|
||||
app.get("/docs", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/docs.html"));
|
||||
});
|
||||
// Legal pages (clean URLs)
|
||||
app.get("/impressum", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/impressum.html"));
|
||||
});
|
||||
app.get("/privacy", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/privacy.html"));
|
||||
});
|
||||
app.get("/terms", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/terms.html"));
|
||||
});
|
||||
// API root
|
||||
app.get("/api", (_req, res) => {
|
||||
res.json({
|
||||
name: "DocFast API",
|
||||
version: "0.2.0",
|
||||
version: "0.2.1",
|
||||
endpoints: [
|
||||
"POST /v1/signup/free — Get a free API key",
|
||||
"POST /v1/convert/html",
|
||||
|
|
@ -82,20 +199,126 @@ app.get("/api", (_req, res) => {
|
|||
],
|
||||
});
|
||||
});
|
||||
// 404 handler - must be after all routes
|
||||
app.use((req, res) => {
|
||||
// Check if it's an API request
|
||||
const isApiRequest = req.path.startsWith('/v1/') || req.path.startsWith('/api') || req.path.startsWith('/health');
|
||||
if (isApiRequest) {
|
||||
// JSON 404 for API paths
|
||||
res.status(404).json({
|
||||
error: "Not Found",
|
||||
message: `The requested endpoint ${req.method} ${req.path} does not exist`,
|
||||
statusCode: 404,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
else {
|
||||
// HTML 404 for browser paths
|
||||
res.status(404).send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - Page Not Found | DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', sans-serif; background: #0b0d11; color: #e4e7ed; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.container { background: #151922; border: 1px solid #1e2433; border-radius: 16px; padding: 48px; max-width: 520px; width: 100%; text-align: center; }
|
||||
h1 { font-size: 3rem; margin-bottom: 12px; font-weight: 700; color: #34d399; }
|
||||
h2 { font-size: 1.5rem; margin-bottom: 16px; font-weight: 600; }
|
||||
p { color: #7a8194; margin-bottom: 24px; line-height: 1.6; }
|
||||
a { color: #34d399; text-decoration: none; font-weight: 600; }
|
||||
a:hover { color: #5eead4; }
|
||||
.emoji { font-size: 4rem; margin-bottom: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="emoji">⚡</div>
|
||||
<h1>404</h1>
|
||||
<h2>Page Not Found</h2>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<p><a href="/">← Back to DocFast</a> | <a href="/docs">Read the docs</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`);
|
||||
}
|
||||
});
|
||||
// 404 handler — must be after all routes
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith("/v1/")) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
else {
|
||||
const accepts = req.headers.accept || "";
|
||||
if (accepts.includes("text/html")) {
|
||||
res.status(404).send(`<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter',-apple-system,sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.c{text-align:center}.c h1{font-size:4rem;font-weight:800;color:#34d399;margin-bottom:12px}.c p{color:#7a8194;margin-bottom:24px}.c a{color:#34d399;text-decoration:none}.c a:hover{color:#5eead4}</style>
|
||||
</head><body><div class="c"><h1>404</h1><p>Page not found.</p><p><a href="/">← Back to DocFast</a> · <a href="/docs">API Docs</a></p></div></body></html>`);
|
||||
}
|
||||
else {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
}
|
||||
});
|
||||
async function start() {
|
||||
// Initialize PostgreSQL
|
||||
await initDatabase();
|
||||
// Load data from PostgreSQL
|
||||
await loadKeys();
|
||||
await loadVerifications();
|
||||
await loadUsageData();
|
||||
await initBrowser();
|
||||
console.log(`Loaded ${getAllKeys().length} API keys`);
|
||||
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
|
||||
const shutdown = async () => {
|
||||
console.log("Shutting down...");
|
||||
await closeBrowser();
|
||||
logger.info(`Loaded ${getAllKeys().length} API keys`);
|
||||
const server = app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`));
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal) => {
|
||||
if (shuttingDown)
|
||||
return;
|
||||
shuttingDown = true;
|
||||
logger.info(`Received ${signal}, starting graceful shutdown...`);
|
||||
// 1. Stop accepting new connections, wait for in-flight requests (max 10s)
|
||||
await new Promise((resolve) => {
|
||||
const forceTimeout = setTimeout(() => {
|
||||
logger.warn("Forcing server close after 10s timeout");
|
||||
resolve();
|
||||
}, 10_000);
|
||||
server.close(() => {
|
||||
clearTimeout(forceTimeout);
|
||||
logger.info("HTTP server closed (all in-flight requests completed)");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// 2. Close Puppeteer browser pool
|
||||
try {
|
||||
await closeBrowser();
|
||||
logger.info("Browser pool closed");
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Error closing browser pool");
|
||||
}
|
||||
// 3. Close PostgreSQL connection pool
|
||||
try {
|
||||
await pool.end();
|
||||
logger.info("PostgreSQL pool closed");
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Error closing PostgreSQL pool");
|
||||
}
|
||||
logger.info("Graceful shutdown complete");
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
}
|
||||
start().catch((err) => {
|
||||
console.error("Failed to start:", err);
|
||||
logger.error({ err }, "Failed to start");
|
||||
process.exit(1);
|
||||
});
|
||||
export { app };
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { isValidKey, getKeyInfo } from "../services/keys.js";
|
||||
export function authMiddleware(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key>" });
|
||||
const xApiKey = req.headers["x-api-key"];
|
||||
let key;
|
||||
if (header?.startsWith("Bearer ")) {
|
||||
key = header.slice(7);
|
||||
}
|
||||
else if (xApiKey) {
|
||||
key = xApiKey;
|
||||
}
|
||||
if (!key) {
|
||||
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key> or X-API-Key: <key>" });
|
||||
return;
|
||||
}
|
||||
const key = header.slice(7);
|
||||
if (!isValidKey(key)) {
|
||||
res.status(403).json({ error: "Invalid API key" });
|
||||
return;
|
||||
|
|
|
|||
105
projects/business/src/pdf-api/dist/routes/convert.js
vendored
105
projects/business/src/pdf-api/dist/routes/convert.js
vendored
|
|
@ -1,15 +1,58 @@
|
|||
import { Router } from "express";
|
||||
import { renderPdf, renderUrlPdf } from "../services/browser.js";
|
||||
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
||||
import dns from "node:dns/promises";
|
||||
import logger from "../services/logger.js";
|
||||
import net from "node:net";
|
||||
function isPrivateIP(ip) {
|
||||
// IPv6 loopback/unspecified
|
||||
if (ip === "::1" || ip === "::")
|
||||
return true;
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") ||
|
||||
ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb"))
|
||||
return true;
|
||||
// IPv4-mapped IPv6
|
||||
if (ip.startsWith("::ffff:"))
|
||||
ip = ip.slice(7);
|
||||
if (!net.isIPv4(ip))
|
||||
return false;
|
||||
const parts = ip.split(".").map(Number);
|
||||
if (parts[0] === 0)
|
||||
return true; // 0.0.0.0/8
|
||||
if (parts[0] === 10)
|
||||
return true; // 10.0.0.0/8
|
||||
if (parts[0] === 127)
|
||||
return true; // 127.0.0.0/8
|
||||
if (parts[0] === 169 && parts[1] === 254)
|
||||
return true; // 169.254.0.0/16
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
||||
return true; // 172.16.0.0/12
|
||||
if (parts[0] === 192 && parts[1] === 168)
|
||||
return true; // 192.168.0.0/16
|
||||
return false;
|
||||
}
|
||||
export const convertRouter = Router();
|
||||
// POST /v1/convert/html
|
||||
convertRouter.post("/html", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body = typeof req.body === "string" ? { html: req.body } : req.body;
|
||||
if (!body.html) {
|
||||
res.status(400).json({ error: "Missing 'html' field" });
|
||||
return;
|
||||
}
|
||||
// Acquire concurrency slot
|
||||
if (req.acquirePdfSlot) {
|
||||
await req.acquirePdfSlot();
|
||||
slotAcquired = true;
|
||||
}
|
||||
// Wrap bare HTML fragments
|
||||
const fullHtml = body.html.includes("<html")
|
||||
? body.html
|
||||
|
|
@ -26,18 +69,33 @@ convertRouter.post("/html", async (req, res) => {
|
|||
res.send(pdf);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Convert HTML error:", err);
|
||||
logger.error({ err }, "Convert HTML error");
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
}
|
||||
}
|
||||
});
|
||||
// POST /v1/convert/markdown
|
||||
convertRouter.post("/markdown", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||
if (!body.markdown) {
|
||||
res.status(400).json({ error: "Missing 'markdown' field" });
|
||||
return;
|
||||
}
|
||||
// Acquire concurrency slot
|
||||
if (req.acquirePdfSlot) {
|
||||
await req.acquirePdfSlot();
|
||||
slotAcquired = true;
|
||||
}
|
||||
const html = markdownToHtml(body.markdown, body.css);
|
||||
const pdf = await renderPdf(html, {
|
||||
format: body.format,
|
||||
|
|
@ -51,21 +109,32 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
res.send(pdf);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Convert MD error:", err);
|
||||
logger.error({ err }, "Convert MD error");
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
}
|
||||
}
|
||||
});
|
||||
// POST /v1/convert/url
|
||||
convertRouter.post("/url", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body.url) {
|
||||
res.status(400).json({ error: "Missing 'url' field" });
|
||||
return;
|
||||
}
|
||||
// Basic URL validation
|
||||
// URL validation + SSRF protection
|
||||
let parsed;
|
||||
try {
|
||||
const parsed = new URL(body.url);
|
||||
parsed = new URL(body.url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
res.status(400).json({ error: "Only http/https URLs are supported" });
|
||||
return;
|
||||
|
|
@ -75,6 +144,23 @@ convertRouter.post("/url", async (req, res) => {
|
|||
res.status(400).json({ error: "Invalid URL" });
|
||||
return;
|
||||
}
|
||||
// DNS lookup to block private/reserved IPs
|
||||
try {
|
||||
const { address } = await dns.lookup(parsed.hostname);
|
||||
if (isPrivateIP(address)) {
|
||||
res.status(400).json({ error: "URL resolves to a private/internal IP address" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
res.status(400).json({ error: "DNS lookup failed for URL hostname" });
|
||||
return;
|
||||
}
|
||||
// Acquire concurrency slot
|
||||
if (req.acquirePdfSlot) {
|
||||
await req.acquirePdfSlot();
|
||||
slotAcquired = true;
|
||||
}
|
||||
const pdf = await renderUrlPdf(body.url, {
|
||||
format: body.format,
|
||||
landscape: body.landscape,
|
||||
|
|
@ -88,7 +174,16 @@ convertRouter.post("/url", async (req, res) => {
|
|||
res.send(pdf);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Convert URL error:", err);
|
||||
logger.error({ err }, "Convert URL error");
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,54 @@
|
|||
import { Router } from "express";
|
||||
import { createRequire } from "module";
|
||||
import { getPoolStats } from "../services/browser.js";
|
||||
import { pool } from "../services/db.js";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require("../../package.json");
|
||||
export const healthRouter = Router();
|
||||
healthRouter.get("/", (_req, res) => {
|
||||
res.json({ status: "ok", version: "0.1.0" });
|
||||
healthRouter.get("/", async (_req, res) => {
|
||||
const poolStats = getPoolStats();
|
||||
let databaseStatus;
|
||||
let overallStatus = "ok";
|
||||
let httpStatus = 200;
|
||||
// Check database connectivity
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const result = await client.query('SELECT version()');
|
||||
const version = result.rows[0]?.version || 'Unknown';
|
||||
// Extract just the PostgreSQL version number (e.g., "PostgreSQL 15.4")
|
||||
const versionMatch = version.match(/PostgreSQL ([\d.]+)/);
|
||||
const shortVersion = versionMatch ? `PostgreSQL ${versionMatch[1]}` : 'PostgreSQL';
|
||||
databaseStatus = {
|
||||
status: "ok",
|
||||
version: shortVersion
|
||||
};
|
||||
}
|
||||
finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
databaseStatus = {
|
||||
status: "error",
|
||||
message: error.message || "Database connection failed"
|
||||
};
|
||||
overallStatus = "degraded";
|
||||
httpStatus = 503;
|
||||
}
|
||||
const response = {
|
||||
status: overallStatus,
|
||||
version: APP_VERSION,
|
||||
database: databaseStatus,
|
||||
pool: {
|
||||
size: poolStats.poolSize,
|
||||
active: poolStats.totalPages - poolStats.availablePages,
|
||||
available: poolStats.availablePages,
|
||||
queueDepth: poolStats.queueDepth,
|
||||
pdfCount: poolStats.pdfCount,
|
||||
restarting: poolStats.restarting,
|
||||
uptimeSeconds: Math.round(poolStats.uptimeMs / 1000),
|
||||
},
|
||||
};
|
||||
res.status(httpStatus).json(response);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Router } from "express";
|
||||
import { renderPdf } from "../services/browser.js";
|
||||
import logger from "../services/logger.js";
|
||||
import { templates, renderTemplate } from "../services/templates.js";
|
||||
export const templatesRouter = Router();
|
||||
// GET /v1/templates — list available templates
|
||||
|
|
@ -21,7 +22,7 @@ templatesRouter.post("/:id/render", async (req, res) => {
|
|||
res.status(404).json({ error: `Template '${id}' not found` });
|
||||
return;
|
||||
}
|
||||
const data = req.body;
|
||||
const data = req.body.data || req.body;
|
||||
const html = renderTemplate(id, data);
|
||||
const pdf = await renderPdf(html, {
|
||||
format: data._format || "A4",
|
||||
|
|
@ -33,7 +34,7 @@ templatesRouter.post("/:id/render", async (req, res) => {
|
|||
res.send(pdf);
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Template render error:", err);
|
||||
logger.error({ err }, "Template render error");
|
||||
res.status(500).json({ error: "Template rendering failed", detail: err.message });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,67 +1,248 @@
|
|||
import puppeteer from "puppeteer";
|
||||
let browser = null;
|
||||
export async function initBrowser() {
|
||||
import logger from "./logger.js";
|
||||
const BROWSER_COUNT = parseInt(process.env.BROWSER_COUNT || "2", 10);
|
||||
const PAGES_PER_BROWSER = parseInt(process.env.PAGES_PER_BROWSER || "8", 10);
|
||||
const RESTART_AFTER_PDFS = 1000;
|
||||
const RESTART_AFTER_MS = 60 * 60 * 1000; // 1 hour
|
||||
const instances = [];
|
||||
const waitingQueue = [];
|
||||
let roundRobinIndex = 0;
|
||||
export function getPoolStats() {
|
||||
const totalAvailable = instances.reduce((s, i) => s + i.availablePages.length, 0);
|
||||
const totalPages = instances.length * PAGES_PER_BROWSER;
|
||||
const totalPdfs = instances.reduce((s, i) => s + i.pdfCount, 0);
|
||||
return {
|
||||
poolSize: totalPages,
|
||||
totalPages,
|
||||
availablePages: totalAvailable,
|
||||
queueDepth: waitingQueue.length,
|
||||
pdfCount: totalPdfs,
|
||||
restarting: instances.some((i) => i.restarting),
|
||||
uptimeMs: Date.now() - (instances[0]?.lastRestartTime || Date.now()),
|
||||
browsers: instances.map((i) => ({
|
||||
id: i.id,
|
||||
available: i.availablePages.length,
|
||||
pdfCount: i.pdfCount,
|
||||
restarting: i.restarting,
|
||||
})),
|
||||
};
|
||||
}
|
||||
async function recyclePage(page) {
|
||||
try {
|
||||
const client = await page.createCDPSession();
|
||||
await client.send("Network.clearBrowserCache").catch(() => { });
|
||||
await client.detach().catch(() => { });
|
||||
const cookies = await page.cookies();
|
||||
if (cookies.length > 0) {
|
||||
await page.deleteCookie(...cookies);
|
||||
}
|
||||
await page.goto("about:blank", { timeout: 5000 }).catch(() => { });
|
||||
}
|
||||
catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
async function createPages(b, count) {
|
||||
const pages = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const page = await b.newPage();
|
||||
pages.push(page);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
function pickInstance() {
|
||||
// Round-robin among instances that have available pages
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
const idx = (roundRobinIndex + i) % instances.length;
|
||||
const inst = instances[idx];
|
||||
if (inst.availablePages.length > 0 && !inst.restarting) {
|
||||
roundRobinIndex = (idx + 1) % instances.length;
|
||||
return inst;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function acquirePage() {
|
||||
// Check restarts
|
||||
for (const inst of instances) {
|
||||
if (!inst.restarting && (inst.pdfCount >= RESTART_AFTER_PDFS || Date.now() - inst.lastRestartTime >= RESTART_AFTER_MS)) {
|
||||
scheduleRestart(inst);
|
||||
}
|
||||
}
|
||||
const inst = pickInstance();
|
||||
if (inst) {
|
||||
const page = inst.availablePages.pop();
|
||||
return { page, instance: inst };
|
||||
}
|
||||
// All pages busy, queue with 30s timeout
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const idx = waitingQueue.findIndex((w) => w.resolve === resolve);
|
||||
if (idx >= 0)
|
||||
waitingQueue.splice(idx, 1);
|
||||
reject(new Error("QUEUE_FULL"));
|
||||
}, 30_000);
|
||||
waitingQueue.push({
|
||||
resolve: (v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
function releasePage(page, inst) {
|
||||
inst.pdfCount++;
|
||||
const waiter = waitingQueue.shift();
|
||||
if (waiter) {
|
||||
recyclePage(page).then(() => waiter.resolve({ page, instance: inst })).catch(() => {
|
||||
if (inst.browser && !inst.restarting) {
|
||||
inst.browser.newPage().then((p) => waiter.resolve({ page: p, instance: inst })).catch(() => {
|
||||
waitingQueue.unshift(waiter);
|
||||
});
|
||||
}
|
||||
else {
|
||||
waitingQueue.unshift(waiter);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
recyclePage(page).then(() => {
|
||||
inst.availablePages.push(page);
|
||||
}).catch(() => {
|
||||
if (inst.browser && !inst.restarting) {
|
||||
inst.browser.newPage().then((p) => inst.availablePages.push(p)).catch(() => { });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function scheduleRestart(inst) {
|
||||
if (inst.restarting)
|
||||
return;
|
||||
inst.restarting = true;
|
||||
logger.info(`Scheduling browser ${inst.id} restart (pdfs=${inst.pdfCount}, uptime=${Math.round((Date.now() - inst.lastRestartTime) / 1000)}s)`);
|
||||
const drainCheck = () => new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (inst.availablePages.length === PAGES_PER_BROWSER && waitingQueue.length === 0) {
|
||||
resolve();
|
||||
}
|
||||
else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
await Promise.race([drainCheck(), new Promise(r => setTimeout(r, 30000))]);
|
||||
for (const page of inst.availablePages) {
|
||||
await page.close().catch(() => { });
|
||||
}
|
||||
inst.availablePages.length = 0;
|
||||
try {
|
||||
await inst.browser.close().catch(() => { });
|
||||
}
|
||||
catch { }
|
||||
const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined;
|
||||
browser = await puppeteer.launch({
|
||||
inst.browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: execPath,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
console.log("Browser pool ready");
|
||||
const pages = await createPages(inst.browser, PAGES_PER_BROWSER);
|
||||
inst.availablePages.push(...pages);
|
||||
inst.pdfCount = 0;
|
||||
inst.lastRestartTime = Date.now();
|
||||
inst.restarting = false;
|
||||
logger.info(`Browser ${inst.id} restarted successfully`);
|
||||
while (waitingQueue.length > 0 && inst.availablePages.length > 0) {
|
||||
const waiter = waitingQueue.shift();
|
||||
const p = inst.availablePages.pop();
|
||||
if (waiter && p)
|
||||
waiter.resolve({ page: p, instance: inst });
|
||||
}
|
||||
}
|
||||
async function launchInstance(id) {
|
||||
const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined;
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath: execPath,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
});
|
||||
const pages = await createPages(browser, PAGES_PER_BROWSER);
|
||||
const inst = {
|
||||
browser,
|
||||
availablePages: pages,
|
||||
pdfCount: 0,
|
||||
lastRestartTime: Date.now(),
|
||||
restarting: false,
|
||||
id,
|
||||
};
|
||||
return inst;
|
||||
}
|
||||
export async function initBrowser() {
|
||||
for (let i = 0; i < BROWSER_COUNT; i++) {
|
||||
const inst = await launchInstance(i);
|
||||
instances.push(inst);
|
||||
}
|
||||
logger.info(`Browser pool ready (${BROWSER_COUNT} browsers × ${PAGES_PER_BROWSER} pages = ${BROWSER_COUNT * PAGES_PER_BROWSER} total)`);
|
||||
}
|
||||
export async function closeBrowser() {
|
||||
if (browser)
|
||||
await browser.close();
|
||||
for (const inst of instances) {
|
||||
for (const page of inst.availablePages) {
|
||||
await page.close().catch(() => { });
|
||||
}
|
||||
inst.availablePages.length = 0;
|
||||
await inst.browser.close().catch(() => { });
|
||||
}
|
||||
instances.length = 0;
|
||||
}
|
||||
export async function renderPdf(html, options = {}) {
|
||||
if (!browser)
|
||||
throw new Error("Browser not initialized");
|
||||
const page = await browser.newPage();
|
||||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
await page.setContent(html, { waitUntil: "networkidle0", timeout: 15_000 });
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || {
|
||||
top: "20mm",
|
||||
right: "15mm",
|
||||
bottom: "20mm",
|
||||
left: "15mm",
|
||||
},
|
||||
headerTemplate: options.headerTemplate,
|
||||
footerTemplate: options.footerTemplate,
|
||||
displayHeaderFooter: options.displayHeaderFooter || false,
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
await page.setJavaScriptEnabled(false);
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
||||
await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" });
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
headerTemplate: options.headerTemplate,
|
||||
footerTemplate: options.footerTemplate,
|
||||
displayHeaderFooter: options.displayHeaderFooter || false,
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)),
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
finally {
|
||||
await page.close();
|
||||
releasePage(page, instance);
|
||||
}
|
||||
}
|
||||
export async function renderUrlPdf(url, options = {}) {
|
||||
if (!browser)
|
||||
throw new Error("Browser not initialized");
|
||||
const page = await browser.newPage();
|
||||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
await page.goto(url, {
|
||||
waitUntil: options.waitUntil || "networkidle0",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || {
|
||||
top: "20mm",
|
||||
right: "15mm",
|
||||
bottom: "20mm",
|
||||
left: "15mm",
|
||||
},
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
await page.setJavaScriptEnabled(false);
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(url, {
|
||||
waitUntil: options.waitUntil || "networkidle0",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
});
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)),
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
finally {
|
||||
await page.close();
|
||||
releasePage(page, instance);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function esc(s) {
|
|||
.replace(/"/g, """);
|
||||
}
|
||||
function renderInvoice(d) {
|
||||
const cur = d.currency || "€";
|
||||
const cur = esc(d.currency || "€");
|
||||
const items = d.items || [];
|
||||
let subtotal = 0;
|
||||
let totalTax = 0;
|
||||
|
|
@ -120,7 +120,7 @@ function renderInvoice(d) {
|
|||
</body></html>`;
|
||||
}
|
||||
function renderReceipt(d) {
|
||||
const cur = d.currency || "€";
|
||||
const cur = esc(d.currency || "€");
|
||||
const items = d.items || [];
|
||||
let total = 0;
|
||||
const rows = items
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue