Session 45: support email, audit fixes (template validation, content-type, admin auth, waitUntil)
All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 2m20s
All checks were successful
Deploy to Production / Deploy to Server (push) Successful in 2m20s
- Added support@docfast.dev to footer, impressum, terms, landing page, openapi.json - Fixed audit #6: Template render validates required fields (400 on missing) - Fixed audit #7: Content-Type check on markdown/URL routes (415) - Fixed audit #11: /v1/usage and /v1/concurrency now require ADMIN_API_KEY - Fixed audit Critical #3: URL convert uses domcontentloaded instead of networkidle0
This commit is contained in:
parent
8a86e34f91
commit
59cc8f3d0e
22 changed files with 166 additions and 61 deletions
41
dist/index.js
vendored
41
dist/index.js
vendored
|
|
@ -89,12 +89,24 @@ app.use("/v1/email-change", emailChangeRouter);
|
|||
// Authenticated routes
|
||||
app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||
// Admin: usage stats
|
||||
app.get("/v1/usage", authMiddleware, (req, res) => {
|
||||
// Admin: usage stats (admin key required)
|
||||
const adminAuth = (req, res, next) => {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) {
|
||||
res.status(503).json({ error: "Admin access not configured" });
|
||||
return;
|
||||
}
|
||||
if (req.apiKeyInfo?.key !== adminKey) {
|
||||
res.status(403).json({ error: "Admin access required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
app.get("/v1/usage", authMiddleware, adminAuth, (req, res) => {
|
||||
res.json(getUsageStats(req.apiKeyInfo?.key));
|
||||
});
|
||||
// Admin: concurrency stats
|
||||
app.get("/v1/concurrency", authMiddleware, (_req, res) => {
|
||||
// Admin: concurrency stats (admin key required)
|
||||
app.get("/v1/concurrency", authMiddleware, adminAuth, (_req, res) => {
|
||||
res.json(getConcurrencyStats());
|
||||
});
|
||||
// Email verification endpoint
|
||||
|
|
@ -246,27 +258,6 @@ app.use((req, res) => {
|
|||
</html>`);
|
||||
}
|
||||
});
|
||||
// 404 handler — must be after all routes
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith("/v1/")) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
else {
|
||||
const accepts = req.headers.accept || "";
|
||||
if (accepts.includes("text/html")) {
|
||||
res.status(404).send(`<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter',-apple-system,sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.c{text-align:center}.c h1{font-size:4rem;font-weight:800;color:#34d399;margin-bottom:12px}.c p{color:#7a8194;margin-bottom:24px}.c a{color:#34d399;text-decoration:none}.c a:hover{color:#5eead4}</style>
|
||||
</head><body><div class="c"><h1>404</h1><p>Page not found.</p><p><a href="/">← Back to DocFast</a> · <a href="/docs">API Docs</a></p></div></body></html>`);
|
||||
}
|
||||
else {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
}
|
||||
});
|
||||
async function start() {
|
||||
// Initialize PostgreSQL
|
||||
await initDatabase();
|
||||
|
|
|
|||
17
dist/routes/billing.js
vendored
17
dist/routes/billing.js
vendored
|
|
@ -65,7 +65,7 @@ a { color: #4f9; }
|
|||
<div class="card">
|
||||
<h1>🎉 Welcome to Pro!</h1>
|
||||
<p>Your API key:</p>
|
||||
<div class="key" style="position:relative">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText('${escapeHtml(keyInfo.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" 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>
|
||||
<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>
|
||||
|
|
@ -82,16 +82,9 @@ router.post("/webhook", async (req, res) => {
|
|||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
let event;
|
||||
if (!webhookSecret) {
|
||||
console.warn("⚠️ STRIPE_WEBHOOK_SECRET is not configured — webhook signature verification skipped. Set this in production!");
|
||||
// Parse the body as a raw event without verification
|
||||
try {
|
||||
event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString());
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err }, "Failed to parse webhook body");
|
||||
res.status(400).json({ error: "Invalid payload" });
|
||||
return;
|
||||
}
|
||||
logger.error("STRIPE_WEBHOOK_SECRET is not configured — refusing to process unverified webhooks");
|
||||
res.status(500).json({ error: "Webhook signature verification is not configured" });
|
||||
return;
|
||||
}
|
||||
else if (!sig) {
|
||||
res.status(400).json({ error: "Missing stripe-signature header" });
|
||||
|
|
@ -134,7 +127,7 @@ router.post("/webhook", async (req, res) => {
|
|||
break;
|
||||
}
|
||||
if (!customerId || !email) {
|
||||
console.warn("checkout.session.completed: missing customerId or email, skipping key provisioning");
|
||||
logger.warn("checkout.session.completed: missing customerId or email, skipping key provisioning");
|
||||
break;
|
||||
}
|
||||
const keyInfo = await createProKey(email, customerId);
|
||||
|
|
|
|||
31
dist/routes/convert.js
vendored
31
dist/routes/convert.js
vendored
|
|
@ -12,6 +12,10 @@ function isPrivateIP(ip) {
|
|||
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);
|
||||
|
|
@ -32,6 +36,10 @@ function isPrivateIP(ip) {
|
|||
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";
|
||||
}
|
||||
export const convertRouter = Router();
|
||||
// POST /v1/convert/html
|
||||
convertRouter.post("/html", async (req, res) => {
|
||||
|
|
@ -63,7 +71,7 @@ convertRouter.post("/html", async (req, res) => {
|
|||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
});
|
||||
const filename = body.filename || "document.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -86,6 +94,12 @@ convertRouter.post("/html", async (req, res) => {
|
|||
convertRouter.post("/markdown", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||
if (!body.markdown) {
|
||||
res.status(400).json({ error: "Missing 'markdown' field" });
|
||||
|
|
@ -103,7 +117,7 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
});
|
||||
const filename = body.filename || "document.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -126,6 +140,12 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
convertRouter.post("/url", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body = req.body;
|
||||
if (!body.url) {
|
||||
res.status(400).json({ error: "Missing 'url' field" });
|
||||
|
|
@ -144,13 +164,15 @@ convertRouter.post("/url", async (req, res) => {
|
|||
res.status(400).json({ error: "Invalid URL" });
|
||||
return;
|
||||
}
|
||||
// DNS lookup to block private/reserved IPs
|
||||
// DNS lookup to block private/reserved IPs + pin resolution to prevent DNS rebinding
|
||||
let resolvedAddress;
|
||||
try {
|
||||
const { address } = await dns.lookup(parsed.hostname);
|
||||
if (isPrivateIP(address)) {
|
||||
res.status(400).json({ error: "URL resolves to a private/internal IP address" });
|
||||
return;
|
||||
}
|
||||
resolvedAddress = address;
|
||||
}
|
||||
catch {
|
||||
res.status(400).json({ error: "DNS lookup failed for URL hostname" });
|
||||
|
|
@ -167,8 +189,9 @@ convertRouter.post("/url", async (req, res) => {
|
|||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
waitUntil: body.waitUntil,
|
||||
hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`,
|
||||
});
|
||||
const filename = body.filename || "page.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "page.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
|
|||
17
dist/routes/templates.js
vendored
17
dist/routes/templates.js
vendored
|
|
@ -2,6 +2,9 @@ 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);
|
||||
}
|
||||
export const templatesRouter = Router();
|
||||
// GET /v1/templates — list available templates
|
||||
templatesRouter.get("/", (_req, res) => {
|
||||
|
|
@ -23,12 +26,24 @@ templatesRouter.post("/:id/render", async (req, res) => {
|
|||
return;
|
||||
}
|
||||
const data = req.body.data || req.body;
|
||||
// Validate required fields
|
||||
const missingFields = template.fields
|
||||
.filter((f) => f.required && (data[f.name] === undefined || data[f.name] === null || data[f.name] === ""))
|
||||
.map((f) => f.name);
|
||||
if (missingFields.length > 0) {
|
||||
res.status(400).json({
|
||||
error: "Missing required fields",
|
||||
missing: missingFields,
|
||||
hint: `Required fields for '${id}': ${template.fields.filter((f) => f.required).map((f) => f.name).join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const html = renderTemplate(id, data);
|
||||
const pdf = await renderPdf(html, {
|
||||
format: data._format || "A4",
|
||||
margin: data._margin,
|
||||
});
|
||||
const filename = data._filename || `${id}.pdf`;
|
||||
const filename = sanitizeFilename(data._filename || `${id}.pdf`);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
|
|||
37
dist/services/browser.js
vendored
37
dist/services/browser.js
vendored
|
|
@ -224,10 +224,45 @@ export async function renderUrlPdf(url, options = {}) {
|
|||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
await page.setJavaScriptEnabled(false);
|
||||
// Pin DNS resolution to prevent DNS rebinding SSRF attacks
|
||||
if (options.hostResolverRules) {
|
||||
const client = await page.createCDPSession();
|
||||
// Use Chrome DevTools Protocol to set host resolver rules per-page
|
||||
await client.send("Network.enable");
|
||||
// Extract hostname and IP from rules like "MAP hostname ip"
|
||||
const match = options.hostResolverRules.match(/^MAP\s+(\S+)\s+(\S+)$/);
|
||||
if (match) {
|
||||
const [, hostname, ip] = match;
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (request) => {
|
||||
const reqUrl = new URL(request.url());
|
||||
if (reqUrl.hostname === hostname) {
|
||||
// For HTTP, rewrite to IP with Host header
|
||||
if (reqUrl.protocol === "http:") {
|
||||
reqUrl.hostname = ip;
|
||||
request.continue({
|
||||
url: reqUrl.toString(),
|
||||
headers: { ...request.headers(), host: hostname },
|
||||
});
|
||||
}
|
||||
else {
|
||||
// For HTTPS, we can't easily swap the IP without cert issues
|
||||
// But we've already validated the IP, and the short window makes rebinding unlikely
|
||||
// Combined with JS disabled, this is sufficient mitigation
|
||||
request.continue();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Block any requests to other hosts (prevent redirects to internal IPs)
|
||||
request.abort("blockedbyclient");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(url, {
|
||||
waitUntil: options.waitUntil || "networkidle0",
|
||||
waitUntil: options.waitUntil || "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
|
|
|
|||
7
dist/services/verification.js
vendored
7
dist/services/verification.js
vendored
|
|
@ -1,4 +1,4 @@
|
|||
import { randomBytes, randomInt } from "crypto";
|
||||
import { randomBytes, randomInt, timingSafeEqual } from "crypto";
|
||||
import logger from "./logger.js";
|
||||
import pool from "./db.js";
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||
|
|
@ -87,7 +87,10 @@ export async function verifyCode(email, code) {
|
|||
return { status: "max_attempts" };
|
||||
}
|
||||
await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]);
|
||||
if (pending.code !== code) {
|
||||
const a = Buffer.from(pending.code, "utf8");
|
||||
const b = Buffer.from(code, "utf8");
|
||||
const codeMatch = a.length === b.length && timingSafeEqual(a, b);
|
||||
if (!codeMatch) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ footer .container { display: flex; justify-content: space-between; align-items:
|
|||
|
||||
<h2>Responsible for Content</h2>
|
||||
<p>Cloonar Technologies GmbH<br>
|
||||
Legal contact: <a href="mailto:legal@docfast.dev">legal@docfast.dev</a></p>
|
||||
Legal contact: <a href="mailto:legal@docfast.dev">legal@docfast.dev</a><br>
|
||||
Support: <a href="mailto:support@docfast.dev">support@docfast.dev</a></p>
|
||||
|
||||
<h2>Disclaimer</h2>
|
||||
<p>Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.</p>
|
||||
|
|
@ -97,6 +98,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
|
|||
<a href="/docs">Docs</a>
|
||||
<a href="/health">API Status</a>
|
||||
<a href="/#change-email" class="open-email-change">Change Email</a>
|
||||
<a href="mailto:support@docfast.dev">Support</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ html, body {
|
|||
<li>5,000 PDFs per month</li>
|
||||
<li>All conversion endpoints</li>
|
||||
<li>All templates included</li>
|
||||
<li>Priority support</li>
|
||||
<li>Priority support (<a href="mailto:support@docfast.dev">support@docfast.dev</a>)</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" style="width:100%" id="btn-checkout">Get Started →</button>
|
||||
</div>
|
||||
|
|
@ -423,6 +423,7 @@ html, body {
|
|||
<a href="/docs">Docs</a>
|
||||
<a href="/health">API Status</a>
|
||||
<a href="/#change-email" class="open-email-change">Change Email</a>
|
||||
<a href="mailto:support@docfast.dev">Support</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"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 <key>` or `X-API-Key: <key>` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 10,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents",
|
||||
"contact": { "name": "DocFast", "url": "https://docfast.dev" }
|
||||
"contact": { "name": "DocFast", "url": "https://docfast.dev", "email": "support@docfast.dev" }
|
||||
},
|
||||
"servers": [{ "url": "https://docfast.dev", "description": "Production" }],
|
||||
"tags": [
|
||||
|
|
|
|||
|
|
@ -180,6 +180,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
|
|||
<a href="/docs">Docs</a>
|
||||
<a href="/health">API Status</a>
|
||||
<a href="/#change-email" class="open-email-change">Change Email</a>
|
||||
<a href="mailto:support@docfast.dev">Support</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@
|
|||
|
||||
<h2>Responsible for Content</h2>
|
||||
<p>Cloonar Technologies GmbH<br>
|
||||
Legal contact: <a href="mailto:legal@docfast.dev">legal@docfast.dev</a></p>
|
||||
Legal contact: <a href="mailto:legal@docfast.dev">legal@docfast.dev</a><br>
|
||||
Support: <a href="mailto:support@docfast.dev">support@docfast.dev</a></p>
|
||||
|
||||
<h2>Disclaimer</h2>
|
||||
<p>Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.</p>
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@
|
|||
<li>5,000 PDFs per month</li>
|
||||
<li>All conversion endpoints</li>
|
||||
<li>All templates included</li>
|
||||
<li>Priority support</li>
|
||||
<li>Priority support (<a href="mailto:support@docfast.dev">support@docfast.dev</a>)</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" style="width:100%" id="btn-checkout">Get Started →</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
<li><strong>Price:</strong> €9 per month</li>
|
||||
<li><strong>Monthly limit:</strong> 10,000 PDF conversions</li>
|
||||
<li><strong>Rate limit:</strong> Higher limits based on fair use</li>
|
||||
<li><strong>Support:</strong> Priority email support</li>
|
||||
<li><strong>Support:</strong> Priority email support (<a href="mailto:support@docfast.dev">support@docfast.dev</a>)</li>
|
||||
<li><strong>Billing:</strong> Monthly subscription via Stripe</li>
|
||||
</ul>
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
|
|||
<li><strong>Price:</strong> €9 per month</li>
|
||||
<li><strong>Monthly limit:</strong> 10,000 PDF conversions</li>
|
||||
<li><strong>Rate limit:</strong> Higher limits based on fair use</li>
|
||||
<li><strong>Support:</strong> Priority email support</li>
|
||||
<li><strong>Support:</strong> Priority email support (<a href="mailto:support@docfast.dev">support@docfast.dev</a>)</li>
|
||||
<li><strong>Billing:</strong> Monthly subscription via Stripe</li>
|
||||
</ul>
|
||||
|
||||
|
|
@ -252,6 +252,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
|
|||
<a href="/docs">Docs</a>
|
||||
<a href="/health">API Status</a>
|
||||
<a href="/#change-email" class="open-email-change">Change Email</a>
|
||||
<a href="mailto:support@docfast.dev">Support</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
|
|
|
|||
14
src/index.ts
14
src/index.ts
|
|
@ -103,13 +103,19 @@ app.use("/v1/email-change", emailChangeRouter);
|
|||
app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||
|
||||
// Admin: usage stats
|
||||
app.get("/v1/usage", authMiddleware, (req: any, res) => {
|
||||
// Admin: usage stats (admin key required)
|
||||
const adminAuth = (req: any, res: any, next: any) => {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; }
|
||||
if (req.apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; }
|
||||
next();
|
||||
};
|
||||
app.get("/v1/usage", authMiddleware, adminAuth, (req: any, res: any) => {
|
||||
res.json(getUsageStats(req.apiKeyInfo?.key));
|
||||
});
|
||||
|
||||
// Admin: concurrency stats
|
||||
app.get("/v1/concurrency", authMiddleware, (_req, res) => {
|
||||
// Admin: concurrency stats (admin key required)
|
||||
app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: any, res: any) => {
|
||||
res.json(getConcurrencyStats());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,12 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
|
|||
convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body: ConvertBody =
|
||||
typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||
|
||||
|
|
@ -151,6 +157,12 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
|
|||
convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string };
|
||||
|
||||
if (!body.url) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { renderPdf } from "../services/browser.js";
|
|||
import logger from "../services/logger.js";
|
||||
import { templates, renderTemplate } from "../services/templates.js";
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200);
|
||||
}
|
||||
|
||||
export const templatesRouter = Router();
|
||||
|
||||
// GET /v1/templates — list available templates
|
||||
|
|
@ -27,13 +31,28 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
const data = req.body.data || req.body;
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = template.fields
|
||||
.filter((f) => f.required && (data[f.name] === undefined || data[f.name] === null || data[f.name] === ""))
|
||||
.map((f) => f.name);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
res.status(400).json({
|
||||
error: "Missing required fields",
|
||||
missing: missingFields,
|
||||
hint: `Required fields for '${id}': ${template.fields.filter((f) => f.required).map((f) => f.name).join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const html = renderTemplate(id, data);
|
||||
const pdf = await renderPdf(html, {
|
||||
format: data._format || "A4",
|
||||
margin: data._margin,
|
||||
});
|
||||
|
||||
const filename = data._filename || `${id}.pdf`;
|
||||
const filename = sanitizeFilename(data._filename || `${id}.pdf`);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ export async function renderUrlPdf(
|
|||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(url, {
|
||||
waitUntil: (options.waitUntil as any) || "networkidle0",
|
||||
waitUntil: (options.waitUntil as any) || "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ footer .container { display: flex; justify-content: space-between; align-items:
|
|||
|
||||
<h2>Responsible for Content</h2>
|
||||
<p>Cloonar Technologies GmbH<br>
|
||||
Legal contact: <a href="mailto:legal@docfast.dev">legal@docfast.dev</a></p>
|
||||
Legal contact: <a href="mailto:legal@docfast.dev">legal@docfast.dev</a><br>
|
||||
Support: <a href="mailto:support@docfast.dev">support@docfast.dev</a></p>
|
||||
|
||||
<h2>Disclaimer</h2>
|
||||
<p>Despite careful content control, we assume no liability for the content of external links. The operators of the linked pages are solely responsible for their content.</p>
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ html, body {
|
|||
<li>5,000 PDFs per month</li>
|
||||
<li>All conversion endpoints</li>
|
||||
<li>All templates included</li>
|
||||
<li>Priority support</li>
|
||||
<li>Priority support (<a href="mailto:support@docfast.dev">support@docfast.dev</a>)</li>
|
||||
</ul>
|
||||
<button class="btn btn-primary" style="width:100%" id="btn-checkout">Get Started →</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
|
|||
<li><strong>Price:</strong> €9 per month</li>
|
||||
<li><strong>Monthly limit:</strong> 10,000 PDF conversions</li>
|
||||
<li><strong>Rate limit:</strong> Higher limits based on fair use</li>
|
||||
<li><strong>Support:</strong> Priority email support</li>
|
||||
<li><strong>Support:</strong> Priority email support (<a href="mailto:support@docfast.dev">support@docfast.dev</a>)</li>
|
||||
<li><strong>Billing:</strong> Monthly subscription via Stripe</li>
|
||||
</ul>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<a href="/docs">Docs</a>
|
||||
<a href="/health">API Status</a>
|
||||
<a href="/#change-email" class="open-email-change">Change Email</a>
|
||||
<a href="mailto:support@docfast.dev">Support</a>
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/privacy">Privacy Policy</a>
|
||||
<a href="/terms">Terms of Service</a>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue