diff --git a/package.json b/package.json index de57f00..0d59065 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.2.9", + "version": "0.3.2", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { diff --git a/public/openapi.json b/public/openapi.json index 56ed829..a8098fa 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -3,7 +3,7 @@ "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## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 10,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents", + "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## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\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" }], diff --git a/public/src/terms.html b/public/src/terms.html index 1a7abca..b36f918 100644 --- a/public/src/terms.html +++ b/public/src/terms.html @@ -52,7 +52,7 @@

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
  • diff --git a/public/terms.html b/public/terms.html index 1340bb2..b950ea7 100644 --- a/public/terms.html +++ b/public/terms.html @@ -98,7 +98,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
    • diff --git a/src/index.ts b/src/index.ts index 07161e0..2768a5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,12 @@ import express from "express"; import { randomUUID } from "crypto"; +import { createRequire } from "module"; import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; + +const _require = createRequire(import.meta.url); +const APP_VERSION: string = _require("../package.json").version; import path from "path"; import { fileURLToPath } from "url"; import rateLimit from "express-rate-limit"; @@ -197,7 +201,7 @@ app.get("/docs", (_req, res) => { // Static asset cache headers middleware app.use((req, res, next) => { - if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { console.log("CACHE HIT:", req.path); + if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); } next(); @@ -235,7 +239,7 @@ app.get("/status", (_req, res) => { app.get("/api", (_req, res) => { res.json({ name: "DocFast API", - version: "0.2.9", + version: APP_VERSION, endpoints: [ "POST /v1/signup/free โ€” Get a free API key", "POST /v1/convert/html", diff --git a/src/index.ts.backup b/src/index.ts.backup deleted file mode 100644 index 31c24d1..0000000 --- a/src/index.ts.backup +++ /dev/null @@ -1,356 +0,0 @@ -import express from "express"; -import { randomUUID } from "crypto"; -import compression from "compression"; -import logger from "./services/logger.js"; -import helmet from "helmet"; -import path from "path"; -import { fileURLToPath } from "url"; -import rateLimit from "express-rate-limit"; -import { convertRouter } from "./routes/convert.js"; -import { templatesRouter } from "./routes/templates.js"; -import { healthRouter } from "./routes/health.js"; -import { signupRouter } from "./routes/signup.js"; -import { recoverRouter } from "./routes/recover.js"; -import { billingRouter } from "./routes/billing.js"; -import { authMiddleware } from "./middleware/auth.js"; -import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; -import { getUsageStats } from "./middleware/usage.js"; -import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRateLimit.js"; -import { initBrowser, closeBrowser } from "./services/browser.js"; -import { loadKeys, getAllKeys } from "./services/keys.js"; -import { verifyToken, loadVerifications } from "./services/verification.js"; -import { initDatabase, pool } from "./services/db.js"; - -const app = express(); -const PORT = parseInt(process.env.PORT || "3100", 10); - -app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); - -// Request ID + request logging middleware -app.use((req, res, next) => { - const requestId = (req.headers["x-request-id"] as string) || randomUUID(); - (req as any).requestId = requestId; - res.setHeader("X-Request-Id", requestId); - const start = Date.now(); - res.on("finish", () => { - const ms = Date.now() - start; - if (req.path !== "/health") { - logger.info({ method: req.method, path: req.path, status: res.statusCode, ms, requestId }, "request"); - } - }); - next(); -}); - -// Permissions-Policy header -app.use((_req, res, next) => { - res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(self)"); - next(); -}); - -// Compression -app.use(compression()); - -// Differentiated CORS middleware -app.use((req, res, next) => { - const isAuthBillingRoute = req.path.startsWith('/v1/signup') || - req.path.startsWith('/v1/recover') || - req.path.startsWith('/v1/billing'); - - if (isAuthBillingRoute) { - res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); - } else { - res.setHeader("Access-Control-Allow-Origin", "*"); - } - - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key"); - res.setHeader("Access-Control-Max-Age", "86400"); - - if (req.method === "OPTIONS") { - res.status(204).end(); - return; - } - next(); -}); - -// Raw body for Stripe webhook signature verification -app.use("/v1/billing/webhook", express.raw({ type: "application/json" })); -app.use(express.json({ limit: "2mb" })); -app.use(express.text({ limit: "2mb", type: "text/*" })); - -// Trust nginx proxy -app.set("trust proxy", 1); - -// Global rate limiting - reduced from 10,000 to reasonable limit -const limiter = rateLimit({ - windowMs: 60_000, - max: 100, - standardHeaders: true, - legacyHeaders: false, -}); -app.use(limiter); - -// Public routes -app.use("/health", healthRouter); -app.use("/v1/signup", signupRouter); -app.use("/v1/recover", recoverRouter); -app.use("/v1/billing", billingRouter); - -// Authenticated routes โ€” conversion routes get tighter body limits (500KB) -const convertBodyLimit = express.json({ limit: "500kb" }); -app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter); -app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); - -// Admin: usage stats (admin key required) -const adminAuth = (req: any, res: any, next: any) => { - const adminKey = process.env.ADMIN_API_KEY; - if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; } - if (req.apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; } - next(); -}; -app.get("/v1/usage", authMiddleware, adminAuth, (req: any, res: any) => { - res.json(getUsageStats(req.apiKeyInfo?.key)); -}); - -// Admin: concurrency stats (admin key required) -app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: any, res: any) => { - res.json(getConcurrencyStats()); -}); - -// Email verification endpoint -app.get("/verify", (req, res) => { - const token = req.query.token as string; - if (!token) { - res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null)); - return; - } - - const result = verifyToken(token); - - switch (result.status) { - case "ok": - res.send(verifyPage("Email Verified! ๐Ÿš€", "Your DocFast API key is ready:", result.verification!.apiKey)); - break; - case "already_verified": - res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification!.apiKey)); - break; - case "expired": - res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null)); - break; - case "invalid": - res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null)); - break; - } -}); - -function verifyPage(title: string, message: string, apiKey: string | null): string { - return ` - -${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/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