diff --git a/dist/__tests__/api.test.js b/dist/__tests__/api.test.js
new file mode 100644
index 0000000..b99fca3
--- /dev/null
+++ b/dist/__tests__/api.test.js
@@ -0,0 +1,122 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { app } from "../index.js";
+// Note: These tests require Puppeteer/Chrome to be available
+// For CI, use the Dockerfile which includes Chrome
+const BASE = "http://localhost:3199";
+let server;
+beforeAll(async () => {
+ process.env.API_KEYS = "test-key";
+ process.env.PORT = "3199";
+ // Import fresh to pick up env
+ server = app.listen(3199);
+ // Wait for browser init
+ await new Promise((r) => setTimeout(r, 2000));
+});
+afterAll(async () => {
+ server?.close();
+});
+describe("Auth", () => {
+ it("rejects requests without API key", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" });
+ expect(res.status).toBe(401);
+ });
+ it("rejects invalid API key", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: { Authorization: "Bearer wrong-key" },
+ });
+ expect(res.status).toBe(403);
+ });
+});
+describe("Health", () => {
+ it("returns ok", async () => {
+ const res = await fetch(`${BASE}/health`);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.status).toBe("ok");
+ });
+});
+describe("HTML to PDF", () => {
+ it("converts simple HTML", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ html: "
Test
" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ const buf = await res.arrayBuffer();
+ expect(buf.byteLength).toBeGreaterThan(100);
+ // PDF magic bytes
+ const header = new Uint8Array(buf.slice(0, 5));
+ expect(String.fromCharCode(...header)).toBe("%PDF-");
+ });
+ it("rejects missing html field", async () => {
+ const res = await fetch(`${BASE}/v1/convert/html`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+describe("Markdown to PDF", () => {
+ it("converts markdown", async () => {
+ const res = await fetch(`${BASE}/v1/convert/markdown`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ markdown: "# Hello\n\nWorld" }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ });
+});
+describe("Templates", () => {
+ it("lists templates", async () => {
+ const res = await fetch(`${BASE}/v1/templates`, {
+ headers: { Authorization: "Bearer test-key" },
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.templates).toBeInstanceOf(Array);
+ expect(data.templates.length).toBeGreaterThan(0);
+ });
+ it("renders invoice template", async () => {
+ const res = await fetch(`${BASE}/v1/templates/invoice/render`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ invoiceNumber: "TEST-001",
+ date: "2026-02-14",
+ from: { name: "Seller", email: "s@test.com" },
+ to: { name: "Buyer", email: "b@test.com" },
+ items: [{ description: "Widget", quantity: 2, unitPrice: 50, taxRate: 20 }],
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toBe("application/pdf");
+ });
+ it("returns 404 for unknown template", async () => {
+ const res = await fetch(`${BASE}/v1/templates/nonexistent/render`, {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer test-key",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(404);
+ });
+});
diff --git a/dist/index.js b/dist/index.js
new file mode 100644
index 0000000..416df55
--- /dev/null
+++ b/dist/index.js
@@ -0,0 +1,286 @@
+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";
+import rateLimit from "express-rate-limit";
+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, 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 } from "./services/db.js";
+const app = express();
+const PORT = parseInt(process.env.PORT || "3100", 10);
+app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
+// Request ID + request logging middleware
+app.use((req, res, next) => {
+ 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");
+ res.setHeader("Access-Control-Max-Age", "86400");
+ if (req.method === "OPTIONS") {
+ res.status(204).end();
+ return;
+ }
+ next();
+});
+// Raw body for Stripe webhook signature verification
+app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
+app.use(express.json({ limit: "2mb" }));
+app.use(express.text({ limit: "2mb", type: "text/*" }));
+// Trust nginx proxy
+app.set("trust proxy", 1);
+// Global rate limiting - reduced from 10,000 to reasonable limit
+const limiter = rateLimit({
+ windowMs: 60_000,
+ max: 100,
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+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, pdfRateLimitMiddleware, convertRouter);
+app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
+// Admin: usage stats
+app.get("/v1/usage", authMiddleware, (_req, res) => {
+ res.json(getUsageStats());
+});
+// 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 `
+
+${title} โ DocFast
+
+
+
+
+
${title}
+
${message}
+${apiKey ? `
+
โ ๏ธ Save your API key securely. You can recover it via email if needed.
+
${apiKey}
+
+` : `
`}
+
`;
+}
+// Landing page
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+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.1",
+ endpoints: [
+ "POST /v1/signup/free โ Get a free API key",
+ "POST /v1/convert/html",
+ "POST /v1/convert/markdown",
+ "POST /v1/convert/url",
+ "POST /v1/templates/:id/render",
+ "GET /v1/templates",
+ "POST /v1/billing/checkout โ Start Pro subscription",
+ ],
+ });
+});
+// 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(`
+
+
+
+
+ 404 - Page Not Found | DocFast
+
+
+
+
+
+
+
+`);
+ }
+});
+// 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(`
+
+404 โ DocFast
+
+
+`);
+ }
+ 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();
+ logger.info(`Loaded ${getAllKeys().length} API keys`);
+ app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`));
+ const shutdown = async () => {
+ logger.info("Shutting down...");
+ await closeBrowser();
+ process.exit(0);
+ };
+ process.on("SIGTERM", shutdown);
+ process.on("SIGINT", shutdown);
+}
+start().catch((err) => {
+ logger.error({ err }, "Failed to start");
+ process.exit(1);
+});
+export { app };
diff --git a/dist/middleware/auth.js b/dist/middleware/auth.js
new file mode 100644
index 0000000..5b6647f
--- /dev/null
+++ b/dist/middleware/auth.js
@@ -0,0 +1,23 @@
+import { isValidKey, getKeyInfo } from "../services/keys.js";
+export function authMiddleware(req, res, next) {
+ const header = req.headers.authorization;
+ 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 or X-API-Key: " });
+ return;
+ }
+ if (!isValidKey(key)) {
+ res.status(403).json({ error: "Invalid API key" });
+ return;
+ }
+ // Attach key info to request for downstream use
+ req.apiKeyInfo = getKeyInfo(key);
+ next();
+}
diff --git a/dist/middleware/pdfRateLimit.js b/dist/middleware/pdfRateLimit.js
new file mode 100644
index 0000000..2b84953
--- /dev/null
+++ b/dist/middleware/pdfRateLimit.js
@@ -0,0 +1,91 @@
+import { isProKey } from "../services/keys.js";
+// Per-key rate limits (requests per minute)
+const FREE_RATE_LIMIT = 10;
+const PRO_RATE_LIMIT = 30;
+const RATE_WINDOW_MS = 60_000; // 1 minute
+// Concurrency limits
+const MAX_CONCURRENT_PDFS = 3;
+const MAX_QUEUE_SIZE = 10;
+const rateLimitStore = new Map();
+let activePdfCount = 0;
+const pdfQueue = [];
+function cleanupExpiredEntries() {
+ const now = Date.now();
+ for (const [key, entry] of rateLimitStore.entries()) {
+ if (now >= entry.resetTime) {
+ rateLimitStore.delete(key);
+ }
+ }
+}
+function getRateLimit(apiKey) {
+ return isProKey(apiKey) ? PRO_RATE_LIMIT : FREE_RATE_LIMIT;
+}
+function checkRateLimit(apiKey) {
+ cleanupExpiredEntries();
+ const now = Date.now();
+ const limit = getRateLimit(apiKey);
+ const entry = rateLimitStore.get(apiKey);
+ if (!entry || now >= entry.resetTime) {
+ // Create new window
+ rateLimitStore.set(apiKey, {
+ count: 1,
+ resetTime: now + RATE_WINDOW_MS
+ });
+ return true;
+ }
+ if (entry.count >= limit) {
+ return false;
+ }
+ entry.count++;
+ return true;
+}
+async function acquireConcurrencySlot() {
+ if (activePdfCount < MAX_CONCURRENT_PDFS) {
+ activePdfCount++;
+ return;
+ }
+ if (pdfQueue.length >= MAX_QUEUE_SIZE) {
+ throw new Error("QUEUE_FULL");
+ }
+ return new Promise((resolve, reject) => {
+ pdfQueue.push({ resolve, reject });
+ });
+}
+function releaseConcurrencySlot() {
+ activePdfCount--;
+ const waiter = pdfQueue.shift();
+ if (waiter) {
+ activePdfCount++;
+ waiter.resolve();
+ }
+}
+export function pdfRateLimitMiddleware(req, res, next) {
+ const keyInfo = req.apiKeyInfo;
+ const apiKey = keyInfo?.key || "unknown";
+ // Check rate limit first
+ if (!checkRateLimit(apiKey)) {
+ const limit = getRateLimit(apiKey);
+ const tier = isProKey(apiKey) ? "pro" : "free";
+ res.status(429).json({
+ error: "Rate limit exceeded",
+ limit: `${limit} PDFs per minute`,
+ tier,
+ retryAfter: "60 seconds"
+ });
+ return;
+ }
+ // Add concurrency control to the request
+ req.acquirePdfSlot = acquireConcurrencySlot;
+ req.releasePdfSlot = releaseConcurrencySlot;
+ next();
+}
+export function getConcurrencyStats() {
+ return {
+ activePdfCount,
+ queueSize: pdfQueue.length,
+ maxConcurrent: MAX_CONCURRENT_PDFS,
+ maxQueue: MAX_QUEUE_SIZE
+ };
+}
+// Proactive cleanup every 60s
+setInterval(cleanupExpiredEntries, 60_000);
diff --git a/dist/middleware/usage.js b/dist/middleware/usage.js
new file mode 100644
index 0000000..0df84f5
--- /dev/null
+++ b/dist/middleware/usage.js
@@ -0,0 +1,75 @@
+import { isProKey } from "../services/keys.js";
+import logger from "../services/logger.js";
+import pool from "../services/db.js";
+const FREE_TIER_LIMIT = 100;
+// In-memory cache, periodically synced to PostgreSQL
+let usage = new Map();
+function getMonthKey() {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+}
+export async function loadUsageData() {
+ try {
+ const result = await pool.query("SELECT key, count, month_key FROM usage");
+ usage = new Map();
+ for (const row of result.rows) {
+ usage.set(row.key, { count: row.count, monthKey: row.month_key });
+ }
+ logger.info(`Loaded usage data for ${usage.size} keys from PostgreSQL`);
+ }
+ catch (error) {
+ logger.info("No existing usage data found, starting fresh");
+ usage = new Map();
+ }
+}
+async function saveUsageEntry(key, record) {
+ try {
+ await pool.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3)
+ ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]);
+ }
+ catch (error) {
+ logger.error({ err: error }, "Failed to save usage data");
+ }
+}
+export function usageMiddleware(req, res, next) {
+ const keyInfo = req.apiKeyInfo;
+ const key = keyInfo?.key || "unknown";
+ const monthKey = getMonthKey();
+ if (isProKey(key)) {
+ trackUsage(key, monthKey);
+ next();
+ return;
+ }
+ const record = usage.get(key);
+ if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) {
+ res.status(429).json({
+ error: "Free tier limit reached",
+ limit: FREE_TIER_LIMIT,
+ used: record.count,
+ upgrade: "Upgrade to Pro for unlimited conversions: https://docfast.dev/pricing",
+ });
+ return;
+ }
+ trackUsage(key, monthKey);
+ next();
+}
+function trackUsage(key, monthKey) {
+ const record = usage.get(key);
+ if (!record || record.monthKey !== monthKey) {
+ const newRecord = { count: 1, monthKey };
+ usage.set(key, newRecord);
+ saveUsageEntry(key, newRecord).catch((err) => logger.error({ err }, "Failed to save usage entry"));
+ }
+ else {
+ record.count++;
+ saveUsageEntry(key, record).catch((err) => logger.error({ err }, "Failed to save usage entry"));
+ }
+}
+export function getUsageStats() {
+ const stats = {};
+ for (const [key, record] of usage) {
+ const masked = key.slice(0, 8) + "...";
+ stats[masked] = { count: record.count, month: record.monthKey };
+ }
+ return stats;
+}
diff --git a/dist/routes/billing.js b/dist/routes/billing.js
new file mode 100644
index 0000000..dca8ff4
--- /dev/null
+++ b/dist/routes/billing.js
@@ -0,0 +1,187 @@
+import { Router } from "express";
+import Stripe from "stripe";
+import { createProKey, revokeByCustomer } from "../services/keys.js";
+import logger from "../services/logger.js";
+function escapeHtml(s) {
+ return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
+}
+let _stripe = null;
+function getStripe() {
+ if (!_stripe) {
+ const key = process.env.STRIPE_SECRET_KEY;
+ if (!key)
+ throw new Error("STRIPE_SECRET_KEY not configured");
+ _stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" });
+ }
+ return _stripe;
+}
+const router = Router();
+// Create a Stripe Checkout session for Pro subscription
+router.post("/checkout", async (_req, res) => {
+ try {
+ const priceId = await getOrCreateProPrice();
+ const session = await getStripe().checkout.sessions.create({
+ mode: "subscription",
+ payment_method_types: ["card"],
+ line_items: [{ price: priceId, quantity: 1 }],
+ success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`,
+ });
+ res.json({ url: session.url });
+ }
+ catch (err) {
+ logger.error({ err }, "Checkout error");
+ res.status(500).json({ error: "Failed to create checkout session" });
+ }
+});
+// Success page โ provision Pro API key after checkout
+router.get("/success", async (req, res) => {
+ const sessionId = req.query.session_id;
+ if (!sessionId) {
+ res.status(400).json({ error: "Missing session_id" });
+ return;
+ }
+ try {
+ const session = await getStripe().checkout.sessions.retrieve(sessionId);
+ const customerId = session.customer;
+ const email = session.customer_details?.email || "unknown@docfast.dev";
+ if (!customerId) {
+ res.status(400).json({ error: "No customer found" });
+ return;
+ }
+ const keyInfo = await createProKey(email, customerId);
+ // Return a nice HTML page instead of raw JSON
+ res.send(`
+Welcome to DocFast Pro!
+
+
+
๐ Welcome to Pro!
+
Your API key:
+
${escapeHtml(keyInfo.key)}
+
Save this key! It won't be shown again.
+
10,000 PDFs/month โข All endpoints โข Priority support
+
View API docs โ
+
`);
+ }
+ catch (err) {
+ logger.error({ err }, "Success page error");
+ res.status(500).json({ error: "Failed to retrieve session" });
+ }
+});
+// Stripe webhook for subscription lifecycle events
+router.post("/webhook", async (req, res) => {
+ const sig = req.headers["stripe-signature"];
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
+ let event;
+ if (!webhookSecret) {
+ console.warn("โ ๏ธ STRIPE_WEBHOOK_SECRET is not configured โ webhook signature verification skipped. Set this in production!");
+ // Parse the body as a raw event without verification
+ try {
+ event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString());
+ }
+ catch (err) {
+ logger.error({ err }, "Failed to parse webhook body");
+ res.status(400).json({ error: "Invalid payload" });
+ return;
+ }
+ }
+ else if (!sig) {
+ res.status(400).json({ error: "Missing stripe-signature header" });
+ return;
+ }
+ else {
+ try {
+ event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret);
+ }
+ catch (err) {
+ logger.error({ err }, "Webhook signature verification failed");
+ res.status(400).json({ error: "Invalid signature" });
+ return;
+ }
+ }
+ switch (event.type) {
+ case "checkout.session.completed": {
+ const session = event.data.object;
+ const customerId = session.customer;
+ const email = session.customer_details?.email;
+ // Filter by product โ this Stripe account is shared with other projects
+ const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
+ try {
+ const fullSession = await getStripe().checkout.sessions.retrieve(session.id, {
+ expand: ["line_items"],
+ });
+ const lineItems = fullSession.line_items?.data || [];
+ const hasDocfastProduct = lineItems.some((item) => {
+ const price = item.price;
+ const productId = typeof price?.product === "string" ? price.product : price?.product?.id;
+ return productId === DOCFAST_PRODUCT_ID;
+ });
+ if (!hasDocfastProduct) {
+ logger.info({ sessionId: session.id }, "Ignoring event for different product");
+ break;
+ }
+ }
+ catch (err) {
+ logger.error({ err, sessionId: session.id }, "Failed to retrieve session line_items");
+ break;
+ }
+ if (!customerId || !email) {
+ console.warn("checkout.session.completed: missing customerId or email, skipping key provisioning");
+ break;
+ }
+ const keyInfo = await createProKey(email, customerId);
+ logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
+ break;
+ }
+ case "customer.subscription.deleted": {
+ const sub = event.data.object;
+ const customerId = sub.customer;
+ await revokeByCustomer(customerId);
+ logger.info({ customerId }, "Subscription cancelled, key revoked");
+ break;
+ }
+ default:
+ break;
+ }
+ res.json({ received: true });
+});
+// --- Price management ---
+let cachedPriceId = null;
+async function getOrCreateProPrice() {
+ if (cachedPriceId)
+ return cachedPriceId;
+ const products = await getStripe().products.search({ query: "name:'DocFast Pro'" });
+ let productId;
+ if (products.data.length > 0) {
+ productId = products.data[0].id;
+ const prices = await getStripe().prices.list({ product: productId, active: true, limit: 1 });
+ if (prices.data.length > 0) {
+ cachedPriceId = prices.data[0].id;
+ return cachedPriceId;
+ }
+ }
+ else {
+ const product = await getStripe().products.create({
+ name: "DocFast Pro",
+ description: "Unlimited PDF conversions via API. HTML, Markdown, and URL to PDF.",
+ });
+ productId = product.id;
+ }
+ const price = await getStripe().prices.create({
+ product: productId,
+ unit_amount: 900,
+ currency: "eur",
+ recurring: { interval: "month" },
+ });
+ cachedPriceId = price.id;
+ return cachedPriceId;
+}
+export { router as billingRouter };
diff --git a/dist/routes/convert.js b/dist/routes/convert.js
new file mode 100644
index 0000000..55a0fa1
--- /dev/null
+++ b/dist/routes/convert.js
@@ -0,0 +1,189 @@
+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(" {
+ 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,
+ 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) {
+ 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;
+ }
+ // URL validation + SSRF protection
+ let parsed;
+ try {
+ parsed = new URL(body.url);
+ if (!["http:", "https:"].includes(parsed.protocol)) {
+ res.status(400).json({ error: "Only http/https URLs are supported" });
+ return;
+ }
+ }
+ catch {
+ 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,
+ margin: body.margin,
+ printBackground: body.printBackground,
+ waitUntil: body.waitUntil,
+ });
+ const filename = body.filename || "page.pdf";
+ res.setHeader("Content-Type", "application/pdf");
+ res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
+ res.send(pdf);
+ }
+ catch (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();
+ }
+ }
+});
diff --git a/dist/routes/email-change.js b/dist/routes/email-change.js
new file mode 100644
index 0000000..3feae38
--- /dev/null
+++ b/dist/routes/email-change.js
@@ -0,0 +1,82 @@
+import { Router } from "express";
+import rateLimit from "express-rate-limit";
+import { createPendingVerification, verifyCode } from "../services/verification.js";
+import { sendVerificationEmail } from "../services/email.js";
+import { getAllKeys, updateKeyEmail } from "../services/keys.js";
+import logger from "../services/logger.js";
+const router = Router();
+const changeLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000,
+ max: 3,
+ message: { error: "Too many attempts. Please try again in 1 hour." },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+router.post("/", changeLimiter, async (req, res) => {
+ const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
+ const newEmail = req.body?.newEmail;
+ if (!apiKey || typeof apiKey !== "string") {
+ res.status(400).json({ error: "API key is required (Authorization header or body)." });
+ return;
+ }
+ if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
+ res.status(400).json({ error: "A valid new email address is required." });
+ return;
+ }
+ const cleanEmail = newEmail.trim().toLowerCase();
+ const keys = getAllKeys();
+ const userKey = keys.find((k) => k.key === apiKey);
+ if (!userKey) {
+ res.status(401).json({ error: "Invalid API key." });
+ return;
+ }
+ const existing = keys.find((k) => k.email === cleanEmail);
+ if (existing) {
+ res.status(409).json({ error: "This email is already associated with another account." });
+ return;
+ }
+ const pending = await createPendingVerification(cleanEmail);
+ sendVerificationEmail(cleanEmail, pending.code).catch((err) => {
+ logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
+ });
+ res.json({ status: "verification_sent", message: "Verification code sent to your new email address." });
+});
+router.post("/verify", changeLimiter, async (req, res) => {
+ const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
+ const { newEmail, code } = req.body || {};
+ if (!apiKey || !newEmail || !code) {
+ res.status(400).json({ error: "API key, new email, and code are required." });
+ return;
+ }
+ const cleanEmail = newEmail.trim().toLowerCase();
+ const cleanCode = String(code).trim();
+ const keys = getAllKeys();
+ const userKey = keys.find((k) => k.key === apiKey);
+ if (!userKey) {
+ res.status(401).json({ error: "Invalid API key." });
+ return;
+ }
+ const result = await verifyCode(cleanEmail, cleanCode);
+ switch (result.status) {
+ case "ok": {
+ const updated = await updateKeyEmail(apiKey, cleanEmail);
+ if (updated) {
+ res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail });
+ }
+ else {
+ res.status(500).json({ error: "Failed to update email." });
+ }
+ break;
+ }
+ case "expired":
+ res.status(410).json({ error: "Verification code has expired. Please request a new one." });
+ break;
+ case "max_attempts":
+ res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
+ break;
+ case "invalid":
+ res.status(400).json({ error: "Invalid verification code." });
+ break;
+ }
+});
+export { router as emailChangeRouter };
diff --git a/dist/routes/health.js b/dist/routes/health.js
new file mode 100644
index 0000000..700dd4b
--- /dev/null
+++ b/dist/routes/health.js
@@ -0,0 +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("/", 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);
+});
diff --git a/dist/routes/recover.js b/dist/routes/recover.js
new file mode 100644
index 0000000..cf8bc9f
--- /dev/null
+++ b/dist/routes/recover.js
@@ -0,0 +1,74 @@
+import { Router } from "express";
+import rateLimit from "express-rate-limit";
+import { createPendingVerification, verifyCode } from "../services/verification.js";
+import { sendVerificationEmail } from "../services/email.js";
+import { getAllKeys } from "../services/keys.js";
+import logger from "../services/logger.js";
+const router = Router();
+const recoverLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000,
+ max: 3,
+ message: { error: "Too many recovery attempts. Please try again in 1 hour." },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+router.post("/", recoverLimiter, async (req, res) => {
+ const { email } = req.body || {};
+ if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ res.status(400).json({ error: "A valid email address is required." });
+ return;
+ }
+ const cleanEmail = email.trim().toLowerCase();
+ const keys = getAllKeys();
+ const userKey = keys.find(k => k.email === cleanEmail);
+ if (!userKey) {
+ res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
+ return;
+ }
+ const pending = await createPendingVerification(cleanEmail);
+ sendVerificationEmail(cleanEmail, pending.code).catch(err => {
+ logger.error({ err, email: cleanEmail }, "Failed to send recovery email");
+ });
+ res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
+});
+router.post("/verify", recoverLimiter, async (req, res) => {
+ const { email, code } = req.body || {};
+ if (!email || !code) {
+ res.status(400).json({ error: "Email and code are required." });
+ return;
+ }
+ const cleanEmail = email.trim().toLowerCase();
+ const cleanCode = String(code).trim();
+ const result = await verifyCode(cleanEmail, cleanCode);
+ switch (result.status) {
+ case "ok": {
+ const keys = getAllKeys();
+ const userKey = keys.find(k => k.email === cleanEmail);
+ if (userKey) {
+ res.json({
+ status: "recovered",
+ apiKey: userKey.key,
+ tier: userKey.tier,
+ message: "Your API key has been recovered. Save it securely โ it is shown only once.",
+ });
+ }
+ else {
+ res.json({
+ status: "recovered",
+ message: "No API key found for this email.",
+ });
+ }
+ break;
+ }
+ case "expired":
+ res.status(410).json({ error: "Verification code has expired. Please request a new one." });
+ break;
+ case "max_attempts":
+ res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
+ break;
+ case "invalid":
+ res.status(400).json({ error: "Invalid verification code." });
+ break;
+ }
+});
+export { router as recoverRouter };
diff --git a/dist/routes/signup.js b/dist/routes/signup.js
new file mode 100644
index 0000000..56cc898
--- /dev/null
+++ b/dist/routes/signup.js
@@ -0,0 +1,92 @@
+import { Router } from "express";
+import rateLimit from "express-rate-limit";
+import { createFreeKey } from "../services/keys.js";
+import { createVerification, createPendingVerification, verifyCode, isEmailVerified } from "../services/verification.js";
+import { sendVerificationEmail } from "../services/email.js";
+import logger from "../services/logger.js";
+const router = Router();
+const signupLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000,
+ max: 5,
+ message: { error: "Too many signup attempts. Please try again in 1 hour.", retryAfter: "1 hour" },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+const verifyLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 15,
+ message: { error: "Too many verification attempts. Please try again later." },
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+async function rejectDuplicateEmail(req, res, next) {
+ const { email } = req.body || {};
+ if (email && typeof email === "string") {
+ const cleanEmail = email.trim().toLowerCase();
+ if (await isEmailVerified(cleanEmail)) {
+ res.status(409).json({ error: "Email already registered" });
+ return;
+ }
+ }
+ next();
+}
+// Step 1: Request signup โ generates 6-digit code, sends via email
+router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => {
+ const { email } = req.body || {};
+ if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ res.status(400).json({ error: "A valid email address is required." });
+ return;
+ }
+ const cleanEmail = email.trim().toLowerCase();
+ if (await isEmailVerified(cleanEmail)) {
+ res.status(409).json({ error: "This email is already registered. Contact support if you need help." });
+ return;
+ }
+ const pending = await createPendingVerification(cleanEmail);
+ sendVerificationEmail(cleanEmail, pending.code).catch(err => {
+ logger.error({ err, email: cleanEmail }, "Failed to send verification email");
+ });
+ res.json({
+ status: "verification_required",
+ message: "Check your email for the verification code.",
+ });
+});
+// Step 2: Verify code โ creates API key
+router.post("/verify", verifyLimiter, async (req, res) => {
+ const { email, code } = req.body || {};
+ if (!email || !code) {
+ res.status(400).json({ error: "Email and code are required." });
+ return;
+ }
+ const cleanEmail = email.trim().toLowerCase();
+ const cleanCode = String(code).trim();
+ if (await isEmailVerified(cleanEmail)) {
+ res.status(409).json({ error: "This email is already verified." });
+ return;
+ }
+ const result = await verifyCode(cleanEmail, cleanCode);
+ switch (result.status) {
+ case "ok": {
+ const keyInfo = await createFreeKey(cleanEmail);
+ const verification = await createVerification(cleanEmail, keyInfo.key);
+ verification.verifiedAt = new Date().toISOString();
+ res.json({
+ status: "verified",
+ message: "Email verified! Here's your API key.",
+ apiKey: keyInfo.key,
+ tier: keyInfo.tier,
+ });
+ break;
+ }
+ case "expired":
+ res.status(410).json({ error: "Verification code has expired. Please sign up again." });
+ break;
+ case "max_attempts":
+ res.status(429).json({ error: "Too many failed attempts. Please sign up again to get a new code." });
+ break;
+ case "invalid":
+ res.status(400).json({ error: "Invalid verification code." });
+ break;
+ }
+});
+export { router as signupRouter };
diff --git a/dist/routes/templates.js b/dist/routes/templates.js
new file mode 100644
index 0000000..720cac0
--- /dev/null
+++ b/dist/routes/templates.js
@@ -0,0 +1,40 @@
+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
+templatesRouter.get("/", (_req, res) => {
+ const list = Object.entries(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
+templatesRouter.post("/:id/render", async (req, res) => {
+ try {
+ const id = req.params.id;
+ const template = templates[id];
+ if (!template) {
+ res.status(404).json({ error: `Template '${id}' not found` });
+ return;
+ }
+ const data = req.body.data || req.body;
+ const html = renderTemplate(id, data);
+ const pdf = await 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) {
+ logger.error({ err }, "Template render error");
+ res.status(500).json({ error: "Template rendering failed", detail: err.message });
+ }
+});
diff --git a/dist/services/browser.js b/dist/services/browser.js
new file mode 100644
index 0000000..7516106
--- /dev/null
+++ b/dist/services/browser.js
@@ -0,0 +1,246 @@
+import puppeteer from "puppeteer";
+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;
+ inst.browser = await puppeteer.launch({
+ headless: true,
+ executablePath: execPath,
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
+ });
+ 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() {
+ 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 = {}) {
+ const { page, instance } = await acquirePage();
+ try {
+ 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 {
+ releasePage(page, instance);
+ }
+}
+export async function renderUrlPdf(url, options = {}) {
+ const { page, instance } = await acquirePage();
+ try {
+ 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 {
+ releasePage(page, instance);
+ }
+}
diff --git a/dist/services/database.js b/dist/services/database.js
new file mode 100644
index 0000000..35635f7
--- /dev/null
+++ b/dist/services/database.js
@@ -0,0 +1,123 @@
+import Database from "better-sqlite3";
+import path from "path";
+import { fileURLToPath } from "url";
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DB_PATH = path.join(__dirname, "../../data/docfast.db");
+class DatabaseService {
+ db;
+ constructor() {
+ this.db = new Database(DB_PATH);
+ this.initialize();
+ }
+ initialize() {
+ // Enable WAL mode for better performance
+ this.db.pragma("journal_mode = WAL");
+ // Create tables
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS keys (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL,
+ api_key TEXT UNIQUE NOT NULL,
+ tier TEXT NOT NULL CHECK (tier IN ('free', 'pro')),
+ created_at TEXT NOT NULL,
+ usage_count INTEGER DEFAULT 0,
+ usage_month TEXT NOT NULL,
+ stripe_customer_id TEXT
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_keys_api_key ON keys(api_key);
+ CREATE INDEX IF NOT EXISTS idx_keys_email ON keys(email);
+ CREATE INDEX IF NOT EXISTS idx_keys_stripe_customer_id ON keys(stripe_customer_id);
+
+ CREATE TABLE IF NOT EXISTS usage (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ api_key TEXT NOT NULL,
+ endpoint TEXT NOT NULL,
+ timestamp TEXT NOT NULL,
+ FOREIGN KEY (api_key) REFERENCES keys(api_key)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_usage_api_key ON usage(api_key);
+ CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage(timestamp);
+ `);
+ }
+ // Key operations
+ insertKey(key) {
+ const stmt = this.db.prepare(`
+ INSERT INTO keys (email, api_key, tier, created_at, usage_count, usage_month, stripe_customer_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `);
+ const result = stmt.run(key.email, key.api_key, key.tier, key.created_at, key.usage_count, key.usage_month, key.stripe_customer_id || null);
+ return { ...key, id: result.lastInsertRowid };
+ }
+ getKeyByApiKey(apiKey) {
+ const stmt = this.db.prepare("SELECT * FROM keys WHERE api_key = ?");
+ return stmt.get(apiKey);
+ }
+ getKeyByEmail(email, tier) {
+ const stmt = this.db.prepare("SELECT * FROM keys WHERE email = ? AND tier = ?");
+ return stmt.get(email, tier);
+ }
+ getKeyByStripeCustomerId(stripeCustomerId) {
+ const stmt = this.db.prepare("SELECT * FROM keys WHERE stripe_customer_id = ?");
+ return stmt.get(stripeCustomerId);
+ }
+ updateKeyTier(apiKey, tier) {
+ const stmt = this.db.prepare("UPDATE keys SET tier = ? WHERE api_key = ?");
+ const result = stmt.run(tier, apiKey);
+ return result.changes > 0;
+ }
+ deleteKeyByStripeCustomerId(stripeCustomerId) {
+ const stmt = this.db.prepare("DELETE FROM keys WHERE stripe_customer_id = ?");
+ const result = stmt.run(stripeCustomerId);
+ return result.changes > 0;
+ }
+ getAllKeys() {
+ const stmt = this.db.prepare("SELECT * FROM keys");
+ return stmt.all();
+ }
+ // Usage operations
+ insertUsage(usage) {
+ const stmt = this.db.prepare(`
+ INSERT INTO usage (api_key, endpoint, timestamp)
+ VALUES (?, ?, ?)
+ `);
+ const result = stmt.run(usage.api_key, usage.endpoint, usage.timestamp);
+ return { ...usage, id: result.lastInsertRowid };
+ }
+ getUsageForKey(apiKey, fromDate, toDate) {
+ let query = "SELECT * FROM usage WHERE api_key = ?";
+ const params = [apiKey];
+ if (fromDate && toDate) {
+ query += " AND timestamp >= ? AND timestamp <= ?";
+ params.push(fromDate, toDate);
+ }
+ else if (fromDate) {
+ query += " AND timestamp >= ?";
+ params.push(fromDate);
+ }
+ query += " ORDER BY timestamp DESC";
+ const stmt = this.db.prepare(query);
+ return stmt.all(...params);
+ }
+ // Utility method to migrate existing JSON data
+ migrateFromJson(jsonKeys) {
+ const insertStmt = this.db.prepare(`
+ INSERT OR IGNORE INTO keys (email, api_key, tier, created_at, usage_count, usage_month, stripe_customer_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ `);
+ const transaction = this.db.transaction((keys) => {
+ for (const key of keys) {
+ const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
+ insertStmt.run(key.email || "", key.key, key.tier, key.createdAt, 0, // reset usage count
+ currentMonth, key.stripeCustomerId || null);
+ }
+ });
+ transaction(jsonKeys);
+ }
+ close() {
+ this.db.close();
+ }
+}
+// Export singleton instance
+export const db = new DatabaseService();
diff --git a/dist/services/db.js b/dist/services/db.js
new file mode 100644
index 0000000..4d4b85a
--- /dev/null
+++ b/dist/services/db.js
@@ -0,0 +1,62 @@
+import pg from "pg";
+import logger from "./logger.js";
+const { Pool } = pg;
+const pool = new Pool({
+ host: process.env.DATABASE_HOST || "172.17.0.1",
+ port: parseInt(process.env.DATABASE_PORT || "5432", 10),
+ database: process.env.DATABASE_NAME || "docfast",
+ user: process.env.DATABASE_USER || "docfast",
+ password: process.env.DATABASE_PASSWORD || "docfast",
+ max: 10,
+ idleTimeoutMillis: 30000,
+});
+pool.on("error", (err) => {
+ logger.error({ err }, "Unexpected PostgreSQL pool error");
+});
+export async function initDatabase() {
+ const client = await pool.connect();
+ try {
+ await client.query(`
+ CREATE TABLE IF NOT EXISTS api_keys (
+ key TEXT PRIMARY KEY,
+ tier TEXT NOT NULL DEFAULT 'free',
+ email TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ stripe_customer_id TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email);
+ CREATE INDEX IF NOT EXISTS idx_api_keys_stripe ON api_keys(stripe_customer_id);
+
+ CREATE TABLE IF NOT EXISTS verifications (
+ id SERIAL PRIMARY KEY,
+ email TEXT NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ api_key TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ verified_at TIMESTAMPTZ
+ );
+ CREATE INDEX IF NOT EXISTS idx_verifications_email ON verifications(email);
+ CREATE INDEX IF NOT EXISTS idx_verifications_token ON verifications(token);
+
+ CREATE TABLE IF NOT EXISTS pending_verifications (
+ email TEXT PRIMARY KEY,
+ code TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL,
+ attempts INT NOT NULL DEFAULT 0
+ );
+
+ CREATE TABLE IF NOT EXISTS usage (
+ key TEXT PRIMARY KEY,
+ count INT NOT NULL DEFAULT 0,
+ month_key TEXT NOT NULL
+ );
+ `);
+ logger.info("PostgreSQL tables initialized");
+ }
+ finally {
+ client.release();
+ }
+}
+export { pool };
+export default pool;
diff --git a/dist/services/email.js b/dist/services/email.js
new file mode 100644
index 0000000..d7efdf2
--- /dev/null
+++ b/dist/services/email.js
@@ -0,0 +1,29 @@
+import nodemailer from "nodemailer";
+import logger from "./logger.js";
+const transporter = nodemailer.createTransport({
+ host: process.env.SMTP_HOST || "host.docker.internal",
+ port: Number(process.env.SMTP_PORT || 25),
+ secure: false,
+ connectionTimeout: 5000,
+ greetingTimeout: 5000,
+ socketTimeout: 10000,
+ tls: { rejectUnauthorized: false },
+});
+export async function sendVerificationEmail(email, code) {
+ try {
+ const info = await transporter.sendMail({
+ from: "DocFast ",
+ to: email,
+ subject: "DocFast - Verify your email",
+ text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.`,
+ });
+ logger.info({ email, messageId: info.messageId }, "Verification email sent");
+ return true;
+ }
+ catch (err) {
+ logger.error({ err, email }, "Failed to send verification email");
+ return false;
+ }
+}
+// NOTE: sendRecoveryEmail removed โ API keys must NEVER be sent via email.
+// Key recovery now shows the key in the browser after code verification.
diff --git a/dist/services/keys.js b/dist/services/keys.js
new file mode 100644
index 0000000..2424060
--- /dev/null
+++ b/dist/services/keys.js
@@ -0,0 +1,100 @@
+import { randomBytes } from "crypto";
+import logger from "./logger.js";
+import pool from "./db.js";
+// In-memory cache for fast lookups, synced with PostgreSQL
+let keysCache = [];
+export async function loadKeys() {
+ try {
+ const result = await pool.query("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys");
+ keysCache = result.rows.map((r) => ({
+ key: r.key,
+ tier: r.tier,
+ email: r.email,
+ createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
+ stripeCustomerId: r.stripe_customer_id || undefined,
+ }));
+ }
+ catch (err) {
+ logger.error({ err }, "Failed to load keys from PostgreSQL");
+ keysCache = [];
+ }
+ // Also load seed keys from env
+ const envKeys = process.env.API_KEYS?.split(",").map((k) => k.trim()).filter(Boolean) || [];
+ for (const k of envKeys) {
+ if (!keysCache.find((e) => e.key === k)) {
+ const entry = { key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() };
+ keysCache.push(entry);
+ // Upsert into DB
+ await pool.query(`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (key) DO NOTHING`, [k, "pro", "seed@docfast.dev", new Date().toISOString()]).catch(() => { });
+ }
+ }
+}
+export function isValidKey(key) {
+ return keysCache.some((k) => k.key === key);
+}
+export function getKeyInfo(key) {
+ return keysCache.find((k) => k.key === key);
+}
+export function isProKey(key) {
+ const info = getKeyInfo(key);
+ return info?.tier === "pro";
+}
+function generateKey(prefix) {
+ return `${prefix}_${randomBytes(24).toString("hex")}`;
+}
+export async function createFreeKey(email) {
+ if (email) {
+ const existing = keysCache.find((k) => k.email === email && k.tier === "free");
+ if (existing)
+ return existing;
+ }
+ const entry = {
+ key: generateKey("df_free"),
+ tier: "free",
+ email: email || "",
+ createdAt: new Date().toISOString(),
+ };
+ await pool.query("INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)", [entry.key, entry.tier, entry.email, entry.createdAt]);
+ keysCache.push(entry);
+ return entry;
+}
+export async function createProKey(email, stripeCustomerId) {
+ const existing = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
+ if (existing) {
+ existing.tier = "pro";
+ await pool.query("UPDATE api_keys SET tier = 'pro' WHERE key = $1", [existing.key]);
+ return existing;
+ }
+ const entry = {
+ key: generateKey("df_pro"),
+ tier: "pro",
+ email,
+ createdAt: new Date().toISOString(),
+ stripeCustomerId,
+ };
+ await pool.query("INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)", [entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId]);
+ keysCache.push(entry);
+ return entry;
+}
+export async function revokeByCustomer(stripeCustomerId) {
+ const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
+ if (idx >= 0) {
+ const key = keysCache[idx].key;
+ keysCache.splice(idx, 1);
+ await pool.query("DELETE FROM api_keys WHERE key = $1", [key]);
+ return true;
+ }
+ return false;
+}
+export function getAllKeys() {
+ return [...keysCache];
+}
+export async function updateKeyEmail(apiKey, newEmail) {
+ const entry = keysCache.find((k) => k.key === apiKey);
+ if (!entry)
+ return false;
+ entry.email = newEmail;
+ await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
+ return true;
+}
diff --git a/dist/services/logger.js b/dist/services/logger.js
new file mode 100644
index 0000000..cff6294
--- /dev/null
+++ b/dist/services/logger.js
@@ -0,0 +1,8 @@
+import pino from "pino";
+const logger = pino({
+ level: process.env.LOG_LEVEL || "info",
+ ...(process.env.NODE_ENV !== "production" && {
+ transport: { target: "pino/file", options: { destination: 1 } },
+ }),
+});
+export default logger;
diff --git a/dist/services/markdown.js b/dist/services/markdown.js
new file mode 100644
index 0000000..55d2b0a
--- /dev/null
+++ b/dist/services/markdown.js
@@ -0,0 +1,30 @@
+import { marked } from "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%; }
+`;
+export function markdownToHtml(md, css) {
+ const html = marked.parse(md, { async: false });
+ return wrapHtml(html, css || defaultCss);
+}
+export function wrapHtml(body, css) {
+ return `
+
+${body}`;
+}
diff --git a/dist/services/templates.js b/dist/services/templates.js
new file mode 100644
index 0000000..585387e
--- /dev/null
+++ b/dist/services/templates.js
@@ -0,0 +1,163 @@
+export const 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, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+function renderInvoice(d) {
+ const cur = esc(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 `
+ | ${esc(item.description)} |
+ ${qty} |
+ ${cur}${price.toFixed(2)} |
+ ${taxRate}% |
+ ${cur}${lineTotal.toFixed(2)} |
+
`;
+ })
+ .join("");
+ const total = subtotal + totalTax;
+ const from = d.from || {};
+ const to = d.to || {};
+ return `
+
+
+
+
From
+
${esc(from.name)}
+ ${from.address ? `
${esc(from.address).replace(/\n/g, "
")}
` : ""}
+ ${from.email ? `
${esc(from.email)}
` : ""}
+ ${from.vatId ? `
VAT: ${esc(from.vatId)}
` : ""}
+
+
+
To
+
${esc(to.name)}
+ ${to.address ? `
${esc(to.address).replace(/\n/g, "
")}
` : ""}
+ ${to.email ? `
${esc(to.email)}
` : ""}
+ ${to.vatId ? `
VAT: ${esc(to.vatId)}
` : ""}
+
+
+
+ | Description | Qty | Price | Tax | Total |
+ ${rows}
+
+
+
Subtotal: ${cur}${subtotal.toFixed(2)}
+
Tax: ${cur}${totalTax.toFixed(2)}
+
Total: ${cur}${total.toFixed(2)}
+
+ ${d.paymentDetails ? `` : ""}
+ ${d.notes ? `` : ""}
+ `;
+}
+function renderReceipt(d) {
+ const cur = esc(d.currency || "โฌ");
+ const items = d.items || [];
+ let total = 0;
+ const rows = items
+ .map((item) => {
+ const amount = Number(item.amount) || 0;
+ total += amount;
+ return `| ${esc(item.description)} | ${cur}${amount.toFixed(2)} |
`;
+ })
+ .join("");
+ const from = d.from || {};
+ const to = d.to || {};
+ return `
+ ${esc(from.name)}
+ ${from.address ? `${esc(from.address)}
` : ""}
+
+ Receipt #${esc(d.receiptNumber)}
+ Date: ${esc(d.date)}
+ ${to?.name ? `Customer: ${esc(to.name)}
` : ""}
+
+
+
+ | TOTAL | ${cur}${total.toFixed(2)} |
+ ${d.paymentMethod ? `
Paid via: ${esc(d.paymentMethod)}
` : ""}
+
Thank you!
+ `;
+}
+export function renderTemplate(id, data) {
+ const template = templates[id];
+ if (!template)
+ throw new Error(`Template '${id}' not found`);
+ return template.render(data);
+}
diff --git a/dist/services/verification.js b/dist/services/verification.js
new file mode 100644
index 0000000..de1e61e
--- /dev/null
+++ b/dist/services/verification.js
@@ -0,0 +1,103 @@
+import { randomBytes, randomInt } from "crypto";
+import logger from "./logger.js";
+import pool from "./db.js";
+const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
+const CODE_EXPIRY_MS = 15 * 60 * 1000;
+const MAX_ATTEMPTS = 3;
+export async function createVerification(email, apiKey) {
+ // Check for existing unexpired, unverified
+ const existing = await pool.query("SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1", [email]);
+ if (existing.rows.length > 0) {
+ const r = existing.rows[0];
+ return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null };
+ }
+ // Remove old unverified
+ await pool.query("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]);
+ const token = randomBytes(32).toString("hex");
+ const now = new Date().toISOString();
+ await pool.query("INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)", [email, token, apiKey, now]);
+ return { email, token, apiKey, createdAt: now, verifiedAt: null };
+}
+export function verifyToken(token) {
+ // Synchronous wrapper โ we'll make it async-compatible
+ // Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor.
+ // For simplicity, we'll cache verifications in memory too.
+ return verifyTokenSync(token);
+}
+// In-memory cache for verifications (loaded on startup, updated on changes)
+let verificationsCache = [];
+export async function loadVerifications() {
+ const result = await pool.query("SELECT * FROM verifications");
+ verificationsCache = result.rows.map((r) => ({
+ email: r.email,
+ token: r.token,
+ apiKey: r.api_key,
+ createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
+ verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null,
+ }));
+ // Cleanup expired entries every 15 minutes
+ setInterval(() => {
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+ const before = verificationsCache.length;
+ verificationsCache = verificationsCache.filter((v) => v.verifiedAt || new Date(v.createdAt).getTime() > cutoff);
+ const removed = before - verificationsCache.length;
+ if (removed > 0)
+ logger.info({ removed }, "Cleaned expired verification cache entries");
+ }, 15 * 60 * 1000);
+}
+function verifyTokenSync(token) {
+ const v = verificationsCache.find((v) => v.token === token);
+ if (!v)
+ return { status: "invalid" };
+ if (v.verifiedAt)
+ return { status: "already_verified", verification: v };
+ const age = Date.now() - new Date(v.createdAt).getTime();
+ if (age > TOKEN_EXPIRY_MS)
+ return { status: "expired" };
+ v.verifiedAt = new Date().toISOString();
+ // Update DB async
+ pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch((err) => logger.error({ err }, "Failed to update verification"));
+ return { status: "ok", verification: v };
+}
+export async function createPendingVerification(email) {
+ await pool.query("DELETE FROM pending_verifications WHERE email = $1", [email]);
+ const now = new Date();
+ const pending = {
+ email,
+ code: String(randomInt(100000, 999999)),
+ createdAt: now.toISOString(),
+ expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(),
+ attempts: 0,
+ };
+ await pool.query("INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)", [pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts]);
+ return pending;
+}
+export async function verifyCode(email, code) {
+ const cleanEmail = email.trim().toLowerCase();
+ const result = await pool.query("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]);
+ const pending = result.rows[0];
+ if (!pending)
+ return { status: "invalid" };
+ if (new Date() > new Date(pending.expires_at)) {
+ await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
+ return { status: "expired" };
+ }
+ if (pending.attempts >= MAX_ATTEMPTS) {
+ await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
+ return { status: "max_attempts" };
+ }
+ await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]);
+ if (pending.code !== code) {
+ return { status: "invalid" };
+ }
+ await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
+ return { status: "ok" };
+}
+export async function isEmailVerified(email) {
+ const result = await pool.query("SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]);
+ return result.rows.length > 0;
+}
+export async function getVerifiedApiKey(email) {
+ const result = await pool.query("SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]);
+ return result.rows[0]?.api_key ?? null;
+}
diff --git a/src/index.ts b/src/index.ts
index 93f4e74..fc05510 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -186,6 +186,22 @@ app.get("/docs", (_req, res) => {
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({