Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
- Add reusable header components (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After) - Reference headers in 200 responses on all conversion and demo endpoints - Add Retry-After header to 429 responses - Update Rate Limits section in API description to mention response headers - Add comprehensive tests for header documentation (21 new tests) - All 809 tests passing
309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
import express from "express";
|
|
import { randomUUID } from "crypto";
|
|
import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot
|
|
import { compressionMiddleware } from "./middleware/compression.js";
|
|
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 { 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 { pdfRateLimitMiddleware } from "./middleware/pdfRateLimit.js";
|
|
import { adminRouter } from "./routes/admin.js";
|
|
import { initBrowser, closeBrowser } from "./services/browser.js";
|
|
import { loadKeys, getAllKeys } from "./services/keys.js";
|
|
import { pagesRouter } from "./routes/pages.js";
|
|
import { initDatabase, pool, cleanupStaleData } from "./services/db.js";
|
|
import { startPeriodicCleanup, stopPeriodicCleanup } from "./utils/periodic-cleanup.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);
|
|
// Block search engine indexing on staging
|
|
app.use((req, res, next) => {
|
|
if (req.hostname.includes("staging")) {
|
|
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
|
}
|
|
next();
|
|
});
|
|
// Differentiated CORS middleware
|
|
const ALLOWED_ORIGINS = new Set(["https://docfast.dev", "https://staging.docfast.dev"]);
|
|
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) {
|
|
const origin = req.headers.origin;
|
|
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
res.setHeader("Vary", "Origin");
|
|
}
|
|
else {
|
|
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);
|
|
// Admin + usage routes (extracted to routes/admin.ts)
|
|
app.use(adminRouter);
|
|
// Pages, favicon, docs, openapi.json, /api (extracted to routes/pages.ts)
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
app.use(pagesRouter);
|
|
// 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();
|
|
});
|
|
app.use(express.static(path.join(__dirname, "../public"), {
|
|
etag: true,
|
|
cacheControl: false,
|
|
}));
|
|
// 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(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>404 - Page Not Found | DocFast</title>
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: 'Inter', sans-serif; background: #0b0d11; color: #e4e7ed; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
|
.container { background: #151922; border: 1px solid #1e2433; border-radius: 16px; padding: 48px; max-width: 520px; width: 100%; text-align: center; }
|
|
h1 { font-size: 3rem; margin-bottom: 12px; font-weight: 700; color: #34d399; }
|
|
h2 { font-size: 1.5rem; margin-bottom: 16px; font-weight: 600; }
|
|
p { color: #7a8194; margin-bottom: 24px; line-height: 1.6; }
|
|
a { color: #34d399; text-decoration: none; font-weight: 600; }
|
|
a:hover { color: #5eead4; }
|
|
.emoji { font-size: 4rem; margin-bottom: 24px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="emoji">⚡</div>
|
|
<h1>404</h1>
|
|
<h2>Page Not Found</h2>
|
|
<p>The page you're looking for doesn't exist or has been moved.</p>
|
|
<p><a href="/">← Back to DocFast</a> | <a href="/docs">Read the docs</a></p>
|
|
</div>
|
|
</body>
|
|
</html>`);
|
|
}
|
|
});
|
|
// Global error handler — must be after all routes
|
|
app.use((err, req, res, _next) => {
|
|
const reqId = req.requestId || "unknown";
|
|
// Check if this is a JSON parse error from express.json()
|
|
if (err instanceof SyntaxError && 'status' in err && err.status === 400 && 'body' in err) {
|
|
logger.warn({ err, requestId: reqId, method: req.method, path: req.path }, "Invalid JSON body");
|
|
if (!res.headersSent) {
|
|
res.status(400).json({ error: "Invalid JSON in request body" });
|
|
}
|
|
return;
|
|
}
|
|
logger.error({ err, requestId: reqId, method: req.method, path: req.path }, "Unhandled route error");
|
|
if (!res.headersSent) {
|
|
const isApi = req.path.startsWith("/v1/") || req.path.startsWith("/health");
|
|
if (isApi) {
|
|
res.status(500).json({ error: "Internal server error" });
|
|
}
|
|
else {
|
|
res.status(500).send("Internal server error");
|
|
}
|
|
}
|
|
});
|
|
async function start() {
|
|
// Initialize PostgreSQL
|
|
await initDatabase();
|
|
// Load data from PostgreSQL
|
|
await loadKeys();
|
|
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);
|
|
// Run database cleanup every 6 hours (expired verifications, orphaned usage)
|
|
startPeriodicCleanup();
|
|
let shuttingDown = false;
|
|
const shutdown = async (signal) => {
|
|
if (shuttingDown)
|
|
return;
|
|
shuttingDown = true;
|
|
logger.info(`Received ${signal}, starting graceful shutdown...`);
|
|
// 0. Stop periodic cleanup timer
|
|
stopPeriodicCleanup();
|
|
// 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 };
|