diff --git a/dist/__tests__/api.test.js b/dist/__tests__/api.test.js new file mode 100644 index 0000000..b99fca3 --- /dev/null +++ b/dist/__tests__/api.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { app } from "../index.js"; +// Note: These tests require Puppeteer/Chrome to be available +// For CI, use the Dockerfile which includes Chrome +const BASE = "http://localhost:3199"; +let server; +beforeAll(async () => { + process.env.API_KEYS = "test-key"; + process.env.PORT = "3199"; + // Import fresh to pick up env + server = app.listen(3199); + // Wait for browser init + await new Promise((r) => setTimeout(r, 2000)); +}); +afterAll(async () => { + server?.close(); +}); +describe("Auth", () => { + it("rejects requests without API key", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" }); + expect(res.status).toBe(401); + }); + it("rejects invalid API key", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer wrong-key" }, + }); + expect(res.status).toBe(403); + }); +}); +describe("Health", () => { + it("returns ok", async () => { + const res = await fetch(`${BASE}/health`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.status).toBe("ok"); + }); +}); +describe("HTML to PDF", () => { + it("converts simple HTML", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ html: "

Test

" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + const buf = await res.arrayBuffer(); + expect(buf.byteLength).toBeGreaterThan(100); + // PDF magic bytes + const header = new Uint8Array(buf.slice(0, 5)); + expect(String.fromCharCode(...header)).toBe("%PDF-"); + }); + it("rejects missing html field", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(400); + }); +}); +describe("Markdown to PDF", () => { + it("converts markdown", async () => { + const res = await fetch(`${BASE}/v1/convert/markdown`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ markdown: "# Hello\n\nWorld" }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); +}); +describe("Templates", () => { + it("lists templates", async () => { + const res = await fetch(`${BASE}/v1/templates`, { + headers: { Authorization: "Bearer test-key" }, + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.templates).toBeInstanceOf(Array); + expect(data.templates.length).toBeGreaterThan(0); + }); + it("renders invoice template", async () => { + const res = await fetch(`${BASE}/v1/templates/invoice/render`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + invoiceNumber: "TEST-001", + date: "2026-02-14", + from: { name: "Seller", email: "s@test.com" }, + to: { name: "Buyer", email: "b@test.com" }, + items: [{ description: "Widget", quantity: 2, unitPrice: 50, taxRate: 20 }], + }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/pdf"); + }); + it("returns 404 for unknown template", async () => { + const res = await fetch(`${BASE}/v1/templates/nonexistent/render`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + expect(res.status).toBe(404); + }); +}); diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..416df55 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,286 @@ +import express from "express"; +import { randomUUID } from "crypto"; +import compression from "compression"; +import logger from "./services/logger.js"; +import helmet from "helmet"; +import path from "path"; +import { fileURLToPath } from "url"; +import rateLimit from "express-rate-limit"; +import { convertRouter } from "./routes/convert.js"; +import { templatesRouter } from "./routes/templates.js"; +import { healthRouter } from "./routes/health.js"; +import { signupRouter } from "./routes/signup.js"; +import { recoverRouter } from "./routes/recover.js"; +import { billingRouter } from "./routes/billing.js"; +import { emailChangeRouter } from "./routes/email-change.js"; +import { authMiddleware } from "./middleware/auth.js"; +import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; +import { getUsageStats } from "./middleware/usage.js"; +import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; +import { initBrowser, closeBrowser } from "./services/browser.js"; +import { loadKeys, getAllKeys } from "./services/keys.js"; +import { verifyToken, loadVerifications } from "./services/verification.js"; +import { initDatabase } from "./services/db.js"; +const app = express(); +const PORT = parseInt(process.env.PORT || "3100", 10); +app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); +// Request ID + request logging middleware +app.use((req, res, next) => { + const requestId = req.headers["x-request-id"] || randomUUID(); + req.requestId = requestId; + res.setHeader("X-Request-Id", requestId); + const start = Date.now(); + res.on("finish", () => { + const ms = Date.now() - start; + if (req.path !== "/health") { + logger.info({ method: req.method, path: req.path, status: res.statusCode, ms, requestId }, "request"); + } + }); + next(); +}); +// Permissions-Policy header +app.use((_req, res, next) => { + res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(self)"); + next(); +}); +// Compression +app.use(compression()); +// Differentiated CORS middleware +app.use((req, res, next) => { + const isAuthBillingRoute = req.path.startsWith('/v1/signup') || + req.path.startsWith('/v1/recover') || + req.path.startsWith('/v1/billing') || + req.path.startsWith('/v1/email-change'); + if (isAuthBillingRoute) { + res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); + } + else { + res.setHeader("Access-Control-Allow-Origin", "*"); + } + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"); + res.setHeader("Access-Control-Max-Age", "86400"); + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); +}); +// Raw body for Stripe webhook signature verification +app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); +app.use(express.json({ limit: "2mb" })); +app.use(express.text({ limit: "2mb", type: "text/*" })); +// Trust nginx proxy +app.set("trust proxy", 1); +// Global rate limiting - reduced from 10,000 to reasonable limit +const limiter = rateLimit({ + windowMs: 60_000, + max: 100, + standardHeaders: true, + legacyHeaders: false, +}); +app.use(limiter); +// Public routes +app.use("/health", healthRouter); +app.use("/v1/signup", signupRouter); +app.use("/v1/recover", recoverRouter); +app.use("/v1/billing", billingRouter); +app.use("/v1/email-change", emailChangeRouter); +// Authenticated routes +app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); +app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); +// Admin: usage stats +app.get("/v1/usage", authMiddleware, (_req, res) => { + res.json(getUsageStats()); +}); +// Admin: concurrency stats +app.get("/v1/concurrency", authMiddleware, (_req, res) => { + res.json(getConcurrencyStats()); +}); +// Email verification endpoint +app.get("/verify", (req, res) => { + const token = req.query.token; + if (!token) { + res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null)); + return; + } + const result = verifyToken(token); + switch (result.status) { + case "ok": + res.send(verifyPage("Email Verified! ๐Ÿš€", "Your DocFast API key is ready:", result.verification.apiKey)); + break; + case "already_verified": + res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification.apiKey)); + break; + case "expired": + res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null)); + break; + case "invalid": + res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null)); + break; + } +}); +function verifyPage(title, message, apiKey) { + return ` + +${title} โ€” DocFast + + + +
+

${title}

+

${message}

+${apiKey ? ` +
โš ๏ธ Save your API key securely. You can recover it via email if needed.
+
${apiKey}
+ +` : ``} +
`; +} +// Landing page +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +app.use(express.static(path.join(__dirname, "../public"), { + maxAge: "1d", + etag: true, + setHeaders: (res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + } +})); +// Docs page (clean URL) +app.get("/docs", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.join(__dirname, "../public/docs.html")); +}); +// Legal pages (clean URLs) +app.get("/impressum", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.join(__dirname, "../public/impressum.html")); +}); +app.get("/privacy", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.join(__dirname, "../public/privacy.html")); +}); +app.get("/terms", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.join(__dirname, "../public/terms.html")); +}); +// API root +app.get("/api", (_req, res) => { + res.json({ + name: "DocFast API", + version: "0.2.1", + endpoints: [ + "POST /v1/signup/free โ€” Get a free API key", + "POST /v1/convert/html", + "POST /v1/convert/markdown", + "POST /v1/convert/url", + "POST /v1/templates/:id/render", + "GET /v1/templates", + "POST /v1/billing/checkout โ€” Start Pro subscription", + ], + }); +}); +// 404 handler - must be after all routes +app.use((req, res) => { + // Check if it's an API request + const isApiRequest = req.path.startsWith('/v1/') || req.path.startsWith('/api') || req.path.startsWith('/health'); + if (isApiRequest) { + // JSON 404 for API paths + res.status(404).json({ + error: "Not Found", + message: `The requested endpoint ${req.method} ${req.path} does not exist`, + statusCode: 404, + timestamp: new Date().toISOString() + }); + } + else { + // HTML 404 for browser paths + res.status(404).send(` + + + + + 404 - Page Not Found | DocFast + + + + + +
+
โšก
+

404

+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

+

โ† Back to DocFast | Read the docs

+
+ +`); + } +}); +// 404 handler โ€” must be after all routes +app.use((req, res) => { + if (req.path.startsWith("/v1/")) { + res.status(404).json({ error: "Not found" }); + } + else { + const accepts = req.headers.accept || ""; + if (accepts.includes("text/html")) { + res.status(404).send(` + +404 โ€” DocFast + + +

404

Page not found.

โ† Back to DocFast ยท API Docs

`); + } + else { + res.status(404).json({ error: "Not found" }); + } + } +}); +async function start() { + // Initialize PostgreSQL + await initDatabase(); + // Load data from PostgreSQL + await loadKeys(); + await loadVerifications(); + await loadUsageData(); + await initBrowser(); + logger.info(`Loaded ${getAllKeys().length} API keys`); + app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`)); + const shutdown = async () => { + logger.info("Shutting down..."); + await closeBrowser(); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} +start().catch((err) => { + logger.error({ err }, "Failed to start"); + process.exit(1); +}); +export { app }; diff --git a/dist/middleware/auth.js b/dist/middleware/auth.js new file mode 100644 index 0000000..5b6647f --- /dev/null +++ b/dist/middleware/auth.js @@ -0,0 +1,23 @@ +import { isValidKey, getKeyInfo } from "../services/keys.js"; +export function authMiddleware(req, res, next) { + const header = req.headers.authorization; + const xApiKey = req.headers["x-api-key"]; + let key; + if (header?.startsWith("Bearer ")) { + key = header.slice(7); + } + else if (xApiKey) { + key = xApiKey; + } + if (!key) { + res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer or X-API-Key: " }); + return; + } + if (!isValidKey(key)) { + res.status(403).json({ error: "Invalid API key" }); + return; + } + // Attach key info to request for downstream use + req.apiKeyInfo = getKeyInfo(key); + next(); +} diff --git a/dist/middleware/pdfRateLimit.js b/dist/middleware/pdfRateLimit.js new file mode 100644 index 0000000..2b84953 --- /dev/null +++ b/dist/middleware/pdfRateLimit.js @@ -0,0 +1,91 @@ +import { isProKey } from "../services/keys.js"; +// Per-key rate limits (requests per minute) +const FREE_RATE_LIMIT = 10; +const PRO_RATE_LIMIT = 30; +const RATE_WINDOW_MS = 60_000; // 1 minute +// Concurrency limits +const MAX_CONCURRENT_PDFS = 3; +const MAX_QUEUE_SIZE = 10; +const rateLimitStore = new Map(); +let activePdfCount = 0; +const pdfQueue = []; +function cleanupExpiredEntries() { + const now = Date.now(); + for (const [key, entry] of rateLimitStore.entries()) { + if (now >= entry.resetTime) { + rateLimitStore.delete(key); + } + } +} +function getRateLimit(apiKey) { + return isProKey(apiKey) ? PRO_RATE_LIMIT : FREE_RATE_LIMIT; +} +function checkRateLimit(apiKey) { + cleanupExpiredEntries(); + const now = Date.now(); + const limit = getRateLimit(apiKey); + const entry = rateLimitStore.get(apiKey); + if (!entry || now >= entry.resetTime) { + // Create new window + rateLimitStore.set(apiKey, { + count: 1, + resetTime: now + RATE_WINDOW_MS + }); + return true; + } + if (entry.count >= limit) { + return false; + } + entry.count++; + return true; +} +async function acquireConcurrencySlot() { + if (activePdfCount < MAX_CONCURRENT_PDFS) { + activePdfCount++; + return; + } + if (pdfQueue.length >= MAX_QUEUE_SIZE) { + throw new Error("QUEUE_FULL"); + } + return new Promise((resolve, reject) => { + pdfQueue.push({ resolve, reject }); + }); +} +function releaseConcurrencySlot() { + activePdfCount--; + const waiter = pdfQueue.shift(); + if (waiter) { + activePdfCount++; + waiter.resolve(); + } +} +export function pdfRateLimitMiddleware(req, res, next) { + const keyInfo = req.apiKeyInfo; + const apiKey = keyInfo?.key || "unknown"; + // Check rate limit first + if (!checkRateLimit(apiKey)) { + const limit = getRateLimit(apiKey); + const tier = isProKey(apiKey) ? "pro" : "free"; + res.status(429).json({ + error: "Rate limit exceeded", + limit: `${limit} PDFs per minute`, + tier, + retryAfter: "60 seconds" + }); + return; + } + // Add concurrency control to the request + req.acquirePdfSlot = acquireConcurrencySlot; + req.releasePdfSlot = releaseConcurrencySlot; + next(); +} +export function getConcurrencyStats() { + return { + activePdfCount, + queueSize: pdfQueue.length, + maxConcurrent: MAX_CONCURRENT_PDFS, + maxQueue: MAX_QUEUE_SIZE + }; +} +// Proactive cleanup every 60s +setInterval(cleanupExpiredEntries, 60_000); diff --git a/dist/middleware/usage.js b/dist/middleware/usage.js new file mode 100644 index 0000000..0df84f5 --- /dev/null +++ b/dist/middleware/usage.js @@ -0,0 +1,75 @@ +import { isProKey } from "../services/keys.js"; +import logger from "../services/logger.js"; +import pool from "../services/db.js"; +const FREE_TIER_LIMIT = 100; +// In-memory cache, periodically synced to PostgreSQL +let usage = new Map(); +function getMonthKey() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} +export async function loadUsageData() { + try { + const result = await pool.query("SELECT key, count, month_key FROM usage"); + usage = new Map(); + for (const row of result.rows) { + usage.set(row.key, { count: row.count, monthKey: row.month_key }); + } + logger.info(`Loaded usage data for ${usage.size} keys from PostgreSQL`); + } + catch (error) { + logger.info("No existing usage data found, starting fresh"); + usage = new Map(); + } +} +async function saveUsageEntry(key, record) { + try { + await pool.query(`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3) + ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`, [key, record.count, record.monthKey]); + } + catch (error) { + logger.error({ err: error }, "Failed to save usage data"); + } +} +export function usageMiddleware(req, res, next) { + const keyInfo = req.apiKeyInfo; + const key = keyInfo?.key || "unknown"; + const monthKey = getMonthKey(); + if (isProKey(key)) { + trackUsage(key, monthKey); + next(); + return; + } + const record = usage.get(key); + if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) { + res.status(429).json({ + error: "Free tier limit reached", + limit: FREE_TIER_LIMIT, + used: record.count, + upgrade: "Upgrade to Pro for unlimited conversions: https://docfast.dev/pricing", + }); + return; + } + trackUsage(key, monthKey); + next(); +} +function trackUsage(key, monthKey) { + const record = usage.get(key); + if (!record || record.monthKey !== monthKey) { + const newRecord = { count: 1, monthKey }; + usage.set(key, newRecord); + saveUsageEntry(key, newRecord).catch((err) => logger.error({ err }, "Failed to save usage entry")); + } + else { + record.count++; + saveUsageEntry(key, record).catch((err) => logger.error({ err }, "Failed to save usage entry")); + } +} +export function getUsageStats() { + const stats = {}; + for (const [key, record] of usage) { + const masked = key.slice(0, 8) + "..."; + stats[masked] = { count: record.count, month: record.monthKey }; + } + return stats; +} diff --git a/dist/routes/billing.js b/dist/routes/billing.js new file mode 100644 index 0000000..dca8ff4 --- /dev/null +++ b/dist/routes/billing.js @@ -0,0 +1,187 @@ +import { Router } from "express"; +import Stripe from "stripe"; +import { createProKey, revokeByCustomer } from "../services/keys.js"; +import logger from "../services/logger.js"; +function escapeHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} +let _stripe = null; +function getStripe() { + if (!_stripe) { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) + throw new Error("STRIPE_SECRET_KEY not configured"); + _stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" }); + } + return _stripe; +} +const router = Router(); +// Create a Stripe Checkout session for Pro subscription +router.post("/checkout", async (_req, res) => { + try { + const priceId = await getOrCreateProPrice(); + const session = await getStripe().checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card"], + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`, + }); + res.json({ url: session.url }); + } + catch (err) { + logger.error({ err }, "Checkout error"); + res.status(500).json({ error: "Failed to create checkout session" }); + } +}); +// Success page โ€” provision Pro API key after checkout +router.get("/success", async (req, res) => { + const sessionId = req.query.session_id; + if (!sessionId) { + res.status(400).json({ error: "Missing session_id" }); + return; + } + try { + const session = await getStripe().checkout.sessions.retrieve(sessionId); + const customerId = session.customer; + const email = session.customer_details?.email || "unknown@docfast.dev"; + if (!customerId) { + res.status(400).json({ error: "No customer found" }); + return; + } + const keyInfo = await createProKey(email, customerId); + // Return a nice HTML page instead of raw JSON + res.send(` +Welcome to DocFast Pro! + +
+

๐ŸŽ‰ Welcome to Pro!

+

Your API key:

+
${escapeHtml(keyInfo.key)}
+

Save this key! It won't be shown again.

+

10,000 PDFs/month โ€ข All endpoints โ€ข Priority support

+

View API docs โ†’

+
`); + } + catch (err) { + logger.error({ err }, "Success page error"); + res.status(500).json({ error: "Failed to retrieve session" }); + } +}); +// Stripe webhook for subscription lifecycle events +router.post("/webhook", async (req, res) => { + const sig = req.headers["stripe-signature"]; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + let event; + if (!webhookSecret) { + console.warn("โš ๏ธ STRIPE_WEBHOOK_SECRET is not configured โ€” webhook signature verification skipped. Set this in production!"); + // Parse the body as a raw event without verification + try { + event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString()); + } + catch (err) { + logger.error({ err }, "Failed to parse webhook body"); + res.status(400).json({ error: "Invalid payload" }); + return; + } + } + else if (!sig) { + res.status(400).json({ error: "Missing stripe-signature header" }); + return; + } + else { + try { + event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret); + } + catch (err) { + logger.error({ err }, "Webhook signature verification failed"); + res.status(400).json({ error: "Invalid signature" }); + return; + } + } + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object; + const customerId = session.customer; + const email = session.customer_details?.email; + // Filter by product โ€” this Stripe account is shared with other projects + const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE"; + try { + const fullSession = await getStripe().checkout.sessions.retrieve(session.id, { + expand: ["line_items"], + }); + const lineItems = fullSession.line_items?.data || []; + const hasDocfastProduct = lineItems.some((item) => { + const price = item.price; + const productId = typeof price?.product === "string" ? price.product : price?.product?.id; + return productId === DOCFAST_PRODUCT_ID; + }); + if (!hasDocfastProduct) { + logger.info({ sessionId: session.id }, "Ignoring event for different product"); + break; + } + } + catch (err) { + logger.error({ err, sessionId: session.id }, "Failed to retrieve session line_items"); + break; + } + if (!customerId || !email) { + console.warn("checkout.session.completed: missing customerId or email, skipping key provisioning"); + break; + } + const keyInfo = await createProKey(email, customerId); + logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key"); + break; + } + case "customer.subscription.deleted": { + const sub = event.data.object; + const customerId = sub.customer; + await revokeByCustomer(customerId); + logger.info({ customerId }, "Subscription cancelled, key revoked"); + break; + } + default: + break; + } + res.json({ received: true }); +}); +// --- Price management --- +let cachedPriceId = null; +async function getOrCreateProPrice() { + if (cachedPriceId) + return cachedPriceId; + const products = await getStripe().products.search({ query: "name:'DocFast Pro'" }); + let productId; + if (products.data.length > 0) { + productId = products.data[0].id; + const prices = await getStripe().prices.list({ product: productId, active: true, limit: 1 }); + if (prices.data.length > 0) { + cachedPriceId = prices.data[0].id; + return cachedPriceId; + } + } + else { + const product = await getStripe().products.create({ + name: "DocFast Pro", + description: "Unlimited PDF conversions via API. HTML, Markdown, and URL to PDF.", + }); + productId = product.id; + } + const price = await getStripe().prices.create({ + product: productId, + unit_amount: 900, + currency: "eur", + recurring: { interval: "month" }, + }); + cachedPriceId = price.id; + return cachedPriceId; +} +export { router as billingRouter }; diff --git a/dist/routes/convert.js b/dist/routes/convert.js new file mode 100644 index 0000000..55a0fa1 --- /dev/null +++ b/dist/routes/convert.js @@ -0,0 +1,189 @@ +import { Router } from "express"; +import { renderPdf, renderUrlPdf } from "../services/browser.js"; +import { markdownToHtml, wrapHtml } from "../services/markdown.js"; +import dns from "node:dns/promises"; +import logger from "../services/logger.js"; +import net from "node:net"; +function isPrivateIP(ip) { + // IPv6 loopback/unspecified + if (ip === "::1" || ip === "::") + return true; + // IPv6 link-local (fe80::/10) + if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") || + ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb")) + return true; + // IPv4-mapped IPv6 + if (ip.startsWith("::ffff:")) + ip = ip.slice(7); + if (!net.isIPv4(ip)) + return false; + const parts = ip.split(".").map(Number); + if (parts[0] === 0) + return true; // 0.0.0.0/8 + if (parts[0] === 10) + return true; // 10.0.0.0/8 + if (parts[0] === 127) + return true; // 127.0.0.0/8 + if (parts[0] === 169 && parts[1] === 254) + return true; // 169.254.0.0/16 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) + return true; // 172.16.0.0/12 + if (parts[0] === 192 && parts[1] === 168) + return true; // 192.168.0.0/16 + return false; +} +export const convertRouter = Router(); +// POST /v1/convert/html +convertRouter.post("/html", async (req, res) => { + let slotAcquired = false; + try { + // Reject non-JSON content types + const ct = req.headers["content-type"] || ""; + if (!ct.includes("application/json")) { + res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); + return; + } + const body = typeof req.body === "string" ? { html: req.body } : req.body; + if (!body.html) { + res.status(400).json({ error: "Missing 'html' field" }); + return; + } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + // Wrap bare HTML fragments + const fullHtml = body.html.includes(" { + let slotAcquired = false; + try { + const body = typeof req.body === "string" ? { markdown: req.body } : req.body; + if (!body.markdown) { + res.status(400).json({ error: "Missing 'markdown' field" }); + return; + } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + const html = markdownToHtml(body.markdown, body.css); + const pdf = await renderPdf(html, { + format: body.format, + landscape: body.landscape, + margin: body.margin, + printBackground: body.printBackground, + }); + const filename = body.filename || "document.pdf"; + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.send(pdf); + } + catch (err) { + logger.error({ err }, "Convert MD error"); + if (err.message === "QUEUE_FULL") { + res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); + return; + } + res.status(500).json({ error: "PDF generation failed", detail: err.message }); + } + finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } + } +}); +// POST /v1/convert/url +convertRouter.post("/url", async (req, res) => { + let slotAcquired = false; + try { + const body = req.body; + if (!body.url) { + res.status(400).json({ error: "Missing 'url' field" }); + return; + } + // URL validation + SSRF protection + let parsed; + try { + parsed = new URL(body.url); + if (!["http:", "https:"].includes(parsed.protocol)) { + res.status(400).json({ error: "Only http/https URLs are supported" }); + return; + } + } + catch { + res.status(400).json({ error: "Invalid URL" }); + return; + } + // DNS lookup to block private/reserved IPs + try { + const { address } = await dns.lookup(parsed.hostname); + if (isPrivateIP(address)) { + res.status(400).json({ error: "URL resolves to a private/internal IP address" }); + return; + } + } + catch { + res.status(400).json({ error: "DNS lookup failed for URL hostname" }); + return; + } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + const pdf = await renderUrlPdf(body.url, { + format: body.format, + landscape: body.landscape, + margin: body.margin, + printBackground: body.printBackground, + waitUntil: body.waitUntil, + }); + const filename = body.filename || "page.pdf"; + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.send(pdf); + } + catch (err) { + logger.error({ err }, "Convert URL error"); + if (err.message === "QUEUE_FULL") { + res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); + return; + } + res.status(500).json({ error: "PDF generation failed", detail: err.message }); + } + finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } + } +}); diff --git a/dist/routes/email-change.js b/dist/routes/email-change.js new file mode 100644 index 0000000..3feae38 --- /dev/null +++ b/dist/routes/email-change.js @@ -0,0 +1,82 @@ +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { createPendingVerification, verifyCode } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import { getAllKeys, updateKeyEmail } from "../services/keys.js"; +import logger from "../services/logger.js"; +const router = Router(); +const changeLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: "Too many attempts. Please try again in 1 hour." }, + standardHeaders: true, + legacyHeaders: false, +}); +router.post("/", changeLimiter, async (req, res) => { + const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; + const newEmail = req.body?.newEmail; + if (!apiKey || typeof apiKey !== "string") { + res.status(400).json({ error: "API key is required (Authorization header or body)." }); + return; + } + if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + res.status(400).json({ error: "A valid new email address is required." }); + return; + } + const cleanEmail = newEmail.trim().toLowerCase(); + const keys = getAllKeys(); + const userKey = keys.find((k) => k.key === apiKey); + if (!userKey) { + res.status(401).json({ error: "Invalid API key." }); + return; + } + const existing = keys.find((k) => k.email === cleanEmail); + if (existing) { + res.status(409).json({ error: "This email is already associated with another account." }); + return; + } + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch((err) => { + logger.error({ err, email: cleanEmail }, "Failed to send email change verification"); + }); + res.json({ status: "verification_sent", message: "Verification code sent to your new email address." }); +}); +router.post("/verify", changeLimiter, async (req, res) => { + const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey; + const { newEmail, code } = req.body || {}; + if (!apiKey || !newEmail || !code) { + res.status(400).json({ error: "API key, new email, and code are required." }); + return; + } + const cleanEmail = newEmail.trim().toLowerCase(); + const cleanCode = String(code).trim(); + const keys = getAllKeys(); + const userKey = keys.find((k) => k.key === apiKey); + if (!userKey) { + res.status(401).json({ error: "Invalid API key." }); + return; + } + const result = await verifyCode(cleanEmail, cleanCode); + switch (result.status) { + case "ok": { + const updated = await updateKeyEmail(apiKey, cleanEmail); + if (updated) { + res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail }); + } + else { + res.status(500).json({ error: "Failed to update email." }); + } + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please request a new one." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } +}); +export { router as emailChangeRouter }; diff --git a/dist/routes/health.js b/dist/routes/health.js new file mode 100644 index 0000000..700dd4b --- /dev/null +++ b/dist/routes/health.js @@ -0,0 +1,54 @@ +import { Router } from "express"; +import { createRequire } from "module"; +import { getPoolStats } from "../services/browser.js"; +import { pool } from "../services/db.js"; +const require = createRequire(import.meta.url); +const { version: APP_VERSION } = require("../../package.json"); +export const healthRouter = Router(); +healthRouter.get("/", async (_req, res) => { + const poolStats = getPoolStats(); + let databaseStatus; + let overallStatus = "ok"; + let httpStatus = 200; + // Check database connectivity + try { + const client = await pool.connect(); + try { + const result = await client.query('SELECT version()'); + const version = result.rows[0]?.version || 'Unknown'; + // Extract just the PostgreSQL version number (e.g., "PostgreSQL 15.4") + const versionMatch = version.match(/PostgreSQL ([\d.]+)/); + const shortVersion = versionMatch ? `PostgreSQL ${versionMatch[1]}` : 'PostgreSQL'; + databaseStatus = { + status: "ok", + version: shortVersion + }; + } + finally { + client.release(); + } + } + catch (error) { + databaseStatus = { + status: "error", + message: error.message || "Database connection failed" + }; + overallStatus = "degraded"; + httpStatus = 503; + } + const response = { + status: overallStatus, + version: APP_VERSION, + database: databaseStatus, + pool: { + size: poolStats.poolSize, + active: poolStats.totalPages - poolStats.availablePages, + available: poolStats.availablePages, + queueDepth: poolStats.queueDepth, + pdfCount: poolStats.pdfCount, + restarting: poolStats.restarting, + uptimeSeconds: Math.round(poolStats.uptimeMs / 1000), + }, + }; + res.status(httpStatus).json(response); +}); diff --git a/dist/routes/recover.js b/dist/routes/recover.js new file mode 100644 index 0000000..cf8bc9f --- /dev/null +++ b/dist/routes/recover.js @@ -0,0 +1,74 @@ +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { createPendingVerification, verifyCode } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import { getAllKeys } from "../services/keys.js"; +import logger from "../services/logger.js"; +const router = Router(); +const recoverLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: "Too many recovery attempts. Please try again in 1 hour." }, + standardHeaders: true, + legacyHeaders: false, +}); +router.post("/", recoverLimiter, async (req, res) => { + const { email } = req.body || {}; + if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ error: "A valid email address is required." }); + return; + } + const cleanEmail = email.trim().toLowerCase(); + const keys = getAllKeys(); + const userKey = keys.find(k => k.email === cleanEmail); + if (!userKey) { + res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); + return; + } + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send recovery email"); + }); + res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); +}); +router.post("/verify", recoverLimiter, async (req, res) => { + const { email, code } = req.body || {}; + if (!email || !code) { + res.status(400).json({ error: "Email and code are required." }); + return; + } + const cleanEmail = email.trim().toLowerCase(); + const cleanCode = String(code).trim(); + const result = await verifyCode(cleanEmail, cleanCode); + switch (result.status) { + case "ok": { + const keys = getAllKeys(); + const userKey = keys.find(k => k.email === cleanEmail); + if (userKey) { + res.json({ + status: "recovered", + apiKey: userKey.key, + tier: userKey.tier, + message: "Your API key has been recovered. Save it securely โ€” it is shown only once.", + }); + } + else { + res.json({ + status: "recovered", + message: "No API key found for this email.", + }); + } + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please request a new one." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } +}); +export { router as recoverRouter }; diff --git a/dist/routes/signup.js b/dist/routes/signup.js new file mode 100644 index 0000000..56cc898 --- /dev/null +++ b/dist/routes/signup.js @@ -0,0 +1,92 @@ +import { Router } from "express"; +import rateLimit from "express-rate-limit"; +import { createFreeKey } from "../services/keys.js"; +import { createVerification, createPendingVerification, verifyCode, isEmailVerified } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import logger from "../services/logger.js"; +const router = Router(); +const signupLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 5, + message: { error: "Too many signup attempts. Please try again in 1 hour.", retryAfter: "1 hour" }, + standardHeaders: true, + legacyHeaders: false, +}); +const verifyLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + message: { error: "Too many verification attempts. Please try again later." }, + standardHeaders: true, + legacyHeaders: false, +}); +async function rejectDuplicateEmail(req, res, next) { + const { email } = req.body || {}; + if (email && typeof email === "string") { + const cleanEmail = email.trim().toLowerCase(); + if (await isEmailVerified(cleanEmail)) { + res.status(409).json({ error: "Email already registered" }); + return; + } + } + next(); +} +// Step 1: Request signup โ€” generates 6-digit code, sends via email +router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => { + const { email } = req.body || {}; + if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ error: "A valid email address is required." }); + return; + } + const cleanEmail = email.trim().toLowerCase(); + if (await isEmailVerified(cleanEmail)) { + res.status(409).json({ error: "This email is already registered. Contact support if you need help." }); + return; + } + const pending = await createPendingVerification(cleanEmail); + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send verification email"); + }); + res.json({ + status: "verification_required", + message: "Check your email for the verification code.", + }); +}); +// Step 2: Verify code โ€” creates API key +router.post("/verify", verifyLimiter, async (req, res) => { + const { email, code } = req.body || {}; + if (!email || !code) { + res.status(400).json({ error: "Email and code are required." }); + return; + } + const cleanEmail = email.trim().toLowerCase(); + const cleanCode = String(code).trim(); + if (await isEmailVerified(cleanEmail)) { + res.status(409).json({ error: "This email is already verified." }); + return; + } + const result = await verifyCode(cleanEmail, cleanCode); + switch (result.status) { + case "ok": { + const keyInfo = await createFreeKey(cleanEmail); + const verification = await createVerification(cleanEmail, keyInfo.key); + verification.verifiedAt = new Date().toISOString(); + res.json({ + status: "verified", + message: "Email verified! Here's your API key.", + apiKey: keyInfo.key, + tier: keyInfo.tier, + }); + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please sign up again." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please sign up again to get a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } +}); +export { router as signupRouter }; diff --git a/dist/routes/templates.js b/dist/routes/templates.js new file mode 100644 index 0000000..720cac0 --- /dev/null +++ b/dist/routes/templates.js @@ -0,0 +1,40 @@ +import { Router } from "express"; +import { renderPdf } from "../services/browser.js"; +import logger from "../services/logger.js"; +import { templates, renderTemplate } from "../services/templates.js"; +export const templatesRouter = Router(); +// GET /v1/templates โ€” list available templates +templatesRouter.get("/", (_req, res) => { + const list = Object.entries(templates).map(([id, t]) => ({ + id, + name: t.name, + description: t.description, + fields: t.fields, + })); + res.json({ templates: list }); +}); +// POST /v1/templates/:id/render โ€” render template to PDF +templatesRouter.post("/:id/render", async (req, res) => { + try { + const id = req.params.id; + const template = templates[id]; + if (!template) { + res.status(404).json({ error: `Template '${id}' not found` }); + return; + } + const data = req.body.data || req.body; + const html = renderTemplate(id, data); + const pdf = await renderPdf(html, { + format: data._format || "A4", + margin: data._margin, + }); + const filename = data._filename || `${id}.pdf`; + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.send(pdf); + } + catch (err) { + logger.error({ err }, "Template render error"); + res.status(500).json({ error: "Template rendering failed", detail: err.message }); + } +}); diff --git a/dist/services/browser.js b/dist/services/browser.js new file mode 100644 index 0000000..7516106 --- /dev/null +++ b/dist/services/browser.js @@ -0,0 +1,246 @@ +import puppeteer from "puppeteer"; +import logger from "./logger.js"; +const BROWSER_COUNT = parseInt(process.env.BROWSER_COUNT || "2", 10); +const PAGES_PER_BROWSER = parseInt(process.env.PAGES_PER_BROWSER || "8", 10); +const RESTART_AFTER_PDFS = 1000; +const RESTART_AFTER_MS = 60 * 60 * 1000; // 1 hour +const instances = []; +const waitingQueue = []; +let roundRobinIndex = 0; +export function getPoolStats() { + const totalAvailable = instances.reduce((s, i) => s + i.availablePages.length, 0); + const totalPages = instances.length * PAGES_PER_BROWSER; + const totalPdfs = instances.reduce((s, i) => s + i.pdfCount, 0); + return { + poolSize: totalPages, + totalPages, + availablePages: totalAvailable, + queueDepth: waitingQueue.length, + pdfCount: totalPdfs, + restarting: instances.some((i) => i.restarting), + uptimeMs: Date.now() - (instances[0]?.lastRestartTime || Date.now()), + browsers: instances.map((i) => ({ + id: i.id, + available: i.availablePages.length, + pdfCount: i.pdfCount, + restarting: i.restarting, + })), + }; +} +async function recyclePage(page) { + try { + const client = await page.createCDPSession(); + await client.send("Network.clearBrowserCache").catch(() => { }); + await client.detach().catch(() => { }); + const cookies = await page.cookies(); + if (cookies.length > 0) { + await page.deleteCookie(...cookies); + } + await page.goto("about:blank", { timeout: 5000 }).catch(() => { }); + } + catch { + // ignore + } +} +async function createPages(b, count) { + const pages = []; + for (let i = 0; i < count; i++) { + const page = await b.newPage(); + pages.push(page); + } + return pages; +} +function pickInstance() { + // Round-robin among instances that have available pages + for (let i = 0; i < instances.length; i++) { + const idx = (roundRobinIndex + i) % instances.length; + const inst = instances[idx]; + if (inst.availablePages.length > 0 && !inst.restarting) { + roundRobinIndex = (idx + 1) % instances.length; + return inst; + } + } + return null; +} +async function acquirePage() { + // Check restarts + for (const inst of instances) { + if (!inst.restarting && (inst.pdfCount >= RESTART_AFTER_PDFS || Date.now() - inst.lastRestartTime >= RESTART_AFTER_MS)) { + scheduleRestart(inst); + } + } + const inst = pickInstance(); + if (inst) { + const page = inst.availablePages.pop(); + return { page, instance: inst }; + } + // All pages busy, queue with 30s timeout + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = waitingQueue.findIndex((w) => w.resolve === resolve); + if (idx >= 0) + waitingQueue.splice(idx, 1); + reject(new Error("QUEUE_FULL")); + }, 30_000); + waitingQueue.push({ + resolve: (v) => { + clearTimeout(timer); + resolve(v); + }, + }); + }); +} +function releasePage(page, inst) { + inst.pdfCount++; + const waiter = waitingQueue.shift(); + if (waiter) { + recyclePage(page).then(() => waiter.resolve({ page, instance: inst })).catch(() => { + if (inst.browser && !inst.restarting) { + inst.browser.newPage().then((p) => waiter.resolve({ page: p, instance: inst })).catch(() => { + waitingQueue.unshift(waiter); + }); + } + else { + waitingQueue.unshift(waiter); + } + }); + return; + } + recyclePage(page).then(() => { + inst.availablePages.push(page); + }).catch(() => { + if (inst.browser && !inst.restarting) { + inst.browser.newPage().then((p) => inst.availablePages.push(p)).catch(() => { }); + } + }); +} +async function scheduleRestart(inst) { + if (inst.restarting) + return; + inst.restarting = true; + logger.info(`Scheduling browser ${inst.id} restart (pdfs=${inst.pdfCount}, uptime=${Math.round((Date.now() - inst.lastRestartTime) / 1000)}s)`); + const drainCheck = () => new Promise((resolve) => { + const check = () => { + if (inst.availablePages.length === PAGES_PER_BROWSER && waitingQueue.length === 0) { + resolve(); + } + else { + setTimeout(check, 100); + } + }; + check(); + }); + await Promise.race([drainCheck(), new Promise(r => setTimeout(r, 30000))]); + for (const page of inst.availablePages) { + await page.close().catch(() => { }); + } + inst.availablePages.length = 0; + try { + await inst.browser.close().catch(() => { }); + } + catch { } + const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined; + inst.browser = await puppeteer.launch({ + headless: true, + executablePath: execPath, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + }); + const pages = await createPages(inst.browser, PAGES_PER_BROWSER); + inst.availablePages.push(...pages); + inst.pdfCount = 0; + inst.lastRestartTime = Date.now(); + inst.restarting = false; + logger.info(`Browser ${inst.id} restarted successfully`); + while (waitingQueue.length > 0 && inst.availablePages.length > 0) { + const waiter = waitingQueue.shift(); + const p = inst.availablePages.pop(); + if (waiter && p) + waiter.resolve({ page: p, instance: inst }); + } +} +async function launchInstance(id) { + const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined; + const browser = await puppeteer.launch({ + headless: true, + executablePath: execPath, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + }); + const pages = await createPages(browser, PAGES_PER_BROWSER); + const inst = { + browser, + availablePages: pages, + pdfCount: 0, + lastRestartTime: Date.now(), + restarting: false, + id, + }; + return inst; +} +export async function initBrowser() { + for (let i = 0; i < BROWSER_COUNT; i++) { + const inst = await launchInstance(i); + instances.push(inst); + } + logger.info(`Browser pool ready (${BROWSER_COUNT} browsers ร— ${PAGES_PER_BROWSER} pages = ${BROWSER_COUNT * PAGES_PER_BROWSER} total)`); +} +export async function closeBrowser() { + for (const inst of instances) { + for (const page of inst.availablePages) { + await page.close().catch(() => { }); + } + inst.availablePages.length = 0; + await inst.browser.close().catch(() => { }); + } + instances.length = 0; +} +export async function renderPdf(html, options = {}) { + const { page, instance } = await acquirePage(); + try { + const result = await Promise.race([ + (async () => { + await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 }); + await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" }); + const pdf = await page.pdf({ + format: options.format || "A4", + landscape: options.landscape || false, + printBackground: options.printBackground !== false, + margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, + headerTemplate: options.headerTemplate, + footerTemplate: options.footerTemplate, + displayHeaderFooter: options.displayHeaderFooter || false, + }); + return Buffer.from(pdf); + })(), + new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)), + ]); + return result; + } + finally { + releasePage(page, instance); + } +} +export async function renderUrlPdf(url, options = {}) { + const { page, instance } = await acquirePage(); + try { + const result = await Promise.race([ + (async () => { + await page.goto(url, { + waitUntil: options.waitUntil || "networkidle0", + timeout: 30_000, + }); + const pdf = await page.pdf({ + format: options.format || "A4", + landscape: options.landscape || false, + printBackground: options.printBackground !== false, + margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, + }); + return Buffer.from(pdf); + })(), + new Promise((_, reject) => setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)), + ]); + return result; + } + finally { + releasePage(page, instance); + } +} diff --git a/dist/services/database.js b/dist/services/database.js new file mode 100644 index 0000000..35635f7 --- /dev/null +++ b/dist/services/database.js @@ -0,0 +1,123 @@ +import Database from "better-sqlite3"; +import path from "path"; +import { fileURLToPath } from "url"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DB_PATH = path.join(__dirname, "../../data/docfast.db"); +class DatabaseService { + db; + constructor() { + this.db = new Database(DB_PATH); + this.initialize(); + } + initialize() { + // Enable WAL mode for better performance + this.db.pragma("journal_mode = WAL"); + // Create tables + this.db.exec(` + CREATE TABLE IF NOT EXISTS keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + api_key TEXT UNIQUE NOT NULL, + tier TEXT NOT NULL CHECK (tier IN ('free', 'pro')), + created_at TEXT NOT NULL, + usage_count INTEGER DEFAULT 0, + usage_month TEXT NOT NULL, + stripe_customer_id TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_keys_api_key ON keys(api_key); + CREATE INDEX IF NOT EXISTS idx_keys_email ON keys(email); + CREATE INDEX IF NOT EXISTS idx_keys_stripe_customer_id ON keys(stripe_customer_id); + + CREATE TABLE IF NOT EXISTS usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key TEXT NOT NULL, + endpoint TEXT NOT NULL, + timestamp TEXT NOT NULL, + FOREIGN KEY (api_key) REFERENCES keys(api_key) + ); + + CREATE INDEX IF NOT EXISTS idx_usage_api_key ON usage(api_key); + CREATE INDEX IF NOT EXISTS idx_usage_timestamp ON usage(timestamp); + `); + } + // Key operations + insertKey(key) { + const stmt = this.db.prepare(` + INSERT INTO keys (email, api_key, tier, created_at, usage_count, usage_month, stripe_customer_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run(key.email, key.api_key, key.tier, key.created_at, key.usage_count, key.usage_month, key.stripe_customer_id || null); + return { ...key, id: result.lastInsertRowid }; + } + getKeyByApiKey(apiKey) { + const stmt = this.db.prepare("SELECT * FROM keys WHERE api_key = ?"); + return stmt.get(apiKey); + } + getKeyByEmail(email, tier) { + const stmt = this.db.prepare("SELECT * FROM keys WHERE email = ? AND tier = ?"); + return stmt.get(email, tier); + } + getKeyByStripeCustomerId(stripeCustomerId) { + const stmt = this.db.prepare("SELECT * FROM keys WHERE stripe_customer_id = ?"); + return stmt.get(stripeCustomerId); + } + updateKeyTier(apiKey, tier) { + const stmt = this.db.prepare("UPDATE keys SET tier = ? WHERE api_key = ?"); + const result = stmt.run(tier, apiKey); + return result.changes > 0; + } + deleteKeyByStripeCustomerId(stripeCustomerId) { + const stmt = this.db.prepare("DELETE FROM keys WHERE stripe_customer_id = ?"); + const result = stmt.run(stripeCustomerId); + return result.changes > 0; + } + getAllKeys() { + const stmt = this.db.prepare("SELECT * FROM keys"); + return stmt.all(); + } + // Usage operations + insertUsage(usage) { + const stmt = this.db.prepare(` + INSERT INTO usage (api_key, endpoint, timestamp) + VALUES (?, ?, ?) + `); + const result = stmt.run(usage.api_key, usage.endpoint, usage.timestamp); + return { ...usage, id: result.lastInsertRowid }; + } + getUsageForKey(apiKey, fromDate, toDate) { + let query = "SELECT * FROM usage WHERE api_key = ?"; + const params = [apiKey]; + if (fromDate && toDate) { + query += " AND timestamp >= ? AND timestamp <= ?"; + params.push(fromDate, toDate); + } + else if (fromDate) { + query += " AND timestamp >= ?"; + params.push(fromDate); + } + query += " ORDER BY timestamp DESC"; + const stmt = this.db.prepare(query); + return stmt.all(...params); + } + // Utility method to migrate existing JSON data + migrateFromJson(jsonKeys) { + const insertStmt = this.db.prepare(` + INSERT OR IGNORE INTO keys (email, api_key, tier, created_at, usage_count, usage_month, stripe_customer_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const transaction = this.db.transaction((keys) => { + for (const key of keys) { + const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM + insertStmt.run(key.email || "", key.key, key.tier, key.createdAt, 0, // reset usage count + currentMonth, key.stripeCustomerId || null); + } + }); + transaction(jsonKeys); + } + close() { + this.db.close(); + } +} +// Export singleton instance +export const db = new DatabaseService(); diff --git a/dist/services/db.js b/dist/services/db.js new file mode 100644 index 0000000..4d4b85a --- /dev/null +++ b/dist/services/db.js @@ -0,0 +1,62 @@ +import pg from "pg"; +import logger from "./logger.js"; +const { Pool } = pg; +const pool = new Pool({ + host: process.env.DATABASE_HOST || "172.17.0.1", + port: parseInt(process.env.DATABASE_PORT || "5432", 10), + database: process.env.DATABASE_NAME || "docfast", + user: process.env.DATABASE_USER || "docfast", + password: process.env.DATABASE_PASSWORD || "docfast", + max: 10, + idleTimeoutMillis: 30000, +}); +pool.on("error", (err) => { + logger.error({ err }, "Unexpected PostgreSQL pool error"); +}); +export async function initDatabase() { + const client = await pool.connect(); + try { + await client.query(` + CREATE TABLE IF NOT EXISTS api_keys ( + key TEXT PRIMARY KEY, + tier TEXT NOT NULL DEFAULT 'free', + email TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + stripe_customer_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email); + CREATE INDEX IF NOT EXISTS idx_api_keys_stripe ON api_keys(stripe_customer_id); + + CREATE TABLE IF NOT EXISTS verifications ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + api_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + verified_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_verifications_email ON verifications(email); + CREATE INDEX IF NOT EXISTS idx_verifications_token ON verifications(token); + + CREATE TABLE IF NOT EXISTS pending_verifications ( + email TEXT PRIMARY KEY, + code TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + attempts INT NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS usage ( + key TEXT PRIMARY KEY, + count INT NOT NULL DEFAULT 0, + month_key TEXT NOT NULL + ); + `); + logger.info("PostgreSQL tables initialized"); + } + finally { + client.release(); + } +} +export { pool }; +export default pool; diff --git a/dist/services/email.js b/dist/services/email.js new file mode 100644 index 0000000..d7efdf2 --- /dev/null +++ b/dist/services/email.js @@ -0,0 +1,29 @@ +import nodemailer from "nodemailer"; +import logger from "./logger.js"; +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST || "host.docker.internal", + port: Number(process.env.SMTP_PORT || 25), + secure: false, + connectionTimeout: 5000, + greetingTimeout: 5000, + socketTimeout: 10000, + tls: { rejectUnauthorized: false }, +}); +export async function sendVerificationEmail(email, code) { + try { + const info = await transporter.sendMail({ + from: "DocFast ", + to: email, + subject: "DocFast - Verify your email", + text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.`, + }); + logger.info({ email, messageId: info.messageId }, "Verification email sent"); + return true; + } + catch (err) { + logger.error({ err, email }, "Failed to send verification email"); + return false; + } +} +// NOTE: sendRecoveryEmail removed โ€” API keys must NEVER be sent via email. +// Key recovery now shows the key in the browser after code verification. diff --git a/dist/services/keys.js b/dist/services/keys.js new file mode 100644 index 0000000..2424060 --- /dev/null +++ b/dist/services/keys.js @@ -0,0 +1,100 @@ +import { randomBytes } from "crypto"; +import logger from "./logger.js"; +import pool from "./db.js"; +// In-memory cache for fast lookups, synced with PostgreSQL +let keysCache = []; +export async function loadKeys() { + try { + const result = await pool.query("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys"); + keysCache = result.rows.map((r) => ({ + key: r.key, + tier: r.tier, + email: r.email, + createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, + stripeCustomerId: r.stripe_customer_id || undefined, + })); + } + catch (err) { + logger.error({ err }, "Failed to load keys from PostgreSQL"); + keysCache = []; + } + // Also load seed keys from env + const envKeys = process.env.API_KEYS?.split(",").map((k) => k.trim()).filter(Boolean) || []; + for (const k of envKeys) { + if (!keysCache.find((e) => e.key === k)) { + const entry = { key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() }; + keysCache.push(entry); + // Upsert into DB + await pool.query(`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4) + ON CONFLICT (key) DO NOTHING`, [k, "pro", "seed@docfast.dev", new Date().toISOString()]).catch(() => { }); + } + } +} +export function isValidKey(key) { + return keysCache.some((k) => k.key === key); +} +export function getKeyInfo(key) { + return keysCache.find((k) => k.key === key); +} +export function isProKey(key) { + const info = getKeyInfo(key); + return info?.tier === "pro"; +} +function generateKey(prefix) { + return `${prefix}_${randomBytes(24).toString("hex")}`; +} +export async function createFreeKey(email) { + if (email) { + const existing = keysCache.find((k) => k.email === email && k.tier === "free"); + if (existing) + return existing; + } + const entry = { + key: generateKey("df_free"), + tier: "free", + email: email || "", + createdAt: new Date().toISOString(), + }; + await pool.query("INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)", [entry.key, entry.tier, entry.email, entry.createdAt]); + keysCache.push(entry); + return entry; +} +export async function createProKey(email, stripeCustomerId) { + const existing = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId); + if (existing) { + existing.tier = "pro"; + await pool.query("UPDATE api_keys SET tier = 'pro' WHERE key = $1", [existing.key]); + return existing; + } + const entry = { + key: generateKey("df_pro"), + tier: "pro", + email, + createdAt: new Date().toISOString(), + stripeCustomerId, + }; + await pool.query("INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)", [entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId]); + keysCache.push(entry); + return entry; +} +export async function revokeByCustomer(stripeCustomerId) { + const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId); + if (idx >= 0) { + const key = keysCache[idx].key; + keysCache.splice(idx, 1); + await pool.query("DELETE FROM api_keys WHERE key = $1", [key]); + return true; + } + return false; +} +export function getAllKeys() { + return [...keysCache]; +} +export async function updateKeyEmail(apiKey, newEmail) { + const entry = keysCache.find((k) => k.key === apiKey); + if (!entry) + return false; + entry.email = newEmail; + await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]); + return true; +} diff --git a/dist/services/logger.js b/dist/services/logger.js new file mode 100644 index 0000000..cff6294 --- /dev/null +++ b/dist/services/logger.js @@ -0,0 +1,8 @@ +import pino from "pino"; +const logger = pino({ + level: process.env.LOG_LEVEL || "info", + ...(process.env.NODE_ENV !== "production" && { + transport: { target: "pino/file", options: { destination: 1 } }, + }), +}); +export default logger; diff --git a/dist/services/markdown.js b/dist/services/markdown.js new file mode 100644 index 0000000..55d2b0a --- /dev/null +++ b/dist/services/markdown.js @@ -0,0 +1,30 @@ +import { marked } from "marked"; +const defaultCss = ` +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.6; + color: #1a1a1a; + max-width: 100%; +} +h1 { font-size: 2em; margin-bottom: 0.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; } +h2 { font-size: 1.5em; margin-bottom: 0.5em; } +h3 { font-size: 1.25em; } +code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; } +pre { background: #f4f4f4; padding: 16px; border-radius: 6px; overflow-x: auto; } +pre code { background: none; padding: 0; } +table { border-collapse: collapse; width: 100%; margin: 1em 0; } +th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; } +th { background: #f8f8f8; font-weight: 600; } +blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; } +img { max-width: 100%; } +`; +export function markdownToHtml(md, css) { + const html = marked.parse(md, { async: false }); + return wrapHtml(html, css || defaultCss); +} +export function wrapHtml(body, css) { + return ` + +${body}`; +} diff --git a/dist/services/templates.js b/dist/services/templates.js new file mode 100644 index 0000000..585387e --- /dev/null +++ b/dist/services/templates.js @@ -0,0 +1,163 @@ +export const templates = { + invoice: { + name: "Invoice", + description: "Professional invoice with line items, taxes, and payment details", + fields: [ + { name: "invoiceNumber", type: "string", required: true, description: "Invoice number" }, + { name: "date", type: "string", required: true, description: "Invoice date (YYYY-MM-DD)" }, + { name: "dueDate", type: "string", required: false, description: "Due date" }, + { name: "from", type: "object", required: true, description: "Sender: {name, address?, email?, phone?, vatId?}" }, + { name: "to", type: "object", required: true, description: "Recipient: {name, address?, email?, vatId?}" }, + { name: "items", type: "array", required: true, description: "Line items: [{description, quantity, unitPrice, taxRate?}]" }, + { name: "currency", type: "string", required: false, description: "Currency symbol (default: โ‚ฌ)" }, + { name: "notes", type: "string", required: false, description: "Additional notes" }, + { name: "paymentDetails", type: "string", required: false, description: "Bank/payment info" }, + ], + render: renderInvoice, + }, + receipt: { + name: "Receipt", + description: "Simple receipt for payments received", + fields: [ + { name: "receiptNumber", type: "string", required: true, description: "Receipt number" }, + { name: "date", type: "string", required: true, description: "Date" }, + { name: "from", type: "object", required: true, description: "Business: {name, address?}" }, + { name: "to", type: "object", required: false, description: "Customer: {name, email?}" }, + { name: "items", type: "array", required: true, description: "Items: [{description, amount}]" }, + { name: "currency", type: "string", required: false, description: "Currency symbol" }, + { name: "paymentMethod", type: "string", required: false, description: "Payment method" }, + ], + render: renderReceipt, + }, +}; +function esc(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} +function renderInvoice(d) { + const cur = esc(d.currency || "โ‚ฌ"); + const items = d.items || []; + let subtotal = 0; + let totalTax = 0; + const rows = items + .map((item) => { + const qty = Number(item.quantity) || 1; + const price = Number(item.unitPrice) || 0; + const taxRate = Number(item.taxRate) || 0; + const lineTotal = qty * price; + const lineTax = lineTotal * (taxRate / 100); + subtotal += lineTotal; + totalTax += lineTax; + return ` + ${esc(item.description)} + ${qty} + ${cur}${price.toFixed(2)} + ${taxRate}% + ${cur}${lineTotal.toFixed(2)} + `; + }) + .join(""); + const total = subtotal + totalTax; + const from = d.from || {}; + const to = d.to || {}; + return ` +
+

INVOICE

+
+
#${esc(d.invoiceNumber)}
+
Date: ${esc(d.date)}
+ ${d.dueDate ? `
Due: ${esc(d.dueDate)}
` : ""} +
+
+
+
+

From

+

${esc(from.name)}

+ ${from.address ? `

${esc(from.address).replace(/\n/g, "
")}

` : ""} + ${from.email ? `

${esc(from.email)}

` : ""} + ${from.vatId ? `

VAT: ${esc(from.vatId)}

` : ""} +
+
+

To

+

${esc(to.name)}

+ ${to.address ? `

${esc(to.address).replace(/\n/g, "
")}

` : ""} + ${to.email ? `

${esc(to.email)}

` : ""} + ${to.vatId ? `

VAT: ${esc(to.vatId)}

` : ""} +
+
+ + + ${rows} +
DescriptionQtyPriceTaxTotal
+
+
Subtotal: ${cur}${subtotal.toFixed(2)}
+
Tax: ${cur}${totalTax.toFixed(2)}
+
Total: ${cur}${total.toFixed(2)}
+
+ ${d.paymentDetails ? `` : ""} + ${d.notes ? `` : ""} + `; +} +function renderReceipt(d) { + const cur = esc(d.currency || "โ‚ฌ"); + const items = d.items || []; + let total = 0; + const rows = items + .map((item) => { + const amount = Number(item.amount) || 0; + total += amount; + return `${esc(item.description)}${cur}${amount.toFixed(2)}`; + }) + .join(""); + const from = d.from || {}; + const to = d.to || {}; + return ` +

${esc(from.name)}

+ ${from.address ? `
${esc(from.address)}
` : ""} +
+
Receipt #${esc(d.receiptNumber)}
+
Date: ${esc(d.date)}
+ ${to?.name ? `
Customer: ${esc(to.name)}
` : ""} +
+ ${rows}
+
+
TOTAL${cur}${total.toFixed(2)}
+ ${d.paymentMethod ? `
Paid via: ${esc(d.paymentMethod)}
` : ""} +
Thank you!
+ `; +} +export function renderTemplate(id, data) { + const template = templates[id]; + if (!template) + throw new Error(`Template '${id}' not found`); + return template.render(data); +} diff --git a/dist/services/verification.js b/dist/services/verification.js new file mode 100644 index 0000000..de1e61e --- /dev/null +++ b/dist/services/verification.js @@ -0,0 +1,103 @@ +import { randomBytes, randomInt } from "crypto"; +import logger from "./logger.js"; +import pool from "./db.js"; +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; +const CODE_EXPIRY_MS = 15 * 60 * 1000; +const MAX_ATTEMPTS = 3; +export async function createVerification(email, apiKey) { + // Check for existing unexpired, unverified + const existing = await pool.query("SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1", [email]); + if (existing.rows.length > 0) { + const r = existing.rows[0]; + return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null }; + } + // Remove old unverified + await pool.query("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]); + const token = randomBytes(32).toString("hex"); + const now = new Date().toISOString(); + await pool.query("INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)", [email, token, apiKey, now]); + return { email, token, apiKey, createdAt: now, verifiedAt: null }; +} +export function verifyToken(token) { + // Synchronous wrapper โ€” we'll make it async-compatible + // Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor. + // For simplicity, we'll cache verifications in memory too. + return verifyTokenSync(token); +} +// In-memory cache for verifications (loaded on startup, updated on changes) +let verificationsCache = []; +export async function loadVerifications() { + const result = await pool.query("SELECT * FROM verifications"); + verificationsCache = result.rows.map((r) => ({ + email: r.email, + token: r.token, + apiKey: r.api_key, + createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, + verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null, + })); + // Cleanup expired entries every 15 minutes + setInterval(() => { + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + const before = verificationsCache.length; + verificationsCache = verificationsCache.filter((v) => v.verifiedAt || new Date(v.createdAt).getTime() > cutoff); + const removed = before - verificationsCache.length; + if (removed > 0) + logger.info({ removed }, "Cleaned expired verification cache entries"); + }, 15 * 60 * 1000); +} +function verifyTokenSync(token) { + const v = verificationsCache.find((v) => v.token === token); + if (!v) + return { status: "invalid" }; + if (v.verifiedAt) + return { status: "already_verified", verification: v }; + const age = Date.now() - new Date(v.createdAt).getTime(); + if (age > TOKEN_EXPIRY_MS) + return { status: "expired" }; + v.verifiedAt = new Date().toISOString(); + // Update DB async + pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch((err) => logger.error({ err }, "Failed to update verification")); + return { status: "ok", verification: v }; +} +export async function createPendingVerification(email) { + await pool.query("DELETE FROM pending_verifications WHERE email = $1", [email]); + const now = new Date(); + const pending = { + email, + code: String(randomInt(100000, 999999)), + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(), + attempts: 0, + }; + await pool.query("INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)", [pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts]); + return pending; +} +export async function verifyCode(email, code) { + const cleanEmail = email.trim().toLowerCase(); + const result = await pool.query("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]); + const pending = result.rows[0]; + if (!pending) + return { status: "invalid" }; + if (new Date() > new Date(pending.expires_at)) { + await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); + return { status: "expired" }; + } + if (pending.attempts >= MAX_ATTEMPTS) { + await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); + return { status: "max_attempts" }; + } + await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]); + if (pending.code !== code) { + return { status: "invalid" }; + } + await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]); + return { status: "ok" }; +} +export async function isEmailVerified(email) { + const result = await pool.query("SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]); + return result.rows.length > 0; +} +export async function getVerifiedApiKey(email) { + const result = await pool.query("SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]); + return result.rows[0]?.api_key ?? null; +} diff --git a/src/index.ts b/src/index.ts index 93f4e74..fc05510 100644 --- a/src/index.ts +++ b/src/index.ts @@ -186,6 +186,22 @@ app.get("/docs", (_req, res) => { res.sendFile(path.join(__dirname, "../public/docs.html")); }); +// Legal pages (clean URLs) +app.get("/impressum", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.join(__dirname, "../public/impressum.html")); +}); + +app.get("/privacy", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.join(__dirname, "../public/privacy.html")); +}); + +app.get("/terms", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.sendFile(path.join(__dirname, "../public/terms.html")); +}); + // API root app.get("/api", (_req, res) => { res.json({