Document rate limit headers in OpenAPI spec
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
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
This commit is contained in:
parent
a3bba8f0d5
commit
70eb6908e3
18 changed files with 801 additions and 821 deletions
253
dist/index.js
vendored
253
dist/index.js
vendored
|
|
@ -1,11 +1,9 @@
|
|||
import express from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createRequire } from "module";
|
||||
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";
|
||||
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";
|
||||
|
|
@ -18,13 +16,13 @@ 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 { pdfRateLimitMiddleware } from "./middleware/pdfRateLimit.js";
|
||||
import { adminRouter } from "./routes/admin.js";
|
||||
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||
import { loadKeys, getAllKeys, isProKey } from "./services/keys.js";
|
||||
import { verifyToken, loadVerifications } from "./services/verification.js";
|
||||
import { loadKeys, getAllKeys } from "./services/keys.js";
|
||||
import { pagesRouter } from "./routes/pages.js";
|
||||
import { initDatabase, pool, cleanupStaleData } from "./services/db.js";
|
||||
import { swaggerSpec } from "./swagger.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" } }));
|
||||
|
|
@ -49,7 +47,15 @@ app.use((_req, res, 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') ||
|
||||
|
|
@ -57,7 +63,14 @@ app.use((req, res, next) => {
|
|||
req.path.startsWith('/v1/demo') ||
|
||||
req.path.startsWith('/v1/email-change');
|
||||
if (isAuthBillingRoute) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
|
||||
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", "*");
|
||||
|
|
@ -128,155 +141,11 @@ app.use("/v1/billing", defaultJsonParser, billingRouter);
|
|||
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 `<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${title} — 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;800&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}
|
||||
.card{background:#151922;border:1px solid #1e2433;border-radius:16px;padding:48px;max-width:520px;width:100%;text-align:center}
|
||||
h1{font-size:1.8rem;margin-bottom:12px;font-weight:800}
|
||||
p{color:#7a8194;margin-bottom:24px;line-height:1.6}
|
||||
.key-box{background:#0b0d11;border:1px solid #34d399;border-radius:8px;padding:16px;font-family:monospace;font-size:0.82rem;word-break:break-all;margin:16px 0;cursor:pointer;transition:background 0.2s;position:relative}
|
||||
.key-box:hover{background:#12151c}
|
||||
.key-box::after{content:'Click to copy';position:absolute;top:-24px;right:0;font-size:0.7rem;color:#7a8194;font-family:'Inter',sans-serif}
|
||||
.warning{background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.15);border-radius:8px;padding:12px 16px;font-size:0.85rem;color:#fbbf24;margin-bottom:16px;text-align:left}
|
||||
.links{margin-top:24px;color:#7a8194;font-size:0.9rem}
|
||||
.links a{color:#34d399;text-decoration:none}
|
||||
.links a:hover{color:#5eead4}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>${title}</h1>
|
||||
<p>${message}</p>
|
||||
${apiKey ? `
|
||||
<div class="warning">⚠️ Save your API key securely. You can recover it via email if needed.</div>
|
||||
<div class="key-box" data-copy="${apiKey}">${apiKey}</div>
|
||||
<div class="links">Upgrade to Pro for 5,000 PDFs/month · <a href="/docs">Read the docs →</a></div>
|
||||
` : `<div class="links"><a href="/">← Back to DocFast</a></div>`}
|
||||
</div>
|
||||
<script src="/copy-helper.js"></script>
|
||||
</body></html>`;
|
||||
}
|
||||
// Landing page
|
||||
// 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));
|
||||
// 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"));
|
||||
});
|
||||
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)) {
|
||||
|
|
@ -284,53 +153,10 @@ app.use((req, res, next) => {
|
|||
}
|
||||
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
|
||||
|
|
@ -373,12 +199,33 @@ app.use((req, res) => {
|
|||
</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 loadVerifications();
|
||||
await loadUsageData();
|
||||
await initBrowser();
|
||||
logger.info(`Loaded ${getAllKeys().length} API keys`);
|
||||
|
|
@ -393,12 +240,16 @@ async function start() {
|
|||
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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue