Add URL→PDF endpoint, usage tracking middleware, free tier limits

This commit is contained in:
DocFast Bot 2026-02-14 13:02:40 +00:00
parent 8e03b8ab3c
commit 6896b72e0c
5 changed files with 158 additions and 4 deletions

View file

@ -23,5 +23,6 @@
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
} },
"type": "module"
} }

View file

@ -7,6 +7,8 @@ import { convertRouter } from "./routes/convert.js";
import { templatesRouter } from "./routes/templates.js"; import { templatesRouter } from "./routes/templates.js";
import { healthRouter } from "./routes/health.js"; import { healthRouter } from "./routes/health.js";
import { authMiddleware } from "./middleware/auth.js"; import { authMiddleware } from "./middleware/auth.js";
import { usageMiddleware } from "./middleware/usage.js";
import { getUsageStats } from "./middleware/usage.js";
import { initBrowser, closeBrowser } from "./services/browser.js"; import { initBrowser, closeBrowser } from "./services/browser.js";
const app = express(); const app = express();
@ -29,8 +31,13 @@ app.use(limiter);
app.use("/health", healthRouter); app.use("/health", healthRouter);
// Authenticated // Authenticated
app.use("/v1/convert", authMiddleware, convertRouter); app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
app.use("/v1/templates", authMiddleware, templatesRouter); app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
// Admin: usage stats (protected by auth)
app.get("/v1/usage", authMiddleware, (_req, res) => {
res.json(getUsageStats());
});
// Landing page // Landing page
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@ -45,6 +52,7 @@ app.get("/api", (_req, res) => {
endpoints: [ endpoints: [
"POST /v1/convert/html", "POST /v1/convert/html",
"POST /v1/convert/markdown", "POST /v1/convert/markdown",
"POST /v1/convert/url",
"POST /v1/templates/:id/render", "POST /v1/templates/:id/render",
"GET /v1/templates", "GET /v1/templates",
], ],

68
src/middleware/usage.ts Normal file
View file

@ -0,0 +1,68 @@
import { Request, Response, NextFunction } from "express";
interface UsageRecord {
count: number;
monthKey: string;
}
// In-memory usage tracking (replace with Redis/DB for production)
const usage = new Map<string, UsageRecord>();
const FREE_TIER_LIMIT = 50; // 50 PDFs/month for free tier
const PRO_KEYS = new Set(
(process.env.PRO_KEYS || "").split(",").map((k) => k.trim()).filter(Boolean)
);
function getMonthKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function usageMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const key = req.headers.authorization?.slice(7) || "unknown";
const monthKey = getMonthKey();
// Pro keys have no limit
if (PRO_KEYS.has(key)) {
trackUsage(key, monthKey);
next();
return;
}
// Free tier limit check
const record = usage.get(key);
if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) {
res.status(429).json({
error: "Free tier limit reached",
limit: FREE_TIER_LIMIT,
used: record.count,
upgrade: "Upgrade to Pro for unlimited conversions: https://docfast.dev/pricing",
});
return;
}
trackUsage(key, monthKey);
next();
}
function trackUsage(key: string, monthKey: string): void {
const record = usage.get(key);
if (!record || record.monthKey !== monthKey) {
usage.set(key, { count: 1, monthKey });
} else {
record.count++;
}
}
export function getUsageStats(): Record<string, { count: number; month: string }> {
const stats: Record<string, { count: number; month: string }> = {};
for (const [key, record] of usage) {
const masked = key.slice(0, 8) + "...";
stats[masked] = { count: record.count, month: record.monthKey };
}
return stats;
}

View file

@ -1,5 +1,5 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { renderPdf } from "../services/browser.js"; import { renderPdf, renderUrlPdf } from "../services/browser.js";
import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js";
export const convertRouter = Router(); export const convertRouter = Router();
@ -76,3 +76,43 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => {
res.status(500).json({ error: "PDF generation failed", detail: err.message }); res.status(500).json({ error: "PDF generation failed", detail: err.message });
} }
}); });
// POST /v1/convert/url
convertRouter.post("/url", async (req: Request, res: Response) => {
try {
const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string };
if (!body.url) {
res.status(400).json({ error: "Missing 'url' field" });
return;
}
// Basic URL validation
try {
const parsed = new URL(body.url);
if (!["http:", "https:"].includes(parsed.protocol)) {
res.status(400).json({ error: "Only http/https URLs are supported" });
return;
}
} catch {
res.status(400).json({ error: "Invalid URL" });
return;
}
const pdf = await renderUrlPdf(body.url, {
format: body.format,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
waitUntil: body.waitUntil,
});
const filename = body.filename || "page.pdf";
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
} catch (err: any) {
console.error("Convert URL error:", err);
res.status(500).json({ error: "PDF generation failed", detail: err.message });
}
});

View file

@ -52,3 +52,40 @@ export async function renderPdf(
await page.close(); await page.close();
} }
} }
export async function renderUrlPdf(
url: string,
options: {
format?: string;
landscape?: boolean;
margin?: { top?: string; right?: string; bottom?: string; left?: string };
printBackground?: boolean;
waitUntil?: string;
} = {}
): Promise<Buffer> {
if (!browser) throw new Error("Browser not initialized");
const page: Page = await browser.newPage();
try {
await page.goto(url, {
waitUntil: (options.waitUntil as any) || "networkidle0",
timeout: 30_000,
});
const pdf = await page.pdf({
format: (options.format as any) || "A4",
landscape: options.landscape || false,
printBackground: options.printBackground !== false,
margin: options.margin || {
top: "20mm",
right: "15mm",
bottom: "20mm",
left: "15mm",
},
});
return Buffer.from(pdf);
} finally {
await page.close();
}
}