docfast/src/routes/billing.ts
OpenClaw 9541ae1826
Some checks failed
Deploy to Production / Deploy to Server (push) Failing after 20s
Backend hardening: structured logging, timeouts, memory leak fixes, compression, XSS fix
- Add pino structured logging with request IDs (X-Request-Id header)
- Add 30s timeout to acquirePage() and renderPdf/renderUrlPdf
- Add verification cache cleanup (every 15min) and rate limit cleanup (every 60s)
- Read version from package.json in health endpoint
- Add compression middleware
- Escape currency in templates (XSS fix)
- Add static asset caching (1h maxAge)
- Remove deprecated docker-compose version field
- Replace all console.log/error with pino logger
2026-02-16 08:27:42 +00:00

203 lines
7.3 KiB
TypeScript

import { Router, Request, Response } from "express";
import Stripe from "stripe";
import { createProKey, revokeByCustomer } from "../services/keys.js";
import logger from "../services/logger.js";
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
let _stripe: Stripe | null = null;
function getStripe(): Stripe {
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" as any });
}
return _stripe;
}
const router = Router();
// Create a Stripe Checkout session for Pro subscription
router.post("/checkout", async (_req: Request, res: Response) => {
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: any) {
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: Request, res: Response) => {
const sessionId = req.query.session_id as string;
if (!sessionId) {
res.status(400).json({ error: "Missing session_id" });
return;
}
try {
const session = await getStripe().checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string;
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: any) {
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: Request, res: Response) => {
const sig = req.headers["stripe-signature"] as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.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()) as Stripe.Event;
} catch (err: any) {
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: any) {
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 as Stripe.Checkout.Session;
const customerId = session.customer as string;
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 as Stripe.Price | null;
const productId = typeof price?.product === "string" ? price.product : (price?.product as Stripe.Product)?.id;
return productId === DOCFAST_PRODUCT_ID;
});
if (!hasDocfastProduct) {
logger.info({ sessionId: session.id }, "Ignoring event for different product");
break;
}
} catch (err: any) {
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 as Stripe.Subscription;
const customerId = sub.customer as string;
await revokeByCustomer(customerId);
logger.info({ customerId }, "Subscription cancelled, key revoked");
break;
}
default:
break;
}
res.json({ received: true });
});
// --- Price management ---
let cachedPriceId: string | null = null;
async function getOrCreateProPrice(): Promise<string> {
if (cachedPriceId) return cachedPriceId;
const products = await getStripe().products.search({ query: "name:'DocFast Pro'" });
let productId: string;
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: "usd",
recurring: { interval: "month" },
});
cachedPriceId = price.id;
return cachedPriceId;
}
export { router as billingRouter };