diff --git a/docker-compose.yml b/docker-compose.yml index a726688..1e77d47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.8" services: docfast: build: . diff --git a/package-lock.json b/package-lock.json index a9d9c03..354f3c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "docfast-api", "version": "0.1.0", "dependencies": { + "compression": "^1.8.1", "express": "^4.21.0", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", @@ -15,11 +16,13 @@ "nanoid": "^5.0.0", "nodemailer": "^8.0.1", "pg": "^8.13.0", + "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", @@ -501,6 +504,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, "node_modules/@puppeteer/browsers": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.1.tgz", @@ -929,6 +937,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1256,6 +1274,14 @@ "node": ">=4" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/b4a": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", @@ -1532,6 +1558,42 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2624,6 +2686,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2636,6 +2706,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2876,6 +2954,40 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -2963,6 +3075,21 @@ "node": ">=0.10.0" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -3120,6 +3247,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3144,6 +3276,14 @@ "node": ">= 0.8" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3237,6 +3377,14 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -3446,6 +3594,14 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3614,6 +3770,17 @@ "b4a": "^1.6.4" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index de045f5..bf58497 100644 --- a/package.json +++ b/package.json @@ -10,25 +10,28 @@ "test": "vitest run" }, "dependencies": { + "compression": "^1.8.1", "express": "^4.21.0", "express-rate-limit": "^7.5.0", "helmet": "^8.0.0", "marked": "^15.0.0", "nanoid": "^5.0.0", "nodemailer": "^8.0.1", + "pg": "^8.13.0", + "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", - "swagger-ui-dist": "^5.31.0", - "pg": "^8.13.0" + "swagger-ui-dist": "^5.31.0" }, "devDependencies": { + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", + "@types/pg": "^8.11.0", "tsx": "^4.19.0", "typescript": "^5.7.0", - "vitest": "^3.0.0", - "@types/pg": "^8.11.0" + "vitest": "^3.0.0" }, "type": "module" -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 87a0d84..adc8fc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ 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"; @@ -24,6 +27,17 @@ const PORT = parseInt(process.env.PORT || "3100", 10); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); +// Request ID middleware +app.use((req, res, next) => { + const requestId = (req.headers["x-request-id"] as string) || randomUUID(); + (req as any).requestId = requestId; + res.setHeader("X-Request-Id", requestId); + next(); +}); + +// Compression +app.use(compression()); + // Differentiated CORS middleware app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || @@ -145,7 +159,7 @@ ${apiKey ? ` // Landing page const __dirname = path.dirname(fileURLToPath(import.meta.url)); -app.use(express.static(path.join(__dirname, "../public"))); +app.use(express.static(path.join(__dirname, "../public"), { maxAge: "1h", etag: true })); // Docs page (clean URL) app.get("/docs", (_req, res) => { @@ -179,11 +193,11 @@ async function start() { await loadUsageData(); await initBrowser(); - console.log(`Loaded ${getAllKeys().length} API keys`); - app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`)); + logger.info(`Loaded ${getAllKeys().length} API keys`); + app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`)); const shutdown = async () => { - console.log("Shutting down..."); + logger.info("Shutting down..."); await closeBrowser(); process.exit(0); }; @@ -192,7 +206,7 @@ async function start() { } start().catch((err) => { - console.error("Failed to start:", err); + logger.error({ err }, "Failed to start"); process.exit(1); }); diff --git a/src/middleware/pdfRateLimit.ts b/src/middleware/pdfRateLimit.ts index ca49ca6..b17dec9 100644 --- a/src/middleware/pdfRateLimit.ts +++ b/src/middleware/pdfRateLimit.ts @@ -112,4 +112,7 @@ export function getConcurrencyStats() { maxConcurrent: MAX_CONCURRENT_PDFS, maxQueue: MAX_QUEUE_SIZE }; -} \ No newline at end of file +} + +// Proactive cleanup every 60s +setInterval(cleanupExpiredEntries, 60_000); \ No newline at end of file diff --git a/src/middleware/usage.ts b/src/middleware/usage.ts index 8077970..fef35cd 100644 --- a/src/middleware/usage.ts +++ b/src/middleware/usage.ts @@ -1,4 +1,5 @@ import { isProKey } from "../services/keys.js"; +import logger from "../services/logger.js"; import pool from "../services/db.js"; const FREE_TIER_LIMIT = 100; @@ -18,9 +19,9 @@ export async function loadUsageData(): Promise { for (const row of result.rows) { usage.set(row.key, { count: row.count, monthKey: row.month_key }); } - console.log(`Loaded usage data for ${usage.size} keys from PostgreSQL`); + logger.info(`Loaded usage data for ${usage.size} keys from PostgreSQL`); } catch (error) { - console.log("No existing usage data found, starting fresh"); + logger.info("No existing usage data found, starting fresh"); usage = new Map(); } } @@ -33,7 +34,7 @@ async function saveUsageEntry(key: string, record: { count: number; monthKey: st [key, record.count, record.monthKey] ); } catch (error) { - console.error("Failed to save usage data:", error); + logger.error({ err: error }, "Failed to save usage data"); } } @@ -68,10 +69,10 @@ function trackUsage(key: string, monthKey: string): void { if (!record || record.monthKey !== monthKey) { const newRecord = { count: 1, monthKey }; usage.set(key, newRecord); - saveUsageEntry(key, newRecord).catch(console.error); + saveUsageEntry(key, newRecord).catch((err) => logger.error({ err }, "Failed to save usage entry")); } else { record.count++; - saveUsageEntry(key, record).catch(console.error); + saveUsageEntry(key, record).catch((err) => logger.error({ err }, "Failed to save usage entry")); } } diff --git a/src/routes/billing.ts b/src/routes/billing.ts index b163d77..ed25293 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from "express"; import Stripe from "stripe"; import { createProKey, revokeByCustomer } from "../services/keys.js"; +import logger from "../services/logger.js"; function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); @@ -33,7 +34,7 @@ router.post("/checkout", async (_req: Request, res: Response) => { res.json({ url: session.url }); } catch (err: any) { - console.error("Checkout error:", err.message); + logger.error({ err }, "Checkout error"); res.status(500).json({ error: "Failed to create checkout session" }); } }); @@ -79,7 +80,7 @@ a { color: #4f9; }

View API docs →

`); } catch (err: any) { - console.error("Success page error:", err.message); + logger.error({ err }, "Success page error"); res.status(500).json({ error: "Failed to retrieve session" }); } }); @@ -97,7 +98,7 @@ router.post("/webhook", async (req: Request, res: Response) => { try { event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString()) as Stripe.Event; } catch (err: any) { - console.error("Failed to parse webhook body:", err.message); + logger.error({ err }, "Failed to parse webhook body"); res.status(400).json({ error: "Invalid payload" }); return; } @@ -108,7 +109,7 @@ router.post("/webhook", async (req: Request, res: Response) => { try { event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret); } catch (err: any) { - console.error("Webhook signature verification failed:", err.message); + logger.error({ err }, "Webhook signature verification failed"); res.status(400).json({ error: "Invalid signature" }); return; } @@ -133,11 +134,11 @@ router.post("/webhook", async (req: Request, res: Response) => { return productId === DOCFAST_PRODUCT_ID; }); if (!hasDocfastProduct) { - console.log(`Ignoring event for different product (session: ${session.id})`); + logger.info({ sessionId: session.id }, "Ignoring event for different product"); break; } } catch (err: any) { - console.error(`Failed to retrieve session line_items: ${err.message}, skipping`); + logger.error({ err, sessionId: session.id }, "Failed to retrieve session line_items"); break; } @@ -147,14 +148,14 @@ router.post("/webhook", async (req: Request, res: Response) => { } const keyInfo = await createProKey(email, customerId); - console.log(`checkout.session.completed: provisioned pro key for ${email} (customer: ${customerId}, key: ${keyInfo.key.slice(0, 12)}...)`); + logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key"); break; } case "customer.subscription.deleted": { const sub = event.data.object as Stripe.Subscription; const customerId = sub.customer as string; await revokeByCustomer(customerId); - console.log(`Subscription cancelled for ${customerId}, key revoked`); + logger.info({ customerId }, "Subscription cancelled, key revoked"); break; } default: diff --git a/src/routes/convert.ts b/src/routes/convert.ts index b36077e..6266f24 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -2,6 +2,7 @@ import { Router, Request, Response } from "express"; import { renderPdf, renderUrlPdf, getPoolStats } 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: string): boolean { @@ -74,7 +75,7 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); } catch (err: any) { - console.error("Convert HTML error:", err); + logger.error({ err }, "Convert HTML error"); if (err.message === "QUEUE_FULL") { res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); return; @@ -118,7 +119,7 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); } catch (err: any) { - console.error("Convert MD error:", 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; @@ -186,7 +187,7 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); } catch (err: any) { - console.error("Convert URL error:", 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; diff --git a/src/routes/email-change.ts b/src/routes/email-change.ts index 2121450..bf4fdd2 100644 --- a/src/routes/email-change.ts +++ b/src/routes/email-change.ts @@ -4,6 +4,7 @@ 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(); @@ -46,7 +47,7 @@ router.post("/", changeLimiter, async (req: Request, res: Response) => { const pending = await createPendingVerification(cleanEmail); sendVerificationEmail(cleanEmail, (pending as any).code).catch((err: Error) => { - console.error(`Failed to send email change verification to ${cleanEmail}:`, 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." }); diff --git a/src/routes/health.ts b/src/routes/health.ts index 5d27dbb..82079e5 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,7 +1,11 @@ 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) => { @@ -38,7 +42,7 @@ healthRouter.get("/", async (_req, res) => { const response = { status: overallStatus, - version: "0.2.1", + version: APP_VERSION, database: databaseStatus, pool: { size: poolStats.poolSize, diff --git a/src/routes/recover.ts b/src/routes/recover.ts index b1027b7..4ada099 100644 --- a/src/routes/recover.ts +++ b/src/routes/recover.ts @@ -3,6 +3,7 @@ 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(); @@ -34,7 +35,7 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => { const pending = await createPendingVerification(cleanEmail); sendVerificationEmail(cleanEmail, pending.code).catch(err => { - console.error(`Failed to send recovery email to ${cleanEmail}:`, 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." }); diff --git a/src/routes/signup.ts b/src/routes/signup.ts index da296e6..cba1423 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit"; import { createFreeKey } from "../services/keys.js"; import { createVerification, createPendingVerification, verifyCode, isEmailVerified, getVerifiedApiKey } from "../services/verification.js"; import { sendVerificationEmail } from "../services/email.js"; +import logger from "../services/logger.js"; const router = Router(); @@ -53,7 +54,7 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, r const pending = await createPendingVerification(cleanEmail); sendVerificationEmail(cleanEmail, pending.code).catch(err => { - console.error(`Failed to send verification email to ${cleanEmail}:`, err); + logger.error({ err, email: cleanEmail }, "Failed to send verification email"); }); res.json({ diff --git a/src/routes/templates.ts b/src/routes/templates.ts index d65a518..0ddf66d 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -1,5 +1,6 @@ import { Router, Request, Response } 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(); @@ -37,7 +38,7 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => { res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); } catch (err: any) { - console.error("Template render error:", err); + logger.error({ err }, "Template render error"); res.status(500).json({ error: "Template rendering failed", detail: err.message }); } }); diff --git a/src/services/browser.ts b/src/services/browser.ts index bc18d1c..662e8b7 100644 --- a/src/services/browser.ts +++ b/src/services/browser.ts @@ -1,4 +1,5 @@ import puppeteer, { Browser, Page } 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); @@ -90,9 +91,19 @@ async function acquirePage(): Promise<{ page: Page; instance: BrowserInstance }> return { page, instance: inst }; } - // All pages busy, queue - return new Promise((resolve) => { - waitingQueue.push({ resolve }); + // 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); + }, + }); }); } @@ -125,7 +136,7 @@ function releasePage(page: Page, inst: BrowserInstance): void { async function scheduleRestart(inst: BrowserInstance): Promise { if (inst.restarting) return; inst.restarting = true; - console.log(`Scheduling browser ${inst.id} restart (pdfs=${inst.pdfCount}, uptime=${Math.round((Date.now() - inst.lastRestartTime) / 1000)}s)`); + 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 = () => { @@ -159,7 +170,7 @@ async function scheduleRestart(inst: BrowserInstance): Promise { inst.pdfCount = 0; inst.lastRestartTime = Date.now(); inst.restarting = false; - console.log(`Browser ${inst.id} restarted successfully`); + logger.info(`Browser ${inst.id} restarted successfully`); while (waitingQueue.length > 0 && inst.availablePages.length > 0) { const waiter = waitingQueue.shift(); @@ -193,7 +204,7 @@ export async function initBrowser(): Promise { const inst = await launchInstance(i); instances.push(inst); } - console.log(`Browser pool ready (${BROWSER_COUNT} browsers × ${PAGES_PER_BROWSER} pages = ${BROWSER_COUNT * PAGES_PER_BROWSER} total)`); + logger.info(`Browser pool ready (${BROWSER_COUNT} browsers × ${PAGES_PER_BROWSER} pages = ${BROWSER_COUNT * PAGES_PER_BROWSER} total)`); } export async function closeBrowser(): Promise { @@ -221,20 +232,26 @@ export async function renderPdf( ): Promise { const { page, instance } = await acquirePage(); try { - 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 as any) || "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); + 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 as any) || "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); } @@ -252,19 +269,25 @@ export async function renderUrlPdf( ): Promise { const { page, instance } = await acquirePage(); try { - await page.goto(url, { - waitUntil: (options.waitUntil as any) || "networkidle0", - timeout: 30_000, - }); - - const pdf = await page.pdf({ - format: (options.format as any) || "A4", - landscape: options.landscape || false, - printBackground: options.printBackground !== false, - margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, - }); - - return Buffer.from(pdf); + const result = await Promise.race([ + (async () => { + await page.goto(url, { + waitUntil: (options.waitUntil as any) || "networkidle0", + timeout: 30_000, + }); + const pdf = await page.pdf({ + format: (options.format as any) || "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/src/services/db.ts b/src/services/db.ts index 41e9a92..48151ad 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -1,5 +1,6 @@ import pg from "pg"; +import logger from "./logger.js"; const { Pool } = pg; const pool = new Pool({ @@ -13,7 +14,7 @@ const pool = new Pool({ }); pool.on("error", (err) => { - console.error("Unexpected PostgreSQL pool error:", err); + logger.error({ err }, "Unexpected PostgreSQL pool error"); }); export async function initDatabase(): Promise { @@ -55,7 +56,7 @@ export async function initDatabase(): Promise { month_key TEXT NOT NULL ); `); - console.log("PostgreSQL tables initialized"); + logger.info("PostgreSQL tables initialized"); } finally { client.release(); } diff --git a/src/services/email.ts b/src/services/email.ts index 639c653..3dab6a5 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import logger from "./logger.js"; const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST || "host.docker.internal", @@ -18,10 +19,10 @@ export async function sendVerificationEmail(email: string, code: string): Promis 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.`, }); - console.log(`📧 Verification email sent to ${email}: ${info.messageId}`); + logger.info({ email, messageId: info.messageId }, "Verification email sent"); return true; } catch (err) { - console.error(`📧 Failed to send verification email to ${email}:`, err); + logger.error({ err, email }, "Failed to send verification email"); return false; } } diff --git a/src/services/keys.ts b/src/services/keys.ts index 0055d61..0737c6a 100644 --- a/src/services/keys.ts +++ b/src/services/keys.ts @@ -1,4 +1,5 @@ import { randomBytes } from "crypto"; +import logger from "./logger.js"; import pool from "./db.js"; export interface ApiKey { @@ -25,7 +26,7 @@ export async function loadKeys(): Promise { stripeCustomerId: r.stripe_customer_id || undefined, })); } catch (err) { - console.error("Failed to load keys from PostgreSQL:", err); + logger.error({ err }, "Failed to load keys from PostgreSQL"); keysCache = []; } diff --git a/src/services/logger.ts b/src/services/logger.ts new file mode 100644 index 0000000..208e1db --- /dev/null +++ b/src/services/logger.ts @@ -0,0 +1,10 @@ +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/src/services/templates.ts b/src/services/templates.ts index d905970..e973add 100644 --- a/src/services/templates.ts +++ b/src/services/templates.ts @@ -47,7 +47,7 @@ function esc(s: string): string { } function renderInvoice(d: any): string { - const cur = d.currency || "€"; + const cur = esc(d.currency || "€"); const items = d.items || []; let subtotal = 0; let totalTax = 0; @@ -133,7 +133,7 @@ function renderInvoice(d: any): string { } function renderReceipt(d: any): string { - const cur = d.currency || "€"; + const cur = esc(d.currency || "€"); const items = d.items || []; let total = 0; diff --git a/src/services/verification.ts b/src/services/verification.ts index 818371c..07f86d1 100644 --- a/src/services/verification.ts +++ b/src/services/verification.ts @@ -1,4 +1,5 @@ import { randomBytes, randomInt } from "crypto"; +import logger from "./logger.js"; import pool from "./db.js"; export interface Verification { @@ -63,6 +64,17 @@ export async function loadVerifications(): Promise { 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: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } { @@ -73,7 +85,7 @@ function verifyTokenSync(token: string): { status: "ok"; verification: Verificat 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(console.error); + 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 }; }