Add landing page, tests, Docker deployment, nginx config

This commit is contained in:
DocFast Bot 2026-02-14 13:01:07 +00:00
parent feee0317ae
commit 8e03b8ab3c
5 changed files with 394 additions and 2 deletions

38
deploy/nginx.conf Normal file
View file

@ -0,0 +1,38 @@
# Nginx reverse proxy config for DocFast
# Place in /etc/nginx/sites-available/docfast.conf and symlink to sites-enabled
# Requires: certbot for SSL (or existing wildcard cert)
server {
listen 80;
server_name api.docfast.dev;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.docfast.dev;
# SSL certs (adjust paths)
ssl_certificate /etc/letsencrypt/live/api.docfast.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.docfast.dev/privkey.pem;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
# Max body size (HTML/Markdown input)
client_max_body_size 2m;
location / {
proxy_pass http://127.0.0.1:3100;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# PDF responses can be large
proxy_read_timeout 30s;
proxy_buffering off;
}
}

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
version: "3.8"
services:
docfast:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:3100:3100"
environment:
- API_KEYS=${API_KEYS}
- PORT=3100
- NODE_ENV=production
mem_limit: 512m
cpus: 1.0

199
public/index.html Normal file
View file

@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocFast — HTML & Markdown to PDF API</title>
<meta name="description" content="Convert HTML and Markdown to beautiful PDFs with a simple API call. Built-in invoice templates. Fast, reliable, developer-friendly.">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --bg: #0a0a0a; --fg: #e8e8e8; --muted: #888; --accent: #4f9; --accent2: #3ad; --card: #141414; --border: #222; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 960px; margin: 0 auto; padding: 0 24px; }
/* Hero */
.hero { padding: 100px 0 80px; text-align: center; }
.hero h1 { font-size: 3rem; font-weight: 700; margin-bottom: 16px; letter-spacing: -1px; }
.hero h1 span { color: var(--accent); }
.hero p { font-size: 1.25rem; color: var(--muted); max-width: 600px; margin: 0 auto 40px; }
.hero-actions { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; }
.btn { display: inline-block; padding: 14px 32px; border-radius: 8px; font-size: 1rem; font-weight: 600; transition: all 0.2s; }
.btn-primary { background: var(--accent); color: #000; }
.btn-primary:hover { background: #6fb; text-decoration: none; }
.btn-secondary { border: 1px solid var(--border); color: var(--fg); }
.btn-secondary:hover { border-color: var(--muted); text-decoration: none; }
/* Code block */
.code-hero { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px 32px; margin: 48px auto 0; max-width: 640px; text-align: left; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.9rem; overflow-x: auto; }
.code-hero .comment { color: #666; }
.code-hero .string { color: var(--accent); }
.code-hero .key { color: var(--accent2); }
/* Features */
.features { padding: 80px 0; }
.features h2 { text-align: center; font-size: 2rem; margin-bottom: 48px; }
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; }
.feature-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 32px; }
.feature-card h3 { font-size: 1.1rem; margin-bottom: 8px; }
.feature-card p { color: var(--muted); font-size: 0.95rem; }
.feature-icon { font-size: 1.5rem; margin-bottom: 12px; }
/* Pricing */
.pricing { padding: 80px 0; }
.pricing h2 { text-align: center; font-size: 2rem; margin-bottom: 12px; }
.pricing .subtitle { text-align: center; color: var(--muted); margin-bottom: 48px; }
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px; max-width: 700px; margin: 0 auto; }
.price-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 32px; text-align: center; }
.price-card.featured { border-color: var(--accent); }
.price-card h3 { font-size: 1.2rem; margin-bottom: 8px; }
.price-amount { font-size: 2.5rem; font-weight: 700; margin: 16px 0; }
.price-amount span { font-size: 1rem; color: var(--muted); font-weight: 400; }
.price-features { list-style: none; text-align: left; margin: 24px 0; }
.price-features li { padding: 6px 0; color: var(--muted); font-size: 0.95rem; }
.price-features li::before { content: "✓ "; color: var(--accent); }
/* Endpoints */
.endpoints { padding: 80px 0; }
.endpoints h2 { text-align: center; font-size: 2rem; margin-bottom: 48px; }
.endpoint { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px 32px; margin-bottom: 16px; }
.endpoint-method { display: inline-block; background: #1a3a20; color: var(--accent); font-family: monospace; font-size: 0.85rem; font-weight: 700; padding: 3px 8px; border-radius: 4px; margin-right: 8px; }
.endpoint-path { font-family: monospace; font-size: 0.95rem; }
.endpoint-desc { color: var(--muted); font-size: 0.9rem; margin-top: 8px; }
/* Footer */
footer { padding: 40px 0; text-align: center; color: var(--muted); font-size: 0.85rem; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<section class="hero">
<div class="container">
<h1>HTML & Markdown to <span>PDF</span></h1>
<p>One API call. Beautiful PDFs. Built-in invoice templates. No headless browser setup, no dependencies, no hassle.</p>
<div class="hero-actions">
<a href="#pricing" class="btn btn-primary">Get API Key</a>
<a href="#endpoints" class="btn btn-secondary">View Docs</a>
</div>
<div class="code-hero">
<span class="comment">// Convert markdown to PDF in one call</span><br>
<span class="key">curl</span> -X POST https://api.docfast.dev/v1/convert/markdown \<br>
&nbsp;&nbsp;-H <span class="string">"Authorization: Bearer YOUR_KEY"</span> \<br>
&nbsp;&nbsp;-H <span class="string">"Content-Type: application/json"</span> \<br>
&nbsp;&nbsp;-d <span class="string">'{"markdown": "# Invoice\\n\\nAmount: $500"}'</span> \<br>
&nbsp;&nbsp;-o invoice.pdf
</div>
</div>
</section>
<section class="features">
<div class="container">
<h2>Why DocFast?</h2>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon"></div>
<h3>Fast</h3>
<p>Sub-second PDF generation. Persistent browser pool means no cold starts.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h3>Beautiful Output</h3>
<p>Full CSS support. Custom fonts, colors, layouts. Your PDFs, your brand.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📄</div>
<h3>Templates</h3>
<p>Built-in invoice and receipt templates. Pass data, get PDF. No HTML needed.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔧</div>
<h3>Simple API</h3>
<p>REST API with JSON in, PDF out. Works with any language. No SDKs required.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📐</div>
<h3>Flexible</h3>
<p>A4, Letter, custom sizes. Portrait or landscape. Configurable margins.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h3>Secure</h3>
<p>Your data is never stored. PDFs are generated and streamed — nothing hits disk.</p>
</div>
</div>
</div>
</section>
<section class="endpoints" id="endpoints">
<div class="container">
<h2>API Endpoints</h2>
<div class="endpoint">
<span class="endpoint-method">POST</span>
<span class="endpoint-path">/v1/convert/html</span>
<div class="endpoint-desc">Convert raw HTML (with optional CSS) to PDF.</div>
</div>
<div class="endpoint">
<span class="endpoint-method">POST</span>
<span class="endpoint-path">/v1/convert/markdown</span>
<div class="endpoint-desc">Convert Markdown to styled PDF with syntax highlighting.</div>
</div>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/v1/templates</span>
<div class="endpoint-desc">List available document templates with field definitions.</div>
</div>
<div class="endpoint">
<span class="endpoint-method">POST</span>
<span class="endpoint-path">/v1/templates/:id/render</span>
<div class="endpoint-desc">Render a template (invoice, receipt) with your data to PDF.</div>
</div>
<div class="endpoint">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/health</span>
<div class="endpoint-desc">Health check — verify the API is running.</div>
</div>
</div>
</section>
<section class="pricing" id="pricing">
<div class="container">
<h2>Simple Pricing</h2>
<p class="subtitle">No per-page fees. No hidden limits. Pay for what you use.</p>
<div class="pricing-grid">
<div class="price-card">
<h3>Free</h3>
<div class="price-amount">$0<span>/mo</span></div>
<ul class="price-features">
<li>100 PDFs / month</li>
<li>All endpoints</li>
<li>All templates</li>
<li>Community support</li>
</ul>
<a href="#" class="btn btn-secondary" style="width:100%">Start Free</a>
</div>
<div class="price-card featured">
<h3>Pro</h3>
<div class="price-amount">$9<span>/mo</span></div>
<ul class="price-features">
<li>10,000 PDFs / month</li>
<li>All endpoints</li>
<li>All templates</li>
<li>Priority support</li>
<li>Custom templates</li>
</ul>
<a href="#" class="btn btn-primary" style="width:100%">Get Started</a>
</div>
</div>
</div>
</section>
<footer>
<div class="container">
<p>DocFast &copy; 2026 — Fast PDF generation for developers</p>
</div>
</footer>
</body>
</html>

136
src/__tests__/api.test.ts Normal file
View file

@ -0,0 +1,136 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import express from "express";
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: any;
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));
});
afterAll(async () => {
server?.close();
});
describe("Auth", () => {
it("rejects requests without API key", async () => {
const res = await fetch(`${BASE}/v1/convert/html`, { method: "POST" });
expect(res.status).toBe(401);
});
it("rejects invalid API key", async () => {
const res = await fetch(`${BASE}/v1/convert/html`, {
method: "POST",
headers: { Authorization: "Bearer wrong-key" },
});
expect(res.status).toBe(403);
});
});
describe("Health", () => {
it("returns ok", async () => {
const res = await fetch(`${BASE}/health`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.status).toBe("ok");
});
});
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",
},
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
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",
},
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
});
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",
},
body: JSON.stringify({ markdown: "# Hello\n\nWorld" }),
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
});
});
describe("Templates", () => {
it("lists templates", async () => {
const res = await fetch(`${BASE}/v1/templates`, {
headers: { Authorization: "Bearer test-key" },
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.templates).toBeInstanceOf(Array);
expect(data.templates.length).toBeGreaterThan(0);
});
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",
},
body: JSON.stringify({
invoiceNumber: "TEST-001",
date: "2026-02-14",
from: { name: "Seller", email: "s@test.com" },
to: { name: "Buyer", email: "b@test.com" },
items: [{ description: "Widget", quantity: 2, unitPrice: 50, taxRate: 20 }],
}),
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
});
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",
},
body: JSON.stringify({}),
});
expect(res.status).toBe(404);
});
});

View file

@ -1,5 +1,7 @@
import express from "express";
import helmet from "helmet";
import path from "path";
import { fileURLToPath } from "url";
import rateLimit from "express-rate-limit";
import { convertRouter } from "./routes/convert.js";
import { templatesRouter } from "./routes/templates.js";
@ -30,8 +32,12 @@ app.use("/health", healthRouter);
app.use("/v1/convert", authMiddleware, convertRouter);
app.use("/v1/templates", authMiddleware, templatesRouter);
// Root
app.get("/", (_req, res) => {
// Landing page
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, "../public")));
// API root (for programmatic discovery)
app.get("/api", (_req, res) => {
res.json({
name: "DocFast API",
version: "0.1.0",