diff --git a/public/app.js b/public/app.js index 9841160..ce2e351 100644 --- a/public/app.js +++ b/public/app.js @@ -239,17 +239,56 @@ function doCopy(text, btn) { btn.textContent = '\u2713 Copied!'; setTimeout(function() { btn.textContent = 'Copy'; }, 2000); } + function showFailed() { + btn.textContent = 'Failed'; + setTimeout(function() { btn.textContent = 'Copy'; }, 2000); + } try { - navigator.clipboard.writeText(text).then(showCopied).catch(function() { + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(showCopied).catch(function() { + // Fallback to execCommand + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + var success = document.execCommand('copy'); + document.body.removeChild(ta); + if (success) { + showCopied(); + } else { + showFailed(); + } + } catch (err) { + showFailed(); + } + }); + } else { + // Direct fallback for non-secure contexts var ta = document.createElement('textarea'); - ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; - document.body.appendChild(ta); ta.select(); - document.execCommand('copy'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + var success = document.execCommand('copy'); document.body.removeChild(ta); - showCopied(); - }); + if (success) { + showCopied(); + } else { + showFailed(); + } + } } catch(e) { - showCopied(); + showFailed(); } } diff --git a/src/index.ts b/src/index.ts index f1b20b1..0d2d85e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { emailChangeRouter } from "./routes/email-change.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware } 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 } from "./services/verification.js"; @@ -59,10 +60,10 @@ app.use(express.text({ limit: "2mb", type: "text/*" })); // Trust nginx proxy app.set("trust proxy", 1); -// Rate limiting +// Global rate limiting - reduced from 10,000 to reasonable limit const limiter = rateLimit({ windowMs: 60_000, - max: 10000, + max: 100, standardHeaders: true, legacyHeaders: false, }); @@ -76,7 +77,7 @@ app.use("/v1/billing", billingRouter); app.use("/v1/email-change", emailChangeRouter); // Authenticated routes -app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter); +app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); // Admin: usage stats @@ -84,6 +85,11 @@ app.get("/v1/usage", authMiddleware, (_req, res) => { res.json(getUsageStats()); }); +// Admin: concurrency stats +app.get("/v1/concurrency", authMiddleware, (_req, res) => { + res.json(getConcurrencyStats()); +}); + // Email verification endpoint app.get("/verify", (req, res) => { const token = req.query.token as string; diff --git a/src/middleware/pdfRateLimit.ts b/src/middleware/pdfRateLimit.ts new file mode 100644 index 0000000..ca49ca6 --- /dev/null +++ b/src/middleware/pdfRateLimit.ts @@ -0,0 +1,115 @@ +import { Request, Response, NextFunction } from "express"; +import { isProKey } from "../services/keys.js"; + +interface RateLimitEntry { + count: number; + resetTime: number; +} + +// Per-key rate limits (requests per minute) +const FREE_RATE_LIMIT = 10; +const PRO_RATE_LIMIT = 30; +const RATE_WINDOW_MS = 60_000; // 1 minute + +// Concurrency limits +const MAX_CONCURRENT_PDFS = 3; +const MAX_QUEUE_SIZE = 10; + +const rateLimitStore = new Map(); +let activePdfCount = 0; +const pdfQueue: Array<{ resolve: () => void; reject: (error: Error) => void }> = []; + +function cleanupExpiredEntries(): void { + const now = Date.now(); + for (const [key, entry] of rateLimitStore.entries()) { + if (now >= entry.resetTime) { + rateLimitStore.delete(key); + } + } +} + +function getRateLimit(apiKey: string): number { + return isProKey(apiKey) ? PRO_RATE_LIMIT : FREE_RATE_LIMIT; +} + +function checkRateLimit(apiKey: string): boolean { + cleanupExpiredEntries(); + + const now = Date.now(); + const limit = getRateLimit(apiKey); + const entry = rateLimitStore.get(apiKey); + + if (!entry || now >= entry.resetTime) { + // Create new window + rateLimitStore.set(apiKey, { + count: 1, + resetTime: now + RATE_WINDOW_MS + }); + return true; + } + + if (entry.count >= limit) { + return false; + } + + entry.count++; + return true; +} + +async function acquireConcurrencySlot(): Promise { + if (activePdfCount < MAX_CONCURRENT_PDFS) { + activePdfCount++; + return; + } + + if (pdfQueue.length >= MAX_QUEUE_SIZE) { + throw new Error("QUEUE_FULL"); + } + + return new Promise((resolve, reject) => { + pdfQueue.push({ resolve, reject }); + }); +} + +function releaseConcurrencySlot(): void { + activePdfCount--; + + const waiter = pdfQueue.shift(); + if (waiter) { + activePdfCount++; + waiter.resolve(); + } +} + +export function pdfRateLimitMiddleware(req: Request & { apiKeyInfo?: any }, res: Response, next: NextFunction): void { + const keyInfo = req.apiKeyInfo; + const apiKey = keyInfo?.key || "unknown"; + + // Check rate limit first + if (!checkRateLimit(apiKey)) { + const limit = getRateLimit(apiKey); + const tier = isProKey(apiKey) ? "pro" : "free"; + res.status(429).json({ + error: "Rate limit exceeded", + limit: `${limit} PDFs per minute`, + tier, + retryAfter: "60 seconds" + }); + return; + } + + // Add concurrency control to the request + (req as any).acquirePdfSlot = acquireConcurrencySlot; + (req as any).releasePdfSlot = releaseConcurrencySlot; + + next(); +} + +export function getConcurrencyStats() { + return { + activePdfCount, + queueSize: pdfQueue.length, + maxConcurrent: MAX_CONCURRENT_PDFS, + maxQueue: MAX_QUEUE_SIZE + }; +} \ No newline at end of file diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 07d657e..b36077e 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -34,7 +34,8 @@ interface ConvertBody { } // POST /v1/convert/html -convertRouter.post("/html", async (req: Request, res: Response) => { +convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { + let slotAcquired = false; try { // Reject non-JSON content types const ct = req.headers["content-type"] || ""; @@ -50,6 +51,12 @@ convertRouter.post("/html", async (req: Request, res: Response) => { return; } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + // Wrap bare HTML fragments const fullHtml = body.html.includes(" { res.send(pdf); } catch (err: any) { console.error("Convert HTML error:", err); - if (err.message === "QUEUE_FULL") { const pool = getPoolStats(); res.status(429).json({ error: "Server busy", queueDepth: pool.queueDepth }); return; } res.status(500).json({ error: "PDF generation failed", detail: err.message }); + 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", detail: err.message }); + } finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } } }); // POST /v1/convert/markdown -convertRouter.post("/markdown", async (req: Request, res: Response) => { +convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { + let slotAcquired = false; try { const body: ConvertBody = typeof req.body === "string" ? { markdown: req.body } : req.body; @@ -83,6 +99,12 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { return; } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + const html = markdownToHtml(body.markdown, body.css); const pdf = await renderPdf(html, { format: body.format, @@ -97,12 +119,21 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { res.send(pdf); } catch (err: any) { console.error("Convert MD error:", err); - if (err.message === "QUEUE_FULL") { const pool = getPoolStats(); res.status(429).json({ error: "Server busy", queueDepth: pool.queueDepth }); return; } res.status(500).json({ error: "PDF generation failed", detail: err.message }); + 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", detail: err.message }); + } finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } } }); // POST /v1/convert/url -convertRouter.post("/url", async (req: Request, res: Response) => { +convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { + let slotAcquired = false; try { const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string }; @@ -136,6 +167,12 @@ convertRouter.post("/url", async (req: Request, res: Response) => { return; } + // Acquire concurrency slot + if (req.acquirePdfSlot) { + await req.acquirePdfSlot(); + slotAcquired = true; + } + const pdf = await renderUrlPdf(body.url, { format: body.format, landscape: body.landscape, @@ -150,6 +187,14 @@ convertRouter.post("/url", async (req: Request, res: Response) => { res.send(pdf); } catch (err: any) { console.error("Convert URL error:", err); - if (err.message === "QUEUE_FULL") { const pool = getPoolStats(); res.status(429).json({ error: "Server busy", queueDepth: pool.queueDepth }); return; } res.status(500).json({ error: "PDF generation failed", detail: err.message }); + 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", detail: err.message }); + } finally { + if (slotAcquired && req.releasePdfSlot) { + req.releasePdfSlot(); + } } }); diff --git a/src/routes/recover.ts b/src/routes/recover.ts index f7a9031..8c0d934 100644 --- a/src/routes/recover.ts +++ b/src/routes/recover.ts @@ -8,7 +8,7 @@ const router = Router(); const recoverLimiter = rateLimit({ windowMs: 60 * 60 * 1000, - max: 5, + max: 3, message: { error: "Too many recovery attempts. Please try again in 1 hour." }, standardHeaders: true, legacyHeaders: false,