feat: Add built dist files with EU compliance routes
Some checks failed
Deploy to Production / Deploy to Server (push) Failing after 20s
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:
parent
5ef8f34133
commit
1ef8f5743c
21 changed files with 2179 additions and 0 deletions
246
dist/services/browser.js
vendored
Normal file
246
dist/services/browser.js
vendored
Normal 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
123
dist/services/database.js
vendored
Normal 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
62
dist/services/db.js
vendored
Normal 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
29
dist/services/email.js
vendored
Normal 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
100
dist/services/keys.js
vendored
Normal 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
8
dist/services/logger.js
vendored
Normal 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
30
dist/services/markdown.js
vendored
Normal 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
163
dist/services/templates.js
vendored
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
function renderInvoice(d) {
|
||||
const cur = esc(d.currency || "€");
|
||||
const items = d.items || [];
|
||||
let subtotal = 0;
|
||||
let totalTax = 0;
|
||||
const rows = items
|
||||
.map((item) => {
|
||||
const qty = Number(item.quantity) || 1;
|
||||
const price = Number(item.unitPrice) || 0;
|
||||
const taxRate = Number(item.taxRate) || 0;
|
||||
const lineTotal = qty * price;
|
||||
const lineTax = lineTotal * (taxRate / 100);
|
||||
subtotal += lineTotal;
|
||||
totalTax += lineTax;
|
||||
return `<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
103
dist/services/verification.js
vendored
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue