diff --git a/dist/index.js b/dist/index.js index 4152bcf..d6e4197 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,15 +1,18 @@ 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 = _require("../package.json").version; 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 { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; @@ -20,6 +23,7 @@ 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"; +import { swaggerSpec } from "./swagger.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); @@ -48,7 +52,8 @@ app.use(compressionMiddleware); app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || req.path.startsWith('/v1/recover') || - req.path.startsWith('/v1/billing'); + req.path.startsWith('/v1/billing') || + req.path.startsWith('/v1/demo'); if (isAuthBillingRoute) { res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); } @@ -80,7 +85,36 @@ const limiter = rateLimit({ app.use(limiter); // Public routes app.use("/health", healthRouter); -app.use("/v1/signup", signupRouter); +app.use("/v1/demo", express.json({ limit: "50kb" }), pdfRateLimitMiddleware, demoRouter); +/** + * @openapi + * /v1/signup/free: + * post: + * tags: [Account] + * summary: Request a free API key (discontinued) + * description: Free accounts have been discontinued. Use the demo endpoints or upgrade to Pro. + * responses: + * 410: + * description: Feature discontinued + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * demo_endpoint: + * type: string + * pro_url: + * type: string + */ +app.use("/v1/signup", (_req, res) => { + res.status(410).json({ + error: "Free accounts have been discontinued. Try our demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev", + demo_endpoint: "/v1/demo/html", + pro_url: "https://docfast.dev/#pricing" + }); +}); app.use("/v1/recover", recoverRouter); app.use("/v1/billing", billingRouter); // Authenticated routes — conversion routes get tighter body limits (500KB) @@ -156,7 +190,7 @@ p{color:#7a8194;margin-bottom:24px;line-height:1.6} ${apiKey ? `
⚠️ Save your API key securely. You can recover it via email if needed.
${apiKey}
- + ` : ``} `; } @@ -168,6 +202,10 @@ app.get("/favicon.ico", (_req, res) => { res.setHeader('Cache-Control', 'public, max-age=604800'); res.sendFile(path.join(__dirname, "../public/favicon.svg")); }); +// Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup +app.get("/openapi.json", (_req, res) => { + res.json(swaggerSpec); +}); // Docs page (clean URL) app.get("/docs", (_req, res) => { // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. @@ -179,7 +217,6 @@ 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); res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); } next(); @@ -209,12 +246,13 @@ 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", - "POST /v1/convert/markdown", - "POST /v1/convert/url", + "POST /v1/demo/html — Try HTML→PDF (no auth, watermarked, 5/hour)", + "POST /v1/demo/markdown — Try Markdown→PDF (no auth, watermarked, 5/hour)", + "POST /v1/convert/html — HTML→PDF (requires API key)", + "POST /v1/convert/markdown — Markdown→PDF (requires API key)", + "POST /v1/convert/url — URL→PDF (requires API key)", "POST /v1/templates/:id/render", "GET /v1/templates", "POST /v1/billing/checkout — Start Pro subscription", diff --git a/dist/routes/billing.js b/dist/routes/billing.js index 0c59405..dbc7c3b 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -1,4 +1,5 @@ import { Router } from "express"; +import rateLimit from "express-rate-limit"; import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js"; import logger from "../services/logger.js"; @@ -39,8 +40,51 @@ async function isDocFastSubscription(subscriptionId) { return false; } } -// Create a Stripe Checkout session for Pro subscription -router.post("/checkout", async (_req, res) => { +// Rate limit checkout: max 3 requests per IP per hour +const checkoutLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, + keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown", + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many checkout requests. Please try again later." }, +}); +/** + * @openapi + * /v1/billing/checkout: + * post: + * tags: [Billing] + * summary: Create a Stripe checkout session + * description: | + * Creates a Stripe Checkout session for a Pro subscription (€9/month). + * Returns a URL to redirect the user to Stripe's hosted payment page. + * Rate limited to 3 requests per hour per IP. + * 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 + */ +router.post("/checkout", checkoutLimiter, async (req, res) => { + // Reject suspiciously large request bodies (>1KB) + const contentLength = parseInt(req.headers["content-length"] || "0", 10); + if (contentLength > 1024) { + res.status(413).json({ error: "Request body too large" }); + return; + } try { const priceId = await getOrCreateProPrice(); const session = await getStripe().checkout.sessions.create({ @@ -50,6 +94,8 @@ router.post("/checkout", async (_req, res) => { success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`, }); + const clientIp = req.ip || req.socket.remoteAddress || "unknown"; + logger.info({ clientIp, sessionId: session.id }, "Checkout session created"); res.json({ url: session.url }); } catch (err) { diff --git a/dist/routes/convert.js b/dist/routes/convert.js index d7b7721..0aa9c50 100644 --- a/dist/routes/convert.js +++ b/dist/routes/convert.js @@ -41,7 +41,55 @@ function sanitizeFilename(name) { return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; } export const convertRouter = Router(); -// POST /v1/convert/html +/** + * @openapi + * /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 + */ convertRouter.post("/html", async (req, res) => { let slotAcquired = false; try { @@ -90,7 +138,54 @@ convertRouter.post("/html", async (req, res) => { } } }); -// POST /v1/convert/markdown +/** + * @openapi + * /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 + */ convertRouter.post("/markdown", async (req, res) => { let slotAcquired = false; try { @@ -136,7 +231,59 @@ convertRouter.post("/markdown", async (req, res) => { } } }); -// POST /v1/convert/url +/** + * @openapi + * /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. + * Private/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding. + * 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 + */ convertRouter.post("/url", async (req, res) => { let slotAcquired = false; try { diff --git a/dist/routes/health.js b/dist/routes/health.js index cf3a146..219fcd2 100644 --- a/dist/routes/health.js +++ b/dist/routes/health.js @@ -6,6 +6,56 @@ const require = createRequire(import.meta.url); const { version: APP_VERSION } = require("../../package.json"); export const healthRouter = Router(); const HEALTH_CHECK_TIMEOUT_MS = 3000; +/** + * @openapi + * /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) + */ healthRouter.get("/", async (_req, res) => { const poolStats = getPoolStats(); let databaseStatus; diff --git a/dist/routes/recover.js b/dist/routes/recover.js index cf8bc9f..f1d13fc 100644 --- a/dist/routes/recover.js +++ b/dist/routes/recover.js @@ -12,6 +12,46 @@ const recoverLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, }); +/** + * @openapi + * /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. + * Response is always the same regardless of whether the email exists (to prevent enumeration). + * Rate limited to 3 requests per hour. + * 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 + */ router.post("/", recoverLimiter, async (req, res) => { const { email } = req.body || {}; if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { @@ -31,6 +71,52 @@ router.post("/", recoverLimiter, async (req, res) => { }); res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); }); +/** + * @openapi + * /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 + */ router.post("/verify", recoverLimiter, async (req, res) => { const { email, code } = req.body || {}; if (!email || !code) { diff --git a/dist/routes/templates.js b/dist/routes/templates.js index 5957e83..dae4e9d 100644 --- a/dist/routes/templates.js +++ b/dist/routes/templates.js @@ -6,7 +6,53 @@ function sanitizeFilename(name) { return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200); } export const templatesRouter = Router(); -// GET /v1/templates — list available templates +/** + * @openapi + * /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 + */ templatesRouter.get("/", (_req, res) => { const list = Object.entries(templates).map(([id, t]) => ({ id, @@ -16,7 +62,71 @@ templatesRouter.get("/", (_req, res) => { })); res.json({ templates: list }); }); -// POST /v1/templates/:id/render — render template to PDF +/** + * @openapi + * /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. + * Use GET /v1/templates to see available templates and their required fields. + * Special fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename). + * 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 + */ templatesRouter.post("/:id/render", async (req, res) => { try { const id = req.params.id; diff --git a/package-lock.json b/package-lock.json index 3a19ea0..349c20e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.11.0", + "@types/swagger-jsdoc": "^6.0.4", "terser": "^5.46.0", "tsx": "^4.19.0", "typescript": "^5.7.0", @@ -1170,6 +1171,13 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", diff --git a/package.json b/package.json index 1844068..9395b77 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,11 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.11.0", + "@types/swagger-jsdoc": "^6.0.4", "terser": "^5.46.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" }, "type": "module" -} \ No newline at end of file +} diff --git a/public/openapi.json b/public/openapi.json deleted file mode 100644 index 2192f5d..0000000 --- a/public/openapi.json +++ /dev/null @@ -1,1052 +0,0 @@ -{ - "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 diff --git a/src/index.ts b/src/index.ts index 284c94b..b039c07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ 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"; +import { swaggerSpec } from "./swagger.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); @@ -98,6 +99,28 @@ app.use(limiter); // Public routes app.use("/health", healthRouter); app.use("/v1/demo", express.json({ limit: "50kb" }), pdfRateLimitMiddleware, demoRouter); +/** + * @openapi + * /v1/signup/free: + * post: + * tags: [Account] + * summary: Request a free API key (discontinued) + * description: Free accounts have been discontinued. Use the demo endpoints or upgrade to Pro. + * responses: + * 410: + * description: Feature discontinued + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * demo_endpoint: + * type: string + * pro_url: + * type: string + */ app.use("/v1/signup", (_req, res) => { res.status(410).json({ error: "Free accounts have been discontinued. Try our demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev", @@ -196,6 +219,11 @@ app.get("/favicon.ico", (_req, res) => { res.sendFile(path.join(__dirname, "../public/favicon.svg")); }); +// Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup +app.get("/openapi.json", (_req, res) => { + res.json(swaggerSpec); +}); + // Docs page (clean URL) app.get("/docs", (_req, res) => { // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. diff --git a/src/swagger.ts b/src/swagger.ts new file mode 100644 index 0000000..7a605db --- /dev/null +++ b/src/swagger.ts @@ -0,0 +1,92 @@ +import swaggerJsdoc from "swagger-jsdoc"; +import { createRequire } from "module"; + +const _require = createRequire(import.meta.url); +const { version } = _require("../package.json"); + +const options: swaggerJsdoc.Options = { + definition: { + openapi: "3.0.3", + info: { + title: "DocFast API", + version, + 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. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev)\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 authentication (watermarked, 5/hour)" }, + { name: "Conversion", description: "Convert HTML, Markdown, or URLs to PDF" }, + { name: "Templates", description: "Built-in document templates" }, + { name: "Account", description: "Signup and key recovery" }, + { 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", example: "20mm" }, + bottom: { type: "string", example: "20mm" }, + left: { type: "string", example: "15mm" }, + right: { type: "string", example: "15mm" }, + }, + }, + printBackground: { + type: "boolean", + default: true, + description: "Print background graphics", + }, + filename: { + type: "string", + default: "document.pdf", + description: "Suggested filename for the PDF", + }, + }, + }, + Error: { + type: "object", + properties: { + error: { type: "string", description: "Error message" }, + }, + }, + }, + }, + }, + apis: ["./dist/routes/*.js", "./dist/index.js"], +}; + +export const swaggerSpec = swaggerJsdoc(options);