diff --git a/dist/index.js b/dist/index.js index aa69275..4152bcf 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,6 +1,6 @@ import express from "express"; import { randomUUID } from "crypto"; -import compression from "compression"; +import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; import path from "path"; @@ -43,7 +43,7 @@ app.use((_req, res, next) => { next(); }); // Compression -app.use(compression()); +app.use(compressionMiddleware); // Differentiated CORS middleware app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || @@ -209,7 +209,7 @@ app.get("/status", (_req, res) => { app.get("/api", (_req, res) => { res.json({ name: "DocFast API", - version: "0.2.1", + version: "0.2.9", endpoints: [ "POST /v1/signup/free โ€” Get a free API key", "POST /v1/convert/html", diff --git a/package.json b/package.json index 0616cf2..de57f00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.2.1", + "version": "0.2.9", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { diff --git a/src/index.ts b/src/index.ts index 51ea85b..07161e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import express from "express"; import { randomUUID } from "crypto"; -import compression from "compression"; +import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; import path from "path"; @@ -48,7 +48,7 @@ app.use((_req, res, next) => { }); // Compression -app.use(compression()); +app.use(compressionMiddleware); // Differentiated CORS middleware app.use((req, res, next) => { @@ -235,7 +235,7 @@ app.get("/status", (_req, res) => { app.get("/api", (_req, res) => { res.json({ name: "DocFast API", - version: "0.2.1", + version: "0.2.9", endpoints: [ "POST /v1/signup/free โ€” Get a free API key", "POST /v1/convert/html", diff --git a/src/index.ts.backup b/src/index.ts.backup new file mode 100644 index 0000000..31c24d1 --- /dev/null +++ b/src/index.ts.backup @@ -0,0 +1,356 @@ +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 { 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, pool } 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"] as string) || randomUUID(); + (req as any).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'); + + 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); + +// 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", authMiddleware, usageMiddleware, templatesRouter); + +// Admin: usage stats (admin key required) +const adminAuth = (req: any, res: any, next: any) => { + 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: any, res: any) => { + res.json(getUsageStats(req.apiKeyInfo?.key)); +}); + +// Admin: concurrency stats (admin key required) +app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: any, res: any) => { + res.json(getConcurrencyStats()); +}); + +// Email verification endpoint +app.get("/verify", (req, res) => { + const token = req.query.token as string; + 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: string, message: string, apiKey: string | null): string { + 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")); +}); + +// 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)) { console.log("CACHE HIT:", req.path); + res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); + } + next(); +}); + +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("/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: "0.2.9", + 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: ${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}`)); + + let shuttingDown = false; + const shutdown = async (signal: string) => { + 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(); + }); + }); + + // 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")); +} + +start().catch((err) => { + logger.error({ err }, "Failed to start"); + process.exit(1); +}); + +export { app }; diff --git a/src/middleware/compression.ts b/src/middleware/compression.ts new file mode 100644 index 0000000..317af41 --- /dev/null +++ b/src/middleware/compression.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from "express"; +import zlib from "zlib"; + +export function compressionMiddleware(req: Request, res: Response, next: NextFunction) { + const acceptEncoding = req.headers["accept-encoding"] || ""; + + // Only compress if content-type suggests compressible content + const originalSend = res.send; + const originalJson = res.json; + + const shouldCompress = (content: any): boolean => { + const contentType = res.getHeader("content-type") as string; + const contentLength = Buffer.byteLength(typeof content === "string" ? content : JSON.stringify(content)); + + // Only compress if content is large enough and is compressible type + return contentLength > 1024 && + (contentType?.includes("text/") || + contentType?.includes("application/json") || + contentType?.includes("application/javascript")); + }; + + const compress = (content: string, encoding: string): Buffer => { + const buffer = Buffer.from(content, "utf8"); + + if (encoding === "br" && acceptEncoding.includes("br")) { + return zlib.brotliCompressSync(buffer, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 6, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.length, + }, + }); + } else if (encoding === "gzip" && acceptEncoding.includes("gzip")) { + return zlib.gzipSync(buffer, { level: 6 }); + } + + return buffer; + }; + + // Override res.send + res.send = function(content: any) { + if (shouldCompress(content)) { + const stringContent = typeof content === "string" ? content : JSON.stringify(content); + + if (acceptEncoding.includes("br")) { + const compressed = compress(stringContent, "br"); + res.setHeader("Content-Encoding", "br"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } else if (acceptEncoding.includes("gzip")) { + const compressed = compress(stringContent, "gzip"); + res.setHeader("Content-Encoding", "gzip"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } + } + + return originalSend.call(this, content); + }; + + // Override res.json + res.json = function(content: any) { + if (shouldCompress(content)) { + const stringContent = JSON.stringify(content); + + if (acceptEncoding.includes("br")) { + const compressed = compress(stringContent, "br"); + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Encoding", "br"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } else if (acceptEncoding.includes("gzip")) { + const compressed = compress(stringContent, "gzip"); + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Encoding", "gzip"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } + } + + return originalJson.call(this, content); + }; + + next(); +}