merge: resolve conflicts, keep standardized footer versions
Some checks failed
Deploy to Production / Deploy to Server (push) Failing after 20s

This commit is contained in:
OpenClaw 2026-02-16 15:04:24 +00:00
commit 59d563841d
22 changed files with 2195 additions and 0 deletions

122
dist/__tests__/api.test.js vendored Normal file
View file

@ -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: "<h1>Test</h1>" }),
});
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);
});
});

286
dist/index.js vendored Normal file
View file

@ -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 `<!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"), {
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(`<!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();
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 };

23
dist/middleware/auth.js vendored Normal file
View file

@ -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 <key> or X-API-Key: <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();
}

91
dist/middleware/pdfRateLimit.js vendored Normal file
View file

@ -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);

75
dist/middleware/usage.js vendored Normal file
View file

@ -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;
}

187
dist/routes/billing.js vendored Normal file
View file

@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
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(`<!DOCTYPE html>
<html><head><title>Welcome to DocFast Pro!</title>
<style>
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
h1 { color: #4f9; margin-bottom: 8px; }
.key { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 16px; margin: 24px 0; font-family: monospace; font-size: 0.9rem; word-break: break-all; cursor: pointer; }
.key:hover { border-color: #4f9; }
p { color: #888; line-height: 1.6; }
a { color: #4f9; }
</style></head><body>
<div class="card">
<h1>🎉 Welcome to Pro!</h1>
<p>Your API key:</p>
<div class="key" onclick="navigator.clipboard.writeText('${escapeHtml(keyInfo.key)}')" title="Click to copy">${escapeHtml(keyInfo.key)}</div>
<p><strong>Save this key!</strong> It won't be shown again.</p>
<p>10,000 PDFs/month All endpoints Priority support</p>
<p><a href="/docs">View API docs </a></p>
</div></body></html>`);
}
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 };

189
dist/routes/convert.js vendored Normal file
View file

@ -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("<html")
? body.html
: wrapHtml(body.html, body.css);
const pdf = await 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) {
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,
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();
}
}
});

82
dist/routes/email-change.js vendored Normal file
View file

@ -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 };

54
dist/routes/health.js vendored Normal file
View file

@ -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);
});

74
dist/routes/recover.js vendored Normal file
View file

@ -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 };

92
dist/routes/signup.js vendored Normal file
View file

@ -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 };

40
dist/routes/templates.js vendored Normal file
View file

@ -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 });
}
});

246
dist/services/browser.js vendored Normal file
View file

@ -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);
}
}

123
dist/services/database.js vendored Normal file
View file

@ -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();

62
dist/services/db.js vendored Normal file
View file

@ -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;

29
dist/services/email.js vendored Normal file
View file

@ -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 <noreply@docfast.dev>",
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.

100
dist/services/keys.js vendored Normal file
View file

@ -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;
}

8
dist/services/logger.js vendored Normal file
View file

@ -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;

30
dist/services/markdown.js vendored Normal file
View file

@ -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 `<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>${css || defaultCss}</style></head>
<body>${body}</body></html>`;
}

163
dist/services/templates.js vendored Normal file
View file

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 `<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 = esc(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>`;
}
export function renderTemplate(id, data) {
const template = templates[id];
if (!template)
throw new Error(`Template '${id}' not found`);
return template.render(data);
}

103
dist/services/verification.js vendored Normal file
View file

@ -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;
}

View file

@ -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({