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
+
+
+
+
+
+
+
+`);
+ }
+});
+
+
+
+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();
+}