import express from "express"; import { randomUUID } from "crypto"; import { createRequire } from "module"; import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; const _require = createRequire(import.meta.url); const APP_VERSION = _require("../package.json").version; 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 { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware, loadUsageData, flushDirtyEntries } from "./middleware/usage.js"; import { getUsageStats, getUsageForKey } from "./middleware/usage.js"; import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; import { loadKeys, getAllKeys, isProKey } from "./services/keys.js"; import { verifyToken, loadVerifications } from "./services/verification.js"; import { initDatabase, pool, cleanupStaleData } from "./services/db.js"; import { swaggerSpec } from "./swagger.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(compressionMiddleware); // 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/demo') || 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" })); // NOTE: No global express.json() here — route-specific parsers are applied // per-route below to enforce correct body size limits (BUG-101 fix). 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/demo", express.json({ limit: "50kb" }), pdfRateLimitMiddleware, demoRouter); /** * @openapi * /v1/signup/free: * post: * tags: [Account] * deprecated: true * summary: Request a free API key (discontinued) * description: Free accounts have been discontinued. Use the demo endpoints or upgrade to Pro. * responses: * 410: * description: Feature discontinued * content: * application/json: * schema: * type: object * properties: * error: * type: string * demo_endpoint: * type: string * pro_url: * type: string */ app.use("/v1/signup", (_req, res) => { res.status(410).json({ error: "Free accounts have been discontinued. Try our demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev", demo_endpoint: "/v1/demo/html", pro_url: "https://docfast.dev/#pricing" }); }); // Default 2MB JSON parser for standard routes const defaultJsonParser = express.json({ limit: "2mb" }); app.use("/v1/recover", defaultJsonParser, recoverRouter); app.use("/v1/email-change", defaultJsonParser, emailChangeRouter); app.use("/v1/billing", defaultJsonParser, billingRouter); // Authenticated routes — conversion routes get tighter body limits (500KB) const convertBodyLimit = express.json({ limit: "500kb" }); app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", defaultJsonParser, authMiddleware, usageMiddleware, templatesRouter); /** * @openapi * /v1/usage/me: * get: * summary: Get your current month's usage * description: Returns the authenticated user's PDF generation usage for the current billing month. * security: * - ApiKeyAuth: [] * responses: * 200: * description: Current usage statistics * content: * application/json: * schema: * type: object * properties: * used: * type: integer * description: Number of PDFs generated this month * limit: * type: integer * description: Monthly PDF limit for your plan * plan: * type: string * enum: [pro, demo] * description: Your current plan * month: * type: string * description: Current billing month (YYYY-MM) * 401: * description: Missing or invalid API key */ app.get("/v1/usage/me", authMiddleware, (req, res) => { const key = req.apiKeyInfo.key; const { count, monthKey } = getUsageForKey(key); const pro = isProKey(key); res.json({ used: count, limit: pro ? 5000 : 100, plan: pro ? "pro" : "demo", month: monthKey, }); }); // Admin: usage stats (admin key required) const adminAuth = (req, res, next) => { const adminKey = process.env.ADMIN_API_KEY; if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; } if (req.apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; } next(); }; app.get("/v1/usage", authMiddleware, adminAuth, (req, res) => { res.json(getUsageStats(req.apiKeyInfo?.key)); }); // Admin: concurrency stats (admin key required) app.get("/v1/concurrency", authMiddleware, adminAuth, (_req, res) => { res.json(getConcurrencyStats()); }); // Admin: database cleanup (admin key required) app.post("/admin/cleanup", authMiddleware, adminAuth, async (_req, res) => { try { const results = await cleanupStaleData(); res.json({ status: "ok", cleaned: results }); } catch (err) { logger.error({ err }, "Admin cleanup failed"); res.status(500).json({ error: "Cleanup failed", message: err.message }); } }); // 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)); // Favicon route app.get("/favicon.ico", (_req, res) => { res.setHeader('Content-Type', 'image/svg+xml'); res.setHeader('Cache-Control', 'public, max-age=604800'); res.sendFile(path.join(__dirname, "../public/favicon.svg")); }); // Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup app.get("/openapi.json", (_req, res) => { res.json(swaggerSpec); }); // Docs page (clean URL) app.get("/docs", (_req, res) => { // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. // Override helmet's default CSP to allow 'unsafe-eval' + blob: for Swagger UI. res.setHeader("Content-Security-Policy", "default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'"); res.setHeader('Cache-Control', 'public, max-age=86400'); res.sendFile(path.join(__dirname, "../public/docs.html")); }); // Static asset cache headers middleware app.use((req, res, next) => { if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); } next(); }); // Landing page (explicit route to set Cache-Control header) app.get("/", (_req, res) => { res.setHeader('Cache-Control', 'public, max-age=3600'); res.sendFile(path.join(__dirname, "../public/index.html")); }); app.use(express.static(path.join(__dirname, "../public"), { etag: true, cacheControl: false, })); // 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")); }); app.get("/examples", (_req, res) => { res.setHeader('Cache-Control', 'public, max-age=86400'); res.sendFile(path.join(__dirname, "../public/examples.html")); }); app.get("/status", (_req, res) => { res.setHeader("Cache-Control", "public, max-age=60"); res.sendFile(path.join(__dirname, "../public/status.html")); }); // API root app.get("/api", (_req, res) => { res.json({ name: "DocFast API", version: APP_VERSION, endpoints: [ "POST /v1/demo/html — Try HTML→PDF (no auth, watermarked, 5/hour)", "POST /v1/demo/markdown — Try Markdown→PDF (no auth, watermarked, 5/hour)", "POST /v1/convert/html — HTML→PDF (requires API key)", "POST /v1/convert/markdown — Markdown→PDF (requires API key)", "POST /v1/convert/url — URL→PDF (requires API key)", "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: ${req.method} ${req.path}` }); } 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

`); } }); 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`); const server = app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`)); // Run database cleanup 30 seconds after startup (non-blocking) setTimeout(async () => { try { logger.info("Running scheduled database cleanup..."); await cleanupStaleData(); } catch (err) { logger.error({ err }, "Startup cleanup failed (non-fatal)"); } }, 30_000); let shuttingDown = false; const shutdown = async (signal) => { if (shuttingDown) return; shuttingDown = true; logger.info(`Received ${signal}, starting graceful shutdown...`); // 1. Stop accepting new connections, wait for in-flight requests (max 10s) await new Promise((resolve) => { const forceTimeout = setTimeout(() => { logger.warn("Forcing server close after 10s timeout"); resolve(); }, 10_000); server.close(() => { clearTimeout(forceTimeout); logger.info("HTTP server closed (all in-flight requests completed)"); resolve(); }); }); // 1.5. Flush dirty usage entries while DB pool is still alive try { await flushDirtyEntries(); logger.info("Usage data flushed"); } catch (err) { logger.error({ err }, "Error flushing usage data during shutdown"); } // 2. Close Puppeteer browser pool try { await closeBrowser(); logger.info("Browser pool closed"); } catch (err) { logger.error({ err }, "Error closing browser pool"); } // 3. Close PostgreSQL connection pool try { await pool.end(); logger.info("PostgreSQL pool closed"); } catch (err) { logger.error({ err }, "Error closing PostgreSQL pool"); } logger.info("Graceful shutdown complete"); process.exit(0); }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); process.on("uncaughtException", (err) => { logger.fatal({ err }, "Uncaught exception — shutting down"); process.exit(1); }); process.on("unhandledRejection", (reason) => { logger.fatal({ err: reason }, "Unhandled rejection — shutting down"); process.exit(1); }); } if (process.env.NODE_ENV !== "test") { start().catch((err) => { logger.error({ err }, "Failed to start"); process.exit(1); }); } export { app };