feat: Add built dist files with EU compliance routes
Some checks failed
Deploy to Production / Deploy to Server (push) Failing after 20s

- Include compiled TypeScript with new /impressum, /privacy, /terms routes
- Temporary commit of dist files for Docker deployment
This commit is contained in:
openclawd 2026-02-16 13:09:25 +00:00
parent 5ef8f34133
commit 1ef8f5743c
21 changed files with 2179 additions and 0 deletions

187
dist/routes/billing.js vendored Normal file
View file

@ -0,0 +1,187 @@
import { Router } from "express";
import Stripe from "stripe";
import { createProKey, revokeByCustomer } from "../services/keys.js";
import logger from "../services/logger.js";
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
let _stripe = null;
function getStripe() {
if (!_stripe) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key)
throw new Error("STRIPE_SECRET_KEY not configured");
_stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" });
}
return _stripe;
}
const router = Router();
// Create a Stripe Checkout session for Pro subscription
router.post("/checkout", async (_req, res) => {
try {
const priceId = await getOrCreateProPrice();
const session = await getStripe().checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`,
});
res.json({ url: session.url });
}
catch (err) {
logger.error({ err }, "Checkout error");
res.status(500).json({ error: "Failed to create checkout session" });
}
});
// Success page — provision Pro API key after checkout
router.get("/success", async (req, res) => {
const sessionId = req.query.session_id;
if (!sessionId) {
res.status(400).json({ error: "Missing session_id" });
return;
}
try {
const session = await getStripe().checkout.sessions.retrieve(sessionId);
const customerId = session.customer;
const email = session.customer_details?.email || "unknown@docfast.dev";
if (!customerId) {
res.status(400).json({ error: "No customer found" });
return;
}
const keyInfo = await createProKey(email, customerId);
// Return a nice HTML page instead of raw JSON
res.send(`<!DOCTYPE html>
<html><head><title>Welcome to DocFast Pro!</title>
<style>
body { font-family: system-ui; background: #0a0a0a; color: #e8e8e8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
.card { background: #141414; border: 1px solid #222; border-radius: 16px; padding: 48px; max-width: 500px; text-align: center; }
h1 { color: #4f9; margin-bottom: 8px; }
.key { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 16px; margin: 24px 0; font-family: monospace; font-size: 0.9rem; word-break: break-all; cursor: pointer; }
.key:hover { border-color: #4f9; }
p { color: #888; line-height: 1.6; }
a { color: #4f9; }
</style></head><body>
<div class="card">
<h1>🎉 Welcome to Pro!</h1>
<p>Your API key:</p>
<div class="key" onclick="navigator.clipboard.writeText('${escapeHtml(keyInfo.key)}')" title="Click to copy">${escapeHtml(keyInfo.key)}</div>
<p><strong>Save this key!</strong> It won't be shown again.</p>
<p>10,000 PDFs/month All endpoints Priority support</p>
<p><a href="/docs">View API docs </a></p>
</div></body></html>`);
}
catch (err) {
logger.error({ err }, "Success page error");
res.status(500).json({ error: "Failed to retrieve session" });
}
});
// Stripe webhook for subscription lifecycle events
router.post("/webhook", async (req, res) => {
const sig = req.headers["stripe-signature"];
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;
}
}
else if (!sig) {
res.status(400).json({ error: "Missing stripe-signature header" });
return;
}
else {
try {
event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret);
}
catch (err) {
logger.error({ err }, "Webhook signature verification failed");
res.status(400).json({ error: "Invalid signature" });
return;
}
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
const customerId = session.customer;
const email = session.customer_details?.email;
// Filter by product — this Stripe account is shared with other projects
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
try {
const fullSession = await getStripe().checkout.sessions.retrieve(session.id, {
expand: ["line_items"],
});
const lineItems = fullSession.line_items?.data || [];
const hasDocfastProduct = lineItems.some((item) => {
const price = item.price;
const productId = typeof price?.product === "string" ? price.product : price?.product?.id;
return productId === DOCFAST_PRODUCT_ID;
});
if (!hasDocfastProduct) {
logger.info({ sessionId: session.id }, "Ignoring event for different product");
break;
}
}
catch (err) {
logger.error({ err, sessionId: session.id }, "Failed to retrieve session line_items");
break;
}
if (!customerId || !email) {
console.warn("checkout.session.completed: missing customerId or email, skipping key provisioning");
break;
}
const keyInfo = await createProKey(email, customerId);
logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object;
const customerId = sub.customer;
await revokeByCustomer(customerId);
logger.info({ customerId }, "Subscription cancelled, key revoked");
break;
}
default:
break;
}
res.json({ received: true });
});
// --- Price management ---
let cachedPriceId = null;
async function getOrCreateProPrice() {
if (cachedPriceId)
return cachedPriceId;
const products = await getStripe().products.search({ query: "name:'DocFast Pro'" });
let productId;
if (products.data.length > 0) {
productId = products.data[0].id;
const prices = await getStripe().prices.list({ product: productId, active: true, limit: 1 });
if (prices.data.length > 0) {
cachedPriceId = prices.data[0].id;
return cachedPriceId;
}
}
else {
const product = await getStripe().products.create({
name: "DocFast Pro",
description: "Unlimited PDF conversions via API. HTML, Markdown, and URL to PDF.",
});
productId = product.id;
}
const price = await getStripe().prices.create({
product: productId,
unit_amount: 900,
currency: "eur",
recurring: { interval: "month" },
});
cachedPriceId = price.id;
return cachedPriceId;
}
export { router as billingRouter };

189
dist/routes/convert.js vendored Normal file
View file

@ -0,0 +1,189 @@
import { Router } from "express";
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;
// 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;
}
export const convertRouter = Router();
// POST /v1/convert/html
convertRouter.post("/html", 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" ? { html: req.body } : req.body;
if (!body.html) {
res.status(400).json({ error: "Missing 'html' field" });
return;
}
// Acquire concurrency slot
if (req.acquirePdfSlot) {
await req.acquirePdfSlot();
slotAcquired = true;
}
// Wrap bare HTML fragments
const fullHtml = body.html.includes("<html")
? body.html
: wrapHtml(body.html, body.css);
const pdf = await renderPdf(fullHtml, {
format: body.format,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
});
const filename = body.filename || "document.pdf";
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
}
catch (err) {
logger.error({ err }, "Convert HTML error");
if (err.message === "QUEUE_FULL") {
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
return;
}
res.status(500).json({ error: "PDF generation failed", detail: err.message });
}
finally {
if (slotAcquired && req.releasePdfSlot) {
req.releasePdfSlot();
}
}
});
// POST /v1/convert/markdown
convertRouter.post("/markdown", async (req, res) => {
let slotAcquired = false;
try {
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
if (!body.markdown) {
res.status(400).json({ error: "Missing 'markdown' field" });
return;
}
// Acquire concurrency slot
if (req.acquirePdfSlot) {
await req.acquirePdfSlot();
slotAcquired = true;
}
const html = markdownToHtml(body.markdown, body.css);
const pdf = await renderPdf(html, {
format: body.format,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
});
const filename = body.filename || "document.pdf";
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
}
catch (err) {
logger.error({ err }, "Convert MD error");
if (err.message === "QUEUE_FULL") {
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
return;
}
res.status(500).json({ error: "PDF generation failed", detail: err.message });
}
finally {
if (slotAcquired && req.releasePdfSlot) {
req.releasePdfSlot();
}
}
});
// POST /v1/convert/url
convertRouter.post("/url", async (req, res) => {
let slotAcquired = false;
try {
const body = req.body;
if (!body.url) {
res.status(400).json({ error: "Missing 'url' field" });
return;
}
// URL validation + SSRF protection
let parsed;
try {
parsed = new URL(body.url);
if (!["http:", "https:"].includes(parsed.protocol)) {
res.status(400).json({ error: "Only http/https URLs are supported" });
return;
}
}
catch {
res.status(400).json({ error: "Invalid URL" });
return;
}
// DNS lookup to block private/reserved IPs
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;
}
}
catch {
res.status(400).json({ error: "DNS lookup failed for URL hostname" });
return;
}
// Acquire concurrency slot
if (req.acquirePdfSlot) {
await req.acquirePdfSlot();
slotAcquired = true;
}
const pdf = await renderUrlPdf(body.url, {
format: body.format,
landscape: body.landscape,
margin: body.margin,
printBackground: body.printBackground,
waitUntil: body.waitUntil,
});
const filename = body.filename || "page.pdf";
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
}
catch (err) {
logger.error({ err }, "Convert URL error");
if (err.message === "QUEUE_FULL") {
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
return;
}
res.status(500).json({ error: "PDF generation failed", detail: err.message });
}
finally {
if (slotAcquired && req.releasePdfSlot) {
req.releasePdfSlot();
}
}
});

82
dist/routes/email-change.js vendored Normal file
View file

@ -0,0 +1,82 @@
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { createPendingVerification, verifyCode } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js";
import { getAllKeys, updateKeyEmail } from "../services/keys.js";
import logger from "../services/logger.js";
const router = Router();
const changeLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
message: { error: "Too many attempts. Please try again in 1 hour." },
standardHeaders: true,
legacyHeaders: false,
});
router.post("/", changeLimiter, async (req, res) => {
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
const newEmail = req.body?.newEmail;
if (!apiKey || typeof apiKey !== "string") {
res.status(400).json({ error: "API key is required (Authorization header or body)." });
return;
}
if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
res.status(400).json({ error: "A valid new email address is required." });
return;
}
const cleanEmail = newEmail.trim().toLowerCase();
const keys = getAllKeys();
const userKey = keys.find((k) => k.key === apiKey);
if (!userKey) {
res.status(401).json({ error: "Invalid API key." });
return;
}
const existing = keys.find((k) => k.email === cleanEmail);
if (existing) {
res.status(409).json({ error: "This email is already associated with another account." });
return;
}
const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, pending.code).catch((err) => {
logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
});
res.json({ status: "verification_sent", message: "Verification code sent to your new email address." });
});
router.post("/verify", changeLimiter, async (req, res) => {
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
const { newEmail, code } = req.body || {};
if (!apiKey || !newEmail || !code) {
res.status(400).json({ error: "API key, new email, and code are required." });
return;
}
const cleanEmail = newEmail.trim().toLowerCase();
const cleanCode = String(code).trim();
const keys = getAllKeys();
const userKey = keys.find((k) => k.key === apiKey);
if (!userKey) {
res.status(401).json({ error: "Invalid API key." });
return;
}
const result = await verifyCode(cleanEmail, cleanCode);
switch (result.status) {
case "ok": {
const updated = await updateKeyEmail(apiKey, cleanEmail);
if (updated) {
res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail });
}
else {
res.status(500).json({ error: "Failed to update email." });
}
break;
}
case "expired":
res.status(410).json({ error: "Verification code has expired. Please request a new one." });
break;
case "max_attempts":
res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
break;
case "invalid":
res.status(400).json({ error: "Invalid verification code." });
break;
}
});
export { router as emailChangeRouter };

54
dist/routes/health.js vendored Normal file
View file

@ -0,0 +1,54 @@
import { Router } from "express";
import { createRequire } from "module";
import { getPoolStats } from "../services/browser.js";
import { pool } from "../services/db.js";
const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require("../../package.json");
export const healthRouter = Router();
healthRouter.get("/", async (_req, res) => {
const poolStats = getPoolStats();
let databaseStatus;
let overallStatus = "ok";
let httpStatus = 200;
// Check database connectivity
try {
const client = await pool.connect();
try {
const result = await client.query('SELECT version()');
const version = result.rows[0]?.version || 'Unknown';
// Extract just the PostgreSQL version number (e.g., "PostgreSQL 15.4")
const versionMatch = version.match(/PostgreSQL ([\d.]+)/);
const shortVersion = versionMatch ? `PostgreSQL ${versionMatch[1]}` : 'PostgreSQL';
databaseStatus = {
status: "ok",
version: shortVersion
};
}
finally {
client.release();
}
}
catch (error) {
databaseStatus = {
status: "error",
message: error.message || "Database connection failed"
};
overallStatus = "degraded";
httpStatus = 503;
}
const response = {
status: overallStatus,
version: APP_VERSION,
database: databaseStatus,
pool: {
size: poolStats.poolSize,
active: poolStats.totalPages - poolStats.availablePages,
available: poolStats.availablePages,
queueDepth: poolStats.queueDepth,
pdfCount: poolStats.pdfCount,
restarting: poolStats.restarting,
uptimeSeconds: Math.round(poolStats.uptimeMs / 1000),
},
};
res.status(httpStatus).json(response);
});

74
dist/routes/recover.js vendored Normal file
View file

@ -0,0 +1,74 @@
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { createPendingVerification, verifyCode } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js";
import { getAllKeys } from "../services/keys.js";
import logger from "../services/logger.js";
const router = Router();
const recoverLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
message: { error: "Too many recovery attempts. Please try again in 1 hour." },
standardHeaders: true,
legacyHeaders: false,
});
router.post("/", recoverLimiter, async (req, res) => {
const { email } = req.body || {};
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "A valid email address is required." });
return;
}
const cleanEmail = email.trim().toLowerCase();
const keys = getAllKeys();
const userKey = keys.find(k => k.email === cleanEmail);
if (!userKey) {
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
return;
}
const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
logger.error({ err, email: cleanEmail }, "Failed to send recovery email");
});
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
});
router.post("/verify", recoverLimiter, async (req, res) => {
const { email, code } = req.body || {};
if (!email || !code) {
res.status(400).json({ error: "Email and code are required." });
return;
}
const cleanEmail = email.trim().toLowerCase();
const cleanCode = String(code).trim();
const result = await verifyCode(cleanEmail, cleanCode);
switch (result.status) {
case "ok": {
const keys = getAllKeys();
const userKey = keys.find(k => k.email === cleanEmail);
if (userKey) {
res.json({
status: "recovered",
apiKey: userKey.key,
tier: userKey.tier,
message: "Your API key has been recovered. Save it securely — it is shown only once.",
});
}
else {
res.json({
status: "recovered",
message: "No API key found for this email.",
});
}
break;
}
case "expired":
res.status(410).json({ error: "Verification code has expired. Please request a new one." });
break;
case "max_attempts":
res.status(429).json({ error: "Too many failed attempts. Please request a new code." });
break;
case "invalid":
res.status(400).json({ error: "Invalid verification code." });
break;
}
});
export { router as recoverRouter };

92
dist/routes/signup.js vendored Normal file
View file

@ -0,0 +1,92 @@
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { createFreeKey } from "../services/keys.js";
import { createVerification, createPendingVerification, verifyCode, isEmailVerified } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js";
import logger from "../services/logger.js";
const router = Router();
const signupLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: { error: "Too many signup attempts. Please try again in 1 hour.", retryAfter: "1 hour" },
standardHeaders: true,
legacyHeaders: false,
});
const verifyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 15,
message: { error: "Too many verification attempts. Please try again later." },
standardHeaders: true,
legacyHeaders: false,
});
async function rejectDuplicateEmail(req, res, next) {
const { email } = req.body || {};
if (email && typeof email === "string") {
const cleanEmail = email.trim().toLowerCase();
if (await isEmailVerified(cleanEmail)) {
res.status(409).json({ error: "Email already registered" });
return;
}
}
next();
}
// Step 1: Request signup — generates 6-digit code, sends via email
router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => {
const { email } = req.body || {};
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
res.status(400).json({ error: "A valid email address is required." });
return;
}
const cleanEmail = email.trim().toLowerCase();
if (await isEmailVerified(cleanEmail)) {
res.status(409).json({ error: "This email is already registered. Contact support if you need help." });
return;
}
const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
logger.error({ err, email: cleanEmail }, "Failed to send verification email");
});
res.json({
status: "verification_required",
message: "Check your email for the verification code.",
});
});
// Step 2: Verify code — creates API key
router.post("/verify", verifyLimiter, async (req, res) => {
const { email, code } = req.body || {};
if (!email || !code) {
res.status(400).json({ error: "Email and code are required." });
return;
}
const cleanEmail = email.trim().toLowerCase();
const cleanCode = String(code).trim();
if (await isEmailVerified(cleanEmail)) {
res.status(409).json({ error: "This email is already verified." });
return;
}
const result = await verifyCode(cleanEmail, cleanCode);
switch (result.status) {
case "ok": {
const keyInfo = await createFreeKey(cleanEmail);
const verification = await createVerification(cleanEmail, keyInfo.key);
verification.verifiedAt = new Date().toISOString();
res.json({
status: "verified",
message: "Email verified! Here's your API key.",
apiKey: keyInfo.key,
tier: keyInfo.tier,
});
break;
}
case "expired":
res.status(410).json({ error: "Verification code has expired. Please sign up again." });
break;
case "max_attempts":
res.status(429).json({ error: "Too many failed attempts. Please sign up again to get a new code." });
break;
case "invalid":
res.status(400).json({ error: "Invalid verification code." });
break;
}
});
export { router as signupRouter };

40
dist/routes/templates.js vendored Normal file
View file

@ -0,0 +1,40 @@
import { Router } from "express";
import { renderPdf } from "../services/browser.js";
import logger from "../services/logger.js";
import { templates, renderTemplate } from "../services/templates.js";
export const templatesRouter = Router();
// GET /v1/templates — list available templates
templatesRouter.get("/", (_req, res) => {
const list = Object.entries(templates).map(([id, t]) => ({
id,
name: t.name,
description: t.description,
fields: t.fields,
}));
res.json({ templates: list });
});
// POST /v1/templates/:id/render — render template to PDF
templatesRouter.post("/:id/render", async (req, res) => {
try {
const id = req.params.id;
const template = templates[id];
if (!template) {
res.status(404).json({ error: `Template '${id}' not found` });
return;
}
const data = req.body.data || req.body;
const html = renderTemplate(id, data);
const pdf = await renderPdf(html, {
format: data._format || "A4",
margin: data._margin,
});
const filename = data._filename || `${id}.pdf`;
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf);
}
catch (err) {
logger.error({ err }, "Template render error");
res.status(500).json({ error: "Template rendering failed", detail: err.message });
}
});