feat: Add built dist files with EU compliance routes
Some checks failed
Deploy to Production / Deploy to Server (push) Failing after 20s

- Include compiled TypeScript with new /impressum, /privacy, /terms routes
- Temporary commit of dist files for Docker deployment
This commit is contained in:
openclawd 2026-02-16 13:09:25 +00:00
parent 5ef8f34133
commit 1ef8f5743c
21 changed files with 2179 additions and 0 deletions

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