fix: version bump 0.3.2, remove debug log, dynamic /api version, Pro plan 5000 PDFs
1. Version bump to 0.3.2
2. Remove debug console.log('CACHE HIT:') from static asset middleware
3. /api endpoint: hardcoded version → dynamic from package.json
4. OpenAPI docs + terms: Pro plan 10,000 → 5,000 PDFs/month
5. Remove .backup files
This commit is contained in:
parent
fb05989b3b
commit
37386bfb5c
8 changed files with 11 additions and 384 deletions
|
|
@ -1,8 +1,12 @@
|
|||
import express from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createRequire } from "module";
|
||||
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: string = _require("../package.json").version;
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import rateLimit from "express-rate-limit";
|
||||
|
|
@ -197,7 +201,7 @@ app.get("/docs", (_req, res) => {
|
|||
|
||||
// Static asset cache headers middleware
|
||||
app.use((req, res, next) => {
|
||||
if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { console.log("CACHE HIT:", req.path);
|
||||
if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
|
||||
}
|
||||
next();
|
||||
|
|
@ -235,7 +239,7 @@ app.get("/status", (_req, res) => {
|
|||
app.get("/api", (_req, res) => {
|
||||
res.json({
|
||||
name: "DocFast API",
|
||||
version: "0.2.9",
|
||||
version: APP_VERSION,
|
||||
endpoints: [
|
||||
"POST /v1/signup/free — Get a free API key",
|
||||
"POST /v1/convert/html",
|
||||
|
|
|
|||
|
|
@ -1,356 +0,0 @@
|
|||
import express from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import compression from "compression";
|
||||
import logger from "./services/logger.js";
|
||||
import helmet from "helmet";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { convertRouter } from "./routes/convert.js";
|
||||
import { templatesRouter } from "./routes/templates.js";
|
||||
import { healthRouter } from "./routes/health.js";
|
||||
import { signupRouter } from "./routes/signup.js";
|
||||
import { recoverRouter } from "./routes/recover.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { usageMiddleware, loadUsageData } from "./middleware/usage.js";
|
||||
import { getUsageStats } from "./middleware/usage.js";
|
||||
import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js";
|
||||
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||
import { loadKeys, getAllKeys } from "./services/keys.js";
|
||||
import { verifyToken, loadVerifications } from "./services/verification.js";
|
||||
import { initDatabase, pool } from "./services/db.js";
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||
|
||||
// Request ID + request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
|
||||
(req as any).requestId = requestId;
|
||||
res.setHeader("X-Request-Id", requestId);
|
||||
const start = Date.now();
|
||||
res.on("finish", () => {
|
||||
const ms = Date.now() - start;
|
||||
if (req.path !== "/health") {
|
||||
logger.info({ method: req.method, path: req.path, status: res.statusCode, ms, requestId }, "request");
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Permissions-Policy header
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(self)");
|
||||
next();
|
||||
});
|
||||
|
||||
// Compression
|
||||
app.use(compression());
|
||||
|
||||
// Differentiated CORS middleware
|
||||
app.use((req, res, next) => {
|
||||
const isAuthBillingRoute = req.path.startsWith('/v1/signup') ||
|
||||
req.path.startsWith('/v1/recover') ||
|
||||
req.path.startsWith('/v1/billing');
|
||||
|
||||
if (isAuthBillingRoute) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
|
||||
} else {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
}
|
||||
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
|
||||
res.setHeader("Access-Control-Max-Age", "86400");
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Raw body for Stripe webhook signature verification
|
||||
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
app.use(express.text({ limit: "2mb", type: "text/*" }));
|
||||
|
||||
// Trust nginx proxy
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
// Global rate limiting - reduced from 10,000 to reasonable limit
|
||||
const limiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Public routes
|
||||
app.use("/health", healthRouter);
|
||||
app.use("/v1/signup", signupRouter);
|
||||
app.use("/v1/recover", recoverRouter);
|
||||
app.use("/v1/billing", billingRouter);
|
||||
|
||||
// Authenticated routes — conversion routes get tighter body limits (500KB)
|
||||
const convertBodyLimit = express.json({ limit: "500kb" });
|
||||
app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||
|
||||
// Admin: usage stats (admin key required)
|
||||
const adminAuth = (req: any, res: any, next: any) => {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; }
|
||||
if (req.apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; }
|
||||
next();
|
||||
};
|
||||
app.get("/v1/usage", authMiddleware, adminAuth, (req: any, res: any) => {
|
||||
res.json(getUsageStats(req.apiKeyInfo?.key));
|
||||
});
|
||||
|
||||
// Admin: concurrency stats (admin key required)
|
||||
app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: any, res: any) => {
|
||||
res.json(getConcurrencyStats());
|
||||
});
|
||||
|
||||
// Email verification endpoint
|
||||
app.get("/verify", (req, res) => {
|
||||
const token = req.query.token as string;
|
||||
if (!token) {
|
||||
res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = verifyToken(token);
|
||||
|
||||
switch (result.status) {
|
||||
case "ok":
|
||||
res.send(verifyPage("Email Verified! 🚀", "Your DocFast API key is ready:", result.verification!.apiKey));
|
||||
break;
|
||||
case "already_verified":
|
||||
res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification!.apiKey));
|
||||
break;
|
||||
case "expired":
|
||||
res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null));
|
||||
break;
|
||||
case "invalid":
|
||||
res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
function verifyPage(title: string, message: string, apiKey: string | null): string {
|
||||
return `<!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" onclick="navigator.clipboard.writeText('${apiKey}');this.style.borderColor='#5eead4';setTimeout(()=>this.style.borderColor='#34d399',1500)">${apiKey}</div>
|
||||
<div class="links">100 free PDFs/month · <a href="/docs">Read the docs →</a></div>
|
||||
` : `<div class="links"><a href="/">← Back to DocFast</a></div>`}
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
// Landing page
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Favicon route
|
||||
app.get("/favicon.ico", (_req, res) => {
|
||||
res.setHeader('Content-Type', 'image/svg+xml');
|
||||
res.setHeader('Cache-Control', 'public, max-age=604800');
|
||||
res.sendFile(path.join(__dirname, "../public/favicon.svg"));
|
||||
});
|
||||
|
||||
// Docs page (clean URL)
|
||||
app.get("/docs", (_req, res) => {
|
||||
// Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation.
|
||||
// Override helmet's default CSP to allow 'unsafe-eval' + blob: for Swagger UI.
|
||||
res.setHeader("Content-Security-Policy",
|
||||
"default-src 'self';script-src 'self' 'unsafe-eval';style-src 'self' https: 'unsafe-inline';img-src 'self' data: blob:;font-src 'self' https: data:;connect-src 'self';worker-src 'self' blob:;base-uri 'self';form-action 'self';frame-ancestors 'self';object-src 'none'"
|
||||
);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/docs.html"));
|
||||
});
|
||||
|
||||
// Static asset cache headers middleware
|
||||
app.use((req, res, next) => {
|
||||
if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { console.log("CACHE HIT:", req.path);
|
||||
res.setHeader('Cache-Control', 'public, max-age=604800, immutable');
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, "../public"), {
|
||||
etag: true,
|
||||
cacheControl: false,
|
||||
}));
|
||||
|
||||
|
||||
// Legal pages (clean URLs)
|
||||
app.get("/impressum", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/impressum.html"));
|
||||
});
|
||||
|
||||
app.get("/privacy", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/privacy.html"));
|
||||
});
|
||||
|
||||
app.get("/terms", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/terms.html"));
|
||||
});
|
||||
|
||||
app.get("/status", (_req, res) => {
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
res.sendFile(path.join(__dirname, "../public/status.html"));
|
||||
});
|
||||
|
||||
|
||||
// API root
|
||||
app.get("/api", (_req, res) => {
|
||||
res.json({
|
||||
name: "DocFast API",
|
||||
version: "0.2.9",
|
||||
endpoints: [
|
||||
"POST /v1/signup/free — Get a free API key",
|
||||
"POST /v1/convert/html",
|
||||
"POST /v1/convert/markdown",
|
||||
"POST /v1/convert/url",
|
||||
"POST /v1/templates/:id/render",
|
||||
"GET /v1/templates",
|
||||
"POST /v1/billing/checkout — Start Pro subscription",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler - must be after all routes
|
||||
app.use((req, res) => {
|
||||
// Check if it's an API request
|
||||
const isApiRequest = req.path.startsWith('/v1/') || req.path.startsWith('/api') || req.path.startsWith('/health');
|
||||
|
||||
if (isApiRequest) {
|
||||
// JSON 404 for API paths
|
||||
res.status(404).json({ error: `Not Found: ${req.method} ${req.path}` });
|
||||
} else {
|
||||
// HTML 404 for browser paths
|
||||
res.status(404).send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>404 - Page Not Found | DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Inter', sans-serif; background: #0b0d11; color: #e4e7ed; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
||||
.container { background: #151922; border: 1px solid #1e2433; border-radius: 16px; padding: 48px; max-width: 520px; width: 100%; text-align: center; }
|
||||
h1 { font-size: 3rem; margin-bottom: 12px; font-weight: 700; color: #34d399; }
|
||||
h2 { font-size: 1.5rem; margin-bottom: 16px; font-weight: 600; }
|
||||
p { color: #7a8194; margin-bottom: 24px; line-height: 1.6; }
|
||||
a { color: #34d399; text-decoration: none; font-weight: 600; }
|
||||
a:hover { color: #5eead4; }
|
||||
.emoji { font-size: 4rem; margin-bottom: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="emoji">⚡</div>
|
||||
<h1>404</h1>
|
||||
<h2>Page Not Found</h2>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<p><a href="/">← Back to DocFast</a> | <a href="/docs">Read the docs</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
async function start() {
|
||||
// Initialize PostgreSQL
|
||||
await initDatabase();
|
||||
|
||||
// Load data from PostgreSQL
|
||||
await loadKeys();
|
||||
await loadVerifications();
|
||||
await loadUsageData();
|
||||
|
||||
await initBrowser();
|
||||
logger.info(`Loaded ${getAllKeys().length} API keys`);
|
||||
const server = app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`));
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info(`Received ${signal}, starting graceful shutdown...`);
|
||||
|
||||
// 1. Stop accepting new connections, wait for in-flight requests (max 10s)
|
||||
await new Promise<void>((resolve) => {
|
||||
const forceTimeout = setTimeout(() => {
|
||||
logger.warn("Forcing server close after 10s timeout");
|
||||
resolve();
|
||||
}, 10_000);
|
||||
server.close(() => {
|
||||
clearTimeout(forceTimeout);
|
||||
logger.info("HTTP server closed (all in-flight requests completed)");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Close Puppeteer browser pool
|
||||
try {
|
||||
await closeBrowser();
|
||||
logger.info("Browser pool closed");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error closing browser pool");
|
||||
}
|
||||
|
||||
// 3. Close PostgreSQL connection pool
|
||||
try {
|
||||
await pool.end();
|
||||
logger.info("PostgreSQL pool closed");
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Error closing PostgreSQL pool");
|
||||
}
|
||||
|
||||
logger.info("Graceful shutdown complete");
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
logger.error({ err }, "Failed to start");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export { app };
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { getPoolStats } from "../services/browser.js";
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
healthRouter.get("/", (_req, res) => {
|
||||
const pool = getPoolStats();
|
||||
res.json({
|
||||
status: "ok",
|
||||
version: "0.2.1",
|
||||
pool: {
|
||||
size: pool.poolSize,
|
||||
active: pool.totalPages - pool.availablePages,
|
||||
available: pool.availablePages,
|
||||
queueDepth: pool.queueDepth,
|
||||
pdfCount: pool.pdfCount,
|
||||
restarting: pool.restarting,
|
||||
uptimeSeconds: Math.round(pool.uptimeMs / 1000),
|
||||
},
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue