docs: add missing OpenAPI annotations for signup/verify, billing/success, billing/webhook
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 16m15s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 16m15s
This commit is contained in:
parent
427ec8e894
commit
8b31d11e74
15 changed files with 2167 additions and 128 deletions
515
dist/__tests__/api.test.js
vendored
515
dist/__tests__/api.test.js
vendored
|
|
@ -1,24 +1,20 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { app } from "../index.js";
|
||||
// Note: These tests require Puppeteer/Chrome to be available
|
||||
// For CI, use the Dockerfile which includes Chrome
|
||||
const BASE = "http://localhost:3199";
|
||||
let server;
|
||||
beforeAll(async () => {
|
||||
process.env.API_KEYS = "test-key";
|
||||
process.env.PORT = "3199";
|
||||
// Import fresh to pick up env
|
||||
server = app.listen(3199);
|
||||
// Wait for browser init
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
});
|
||||
afterAll(async () => {
|
||||
server?.close();
|
||||
await new Promise((resolve) => server?.close(() => resolve()));
|
||||
});
|
||||
describe("Auth", () => {
|
||||
it("rejects requests without API key", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" });
|
||||
expect(res.status).toBe(401);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
it("rejects invalid API key", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
|
|
@ -26,6 +22,8 @@ describe("Auth", () => {
|
|||
headers: { Authorization: "Bearer wrong-key" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
describe("Health", () => {
|
||||
|
|
@ -35,51 +33,243 @@ describe("Health", () => {
|
|||
const data = await res.json();
|
||||
expect(data.status).toBe("ok");
|
||||
});
|
||||
it("includes database field", async () => {
|
||||
const res = await fetch(`${BASE}/health`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.database).toBeDefined();
|
||||
expect(data.database.status).toBeDefined();
|
||||
});
|
||||
it("includes pool field with size, active, available", async () => {
|
||||
const res = await fetch(`${BASE}/health`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.pool).toBeDefined();
|
||||
expect(typeof data.pool.size).toBe("number");
|
||||
expect(typeof data.pool.active).toBe("number");
|
||||
expect(typeof data.pool.available).toBe("number");
|
||||
});
|
||||
it("includes version field", async () => {
|
||||
const res = await fetch(`${BASE}/health`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.version).toBeDefined();
|
||||
expect(typeof data.version).toBe("string");
|
||||
});
|
||||
});
|
||||
describe("HTML to PDF", () => {
|
||||
it("converts simple HTML", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: "<h1>Test</h1>" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
const buf = await res.arrayBuffer();
|
||||
expect(buf.byteLength).toBeGreaterThan(100);
|
||||
// PDF magic bytes
|
||||
expect(buf.byteLength).toBeGreaterThan(10);
|
||||
const header = new Uint8Array(buf.slice(0, 5));
|
||||
expect(String.fromCharCode(...header)).toBe("%PDF-");
|
||||
});
|
||||
it("rejects missing html field", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
it("converts HTML with A3 format option", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: "<h1>A3 Test</h1>", options: { format: "A3" } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
});
|
||||
it("converts HTML with landscape option", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: "<h1>Landscape Test</h1>", options: { landscape: true } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
});
|
||||
it("converts HTML with margin options", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: "<h1>Margin Test</h1>", options: { margin: { top: "2cm" } } }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
});
|
||||
it("rejects invalid JSON body", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: "invalid json{",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
it("rejects wrong content-type header", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "text/plain" },
|
||||
body: JSON.stringify({ html: "<h1>Test</h1>" }),
|
||||
});
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
it("handles empty html string", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: "" }),
|
||||
});
|
||||
// Empty HTML should still generate a PDF (just blank) - but validation may reject it
|
||||
expect([200, 400]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
describe("Markdown to PDF", () => {
|
||||
it("converts markdown", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/markdown`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ markdown: "# Hello\n\nWorld" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
});
|
||||
});
|
||||
describe("URL to PDF", () => {
|
||||
it("rejects missing url field", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/url`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain("url");
|
||||
});
|
||||
it("blocks private IP addresses (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://127.0.0.1" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain("private");
|
||||
});
|
||||
it("blocks localhost (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://localhost" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
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: "<p>hello</p>" }),
|
||||
});
|
||||
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",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "ftp://example.com" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain("http");
|
||||
});
|
||||
it("rejects invalid URL format", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/url`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "not-a-url" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toContain("Invalid");
|
||||
});
|
||||
it("converts valid URL to PDF", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/url`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
const buf = await res.arrayBuffer();
|
||||
expect(buf.byteLength).toBeGreaterThan(10);
|
||||
const header = new Uint8Array(buf.slice(0, 5));
|
||||
expect(String.fromCharCode(...header)).toBe("%PDF-");
|
||||
});
|
||||
});
|
||||
describe("Demo Endpoints", () => {
|
||||
it("demo/html converts HTML to PDF without auth", async () => {
|
||||
const res = await fetch(`${BASE}/v1/demo/html`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ html: "<h1>Demo Test</h1>" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
const buf = await res.arrayBuffer();
|
||||
expect(buf.byteLength).toBeGreaterThan(10);
|
||||
const header = new Uint8Array(buf.slice(0, 5));
|
||||
expect(String.fromCharCode(...header)).toBe("%PDF-");
|
||||
});
|
||||
it("demo/markdown converts markdown to PDF without auth", async () => {
|
||||
const res = await fetch(`${BASE}/v1/demo/markdown`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ markdown: "# Demo Markdown\n\nTest content" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("application/pdf");
|
||||
});
|
||||
it("demo rejects missing html field", async () => {
|
||||
const res = await fetch(`${BASE}/v1/demo/html`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
it("demo rejects wrong content-type", async () => {
|
||||
const res = await fetch(`${BASE}/v1/demo/html`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: "<h1>Test</h1>",
|
||||
});
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
});
|
||||
describe("Templates", () => {
|
||||
it("lists templates", async () => {
|
||||
const res = await fetch(`${BASE}/v1/templates`, {
|
||||
|
|
@ -93,10 +283,7 @@ describe("Templates", () => {
|
|||
it("renders invoice template", async () => {
|
||||
const res = await fetch(`${BASE}/v1/templates/invoice/render`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
invoiceNumber: "TEST-001",
|
||||
date: "2026-02-14",
|
||||
|
|
@ -111,12 +298,280 @@ describe("Templates", () => {
|
|||
it("returns 404 for unknown template", async () => {
|
||||
const res = await fetch(`${BASE}/v1/templates/nonexistent/render`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer test-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
// === NEW TESTS: Task 3 ===
|
||||
describe("Signup endpoint (discontinued)", () => {
|
||||
it("returns 410 Gone", async () => {
|
||||
const res = await fetch(`${BASE}/v1/signup/free`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
});
|
||||
expect(res.status).toBe(410);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
describe("Recovery endpoint validation", () => {
|
||||
it("rejects missing email", async () => {
|
||||
const res = await fetch(`${BASE}/v1/recover`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
it("rejects invalid email format", async () => {
|
||||
const res = await fetch(`${BASE}/v1/recover`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "not-an-email" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
it("accepts valid email (always returns success)", async () => {
|
||||
const res = await fetch(`${BASE}/v1/recover`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "user@example.com" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.status).toBe("recovery_sent");
|
||||
});
|
||||
it("verify rejects missing fields", async () => {
|
||||
const res = await fetch(`${BASE}/v1/recover/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
// May be 400 (validation) or 429 (rate limited from previous recover calls)
|
||||
expect([400, 429]).toContain(res.status);
|
||||
const data = await res.json();
|
||||
expect(data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
describe("CORS headers", () => {
|
||||
it("sets Access-Control-Allow-Origin to * for API routes", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
});
|
||||
it("restricts CORS for signup/billing/demo routes to docfast.dev", async () => {
|
||||
const res = await fetch(`${BASE}/v1/demo/html`, {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("https://docfast.dev");
|
||||
});
|
||||
it("includes correct allowed methods", async () => {
|
||||
const res = await fetch(`${BASE}/health`, { method: "OPTIONS" });
|
||||
const methods = res.headers.get("access-control-allow-methods");
|
||||
expect(methods).toContain("GET");
|
||||
expect(methods).toContain("POST");
|
||||
});
|
||||
});
|
||||
describe("Error response format consistency", () => {
|
||||
it("401 returns {error: string}", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" });
|
||||
expect(res.status).toBe(401);
|
||||
const data = await res.json();
|
||||
expect(typeof data.error).toBe("string");
|
||||
});
|
||||
it("403 returns {error: string}", async () => {
|
||||
const res = await fetch(`${BASE}/v1/convert/html`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer bad-key" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
const data = await res.json();
|
||||
expect(typeof data.error).toBe("string");
|
||||
});
|
||||
it("404 API returns {error: string}", async () => {
|
||||
const res = await fetch(`${BASE}/v1/nonexistent`);
|
||||
expect(res.status).toBe(404);
|
||||
const data = await res.json();
|
||||
expect(typeof data.error).toBe("string");
|
||||
});
|
||||
it("410 returns {error: string}", async () => {
|
||||
const res = await fetch(`${BASE}/v1/signup/free`, { method: "POST" });
|
||||
expect(res.status).toBe(410);
|
||||
const data = await res.json();
|
||||
expect(typeof data.error).toBe("string");
|
||||
});
|
||||
});
|
||||
describe("Rate limiting (global)", () => {
|
||||
it("includes rate limit headers", async () => {
|
||||
const res = await fetch(`${BASE}/health`);
|
||||
// express-rate-limit with standardHeaders:true uses RateLimit-* headers
|
||||
const limit = res.headers.get("ratelimit-limit");
|
||||
expect(limit).toBeDefined();
|
||||
});
|
||||
});
|
||||
describe("API root", () => {
|
||||
it("returns API info", async () => {
|
||||
const res = await fetch(`${BASE}/api`);
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(data.name).toBe("DocFast API");
|
||||
expect(data.version).toBeDefined();
|
||||
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: "<h1>Test</h1>" }),
|
||||
});
|
||||
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: "<h1>Demo Test</h1>" }),
|
||||
});
|
||||
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("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`);
|
||||
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("<!DOCTYPE html>");
|
||||
expect(html).toContain("404");
|
||||
expect(html).toContain("Page Not Found");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
48
dist/index.js
vendored
48
dist/index.js
vendored
|
|
@ -22,7 +22,7 @@ import { pdfRateLimitMiddleware, getConcurrencyStats } from "./middleware/pdfRat
|
|||
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 { initDatabase, pool, cleanupStaleData } from "./services/db.js";
|
||||
import { swaggerSpec } from "./swagger.js";
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || "3100", 10);
|
||||
|
|
@ -142,6 +142,17 @@ app.get("/v1/usage", authMiddleware, adminAuth, (req, res) => {
|
|||
app.get("/v1/concurrency", authMiddleware, adminAuth, (_req, res) => {
|
||||
res.json(getConcurrencyStats());
|
||||
});
|
||||
// Admin: database cleanup (admin key required)
|
||||
app.post("/admin/cleanup", authMiddleware, adminAuth, async (_req, res) => {
|
||||
try {
|
||||
const results = await cleanupStaleData();
|
||||
res.json({ status: "ok", cleaned: results });
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Admin cleanup failed");
|
||||
res.status(500).json({ error: "Cleanup failed", message: err.message });
|
||||
}
|
||||
});
|
||||
// Email verification endpoint
|
||||
app.get("/verify", (req, res) => {
|
||||
const token = req.query.token;
|
||||
|
|
@ -190,10 +201,12 @@ p{color:#7a8194;margin-bottom:24px;line-height:1.6}
|
|||
<p>${message}</p>
|
||||
${apiKey ? `
|
||||
<div class="warning">⚠️ Save your API key securely. You can recover it via email if needed.</div>
|
||||
<div class="key-box" onclick="navigator.clipboard.writeText('${apiKey}');this.style.borderColor='#5eead4';setTimeout(()=>this.style.borderColor='#34d399',1500)">${apiKey}</div>
|
||||
<div class="key-box" data-copy="${apiKey}">${apiKey}</div>
|
||||
<div class="links">Upgrade to Pro for 5,000 PDFs/month · <a href="/docs">Read the docs →</a></div>
|
||||
` : `<div class="links"><a href="/">← Back to DocFast</a></div>`}
|
||||
</div></body></html>`;
|
||||
</div>
|
||||
<script src="/copy-helper.js"></script>
|
||||
</body></html>`;
|
||||
}
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
start().catch((err) => {
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
start().catch((err) => {
|
||||
logger.error({ err }, "Failed to start");
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
export { app };
|
||||
|
|
|
|||
36
dist/middleware/pdfRateLimit.js
vendored
36
dist/middleware/pdfRateLimit.js
vendored
|
|
@ -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)
|
||||
|
|
|
|||
81
dist/routes/billing.js
vendored
81
dist/routes/billing.js
vendored
|
|
@ -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, """).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; }
|
|||
<div class="card">
|
||||
<h1>🎉 Welcome to Pro!</h1>
|
||||
<p>Your API key:</p>
|
||||
<div class="key" style="position:relative" data-key="${escapeHtml(keyInfo.key)}">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText(this.parentElement.dataset.key);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
||||
<div class="key" style="position:relative">${escapeHtml(keyInfo.key)}<button data-copy="${escapeHtml(keyInfo.key)}" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
||||
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
||||
<p>5,000 PDFs/month • All endpoints • Priority support</p>
|
||||
<p><a href="/docs">View API docs →</a></p>
|
||||
</div></body></html>`);
|
||||
</div>
|
||||
<script src="/copy-helper.js"></script>
|
||||
</body></html>`);
|
||||
}
|
||||
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"];
|
||||
|
|
|
|||
63
dist/routes/convert.js
vendored
63
dist/routes/convert.js
vendored
|
|
@ -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}`,
|
||||
});
|
||||
|
|
|
|||
54
dist/routes/signup.js
vendored
54
dist/routes/signup.js
vendored
|
|
@ -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 || {};
|
||||
|
|
|
|||
4
dist/routes/templates.js
vendored
4
dist/routes/templates.js
vendored
|
|
@ -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
|
||||
|
|
|
|||
13
dist/services/browser.js
vendored
13
dist/services/browser.js
vendored
|
|
@ -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);
|
||||
})(),
|
||||
|
|
|
|||
69
dist/services/db.js
vendored
69
dist/services/db.js
vendored
|
|
@ -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;
|
||||
|
|
|
|||
29
dist/services/email.js
vendored
29
dist/services/email.js
vendored
|
|
@ -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: `<!DOCTYPE html>
|
||||
<html><body style="margin:0;padding:0;background:#0a0a0a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0a0a0a;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="480" cellpadding="0" cellspacing="0" style="background:#111;border-radius:12px;padding:40px;">
|
||||
<tr><td align="center" style="padding-bottom:24px;">
|
||||
<h1 style="margin:0;font-size:28px;font-weight:700;color:#44ff99;letter-spacing:-0.5px;">DocFast</h1>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding-bottom:8px;">
|
||||
<p style="margin:0;font-size:16px;color:#e8e8e8;">Your verification code</p>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding-bottom:24px;">
|
||||
<div style="display:inline-block;background:#0a0a0a;border:2px solid #44ff99;border-radius:8px;padding:16px 32px;font-family:monospace;font-size:32px;letter-spacing:8px;color:#44ff99;font-weight:700;">${code}</div>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding-bottom:8px;">
|
||||
<p style="margin:0;font-size:14px;color:#999;">This code expires in 15 minutes.</p>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="padding-bottom:24px;">
|
||||
<p style="margin:0;font-size:14px;color:#999;">If you didn't request this, ignore this email.</p>
|
||||
</td></tr>
|
||||
<tr><td align="center" style="border-top:1px solid #222;padding-top:20px;">
|
||||
<p style="margin:0;font-size:12px;color:#666;">DocFast — HTML to PDF API<br><a href="https://docfast.dev" style="color:#44ff99;text-decoration:none;">docfast.dev</a></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>`,
|
||||
});
|
||||
logger.info({ email, messageId: info.messageId }, "Verification email sent");
|
||||
return true;
|
||||
|
|
|
|||
3
dist/services/templates.js
vendored
3
dist/services/templates.js
vendored
|
|
@ -35,7 +35,8 @@ function esc(s) {
|
|||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
function renderInvoice(d) {
|
||||
const cur = esc(d.currency || "€");
|
||||
|
|
|
|||
1226
public/openapi.json
1226
public/openapi.json
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue