From 170ed444de53cd588af61b29d8866ce287ceb23a Mon Sep 17 00:00:00 2001 From: OpenClaw Deployer Date: Wed, 18 Feb 2026 18:05:17 +0000 Subject: [PATCH 001/174] Fix version number to 0.2.9 and add Brotli compression support (BUG-054) --- dist/index.js | 6 +- package.json | 2 +- src/index.ts | 6 +- src/index.ts.backup | 356 ++++++++++++++++++++++++++++++++++ src/middleware/compression.ts | 84 ++++++++ 5 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 src/index.ts.backup create mode 100644 src/middleware/compression.ts diff --git a/dist/index.js b/dist/index.js index aa69275..4152bcf 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,6 +1,6 @@ import express from "express"; import { randomUUID } from "crypto"; -import compression from "compression"; +import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; import path from "path"; @@ -43,7 +43,7 @@ app.use((_req, res, next) => { next(); }); // Compression -app.use(compression()); +app.use(compressionMiddleware); // Differentiated CORS middleware app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || @@ -209,7 +209,7 @@ app.get("/status", (_req, res) => { app.get("/api", (_req, res) => { res.json({ name: "DocFast API", - version: "0.2.1", + version: "0.2.9", endpoints: [ "POST /v1/signup/free โ€” Get a free API key", "POST /v1/convert/html", diff --git a/package.json b/package.json index 0616cf2..de57f00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.2.1", + "version": "0.2.9", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { diff --git a/src/index.ts b/src/index.ts index 51ea85b..07161e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import express from "express"; import { randomUUID } from "crypto"; -import compression from "compression"; +import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; import path from "path"; @@ -48,7 +48,7 @@ app.use((_req, res, next) => { }); // Compression -app.use(compression()); +app.use(compressionMiddleware); // Differentiated CORS middleware app.use((req, res, next) => { @@ -235,7 +235,7 @@ app.get("/status", (_req, res) => { app.get("/api", (_req, res) => { res.json({ name: "DocFast API", - version: "0.2.1", + version: "0.2.9", endpoints: [ "POST /v1/signup/free โ€” Get a free API key", "POST /v1/convert/html", diff --git a/src/index.ts.backup b/src/index.ts.backup new file mode 100644 index 0000000..31c24d1 --- /dev/null +++ b/src/index.ts.backup @@ -0,0 +1,356 @@ +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 ` + +${title} โ€” DocFast + + + +
+

${title}

+

${message}

+${apiKey ? ` +
โš ๏ธ Save your API key securely. You can recover it via email if needed.
+
${apiKey}
+ +` : ``} +
`; +} + +// 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(` + + + + + 404 - Page Not Found | DocFast + + + + + +
+
โšก
+

404

+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

+

โ† Back to DocFast | Read the docs

+
+ +`); + } +}); + + + +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((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 }; diff --git a/src/middleware/compression.ts b/src/middleware/compression.ts new file mode 100644 index 0000000..317af41 --- /dev/null +++ b/src/middleware/compression.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from "express"; +import zlib from "zlib"; + +export function compressionMiddleware(req: Request, res: Response, next: NextFunction) { + const acceptEncoding = req.headers["accept-encoding"] || ""; + + // Only compress if content-type suggests compressible content + const originalSend = res.send; + const originalJson = res.json; + + const shouldCompress = (content: any): boolean => { + const contentType = res.getHeader("content-type") as string; + const contentLength = Buffer.byteLength(typeof content === "string" ? content : JSON.stringify(content)); + + // Only compress if content is large enough and is compressible type + return contentLength > 1024 && + (contentType?.includes("text/") || + contentType?.includes("application/json") || + contentType?.includes("application/javascript")); + }; + + const compress = (content: string, encoding: string): Buffer => { + const buffer = Buffer.from(content, "utf8"); + + if (encoding === "br" && acceptEncoding.includes("br")) { + return zlib.brotliCompressSync(buffer, { + params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 6, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.length, + }, + }); + } else if (encoding === "gzip" && acceptEncoding.includes("gzip")) { + return zlib.gzipSync(buffer, { level: 6 }); + } + + return buffer; + }; + + // Override res.send + res.send = function(content: any) { + if (shouldCompress(content)) { + const stringContent = typeof content === "string" ? content : JSON.stringify(content); + + if (acceptEncoding.includes("br")) { + const compressed = compress(stringContent, "br"); + res.setHeader("Content-Encoding", "br"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } else if (acceptEncoding.includes("gzip")) { + const compressed = compress(stringContent, "gzip"); + res.setHeader("Content-Encoding", "gzip"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } + } + + return originalSend.call(this, content); + }; + + // Override res.json + res.json = function(content: any) { + if (shouldCompress(content)) { + const stringContent = JSON.stringify(content); + + if (acceptEncoding.includes("br")) { + const compressed = compress(stringContent, "br"); + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Encoding", "br"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } else if (acceptEncoding.includes("gzip")) { + const compressed = compress(stringContent, "gzip"); + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Encoding", "gzip"); + res.setHeader("Content-Length", compressed.length); + return originalSend.call(this, compressed); + } + } + + return originalJson.call(this, content); + }; + + next(); +} From 9c8dc237c33e3ec36b867773e1fca18078b1e54a Mon Sep 17 00:00:00 2001 From: OpenClaw Deployer Date: Wed, 18 Feb 2026 18:08:17 +0000 Subject: [PATCH 002/174] Trigger CI/CD pipeline for version 0.2.9 From 2332aa9f1f9b50175c6aa1eb3d52912cabc47d47 Mon Sep 17 00:00:00 2001 From: OpenClaw Deployer Date: Thu, 19 Feb 2026 08:02:44 +0000 Subject: [PATCH 003/174] fix: use compression package for proper static file compression --- src/middleware/compression.ts | 92 ++++------------------------------- 1 file changed, 9 insertions(+), 83 deletions(-) diff --git a/src/middleware/compression.ts b/src/middleware/compression.ts index 317af41..0bd0df9 100644 --- a/src/middleware/compression.ts +++ b/src/middleware/compression.ts @@ -1,84 +1,10 @@ -import { Request, Response, NextFunction } from "express"; -import zlib from "zlib"; +import compression from "compression"; -export function compressionMiddleware(req: Request, res: Response, next: NextFunction) { - const acceptEncoding = req.headers["accept-encoding"] || ""; - - // Only compress if content-type suggests compressible content - const originalSend = res.send; - const originalJson = res.json; - - const shouldCompress = (content: any): boolean => { - const contentType = res.getHeader("content-type") as string; - const contentLength = Buffer.byteLength(typeof content === "string" ? content : JSON.stringify(content)); - - // Only compress if content is large enough and is compressible type - return contentLength > 1024 && - (contentType?.includes("text/") || - contentType?.includes("application/json") || - contentType?.includes("application/javascript")); - }; - - const compress = (content: string, encoding: string): Buffer => { - const buffer = Buffer.from(content, "utf8"); - - if (encoding === "br" && acceptEncoding.includes("br")) { - return zlib.brotliCompressSync(buffer, { - params: { - [zlib.constants.BROTLI_PARAM_QUALITY]: 6, - [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buffer.length, - }, - }); - } else if (encoding === "gzip" && acceptEncoding.includes("gzip")) { - return zlib.gzipSync(buffer, { level: 6 }); - } - - return buffer; - }; - - // Override res.send - res.send = function(content: any) { - if (shouldCompress(content)) { - const stringContent = typeof content === "string" ? content : JSON.stringify(content); - - if (acceptEncoding.includes("br")) { - const compressed = compress(stringContent, "br"); - res.setHeader("Content-Encoding", "br"); - res.setHeader("Content-Length", compressed.length); - return originalSend.call(this, compressed); - } else if (acceptEncoding.includes("gzip")) { - const compressed = compress(stringContent, "gzip"); - res.setHeader("Content-Encoding", "gzip"); - res.setHeader("Content-Length", compressed.length); - return originalSend.call(this, compressed); - } - } - - return originalSend.call(this, content); - }; - - // Override res.json - res.json = function(content: any) { - if (shouldCompress(content)) { - const stringContent = JSON.stringify(content); - - if (acceptEncoding.includes("br")) { - const compressed = compress(stringContent, "br"); - res.setHeader("Content-Type", "application/json"); - res.setHeader("Content-Encoding", "br"); - res.setHeader("Content-Length", compressed.length); - return originalSend.call(this, compressed); - } else if (acceptEncoding.includes("gzip")) { - const compressed = compress(stringContent, "gzip"); - res.setHeader("Content-Type", "application/json"); - res.setHeader("Content-Encoding", "gzip"); - res.setHeader("Content-Length", compressed.length); - return originalSend.call(this, compressed); - } - } - - return originalJson.call(this, content); - }; - - next(); -} +export const compressionMiddleware = compression({ + level: 6, + threshold: 1024, + filter: (req: any, res: any) => { + if (req.headers["x-no-compression"]) return false; + return compression.filter(req, res); + } +}); From c6af7cd864f69a26de22c62faac5ca71dcb796ad Mon Sep 17 00:00:00 2001 From: OpenClaw Deployer Date: Thu, 19 Feb 2026 08:09:59 +0000 Subject: [PATCH 004/174] fix: disable buildx cache + simplify compression middleware --- .forgejo/workflows/deploy.yml | 1 + src/middleware/compression.ts | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 9dff451..8a48a9f 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -31,6 +31,7 @@ jobs: with: context: . push: true + no-cache: true tags: | git.cloonar.com/openclawd/docfast:latest git.cloonar.com/openclawd/docfast:${{ github.sha }} diff --git a/src/middleware/compression.ts b/src/middleware/compression.ts index 0bd0df9..fed4572 100644 --- a/src/middleware/compression.ts +++ b/src/middleware/compression.ts @@ -3,8 +3,4 @@ import compression from "compression"; export const compressionMiddleware = compression({ level: 6, threshold: 1024, - filter: (req: any, res: any) => { - if (req.headers["x-no-compression"]) return false; - return compression.filter(req, res); - } }); From fb05989b3bc0dc410914e9a915034773e10a2570 Mon Sep 17 00:00:00 2001 From: OpenClaw Deployer Date: Thu, 19 Feb 2026 08:39:56 +0000 Subject: [PATCH 005/174] fix: SEO + accessibility + consistency fixes (BUG-056,062,063,064,065,066,067,068) --- public/app.js | 5 +++++ public/docs.html | 1 + public/index.html | 41 ++++++++++++++++++++++------------------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/public/app.js b/public/app.js index 0f526cf..36a15fc 100644 --- a/public/app.js +++ b/public/app.js @@ -304,6 +304,11 @@ async function checkout() { } document.addEventListener('DOMContentLoaded', function() { + // BUG-068: Open change email modal if navigated via #change-email hash + if (window.location.hash === '#change-email') { + openEmailChange(); + } + document.getElementById('btn-signup').addEventListener('click', openSignup); document.getElementById('btn-signup-2').addEventListener('click', openSignup); document.getElementById('btn-checkout').addEventListener('click', checkout); diff --git a/public/docs.html b/public/docs.html index e99db2a..c016c09 100644 --- a/public/docs.html +++ b/public/docs.html @@ -14,6 +14,7 @@ + -
-

${title}

-

${message}

-${apiKey ? ` -
โš ๏ธ Save your API key securely. You can recover it via email if needed.
-
${apiKey}
- -` : ``} -
`; -} - -// 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(` - - - - - 404 - Page Not Found | DocFast - - - - - -
-
โšก
-

404

-

Page Not Found

-

The page you're looking for doesn't exist or has been moved.

-

โ† Back to DocFast | Read the docs

-
- -`); - } -}); - - - -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((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 }; diff --git a/src/routes/health.ts.backup b/src/routes/health.ts.backup deleted file mode 100644 index bbee73a..0000000 --- a/src/routes/health.ts.backup +++ /dev/null @@ -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), - }, - }); -}); diff --git a/templates/pages/terms.html b/templates/pages/terms.html index 9aaed54..30cd632 100644 --- a/templates/pages/terms.html +++ b/templates/pages/terms.html @@ -89,7 +89,7 @@ footer .container { display: flex; justify-content: space-between; align-items:

2.2 Pro Tier

  • Price: โ‚ฌ9 per month
  • -
  • Monthly limit: 10,000 PDF conversions
  • +
  • Monthly limit: 5,000 PDF conversions
  • Rate limit: Higher limits based on fair use
  • Support: Priority email support (support@docfast.dev)
  • Billing: Monthly subscription via Stripe
  • From 32a00be0b3ba61888c0ce53f11ea73d9b694a987 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 20 Feb 2026 07:03:48 +0000 Subject: [PATCH 007/174] a11y & SEO: aria-labels, focus management, structured data, sitemap update, v0.3.3 --- package.json | 2 +- public/app.min.js | 2 +- public/index.html | 29 ++++++++++++++++------------- public/sitemap.xml | 13 +++++++------ 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 0d59065..86af9de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.3.2", + "version": "0.3.3", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { diff --git a/public/app.min.js b/public/app.min.js index 258c395..6b5d8f6 100644 --- a/public/app.min.js +++ b/public/app.min.js @@ -1 +1 @@ -var signupEmail="",recoverEmail="";function showState(e){["signupInitial","signupLoading","signupVerify","signupResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openSignup(){document.getElementById("signupModal").classList.add("active"),showState("signupInitial"),document.getElementById("signupError").style.display="none",document.getElementById("verifyError").style.display="none",document.getElementById("signupEmail").value="",document.getElementById("verifyCode").value="",signupEmail=""}function closeSignup(){document.getElementById("signupModal").classList.remove("active")}function openRecover(){closeSignup(),document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail=""}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitSignup(){var e=document.getElementById("signupError"),t=document.getElementById("signupBtn"),n=document.getElementById("signupEmail").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showState("signupLoading");try{var a=await fetch("/v1/signup/free",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),i=await a.json();if(!a.ok)return showState("signupInitial"),e.textContent=i.error||"Something went wrong. Please try again.",e.style.display="block",void(t.disabled=!1);signupEmail=n,document.getElementById("verifyEmailDisplay").textContent=n,showState("signupVerify"),document.getElementById("verifyCode").focus(),t.disabled=!1}catch(n){showState("signupInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitVerify(){var e=document.getElementById("verifyError"),t=document.getElementById("verifyBtn"),n=document.getElementById("verifyCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/signup/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:signupEmail,code:n})}),i=await a.json();if(!a.ok)return e.textContent=i.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("apiKeyText").textContent=i.apiKey,showState("signupResult")}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),i=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),i=await a.json();if(!a.ok)return e.textContent=i.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);i.apiKey?(document.getElementById("recoveredKeyText").textContent=i.apiKey,showRecoverState("recoverResult")):(e.textContent=i.message||"No key found for this email.",e.style.display="block",t.disabled=!1)}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyKey(){doCopy(document.getElementById("apiKeyText").textContent,document.getElementById("copyBtn"))}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="โœ“ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{if(navigator.clipboard&&window.isSecureContext)navigator.clipboard.writeText(e).then(n).catch(function(){try{var t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",t.style.top="-9999px",t.style.left="-9999px",document.body.appendChild(t),t.focus(),t.select();var i=document.execCommand("copy");document.body.removeChild(t),i?n():a()}catch(e){a()}});else{var i=document.createElement("textarea");i.value=e,i.style.position="fixed",i.style.opacity="0",i.style.top="-9999px",i.style.left="-9999px",document.body.appendChild(i),i.focus(),i.select();var o=document.execCommand("copy");document.body.removeChild(i),o?n():a()}}catch(e){a()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}document.addEventListener("DOMContentLoaded",function(){document.getElementById("btn-signup").addEventListener("click",openSignup),document.getElementById("btn-signup-2").addEventListener("click",openSignup),document.getElementById("btn-checkout").addEventListener("click",checkout),document.getElementById("btn-close-signup").addEventListener("click",closeSignup),document.getElementById("signupBtn").addEventListener("click",submitSignup),document.getElementById("verifyBtn").addEventListener("click",submitVerify),document.getElementById("copyBtn").addEventListener("click",copyKey),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.getElementById("signupModal").addEventListener("click",function(e){e.target===this&&closeSignup()}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){e.preventDefault();var t=document.querySelector(this.getAttribute("href"));t&&t.scrollIntoView({behavior:"smooth"})})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeSignup(),closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var i=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),o=await i.json();if(!i.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),i=await a.json();if(!a.ok)return e.textContent=i.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=i.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult")}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["signupModal","recoverModal","emailChangeModal"],t=0;t - + + diff --git a/public/src/index.html b/public/src/index.html index d74bea7..291341e 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -14,10 +14,13 @@ - + + {{> styles_base}} {{> styles_index_extra}} @@ -36,7 +39,7 @@

    HTML to PDF
    in one API call

    Convert HTML, Markdown, or URLs to pixel-perfect PDFs. Built-in templates for invoices & receipts. No headless browser headaches.

    - + Read the Docs

    Already have an account? Lost your API key? Recover it โ†’

    @@ -147,7 +150,7 @@
  • All templates included
  • Rate limiting: 10 req/min
- + @@ -183,7 +186,7 @@ - +

Your key will be shown here after verification โ€” never sent via email

@@ -198,7 +201,7 @@ - +

Code expires in 15 minutes

@@ -210,7 +213,7 @@
- +

Read the docs โ†’

From 53755d6093598013816ffeedb61b5dad6cb6a018 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 07:32:45 +0000 Subject: [PATCH 010/174] v0.4.0: Remove free tier, add public demo endpoint with watermark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove free account signup flow entirely - Add POST /v1/demo/html and /v1/demo/markdown (public, no auth) - Demo: 5 requests/hour per IP, 50KB body limit, watermarked PDFs - Landing page: interactive playground replaces 'Get Free API Key' - Pricing: Demo (free) + Pro (โ‚ฌ9/mo), no more Free tier - /v1/signup returns 410 Gone with redirect to demo/pro - Keep /v1/recover for existing Pro users - Update JSON-LD, API discovery, verify page text --- package.json | 2 +- public/app.js | 253 +++++++++++++++------------------------------ public/app.min.js | 2 +- public/index.html | 111 +++++++++----------- src/index.ts | 25 +++-- src/routes/demo.ts | 146 ++++++++++++++++++++++++++ 6 files changed, 299 insertions(+), 240 deletions(-) create mode 100644 src/routes/demo.ts diff --git a/package.json b/package.json index 0cfbbaa..c806717 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.3.4", + "version": "0.4.0", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { diff --git a/public/app.js b/public/app.js index 3a865bc..aa1806e 100644 --- a/public/app.js +++ b/public/app.js @@ -1,14 +1,5 @@ -var signupEmail = ''; var recoverEmail = ''; -function showState(state) { - ['signupInitial', 'signupLoading', 'signupVerify', 'signupResult'].forEach(function(id) { - var el = document.getElementById(id); - if (el) el.classList.remove('active'); - }); - document.getElementById(state).classList.add('active'); -} - function showRecoverState(state) { ['recoverInitial', 'recoverLoading', 'recoverVerify', 'recoverResult'].forEach(function(id) { var el = document.getElementById(id); @@ -17,23 +8,7 @@ function showRecoverState(state) { document.getElementById(state).classList.add('active'); } -function openSignup() { - document.getElementById('signupModal').classList.add('active'); - showState('signupInitial'); - document.getElementById('signupError').style.display = 'none'; - document.getElementById('verifyError').style.display = 'none'; - document.getElementById('signupEmail').value = ''; - document.getElementById('verifyCode').value = ''; - signupEmail = ''; - setTimeout(function() { document.getElementById('signupEmail').focus(); }, 100); -} - -function closeSignup() { - document.getElementById('signupModal').classList.remove('active'); -} - function openRecover() { - closeSignup(); document.getElementById('recoverModal').classList.add('active'); showRecoverState('recoverInitial'); var errEl = document.getElementById('recoverError'); @@ -50,92 +25,6 @@ function closeRecover() { document.getElementById('recoverModal').classList.remove('active'); } -async function submitSignup() { - var errEl = document.getElementById('signupError'); - var btn = document.getElementById('signupBtn'); - var emailInput = document.getElementById('signupEmail'); - var email = emailInput.value.trim(); - - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errEl.textContent = 'Please enter a valid email address.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - showState('signupLoading'); - - try { - var res = await fetch('/v1/signup/free', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email }) - }); - var data = await res.json(); - - if (!res.ok) { - showState('signupInitial'); - errEl.textContent = data.error || 'Something went wrong. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - signupEmail = email; - document.getElementById('verifyEmailDisplay').textContent = email; - showState('signupVerify'); - document.getElementById('verifyCode').focus(); - btn.disabled = false; - } catch (err) { - showState('signupInitial'); - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - -async function submitVerify() { - var errEl = document.getElementById('verifyError'); - var btn = document.getElementById('verifyBtn'); - var codeInput = document.getElementById('verifyCode'); - var code = codeInput.value.trim(); - - if (!code || !/^\d{6}$/.test(code)) { - errEl.textContent = 'Please enter a 6-digit code.'; - errEl.style.display = 'block'; - return; - } - - errEl.style.display = 'none'; - btn.disabled = true; - - try { - var res = await fetch('/v1/signup/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: signupEmail, code: code }) - }); - var data = await res.json(); - - if (!res.ok) { - errEl.textContent = data.error || 'Verification failed.'; - errEl.style.display = 'block'; - btn.disabled = false; - return; - } - - document.getElementById('apiKeyText').textContent = data.apiKey; - showState('signupResult'); - var resultH2 = document.querySelector('#signupResult h2'); - if (resultH2) { resultH2.setAttribute('tabindex', '-1'); resultH2.focus(); } - } catch (err) { - errEl.textContent = 'Network error. Please try again.'; - errEl.style.display = 'block'; - btn.disabled = false; - } -} - async function submitRecover() { var errEl = document.getElementById('recoverError'); var btn = document.getElementById('recoverBtn'); @@ -228,12 +117,6 @@ async function submitRecoverVerify() { } } -function copyKey() { - var key = document.getElementById('apiKeyText').textContent; - var btn = document.getElementById('copyBtn'); - doCopy(key, btn); -} - function copyRecoveredKey() { var key = document.getElementById('recoveredKeyText').textContent; var btn = document.getElementById('copyRecoveredBtn'); @@ -252,52 +135,32 @@ function doCopy(text, btn) { try { 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(); - } + fallbackCopy(text, showCopied, showFailed); }); } else { - // Direct fallback for non-secure contexts - 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(); - } + fallbackCopy(text, showCopied, showFailed); } } catch(e) { showFailed(); } } +function fallbackCopy(text, onSuccess, onFail) { + try { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + var success = document.execCommand('copy'); + document.body.removeChild(ta); + success ? onSuccess() : onFail(); + } catch(e) { onFail(); } +} + async function checkout() { try { var res = await fetch('/v1/billing/checkout', { method: 'POST' }); @@ -309,19 +172,71 @@ async function checkout() { } } +// === Demo Playground === +async function generateDemo() { + var btn = document.getElementById('demoGenerateBtn'); + var status = document.getElementById('demoStatus'); + var result = document.getElementById('demoResult'); + var errorEl = document.getElementById('demoError'); + var html = document.getElementById('demoHtml').value.trim(); + + if (!html) { + errorEl.textContent = 'Please enter some HTML.'; + errorEl.style.display = 'block'; + result.style.display = 'none'; + return; + } + + errorEl.style.display = 'none'; + result.style.display = 'none'; + btn.disabled = true; + status.textContent = 'Generating PDFโ€ฆ'; + + try { + var res = await fetch('/v1/demo/html', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ html: html }) + }); + + if (!res.ok) { + var data = await res.json(); + errorEl.textContent = data.error || 'Something went wrong.'; + errorEl.style.display = 'block'; + btn.disabled = false; + status.textContent = ''; + return; + } + + var blob = await res.blob(); + var url = URL.createObjectURL(blob); + var dl = document.getElementById('demoDownload'); + dl.href = url; + result.style.display = 'block'; + status.textContent = ''; + btn.disabled = false; + } catch (err) { + errorEl.textContent = 'Network error. Please try again.'; + errorEl.style.display = 'block'; + btn.disabled = false; + status.textContent = ''; + } +} + +// === Init === document.addEventListener('DOMContentLoaded', function() { - // BUG-068: Open change email modal if navigated via #change-email hash + // BUG-068: Open change email modal if navigated via hash if (window.location.hash === '#change-email') { openEmailChange(); } - document.getElementById('btn-signup').addEventListener('click', openSignup); - document.getElementById('btn-signup-2').addEventListener('click', openSignup); + // Demo playground + document.getElementById('demoGenerateBtn').addEventListener('click', generateDemo); + + // Checkout buttons document.getElementById('btn-checkout').addEventListener('click', checkout); - document.getElementById('btn-close-signup').addEventListener('click', closeSignup); - document.getElementById('signupBtn').addEventListener('click', submitSignup); - document.getElementById('verifyBtn').addEventListener('click', submitVerify); - document.getElementById('copyBtn').addEventListener('click', copyKey); + var heroCheckout = document.getElementById('btn-checkout-hero'); + if (heroCheckout) heroCheckout.addEventListener('click', checkout); // Recovery modal document.getElementById('btn-close-recover').addEventListener('click', closeRecover); @@ -337,13 +252,13 @@ document.addEventListener('DOMContentLoaded', function() { el.addEventListener('click', function(e) { e.preventDefault(); openRecover(); }); }); - document.getElementById('signupModal').addEventListener('click', function(e) { - if (e.target === this) closeSignup(); - }); + // Smooth scroll for hash links document.querySelectorAll('a[href^="#"]').forEach(function(a) { a.addEventListener('click', function(e) { + var target = this.getAttribute('href'); + if (target === '#') return; e.preventDefault(); - var el = document.querySelector(this.getAttribute('href')); + var el = document.querySelector(target); if (el) el.scrollIntoView({ behavior: 'smooth' }); }); }); @@ -362,7 +277,6 @@ function showEmailChangeState(state) { } function openEmailChange() { - closeSignup(); closeRecover(); document.getElementById('emailChangeModal').classList.add('active'); showEmailChangeState('emailChangeInitial'); @@ -472,7 +386,7 @@ async function submitEmailChangeVerify() { } } -// Add event listeners for email change (append to DOMContentLoaded) +// Email change event listeners document.addEventListener('DOMContentLoaded', function() { var closeBtn = document.getElementById('btn-close-email-change'); if (closeBtn) closeBtn.addEventListener('click', closeEmailChange); @@ -494,7 +408,7 @@ document.addEventListener('DOMContentLoaded', function() { // === Accessibility: Escape key closes modals, focus trapping === (function() { function getActiveModal() { - var modals = ['signupModal', 'recoverModal', 'emailChangeModal']; + var modals = ['recoverModal', 'emailChangeModal']; for (var i = 0; i < modals.length; i++) { var m = document.getElementById(modals[i]); if (m && m.classList.contains('active')) return m; @@ -511,7 +425,6 @@ document.addEventListener('DOMContentLoaded', function() { document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeActiveModal(); - // Focus trap inside active modal if (e.key === 'Tab') { var modal = getActiveModal(); if (!modal) return; diff --git a/public/app.min.js b/public/app.min.js index 6b5d8f6..904a38d 100644 --- a/public/app.min.js +++ b/public/app.min.js @@ -1 +1 @@ -var signupEmail="",recoverEmail="";function showState(e){["signupInitial","signupLoading","signupVerify","signupResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openSignup(){document.getElementById("signupModal").classList.add("active"),showState("signupInitial"),document.getElementById("signupError").style.display="none",document.getElementById("verifyError").style.display="none",document.getElementById("signupEmail").value="",document.getElementById("verifyCode").value="",signupEmail="",setTimeout(function(){document.getElementById("signupEmail").focus()},100)}function closeSignup(){document.getElementById("signupModal").classList.remove("active")}function openRecover(){closeSignup(),document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail="",setTimeout(function(){document.getElementById("recoverEmailInput").focus()},100)}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitSignup(){var e=document.getElementById("signupError"),t=document.getElementById("signupBtn"),n=document.getElementById("signupEmail").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showState("signupLoading");try{var a=await fetch("/v1/signup/free",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),i=await a.json();if(!a.ok)return showState("signupInitial"),e.textContent=i.error||"Something went wrong. Please try again.",e.style.display="block",void(t.disabled=!1);signupEmail=n,document.getElementById("verifyEmailDisplay").textContent=n,showState("signupVerify"),document.getElementById("verifyCode").focus(),t.disabled=!1}catch(n){showState("signupInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitVerify(){var e=document.getElementById("verifyError"),t=document.getElementById("verifyBtn"),n=document.getElementById("verifyCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/signup/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:signupEmail,code:n})}),i=await a.json();if(!a.ok)return e.textContent=i.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("apiKeyText").textContent=i.apiKey,showState("signupResult"),function(){var h=document.querySelector("#signupResult h2");h&&(h.setAttribute("tabindex","-1"),h.focus())}()}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),i=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),i=await a.json();if(!a.ok)return e.textContent=i.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);i.apiKey?(document.getElementById("recoveredKeyText").textContent=i.apiKey,showRecoverState("recoverResult"),function(){var h=document.querySelector("#recoverResult h2");h&&(h.setAttribute("tabindex","-1"),h.focus())}()):(e.textContent=i.message||"No key found for this email.",e.style.display="block",t.disabled=!1)}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyKey(){doCopy(document.getElementById("apiKeyText").textContent,document.getElementById("copyBtn"))}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="โœ“ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{if(navigator.clipboard&&window.isSecureContext)navigator.clipboard.writeText(e).then(n).catch(function(){try{var t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",t.style.top="-9999px",t.style.left="-9999px",document.body.appendChild(t),t.focus(),t.select();var i=document.execCommand("copy");document.body.removeChild(t),i?n():a()}catch(e){a()}});else{var i=document.createElement("textarea");i.value=e,i.style.position="fixed",i.style.opacity="0",i.style.top="-9999px",i.style.left="-9999px",document.body.appendChild(i),i.focus(),i.select();var o=document.execCommand("copy");document.body.removeChild(i),o?n():a()}}catch(e){a()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}document.addEventListener("DOMContentLoaded",function(){document.getElementById("btn-signup").addEventListener("click",openSignup),document.getElementById("btn-signup-2").addEventListener("click",openSignup),document.getElementById("btn-checkout").addEventListener("click",checkout),document.getElementById("btn-close-signup").addEventListener("click",closeSignup),document.getElementById("signupBtn").addEventListener("click",submitSignup),document.getElementById("verifyBtn").addEventListener("click",submitVerify),document.getElementById("copyBtn").addEventListener("click",copyKey),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.getElementById("signupModal").addEventListener("click",function(e){e.target===this&&closeSignup()}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){e.preventDefault();var t=document.querySelector(this.getAttribute("href"));t&&t.scrollIntoView({behavior:"smooth"})})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeSignup(),closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var i=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),o=await i.json();if(!i.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),i=await a.json();if(!a.ok)return e.textContent=i.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=i.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult"),function(){var h=document.querySelector("#emailChangeResult h2");h&&(h.setAttribute("tabindex","-1"),h.focus())}()}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["signupModal","recoverModal","emailChangeModal"],t=0;t \n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development โ€” Landing Page40 hrs$150$6,000
UI/UX Design โ€” Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n', + report: '\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team โ€” February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

๐ŸŽฏ Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n', + custom: '\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n' +}; + +var previewDebounce = null; +function updatePreview() { + var iframe = document.getElementById('demoPreview'); + var html = document.getElementById('demoHtml').value; + if (!iframe) return; + var doc = iframe.contentDocument || iframe.contentWindow.document; + doc.open(); + doc.write(html); + doc.close(); +} + +function setTemplate(name) { + var ta = document.getElementById('demoHtml'); + ta.value = pgTemplates[name] || pgTemplates.custom; + updatePreview(); + // Update active tab + document.querySelectorAll('.pg-tab').forEach(function(t) { + var isActive = t.getAttribute('data-template') === name; + t.classList.toggle('active', isActive); + t.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); +} + async function generateDemo() { var btn = document.getElementById('demoGenerateBtn'); var status = document.getElementById('demoStatus'); @@ -183,14 +212,16 @@ async function generateDemo() { if (!html) { errorEl.textContent = 'Please enter some HTML.'; errorEl.style.display = 'block'; - result.style.display = 'none'; + result.classList.remove('visible'); return; } errorEl.style.display = 'none'; - result.style.display = 'none'; + result.classList.remove('visible'); btn.disabled = true; - status.textContent = 'Generating PDFโ€ฆ'; + btn.classList.add('pg-generating'); + status.textContent = 'Generatingโ€ฆ'; + var startTime = performance.now(); try { var res = await fetch('/v1/demo/html', { @@ -204,21 +235,25 @@ async function generateDemo() { errorEl.textContent = data.error || 'Something went wrong.'; errorEl.style.display = 'block'; btn.disabled = false; + btn.classList.remove('pg-generating'); status.textContent = ''; return; } + var elapsed = ((performance.now() - startTime) / 1000).toFixed(1); var blob = await res.blob(); var url = URL.createObjectURL(blob); - var dl = document.getElementById('demoDownload'); - dl.href = url; - result.style.display = 'block'; + document.getElementById('demoDownload').href = url; + document.getElementById('demoTime').textContent = elapsed; + result.classList.add('visible'); status.textContent = ''; btn.disabled = false; + btn.classList.remove('pg-generating'); } catch (err) { errorEl.textContent = 'Network error. Please try again.'; errorEl.style.display = 'block'; btn.disabled = false; + btn.classList.remove('pg-generating'); status.textContent = ''; } } @@ -233,6 +268,21 @@ document.addEventListener('DOMContentLoaded', function() { // Demo playground document.getElementById('demoGenerateBtn').addEventListener('click', generateDemo); + // Playground tabs + document.querySelectorAll('.pg-tab').forEach(function(tab) { + tab.addEventListener('click', function() { setTemplate(this.getAttribute('data-template')); }); + }); + // Init with invoice template + setTemplate('invoice'); + // Live preview on input + document.getElementById('demoHtml').addEventListener('input', function() { + clearTimeout(previewDebounce); + previewDebounce = setTimeout(updatePreview, 150); + }); + // Playground checkout button + var pgCheckout = document.getElementById('btn-checkout-playground'); + if (pgCheckout) pgCheckout.addEventListener('click', checkout); + // Checkout buttons document.getElementById('btn-checkout').addEventListener('click', checkout); var heroCheckout = document.getElementById('btn-checkout-hero'); diff --git a/public/app.min.js b/public/app.min.js index 904a38d..c5006e6 100644 --- a/public/app.min.js +++ b/public/app.min.js @@ -1 +1 @@ -var recoverEmail="";function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openRecover(){document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail="",setTimeout(function(){document.getElementById("recoverEmailInput").focus()},100)}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),o=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);if(o.apiKey){document.getElementById("recoveredKeyText").textContent=o.apiKey,showRecoverState("recoverResult");var i=document.querySelector("#recoverResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}else e.textContent=o.message||"No key found for this email.",e.style.display="block",t.disabled=!1}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="โœ“ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(e).then(n).catch(function(){fallbackCopy(e,n,a)}):fallbackCopy(e,n,a)}catch(e){a()}}function fallbackCopy(e,t,n){try{var a=document.createElement("textarea");a.value=e,a.style.position="fixed",a.style.opacity="0",a.style.top="-9999px",document.body.appendChild(a),a.focus(),a.select();var o=document.execCommand("copy");document.body.removeChild(a),o?t():n()}catch(e){n()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void(n.style.display="none");a.style.display="none",n.style.display="none",e.disabled=!0,t.textContent="Generating PDFโ€ฆ";try{var i=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!i.ok){var l=await i.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,void(t.textContent="")}var r=await i.blob(),c=URL.createObjectURL(r);document.getElementById("demoDownload").href=c,n.style.display="block",t.textContent="",e.disabled=!1}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.getElementById("btn-checkout").addEventListener("click",checkout);var e=document.getElementById("btn-checkout-hero");e&&e.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t\n\n\n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development โ€” Landing Page40 hrs$150$6,000
UI/UX Design โ€” Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n',report:'\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team โ€” February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

๐ŸŽฏ Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n',custom:"\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generatingโ€ฆ";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t - + -{{> nav}} + -
-
+
๐Ÿš€ Simple PDF API for Developers

HTML to PDF
in one API call

@@ -56,7 +362,7 @@
-
+
@@ -86,7 +392,7 @@
๐Ÿ‡ช๐Ÿ‡บ
-

Hosted in the EU

+

Hosted in the EU

Your data never leaves the EU โ€ข GDPR Compliant โ€ข Hetzner Germany (Nuremberg)

@@ -132,20 +438,69 @@
-
+
-

Try it now

-

Paste HTML, get a watermarked PDF. No signup required.

-
- -
- - +

Try it โ€” right now

+

Pick a template or write your own HTML. Generate a real PDF in seconds.

+ + +
+ + + +
+ + +
+
+
+ + HTML +
+
- - + + +
+ + +
+ + + +
+
+
โœ…
+
+

PDF generated in 0.4s

+ Download PDF โ†’ +
+
+
+
+
๐Ÿ†“ Free Demo
+
Watermarked output
+
+
โ†’
+
+
โšก Pro
+
Clean, production-ready
+
+
+
+
@@ -167,28 +522,38 @@
  • No watermarks
  • Priority support (support@docfast.dev)
  • - +
    - - -{{> footer}} + - @@ -223,7 +587,43 @@ - + + + + diff --git a/templates/pages/index.html b/templates/pages/index.html index 3fec6da..a8f49d6 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -255,6 +255,63 @@ html, body { #emailChangeResult.active { display: block; } #emailChangeVerify.active { display: block; } +/* Playground โ€” redesigned */ +.playground { padding: 80px 0; } +.pg-tabs { display: flex; gap: 8px; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; } +.pg-tab { background: var(--card); border: 1px solid var(--border); color: var(--muted); padding: 10px 20px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.2s; } +.pg-tab:hover { border-color: var(--muted); color: var(--fg); } +.pg-tab.active { background: rgba(52,211,153,0.08); border-color: var(--accent); color: var(--accent); } +.pg-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--card); min-height: 380px; } +.pg-editor-pane, .pg-preview-pane { display: flex; flex-direction: column; min-height: 0; } +.pg-pane-header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: #1a1f2b; border-bottom: 1px solid var(--border); } +.pg-pane-header-preview { justify-content: space-between; } +.pg-pane-dots { display: flex; gap: 5px; } +.pg-pane-dots span { width: 8px; height: 8px; border-radius: 50%; } +.pg-pane-dots span:nth-child(1) { background: #f87171; } +.pg-pane-dots span:nth-child(2) { background: #fbbf24; } +.pg-pane-dots span:nth-child(3) { background: #34d399; } +.pg-pane-label { font-size: 0.75rem; color: var(--muted); font-family: monospace; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; } +.pg-preview-badge { font-size: 0.65rem; color: var(--accent); background: rgba(52,211,153,0.08); padding: 3px 8px; border-radius: 4px; font-weight: 500; } +#demoHtml { flex: 1; width: 100%; padding: 16px; border: none; background: transparent; color: var(--fg); font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 0.82rem; line-height: 1.7; resize: none; outline: none; tab-size: 2; } +.pg-preview-pane { border-left: 1px solid var(--border); } +.pg-preview-frame-wrap { flex: 1; background: #fff; position: relative; overflow: hidden; } +#demoPreview { width: 100%; height: 100%; border: none; background: #fff; } +.pg-actions { display: flex; align-items: center; gap: 16px; justify-content: center; margin-top: 24px; flex-wrap: wrap; } +.btn-lg { padding: 16px 36px; font-size: 1.05rem; border-radius: 12px; } +.btn-sm { padding: 10px 20px; font-size: 0.85rem; } +.pg-btn-icon { font-size: 1.1rem; } +.pg-status { color: var(--muted); font-size: 0.9rem; } +.pg-result { display: none; margin-top: 24px; background: var(--card); border: 1px solid var(--accent); border-radius: var(--radius-lg); padding: 28px; animation: pgSlideIn 0.3s ease; } +.pg-result.visible { display: block; } +@keyframes pgSlideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +.pg-result-inner { display: flex; align-items: center; gap: 16px; } +.pg-result-icon { font-size: 2rem; } +.pg-result-title { font-weight: 700; font-size: 1.05rem; margin-bottom: 8px; } +.pg-result-comparison { display: flex; align-items: center; gap: 16px; justify-content: center; margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border); } +.pg-compare-item { padding: 12px 20px; border-radius: 8px; text-align: center; flex: 1; max-width: 200px; } +.pg-compare-free { background: rgba(248,113,113,0.06); border: 1px solid rgba(248,113,113,0.15); } +.pg-compare-pro { background: rgba(52,211,153,0.06); border: 1px solid rgba(52,211,153,0.2); } +.pg-compare-label { font-weight: 700; font-size: 0.9rem; margin-bottom: 4px; } +.pg-compare-free .pg-compare-label { color: #f87171; } +.pg-compare-pro .pg-compare-label { color: var(--accent); } +.pg-compare-desc { color: var(--muted); font-size: 0.8rem; } +.pg-compare-arrow { color: var(--muted); font-size: 1.2rem; font-weight: 700; } +.pg-result-cta { text-align: center; margin-top: 20px; } +.pg-generating .pg-btn-icon { display: inline-block; animation: spin 0.7s linear infinite; } +@media (max-width: 768px) { + .pg-split { grid-template-columns: 1fr; min-height: auto; } + .pg-preview-pane { border-left: none; border-top: 1px solid var(--border); } + .pg-preview-frame-wrap { height: 250px; } + #demoHtml { min-height: 200px; } + .pg-result-comparison { flex-direction: column; gap: 8px; } + .pg-compare-arrow { transform: rotate(90deg); } + .pg-compare-item { max-width: 100%; } +} +@media (max-width: 375px) { + .pg-tabs { gap: 4px; } + .pg-tab { padding: 8px 12px; font-size: 0.75rem; } +} + /* Focus-visible for accessibility */ .btn:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* Skip to content */ @@ -381,20 +438,69 @@ html, body {
    -
    +
    -

    Try it now

    -

    Paste HTML, get a watermarked PDF. No signup required.

    -
    - -
    - - +

    Try it โ€” right now

    +

    Pick a template or write your own HTML. Generate a real PDF in seconds.

    + + +
    + + + +
    + + +
    +
    +
    + + HTML +
    +
    - - + + +
    + + +
    + + + +
    +
    +
    โœ…
    +
    +

    PDF generated in 0.4s

    + Download PDF โ†’ +
    +
    +
    +
    +
    ๐Ÿ†“ Free Demo
    +
    Watermarked output
    +
    +
    โ†’
    +
    +
    โšก Pro
    +
    Clean, production-ready
    +
    +
    +
    +
    From ca070520b4418f8e0e3a141932720d3df2afda69 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Fri, 20 Feb 2026 09:46:40 +0000 Subject: [PATCH 016/174] Remove rate limiting mention from landing page Rate limiting is a technical constraint, not a feature to advertise. Focus on what customers get: security, zero storage, streaming. --- public/index.html | 2 +- public/src/index.html | 2 +- templates/pages/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index ae69e3f..8b8ac67 100644 --- a/public/index.html +++ b/public/index.html @@ -432,7 +432,7 @@ html, body {

    Secure by Default

    -

    HTTPS only. Rate limiting. No data stored. PDFs stream directly โ€” nothing touches disk.

    +

    HTTPS only. No data stored. PDFs stream directly to you โ€” nothing touches disk.

    diff --git a/public/src/index.html b/public/src/index.html index ae69e3f..8b8ac67 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -432,7 +432,7 @@ html, body {

    Secure by Default

    -

    HTTPS only. Rate limiting. No data stored. PDFs stream directly โ€” nothing touches disk.

    +

    HTTPS only. No data stored. PDFs stream directly to you โ€” nothing touches disk.

    diff --git a/templates/pages/index.html b/templates/pages/index.html index a8f49d6..8e826cc 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -432,7 +432,7 @@ html, body {

    Secure by Default

    -

    HTTPS only. Rate limiting. No data stored. PDFs stream directly โ€” nothing touches disk.

    +

    HTTPS only. No data stored. PDFs stream directly to you โ€” nothing touches disk.

    From 432a24dd81b235ec1fc4e4dffccfb8994ba17143 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Fri, 20 Feb 2026 09:51:20 +0000 Subject: [PATCH 017/174] fix: download button in playground + de-emphasize rate limits - Fix download button: exclude #demoDownload from smooth scroll handler that was calling preventDefault() on blob: URLs after PDF generation - Replace '5,000 PDFs per month' with 'High-volume PDF generation' in pricing - Update schema.org structured data to remove specific limits --- public/app.js | 3 ++- public/app.min.js | 2 +- public/index.html | 4 ++-- public/src/index.html | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index f613f65..808c0e3 100644 --- a/public/app.js +++ b/public/app.js @@ -302,8 +302,9 @@ document.addEventListener('DOMContentLoaded', function() { el.addEventListener('click', function(e) { e.preventDefault(); openRecover(); }); }); - // Smooth scroll for hash links + // Smooth scroll for hash links (exclude download link) document.querySelectorAll('a[href^="#"]').forEach(function(a) { + if (a.id === 'demoDownload') return; a.addEventListener('click', function(e) { var target = this.getAttribute('href'); if (target === '#') return; diff --git a/public/app.min.js b/public/app.min.js index c5006e6..86e9023 100644 --- a/public/app.min.js +++ b/public/app.min.js @@ -1 +1 @@ -var recoverEmail="";function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openRecover(){document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail="",setTimeout(function(){document.getElementById("recoverEmailInput").focus()},100)}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),o=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);if(o.apiKey){document.getElementById("recoveredKeyText").textContent=o.apiKey,showRecoverState("recoverResult");var i=document.querySelector("#recoverResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}else e.textContent=o.message||"No key found for this email.",e.style.display="block",t.disabled=!1}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="โœ“ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(e).then(n).catch(function(){fallbackCopy(e,n,a)}):fallbackCopy(e,n,a)}catch(e){a()}}function fallbackCopy(e,t,n){try{var a=document.createElement("textarea");a.value=e,a.style.position="fixed",a.style.opacity="0",a.style.top="-9999px",document.body.appendChild(a),a.focus(),a.select();var o=document.execCommand("copy");document.body.removeChild(a),o?t():n()}catch(e){n()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}var pgTemplates={invoice:'\n\n\n\n\n
    \n
    \n
    Acme Corp
    \n
    123 Business Ave, Suite 100
    San Francisco, CA 94102
    \n
    \n
    \n
    Invoice
    \n
    #INV-2024-0042
    \n
    Feb 20, 2026
    \n
    \n
    \n
    \n
    Bill To
    Jane Smith
    456 Client Road
    New York, NY 10001
    \n
    Payment Due
    March 20, 2026
    Payment Method
    Bank Transfer
    \n
    \n \n \n \n \n \n \n \n \n
    DescriptionQtyRateAmount
    Web Development โ€” Landing Page40 hrs$150$6,000
    UI/UX Design โ€” Mockups16 hrs$125$2,000
    API Integration & Testing24 hrs$150$3,600
    Total$11,600
    \n \n\n',report:'\n\n\n\n\n

    Q4 2025 Performance Report

    \n
    Prepared by Analytics Team โ€” February 2026
    \n
    \n
    142%
    Revenue Growth
    \n
    2.4M
    API Calls
    \n
    99.9%
    Uptime
    \n
    \n

    Executive Summary

    \n

    Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

    \n
    \n

    ๐ŸŽฏ Key Achievement

    \n

    Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

    \n
    \n

    Product Updates

    \n

    Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

    \n

    Outlook

    \n

    Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

    \n\n',custom:"\n\n\n\n\n

    Hello World!

    \n

    Edit this HTML and watch the preview update in real time.

    \n

    Then click Generate PDF to download it.

    \n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generatingโ€ฆ";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t\n\n\n\n\n
    \n
    \n
    Acme Corp
    \n
    123 Business Ave, Suite 100
    San Francisco, CA 94102
    \n
    \n
    \n
    Invoice
    \n
    #INV-2024-0042
    \n
    Feb 20, 2026
    \n
    \n
    \n
    \n
    Bill To
    Jane Smith
    456 Client Road
    New York, NY 10001
    \n
    Payment Due
    March 20, 2026
    Payment Method
    Bank Transfer
    \n
    \n \n \n \n \n \n \n \n \n
    DescriptionQtyRateAmount
    Web Development โ€” Landing Page40 hrs$150$6,000
    UI/UX Design โ€” Mockups16 hrs$125$2,000
    API Integration & Testing24 hrs$150$3,600
    Total$11,600
    \n \n\n',report:'\n\n\n\n\n

    Q4 2025 Performance Report

    \n
    Prepared by Analytics Team โ€” February 2026
    \n
    \n
    142%
    Revenue Growth
    \n
    2.4M
    API Calls
    \n
    99.9%
    Uptime
    \n
    \n

    Executive Summary

    \n

    Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

    \n
    \n

    ๐ŸŽฏ Key Achievement

    \n

    Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

    \n
    \n

    Product Updates

    \n

    Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

    \n

    Outlook

    \n

    Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

    \n\n',custom:"\n\n\n\n\n

    Hello World!

    \n

    Edit this HTML and watch the preview update in real time.

    \n

    Then click Generate PDF to download it.

    \n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generatingโ€ฆ";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){"demoDownload"!==e.id&&e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t +
    ${Array(80).fill('
    DEMO โ€” docfast.dev
    ').join("")}
    +
    Generated by DocFast โ€” docfast.dev | Upgrade to Pro for clean PDFs
    `; function injectWatermark(html: string): string { if (html.includes("")) { From c7ee2a8d74259cf97a31a5a3bf0ba5c298bd4cfc Mon Sep 17 00:00:00 2001 From: OpenClawd Date: Fri, 20 Feb 2026 09:59:59 +0000 Subject: [PATCH 020/174] ci: retrigger build From 8777b1fc3dbb118637ab598536163a8b50193495 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 10:01:43 +0000 Subject: [PATCH 021/174] chore: bump version to 0.4.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9395b77..85b2dcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.4.0", + "version": "0.4.1", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { From 6b0d9d8f4062bd14f48b0b6b0668db091b5cc14b Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 10:02:35 +0000 Subject: [PATCH 022/174] fix: use SVG background-repeat for reliable diagonal watermark tiling HTML div tiles were too faint. SVG background pattern renders reliably in Chromium print mode with consistent coverage. --- src/routes/demo.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/routes/demo.ts b/src/routes/demo.ts index 78e968c..fb8a094 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -6,25 +6,10 @@ import logger from "../services/logger.js"; const router = Router(); +const WATERMARK_SVG = `DEMO โ€” docfast.dev`; +const WATERMARK_BG = `data:image/svg+xml,${encodeURIComponent(WATERMARK_SVG)}`; const WATERMARK_HTML = ` - -
    ${Array(80).fill('
    DEMO โ€” docfast.dev
    ').join("")}
    +
    Generated by DocFast โ€” docfast.dev | Upgrade to Pro for clean PDFs
    `; function injectWatermark(html: string): string { From 1d97f5e2aa3fda4ce903d5bf92c0081faab14cc1 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 10:04:45 +0000 Subject: [PATCH 023/174] Add /examples page with code examples for common use cases --- public/examples.html | 359 ++++++++++++++++++++++++++++++++++++++ public/impressum.html | 18 +- public/partials/_nav.html | 1 + public/privacy.html | 18 +- public/sitemap.xml | 1 + public/src/examples.html | 296 +++++++++++++++++++++++++++++++ public/status.html | 1 + public/terms.html | 20 ++- 8 files changed, 692 insertions(+), 22 deletions(-) create mode 100644 public/examples.html create mode 100644 public/src/examples.html diff --git a/public/examples.html b/public/examples.html new file mode 100644 index 0000000..bbf5fd2 --- /dev/null +++ b/public/examples.html @@ -0,0 +1,359 @@ + + + + + +Code Examples โ€” DocFast HTML to PDF API + + + + + + + + + + + + + + + + + + +
    +
    + +
    +

    Code Examples

    +

    Practical examples for generating PDFs with the DocFast API โ€” invoices, reports, receipts, and integration guides.

    +
    + + + + +
    +

    Generate an Invoice PDF

    +

    Create a professional invoice with inline CSS and convert it to PDF with a single API call.

    + +
    + HTML โ€” invoice.html +
    <html>
    +<body style="font-family: sans-serif; padding: 40px; color: #333;">
    +  <div style="display: flex; justify-content: space-between;">
    +    <div>
    +      <h1 style="margin: 0; color: #111;">INVOICE</h1>
    +      <p style="color: #666;">#INV-2026-0042</p>
    +    </div>
    +    <div style="text-align: right;">
    +      <strong>Acme Corp</strong><br>
    +      123 Main St<br>
    +      hello@acme.com
    +    </div>
    +  </div>
    +
    +  <table style="width: 100%; border-collapse: collapse; margin-top: 40px;">
    +    <tr style="border-bottom: 2px solid #111;">
    +      <th style="text-align: left; padding: 8px 0;">Item</th>
    +      <th style="text-align: right; padding: 8px 0;">Qty</th>
    +      <th style="text-align: right; padding: 8px 0;">Price</th>
    +    </tr>
    +    <tr style="border-bottom: 1px solid #eee;">
    +      <td style="padding: 12px 0;">API Pro Plan (monthly)</td>
    +      <td style="text-align: right;">1</td>
    +      <td style="text-align: right;">$49.00</td>
    +    </tr>
    +    <tr>
    +      <td style="padding: 12px 0;">Extra PDF renders (500)</td>
    +      <td style="text-align: right;">500</td>
    +      <td style="text-align: right;">$15.00</td>
    +    </tr>
    +  </table>
    +
    +  <p style="text-align: right; font-size: 1.4em; margin-top: 24px;">
    +    <strong>Total: $64.00</strong>
    +  </p>
    +</body>
    +</html>
    +
    + +
    + curl +
    curl -X POST https://api.docfast.dev/v1/convert/html \
    +  -H "Authorization: Bearer YOUR_API_KEY" \
    +  -H "Content-Type: application/json" \
    +  -d '{"html": "<html>...your invoice HTML...</html>"}' \
    +  --output invoice.pdf
    +
    +
    + + +
    +

    Convert Markdown to PDF

    +

    Send Markdown content directly โ€” DocFast renders it with clean typography and outputs a styled PDF.

    + +
    + curl +
    curl -X POST https://api.docfast.dev/v1/convert/markdown \
    +  -H "Authorization: Bearer YOUR_API_KEY" \
    +  -H "Content-Type: application/json" \
    +  -d '{
    +    "markdown": "# Project Report\n\n## Summary\n\nQ4 revenue grew **32%** year-over-year.\n\n## Key Metrics\n\n| Metric | Value |\n|--------|-------|\n| Revenue | $1.2M |\n| Users | 45,000 |\n| Uptime | 99.97% |\n\n## Next Steps\n\n1. Launch mobile SDK\n2. Expand EU infrastructure\n3. SOC 2 certification"
    +  }' \
    +  --output report.pdf
    +
    +
    + + +
    +

    HTML Report with Charts

    +

    Embed inline SVG charts in your HTML for data-driven reports โ€” no JavaScript or external libraries needed.

    + +
    + HTML โ€” report with SVG bar chart +
    <html>
    +<body style="font-family: sans-serif; padding: 40px;">
    +  <h1>Quarterly Revenue</h1>
    +
    +  <svg width="400" height="200" viewBox="0 0 400 200">
    +    <!-- Bars -->
    +    <rect x="20"  y="120" width="60" height="80"  fill="#34d399"/>
    +    <rect x="110" y="80"  width="60" height="120" fill="#34d399"/>
    +    <rect x="200" y="50"  width="60" height="150" fill="#34d399"/>
    +    <rect x="290" y="20"  width="60" height="180" fill="#34d399"/>
    +    <!-- Labels -->
    +    <text x="50"  y="115" text-anchor="middle" font-size="12">$80k</text>
    +    <text x="140" y="75"  text-anchor="middle" font-size="12">$120k</text>
    +    <text x="230" y="45"  text-anchor="middle" font-size="12">$150k</text>
    +    <text x="320" y="15"  text-anchor="middle" font-size="12">$180k</text>
    +  </svg>
    +</body>
    +</html>
    +
    + +
    + curl +
    curl -X POST https://api.docfast.dev/v1/convert/html \
    +  -H "Authorization: Bearer YOUR_API_KEY" \
    +  -H "Content-Type: application/json" \
    +  -d @report.json \
    +  --output chart-report.pdf
    +
    +
    + + +
    +

    Receipt / Confirmation PDF

    +

    Generate a simple receipt or order confirmation โ€” perfect for e-commerce and SaaS billing.

    + +
    + HTML โ€” receipt template +
    <html>
    +<body style="font-family: sans-serif; max-width: 400px; margin: 0 auto; padding: 40px;">
    +  <div style="text-align: center; margin-bottom: 24px;">
    +    <h2 style="margin: 0;">Payment Receipt</h2>
    +    <p style="color: #888;">Feb 20, 2026</p>
    +  </div>
    +
    +  <hr style="border: none; border-top: 1px dashed #ccc;">
    +
    +  <p><strong>Order:</strong> #ORD-98712</p>
    +  <p><strong>Customer:</strong> jane@example.com</p>
    +
    +  <table style="width: 100%; margin: 16px 0;">
    +    <tr>
    +      <td>Pro Plan</td>
    +      <td style="text-align: right;">$29.00</td>
    +    </tr>
    +    <tr>
    +      <td>Tax</td>
    +      <td style="text-align: right;">$2.90</td>
    +    </tr>
    +  </table>
    +
    +  <hr style="border: none; border-top: 1px dashed #ccc;">
    +
    +  <p style="text-align: right; font-size: 1.3em;">
    +    <strong>Total: $31.90</strong>
    +  </p>
    +  <p style="text-align: center; color: #34d399; margin-top: 24px;">
    +    โœ“ Payment successful
    +  </p>
    +</body>
    +</html>
    +
    +
    + + +
    +

    Node.js Integration

    +

    A complete Node.js script to generate a PDF and save it to disk. Works with Node 18+ using native fetch.

    + +
    + JavaScript โ€” generate-pdf.mjs +
    const html = `
    +  <h1>Hello from Node.js</h1>
    +  <p>Generated at ${new Date().toISOString()}</p>
    +`;
    +
    +const res = await fetch("https://api.docfast.dev/v1/convert/html", {
    +  method: "POST",
    +  headers: {
    +    "Authorization": `Bearer ${process.env.DOCFAST_API_KEY}`,
    +    "Content-Type": "application/json",
    +  },
    +  body: JSON.stringify({ html }),
    +});
    +
    +if (!res.ok) throw new Error(`API error: ${res.status}`);
    +
    +const buffer = Buffer.from(await res.arrayBuffer());
    +await import("fs").then(fs =>
    +  fs.writeFileSync("output.pdf", buffer)
    +);
    +
    +console.log("โœ“ Saved output.pdf");
    +
    +
    + + +
    +

    Python Integration

    +

    Generate a PDF from Python using the requests library. Drop this into any Flask, Django, or FastAPI app.

    + +
    + Python โ€” generate_pdf.py +
    import os
    +import requests
    +
    +html = """
    +<h1>Hello from Python</h1>
    +<p>This PDF was generated via the DocFast API.</p>
    +<ul>
    +  <li>Fast rendering</li>
    +  <li>Pixel-perfect output</li>
    +  <li>Simple REST API</li>
    +</ul>
    +"""
    +
    +response = requests.post(
    +    "https://api.docfast.dev/v1/convert/html",
    +    headers={
    +        "Authorization": f"Bearer {os.environ['DOCFAST_API_KEY']}",
    +        "Content-Type": "application/json",
    +    },
    +    json={"html": html},
    +)
    +
    +response.raise_for_status()
    +
    +with open("output.pdf", "wb") as f:
    +    f.write(response.content)
    +
    +print("โœ“ Saved output.pdf")
    +
    +
    + +
    +
    + + + + + diff --git a/public/impressum.html b/public/impressum.html index 5907197..191cf16 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - + -
    +

    Impressum

    Legal notice according to ยง 5 ECG and ยง 25 MedienG (Austrian law)

    @@ -103,8 +108,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
    diff --git a/public/privacy.html b/public/privacy.html index a0d2055..43ae51b 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - + -
    +

    Privacy Policy

    Last updated: February 16, 2026

    @@ -185,8 +190,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
    diff --git a/public/terms.html b/public/terms.html index b950ea7..33a777c 100644 --- a/public/terms.html +++ b/public/terms.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - + -
    +

    Terms of Service

    Last updated: February 16, 2026

    @@ -145,7 +150,7 @@ footer .container { display: flex; justify-content: space-between; align-items:

    5.2 Performance

    @@ -257,8 +262,7 @@ footer .container { display: flex; justify-content: space-between; align-items:

    Python Integration

    -

    Generate a PDF from Python using the requests library. Drop this into any Flask, Django, or FastAPI app.

    +

    Install the official SDK: pip install docfast

    - Python โ€” generate_pdf.py -
    import os
    -import requests
    +        Python โ€” Using the SDK (recommended)
    +        
    from docfast import DocFast
     
    -html = """
    -<h1>Hello from Python</h1>
    -<p>This PDF was generated via the DocFast API.</p>
    -<ul>
    -  <li>Fast rendering</li>
    -  <li>Pixel-perfect output</li>
    -  <li>Simple REST API</li>
    -</ul>
    -"""
    +client = DocFast("df_pro_your_api_key")
    +
    +# HTML to PDF
    +pdf = client.html("<h1>Hello World</h1>")
    +with open("output.pdf", "wb") as f:
    +    f.write(pdf)
    +
    +# With options
    +pdf = client.html(html, format="A4", landscape=True)
    +
    +# Async support
    +from docfast import AsyncDocFast
    +
    +async with AsyncDocFast("df_pro_your_api_key") as client:
    +    pdf = await client.html("<h1>Hello</h1>")
    +
    + +
    + Python โ€” Using requests (no SDK) +
    import requests
     
     response = requests.post(
    -    "https://api.docfast.dev/v1/convert/html",
    -    headers={
    -        "Authorization": f"Bearer {os.environ['DOCFAST_API_KEY']}",
    -        "Content-Type": "application/json",
    -    },
    -    json={"html": html},
    +    "https://docfast.dev/v1/convert/html",
    +    headers={"Authorization": f"Bearer {api_key}"},
    +    json={"html": "<h1>Hello</h1>"},
     )
    -
    -response.raise_for_status()
    -
    -with open("output.pdf", "wb") as f:
    -    f.write(response.content)
    -
    -print("โœ“ Saved output.pdf")
    +pdf = response.content
    diff --git a/public/index.html b/public/index.html index a160313..6628c13 100644 --- a/public/index.html +++ b/public/index.html @@ -402,7 +402,7 @@ html, body {

    Everything you need

    -

    A complete PDF generation API. No SDKs, no dependencies, no setup.

    +

    A complete PDF generation API. Official SDKs for Node.js & Python, or just use curl.

    From 7ab371a40b8ddb3b562204cd0ce6fe1a97272797 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 21 Feb 2026 07:02:20 +0000 Subject: [PATCH 034/174] Update landing page copy: replace 'No SDKs' with SDK availability messaging --- public/src/index.html | 2 +- templates/pages/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/src/index.html b/public/src/index.html index a160313..d93794c 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -402,7 +402,7 @@ html, body {

    Everything you need

    -

    A complete PDF generation API. No SDKs, no dependencies, no setup.

    +

    Official SDKs for Node.js and Python. Or just use curl.

    diff --git a/templates/pages/index.html b/templates/pages/index.html index 5003e78..b9ee550 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -402,7 +402,7 @@ html, body {

    Everything you need

    -

    A complete PDF generation API. No SDKs, no dependencies, no setup.

    +

    Official SDKs for Node.js and Python. Or just use curl.

    From a5f3683e3074e93332c4606fdbff7a20d80eabe0 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 21 Feb 2026 07:03:27 +0000 Subject: [PATCH 035/174] Build pages with updated SDK messaging --- public/impressum.html | 18 +++++++----------- public/index.html | 2 +- public/privacy.html | 18 +++++++----------- public/terms.html | 20 ++++++++------------ 4 files changed, 23 insertions(+), 35 deletions(-) diff --git a/public/impressum.html b/public/impressum.html index 191cf16..5907197 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -8,9 +8,6 @@ - - - @@ -26,7 +23,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -50,15 +47,14 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } - -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } +/* Skip to content */ +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } .skip-link:focus { top: 0; } + -
    -
    +

    Impressum

    Legal notice according to ยง 5 ECG and ยง 25 MedienG (Austrian law)

    @@ -108,7 +103,8 @@ footer .container { display: flex; justify-content: space-between; align-items:
    From f17b483682a24e2ec42641f6e159b6db99b14ec2 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sun, 22 Feb 2026 16:02:32 +0000 Subject: [PATCH 048/174] fix: correct Pro plan description from 'unlimited' to '5,000/month' --- public/index.html | 4 ++-- public/src/index.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/index.html b/public/index.html index fcb2227..95c75d9 100644 --- a/public/index.html +++ b/public/index.html @@ -16,7 +16,7 @@ + diff --git a/public/openapi.json b/public/openapi.json index 2192f5d..9e26dfe 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -1,1052 +1 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "DocFast API", - "version": "1.0.0", - "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Demo Endpoints\nTry the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs.\n\n## Rate Limits\n- Demo: 5 PDFs/hour per IP (watermarked)\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo at `POST /v1/demo/html` โ€” no signup needed\n2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs\n3. Use your API key to convert documents", - "contact": { - "name": "DocFast", - "url": "https://docfast.dev", - "email": "support@docfast.dev" - } - }, - "servers": [ - { - "url": "https://docfast.dev", - "description": "Production" - } - ], - "tags": [ - { - "name": "Demo", - "description": "Try the API without signing up โ€” watermarked PDFs, rate-limited" - }, - { - "name": "Conversion", - "description": "Convert HTML, Markdown, or URLs to PDF (requires API key)" - }, - { - "name": "Templates", - "description": "Built-in document templates" - }, - { - "name": "Account", - "description": "Key recovery and email management" - }, - { - "name": "Billing", - "description": "Stripe-powered subscription management" - }, - { - "name": "System", - "description": "Health checks and usage stats" - } - ], - "components": { - "securitySchemes": { - "BearerAuth": { - "type": "http", - "scheme": "bearer", - "description": "API key as Bearer token" - }, - "ApiKeyHeader": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "API key via X-API-Key header" - } - }, - "schemas": { - "PdfOptions": { - "type": "object", - "properties": { - "format": { - "type": "string", - "enum": [ - "A4", - "Letter", - "Legal", - "A3", - "A5", - "Tabloid" - ], - "default": "A4", - "description": "Page size" - }, - "landscape": { - "type": "boolean", - "default": false, - "description": "Landscape orientation" - }, - "margin": { - "type": "object", - "properties": { - "top": { - "type": "string", - "description": "Top margin (e.g. \"10mm\", \"1in\")", - "default": "0" - }, - "right": { - "type": "string", - "description": "Right margin", - "default": "0" - }, - "bottom": { - "type": "string", - "description": "Bottom margin", - "default": "0" - }, - "left": { - "type": "string", - "description": "Left margin", - "default": "0" - } - }, - "description": "Page margins" - }, - "printBackground": { - "type": "boolean", - "default": true, - "description": "Print background colors and images" - }, - "filename": { - "type": "string", - "description": "Custom filename for Content-Disposition header", - "default": "document.pdf" - } - } - }, - "Error": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Error message" - } - }, - "required": [ - "error" - ] - } - } - }, - "paths": { - "/v1/billing/checkout": { - "post": { - "tags": [ - "Billing" - ], - "summary": "Create a Stripe checkout session", - "description": "Creates a Stripe Checkout session for a Pro subscription (โ‚ฌ9/month).\nReturns a URL to redirect the user to Stripe's hosted payment page.\nRate limited to 3 requests per hour per IP.\n", - "responses": { - "200": { - "description": "Checkout session created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "Stripe Checkout URL to redirect the user to" - } - } - } - } - } - }, - "413": { - "description": "Request body too large" - }, - "429": { - "description": "Too many checkout requests" - }, - "500": { - "description": "Failed to create checkout session" - } - } - } - }, - "/v1/convert/html": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert HTML to PDF", - "description": "Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "html" - ], - "properties": { - "html": { - "type": "string", - "description": "HTML content to convert. Can be a full document or a fragment.", - "example": "

    Hello World

    My first PDF

    " - }, - "css": { - "type": "string", - "description": "Optional CSS to inject (only used when html is a fragment, not a full document)", - "example": "body { font-family: sans-serif; padding: 40px; }" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing html field" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type (must be application/json)" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/convert/markdown": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert Markdown to PDF", - "description": "Converts Markdown content to HTML and then to a PDF document.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "markdown" - ], - "properties": { - "markdown": { - "type": "string", - "description": "Markdown content to convert", - "example": "# Hello World\\n\\nThis is **bold** and *italic*." - }, - "css": { - "type": "string", - "description": "Optional CSS to inject into the rendered HTML" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing markdown field" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/convert/url": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert URL to PDF", - "description": "Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.\nPrivate/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.\n", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "URL to convert (http or https only)", - "example": "https://example.com" - }, - "waitUntil": { - "type": "string", - "enum": [ - "load", - "domcontentloaded", - "networkidle0", - "networkidle2" - ], - "default": "domcontentloaded", - "description": "When to consider navigation finished" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing/invalid URL or URL resolves to private IP" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/demo/html": { - "post": { - "tags": [ - "Demo" - ], - "summary": "Convert HTML to PDF (demo)", - "description": "Public endpoint โ€” no API key required. Rate limited to 5 requests per hour per IP.\nOutput PDFs include a DocFast watermark. Upgrade to Pro for clean output.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "html" - ], - "properties": { - "html": { - "type": "string", - "description": "HTML content to convert", - "example": "

    Hello World

    My first PDF

    " - }, - "css": { - "type": "string", - "description": "Optional CSS to inject (used when html is a fragment)" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Watermarked PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing html field", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Demo rate limit exceeded (5/hour)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "Server busy" - }, - "504": { - "description": "PDF generation timed out" - } - } - } - }, - "/v1/demo/markdown": { - "post": { - "tags": [ - "Demo" - ], - "summary": "Convert Markdown to PDF (demo)", - "description": "Public endpoint โ€” no API key required. Rate limited to 5 requests per hour per IP.\nMarkdown is converted to HTML then rendered to PDF with a DocFast watermark.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "markdown" - ], - "properties": { - "markdown": { - "type": "string", - "description": "Markdown content to convert", - "example": "# Hello World\\n\\nThis is **bold** and *italic*." - }, - "css": { - "type": "string", - "description": "Optional CSS to inject" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Watermarked PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing markdown field" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Demo rate limit exceeded (5/hour)" - }, - "503": { - "description": "Server busy" - }, - "504": { - "description": "PDF generation timed out" - } - } - } - }, - "/health": { - "get": { - "tags": [ - "System" - ], - "summary": "Health check", - "description": "Returns service health status including database connectivity and browser pool stats.", - "responses": { - "200": { - "description": "Service is healthy", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "ok", - "degraded" - ] - }, - "version": { - "type": "string", - "example": "0.4.0" - }, - "database": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "ok", - "error" - ] - }, - "version": { - "type": "string", - "example": "PostgreSQL 17.4" - } - } - }, - "pool": { - "type": "object", - "properties": { - "size": { - "type": "integer" - }, - "active": { - "type": "integer" - }, - "available": { - "type": "integer" - }, - "queueDepth": { - "type": "integer" - }, - "pdfCount": { - "type": "integer" - }, - "restarting": { - "type": "boolean" - }, - "uptimeSeconds": { - "type": "integer" - } - } - } - } - } - } - } - }, - "503": { - "description": "Service is degraded (database issue)" - } - } - } - }, - "/v1/recover": { - "post": { - "tags": [ - "Account" - ], - "summary": "Request API key recovery", - "description": "Sends a 6-digit verification code to the email address if an account exists.\nResponse is always the same regardless of whether the email exists (to prevent enumeration).\nRate limited to 3 requests per hour.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string", - "format": "email", - "description": "Email address associated with the API key" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Recovery code sent (or no-op if email not found)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "recovery_sent" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Invalid email format" - }, - "429": { - "description": "Too many recovery attempts" - } - } - } - }, - "/v1/recover/verify": { - "post": { - "tags": [ - "Account" - ], - "summary": "Verify recovery code and retrieve API key", - "description": "Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email", - "code" - ], - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "code": { - "type": "string", - "pattern": "^\\d{6}$", - "description": "6-digit verification code" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "API key recovered", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "recovered" - }, - "apiKey": { - "type": "string", - "description": "The recovered API key" - }, - "tier": { - "type": "string", - "enum": [ - "free", - "pro" - ] - } - } - } - } - } - }, - "400": { - "description": "Invalid verification code or missing fields" - }, - "410": { - "description": "Verification code expired" - }, - "429": { - "description": "Too many failed attempts" - } - } - } - }, - "/v1/templates": { - "get": { - "tags": [ - "Templates" - ], - "summary": "List available templates", - "description": "Returns a list of all built-in document templates with their required fields.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "responses": { - "200": { - "description": "List of templates", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "templates": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "invoice" - }, - "name": { - "type": "string", - "example": "Invoice" - }, - "description": { - "type": "string" - }, - "fields": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - } - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - } - } - } - }, - "/v1/templates/{id}/render": { - "post": { - "tags": [ - "Templates" - ], - "summary": "Render a template to PDF", - "description": "Renders a built-in template with the provided data and returns a PDF.\nUse GET /v1/templates to see available templates and their required fields.\nSpecial fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).\n", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - }, - "description": "Template ID (e.g. \"invoice\", \"receipt\")" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "description": "Template data (fields depend on template). Can also be passed at root level." - }, - "_format": { - "type": "string", - "enum": [ - "A4", - "Letter", - "Legal", - "A3", - "A5", - "Tabloid" - ], - "default": "A4", - "description": "Page size override" - }, - "_margin": { - "type": "object", - "properties": { - "top": { - "type": "string" - }, - "right": { - "type": "string" - }, - "bottom": { - "type": "string" - }, - "left": { - "type": "string" - } - }, - "description": "Page margin override" - }, - "_filename": { - "type": "string", - "description": "Custom output filename" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing required template fields" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "404": { - "description": "Template not found" - }, - "500": { - "description": "Template rendering failed" - } - } - } - }, - "/v1/signup/free": { - "post": { - "tags": [ - "Account" - ], - "summary": "Free signup (discontinued)", - "description": "Free accounts have been discontinued. Use the demo endpoint for testing\nor subscribe to Pro for production use.\n", - "deprecated": true, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - } - } - } - } - }, - "responses": { - "410": { - "description": "Free accounts discontinued", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "Free accounts have been discontinued." - }, - "demo_endpoint": { - "type": "string", - "example": "/v1/demo/html" - }, - "pro_url": { - "type": "string", - "example": "https://docfast.dev/#pricing" - } - } - } - } - } - } - } - } - }, - "/v1/usage": { - "get": { - "tags": [ - "System" - ], - "summary": "Usage statistics (admin only)", - "description": "Returns usage statistics for the authenticated user. Requires admin API key.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "responses": { - "200": { - "description": "Usage statistics", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "month": { - "type": "string" - } - } - } - } - } - } - }, - "403": { - "description": "Admin access required" - }, - "503": { - "description": "Admin access not configured" - } - } - } - } - } -} \ No newline at end of file +{} \ No newline at end of file diff --git a/public/src/index.html b/public/src/index.html index 7b02099..b8a3d8a 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -672,6 +672,6 @@ html, body {
    - + diff --git a/public/src/status.html b/public/src/status.html index f616ed9..9a1fcd2 100644 --- a/public/src/status.html +++ b/public/src/status.html @@ -49,6 +49,6 @@ {{> footer}} - + diff --git a/public/status.html b/public/status.html index c7288eb..b13374c 100644 --- a/public/status.html +++ b/public/status.html @@ -112,6 +112,6 @@ footer .container { display: flex; justify-content: space-between; align-items:
    - + diff --git a/public/status.js b/public/status.js index bd8a0b2..40617dd 100644 --- a/public/status.js +++ b/public/status.js @@ -1,48 +1 @@ -async function fetchStatus() { - const el = document.getElementById("status-content"); - try { - const res = await fetch("/health"); - const d = await res.json(); - const isOk = d.status === "ok"; - const isDegraded = d.status === "degraded"; - const dotClass = isOk ? "ok" : isDegraded ? "degraded" : "error"; - const label = isOk ? "All Systems Operational" : isDegraded ? "Degraded Performance" : "Service Disruption"; - const now = new Date().toLocaleTimeString(); - - el.innerHTML = - "
    " + - "
    " + label + "
    " + - "
    Version " + d.version + " ยท Last checked " + now + " ยท Auto-refreshes every 30s
    " + - "
    " + - "
    " + - "
    " + - "

    ๐Ÿ—„๏ธ Database

    " + - "
    Status" + (d.database && d.database.status === "ok" ? "Connected" : "Error") + "
    " + - "
    Engine" + (d.database ? d.database.version : "Unknown") + "
    " + - "
    " + - "
    " + - "

    ๐Ÿ–จ๏ธ PDF Engine

    " + - "
    Status 0 ? "ok" : "warn") + "\">" + (d.pool && d.pool.available > 0 ? "Ready" : "Busy") + "
    " + - "
    Available" + (d.pool ? d.pool.available : 0) + " / " + (d.pool ? d.pool.size : 0) + "
    " + - "
    Queue 0 ? "warn" : "ok") + "\">" + (d.pool ? d.pool.queueDepth : 0) + " waiting
    " + - "
    PDFs Generated" + (d.pool ? d.pool.pdfCount.toLocaleString() : "0") + "
    " + - "
    Uptime" + formatUptime(d.pool ? d.pool.uptimeSeconds : 0) + "
    " + - "
    " + - "
    " + - ""; - } catch (e) { - el.innerHTML = "
    Unable to reach API
    The service may be temporarily unavailable. Please try again shortly.
    "; - } -} - -function formatUptime(s) { - if (!s && s !== 0) return "Unknown"; - if (s < 60) return s + "s"; - if (s < 3600) return Math.floor(s/60) + "m " + (s%60) + "s"; - var h = Math.floor(s/3600); - var m = Math.floor((s%3600)/60); - return h + "h " + m + "m"; -} - -fetchStatus(); -setInterval(fetchStatus, 30000); +async function fetchStatus(){const s=document.getElementById("status-content");try{const a=await fetch("/health"),t=await a.json(),e="ok"===t.status,l="degraded"===t.status,o=e?"ok":l?"degraded":"error",n=e?"All Systems Operational":l?"Degraded Performance":"Service Disruption",i=(new Date).toLocaleTimeString();s.innerHTML='
    '+n+'
    Version '+t.version+" ยท Last checked "+i+' ยท Auto-refreshes every 30s

    ๐Ÿ—„๏ธ Database

    Status'+(t.database&&"ok"===t.database.status?"Connected":"Error")+'
    Engine'+(t.database?t.database.version:"Unknown")+'

    ๐Ÿ–จ๏ธ PDF Engine

    Status'+(t.pool&&t.pool.available>0?"Ready":"Busy")+'
    Available'+(t.pool?t.pool.available:0)+" / "+(t.pool?t.pool.size:0)+'
    Queue'+(t.pool?t.pool.queueDepth:0)+' waiting
    PDFs Generated'+(t.pool?t.pool.pdfCount.toLocaleString():"0")+'
    Uptime'+formatUptime(t.pool?t.pool.uptimeSeconds:0)+'
    '}catch(a){s.innerHTML='
    Unable to reach API
    The service may be temporarily unavailable. Please try again shortly.
    '}}function formatUptime(s){return s||0===s?s<60?s+"s":s<3600?Math.floor(s/60)+"m "+s%60+"s":Math.floor(s/3600)+"h "+Math.floor(s%3600/60)+"m":"Unknown"}fetchStatus(),setInterval(fetchStatus,3e4); \ No newline at end of file diff --git a/scripts/build-html.cjs b/scripts/build-html.cjs index 9bdecb2..d3c3188 100644 --- a/scripts/build-html.cjs +++ b/scripts/build-html.cjs @@ -47,18 +47,18 @@ for (const file of files) { } console.log('Done.'); -// JS Minification (requires terser) +// JS Minification (overwrite original files) const { execSync } = require("child_process"); -const jsFiles = [ - { src: "public/app.js", out: "public/app.min.js" }, - { src: "public/status.js", out: "public/status.min.js" }, -]; +const jsFiles = ["public/app.js", "public/status.js"]; console.log("Minifying JS..."); -for (const { src, out } of jsFiles) { - const srcPath = path.join(__dirname, "..", src); - const outPath = path.join(__dirname, "..", out); - if (fs.existsSync(srcPath)) { - execSync(`npx terser ${srcPath} -o ${outPath} -c -m`, { stdio: "inherit" }); - console.log(` Minified: ${src} โ†’ ${out}`); +for (const jsFile of jsFiles) { + const filePath = path.join(__dirname, "..", jsFile); + if (fs.existsSync(filePath)) { + // Create backup, minify, then overwrite original + const backupPath = filePath + ".bak"; + fs.copyFileSync(filePath, backupPath); + execSync(`npx terser ${filePath} -o ${filePath} -c -m`, { stdio: "inherit" }); + fs.unlinkSync(backupPath); // Clean up backup + console.log(` Minified: ${jsFile} (overwritten)`); } } diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 7a0b8ce..5954ea7 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -456,3 +456,152 @@ describe("API root", () => { expect(data.endpoints).toBeInstanceOf(Array); }); }); + +describe("JS minification", () => { + it("serves minified JS files in homepage HTML", async () => { + const res = await fetch(`${BASE}/`); + expect(res.status).toBe(200); + const html = await res.text(); + + // Check that HTML references app.js and status.js + expect(html).toContain('src="/app.js"'); + + // Fetch the JS file and verify it's minified (no excessive whitespace) + const jsRes = await fetch(`${BASE}/app.js`); + expect(jsRes.status).toBe(200); + const jsContent = await jsRes.text(); + + // Minified JS should not have excessive whitespace or comments + // Basic check: line count should be reasonable for minified code + const lineCount = jsContent.split('\n').length; + expect(lineCount).toBeLessThan(50); // Original has ~400+ lines, minified should be much less + + // Should not contain developer comments (/* ... */) + expect(jsContent).not.toMatch(/\/\*[\s\S]*?\*\//); + }); +}); + +describe("Usage endpoint", () => { + it("requires authentication (401 without key)", async () => { + const res = await fetch(`${BASE}/v1/usage`); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBeDefined(); + expect(typeof data.error).toBe("string"); + }); + + it("requires admin key (503 when not configured)", async () => { + const res = await fetch(`${BASE}/v1/usage`, { + headers: { Authorization: "Bearer test-key" }, + }); + expect(res.status).toBe(503); + const data = await res.json(); + expect(data.error).toBeDefined(); + expect(data.error).toContain("Admin access not configured"); + }); + + it("returns usage data with admin key", async () => { + // This test will likely fail since we don't have an admin key set in test environment + // But it documents the expected behavior + const res = await fetch(`${BASE}/v1/usage`, { + headers: { Authorization: "Bearer admin-key" }, + }); + // Could be 503 (admin access not configured) or 403 (admin access required) + expect([403, 503]).toContain(res.status); + }); +}); + +describe("Billing checkout", () => { + it("has rate limiting headers", async () => { + const res = await fetch(`${BASE}/v1/billing/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + // Check rate limit headers are present (express-rate-limit should add these) + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); + + it("fails when Stripe not configured", async () => { + const res = await fetch(`${BASE}/v1/billing/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + // Returns 500 due to missing STRIPE_SECRET_KEY in test environment + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); +}); + +describe("Rate limit headers on PDF endpoints", () => { + it("includes rate limit headers on HTML conversion", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ html: "

    Test

    " }), + }); + + expect(res.status).toBe(200); + + // Check for rate limit headers + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); + + it("includes rate limit headers on demo endpoint", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

    Demo Test

    " }), + }); + + expect(res.status).toBe(200); + + // Check for rate limit headers + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); +}); + +describe("404 handler", () => { + it("returns proper JSON error format for API routes", async () => { + const res = await fetch(`${BASE}/v1/nonexistent-endpoint`); + expect(res.status).toBe(404); + const data = await res.json(); + expect(typeof data.error).toBe("string"); + expect(data.error).toContain("Not Found"); + expect(data.error).toContain("GET"); + expect(data.error).toContain("/v1/nonexistent-endpoint"); + }); + + it("returns HTML 404 for non-API routes", async () => { + const res = await fetch(`${BASE}/nonexistent-page`); + expect(res.status).toBe(404); + const html = await res.text(); + expect(html).toContain(""); + expect(html).toContain("404"); + expect(html).toContain("Page Not Found"); + }); +}); From 288d6c7aab5e26a8d78dc563f9d0d7eb69bc3173 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Wed, 25 Feb 2026 13:04:26 +0000 Subject: [PATCH 061/174] fix: revert swagger-jsdoc to 6.2.8 (7.0.0-rc.6 broke OpenAPI spec generation) + add OpenAPI spec tests swagger-jsdoc 7.0.0-rc.6 returns empty spec (0 paths), breaking /docs and /openapi.json. Reverted to 6.2.8 which correctly generates all 10+ paths. Added 2 regression tests to catch this in CI. --- package-lock.json | 65 ++++++++++++++++++++++++++------------- package.json | 2 +- src/__tests__/api.test.ts | 21 +++++++++++++ 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49e2634..99652fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", - "swagger-jsdoc": "^7.0.0-rc.6", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { @@ -63,9 +63,9 @@ "license": "MIT" }, "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.2.tgz", - "integrity": "sha512-JFxcEyp8RlNHgBCE98nwuTkZT6eNFPc1aosWV6wPcQph72TSEEu1k3baJD4/x1qznU+JiDdz8F5pTwabZh+Dhg==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^9.0.6", @@ -73,7 +73,7 @@ "@apidevtools/swagger-methods": "^3.0.2", "@jsdevtools/ono": "^7.1.3", "call-me-maybe": "^1.0.1", - "z-schema": "^4.2.3" + "z-schema": "^5.0.1" }, "peerDependencies": { "openapi-types": ">=7" @@ -1713,7 +1713,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/compressible": { @@ -4002,21 +4002,34 @@ } }, "node_modules/swagger-jsdoc": { - "version": "7.0.0-rc.6", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-7.0.0-rc.6.tgz", - "integrity": "sha512-LIvIPQxipRaOzIij+HrWOcCWTINE6OeJuqmXCfDkofVcstPVABHRkaAc3D7vrX9s7L0ccH0sH0amwHgN6+SXPg==", + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", "license": "MIT", "dependencies": { + "commander": "6.2.0", "doctrine": "3.0.0", "glob": "7.1.6", - "lodash.mergewith": "4.6.2", - "swagger-parser": "10.0.2", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", "yaml": "2.0.0-1" }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, "engines": { "node": ">=12.0.0" } }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/swagger-jsdoc/node_modules/yaml": { "version": "2.0.0-1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", @@ -4027,12 +4040,12 @@ } }, "node_modules/swagger-parser": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.2.tgz", - "integrity": "sha512-9jHkHM+QXyLGFLk1DkXBwV+4HyNm0Za3b8/zk/+mjr8jgOSiqm3FOTHBSDsBjtn9scdL+8eWcHdupp2NLM8tDw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", "license": "MIT", "dependencies": { - "@apidevtools/swagger-parser": "10.0.2" + "@apidevtools/swagger-parser": "10.0.3" }, "engines": { "node": ">=10" @@ -4644,23 +4657,33 @@ } }, "node_modules/z-schema": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-4.2.4.tgz", - "integrity": "sha512-YvBeW5RGNeNzKOUJs3rTL4+9rpcvHXt5I051FJbOcitV8bl40pEfcG0Q+dWSwS0/BIYrMZ/9HHoqLllMkFhD0w==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", "license": "MIT", "dependencies": { "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", - "validator": "^13.6.0" + "validator": "^13.7.0" }, "bin": { "z-schema": "bin/z-schema" }, "engines": { - "node": ">=6.0.0" + "node": ">=8.0.0" }, "optionalDependencies": { - "commander": "^2.7.1" + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" } }, "node_modules/zod": { diff --git a/package.json b/package.json index bffa2a5..e9db8d0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", - "swagger-jsdoc": "^7.0.0-rc.6", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 5954ea7..d173297 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -585,6 +585,27 @@ describe("Rate limit headers on PDF endpoints", () => { }); }); +describe("OpenAPI spec", () => { + it("returns a valid OpenAPI 3.0 spec with paths", async () => { + const res = await fetch(`${BASE}/openapi.json`); + expect(res.status).toBe(200); + const spec = await res.json(); + expect(spec.openapi).toBe("3.0.3"); + expect(spec.info).toBeDefined(); + expect(spec.info.title).toBe("DocFast API"); + expect(Object.keys(spec.paths).length).toBeGreaterThanOrEqual(8); + }); + + it("includes all major endpoint groups", async () => { + const res = await fetch(`${BASE}/openapi.json`); + const spec = await res.json(); + const paths = Object.keys(spec.paths); + expect(paths).toContain("/v1/convert/html"); + expect(paths).toContain("/v1/convert/markdown"); + expect(paths).toContain("/health"); + }); +}); + describe("404 handler", () => { it("returns proper JSON error format for API routes", async () => { const res = await fetch(`${BASE}/v1/nonexistent-endpoint`); From c4fea7932c72470950c80d90b1eeabed97856a84 Mon Sep 17 00:00:00 2001 From: DocFast Dev Date: Wed, 25 Feb 2026 13:10:32 +0000 Subject: [PATCH 062/174] feat: add unhandled error handlers + SSRF and Content-Disposition tests --- src/__tests__/api.test.ts | 22 ++++++++++++++++++++++ src/index.ts | 10 ++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index d173297..0645f00 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -197,6 +197,28 @@ describe("URL to PDF", () => { expect(data.error).toContain("private"); }); + it("blocks 0.0.0.0 (SSRF protection)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://0.0.0.0" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("private"); + }); + + it("returns default filename in Content-Disposition for /convert/html", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

    hello

    " }), + }); + expect(res.status).toBe(200); + const disposition = res.headers.get("content-disposition"); + expect(disposition).toContain('filename="document.pdf"'); + }); + it("rejects invalid protocol (ftp)", async () => { const res = await fetch(`${BASE}/v1/convert/url`, { method: "POST", diff --git a/src/index.ts b/src/index.ts index 2d8cd94..a0499b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -422,6 +422,16 @@ async function start() { }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); + + process.on("uncaughtException", (err) => { + logger.fatal({ err }, "Uncaught exception โ€” shutting down"); + process.exit(1); + }); + + process.on("unhandledRejection", (reason) => { + logger.fatal({ err: reason }, "Unhandled rejection โ€” shutting down"); + process.exit(1); + }); } if (process.env.NODE_ENV !== "test") { From 0a002f94efbb52743d77eec38ab4ed6ce2050189 Mon Sep 17 00:00:00 2001 From: Hoid Date: Wed, 25 Feb 2026 16:04:22 +0000 Subject: [PATCH 063/174] refactor: deduplicate sanitizeFilename, add template+sanitize unit tests, fix esc single-quote --- src/__tests__/sanitize.test.ts | 24 ++++++++++++++ src/__tests__/templates.test.ts | 57 +++++++++++++++++++++++++++++++++ src/routes/convert.ts | 5 +-- src/routes/templates.ts | 5 +-- src/services/templates.ts | 3 +- src/utils/sanitize.ts | 4 +++ 6 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/sanitize.test.ts create mode 100644 src/__tests__/templates.test.ts create mode 100644 src/utils/sanitize.ts diff --git a/src/__tests__/sanitize.test.ts b/src/__tests__/sanitize.test.ts new file mode 100644 index 0000000..550ecd0 --- /dev/null +++ b/src/__tests__/sanitize.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeFilename } from "../utils/sanitize.js"; + +describe("sanitizeFilename", () => { + it("passes normal filename through", () => { + expect(sanitizeFilename("report.pdf")).toBe("report.pdf"); + }); + it("replaces control characters", () => { + expect(sanitizeFilename("file\x00name.pdf")).toBe("file_name.pdf"); + }); + it("replaces quotes", () => { + expect(sanitizeFilename('file"name.pdf')).toBe("file_name.pdf"); + }); + it("returns default for empty string", () => { + expect(sanitizeFilename("")).toBe("document.pdf"); + }); + it("truncates to 200 characters", () => { + const long = "a".repeat(250) + ".pdf"; + expect(sanitizeFilename(long).length).toBeLessThanOrEqual(200); + }); + it("supports custom default name", () => { + expect(sanitizeFilename("", "invoice.pdf")).toBe("invoice.pdf"); + }); +}); diff --git a/src/__tests__/templates.test.ts b/src/__tests__/templates.test.ts new file mode 100644 index 0000000..52ca00b --- /dev/null +++ b/src/__tests__/templates.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { renderTemplate, templates } from "../services/templates.js"; + +// Access esc via rendering โ€” test that HTML entities are escaped in output +describe("Template rendering", () => { + it("throws for unknown template", () => { + expect(() => renderTemplate("nonexistent", {})).toThrow("not found"); + }); + + it("invoice renders with correct totals", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-001", + date: "2026-01-01", + from: { name: "Seller" }, + to: { name: "Buyer" }, + items: [{ description: "Widget", quantity: 2, unitPrice: 10, taxRate: 20 }], + }); + expect(html).toContain("INV-001"); + expect(html).toContain("โ‚ฌ20.00"); // subtotal: 2*10 + expect(html).toContain("โ‚ฌ4.00"); // tax: 20*0.2 + expect(html).toContain("โ‚ฌ24.00"); // total + }); + + it("receipt renders with correct total", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-001", + date: "2026-01-01", + from: { name: "Shop" }, + items: [{ description: "Item A", amount: 15 }, { description: "Item B", amount: 25 }], + }); + expect(html).toContain("R-001"); + expect(html).toContain("โ‚ฌ40.00"); + }); + + it("defaults currency to โ‚ฌ", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "X", date: "2026-01-01", + from: { name: "A" }, to: { name: "B" }, + items: [{ description: "Test", quantity: 1, unitPrice: 5 }], + }); + expect(html).toContain("โ‚ฌ5.00"); + }); + + it("escapes HTML entities including single quotes", () => { + const html = renderTemplate("invoice", { + invoiceNumber: '', + date: "2026-01-01", + from: { name: "O'Brien & Co" }, + to: { name: "Bob" }, + items: [{ description: "Test", quantity: 1, unitPrice: 1 }], + }); + expect(html).not.toContain(" +`; } // Landing page const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -222,6 +235,11 @@ 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, @@ -316,6 +334,16 @@ async function start() { await initBrowser(); logger.info(`Loaded ${getAllKeys().length} API keys`); const server = app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`)); + // Run database cleanup 30 seconds after startup (non-blocking) + setTimeout(async () => { + try { + logger.info("Running scheduled database cleanup..."); + await cleanupStaleData(); + } + catch (err) { + logger.error({ err }, "Startup cleanup failed (non-fatal)"); + } + }, 30_000); let shuttingDown = false; const shutdown = async (signal) => { if (shuttingDown) @@ -355,9 +383,19 @@ async function start() { }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); + process.on("uncaughtException", (err) => { + logger.fatal({ err }, "Uncaught exception โ€” shutting down"); + process.exit(1); + }); + process.on("unhandledRejection", (reason) => { + logger.fatal({ err: reason }, "Unhandled rejection โ€” shutting down"); + process.exit(1); + }); +} +if (process.env.NODE_ENV !== "test") { + start().catch((err) => { + logger.error({ err }, "Failed to start"); + process.exit(1); + }); } -start().catch((err) => { - logger.error({ err }, "Failed to start"); - process.exit(1); -}); export { app }; diff --git a/dist/middleware/pdfRateLimit.js b/dist/middleware/pdfRateLimit.js index 62bba72..d83c0ae 100644 --- a/dist/middleware/pdfRateLimit.js +++ b/dist/middleware/pdfRateLimit.js @@ -29,17 +29,33 @@ function checkRateLimit(apiKey) { const limit = getRateLimit(apiKey); const entry = rateLimitStore.get(apiKey); if (!entry || now >= entry.resetTime) { + const resetTime = now + RATE_WINDOW_MS; rateLimitStore.set(apiKey, { count: 1, - resetTime: now + RATE_WINDOW_MS + resetTime }); - return true; + return { + allowed: true, + limit, + remaining: limit - 1, + resetTime + }; } if (entry.count >= limit) { - return false; + return { + allowed: false, + limit, + remaining: 0, + resetTime: entry.resetTime + }; } entry.count++; - return true; + return { + allowed: true, + limit, + remaining: limit - entry.count, + resetTime: entry.resetTime + }; } function getQueuedCountForKey(apiKey) { return pdfQueue.filter(w => w.apiKey === apiKey).length; @@ -73,10 +89,16 @@ export function pdfRateLimitMiddleware(req, res, next) { const keyInfo = req.apiKeyInfo; const apiKey = keyInfo?.key || "unknown"; // Check rate limit first - if (!checkRateLimit(apiKey)) { - const limit = getRateLimit(apiKey); + const rateLimitResult = checkRateLimit(apiKey); + // Set rate limit headers on ALL responses + res.set('X-RateLimit-Limit', String(rateLimitResult.limit)); + res.set('X-RateLimit-Remaining', String(rateLimitResult.remaining)); + res.set('X-RateLimit-Reset', String(Math.ceil(rateLimitResult.resetTime / 1000))); + if (!rateLimitResult.allowed) { const tier = isProKey(apiKey) ? "pro" : "free"; - res.status(429).json({ error: `Rate limit exceeded: ${limit} PDFs/min allowed for ${tier} tier. Retry after 60s.` }); + const retryAfterSeconds = Math.ceil((rateLimitResult.resetTime - Date.now()) / 1000); + res.set('Retry-After', String(retryAfterSeconds)); + res.status(429).json({ error: `Rate limit exceeded: ${rateLimitResult.limit} PDFs/min allowed for ${tier} tier. Retry after ${retryAfterSeconds}s.` }); return; } // Add concurrency control to the request (pass apiKey for fairness) diff --git a/dist/routes/billing.js b/dist/routes/billing.js index 761fda1..096d291 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -3,9 +3,7 @@ import rateLimit from "express-rate-limit"; import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js"; import logger from "../services/logger.js"; -function escapeHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); -} +import { escapeHtml } from "../utils/html.js"; let _stripe = null; function getStripe() { if (!_stripe) { @@ -103,6 +101,36 @@ router.post("/checkout", checkoutLimiter, async (req, res) => { res.status(500).json({ error: "Failed to create checkout session" }); } }); +/** + * @openapi + * /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. + * Called by Stripe redirect after payment completion. + * 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 + */ // Success page โ€” provision Pro API key after checkout router.get("/success", async (req, res) => { const sessionId = req.query.session_id; @@ -161,17 +189,60 @@ a { color: #4f9; }

    ๐ŸŽ‰ Welcome to Pro!

    Your API key:

    -
    ${escapeHtml(keyInfo.key)}
    +
    ${escapeHtml(keyInfo.key)}

    Save this key! It won't be shown again.

    5,000 PDFs/month โ€ข All endpoints โ€ข Priority support

    View API docs โ†’

    -
    `); +
    + +`); } catch (err) { logger.error({ err }, "Success page error"); res.status(500).json({ error: "Failed to retrieve session" }); } }); +/** + * @openapi + * /v1/billing/webhook: + * post: + * tags: [Billing] + * summary: Stripe webhook endpoint + * description: | + * Receives Stripe webhook events for subscription lifecycle management. + * Requires the raw request body and a valid Stripe-Signature header for verification. + * Handles checkout.session.completed, customer.subscription.updated, + * customer.subscription.deleted, and customer.updated events. + * 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 + */ // Stripe webhook for subscription lifecycle events router.post("/webhook", async (req, res) => { const sig = req.headers["stripe-signature"]; diff --git a/dist/routes/convert.js b/dist/routes/convert.js index 0aa9c50..6d029de 100644 --- a/dist/routes/convert.js +++ b/dist/routes/convert.js @@ -3,43 +3,8 @@ 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 net from "node:net"; -function isPrivateIP(ip) { - // IPv6 loopback/unspecified - if (ip === "::1" || ip === "::") - return true; - // IPv6 link-local (fe80::/10) - if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") || - ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb")) - return true; - // IPv6 unique local (fc00::/7) - const lower = ip.toLowerCase(); - if (lower.startsWith("fc") || lower.startsWith("fd")) - return true; - // IPv4-mapped IPv6 - if (ip.startsWith("::ffff:")) - ip = ip.slice(7); - if (!net.isIPv4(ip)) - return false; - const parts = ip.split(".").map(Number); - if (parts[0] === 0) - return true; // 0.0.0.0/8 - if (parts[0] === 10) - return true; // 10.0.0.0/8 - if (parts[0] === 127) - return true; // 127.0.0.0/8 - if (parts[0] === 169 && parts[1] === 254) - return true; // 169.254.0.0/16 - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) - return true; // 172.16.0.0/12 - if (parts[0] === 192 && parts[1] === 168) - return true; // 192.168.0.0/16 - return false; -} -function sanitizeFilename(name) { - // Strip characters dangerous in Content-Disposition headers - return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; -} +import { isPrivateIP } from "../utils/network.js"; +import { sanitizeFilename } from "../utils/sanitize.js"; export const convertRouter = Router(); /** * @openapi @@ -118,6 +83,14 @@ convertRouter.post("/html", async (req, res) => { landscape: body.landscape, margin: body.margin, printBackground: body.printBackground, + headerTemplate: body.headerTemplate, + footerTemplate: body.footerTemplate, + displayHeaderFooter: body.displayHeaderFooter, + scale: body.scale, + pageRanges: body.pageRanges, + preferCSSPageSize: body.preferCSSPageSize, + width: body.width, + height: body.height, }); const filename = sanitizeFilename(body.filename || "document.pdf"); res.setHeader("Content-Type", "application/pdf"); @@ -211,6 +184,14 @@ convertRouter.post("/markdown", async (req, res) => { landscape: body.landscape, margin: body.margin, printBackground: body.printBackground, + headerTemplate: body.headerTemplate, + footerTemplate: body.footerTemplate, + displayHeaderFooter: body.displayHeaderFooter, + scale: body.scale, + pageRanges: body.pageRanges, + preferCSSPageSize: body.preferCSSPageSize, + width: body.width, + height: body.height, }); const filename = sanitizeFilename(body.filename || "document.pdf"); res.setHeader("Content-Type", "application/pdf"); @@ -335,6 +316,14 @@ convertRouter.post("/url", async (req, res) => { landscape: body.landscape, margin: body.margin, printBackground: body.printBackground, + headerTemplate: body.headerTemplate, + footerTemplate: body.footerTemplate, + displayHeaderFooter: body.displayHeaderFooter, + scale: body.scale, + pageRanges: body.pageRanges, + preferCSSPageSize: body.preferCSSPageSize, + width: body.width, + height: body.height, waitUntil: body.waitUntil, hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`, }); diff --git a/dist/routes/signup.js b/dist/routes/signup.js index bfa34df..0eb745f 100644 --- a/dist/routes/signup.js +++ b/dist/routes/signup.js @@ -51,6 +51,60 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => { message: "Check your email for the verification code.", }); }); +/** + * @openapi + * /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. + * Rate limited to 15 attempts per 15 minutes. + * 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 + */ // Step 2: Verify code โ€” creates API key router.post("/verify", verifyLimiter, async (req, res) => { const { email, code } = req.body || {}; diff --git a/dist/routes/templates.js b/dist/routes/templates.js index dae4e9d..22dd769 100644 --- a/dist/routes/templates.js +++ b/dist/routes/templates.js @@ -2,9 +2,7 @@ import { Router } from "express"; import { renderPdf } from "../services/browser.js"; import logger from "../services/logger.js"; import { templates, renderTemplate } from "../services/templates.js"; -function sanitizeFilename(name) { - return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200); -} +import { sanitizeFilename } from "../utils/sanitize.js"; export const templatesRouter = Router(); /** * @openapi diff --git a/dist/services/browser.js b/dist/services/browser.js index 2ec7521..923b501 100644 --- a/dist/services/browser.js +++ b/dist/services/browser.js @@ -209,6 +209,11 @@ export async function renderPdf(html, options = {}) { 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 }), }); return Buffer.from(pdf); })(), @@ -270,6 +275,14 @@ export async function renderUrlPdf(url, options = {}) { 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 }), }); return Buffer.from(pdf); })(), diff --git a/dist/services/db.js b/dist/services/db.js index 35af8bb..fde5e35 100644 --- a/dist/services/db.js +++ b/dist/services/db.js @@ -1,20 +1,7 @@ import pg from "pg"; import logger from "./logger.js"; +import { isTransientError } from "../utils/errors.js"; const { Pool } = pg; -// Transient error codes from PgBouncer / PostgreSQL that warrant retry -const TRANSIENT_ERRORS = new Set([ - "ECONNRESET", - "ECONNREFUSED", - "EPIPE", - "ETIMEDOUT", - "CONNECTION_LOST", - "57P01", // admin_shutdown - "57P02", // crash_shutdown - "57P03", // cannot_connect_now - "08006", // connection_failure - "08003", // connection_does_not_exist - "08001", // sqlclient_unable_to_establish_sqlconnection -]); const pool = new Pool({ host: process.env.DATABASE_HOST || "172.17.0.1", port: parseInt(process.env.DATABASE_PORT || "5432", 10), @@ -33,28 +20,7 @@ const pool = new Pool({ pool.on("error", (err, client) => { logger.error({ err }, "Unexpected error on idle PostgreSQL client โ€” evicted from pool"); }); -/** - * Determine if an error is transient (PgBouncer failover, network blip) - */ -export function isTransientError(err) { - if (!err) - return false; - const code = err.code || ""; - const msg = (err.message || "").toLowerCase(); - if (TRANSIENT_ERRORS.has(code)) - return true; - if (msg.includes("no available server")) - return true; // PgBouncer specific - if (msg.includes("connection terminated")) - return true; - if (msg.includes("connection refused")) - return true; - if (msg.includes("server closed the connection")) - return true; - if (msg.includes("timeout expired")) - return true; - return false; -} +export { isTransientError } from "../utils/errors.js"; /** * Execute a query with automatic retry on transient errors. * @@ -180,5 +146,36 @@ export async function initDatabase() { client.release(); } } +/** + * Clean up stale database entries: + * - Expired pending verifications + * - Unverified free-tier API keys (never completed verification) + * - Orphaned usage rows (key no longer exists) + */ +export async function cleanupStaleData() { + const results = { expiredVerifications: 0, staleKeys: 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 + 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`); + return results; +} export { pool }; export default pool; diff --git a/dist/services/email.js b/dist/services/email.js index ce66697..3dc4d46 100644 --- a/dist/services/email.js +++ b/dist/services/email.js @@ -25,7 +25,34 @@ export async function sendVerificationEmail(email, code) { from: smtpFrom, to: email, subject: "DocFast - Verify your email", - text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.`, + text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.\n\n---\nDocFast โ€” HTML to PDF API\nhttps://docfast.dev`, + html: ` + + + +
    + + + + + + + +
    +

    DocFast

    +
    +

    Your verification code

    +
    +
    ${code}
    +
    +

    This code expires in 15 minutes.

    +
    +

    If you didn't request this, ignore this email.

    +
    +

    DocFast โ€” HTML to PDF API
    docfast.dev

    +
    +
    +`, }); logger.info({ email, messageId: info.messageId }, "Verification email sent"); return true; diff --git a/dist/services/templates.js b/dist/services/templates.js index 585387e..c1376bd 100644 --- a/dist/services/templates.js +++ b/dist/services/templates.js @@ -35,7 +35,8 @@ function esc(s) { .replace(/&/g, "&") .replace(//g, ">") - .replace(/"/g, """); + .replace(/"/g, """) + .replace(/'/g, "'"); } function renderInvoice(d) { const cur = esc(d.currency || "โ‚ฌ"); diff --git a/public/openapi.json b/public/openapi.json index 9e26dfe..aa1977b 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -1 +1,1225 @@ -{} \ No newline at end of file +{ + "openapi": "3.0.3", + "info": { + "title": "DocFast API", + "version": "1.0.0", + "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Demo Endpoints\nTry the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs.\n\n## Rate Limits\n- Demo: 5 PDFs/hour per IP (watermarked)\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo at `POST /v1/demo/html` โ€” no signup needed\n2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs\n3. Use your API key to convert documents", + "contact": { + "name": "DocFast", + "url": "https://docfast.dev", + "email": "support@docfast.dev" + } + }, + "servers": [ + { + "url": "https://docfast.dev", + "description": "Production" + } + ], + "tags": [ + { + "name": "Demo", + "description": "Try the API without signing up โ€” watermarked PDFs, rate-limited" + }, + { + "name": "Conversion", + "description": "Convert HTML, Markdown, or URLs to PDF (requires API key)" + }, + { + "name": "Templates", + "description": "Built-in document templates" + }, + { + "name": "Account", + "description": "Key recovery and email management" + }, + { + "name": "Billing", + "description": "Stripe-powered subscription management" + }, + { + "name": "System", + "description": "Health checks and usage stats" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "API key as Bearer token" + }, + "ApiKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key via X-API-Key header" + } + }, + "schemas": { + "PdfOptions": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size" + }, + "landscape": { + "type": "boolean", + "default": false, + "description": "Landscape orientation" + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "string", + "description": "Top margin (e.g. \"10mm\", \"1in\")", + "default": "0" + }, + "right": { + "type": "string", + "description": "Right margin", + "default": "0" + }, + "bottom": { + "type": "string", + "description": "Bottom margin", + "default": "0" + }, + "left": { + "type": "string", + "description": "Left margin", + "default": "0" + } + }, + "description": "Page margins" + }, + "printBackground": { + "type": "boolean", + "default": true, + "description": "Print background colors and images" + }, + "filename": { + "type": "string", + "description": "Custom filename for Content-Disposition header", + "default": "document.pdf" + } + } + }, + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message" + } + }, + "required": [ + "error" + ] + } + } + }, + "paths": { + "/v1/billing/checkout": { + "post": { + "tags": [ + "Billing" + ], + "summary": "Create a Stripe checkout session", + "description": "Creates a Stripe Checkout session for a Pro subscription (โ‚ฌ9/month).\nReturns a URL to redirect the user to Stripe's hosted payment page.\nRate limited to 3 requests per hour per IP.\n", + "responses": { + "200": { + "description": "Checkout session created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Stripe Checkout URL to redirect the user to" + } + } + } + } + } + }, + "413": { + "description": "Request body too large" + }, + "429": { + "description": "Too many checkout requests" + }, + "500": { + "description": "Failed to create checkout session" + } + } + } + }, + "/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": [ + "Conversion" + ], + "summary": "Convert HTML to PDF", + "description": "Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string", + "description": "HTML content to convert. Can be a full document or a fragment.", + "example": "

    Hello World

    My first PDF

    " + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (only used when html is a fragment, not a full document)", + "example": "body { font-family: sans-serif; padding: 40px; }" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type (must be application/json)" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/convert/markdown": { + "post": { + "tags": [ + "Conversion" + ], + "summary": "Convert Markdown to PDF", + "description": "Converts Markdown content to HTML and then to a PDF document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "markdown" + ], + "properties": { + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject into the rendered HTML" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/convert/url": { + "post": { + "tags": [ + "Conversion" + ], + "summary": "Convert URL to PDF", + "description": "Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.\nPrivate/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to convert (http or https only)", + "example": "https://example.com" + }, + "waitUntil": { + "type": "string", + "enum": [ + "load", + "domcontentloaded", + "networkidle0", + "networkidle2" + ], + "default": "domcontentloaded", + "description": "When to consider navigation finished" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing/invalid URL or URL resolves to private IP" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/demo/html": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert HTML to PDF (demo)", + "description": "Public endpoint โ€” no API key required. Rate limited to 5 requests per hour per IP.\nOutput PDFs include a DocFast watermark. Upgrade to Pro for clean output.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string", + "description": "HTML content to convert", + "example": "

    Hello World

    My first PDF

    " + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (used when html is a fragment)" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/v1/demo/markdown": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert Markdown to PDF (demo)", + "description": "Public endpoint โ€” no API key required. Rate limited to 5 requests per hour per IP.\nMarkdown is converted to HTML then rendered to PDF with a DocFast watermark.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "markdown" + ], + "properties": { + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)" + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health check", + "description": "Returns service health status including database connectivity and browser pool stats.", + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded" + ] + }, + "version": { + "type": "string", + "example": "0.4.0" + }, + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "error" + ] + }, + "version": { + "type": "string", + "example": "PostgreSQL 17.4" + } + } + }, + "pool": { + "type": "object", + "properties": { + "size": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "available": { + "type": "integer" + }, + "queueDepth": { + "type": "integer" + }, + "pdfCount": { + "type": "integer" + }, + "restarting": { + "type": "boolean" + }, + "uptimeSeconds": { + "type": "integer" + } + } + } + } + } + } + } + }, + "503": { + "description": "Service is degraded (database issue)" + } + } + } + }, + "/v1/recover": { + "post": { + "tags": [ + "Account" + ], + "summary": "Request API key recovery", + "description": "Sends a 6-digit verification code to the email address if an account exists.\nResponse is always the same regardless of whether the email exists (to prevent enumeration).\nRate limited to 3 requests per hour.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email address associated with the API key" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Recovery code sent (or no-op if email not found)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovery_sent" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid email format" + }, + "429": { + "description": "Too many recovery attempts" + } + } + } + }, + "/v1/recover/verify": { + "post": { + "tags": [ + "Account" + ], + "summary": "Verify recovery code and retrieve API key", + "description": "Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "code" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "code": { + "type": "string", + "pattern": "^\\d{6}$", + "description": "6-digit verification code" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "API key recovered", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovered" + }, + "apiKey": { + "type": "string", + "description": "The recovered API key" + }, + "tier": { + "type": "string", + "enum": [ + "free", + "pro" + ] + } + } + } + } + } + }, + "400": { + "description": "Invalid verification code or missing fields" + }, + "410": { + "description": "Verification code expired" + }, + "429": { + "description": "Too many failed attempts" + } + } + } + }, + "/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": [ + "Templates" + ], + "summary": "List available templates", + "description": "Returns a list of all built-in document templates with their required fields.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "responses": { + "200": { + "description": "List of templates", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "templates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "invoice" + }, + "name": { + "type": "string", + "example": "Invoice" + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + } + } + } + }, + "/v1/templates/{id}/render": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Render a template to PDF", + "description": "Renders a built-in template with the provided data and returns a PDF.\nUse GET /v1/templates to see available templates and their required fields.\nSpecial fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Template ID (e.g. \"invoice\", \"receipt\")" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "Template data (fields depend on template). Can also be passed at root level." + }, + "_format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size override" + }, + "_margin": { + "type": "object", + "properties": { + "top": { + "type": "string" + }, + "right": { + "type": "string" + }, + "bottom": { + "type": "string" + }, + "left": { + "type": "string" + } + }, + "description": "Page margin override" + }, + "_filename": { + "type": "string", + "description": "Custom output filename" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing required template fields" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "404": { + "description": "Template not found" + }, + "500": { + "description": "Template rendering failed" + } + } + } + }, + "/v1/signup/free": { + "post": { + "tags": [ + "Account" + ], + "summary": "Free signup (discontinued)", + "description": "Free accounts have been discontinued. Use the demo endpoint for testing\nor subscribe to Pro for production use.\n", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "responses": { + "410": { + "description": "Free accounts discontinued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Free accounts have been discontinued." + }, + "demo_endpoint": { + "type": "string", + "example": "/v1/demo/html" + }, + "pro_url": { + "type": "string", + "example": "https://docfast.dev/#pricing" + } + } + } + } + } + } + } + } + }, + "/v1/usage": { + "get": { + "tags": [ + "System" + ], + "summary": "Usage statistics (admin only)", + "description": "Returns usage statistics for the authenticated user. Requires admin API key.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "responses": { + "200": { + "description": "Usage statistics", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "month": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Admin access required" + }, + "503": { + "description": "Admin access not configured" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/app-routes.test.ts b/src/__tests__/app-routes.test.ts index bc670cd..27b7c96 100644 --- a/src/__tests__/app-routes.test.ts +++ b/src/__tests__/app-routes.test.ts @@ -92,6 +92,31 @@ describe("App-level routes", () => { }); }); + describe("OpenAPI spec completeness", () => { + let spec: any; + + beforeAll(async () => { + const res = await request(app).get("/openapi.json"); + expect(res.status).toBe(200); + spec = res.body; + }); + + it("includes POST /v1/signup/verify", () => { + expect(spec.paths["/v1/signup/verify"]).toBeDefined(); + expect(spec.paths["/v1/signup/verify"].post).toBeDefined(); + }); + + it("includes GET /v1/billing/success", () => { + expect(spec.paths["/v1/billing/success"]).toBeDefined(); + expect(spec.paths["/v1/billing/success"].get).toBeDefined(); + }); + + it("includes POST /v1/billing/webhook", () => { + expect(spec.paths["/v1/billing/webhook"]).toBeDefined(); + expect(spec.paths["/v1/billing/webhook"].post).toBeDefined(); + }); + }); + describe("Security headers", () => { it("includes helmet security headers", async () => { const res = await request(app).get("/api"); diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 091689d..849de5c 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -112,6 +112,36 @@ router.post("/checkout", checkoutLimiter, async (req: Request, res: Response) => } }); +/** + * @openapi + * /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. + * Called by Stripe redirect after payment completion. + * 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 + */ // Success page โ€” provision Pro API key after checkout router.get("/success", async (req: Request, res: Response) => { const sessionId = req.query.session_id as string; @@ -189,6 +219,47 @@ a { color: #4f9; } } }); +/** + * @openapi + * /v1/billing/webhook: + * post: + * tags: [Billing] + * summary: Stripe webhook endpoint + * description: | + * Receives Stripe webhook events for subscription lifecycle management. + * Requires the raw request body and a valid Stripe-Signature header for verification. + * Handles checkout.session.completed, customer.subscription.updated, + * customer.subscription.deleted, and customer.updated events. + * 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 + */ // Stripe webhook for subscription lifecycle events router.post("/webhook", async (req: Request, res: Response) => { const sig = req.headers["stripe-signature"] as string; diff --git a/src/routes/signup.ts b/src/routes/signup.ts index fd422eb..91a9ae6 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -63,6 +63,60 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, r }); }); +/** + * @openapi + * /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. + * Rate limited to 15 attempts per 15 minutes. + * 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 + */ // Step 2: Verify code โ€” creates API key router.post("/verify", verifyLimiter, async (req: Request, res: Response) => { const { email, code } = req.body || {}; From 480c794a85b9eb6a8781928fea8bc0ac13adee4f Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 27 Feb 2026 19:04:36 +0000 Subject: [PATCH 076/174] feat: add email change routes (BUG-090) --- src/__tests__/email-change.test.ts | 120 +++++++++++++++++ src/index.ts | 2 + src/routes/email-change.ts | 204 +++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 src/__tests__/email-change.test.ts create mode 100644 src/routes/email-change.ts diff --git a/src/__tests__/email-change.test.ts b/src/__tests__/email-change.test.ts new file mode 100644 index 0000000..d07ad5d --- /dev/null +++ b/src/__tests__/email-change.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +vi.mock("../services/verification.js"); +vi.mock("../services/email.js"); +vi.mock("../services/db.js"); +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const { createPendingVerification, verifyCode } = await import("../services/verification.js"); + const { sendVerificationEmail } = await import("../services/email.js"); + const { queryWithRetry } = await import("../services/db.js"); + + vi.mocked(createPendingVerification).mockResolvedValue({ email: "new@example.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 }); + vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); + vi.mocked(sendVerificationEmail).mockResolvedValue(true); + // Default: apiKey exists, email not taken + vi.mocked(queryWithRetry).mockImplementation(async (sql: string, params?: any[]) => { + if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("key =")) { + return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; + } + if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("email =")) { + return { rows: [], rowCount: 0 }; + } + if (sql.includes("UPDATE")) { + return { rows: [{ email: "new@example.com" }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + + const { emailChangeRouter } = await import("../routes/email-change.js"); + app = express(); + app.use(express.json()); + app.use("/v1/email-change", emailChangeRouter); +}); + +describe("POST /v1/email-change", () => { + it("returns 400 for missing apiKey", async () => { + const res = await request(app).post("/v1/email-change").send({ newEmail: "new@example.com" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing newEmail", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid email format", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "bad" }); + expect(res.status).toBe(400); + }); + + it("returns 403 for invalid API key", async () => { + const { queryWithRetry } = await import("../services/db.js"); + vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + if (sql.includes("SELECT") && sql.includes("key =")) { + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 0 }; + }); + const res = await request(app).post("/v1/email-change").send({ apiKey: "fake", newEmail: "new@example.com" }); + expect(res.status).toBe(403); + }); + + it("returns 409 when email already taken", async () => { + const { queryWithRetry } = await import("../services/db.js"); + vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + if (sql.includes("SELECT") && sql.includes("key =")) { + return { rows: [{ key: "df_pro_xxx", email: "old@example.com" }], rowCount: 1 }; + } + if (sql.includes("SELECT") && sql.includes("email =")) { + return { rows: [{ key: "df_pro_other", email: "new@example.com" }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); + expect(res.status).toBe(409); + }); + + it("returns 200 with verification_sent on success", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("verification_sent"); + }); +}); + +describe("POST /v1/email-change/verify", () => { + it("returns 400 for missing fields", async () => { + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" }); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); + expect(res.status).toBe(400); + }); + + it("returns 200 and updates email on success", async () => { + const { queryWithRetry } = await import("../services/db.js"); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(res.body.newEmail).toBe("new@example.com"); + // Verify UPDATE was called + expect(queryWithRetry).toHaveBeenCalledWith( + expect.stringContaining("UPDATE"), + expect.arrayContaining(["new@example.com", "df_pro_xxx"]) + ); + }); +}); diff --git a/src/index.ts b/src/index.ts index a0499b3..7ba5b7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { templatesRouter } from "./routes/templates.js"; import { healthRouter } from "./routes/health.js"; import { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; +import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; @@ -130,6 +131,7 @@ app.use("/v1/signup", (_req, res) => { }); }); app.use("/v1/recover", recoverRouter); +app.use("/v1/email-change", emailChangeRouter); app.use("/v1/billing", billingRouter); // Authenticated routes โ€” conversion routes get tighter body limits (500KB) diff --git a/src/routes/email-change.ts b/src/routes/email-change.ts new file mode 100644 index 0000000..3041b15 --- /dev/null +++ b/src/routes/email-change.ts @@ -0,0 +1,204 @@ +import { Router, Request, Response } from "express"; +import rateLimit from "express-rate-limit"; +import { createPendingVerification, verifyCode } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import { queryWithRetry } from "../services/db.js"; +import logger from "../services/logger.js"; + +const router = Router(); + +const emailChangeLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: "Too many email change attempts. Please try again in 1 hour." }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => req.body?.apiKey || req.ip || "unknown", +}); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +async function validateApiKey(apiKey: string) { + const result = await queryWithRetry( + `SELECT key, email, tier FROM api_keys WHERE key = $1`, + [apiKey] + ); + return result.rows[0] || null; +} + +/** + * @openapi + * /v1/email-change: + * post: + * tags: [Account] + * summary: Request email change + * description: | + * Sends a 6-digit verification code to the new email address. + * Rate limited to 3 requests per hour per API key. + * 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 + */ +router.post("/", emailChangeLimiter, async (req: Request, res: Response) => { + 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." }); +}); + +/** + * @openapi + * /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 + */ +router.post("/verify", async (req: Request, res: Response) => { + 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; + } + 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; + } +}); + +export { router as emailChangeRouter }; From 03f82a8d034789c995dc3e97068b81e1f98076d4 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Sat, 28 Feb 2026 07:02:30 +0000 Subject: [PATCH 077/174] fix: update basic-ftp and rollup to resolve security vulnerabilities - basic-ftp: critical path traversal (GHSA-5rq4-664w-9x2c) - production dep via puppeteer - rollup: high path traversal (GHSA-mw96-cpmx-2vgc) - dev dep via vitest - npm audit now shows 0 vulnerabilities - All 291 tests pass --- package-lock.json | 212 +++++++++++++++++++++++----------------------- 1 file changed, 106 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ddfdda..30e5f96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -675,9 +675,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -689,9 +689,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -703,9 +703,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -717,9 +717,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -731,9 +731,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -745,9 +745,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -759,9 +759,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -773,9 +773,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -787,9 +787,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -801,9 +801,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -815,9 +815,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -829,9 +829,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -843,9 +843,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -857,9 +857,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -871,9 +871,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -885,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -899,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -913,9 +913,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -927,9 +927,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -941,9 +941,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -955,9 +955,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -969,9 +969,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -983,9 +983,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -997,9 +997,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1011,9 +1011,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1593,9 +1593,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3777,9 +3777,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -3793,31 +3793,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, From 0e03e39ec7fe0bfe888a3af31025d1e482d2440d Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Sat, 28 Feb 2026 11:09:59 +0100 Subject: [PATCH 078/174] docs: comprehensive README with all endpoints, options, and setup --- README.md | 149 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6cd4e54..4052ea8 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,71 @@ # DocFast API -Fast, simple HTML/Markdown to PDF API with built-in invoice templates. +Fast, reliable HTML/Markdown/URL to PDF conversion API. EU-hosted, GDPR compliant. + +**Website:** https://docfast.dev +**Docs:** https://docfast.dev/docs +**Status:** https://docfast.dev/status + +## Features + +- **HTML โ†’ PDF** โ€” Full documents or fragments with optional CSS +- **Markdown โ†’ PDF** โ€” GitHub-flavored Markdown with syntax highlighting +- **URL โ†’ PDF** โ€” Render any public webpage as PDF (SSRF-protected) +- **Invoice Templates** โ€” Built-in professional invoice template +- **PDF Options** โ€” Paper size, orientation, margins, headers/footers, page ranges, scaling ## Quick Start +### 1. Get an API Key + +Sign up at https://docfast.dev โ€” free demo available, Pro plan at โ‚ฌ9/month for 5,000 PDFs. + +### 2. Generate a PDF + ```bash -npm install -npm run build -API_KEYS=your-key-here npm start +curl -X POST https://docfast.dev/v1/convert/html \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"html": "

    Hello World

    Your first PDF.

    "}' \ + -o output.pdf ``` -## Endpoints +## API Endpoints ### Convert HTML to PDF + ```bash -curl -X POST http://localhost:3100/v1/convert/html \ +curl -X POST https://docfast.dev/v1/convert/html \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ - -d '{"html": "

    Hello

    World

    "}' \ + -d '{"html": "

    Hello

    ", "format": "A4", "margin": {"top": "20mm"}}' \ -o output.pdf ``` ### Convert Markdown to PDF + ```bash -curl -X POST http://localhost:3100/v1/convert/markdown \ +curl -X POST https://docfast.dev/v1/convert/markdown \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ - -d '{"markdown": "# Hello\n\nWorld"}' \ + -d '{"markdown": "# Hello\n\nWorld", "css": "body { font-family: sans-serif; }"}' \ + -o output.pdf +``` + +### Convert URL to PDF + +```bash +curl -X POST https://docfast.dev/v1/convert/url \ + -H "Authorization: Bearer YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com", "format": "A4", "landscape": true}' \ -o output.pdf ``` ### Invoice Template + ```bash -curl -X POST http://localhost:3100/v1/templates/invoice/render \ +curl -X POST https://docfast.dev/v1/templates/invoice/render \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -40,23 +73,95 @@ curl -X POST http://localhost:3100/v1/templates/invoice/render \ "date": "2026-02-14", "from": {"name": "Your Company", "email": "you@example.com"}, "to": {"name": "Client", "email": "client@example.com"}, - "items": [{"description": "Service", "quantity": 1, "unitPrice": 100, "taxRate": 20}] + "items": [{"description": "Consulting", "quantity": 10, "unitPrice": 150, "taxRate": 20}] }' \ -o invoice.pdf ``` -### Options -- `format`: Paper size (A4, Letter, Legal, etc.) -- `landscape`: true/false -- `margin`: `{top, right, bottom, left}` in CSS units -- `css`: Custom CSS (for markdown/html fragments) -- `filename`: Suggested filename in Content-Disposition header +### Demo (No Auth Required) -## Auth -Pass API key via `Authorization: Bearer `. Set `API_KEYS` env var (comma-separated for multiple keys). +Try the API without signing up: -## Docker ```bash -docker build -t docfast . -docker run -p 3100:3100 -e API_KEYS=your-key docfast +curl -X POST https://docfast.dev/v1/demo/html \ + -H "Content-Type: application/json" \ + -d '{"html": "

    Demo PDF

    No API key needed.

    "}' \ + -o demo.pdf ``` + +Demo PDFs include a watermark and are rate-limited. + +## PDF Options + +All conversion endpoints accept these options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | string | `"A4"` | Paper size: A4, Letter, Legal, A3, etc. | +| `landscape` | boolean | `false` | Landscape orientation | +| `margin` | object | `{top:"0",right:"0",bottom:"0",left:"0"}` | Margins in CSS units (px, mm, in, cm) | +| `printBackground` | boolean | `true` | Include background colors/images | +| `filename` | string | `"document.pdf"` | Suggested filename in Content-Disposition | +| `css` | string | โ€” | Custom CSS (for HTML fragments and Markdown) | +| `scale` | number | `1` | Scale (0.1โ€“2.0) | +| `pageRanges` | string | โ€” | Page ranges, e.g. `"1-3, 5"` | +| `width` | string | โ€” | Custom page width (overrides format) | +| `height` | string | โ€” | Custom page height (overrides format) | +| `headerTemplate` | string | โ€” | HTML template for page header | +| `footerTemplate` | string | โ€” | HTML template for page footer | +| `displayHeaderFooter` | boolean | `false` | Show header/footer | +| `preferCSSPageSize` | boolean | `false` | Use CSS `@page` size over format | + +## Authentication + +Pass your API key via either: +- `Authorization: Bearer ` header +- `X-API-Key: ` header + +## Development + +```bash +# Install dependencies +npm install + +# Run in development mode +npm run dev + +# Run tests +npm test + +# Build +npm run build + +# Start production server +npm start +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | PostgreSQL connection string | +| `STRIPE_SECRET_KEY` | Yes | Stripe API key for billing | +| `STRIPE_WEBHOOK_SECRET` | Yes | Stripe webhook signature secret | +| `SMTP_HOST` | Yes | SMTP server hostname | +| `SMTP_PORT` | Yes | SMTP server port | +| `SMTP_USER` | Yes | SMTP username | +| `SMTP_PASS` | Yes | SMTP password | +| `BASE_URL` | No | Base URL (default: https://docfast.dev) | +| `PORT` | No | Server port (default: 3100) | +| `BROWSER_COUNT` | No | Puppeteer browser instances (default: 2) | +| `PAGES_PER_BROWSER` | No | Pages per browser (default: 8) | +| `LOG_LEVEL` | No | Pino log level (default: info) | + +### Architecture + +- **Runtime:** Node.js + Express +- **PDF Engine:** Puppeteer (Chromium) with browser pool +- **Database:** PostgreSQL (via pg) +- **Payments:** Stripe +- **Email:** SMTP (nodemailer) + +## License + +Proprietary โ€” Cloonar Technologies GmbH From f89a3181f7f135d433cc81f12d2c170b99cb7692 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 28 Feb 2026 14:05:32 +0100 Subject: [PATCH 079/174] feat: validate PDF options with TDD tests --- src/__tests__/convert.test.ts | 63 ++++++++++++ src/__tests__/pdf-options.test.ts | 162 ++++++++++++++++++++++++++++++ src/routes/convert.ts | 22 ++++ src/utils/pdf-options.ts | 88 ++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 src/__tests__/pdf-options.test.ts create mode 100644 src/utils/pdf-options.ts diff --git a/src/__tests__/convert.test.ts b/src/__tests__/convert.test.ts index 5cc7c1c..507107b 100644 --- a/src/__tests__/convert.test.ts +++ b/src/__tests__/convert.test.ts @@ -182,3 +182,66 @@ describe("POST /v1/convert/url", () => { expect(res.headers["content-type"]).toMatch(/application\/pdf/); }); }); + +describe("PDF option validation (all endpoints)", () => { + const endpoints = [ + { path: "/v1/convert/html", body: { html: "

    Hi

    " } }, + { path: "/v1/convert/markdown", body: { markdown: "# Hi" } }, + ]; + + for (const { path, body } of endpoints) { + it(`${path} returns 400 for invalid scale`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, scale: 5 }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("scale"); + }); + + it(`${path} returns 400 for invalid format`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, format: "B5" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("format"); + }); + + it(`${path} returns 400 for non-boolean landscape`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, landscape: "yes" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("landscape"); + }); + + it(`${path} returns 400 for invalid pageRanges`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, pageRanges: "abc" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("pageRanges"); + }); + + it(`${path} returns 400 for invalid margin`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, margin: "1cm" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("margin"); + }); + } + + it("/v1/convert/url returns 400 for invalid scale", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://example.com", scale: 5 }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("scale"); + }); +}); diff --git a/src/__tests__/pdf-options.test.ts b/src/__tests__/pdf-options.test.ts new file mode 100644 index 0000000..224886f --- /dev/null +++ b/src/__tests__/pdf-options.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { validatePdfOptions } from "../utils/pdf-options.js"; + +describe("validatePdfOptions", () => { + // --- Happy path --- + it("accepts empty options", () => { + const result = validatePdfOptions({}); + expect(result.valid).toBe(true); + }); + + it("accepts undefined", () => { + const result = validatePdfOptions(undefined as any); + expect(result.valid).toBe(true); + }); + + it("accepts all valid options together", () => { + const result = validatePdfOptions({ + scale: 1.5, + format: "A4", + landscape: true, + printBackground: false, + displayHeaderFooter: true, + preferCSSPageSize: false, + width: "210mm", + height: "297mm", + margin: { top: "1cm", right: "1cm", bottom: "1cm", left: "1cm" }, + pageRanges: "1-5", + }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.sanitized.scale).toBe(1.5); + expect(result.sanitized.format).toBe("A4"); + } + }); + + // --- scale --- + describe("scale", () => { + it("accepts 0.1", () => { + expect(validatePdfOptions({ scale: 0.1 }).valid).toBe(true); + }); + it("accepts 2.0", () => { + expect(validatePdfOptions({ scale: 2.0 }).valid).toBe(true); + }); + it("rejects 0.05", () => { + const r = validatePdfOptions({ scale: 0.05 }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain("scale"); + }); + it("rejects 2.5", () => { + expect(validatePdfOptions({ scale: 2.5 }).valid).toBe(false); + }); + it("rejects non-number", () => { + expect(validatePdfOptions({ scale: "big" as any }).valid).toBe(false); + }); + }); + + // --- format --- + describe("format", () => { + const validFormats = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"]; + for (const f of validFormats) { + it(`accepts ${f}`, () => { + expect(validatePdfOptions({ format: f }).valid).toBe(true); + }); + } + it("accepts case-insensitive (a4)", () => { + const r = validatePdfOptions({ format: "a4" }); + expect(r.valid).toBe(true); + if (r.valid) expect(r.sanitized.format).toBe("A4"); + }); + it("accepts case-insensitive (letter)", () => { + const r = validatePdfOptions({ format: "letter" }); + expect(r.valid).toBe(true); + if (r.valid) expect(r.sanitized.format).toBe("Letter"); + }); + it("rejects invalid format", () => { + const r = validatePdfOptions({ format: "B5" }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain("format"); + }); + }); + + // --- booleans --- + for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"] as const) { + describe(field, () => { + it("accepts true", () => { + expect(validatePdfOptions({ [field]: true }).valid).toBe(true); + }); + it("accepts false", () => { + expect(validatePdfOptions({ [field]: false }).valid).toBe(true); + }); + it("rejects string", () => { + const r = validatePdfOptions({ [field]: "yes" as any }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain(field); + }); + it("rejects number", () => { + expect(validatePdfOptions({ [field]: 1 as any }).valid).toBe(false); + }); + }); + } + + // --- width/height --- + for (const field of ["width", "height"] as const) { + describe(field, () => { + it("accepts string", () => { + expect(validatePdfOptions({ [field]: "210mm" }).valid).toBe(true); + }); + it("rejects number", () => { + expect(validatePdfOptions({ [field]: 210 as any }).valid).toBe(false); + const r = validatePdfOptions({ [field]: 210 as any }); + if (!r.valid) expect(r.error).toContain(field); + }); + }); + } + + // --- margin --- + describe("margin", () => { + it("accepts valid margin object", () => { + expect(validatePdfOptions({ margin: { top: "1cm", bottom: "2cm" } }).valid).toBe(true); + }); + it("accepts empty margin object", () => { + expect(validatePdfOptions({ margin: {} }).valid).toBe(true); + }); + it("rejects non-object margin", () => { + expect(validatePdfOptions({ margin: "1cm" as any }).valid).toBe(false); + }); + it("rejects margin with non-string values", () => { + expect(validatePdfOptions({ margin: { top: 10 } as any }).valid).toBe(false); + }); + it("rejects margin with unknown keys", () => { + expect(validatePdfOptions({ margin: { top: "1cm", padding: "2cm" } as any }).valid).toBe(false); + }); + }); + + // --- pageRanges --- + describe("pageRanges", () => { + it("accepts '1-5'", () => { + expect(validatePdfOptions({ pageRanges: "1-5" }).valid).toBe(true); + }); + it("accepts '1,3,5'", () => { + expect(validatePdfOptions({ pageRanges: "1,3,5" }).valid).toBe(true); + }); + it("accepts '2-'", () => { + expect(validatePdfOptions({ pageRanges: "2-" }).valid).toBe(true); + }); + it("accepts '1-3,5,7-9'", () => { + expect(validatePdfOptions({ pageRanges: "1-3,5,7-9" }).valid).toBe(true); + }); + it("accepts single page '3'", () => { + expect(validatePdfOptions({ pageRanges: "3" }).valid).toBe(true); + }); + it("rejects non-string", () => { + expect(validatePdfOptions({ pageRanges: 5 as any }).valid).toBe(false); + }); + it("rejects invalid pattern", () => { + expect(validatePdfOptions({ pageRanges: "abc" }).valid).toBe(false); + }); + it("rejects 'all'", () => { + expect(validatePdfOptions({ pageRanges: "all" }).valid).toBe(false); + }); + }); +}); diff --git a/src/routes/convert.ts b/src/routes/convert.ts index bdfb3eb..8e46885 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -6,6 +6,7 @@ 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"; export const convertRouter = Router(); @@ -94,6 +95,13 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi 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(); @@ -203,6 +211,13 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P 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(); @@ -339,6 +354,13 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis 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(); diff --git a/src/utils/pdf-options.ts b/src/utils/pdf-options.ts new file mode 100644 index 0000000..ec7fd05 --- /dev/null +++ b/src/utils/pdf-options.ts @@ -0,0 +1,88 @@ +const VALID_FORMATS = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"]; +const FORMAT_MAP = new Map(VALID_FORMATS.map(f => [f.toLowerCase(), f])); +const PAGE_RANGES_RE = /^\d+(-\d*)?(\s*,\s*\d+(-\d*)?)*$/; +const MARGIN_KEYS = new Set(["top", "right", "bottom", "left"]); + +type PdfInput = Record; +type ValidResult = { valid: true; sanitized: Record }; +type InvalidResult = { valid: false; error: string }; + +export function validatePdfOptions(opts: PdfInput): ValidResult | InvalidResult { + if (!opts || typeof opts !== "object") return { valid: true, sanitized: {} }; + + const sanitized: Record = {}; + + // scale + if (opts.scale !== undefined) { + if (typeof opts.scale !== "number" || opts.scale < 0.1 || opts.scale > 2.0) { + return { valid: false, error: "scale must be a number between 0.1 and 2.0" }; + } + sanitized.scale = opts.scale; + } + + // format + if (opts.format !== undefined) { + if (typeof opts.format !== "string") { + return { valid: false, error: "format must be a string" }; + } + const canonical = FORMAT_MAP.get(opts.format.toLowerCase()); + if (!canonical) { + return { valid: false, error: `format must be one of: ${VALID_FORMATS.join(", ")}` }; + } + sanitized.format = canonical; + } + + // booleans + for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"]) { + if (opts[field] !== undefined) { + if (typeof opts[field] !== "boolean") { + return { valid: false, error: `${field} must be a boolean` }; + } + sanitized[field] = opts[field]; + } + } + + // width/height + for (const field of ["width", "height"]) { + if (opts[field] !== undefined) { + if (typeof opts[field] !== "string") { + return { valid: false, error: `${field} must be a string (CSS dimension)` }; + } + sanitized[field] = opts[field]; + } + } + + // margin + if (opts.margin !== undefined) { + if (typeof opts.margin !== "object" || opts.margin === null || Array.isArray(opts.margin)) { + return { valid: false, error: "margin must be an object with top/right/bottom/left string fields" }; + } + for (const key of Object.keys(opts.margin)) { + if (!MARGIN_KEYS.has(key)) { + return { valid: false, error: `margin contains unknown key: ${key}` }; + } + if (typeof opts.margin[key] !== "string") { + return { valid: false, error: `margin.${key} must be a string` }; + } + } + sanitized.margin = { ...opts.margin }; + } + + // pageRanges + if (opts.pageRanges !== undefined) { + if (typeof opts.pageRanges !== "string") { + return { valid: false, error: "pageRanges must be a string" }; + } + if (!PAGE_RANGES_RE.test(opts.pageRanges.trim())) { + return { valid: false, error: "pageRanges must match pattern like '1-5', '1,3,5', or '2-'" }; + } + sanitized.pageRanges = opts.pageRanges; + } + + // Pass through non-validated fields + for (const key of ["headerTemplate", "footerTemplate"]) { + if (opts[key] !== undefined) sanitized[key] = opts[key]; + } + + return { valid: true, sanitized }; +} From 597be6bcae78bf87ff72a74660d8c19e7c57693c Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Sat, 28 Feb 2026 17:05:47 +0100 Subject: [PATCH 080/174] fix: resolve TypeScript errors in email-change tests (broken Docker build) --- src/__tests__/email-change.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/email-change.test.ts b/src/__tests__/email-change.test.ts index d07ad5d..48b0377 100644 --- a/src/__tests__/email-change.test.ts +++ b/src/__tests__/email-change.test.ts @@ -23,7 +23,7 @@ beforeEach(async () => { vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); vi.mocked(sendVerificationEmail).mockResolvedValue(true); // Default: apiKey exists, email not taken - vi.mocked(queryWithRetry).mockImplementation(async (sql: string, params?: any[]) => { + vi.mocked(queryWithRetry).mockImplementation((async (sql: string, params?: any[]) => { if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; } @@ -34,7 +34,7 @@ beforeEach(async () => { return { rows: [{ email: "new@example.com" }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; - }); + }) as any); const { emailChangeRouter } = await import("../routes/email-change.js"); app = express(); @@ -60,19 +60,19 @@ describe("POST /v1/email-change", () => { it("returns 403 for invalid API key", async () => { const { queryWithRetry } = await import("../services/db.js"); - vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { if (sql.includes("SELECT") && sql.includes("key =")) { return { rows: [], rowCount: 0 }; } return { rows: [], rowCount: 0 }; - }); + }) as any); const res = await request(app).post("/v1/email-change").send({ apiKey: "fake", newEmail: "new@example.com" }); expect(res.status).toBe(403); }); it("returns 409 when email already taken", async () => { const { queryWithRetry } = await import("../services/db.js"); - vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { if (sql.includes("SELECT") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com" }], rowCount: 1 }; } @@ -80,7 +80,7 @@ describe("POST /v1/email-change", () => { return { rows: [{ key: "df_pro_other", email: "new@example.com" }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; - }); + }) as any); const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(409); }); From a91b4c53a95e4d60bb1edaddadf7fb5aa75c9166 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sat, 28 Feb 2026 20:03:14 +0100 Subject: [PATCH 081/174] test: add comprehensive tests for isTransientError utility --- src/__tests__/errors.test.ts | 199 +++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/__tests__/errors.test.ts diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts new file mode 100644 index 0000000..7d42e79 --- /dev/null +++ b/src/__tests__/errors.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from "vitest"; +import { isTransientError } from "../utils/errors.js"; + +describe("isTransientError", () => { + describe("null/undefined/empty input", () => { + it("returns false for null", () => { + expect(isTransientError(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isTransientError(undefined)).toBe(false); + }); + + it("returns false for empty object", () => { + expect(isTransientError({})).toBe(false); + }); + }); + + describe("error codes from TRANSIENT_ERRORS set", () => { + it("returns true for ECONNRESET", () => { + expect(isTransientError({ code: "ECONNRESET" })).toBe(true); + }); + + it("returns true for ECONNREFUSED", () => { + expect(isTransientError({ code: "ECONNREFUSED" })).toBe(true); + }); + + it("returns true for EPIPE", () => { + expect(isTransientError({ code: "EPIPE" })).toBe(true); + }); + + it("returns true for ETIMEDOUT", () => { + expect(isTransientError({ code: "ETIMEDOUT" })).toBe(true); + }); + + it("returns true for CONNECTION_LOST", () => { + expect(isTransientError({ code: "CONNECTION_LOST" })).toBe(true); + }); + + it("returns true for 57P01 (admin_shutdown)", () => { + expect(isTransientError({ code: "57P01" })).toBe(true); + }); + + it("returns true for 57P02 (crash_shutdown)", () => { + expect(isTransientError({ code: "57P02" })).toBe(true); + }); + + it("returns true for 57P03 (cannot_connect_now)", () => { + expect(isTransientError({ code: "57P03" })).toBe(true); + }); + + it("returns true for 08006 (connection_failure)", () => { + expect(isTransientError({ code: "08006" })).toBe(true); + }); + + it("returns true for 08003 (connection_does_not_exist)", () => { + expect(isTransientError({ code: "08003" })).toBe(true); + }); + + it("returns true for 08001 (sqlclient_unable_to_establish_sqlconnection)", () => { + expect(isTransientError({ code: "08001" })).toBe(true); + }); + }); + + describe("message substring matching", () => { + it("returns true for 'no available server'", () => { + expect(isTransientError({ message: "no available server" })).toBe(true); + }); + + it("returns true for 'connection terminated'", () => { + expect(isTransientError({ message: "connection terminated unexpectedly" })).toBe(true); + }); + + it("returns true for 'connection refused'", () => { + expect(isTransientError({ message: "connection refused by server" })).toBe(true); + }); + + it("returns true for 'server closed the connection'", () => { + expect(isTransientError({ message: "server closed the connection unexpectedly" })).toBe(true); + }); + + it("returns true for 'timeout expired'", () => { + expect(isTransientError({ message: "timeout expired waiting for connection" })).toBe(true); + }); + }); + + describe("case-insensitive message matching", () => { + it("returns true for 'No Available Server' (mixed case)", () => { + expect(isTransientError({ message: "No Available Server" })).toBe(true); + }); + + it("returns true for 'CONNECTION TERMINATED' (uppercase)", () => { + expect(isTransientError({ message: "CONNECTION TERMINATED" })).toBe(true); + }); + + it("returns true for 'Connection Refused' (title case)", () => { + expect(isTransientError({ message: "Connection Refused" })).toBe(true); + }); + + it("returns true for 'SERVER CLOSED THE CONNECTION' (uppercase)", () => { + expect(isTransientError({ message: "SERVER CLOSED THE CONNECTION" })).toBe(true); + }); + + it("returns true for 'Timeout Expired' (title case)", () => { + expect(isTransientError({ message: "Timeout Expired" })).toBe(true); + }); + }); + + describe("non-transient errors", () => { + it("returns false for syntax error", () => { + expect(isTransientError({ + code: "42601", + message: "syntax error at or near SELECT" + })).toBe(false); + }); + + it("returns false for unique constraint violation", () => { + expect(isTransientError({ + code: "23505", + message: "duplicate key value violates unique constraint" + })).toBe(false); + }); + + it("returns false for foreign key violation", () => { + expect(isTransientError({ + code: "23503", + message: "foreign key constraint violation" + })).toBe(false); + }); + + it("returns false for not null violation", () => { + expect(isTransientError({ + code: "23502", + message: "null value in column violates not-null constraint" + })).toBe(false); + }); + + it("returns false for permission denied", () => { + expect(isTransientError({ + code: "42501", + message: "permission denied for table users" + })).toBe(false); + }); + }); + + describe("unrelated codes and messages", () => { + it("returns false for unrelated error code", () => { + expect(isTransientError({ code: "UNKNOWN_ERROR" })).toBe(false); + }); + + it("returns false for unrelated error message", () => { + expect(isTransientError({ message: "Something went wrong" })).toBe(false); + }); + + it("returns false for generic database error", () => { + expect(isTransientError({ + code: "P0001", + message: "Database operation failed" + })).toBe(false); + }); + + it("returns false for application error", () => { + expect(isTransientError({ + message: "Invalid user input" + })).toBe(false); + }); + }); + + describe("edge cases", () => { + it("returns true when both code and message match", () => { + expect(isTransientError({ + code: "ECONNRESET", + message: "connection terminated" + })).toBe(true); + }); + + it("returns true when only code matches", () => { + expect(isTransientError({ + code: "ETIMEDOUT", + message: "some other message" + })).toBe(true); + }); + + it("returns true when only message matches", () => { + expect(isTransientError({ + code: "SOME_CODE", + message: "no available server to connect" + })).toBe(true); + }); + + it("returns false for error with only unrelated code", () => { + expect(isTransientError({ code: "NOTFOUND" })).toBe(false); + }); + + it("returns false for error with empty message", () => { + expect(isTransientError({ message: "" })).toBe(false); + }); + }); +}); From ecc7b9640c5149bdee12636e06e64cae32982dea Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 08:06:55 +0100 Subject: [PATCH 082/174] feat: add PDF options validation to demo route (TDD) --- src/__tests__/demo.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/routes/demo.ts | 18 ++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/__tests__/demo.test.ts b/src/__tests__/demo.test.ts index e613bfe..295f90c 100644 --- a/src/__tests__/demo.test.ts +++ b/src/__tests__/demo.test.ts @@ -83,6 +83,42 @@ describe("POST /v1/demo/html", () => { expect(calledHtml).toContain("DEMO"); expect(calledHtml).toContain("docfast.dev"); }); + + it("returns 400 for invalid scale", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", scale: 99 }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/scale/); + }); + + it("returns 400 for invalid format", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", format: "INVALID" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/format/); + }); + + it("returns 400 for non-boolean landscape", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", landscape: "yes" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/landscape/); + }); + + it("returns 400 for invalid margin", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", margin: "10px" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/margin/); + }); }); describe("POST /v1/demo/markdown", () => { @@ -145,4 +181,22 @@ describe("POST /v1/demo/markdown", () => { expect(calledHtml).toContain("DEMO"); expect(calledHtml).toContain("docfast.dev"); }); + + it("returns 400 for invalid scale", async () => { + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello", scale: 99 }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/scale/); + }); + + it("returns 400 for invalid format", async () => { + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello", format: "INVALID" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/format/); + }); }); diff --git a/src/routes/demo.ts b/src/routes/demo.ts index fb8a094..8e5a28f 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -3,6 +3,8 @@ import rateLimit from "express-rate-limit"; import { renderPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import logger from "../services/logger.js"; +import { sanitizeFilename } from "../utils/sanitize.js"; +import { validatePdfOptions } from "../utils/pdf-options.js"; const router = Router(); @@ -42,10 +44,6 @@ interface DemoBody { filename?: string; } -function sanitizeFilename(name: string): string { - return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; -} - /** * @openapi * /v1/demo/html: @@ -114,6 +112,12 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise Promise< return; } + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + if (req.acquirePdfSlot) { await req.acquirePdfSlot(); slotAcquired = true; From d976afebc5ed6c970927ea1a720a36fd7eb3c262 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 11:03:18 +0100 Subject: [PATCH 083/174] test: add escapeHtml utility tests --- src/__tests__/html.test.ts | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/__tests__/html.test.ts diff --git a/src/__tests__/html.test.ts b/src/__tests__/html.test.ts new file mode 100644 index 0000000..45f264b --- /dev/null +++ b/src/__tests__/html.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { escapeHtml } from '../utils/html'; + +describe('escapeHtml', () => { + it('escapes ampersands', () => { + expect(escapeHtml('foo & bar')).toBe('foo & bar'); + }); + + it('escapes less-than', () => { + expect(escapeHtml('a < b')).toBe('a < b'); + }); + + it('escapes greater-than', () => { + expect(escapeHtml('a > b')).toBe('a > b'); + }); + + it('escapes double quotes', () => { + expect(escapeHtml('say "hello"')).toBe('say "hello"'); + }); + + it('escapes single quotes', () => { + expect(escapeHtml("it's")).toBe('it's'); + }); + + it('returns empty string unchanged', () => { + expect(escapeHtml('')).toBe(''); + }); + + it('passes through strings with no special chars', () => { + expect(escapeHtml('hello world 123')).toBe('hello world 123'); + }); + + it('escapes multiple special chars combined', () => { + expect(escapeHtml('
    &
    ')).toBe('<div class="x">&</div>'); + }); + + it('escapes XSS payload', () => { + expect(escapeHtml('')).toBe('<script>alert("xss")</script>'); + }); + + it('double-escapes existing entities', () => { + expect(escapeHtml('&')).toBe('&amp;'); + expect(escapeHtml('<')).toBe('&lt;'); + }); + + it('escapes single quotes in attributes', () => { + expect(escapeHtml("data-x='val'")).toBe('data-x='val''); + }); +}); From 7808d85ddef5662461ef60588ff9605fa61af14f Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 11:05:08 +0100 Subject: [PATCH 084/174] fix: add .js extension to html test import (TypeScript moduleResolution) --- src/__tests__/html.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/html.test.ts b/src/__tests__/html.test.ts index 45f264b..412ebf2 100644 --- a/src/__tests__/html.test.ts +++ b/src/__tests__/html.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { escapeHtml } from '../utils/html'; +import { escapeHtml } from '../utils/html.js'; describe('escapeHtml', () => { it('escapes ampersands', () => { From 4887e8ffbed1cd5eb86efeac91d815ae545e2954 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 14:05:43 +0100 Subject: [PATCH 085/174] test: add missing email-change verify edge cases (expired, max_attempts) --- src/__tests__/email-change.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/__tests__/email-change.test.ts b/src/__tests__/email-change.test.ts index 48b0377..e87b23c 100644 --- a/src/__tests__/email-change.test.ts +++ b/src/__tests__/email-change.test.ts @@ -105,6 +105,20 @@ describe("POST /v1/email-change/verify", () => { expect(res.status).toBe(400); }); + it("returns 410 for expired code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "expired" }); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); + expect(res.status).toBe(410); + }); + + it("returns 429 for max attempts", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" }); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); + expect(res.status).toBe(429); + }); + it("returns 200 and updates email on success", async () => { const { queryWithRetry } = await import("../services/db.js"); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); From bb0a17a6f3c1b1fffb54eab4863d6d42a627add7 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 17:03:50 +0100 Subject: [PATCH 086/174] test: add 14 comprehensive template service tests Cover edge cases for invoice and receipt rendering: - Custom currency (invoice + receipt) - Multiple items with different tax rates - Zero tax rate - Missing optional fields - All optional fields present - Receipt with/without to field - Receipt paymentMethod - Empty items array (invoice + receipt) - Missing quantity (defaults to 1) - Missing unitPrice (defaults to 0) - Template list completeness check Total tests: 428 (was 414) --- src/__tests__/templates.test.ts | 182 ++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/__tests__/templates.test.ts b/src/__tests__/templates.test.ts index 52ca00b..9c5147b 100644 --- a/src/__tests__/templates.test.ts +++ b/src/__tests__/templates.test.ts @@ -54,4 +54,186 @@ describe("Template rendering", () => { expect(html).toContain("'"); expect(html).toContain("&"); }); + + // --- New tests --- + + it("invoice with custom currency uses $ instead of โ‚ฌ", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-100", date: "2026-02-01", + from: { name: "US Corp" }, to: { name: "Client" }, + items: [{ description: "Service", quantity: 1, unitPrice: 50 }], + currency: "$", + }); + expect(html).toContain("$50.00"); + expect(html).not.toContain("โ‚ฌ"); + }); + + it("invoice with multiple items calculates correct subtotal, tax, and total", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-200", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [ + { description: "Item A", quantity: 2, unitPrice: 100, taxRate: 20 }, // 200 + 40 tax + { description: "Item B", quantity: 1, unitPrice: 50, taxRate: 10 }, // 50 + 5 tax + { description: "Item C", quantity: 3, unitPrice: 30, taxRate: 0 }, // 90 + 0 tax + ], + }); + // Subtotal: 200 + 50 + 90 = 340 + expect(html).toContain("โ‚ฌ340.00"); + // Tax: 40 + 5 + 0 = 45 + expect(html).toContain("โ‚ฌ45.00"); + // Total: 385 + expect(html).toContain("โ‚ฌ385.00"); + }); + + it("invoice with zero tax rate shows 0% and no tax amount", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-300", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Tax-free item", quantity: 1, unitPrice: 100, taxRate: 0 }], + }); + expect(html).toContain("0%"); + // Subtotal and total should be the same + expect(html).toContain("Subtotal: โ‚ฌ100.00"); + expect(html).toContain("Tax: โ‚ฌ0.00"); + expect(html).toContain("Total: โ‚ฌ100.00"); + }); + + it("invoice with missing optional fields renders without errors", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-400", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Basic", quantity: 1, unitPrice: 10 }], + // no dueDate, no notes, no paymentDetails + }); + expect(html).toContain("INVOICE"); + expect(html).toContain("INV-400"); + expect(html).not.toContain("Due:"); + expect(html).not.toContain("Payment Details"); + expect(html).not.toContain("Notes"); + }); + + it("invoice with all optional fields renders them all", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-500", date: "2026-02-01", + dueDate: "2026-03-01", + from: { name: "Full Seller", address: "123 Main St", email: "seller@test.com", phone: "+1234", vatId: "AT123" }, + to: { name: "Full Buyer", address: "456 Oak Ave", email: "buyer@test.com", vatId: "DE456" }, + items: [{ description: "Premium", quantity: 1, unitPrice: 200, taxRate: 10 }], + currency: "โ‚ฌ", + notes: "Please pay promptly", + paymentDetails: "IBAN: AT123456", + }); + expect(html).toContain("Due: 2026-03-01"); + expect(html).toContain("123 Main St"); + expect(html).toContain("seller@test.com"); + expect(html).toContain("VAT: AT123"); + expect(html).toContain("456 Oak Ave"); + expect(html).toContain("buyer@test.com"); + expect(html).toContain("VAT: DE456"); + expect(html).toContain("Please pay promptly"); + expect(html).toContain("IBAN: AT123456"); + }); + + it("receipt with custom currency uses ยฃ", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-100", date: "2026-02-01", + from: { name: "UK Shop" }, + items: [{ description: "Tea", amount: 3.50 }], + currency: "ยฃ", + }); + expect(html).toContain("ยฃ3.50"); + expect(html).not.toContain("โ‚ฌ"); + }); + + it("receipt with paymentMethod shows it", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-200", date: "2026-02-01", + from: { name: "Shop" }, + items: [{ description: "Item", amount: 10 }], + paymentMethod: "Credit Card", + }); + expect(html).toContain("Paid via: Credit Card"); + }); + + it("receipt with to field shows customer name", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-300", date: "2026-02-01", + from: { name: "Shop" }, + to: { name: "Jane Doe" }, + items: [{ description: "Item", amount: 5 }], + }); + expect(html).toContain("Customer: Jane Doe"); + }); + + it("receipt without to field renders without error", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-400", date: "2026-02-01", + from: { name: "Shop" }, + items: [{ description: "Item", amount: 5 }], + }); + expect(html).toContain("Shop"); + expect(html).not.toContain("Customer:"); + }); + + it("invoice with empty items array renders with โ‚ฌ0.00 total", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-600", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [], + }); + expect(html).toContain("Total: โ‚ฌ0.00"); + }); + + it("receipt with empty items array renders with โ‚ฌ0.00 total", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-500", date: "2026-02-01", + from: { name: "Shop" }, + items: [], + }); + expect(html).toContain("โ‚ฌ0.00"); + }); + + it("invoice items with missing quantity defaults to 1", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-700", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Widget", unitPrice: 25 }], + }); + // quantity defaults to 1, so line total = 25 + expect(html).toContain("โ‚ฌ25.00"); + }); + + it("invoice items with missing unitPrice defaults to 0", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-800", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Free item", quantity: 5 }], + }); + // unitPrice defaults to 0, line total = 0 + expect(html).toContain("Total: โ‚ฌ0.00"); + }); + + it("template list contains both invoice and receipt with correct field definitions", () => { + expect(templates).toHaveProperty("invoice"); + expect(templates).toHaveProperty("receipt"); + expect(templates.invoice.name).toBe("Invoice"); + expect(templates.receipt.name).toBe("Receipt"); + // Invoice required fields + const invoiceRequired = templates.invoice.fields.filter(f => f.required).map(f => f.name); + expect(invoiceRequired).toContain("invoiceNumber"); + expect(invoiceRequired).toContain("date"); + expect(invoiceRequired).toContain("from"); + expect(invoiceRequired).toContain("to"); + expect(invoiceRequired).toContain("items"); + // Receipt required fields + const receiptRequired = templates.receipt.fields.filter(f => f.required).map(f => f.name); + expect(receiptRequired).toContain("receiptNumber"); + expect(receiptRequired).toContain("date"); + expect(receiptRequired).toContain("from"); + expect(receiptRequired).toContain("items"); + // Receipt 'to' is optional + const receiptTo = templates.receipt.fields.find(f => f.name === "to"); + expect(receiptTo?.required).toBe(false); + }); }); From 82946ffcf060aa0284b3e17346a69413e97a2ff0 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 20:03:55 +0100 Subject: [PATCH 087/174] fix(BUG-092): add Change Email link to footer on landing and sub-pages --- public/examples.html | 1 + public/impressum.html | 1 + public/index.html | 1 + public/partials/_footer.html | 1 + public/privacy.html | 1 + public/src/index.html | 1 + public/status.html | 1 + public/terms.html | 1 + src/__tests__/app-routes.test.ts | 21 +++++++++++++++++++++ 9 files changed, 29 insertions(+) diff --git a/public/examples.html b/public/examples.html index 595559c..fb5a2a7 100644 --- a/public/examples.html +++ b/public/examples.html @@ -408,6 +408,7 @@ $pdf = DocFast::html(view('invoice')->render()); Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/impressum.html b/public/impressum.html index 310ab15..d76f919 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -110,6 +110,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/index.html b/public/index.html index 9f776a0..629c9d3 100644 --- a/public/index.html +++ b/public/index.html @@ -586,6 +586,7 @@ html, body { Examples API Status Support + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/partials/_footer.html b/public/partials/_footer.html index 9585632..b64c902 100644 --- a/public/partials/_footer.html +++ b/public/partials/_footer.html @@ -6,6 +6,7 @@ Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/privacy.html b/public/privacy.html index ba5d1ee..47caaed 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -192,6 +192,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/src/index.html b/public/src/index.html index 9f776a0..629c9d3 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -586,6 +586,7 @@ html, body { Examples API Status Support + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/status.html b/public/status.html index 996f100..1a79802 100644 --- a/public/status.html +++ b/public/status.html @@ -106,6 +106,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/terms.html b/public/terms.html index f240aff..203b61e 100644 --- a/public/terms.html +++ b/public/terms.html @@ -264,6 +264,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/src/__tests__/app-routes.test.ts b/src/__tests__/app-routes.test.ts index 27b7c96..c551053 100644 --- a/src/__tests__/app-routes.test.ts +++ b/src/__tests__/app-routes.test.ts @@ -129,4 +129,25 @@ describe("App-level routes", () => { expect(res.headers["permissions-policy"]).toContain("camera=()"); }); }); + + describe("BUG-092: Footer Change Email link", () => { + it("landing page footer contains Change Email link", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(200); + const html = res.text; + expect(html).toContain('class="open-email-change"'); + expect(html).toMatch(/footer-links[\s\S]*open-email-change[\s\S]*Change Email/); + }); + + it("sub-page footer partial contains Change Email link", async () => { + const fs = await import("fs"); + const path = await import("path"); + const footer = fs.readFileSync( + path.join(__dirname, "../../public/partials/_footer.html"), + "utf-8" + ); + expect(footer).toContain('class="open-email-change"'); + expect(footer).toContain('href="/#change-email"'); + }); + }); }); From 9eb9b4232b89acc21079fd741543d7207dd15d41 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 20:05:01 +0100 Subject: [PATCH 088/174] test: add billing edge case tests (characterization) --- src/__tests__/billing.test.ts | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 07790dc..30701bc 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -137,6 +137,36 @@ describe("GET /v1/billing/success", () => { const res = await request(app).get("/v1/billing/success?session_id=cs_err"); expect(res.status).toBe(500); }); + + it("returns 400 when session has no customer", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_cust", + customer: null, + customer_details: { email: "test@test.com" }, + }); + const res = await request(app).get("/v1/billing/success?session_id=cs_no_cust"); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/No customer found/); + }); + + it("escapes HTML in displayed key to prevent XSS", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_xss", + customer: "cus_xss", + customer_details: { email: "xss@test.com" }, + }); + const { createProKey } = await import("../services/keys.js"); + vi.mocked(createProKey).mockResolvedValue({ + key: '', + tier: "pro", + email: "xss@test.com", + createdAt: new Date().toISOString(), + } as any); + const res = await request(app).get("/v1/billing/success?session_id=cs_xss"); + expect(res.status).toBe(200); + expect(res.text).not.toContain(''); + expect(res.text).toContain("<script>"); + }); }); describe("POST /v1/billing/webhook", () => { @@ -275,6 +305,170 @@ describe("POST /v1/billing/webhook", () => { expect(downgradeByCustomer).toHaveBeenCalledWith("cus_cancel"); }); + it("does not provision key when checkout.session.completed has missing customer", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_no_cust", + customer: null, + customer_details: { email: "nocust@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_cust", + line_items: { data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }] }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(createProKey).not.toHaveBeenCalled(); + }); + + it("does not provision key when checkout.session.completed has missing email", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_no_email", + customer: "cus_no_email", + customer_details: {}, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_email", + line_items: { data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }] }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(createProKey).not.toHaveBeenCalled(); + }); + + it("does not downgrade on customer.subscription.updated with non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_other", customer: "cus_other", status: "canceled", cancel_at_period_end: false }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_OTHER" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("downgrades on customer.subscription.updated with past_due status", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_past", customer: "cus_past", status: "past_due", cancel_at_period_end: false }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_TygeG8tQPtEAdE" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_past"); + }); + + it("does not downgrade on customer.subscription.updated with active status and no cancel", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_ok", customer: "cus_ok", status: "active", cancel_at_period_end: false }, + }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("does not downgrade on customer.subscription.deleted with non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.deleted", + data: { + object: { id: "sub_del_other", customer: "cus_del_other" }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_OTHER" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.deleted" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("returns 200 for unknown event type", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "invoice.payment_failed", + data: { object: {} }, + }); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "invoice.payment_failed" })); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it("returns 200 when session retrieve fails on checkout.session.completed", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_fail_retrieve", + customer: "cus_fail", + customer_details: { email: "fail@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockRejectedValue(new Error("Stripe retrieve failed")); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + expect(createProKey).not.toHaveBeenCalled(); + }); + it("syncs email on customer.updated", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated", From cf1a589a47d852b820f51c5e431d264e70f3af04 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Mon, 2 Mar 2026 08:12:30 +0100 Subject: [PATCH 089/174] chore: bump to v0.5.2, update sitemap dates, add .dockerignore, update deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version bump 0.5.1 โ†’ 0.5.2 (24 commits since last tag) - Update sitemap lastmod dates to 2026-03-02 - Add .dockerignore to exclude node_modules, .git, tests from build context - Update minor deps: pg, puppeteer, stripe, swagger-ui-dist, @types/* - npm audit: 0 vulnerabilities, 440 tests passing --- .dockerignore | 10 ++++ package-lock.json | 142 ++++++++++++++++++++++++--------------------- package.json | 2 +- public/sitemap.xml | 14 ++--- 4 files changed, 93 insertions(+), 75 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0eb4c73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.gitignore +*.md +src/__tests__ +vitest.config.ts +.env* +.credentials +memory +dist diff --git a/package-lock.json b/package-lock.json index 30e5f96..7838e6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docfast-api", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docfast-api", - "version": "0.5.1", + "version": "0.5.2", "dependencies": { "compression": "^1.8.1", "express": "^4.21.0", @@ -631,9 +631,9 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" }, "node_modules/@puppeteer/browsers": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.1.tgz", - "integrity": "sha512-fXa6uXLxfslBlus3MEpW8S6S9fe5RwmAE5Gd8u3krqOwnkZJV3/lQJiY3LaFdTctLLqJtyMgEUGkbDnRNf6vbQ==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", "license": "Apache-2.0", "dependencies": { "debug": "^4.4.3", @@ -1155,18 +1155,19 @@ } }, "node_modules/@types/nodemailer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", - "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/pg": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", - "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1482,9 +1483,9 @@ } }, "node_modules/b4a": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", - "integrity": "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -1516,11 +1517,10 @@ } }, "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -1541,11 +1541,10 @@ } }, "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.0.tgz", + "integrity": "sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g==", "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -1555,19 +1554,18 @@ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", + "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "streamx": "^2.21.0", + "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", @@ -1587,7 +1585,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -3311,14 +3308,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -3360,18 +3357,18 @@ } }, "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", "license": "MIT" }, "node_modules/pg-types": { @@ -3625,9 +3622,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3635,17 +3632,17 @@ } }, "node_modules/puppeteer": { - "version": "24.37.3", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.3.tgz", - "integrity": "sha512-AUGGWq0BhPM+IOS2U9A+ZREH3HDFkV1Y5HERYGDg5cbGXjoGsTCT7/A6VZRfNU0UJJdCclyEimZICkZW6pqJyw==", + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.5.tgz", + "integrity": "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.1", + "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1566079", - "puppeteer-core": "24.37.3", + "puppeteer-core": "24.37.5", "typed-query-selector": "^2.12.0" }, "bin": { @@ -3656,12 +3653,12 @@ } }, "node_modules/puppeteer-core": { - "version": "24.37.3", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.3.tgz", - "integrity": "sha512-fokQ8gv+hNgsRWqVuP5rUjGp+wzV5aMTP3fcm8ekNabmLGlJdFHas1OdMscAH9Gzq4Qcf7cfI/Pe6wEcAqQhqg==", + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", + "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.1", + "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", @@ -4187,9 +4184,9 @@ "license": "MIT" }, "node_modules/stripe": { - "version": "20.3.1", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", - "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", "license": "MIT", "engines": { "node": ">=16" @@ -4338,9 +4335,10 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.31.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", - "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", + "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", + "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" } @@ -4360,16 +4358,26 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -4390,9 +4398,9 @@ } }, "node_modules/text-decoder": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.6.tgz", - "integrity": "sha512-27FeW5GQFDfw0FpwMQhMagB7BztOOlmjcSRi97t2oplhKVTZtp0DZbSegSaXS5IIC6mxMvBG4AR1Sgc6BX3CQg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -4519,9 +4527,9 @@ } }, "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", "license": "MIT" }, "node_modules/typescript": { diff --git a/package.json b/package.json index 64440f2..6acc74b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.5.1", + "version": "0.5.2", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { diff --git a/public/sitemap.xml b/public/sitemap.xml index 6749df1..0f9e41a 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,10 +1,10 @@ - https://docfast.dev/2026-02-20weekly1.0 - https://docfast.dev/docs2026-02-20weekly0.8 - https://docfast.dev/examples2026-02-20monthly0.7 - https://docfast.dev/impressum2026-02-20monthly0.3 - https://docfast.dev/privacy2026-02-20monthly0.3 - https://docfast.dev/terms2026-02-20monthly0.3 - https://docfast.dev/status2026-02-20always0.2 + https://docfast.dev/2026-03-02weekly1.0 + https://docfast.dev/docs2026-03-02weekly0.8 + https://docfast.dev/examples2026-03-02monthly0.7 + https://docfast.dev/impressum2026-03-02monthly0.3 + https://docfast.dev/privacy2026-03-02monthly0.3 + https://docfast.dev/terms2026-03-02monthly0.3 + https://docfast.dev/status2026-03-02always0.2 From 6290c3eb976652f16096de4dfb4d94e44a3d18e2 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 2 Mar 2026 14:11:13 +0100 Subject: [PATCH 090/174] fix(BUG-095,BUG-097): add Support link to footer partial, expand docs.html footer --- public/docs.html | 6 +++++ public/examples.html | 1 + public/impressum.html | 1 + public/partials/_footer.html | 1 + public/privacy.html | 1 + public/status.html | 1 + public/terms.html | 1 + src/__tests__/app-routes.test.ts | 40 ++++++++++++++++++++++++++++++++ 8 files changed, 52 insertions(+) diff --git a/public/docs.html b/public/docs.html index e99db2a..7d4ba24 100644 --- a/public/docs.html +++ b/public/docs.html @@ -120,6 +120,12 @@