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(() => {
|
||||
|
|
|
|||
2
dist/middleware/usage.js
vendored
2
dist/middleware/usage.js
vendored
|
|
@ -83,7 +83,7 @@ export function usageMiddleware(req, res, next) {
|
|||
}
|
||||
const record = usage.get(key);
|
||||
if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) {
|
||||
res.status(429).json({ error: "Free tier limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." });
|
||||
res.status(429).json({ error: "Account limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." });
|
||||
return;
|
||||
}
|
||||
trackUsage(key, monthKey);
|
||||
|
|
|
|||
46
dist/routes/billing.js
vendored
46
dist/routes/billing.js
vendored
|
|
@ -1,15 +1,16 @@
|
|||
import { Router } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
import Stripe from "stripe";
|
||||
import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
import { escapeHtml } from "../utils/html.js";
|
||||
import { renderSuccessPage, renderAlreadyProvisionedPage } from "../utils/billing-templates.js";
|
||||
let _stripe = null;
|
||||
function getStripe() {
|
||||
if (!_stripe) {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key)
|
||||
throw new Error("STRIPE_SECRET_KEY not configured");
|
||||
// @ts-expect-error Stripe SDK types lag behind API versions
|
||||
_stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" });
|
||||
}
|
||||
return _stripe;
|
||||
|
|
@ -63,7 +64,7 @@ async function isDocFastSubscription(subscriptionId) {
|
|||
const checkoutLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 3,
|
||||
keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown",
|
||||
keyGenerator: (req) => ipKeyGenerator(req.ip || req.socket.remoteAddress || "unknown"),
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many checkout requests. Please try again later." },
|
||||
|
|
@ -148,47 +149,12 @@ router.get("/success", async (req, res) => {
|
|||
const existingKey = await findKeyByCustomerId(customerId);
|
||||
if (existingKey) {
|
||||
provisionedSessions.set(session.id, Date.now());
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html><head><title>DocFast Pro — Key Already Provisioned</title>
|
||||
<style>
|
||||
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
|
||||
h1 { color: #4f9; margin-bottom: 8px; }
|
||||
p { color: #888; line-height: 1.6; }
|
||||
a { color: #4f9; }
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>✅ Key Already Provisioned</h1>
|
||||
<p>A Pro API key has already been created for this purchase.</p>
|
||||
<p>If you lost your key, use the <a href="/docs#key-recovery">key recovery feature</a>.</p>
|
||||
<p><a href="/docs">View API docs →</a></p>
|
||||
</div></body></html>`);
|
||||
res.send(renderAlreadyProvisionedPage());
|
||||
return;
|
||||
}
|
||||
const keyInfo = await createProKey(email, customerId);
|
||||
provisionedSessions.set(session.id, Date.now());
|
||||
// Return a nice HTML page instead of raw JSON
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html><head><title>Welcome to DocFast Pro!</title>
|
||||
<style>
|
||||
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
|
||||
h1 { color: #4f9; margin-bottom: 8px; }
|
||||
.key { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 16px; margin: 24px 0; font-family: monospace; font-size: 0.9rem; word-break: break-all; cursor: pointer; }
|
||||
.key:hover { border-color: #4f9; }
|
||||
p { color: #888; line-height: 1.6; }
|
||||
a { color: #4f9; }
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>🎉 Welcome to Pro!</h1>
|
||||
<p>Your API key:</p>
|
||||
<div class="key" style="position:relative">${escapeHtml(keyInfo.key)}<button data-copy="${escapeHtml(keyInfo.key)}" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
||||
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
||||
<p>5,000 PDFs/month • All endpoints • Priority support</p>
|
||||
<p><a href="/docs">View API docs →</a></p>
|
||||
</div>
|
||||
<script src="/copy-helper.js"></script>
|
||||
</body></html>`);
|
||||
res.send(renderSuccessPage(keyInfo.key));
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Success page error");
|
||||
|
|
|
|||
183
dist/routes/convert.js
vendored
183
dist/routes/convert.js
vendored
|
|
@ -2,10 +2,9 @@ import { Router } from "express";
|
|||
import { renderPdf, renderUrlPdf } from "../services/browser.js";
|
||||
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
||||
import dns from "node:dns/promises";
|
||||
import logger from "../services/logger.js";
|
||||
import { isPrivateIP } from "../utils/network.js";
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
import { validatePdfOptions } from "../utils/pdf-options.js";
|
||||
import { handlePdfRoute } from "../utils/pdf-handler.js";
|
||||
export const convertRouter = Router();
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -38,6 +37,13 @@ export const convertRouter = Router();
|
|||
* responses:
|
||||
* 200:
|
||||
* description: PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -53,60 +59,25 @@ export const convertRouter = Router();
|
|||
* description: Unsupported Content-Type (must be application/json)
|
||||
* 429:
|
||||
* description: Rate limit or usage limit exceeded
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* 500:
|
||||
* description: PDF generation failed
|
||||
*/
|
||||
convertRouter.post("/html", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
await handlePdfRoute(req, res, async (sanitizedOptions) => {
|
||||
const body = typeof req.body === "string" ? { html: req.body } : req.body;
|
||||
if (!body.html) {
|
||||
res.status(400).json({ error: "Missing 'html' field" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
// Validate PDF options
|
||||
const validation = validatePdfOptions(body);
|
||||
if (!validation.valid) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
// Acquire concurrency slot
|
||||
if (req.acquirePdfSlot) {
|
||||
await req.acquirePdfSlot();
|
||||
slotAcquired = true;
|
||||
}
|
||||
// Wrap bare HTML fragments
|
||||
const fullHtml = body.html.includes("<html")
|
||||
? body.html
|
||||
: wrapHtml(body.html, body.css);
|
||||
const { pdf, durationMs } = await renderPdf(fullHtml, {
|
||||
...validation.sanitized,
|
||||
});
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("X-Render-Time", String(durationMs));
|
||||
res.send(pdf);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Convert HTML error");
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
}
|
||||
}
|
||||
const { pdf, durationMs } = await renderPdf(fullHtml, { ...sanitizedOptions });
|
||||
return { pdf, durationMs, filename: sanitizeFilename(body.filename || "document.pdf") };
|
||||
});
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -138,6 +109,13 @@ convertRouter.post("/html", async (req, res) => {
|
|||
* responses:
|
||||
* 200:
|
||||
* description: PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -153,57 +131,23 @@ convertRouter.post("/html", async (req, res) => {
|
|||
* description: Unsupported Content-Type
|
||||
* 429:
|
||||
* description: Rate limit or usage limit exceeded
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* 500:
|
||||
* description: PDF generation failed
|
||||
*/
|
||||
convertRouter.post("/markdown", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
await handlePdfRoute(req, res, async (sanitizedOptions) => {
|
||||
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||
if (!body.markdown) {
|
||||
res.status(400).json({ error: "Missing 'markdown' field" });
|
||||
return;
|
||||
}
|
||||
// Validate PDF options
|
||||
const validation = validatePdfOptions(body);
|
||||
if (!validation.valid) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
// Acquire concurrency slot
|
||||
if (req.acquirePdfSlot) {
|
||||
await req.acquirePdfSlot();
|
||||
slotAcquired = true;
|
||||
return null;
|
||||
}
|
||||
const html = markdownToHtml(body.markdown, body.css);
|
||||
const { pdf, durationMs } = await renderPdf(html, {
|
||||
...validation.sanitized,
|
||||
});
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("X-Render-Time", String(durationMs));
|
||||
res.send(pdf);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Convert MD error");
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
}
|
||||
}
|
||||
const { pdf, durationMs } = await renderPdf(html, { ...sanitizedOptions });
|
||||
return { pdf, durationMs, filename: sanitizeFilename(body.filename || "document.pdf") };
|
||||
});
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -240,6 +184,13 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
* responses:
|
||||
* 200:
|
||||
* description: PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -255,22 +206,18 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
* description: Unsupported Content-Type
|
||||
* 429:
|
||||
* description: Rate limit or usage limit exceeded
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* 500:
|
||||
* description: PDF generation failed
|
||||
*/
|
||||
convertRouter.post("/url", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
await handlePdfRoute(req, res, async (sanitizedOptions) => {
|
||||
const body = req.body;
|
||||
if (!body.url) {
|
||||
res.status(400).json({ error: "Missing 'url' field" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
// URL validation + SSRF protection
|
||||
let parsed;
|
||||
|
|
@ -278,59 +225,31 @@ convertRouter.post("/url", async (req, res) => {
|
|||
parsed = new URL(body.url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
res.status(400).json({ error: "Only http/https URLs are supported" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
res.status(400).json({ error: "Invalid URL" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
// DNS lookup to block private/reserved IPs + pin resolution to prevent DNS rebinding
|
||||
// DNS lookup to block private/reserved IPs + pin resolution
|
||||
let resolvedAddress;
|
||||
try {
|
||||
const { address } = await dns.lookup(parsed.hostname);
|
||||
if (isPrivateIP(address)) {
|
||||
res.status(400).json({ error: "URL resolves to a private/internal IP address" });
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
resolvedAddress = address;
|
||||
}
|
||||
catch {
|
||||
res.status(400).json({ error: "DNS lookup failed for URL hostname" });
|
||||
return;
|
||||
}
|
||||
// Validate PDF options
|
||||
const validation = validatePdfOptions(body);
|
||||
if (!validation.valid) {
|
||||
res.status(400).json({ error: validation.error });
|
||||
return;
|
||||
}
|
||||
// Acquire concurrency slot
|
||||
if (req.acquirePdfSlot) {
|
||||
await req.acquirePdfSlot();
|
||||
slotAcquired = true;
|
||||
return null;
|
||||
}
|
||||
const { pdf, durationMs } = await renderUrlPdf(body.url, {
|
||||
...validation.sanitized,
|
||||
...sanitizedOptions,
|
||||
hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`,
|
||||
});
|
||||
const filename = sanitizeFilename(body.filename || "page.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("X-Render-Time", String(durationMs));
|
||||
res.send(pdf);
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Convert URL error");
|
||||
if (err.message === "QUEUE_FULL") {
|
||||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
}
|
||||
}
|
||||
return { pdf, durationMs, filename: sanitizeFilename(body.filename || "page.pdf") };
|
||||
});
|
||||
});
|
||||
|
|
|
|||
130
dist/routes/email-change.js
vendored
130
dist/routes/email-change.js
vendored
|
|
@ -1,5 +1,5 @@
|
|||
import { Router } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
import { createPendingVerification, verifyCode } from "../services/verification.js";
|
||||
import { sendVerificationEmail } from "../services/email.js";
|
||||
import { queryWithRetry } from "../services/db.js";
|
||||
|
|
@ -11,7 +11,7 @@ const emailChangeLimiter = rateLimit({
|
|||
message: { error: "Too many email change attempts. Please try again in 1 hour." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.body?.apiKey || req.ip || "unknown",
|
||||
keyGenerator: (req) => req.body?.apiKey || ipKeyGenerator(req.ip || "unknown"),
|
||||
});
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
async function validateApiKey(apiKey) {
|
||||
|
|
@ -63,36 +63,43 @@ async function validateApiKey(apiKey) {
|
|||
* description: Too many attempts
|
||||
*/
|
||||
router.post("/", emailChangeLimiter, async (req, res) => {
|
||||
const { apiKey, newEmail } = req.body || {};
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
res.status(400).json({ error: "apiKey is required." });
|
||||
return;
|
||||
try {
|
||||
const { apiKey, newEmail } = req.body || {};
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
res.status(400).json({ error: "apiKey is required." });
|
||||
return;
|
||||
}
|
||||
if (!newEmail || typeof newEmail !== "string") {
|
||||
res.status(400).json({ error: "newEmail is required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
if (!EMAIL_RE.test(cleanEmail)) {
|
||||
res.status(400).json({ error: "Invalid email format." });
|
||||
return;
|
||||
}
|
||||
const keyRow = await validateApiKey(apiKey);
|
||||
if (!keyRow) {
|
||||
res.status(403).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
// Check if email is already taken by another key
|
||||
const existing = await queryWithRetry(`SELECT key FROM api_keys WHERE email = $1 AND key != $2`, [cleanEmail, apiKey]);
|
||||
if (existing.rows.length > 0) {
|
||||
res.status(409).json({ error: "This email is already associated with another account." });
|
||||
return;
|
||||
}
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
|
||||
});
|
||||
res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." });
|
||||
}
|
||||
if (!newEmail || typeof newEmail !== "string") {
|
||||
res.status(400).json({ error: "newEmail is required." });
|
||||
return;
|
||||
catch (err) {
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
if (!EMAIL_RE.test(cleanEmail)) {
|
||||
res.status(400).json({ error: "Invalid email format." });
|
||||
return;
|
||||
}
|
||||
const keyRow = await validateApiKey(apiKey);
|
||||
if (!keyRow) {
|
||||
res.status(403).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
// Check if email is already taken by another key
|
||||
const existing = await queryWithRetry(`SELECT key FROM api_keys WHERE email = $1 AND key != $2`, [cleanEmail, apiKey]);
|
||||
if (existing.rows.length > 0) {
|
||||
res.status(409).json({ error: "This email is already associated with another account." });
|
||||
return;
|
||||
}
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
|
||||
});
|
||||
res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." });
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -140,35 +147,42 @@ router.post("/", emailChangeLimiter, async (req, res) => {
|
|||
* description: Too many failed attempts
|
||||
*/
|
||||
router.post("/verify", async (req, res) => {
|
||||
const { apiKey, newEmail, code } = req.body || {};
|
||||
if (!apiKey || !newEmail || !code) {
|
||||
res.status(400).json({ error: "apiKey, newEmail, and code are required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
const keyRow = await validateApiKey(apiKey);
|
||||
if (!keyRow) {
|
||||
res.status(403).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
const result = await verifyCode(cleanEmail, cleanCode);
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
await queryWithRetry(`UPDATE api_keys SET email = $1 WHERE key = $2`, [cleanEmail, apiKey]);
|
||||
logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed");
|
||||
res.json({ status: "ok", newEmail: cleanEmail });
|
||||
break;
|
||||
try {
|
||||
const { apiKey, newEmail, code } = req.body || {};
|
||||
if (!apiKey || !newEmail || !code) {
|
||||
res.status(400).json({ error: "apiKey, newEmail, and code are required." });
|
||||
return;
|
||||
}
|
||||
case "expired":
|
||||
res.status(410).json({ error: "Verification code has expired. Please request a new one." });
|
||||
break;
|
||||
case "max_attempts":
|
||||
res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
|
||||
break;
|
||||
case "invalid":
|
||||
res.status(400).json({ error: "Invalid verification code." });
|
||||
break;
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
const keyRow = await validateApiKey(apiKey);
|
||||
if (!keyRow) {
|
||||
res.status(403).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
const result = await verifyCode(cleanEmail, cleanCode);
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
await queryWithRetry(`UPDATE api_keys SET email = $1 WHERE key = $2`, [cleanEmail, apiKey]);
|
||||
logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed");
|
||||
res.json({ status: "ok", newEmail: cleanEmail });
|
||||
break;
|
||||
}
|
||||
case "expired":
|
||||
res.status(410).json({ error: "Verification code has expired. Please request a new one." });
|
||||
break;
|
||||
case "max_attempts":
|
||||
res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
|
||||
break;
|
||||
case "invalid":
|
||||
res.status(400).json({ error: "Invalid verification code." });
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change/verify");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
export { router as emailChangeRouter };
|
||||
|
|
|
|||
2
dist/routes/health.js
vendored
2
dist/routes/health.js
vendored
|
|
@ -90,7 +90,7 @@ healthRouter.get("/", async (_req, res) => {
|
|||
catch (error) {
|
||||
databaseStatus = {
|
||||
status: "error",
|
||||
message: error.message || "Database connection failed"
|
||||
message: error instanceof Error ? error.message : "Database connection failed"
|
||||
};
|
||||
overallStatus = "degraded";
|
||||
httpStatus = 503;
|
||||
|
|
|
|||
155
dist/routes/recover.js
vendored
155
dist/routes/recover.js
vendored
|
|
@ -54,23 +54,39 @@ const recoverLimiter = rateLimit({
|
|||
* description: Too many recovery attempts
|
||||
*/
|
||||
router.post("/", recoverLimiter, async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
res.status(400).json({ error: "A valid email address is required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find(k => k.email === cleanEmail);
|
||||
if (!userKey) {
|
||||
try {
|
||||
const { email } = req.body || {};
|
||||
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
res.status(400).json({ error: "A valid email address is required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find(k => k.email === cleanEmail);
|
||||
if (!userKey) {
|
||||
// DB fallback: cache may be stale in multi-replica setups
|
||||
const dbResult = await queryWithRetry("SELECT key FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]);
|
||||
if (dbResult.rows.length > 0) {
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send recovery email");
|
||||
});
|
||||
logger.info({ email: cleanEmail }, "recover: cache miss, sent recovery via DB fallback");
|
||||
}
|
||||
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
|
||||
return;
|
||||
}
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send recovery email");
|
||||
});
|
||||
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
|
||||
return;
|
||||
}
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send recovery email");
|
||||
});
|
||||
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
|
||||
catch (err) {
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* @openapi
|
||||
|
|
@ -119,58 +135,65 @@ router.post("/", recoverLimiter, async (req, res) => {
|
|||
* description: Too many failed attempts
|
||||
*/
|
||||
router.post("/verify", recoverLimiter, async (req, res) => {
|
||||
const { email, code } = req.body || {};
|
||||
if (!email || !code) {
|
||||
res.status(400).json({ error: "Email and code are required." });
|
||||
return;
|
||||
}
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
const result = await verifyCode(cleanEmail, cleanCode);
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
const keys = getAllKeys();
|
||||
let userKey = keys.find(k => k.email === cleanEmail);
|
||||
// DB fallback: cache may be stale in multi-replica setups
|
||||
if (!userKey) {
|
||||
logger.info({ email: cleanEmail }, "recover verify: cache miss, falling back to DB");
|
||||
const dbResult = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]);
|
||||
if (dbResult.rows.length > 0) {
|
||||
const row = dbResult.rows[0];
|
||||
userKey = {
|
||||
key: row.key,
|
||||
tier: row.tier,
|
||||
email: row.email,
|
||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
||||
stripeCustomerId: row.stripe_customer_id || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (userKey) {
|
||||
res.json({
|
||||
status: "recovered",
|
||||
apiKey: userKey.key,
|
||||
tier: userKey.tier,
|
||||
message: "Your API key has been recovered. Save it securely — it is shown only once.",
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
status: "recovered",
|
||||
message: "No API key found for this email.",
|
||||
});
|
||||
}
|
||||
break;
|
||||
try {
|
||||
const { email, code } = req.body || {};
|
||||
if (!email || !code) {
|
||||
res.status(400).json({ error: "Email and code are required." });
|
||||
return;
|
||||
}
|
||||
case "expired":
|
||||
res.status(410).json({ error: "Verification code has expired. Please request a new one." });
|
||||
break;
|
||||
case "max_attempts":
|
||||
res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
|
||||
break;
|
||||
case "invalid":
|
||||
res.status(400).json({ error: "Invalid verification code." });
|
||||
break;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
const result = await verifyCode(cleanEmail, cleanCode);
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
const keys = getAllKeys();
|
||||
let userKey = keys.find(k => k.email === cleanEmail);
|
||||
// DB fallback: cache may be stale in multi-replica setups
|
||||
if (!userKey) {
|
||||
logger.info({ email: cleanEmail }, "recover verify: cache miss, falling back to DB");
|
||||
const dbResult = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1 LIMIT 1", [cleanEmail]);
|
||||
if (dbResult.rows.length > 0) {
|
||||
const row = dbResult.rows[0];
|
||||
userKey = {
|
||||
key: row.key,
|
||||
tier: row.tier,
|
||||
email: row.email,
|
||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
||||
stripeCustomerId: row.stripe_customer_id || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (userKey) {
|
||||
res.json({
|
||||
status: "recovered",
|
||||
apiKey: userKey.key,
|
||||
tier: userKey.tier,
|
||||
message: "Your API key has been recovered. Save it securely — it is shown only once.",
|
||||
});
|
||||
}
|
||||
else {
|
||||
res.json({
|
||||
status: "recovered",
|
||||
message: "No API key found for this email.",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "expired":
|
||||
res.status(410).json({ error: "Verification code has expired. Please request a new one." });
|
||||
break;
|
||||
case "max_attempts":
|
||||
res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
|
||||
break;
|
||||
case "invalid":
|
||||
res.status(400).json({ error: "Invalid verification code." });
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
const reqId = req.requestId || "unknown";
|
||||
logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover/verify");
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
export { router as recoverRouter };
|
||||
|
|
|
|||
2
dist/routes/templates.js
vendored
2
dist/routes/templates.js
vendored
|
|
@ -168,6 +168,6 @@ templatesRouter.post("/:id/render", async (req, res) => {
|
|||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Template render error");
|
||||
res.status(500).json({ error: "Template rendering failed", detail: err.message });
|
||||
res.status(500).json({ error: "Template rendering failed" });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
56
dist/services/browser.js
vendored
56
dist/services/browser.js
vendored
|
|
@ -196,6 +196,32 @@ export async function closeBrowser() {
|
|||
}
|
||||
instances.length = 0;
|
||||
}
|
||||
/** Build a Puppeteer-compatible PDFOptions object from user-supplied render options. */
|
||||
export function buildPdfOptions(options) {
|
||||
const result = {
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
};
|
||||
if (options.headerTemplate !== undefined)
|
||||
result.headerTemplate = options.headerTemplate;
|
||||
if (options.footerTemplate !== undefined)
|
||||
result.footerTemplate = options.footerTemplate;
|
||||
if (options.displayHeaderFooter !== undefined)
|
||||
result.displayHeaderFooter = options.displayHeaderFooter;
|
||||
if (options.scale !== undefined)
|
||||
result.scale = options.scale;
|
||||
if (options.pageRanges)
|
||||
result.pageRanges = options.pageRanges;
|
||||
if (options.preferCSSPageSize !== undefined)
|
||||
result.preferCSSPageSize = options.preferCSSPageSize;
|
||||
if (options.width)
|
||||
result.width = options.width;
|
||||
if (options.height)
|
||||
result.height = options.height;
|
||||
return result;
|
||||
}
|
||||
export async function renderPdf(html, options = {}) {
|
||||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
|
|
@ -206,20 +232,7 @@ export async function renderPdf(html, options = {}) {
|
|||
(async () => {
|
||||
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
|
||||
await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" });
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
headerTemplate: options.headerTemplate,
|
||||
footerTemplate: options.footerTemplate,
|
||||
displayHeaderFooter: options.displayHeaderFooter || false,
|
||||
...(options.scale !== undefined && { scale: options.scale }),
|
||||
...(options.pageRanges && { pageRanges: options.pageRanges }),
|
||||
...(options.preferCSSPageSize !== undefined && { preferCSSPageSize: options.preferCSSPageSize }),
|
||||
...(options.width && { width: options.width }),
|
||||
...(options.height && { height: options.height }),
|
||||
});
|
||||
const pdf = await page.pdf(buildPdfOptions(options));
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => {
|
||||
|
|
@ -281,20 +294,7 @@ export async function renderUrlPdf(url, options = {}) {
|
|||
waitUntil: options.waitUntil || "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
format: options.format || "A4",
|
||||
landscape: options.landscape || false,
|
||||
printBackground: options.printBackground !== false,
|
||||
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
|
||||
...(options.headerTemplate && { headerTemplate: options.headerTemplate }),
|
||||
...(options.footerTemplate && { footerTemplate: options.footerTemplate }),
|
||||
...(options.displayHeaderFooter !== undefined && { displayHeaderFooter: options.displayHeaderFooter }),
|
||||
...(options.scale !== undefined && { scale: options.scale }),
|
||||
...(options.pageRanges && { pageRanges: options.pageRanges }),
|
||||
...(options.preferCSSPageSize !== undefined && { preferCSSPageSize: options.preferCSSPageSize }),
|
||||
...(options.width && { width: options.width }),
|
||||
...(options.height && { height: options.height }),
|
||||
});
|
||||
const pdf = await page.pdf(buildPdfOptions(options));
|
||||
return Buffer.from(pdf);
|
||||
})(),
|
||||
new Promise((_, reject) => {
|
||||
|
|
|
|||
24
dist/services/db.js
vendored
24
dist/services/db.js
vendored
|
|
@ -1,6 +1,6 @@
|
|||
import pg from "pg";
|
||||
import logger from "./logger.js";
|
||||
import { isTransientError } from "../utils/errors.js";
|
||||
import { isTransientError, errorMessage, errorCode } from "../utils/errors.js";
|
||||
const { Pool } = pg;
|
||||
const pool = new Pool({
|
||||
host: process.env.DATABASE_HOST || "172.17.0.1",
|
||||
|
|
@ -51,7 +51,7 @@ export async function queryWithRetry(queryText, params, maxRetries = 3) {
|
|||
throw err;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); // 1s, 2s, 4s (capped at 5s)
|
||||
logger.warn({ err: err.message, code: err.code, attempt: attempt + 1, maxRetries, delayMs }, "Transient DB error, destroying bad connection and retrying...");
|
||||
logger.warn({ err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB error, destroying bad connection and retrying...");
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
|
@ -81,7 +81,7 @@ export async function connectWithRetry(maxRetries = 3) {
|
|||
throw validationErr;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000);
|
||||
logger.warn({ err: validationErr.message, code: validationErr.code, attempt: attempt + 1 }, "Connection validation failed, destroying and retrying...");
|
||||
logger.warn({ err: errorMessage(validationErr), code: errorCode(validationErr), attempt: attempt + 1 }, "Connection validation failed, destroying and retrying...");
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@ export async function connectWithRetry(maxRetries = 3) {
|
|||
throw err;
|
||||
}
|
||||
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000);
|
||||
logger.warn({ err: err.message, code: err.code, attempt: attempt + 1, maxRetries, delayMs }, "Transient DB connect error, retrying...");
|
||||
logger.warn({ err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB connect error, retrying...");
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
|
@ -153,28 +153,18 @@ export async function initDatabase() {
|
|||
* - Orphaned usage rows (key no longer exists)
|
||||
*/
|
||||
export async function cleanupStaleData() {
|
||||
const results = { expiredVerifications: 0, staleKeys: 0, orphanedUsage: 0 };
|
||||
const results = { expiredVerifications: 0, orphanedUsage: 0 };
|
||||
// 1. Delete expired pending verifications
|
||||
const pv = await queryWithRetry("DELETE FROM pending_verifications WHERE expires_at < NOW() RETURNING email");
|
||||
results.expiredVerifications = pv.rowCount || 0;
|
||||
// 2. Delete unverified free-tier keys (email not in verified verifications)
|
||||
const sk = await queryWithRetry(`
|
||||
DELETE FROM api_keys
|
||||
WHERE tier = 'free'
|
||||
AND email NOT IN (
|
||||
SELECT DISTINCT email FROM verifications WHERE verified_at IS NOT NULL
|
||||
)
|
||||
RETURNING key
|
||||
`);
|
||||
results.staleKeys = sk.rowCount || 0;
|
||||
// 3. Delete orphaned usage rows
|
||||
// 2. Delete orphaned usage rows (key no longer exists in api_keys)
|
||||
const ou = await queryWithRetry(`
|
||||
DELETE FROM usage
|
||||
WHERE key NOT IN (SELECT key FROM api_keys)
|
||||
RETURNING key
|
||||
`);
|
||||
results.orphanedUsage = ou.rowCount || 0;
|
||||
logger.info({ ...results }, `Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.staleKeys} stale keys, ${results.orphanedUsage} orphaned usage rows removed`);
|
||||
logger.info({ ...results }, `Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.orphanedUsage} orphaned usage rows removed`);
|
||||
return results;
|
||||
}
|
||||
export { pool };
|
||||
|
|
|
|||
4
dist/services/email.js
vendored
4
dist/services/email.js
vendored
|
|
@ -14,10 +14,8 @@ const transportConfig = {
|
|||
greetingTimeout: 5000,
|
||||
socketTimeout: 10000,
|
||||
tls: { rejectUnauthorized: false },
|
||||
...(smtpUser && smtpPass ? { auth: { user: smtpUser, pass: smtpPass } } : {}),
|
||||
};
|
||||
if (smtpUser && smtpPass) {
|
||||
transportConfig.auth = { user: smtpUser, pass: smtpPass };
|
||||
}
|
||||
const transporter = nodemailer.createTransport(transportConfig);
|
||||
export async function sendVerificationEmail(email, code) {
|
||||
try {
|
||||
|
|
|
|||
77
dist/services/keys.js
vendored
77
dist/services/keys.js
vendored
|
|
@ -3,6 +3,20 @@ import logger from "./logger.js";
|
|||
import { queryWithRetry } from "./db.js";
|
||||
// In-memory cache for fast lookups, synced with PostgreSQL
|
||||
let keysCache = [];
|
||||
/** Look up a key row in the DB by a given column. Returns null if not found. */
|
||||
export async function findKeyInCacheOrDb(column, value) {
|
||||
const result = await queryWithRetry(`SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE ${column} = $1 LIMIT 1`, [value]);
|
||||
if (result.rows.length === 0)
|
||||
return null;
|
||||
const r = result.rows[0];
|
||||
return {
|
||||
key: r.key,
|
||||
tier: r.tier,
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
}
|
||||
export async function loadKeys() {
|
||||
try {
|
||||
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys");
|
||||
|
|
@ -102,55 +116,60 @@ export async function downgradeByCustomer(stripeCustomerId) {
|
|||
}
|
||||
// DB fallback: key may exist on another pod's cache or after a restart
|
||||
logger.info({ stripeCustomerId }, "downgradeByCustomer: cache miss, falling back to DB");
|
||||
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1", [stripeCustomerId]);
|
||||
if (result.rows.length === 0) {
|
||||
const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||
if (!dbKey) {
|
||||
logger.warn({ stripeCustomerId }, "downgradeByCustomer: customer not found in cache or DB");
|
||||
return false;
|
||||
}
|
||||
const row = result.rows[0];
|
||||
await queryWithRetry("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
|
||||
// Add to local cache so subsequent lookups on this pod work
|
||||
const cached = {
|
||||
key: row.key,
|
||||
tier: "free",
|
||||
email: row.email,
|
||||
createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
|
||||
stripeCustomerId: row.stripe_customer_id || undefined,
|
||||
};
|
||||
keysCache.push(cached);
|
||||
logger.info({ stripeCustomerId, key: row.key }, "downgradeByCustomer: downgraded via DB fallback");
|
||||
dbKey.tier = "free";
|
||||
keysCache.push(dbKey);
|
||||
logger.info({ stripeCustomerId, key: dbKey.key }, "downgradeByCustomer: downgraded via DB fallback");
|
||||
return true;
|
||||
}
|
||||
export async function findKeyByCustomerId(stripeCustomerId) {
|
||||
// Check DB directly — survives pod restarts unlike in-memory cache
|
||||
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1 LIMIT 1", [stripeCustomerId]);
|
||||
if (result.rows.length === 0)
|
||||
return null;
|
||||
const r = result.rows[0];
|
||||
return {
|
||||
key: r.key,
|
||||
tier: r.tier,
|
||||
email: r.email,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
stripeCustomerId: r.stripe_customer_id || undefined,
|
||||
};
|
||||
return findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||
}
|
||||
export function getAllKeys() {
|
||||
return [...keysCache];
|
||||
}
|
||||
export async function updateKeyEmail(apiKey, newEmail) {
|
||||
const entry = keysCache.find((k) => k.key === apiKey);
|
||||
if (!entry)
|
||||
if (entry) {
|
||||
entry.email = newEmail;
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
||||
return true;
|
||||
}
|
||||
// DB fallback: key may exist on another pod's cache or after a restart
|
||||
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: cache miss, falling back to DB");
|
||||
const dbKey = await findKeyInCacheOrDb("key", apiKey);
|
||||
if (!dbKey) {
|
||||
logger.warn({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: key not found in cache or DB");
|
||||
return false;
|
||||
entry.email = newEmail;
|
||||
}
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
||||
dbKey.email = newEmail;
|
||||
keysCache.push(dbKey);
|
||||
logger.info({ apiKey: apiKey.slice(0, 10) + "..." }, "updateKeyEmail: updated via DB fallback");
|
||||
return true;
|
||||
}
|
||||
export async function updateEmailByCustomer(stripeCustomerId, newEmail) {
|
||||
const entry = keysCache.find(k => k.stripeCustomerId === stripeCustomerId);
|
||||
if (!entry)
|
||||
if (entry) {
|
||||
entry.email = newEmail;
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
|
||||
return true;
|
||||
}
|
||||
// DB fallback: key may exist on another pod's cache or after a restart
|
||||
logger.info({ stripeCustomerId }, "updateEmailByCustomer: cache miss, falling back to DB");
|
||||
const dbKey = await findKeyInCacheOrDb("stripe_customer_id", stripeCustomerId);
|
||||
if (!dbKey) {
|
||||
logger.warn({ stripeCustomerId }, "updateEmailByCustomer: customer not found in cache or DB");
|
||||
return false;
|
||||
entry.email = newEmail;
|
||||
}
|
||||
await queryWithRetry("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
|
||||
dbKey.email = newEmail;
|
||||
keysCache.push(dbKey);
|
||||
logger.info({ stripeCustomerId, key: dbKey.key }, "updateEmailByCustomer: updated via DB fallback");
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
67
dist/services/verification.js
vendored
67
dist/services/verification.js
vendored
|
|
@ -1,64 +1,7 @@
|
|||
import { randomBytes, randomInt, timingSafeEqual } from "crypto";
|
||||
import logger from "./logger.js";
|
||||
import { randomInt, timingSafeEqual } from "crypto";
|
||||
import { queryWithRetry } from "./db.js";
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
const CODE_EXPIRY_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
export async function createVerification(email, apiKey) {
|
||||
// Check for existing unexpired, unverified
|
||||
const existing = await queryWithRetry("SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1", [email]);
|
||||
if (existing.rows.length > 0) {
|
||||
const r = existing.rows[0];
|
||||
return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null };
|
||||
}
|
||||
// Remove old unverified
|
||||
await queryWithRetry("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]);
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const now = new Date().toISOString();
|
||||
await queryWithRetry("INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)", [email, token, apiKey, now]);
|
||||
return { email, token, apiKey, createdAt: now, verifiedAt: null };
|
||||
}
|
||||
export function verifyToken(token) {
|
||||
// Synchronous wrapper — we'll make it async-compatible
|
||||
// Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor.
|
||||
// For simplicity, we'll cache verifications in memory too.
|
||||
return verifyTokenSync(token);
|
||||
}
|
||||
// In-memory cache for verifications (loaded on startup, updated on changes)
|
||||
let verificationsCache = [];
|
||||
export async function loadVerifications() {
|
||||
const result = await queryWithRetry("SELECT * FROM verifications");
|
||||
verificationsCache = result.rows.map((r) => ({
|
||||
email: r.email,
|
||||
token: r.token,
|
||||
apiKey: r.api_key,
|
||||
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
||||
verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null,
|
||||
}));
|
||||
// Cleanup expired entries every 15 minutes
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const before = verificationsCache.length;
|
||||
verificationsCache = verificationsCache.filter((v) => v.verifiedAt || new Date(v.createdAt).getTime() > cutoff);
|
||||
const removed = before - verificationsCache.length;
|
||||
if (removed > 0)
|
||||
logger.info({ removed }, "Cleaned expired verification cache entries");
|
||||
}, 15 * 60 * 1000);
|
||||
}
|
||||
function verifyTokenSync(token) {
|
||||
const v = verificationsCache.find((v) => v.token === token);
|
||||
if (!v)
|
||||
return { status: "invalid" };
|
||||
if (v.verifiedAt)
|
||||
return { status: "already_verified", verification: v };
|
||||
const age = Date.now() - new Date(v.createdAt).getTime();
|
||||
if (age > TOKEN_EXPIRY_MS)
|
||||
return { status: "expired" };
|
||||
v.verifiedAt = new Date().toISOString();
|
||||
// Update DB async
|
||||
queryWithRetry("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch((err) => logger.error({ err }, "Failed to update verification"));
|
||||
return { status: "ok", verification: v };
|
||||
}
|
||||
export async function createPendingVerification(email) {
|
||||
await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [email]);
|
||||
const now = new Date();
|
||||
|
|
@ -96,11 +39,3 @@ export async function verifyCode(email, code) {
|
|||
await queryWithRetry("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
|
||||
return { status: "ok" };
|
||||
}
|
||||
export async function isEmailVerified(email) {
|
||||
const result = await queryWithRetry("SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
export async function getVerifiedApiKey(email) {
|
||||
const result = await queryWithRetry("SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1", [email]);
|
||||
return result.rows[0]?.api_key ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,59 @@
|
|||
}
|
||||
},
|
||||
"paths": {
|
||||
"/v1/usage/me": {
|
||||
"get": {
|
||||
"summary": "Get your usage stats",
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
},
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Usage statistics for the authenticated user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"used": {
|
||||
"type": "integer",
|
||||
"description": "PDFs generated this month"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Monthly PDF limit for your plan"
|
||||
},
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"demo",
|
||||
"pro"
|
||||
],
|
||||
"description": "Current plan"
|
||||
},
|
||||
"month": {
|
||||
"type": "string",
|
||||
"description": "Current billing month (YYYY-MM)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid API key"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/billing/checkout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -168,102 +221,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/billing/success": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Billing"
|
||||
],
|
||||
"summary": "Checkout success page",
|
||||
"description": "Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page.\nCalled by Stripe redirect after payment completion.\n",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "session_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Stripe Checkout session ID"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "HTML page displaying the new API key",
|
||||
"content": {
|
||||
"text/html": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing session_id or no customer found"
|
||||
},
|
||||
"409": {
|
||||
"description": "Checkout session already used"
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to retrieve session"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/billing/webhook": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Billing"
|
||||
],
|
||||
"summary": "Stripe webhook endpoint",
|
||||
"description": "Receives Stripe webhook events for subscription lifecycle management.\nRequires the raw request body and a valid Stripe-Signature header for verification.\nHandles checkout.session.completed, customer.subscription.updated,\ncustomer.subscription.deleted, and customer.updated events.\n",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "Stripe-Signature",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Stripe webhook signature for payload verification"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "Raw Stripe event payload"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Webhook received",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"received": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing Stripe-Signature header or invalid signature"
|
||||
},
|
||||
"500": {
|
||||
"description": "Webhook secret not configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/convert/html": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -314,6 +271,17 @@
|
|||
"responses": {
|
||||
"200": {
|
||||
"description": "PDF document",
|
||||
"headers": {
|
||||
"X-RateLimit-Limit": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Limit"
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Remaining"
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Reset"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/pdf": {
|
||||
"schema": {
|
||||
|
|
@ -336,7 +304,12 @@
|
|||
"description": "Unsupported Content-Type (must be application/json)"
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit or usage limit exceeded"
|
||||
"description": "Rate limit or usage limit exceeded",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "PDF generation failed"
|
||||
|
|
@ -393,6 +366,17 @@
|
|||
"responses": {
|
||||
"200": {
|
||||
"description": "PDF document",
|
||||
"headers": {
|
||||
"X-RateLimit-Limit": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Limit"
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Remaining"
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Reset"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/pdf": {
|
||||
"schema": {
|
||||
|
|
@ -415,7 +399,12 @@
|
|||
"description": "Unsupported Content-Type"
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit or usage limit exceeded"
|
||||
"description": "Rate limit or usage limit exceeded",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "PDF generation failed"
|
||||
|
|
@ -480,6 +469,17 @@
|
|||
"responses": {
|
||||
"200": {
|
||||
"description": "PDF document",
|
||||
"headers": {
|
||||
"X-RateLimit-Limit": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Limit"
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Remaining"
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Reset"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/pdf": {
|
||||
"schema": {
|
||||
|
|
@ -502,7 +502,12 @@
|
|||
"description": "Unsupported Content-Type"
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit or usage limit exceeded"
|
||||
"description": "Rate limit or usage limit exceeded",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "PDF generation failed"
|
||||
|
|
@ -551,6 +556,17 @@
|
|||
"responses": {
|
||||
"200": {
|
||||
"description": "Watermarked PDF document",
|
||||
"headers": {
|
||||
"X-RateLimit-Limit": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Limit"
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Remaining"
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Reset"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/pdf": {
|
||||
"schema": {
|
||||
|
|
@ -575,6 +591,11 @@
|
|||
},
|
||||
"429": {
|
||||
"description": "Demo rate limit exceeded (5/hour)",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
|
|
@ -633,6 +654,17 @@
|
|||
"responses": {
|
||||
"200": {
|
||||
"description": "Watermarked PDF document",
|
||||
"headers": {
|
||||
"X-RateLimit-Limit": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Limit"
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Remaining"
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"$ref": "#/components/headers/X-RateLimit-Reset"
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/pdf": {
|
||||
"schema": {
|
||||
|
|
@ -649,7 +681,12 @@
|
|||
"description": "Unsupported Content-Type"
|
||||
},
|
||||
"429": {
|
||||
"description": "Demo rate limit exceeded (5/hour)"
|
||||
"description": "Demo rate limit exceeded (5/hour)",
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"$ref": "#/components/headers/Retry-After"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Server busy"
|
||||
|
|
@ -660,6 +697,141 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/email-change": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"summary": "Request email change",
|
||||
"description": "Sends a 6-digit verification code to the new email address.\nRate limited to 3 requests per hour per API key.\n",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"apiKey",
|
||||
"newEmail"
|
||||
],
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"newEmail": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification code sent",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "verification_sent"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing or invalid fields"
|
||||
},
|
||||
"403": {
|
||||
"description": "Invalid API key"
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already taken"
|
||||
},
|
||||
"429": {
|
||||
"description": "Too many attempts"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/email-change/verify": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"summary": "Verify email change code",
|
||||
"description": "Verifies the 6-digit code and updates the account email.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"apiKey",
|
||||
"newEmail",
|
||||
"code"
|
||||
],
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"newEmail": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{6}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "ok"
|
||||
},
|
||||
"newEmail": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing fields or invalid code"
|
||||
},
|
||||
"403": {
|
||||
"description": "Invalid API key"
|
||||
},
|
||||
"410": {
|
||||
"description": "Code expired"
|
||||
},
|
||||
"429": {
|
||||
"description": "Too many failed attempts"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
@ -867,83 +1039,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/signup/verify": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Account"
|
||||
],
|
||||
"summary": "Verify email and get API key",
|
||||
"description": "Verifies the 6-digit code sent to the user's email and provisions a free API key.\nRate limited to 15 attempts per 15 minutes.\n",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"code"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "Email address used during signup",
|
||||
"example": "user@example.com"
|
||||
},
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "6-digit verification code from email",
|
||||
"example": "123456"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email verified, API key issued",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "verified"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "The provisioned API key"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"example": "free"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Missing fields or invalid verification code"
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified"
|
||||
},
|
||||
"410": {
|
||||
"description": "Verification code expired"
|
||||
},
|
||||
"429": {
|
||||
"description": "Too many failed attempts"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/templates": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
|
|
|||
|
|
@ -15,4 +15,94 @@ describe("OpenAPI spec accuracy", () => {
|
|||
it("should mark /v1/signup/verify as deprecated", () => {
|
||||
expect(spec.paths["/v1/signup/verify"]?.post?.deprecated).toBe(true);
|
||||
});
|
||||
|
||||
describe("Rate limit headers", () => {
|
||||
it("should define rate limit header components", () => {
|
||||
expect(spec.components.headers).toBeDefined();
|
||||
expect(spec.components.headers["X-RateLimit-Limit"]).toBeDefined();
|
||||
expect(spec.components.headers["X-RateLimit-Remaining"]).toBeDefined();
|
||||
expect(spec.components.headers["X-RateLimit-Reset"]).toBeDefined();
|
||||
expect(spec.components.headers["Retry-After"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("X-RateLimit-Limit should be integer type with description", () => {
|
||||
const header = spec.components.headers["X-RateLimit-Limit"];
|
||||
expect(header.schema.type).toBe("integer");
|
||||
expect(header.description).toContain("maximum");
|
||||
});
|
||||
|
||||
it("X-RateLimit-Remaining should be integer type with description", () => {
|
||||
const header = spec.components.headers["X-RateLimit-Remaining"];
|
||||
expect(header.schema.type).toBe("integer");
|
||||
expect(header.description).toContain("remaining");
|
||||
});
|
||||
|
||||
it("X-RateLimit-Reset should be integer type with Unix timestamp description", () => {
|
||||
const header = spec.components.headers["X-RateLimit-Reset"];
|
||||
expect(header.schema.type).toBe("integer");
|
||||
expect(header.description.toLowerCase()).toContain("unix");
|
||||
expect(header.description.toLowerCase()).toContain("timestamp");
|
||||
});
|
||||
|
||||
it("Retry-After should be integer type with description about seconds", () => {
|
||||
const header = spec.components.headers["Retry-After"];
|
||||
expect(header.schema.type).toBe("integer");
|
||||
expect(header.description.toLowerCase()).toContain("second");
|
||||
});
|
||||
|
||||
const conversionEndpoints = [
|
||||
"/v1/convert/html",
|
||||
"/v1/convert/markdown",
|
||||
"/v1/convert/url",
|
||||
];
|
||||
|
||||
const demoEndpoints = ["/v1/demo/html", "/v1/demo/markdown"];
|
||||
|
||||
const allRateLimitedEndpoints = [...conversionEndpoints, ...demoEndpoints];
|
||||
|
||||
allRateLimitedEndpoints.forEach((endpoint) => {
|
||||
describe(`${endpoint}`, () => {
|
||||
it("should include rate limit headers in 200 response", () => {
|
||||
const response200 = spec.paths[endpoint]?.post?.responses["200"];
|
||||
expect(response200).toBeDefined();
|
||||
expect(response200.headers).toBeDefined();
|
||||
expect(response200.headers["X-RateLimit-Limit"]).toBeDefined();
|
||||
expect(response200.headers["X-RateLimit-Remaining"]).toBeDefined();
|
||||
expect(response200.headers["X-RateLimit-Reset"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should reference header components in 200 response", () => {
|
||||
const headers = spec.paths[endpoint]?.post?.responses["200"]?.headers;
|
||||
expect(headers["X-RateLimit-Limit"].$ref).toBe(
|
||||
"#/components/headers/X-RateLimit-Limit"
|
||||
);
|
||||
expect(headers["X-RateLimit-Remaining"].$ref).toBe(
|
||||
"#/components/headers/X-RateLimit-Remaining"
|
||||
);
|
||||
expect(headers["X-RateLimit-Reset"].$ref).toBe(
|
||||
"#/components/headers/X-RateLimit-Reset"
|
||||
);
|
||||
});
|
||||
|
||||
it("should include Retry-After header in 429 response", () => {
|
||||
const response429 = spec.paths[endpoint]?.post?.responses["429"];
|
||||
expect(response429).toBeDefined();
|
||||
expect(response429.headers).toBeDefined();
|
||||
expect(response429.headers["Retry-After"]).toBeDefined();
|
||||
expect(response429.headers["Retry-After"].$ref).toBe(
|
||||
"#/components/headers/Retry-After"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should mention rate limit headers in API description", () => {
|
||||
const description = spec.info.description;
|
||||
expect(description).toContain("X-RateLimit-Limit");
|
||||
expect(description).toContain("X-RateLimit-Remaining");
|
||||
expect(description).toContain("X-RateLimit-Reset");
|
||||
expect(description).toContain("Retry-After");
|
||||
expect(description).toContain("429");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ export const convertRouter = Router();
|
|||
* responses:
|
||||
* 200:
|
||||
* description: PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -55,6 +62,9 @@ export const convertRouter = Router();
|
|||
* description: Unsupported Content-Type (must be application/json)
|
||||
* 429:
|
||||
* description: Rate limit or usage limit exceeded
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* 500:
|
||||
* description: PDF generation failed
|
||||
*/
|
||||
|
|
@ -103,6 +113,13 @@ convertRouter.post("/html", async (req: Request, res: Response) => {
|
|||
* responses:
|
||||
* 200:
|
||||
* description: PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -118,6 +135,9 @@ convertRouter.post("/html", async (req: Request, res: Response) => {
|
|||
* description: Unsupported Content-Type
|
||||
* 429:
|
||||
* description: Rate limit or usage limit exceeded
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* 500:
|
||||
* description: PDF generation failed
|
||||
*/
|
||||
|
|
@ -169,6 +189,13 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => {
|
|||
* responses:
|
||||
* 200:
|
||||
* description: PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -184,6 +211,9 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => {
|
|||
* description: Unsupported Content-Type
|
||||
* 429:
|
||||
* description: Rate limit or usage limit exceeded
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* 500:
|
||||
* description: PDF generation failed
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -101,6 +101,13 @@ interface DemoBody {
|
|||
* responses:
|
||||
* 200:
|
||||
* description: Watermarked PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -116,6 +123,9 @@ interface DemoBody {
|
|||
* description: Unsupported Content-Type
|
||||
* 429:
|
||||
* description: Demo rate limit exceeded (5/hour)
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
|
|
@ -187,6 +197,13 @@ router.post("/html", async (req: Request, res: Response) => {
|
|||
* responses:
|
||||
* 200:
|
||||
* description: Watermarked PDF document
|
||||
* headers:
|
||||
* X-RateLimit-Limit:
|
||||
* $ref: '#/components/headers/X-RateLimit-Limit'
|
||||
* X-RateLimit-Remaining:
|
||||
* $ref: '#/components/headers/X-RateLimit-Remaining'
|
||||
* X-RateLimit-Reset:
|
||||
* $ref: '#/components/headers/X-RateLimit-Reset'
|
||||
* content:
|
||||
* application/pdf:
|
||||
* schema:
|
||||
|
|
@ -198,6 +215,9 @@ router.post("/html", async (req: Request, res: Response) => {
|
|||
* description: Unsupported Content-Type
|
||||
* 429:
|
||||
* description: Demo rate limit exceeded (5/hour)
|
||||
* headers:
|
||||
* Retry-After:
|
||||
* $ref: '#/components/headers/Retry-After'
|
||||
* 503:
|
||||
* description: Server busy
|
||||
* 504:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const options: swaggerJsdoc.Options = {
|
|||
title: "DocFast API",
|
||||
version,
|
||||
description:
|
||||
"Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion endpoints require an API key via `Authorization: Bearer <key>` or `X-API-Key: <key>` header. Get your key at [docfast.dev](https://docfast.dev).\n\n## Rate Limits\n- Demo: 5 conversions/hour, watermarked output\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev) for clean output and higher limits\n3. Use your API key to convert documents",
|
||||
"Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion endpoints require an API key via `Authorization: Bearer <key>` or `X-API-Key: <key>` header. Get your key at [docfast.dev](https://docfast.dev).\n\n## Rate Limits\n- Demo: 5 conversions/hour, watermarked output\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\nAll rate-limited endpoints return `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. On `429`, a `Retry-After` header indicates seconds until the next allowed request.\n\n## Getting Started\n1. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev) for clean output and higher limits\n3. Use your API key to convert documents",
|
||||
contact: {
|
||||
name: "DocFast",
|
||||
url: "https://docfast.dev",
|
||||
|
|
@ -41,6 +41,36 @@ const options: swaggerJsdoc.Options = {
|
|||
description: "API key via X-API-Key header",
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
"X-RateLimit-Limit": {
|
||||
description: "The maximum number of requests allowed in the current time window",
|
||||
schema: {
|
||||
type: "integer",
|
||||
example: 30,
|
||||
},
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
description: "The number of requests remaining in the current time window",
|
||||
schema: {
|
||||
type: "integer",
|
||||
example: 29,
|
||||
},
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
description: "Unix timestamp (seconds since epoch) when the rate limit window resets",
|
||||
schema: {
|
||||
type: "integer",
|
||||
example: 1679875200,
|
||||
},
|
||||
},
|
||||
"Retry-After": {
|
||||
description: "Number of seconds to wait before retrying the request (returned on 429 responses)",
|
||||
schema: {
|
||||
type: "integer",
|
||||
example: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
PdfOptions: {
|
||||
type: "object",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue