DocFast MVP: HTML/Markdown to PDF API with invoice templates

This commit is contained in:
Hoid 2026-02-14 12:30:17 +00:00
parent 789b3bfeeb
commit 77ec1c5524
24 changed files with 5010 additions and 22 deletions

View file

@ -0,0 +1,62 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.app = void 0;
const express_1 = __importDefault(require("express"));
const helmet_1 = __importDefault(require("helmet"));
const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
const convert_js_1 = require("./routes/convert.js");
const templates_js_1 = require("./routes/templates.js");
const health_js_1 = require("./routes/health.js");
const auth_js_1 = require("./middleware/auth.js");
const browser_js_1 = require("./services/browser.js");
const app = (0, express_1.default)();
exports.app = app;
const PORT = parseInt(process.env.PORT || "3100", 10);
app.use((0, helmet_1.default)());
app.use(express_1.default.json({ limit: "2mb" }));
app.use(express_1.default.text({ limit: "2mb", type: "text/*" }));
// Rate limiting: 100 req/min for free tier
const limiter = (0, express_rate_limit_1.default)({
windowMs: 60_000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// Public
app.use("/health", health_js_1.healthRouter);
// Authenticated
app.use("/v1/convert", auth_js_1.authMiddleware, convert_js_1.convertRouter);
app.use("/v1/templates", auth_js_1.authMiddleware, templates_js_1.templatesRouter);
// Root
app.get("/", (_req, res) => {
res.json({
name: "DocFast API",
version: "0.1.0",
docs: "/health",
endpoints: [
"POST /v1/convert/html",
"POST /v1/convert/markdown",
"POST /v1/templates/:id/render",
"GET /v1/templates",
],
});
});
async function start() {
await (0, browser_js_1.initBrowser)();
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`));
const shutdown = async () => {
console.log("Shutting down...");
await (0, browser_js_1.closeBrowser)();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
start().catch((err) => {
console.error("Failed to start:", err);
process.exit(1);
});

View file

@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.authMiddleware = authMiddleware;
const API_KEYS = new Set((process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim()));
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>" });
return;
}
const key = header.slice(7);
if (!API_KEYS.has(key)) {
res.status(403).json({ error: "Invalid API key" });
return;
}
next();
}

View file

@ -0,0 +1,60 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertRouter = void 0;
const express_1 = require("express");
const browser_js_1 = require("../services/browser.js");
const markdown_js_1 = require("../services/markdown.js");
exports.convertRouter = (0, express_1.Router)();
// POST /v1/convert/html
exports.convertRouter.post("/html", async (req, res) => {
try {
const body = typeof req.body === "string" ? { html: req.body } : req.body;
if (!body.html) {
res.status(400).json({ error: "Missing 'html' field" });
return;
}
// Wrap bare HTML fragments
const fullHtml = body.html.includes("<html")
? body.html
: (0, markdown_js_1.wrapHtml)(body.html, body.css);
const pdf = await (0, browser_js_1.renderPdf)(fullHtml, {
format: body.format,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
});
const filename = body.filename || "document.pdf";
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
}
catch (err) {
console.error("Convert HTML error:", err);
res.status(500).json({ error: "PDF generation failed", detail: err.message });
}
});
// POST /v1/convert/markdown
exports.convertRouter.post("/markdown", async (req, res) => {
try {
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
if (!body.markdown) {
res.status(400).json({ error: "Missing 'markdown' field" });
return;
}
const html = (0, markdown_js_1.markdownToHtml)(body.markdown, body.css);
const pdf = await (0, browser_js_1.renderPdf)(html, {
format: body.format,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
});
const filename = body.filename || "document.pdf";
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
}
catch (err) {
console.error("Convert MD error:", err);
res.status(500).json({ error: "PDF generation failed", detail: err.message });
}
});

View file

@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.healthRouter = void 0;
const express_1 = require("express");
exports.healthRouter = (0, express_1.Router)();
exports.healthRouter.get("/", (_req, res) => {
res.json({ status: "ok", version: "0.1.0" });
});

View file

@ -0,0 +1,42 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.templatesRouter = void 0;
const express_1 = require("express");
const browser_js_1 = require("../services/browser.js");
const templates_js_1 = require("../services/templates.js");
exports.templatesRouter = (0, express_1.Router)();
// GET /v1/templates — list available templates
exports.templatesRouter.get("/", (_req, res) => {
const list = Object.entries(templates_js_1.templates).map(([id, t]) => ({
id,
name: t.name,
description: t.description,
fields: t.fields,
}));
res.json({ templates: list });
});
// POST /v1/templates/:id/render — render template to PDF
exports.templatesRouter.post("/:id/render", async (req, res) => {
try {
const id = req.params.id;
const template = templates_js_1.templates[id];
if (!template) {
res.status(404).json({ error: `Template '${id}' not found` });
return;
}
const data = req.body;
const html = (0, templates_js_1.renderTemplate)(id, data);
const pdf = await (0, browser_js_1.renderPdf)(html, {
format: data._format || "A4",
margin: data._margin,
});
const filename = data._filename || `${id}.pdf`;
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
}
catch (err) {
console.error("Template render error:", err);
res.status(500).json({ error: "Template rendering failed", detail: err.message });
}
});

View file

@ -0,0 +1,47 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.initBrowser = initBrowser;
exports.closeBrowser = closeBrowser;
exports.renderPdf = renderPdf;
const puppeteer_1 = __importDefault(require("puppeteer"));
let browser = null;
async function initBrowser() {
browser = await puppeteer_1.default.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"],
});
console.log("Browser pool ready");
}
async function closeBrowser() {
if (browser)
await browser.close();
}
async function renderPdf(html, options = {}) {
if (!browser)
throw new Error("Browser not initialized");
const page = await browser.newPage();
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);
}
finally {
await page.close();
}
}

View file

@ -0,0 +1,34 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.markdownToHtml = markdownToHtml;
exports.wrapHtml = wrapHtml;
const marked_1 = require("marked");
const defaultCss = `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #1a1a1a;
max-width: 100%;
}
h1 { font-size: 2em; margin-bottom: 0.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; margin-bottom: 0.5em; }
h3 { font-size: 1.25em; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f8f8f8; font-weight: 600; }
blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; }
img { max-width: 100%; }
`;
function markdownToHtml(md, css) {
const html = marked_1.marked.parse(md, { async: false });
return wrapHtml(html, css || defaultCss);
}
function wrapHtml(body, css) {
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>${css || defaultCss}</style></head>
<body>${body}</body></html>`;
}

View file

@ -0,0 +1,167 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.templates = void 0;
exports.renderTemplate = renderTemplate;
exports.templates = {
invoice: {
name: "Invoice",
description: "Professional invoice with line items, taxes, and payment details",
fields: [
{ name: "invoiceNumber", type: "string", required: true, description: "Invoice number" },
{ name: "date", type: "string", required: true, description: "Invoice date (YYYY-MM-DD)" },
{ name: "dueDate", type: "string", required: false, description: "Due date" },
{ name: "from", type: "object", required: true, description: "Sender: {name, address?, email?, phone?, vatId?}" },
{ name: "to", type: "object", required: true, description: "Recipient: {name, address?, email?, vatId?}" },
{ name: "items", type: "array", required: true, description: "Line items: [{description, quantity, unitPrice, taxRate?}]" },
{ name: "currency", type: "string", required: false, description: "Currency symbol (default: €)" },
{ name: "notes", type: "string", required: false, description: "Additional notes" },
{ name: "paymentDetails", type: "string", required: false, description: "Bank/payment info" },
],
render: renderInvoice,
},
receipt: {
name: "Receipt",
description: "Simple receipt for payments received",
fields: [
{ name: "receiptNumber", type: "string", required: true, description: "Receipt number" },
{ name: "date", type: "string", required: true, description: "Date" },
{ name: "from", type: "object", required: true, description: "Business: {name, address?}" },
{ name: "to", type: "object", required: false, description: "Customer: {name, email?}" },
{ name: "items", type: "array", required: true, description: "Items: [{description, amount}]" },
{ name: "currency", type: "string", required: false, description: "Currency symbol" },
{ name: "paymentMethod", type: "string", required: false, description: "Payment method" },
],
render: renderReceipt,
},
};
function esc(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderInvoice(d) {
const cur = d.currency || "€";
const items = d.items || [];
let subtotal = 0;
let totalTax = 0;
const rows = items
.map((item) => {
const qty = Number(item.quantity) || 1;
const price = Number(item.unitPrice) || 0;
const taxRate = Number(item.taxRate) || 0;
const lineTotal = qty * price;
const lineTax = lineTotal * (taxRate / 100);
subtotal += lineTotal;
totalTax += lineTax;
return `<tr>
<td>${esc(item.description)}</td>
<td style="text-align:right">${qty}</td>
<td style="text-align:right">${cur}${price.toFixed(2)}</td>
<td style="text-align:right">${taxRate}%</td>
<td style="text-align:right">${cur}${lineTotal.toFixed(2)}</td>
</tr>`;
})
.join("");
const total = subtotal + totalTax;
const from = d.from || {};
const to = d.to || {};
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; color: #222; padding: 40px; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
.header h1 { font-size: 28px; color: #1a1a1a; }
.meta { text-align: right; }
.meta div { margin-bottom: 4px; }
.parties { display: flex; justify-content: space-between; margin-bottom: 30px; }
.party { width: 45%; }
.party h3 { font-size: 11px; text-transform: uppercase; color: #888; margin-bottom: 8px; }
.party p { margin-bottom: 2px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th { background: #f8f8f8; text-align: left; padding: 10px; font-size: 11px; text-transform: uppercase; color: #666; border-bottom: 2px solid #ddd; }
td { padding: 10px; border-bottom: 1px solid #eee; }
.totals { text-align: right; margin-bottom: 30px; }
.totals div { margin-bottom: 4px; }
.totals .total { font-size: 18px; font-weight: 700; color: #1a1a1a; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
</style></head><body>
<div class="header">
<h1>INVOICE</h1>
<div class="meta">
<div><strong>#${esc(d.invoiceNumber)}</strong></div>
<div>Date: ${esc(d.date)}</div>
${d.dueDate ? `<div>Due: ${esc(d.dueDate)}</div>` : ""}
</div>
</div>
<div class="parties">
<div class="party">
<h3>From</h3>
<p><strong>${esc(from.name)}</strong></p>
${from.address ? `<p>${esc(from.address).replace(/\n/g, "<br>")}</p>` : ""}
${from.email ? `<p>${esc(from.email)}</p>` : ""}
${from.vatId ? `<p>VAT: ${esc(from.vatId)}</p>` : ""}
</div>
<div class="party">
<h3>To</h3>
<p><strong>${esc(to.name)}</strong></p>
${to.address ? `<p>${esc(to.address).replace(/\n/g, "<br>")}</p>` : ""}
${to.email ? `<p>${esc(to.email)}</p>` : ""}
${to.vatId ? `<p>VAT: ${esc(to.vatId)}</p>` : ""}
</div>
</div>
<table>
<thead><tr><th>Description</th><th style="text-align:right">Qty</th><th style="text-align:right">Price</th><th style="text-align:right">Tax</th><th style="text-align:right">Total</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="totals">
<div>Subtotal: ${cur}${subtotal.toFixed(2)}</div>
<div>Tax: ${cur}${totalTax.toFixed(2)}</div>
<div class="total">Total: ${cur}${total.toFixed(2)}</div>
</div>
${d.paymentDetails ? `<div class="footer"><strong>Payment Details</strong><br>${esc(d.paymentDetails).replace(/\n/g, "<br>")}</div>` : ""}
${d.notes ? `<div class="footer"><strong>Notes</strong><br>${esc(d.notes)}</div>` : ""}
</body></html>`;
}
function renderReceipt(d) {
const cur = d.currency || "€";
const items = d.items || [];
let total = 0;
const rows = items
.map((item) => {
const amount = Number(item.amount) || 0;
total += amount;
return `<tr><td>${esc(item.description)}</td><td style="text-align:right">${cur}${amount.toFixed(2)}</td></tr>`;
})
.join("");
const from = d.from || {};
const to = d.to || {};
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
body { font-family: 'Courier New', monospace; font-size: 13px; max-width: 320px; margin: 0 auto; padding: 30px 20px; }
h1 { text-align: center; font-size: 18px; margin-bottom: 4px; }
.center { text-align: center; margin-bottom: 16px; }
hr { border: none; border-top: 1px dashed #999; margin: 12px 0; }
table { width: 100%; }
td { padding: 3px 0; }
.total { font-weight: bold; font-size: 16px; }
</style></head><body>
<h1>${esc(from.name)}</h1>
${from.address ? `<div class="center">${esc(from.address)}</div>` : ""}
<hr>
<div>Receipt #${esc(d.receiptNumber)}</div>
<div>Date: ${esc(d.date)}</div>
${to?.name ? `<div>Customer: ${esc(to.name)}</div>` : ""}
<hr>
<table>${rows}</table>
<hr>
<table><tr><td class="total">TOTAL</td><td class="total" style="text-align:right">${cur}${total.toFixed(2)}</td></tr></table>
${d.paymentMethod ? `<hr><div>Paid via: ${esc(d.paymentMethod)}</div>` : ""}
<hr><div class="center">Thank you!</div>
</body></html>`;
}
function renderTemplate(id, data) {
const template = exports.templates[id];
if (!template)
throw new Error(`Template '${id}' not found`);
return template.render(data);
}