feat: initial codebase v0.4.1
Some checks failed
Deploy to Staging / build-and-deploy (push) Failing after 9m44s

- Extract complete codebase from running staging pod
- Add Dockerfile with multi-stage build for Node.js + Puppeteer
- Configure CI/CD workflows for staging and production deployment
- Include all source files, configs, and public assets
This commit is contained in:
OpenClaw DevOps 2026-02-19 17:05:16 +00:00
commit b58f634318
28 changed files with 5669 additions and 0 deletions

157
src/index.ts Normal file
View file

@ -0,0 +1,157 @@
import express from "express";
import { randomUUID } from "crypto";
import { compressionMiddleware } from "./middleware/compression.js";
import logger from "./services/logger.js";
import helmet from "helmet";
import path from "path";
import { fileURLToPath } from "url";
import rateLimit from "express-rate-limit";
import { screenshotRouter } from "./routes/screenshot.js";
import { healthRouter } from "./routes/health.js";
import { playgroundRouter } from "./routes/playground.js";
import { authMiddleware } from "./middleware/auth.js";
import { usageMiddleware, loadUsageData } from "./middleware/usage.js";
import { initBrowser, closeBrowser } from "./services/browser.js";
import { loadKeys, getAllKeys } from "./services/keys.js";
import { initDatabase, pool } from "./services/db.js";
import { billingRouter } from "./routes/billing.js";
import { statusRouter } from "./routes/status.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Helmet with relaxed CSP for /docs (Swagger UI needs external scripts)
app.use((req, res, next) => {
if (req.path === "/docs" || req.path === "/docs.html") {
helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
},
},
})(req, res, next);
} else {
helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'"],
scriptSrcAttr: ["'unsafe-inline'"],
},
},
})(req, res, next);
}
});
// Request ID + logging
app.use((req, res, next) => {
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
(req as any).requestId = requestId;
res.setHeader("X-Request-Id", requestId);
const start = Date.now();
res.on("finish", () => {
if (req.path !== "/health") {
logger.info({ method: req.method, path: req.path, status: res.statusCode, ms: Date.now() - start, requestId }, "request");
}
});
next();
});
app.use(compressionMiddleware);
// CORS
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
res.setHeader("Access-Control-Max-Age", "86400");
if (req.method === "OPTIONS") { res.status(204).end(); return; }
next();
});
// Raw body for Stripe webhook (must be before JSON parser)
app.use("/v1/billing/webhook", express.raw({ type: "application/json" }));
app.use(express.json({ limit: "1mb" }));
app.set("trust proxy", 1);
// Global rate limiting
app.use(rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
// Public routes
app.use("/health", healthRouter);
app.use("/v1/billing", billingRouter);
app.use("/status", statusRouter);
app.use("/v1/playground", playgroundRouter);
// Authenticated routes
app.use("/v1/screenshot", authMiddleware, usageMiddleware, screenshotRouter);
// API info
app.get("/api", (_req, res) => {
res.json({
name: "SnapAPI",
version: "0.3.0",
endpoints: [
"POST /v1/playground — Try the API (no auth, watermarked, 5 req/hr)",
"POST /v1/screenshot — Take a screenshot (requires API key)",
"GET /health — Health check",
],
});
});
// Swagger docs
app.get("/docs", (_req, res) => {
res.sendFile(path.join(__dirname, "../public/docs.html"));
});
// Static files (landing page)
app.use(express.static(path.join(__dirname, "../public"), { etag: true }));
// 404
app.use((req, res) => {
if (req.path.startsWith("/v1/") || req.path.startsWith("/api")) {
res.status(404).json({ error: "Not Found: " + req.method + " " + req.path });
} else {
res.status(404).sendFile(path.join(__dirname, "../public/index.html"));
}
});
async function start() {
await initDatabase();
await loadKeys();
await loadUsageData();
await initBrowser();
logger.info("Loaded " + getAllKeys().length + " API keys");
const server = app.listen(PORT, () => logger.info("SnapAPI running on :" + PORT));
let shuttingDown = false;
const shutdown = async (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(signal + " received, shutting down...");
await new Promise<void>(resolve => {
const t = setTimeout(resolve, 10_000);
server.close(() => { clearTimeout(t); resolve(); });
});
try { await closeBrowser(); } catch {}
try { await pool.end(); } catch {}
process.exit(0);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
}
start().catch(err => { logger.error({ err }, "Failed to start"); process.exit(1); });
export { app };

22
src/middleware/auth.ts Normal file
View file

@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from "express";
import { isValidKey, getKeyInfo } from "../services/keys.js";
export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
const xApiKey = req.headers["x-api-key"] as string | undefined;
let key: string | undefined;
if (header?.startsWith("Bearer ")) key = header.slice(7);
else if (xApiKey) key = xApiKey;
if (!key) {
res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer <key> or X-API-Key: <key>" });
return;
}
if (!(await isValidKey(key))) {
res.status(403).json({ error: "Invalid API key" });
return;
}
(req as any).apiKeyInfo = await getKeyInfo(key);
next();
}

View file

@ -0,0 +1,11 @@
import compression from "compression";
export const compressionMiddleware = compression({
level: 6,
threshold: 1024,
filter: (req, res) => {
// Don't compress screenshot responses (already binary)
if (res.getHeader("Content-Type")?.toString().startsWith("image/")) return false;
return compression.filter(req, res);
},
});

91
src/middleware/usage.ts Normal file
View file

@ -0,0 +1,91 @@
import logger from "../services/logger.js";
import { queryWithRetry, connectWithRetry } from "../services/db.js";
import { getTierLimit } from "../services/keys.js";
let usage = new Map<string, { count: number; monthKey: string }>();
const dirtyKeys = new Set<string>();
const FLUSH_INTERVAL_MS = 5000;
function getMonthKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export async function loadUsageData(): Promise<void> {
try {
const result = await queryWithRetry("SELECT key, count, month_key FROM usage");
usage = new Map();
for (const row of result.rows) {
usage.set(row.key, { count: row.count, monthKey: row.month_key });
}
logger.info(`Loaded usage for ${usage.size} keys`);
} catch {
usage = new Map();
}
}
async function flushDirtyEntries(): Promise<void> {
if (dirtyKeys.size === 0) return;
const keys = [...dirtyKeys];
const client = await connectWithRetry();
try {
await client.query("BEGIN");
for (const key of keys) {
const record = usage.get(key);
if (!record) continue;
await client.query(
`INSERT INTO usage (key, month_key, count) VALUES ($1, $2, $3)
ON CONFLICT (key, month_key) DO UPDATE SET count = $3`,
[key, record.monthKey, record.count]
);
dirtyKeys.delete(key);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
logger.error({ err }, "Failed to flush usage");
} finally {
client.release();
}
}
setInterval(flushDirtyEntries, FLUSH_INTERVAL_MS);
process.on("SIGTERM", () => { flushDirtyEntries().catch(() => {}); });
export function usageMiddleware(req: any, res: any, next: any): void {
const keyInfo = req.apiKeyInfo;
if (!keyInfo) { next(); return; }
const key = keyInfo.key;
const monthKey = getMonthKey();
const limit = getTierLimit(keyInfo.tier);
const record = usage.get(key);
if (record && record.monthKey === monthKey && record.count >= limit) {
res.status(429).json({
error: `Monthly limit reached (${limit} screenshots/month for ${keyInfo.tier} tier).`,
usage: record.count,
limit,
});
return;
}
// Track
if (!record || record.monthKey !== monthKey) {
usage.set(key, { count: 1, monthKey });
} else {
record.count++;
}
dirtyKeys.add(key);
// Attach usage info to response
const current = usage.get(key)!;
res.setHeader("X-Usage-Count", current.count.toString());
res.setHeader("X-Usage-Limit", limit.toString());
next();
}
export function getUsageForKey(key: string): { count: number; monthKey: string } | undefined {
return usage.get(key);
}

276
src/routes/billing.ts Normal file
View file

@ -0,0 +1,276 @@
import { Router, Request, Response } from "express";
import Stripe from "stripe";
import logger from "../services/logger.js";
import { createPaidKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-01-27.acacia" as any,
});
const BASE_URL = process.env.BASE_URL || "https://snapapi.eu";
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
// DocFast product ID — NEVER process events for this
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
// Plan definitions
const PLANS: Record<string, { name: string; amount: number; description: string; tier: "starter" | "pro" | "business" }> = {
starter: { name: "SnapAPI Starter", amount: 900, description: "1,000 screenshots/month", tier: "starter" },
pro: { name: "SnapAPI Pro", amount: 2900, description: "5,000 screenshots/month", tier: "pro" },
business: { name: "SnapAPI Business", amount: 7900, description: "25,000 screenshots/month", tier: "business" },
};
// Cached price IDs
const priceCache: Record<string, string> = {};
// SnapAPI product IDs for webhook filtering
const snapapiProductIds = new Set<string>();
// Provisioned session IDs (dedup)
const provisionedSessions = new Set<string>();
async function getOrCreatePrice(name: string, amount: number, description: string): Promise<string> {
if (priceCache[name]) return priceCache[name];
// Search for existing product by name
const products = await stripe.products.search({ query: `name:"${name}"` });
let product: Stripe.Product;
if (products.data.length > 0) {
product = products.data[0];
logger.info({ productId: product.id, name }, "Found existing Stripe product");
} else {
product = await stripe.products.create({ name, description });
logger.info({ productId: product.id, name }, "Created Stripe product");
}
snapapiProductIds.add(product.id);
// Check for existing price
const prices = await stripe.prices.list({ product: product.id, active: true, limit: 1 });
if (prices.data.length > 0 && prices.data[0].unit_amount === amount) {
priceCache[name] = prices.data[0].id;
return prices.data[0].id;
}
const price = await stripe.prices.create({
product: product.id,
unit_amount: amount,
currency: "eur",
recurring: { interval: "month" },
});
priceCache[name] = price.id;
logger.info({ priceId: price.id, productId: product.id, name, amount }, "Created Stripe price");
return price.id;
}
// Initialize prices on startup
async function initPrices() {
for (const [key, plan] of Object.entries(PLANS)) {
try {
await getOrCreatePrice(plan.name, plan.amount, plan.description);
} catch (err) {
logger.error({ err, plan: key }, "Failed to init price");
}
}
logger.info({ productIds: [...snapapiProductIds], prices: { ...priceCache } }, "SnapAPI Stripe products initialized");
}
initPrices().catch(err => logger.error({ err }, "Failed to initialize Stripe prices"));
// Helper: check if event belongs to SnapAPI
async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
try {
let productId: string | undefined;
const obj = (event.data as any).object;
if (event.type === "customer.updated") return true; // Can't filter by product
// For checkout.session.completed
if (event.type === "checkout.session.completed") {
const session = obj as Stripe.Checkout.Session;
if (session.subscription) {
const sub = await stripe.subscriptions.retrieve(session.subscription as string, { expand: ["items.data.price.product"] });
const item = sub.items.data[0];
const prod = item?.price?.product;
productId = typeof prod === "string" ? prod : prod?.id;
}
}
// For subscription events
if (event.type.startsWith("customer.subscription.")) {
const sub = obj as Stripe.Subscription;
const item = sub.items?.data?.[0];
if (item) {
const prod = item.price?.product;
productId = typeof prod === "string" ? prod : (prod as any)?.id;
// If product not expanded, fetch it
if (!productId && item.price?.id) {
const price = await stripe.prices.retrieve(item.price.id);
productId = typeof price.product === "string" ? price.product : (price.product as any)?.id;
}
}
}
if (!productId) return false;
if (productId === DOCFAST_PRODUCT_ID) return false;
return snapapiProductIds.has(productId);
} catch (err) {
logger.error({ err, eventType: event.type }, "Error checking if SnapAPI event");
return false;
}
}
// POST /v1/billing/checkout
router.post("/checkout", async (req: Request, res: Response) => {
try {
const { plan } = req.body;
if (!plan || !PLANS[plan]) {
return res.status(400).json({ error: "Invalid plan. Choose: starter, pro, business" });
}
const planDef = PLANS[plan];
const priceId = await getOrCreatePrice(planDef.name, planDef.amount, planDef.description);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${BASE_URL}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/#pricing`,
metadata: { plan, service: "snapapi" },
});
res.json({ url: session.url });
} catch (err: any) {
logger.error({ err }, "Checkout error");
res.status(500).json({ error: "Failed to create checkout session" });
}
});
// GET /v1/billing/success
router.get("/success", async (req: Request, res: Response) => {
try {
const sessionId = req.query.session_id as string;
if (!sessionId) return res.status(400).send("Missing session_id");
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"] });
const email = session.customer_details?.email || session.customer_email || "unknown@snapapi.eu";
const plan = session.metadata?.plan || "starter";
const tier = PLANS[plan]?.tier || "starter";
const customerId = typeof session.customer === "string" ? session.customer : (session.customer as any)?.id;
let apiKey: string;
if (provisionedSessions.has(sessionId)) {
// Already provisioned — look up key
apiKey = "(already provisioned — check your email or contact support)";
} else {
provisionedSessions.add(sessionId);
const keyEntry = await createPaidKey(email, tier, customerId);
apiKey = keyEntry.key;
}
res.send(`<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Welcome to SnapAPI!</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#141414;border:1px solid #2a2a2a;border-radius:16px;padding:48px;max-width:520px;width:90%;text-align:center}
h1{font-size:1.8rem;margin-bottom:8px;color:#fff}
.plan{color:#4ecb71;font-size:1.1rem;margin-bottom:24px}
.key-box{background:#0a0a0a;border:1px solid #333;border-radius:8px;padding:16px;margin:24px 0;word-break:break-all;font-family:monospace;font-size:.95rem;color:#4ecb71;position:relative}
.copy-btn{background:#4ecb71;color:#0a0a0a;border:none;padding:10px 24px;border-radius:8px;font-weight:600;cursor:pointer;margin-top:16px;font-size:.95rem}
.copy-btn:hover{background:#3db85e}
.note{color:#888;font-size:.85rem;margin-top:24px;line-height:1.6}
a{color:#4ecb71}
</style></head><body>
<div class="card">
<h1>🎉 Welcome to SnapAPI!</h1>
<div class="plan">${tier.charAt(0).toUpperCase() + tier.slice(1)} Plan</div>
<p>Your API key is ready:</p>
<div class="key-box" id="key">${apiKey}</div>
<button class="copy-btn" onclick="navigator.clipboard.writeText(document.getElementById('key').textContent);this.textContent='Copied!'">Copy API Key</button>
<div class="note">
Save this key it won't be shown again.<br>
Use it with <code>X-API-Key</code> header or <code>?key=</code> param.<br><br>
<a href="/docs">View API Documentation </a>
</div>
</div></body></html>`);
} catch (err: any) {
logger.error({ err }, "Success page error");
res.status(500).send("Something went wrong. Please contact support.");
}
});
// POST /v1/billing/webhook
router.post("/webhook", async (req: Request, res: Response) => {
try {
const sig = req.headers["stripe-signature"] as string;
if (!sig) return res.status(400).send("Missing signature");
const event = stripe.webhooks.constructEvent(req.body, sig, WEBHOOK_SECRET);
// Filter: only process SnapAPI events
if (event.type !== "customer.updated") {
const isOurs = await isSnapAPIEvent(event);
if (!isOurs) {
logger.info({ eventType: event.type, eventId: event.id }, "Ignoring non-SnapAPI event");
return res.json({ received: true, ignored: true });
}
}
logger.info({ eventType: event.type, eventId: event.id }, "Processing webhook");
switch (event.type) {
case "checkout.session.completed": {
const session = (event.data as any).object as Stripe.Checkout.Session;
const sessionId = session.id;
if (!provisionedSessions.has(sessionId)) {
provisionedSessions.add(sessionId);
const email = session.customer_details?.email || session.customer_email || "unknown@snapapi.eu";
const plan = session.metadata?.plan || "starter";
const tier = PLANS[plan]?.tier || "starter";
const customerId = typeof session.customer === "string" ? session.customer : (session.customer as any)?.id;
await createPaidKey(email, tier, customerId);
logger.info({ email, tier, customerId }, "Provisioned key via webhook");
}
break;
}
case "customer.subscription.updated": {
const sub = (event.data as any).object as Stripe.Subscription;
const customerId = typeof sub.customer === "string" ? sub.customer : (sub.customer as any)?.id;
if (sub.status === "canceled" || sub.status === "past_due" || sub.status === "unpaid") {
await downgradeByCustomer(customerId);
logger.info({ customerId, status: sub.status }, "Downgraded customer");
}
break;
}
case "customer.subscription.deleted": {
const sub = (event.data as any).object as Stripe.Subscription;
const customerId = typeof sub.customer === "string" ? sub.customer : (sub.customer as any)?.id;
await downgradeByCustomer(customerId);
logger.info({ customerId }, "Subscription deleted, downgraded");
break;
}
case "customer.updated": {
const customer = (event.data as any).object as Stripe.Customer;
if (customer.email) {
await updateEmailByCustomer(customer.id, customer.email);
}
break;
}
}
res.json({ received: true });
} catch (err: any) {
logger.error({ err }, "Webhook error");
res.status(400).send("Webhook error: " + err.message);
}
});
export { router as billingRouter };

14
src/routes/health.ts Normal file
View file

@ -0,0 +1,14 @@
import { Router } from "express";
import { getPoolStats } from "../services/browser.js";
export const healthRouter = Router();
healthRouter.get("/", (_req, res) => {
const pool = getPoolStats();
res.json({
status: "ok",
version: "0.1.0",
uptime: process.uptime(),
browser: pool,
});
});

68
src/routes/playground.ts Normal file
View file

@ -0,0 +1,68 @@
import { Router } from "express";
import { takeScreenshot } from "../services/screenshot.js";
import { addWatermark } from "../services/watermark.js";
import logger from "../services/logger.js";
import rateLimit from "express-rate-limit";
export const playgroundRouter = Router();
// 5 requests per hour per IP
const playgroundLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Playground rate limit exceeded (5 requests/hour). Get an API key for unlimited access.", upgrade: "https://snapapi.eu/#pricing" },
keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown",
});
playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
const { url, format, width, height } = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({ error: "Missing required parameter: url" });
return;
}
// Enforce reasonable limits for playground
const safeWidth = Math.min(Math.max(parseInt(width, 10) || 1280, 320), 1920);
const safeHeight = Math.min(Math.max(parseInt(height, 10) || 800, 200), 1080);
const safeFormat = ["png", "jpeg", "webp"].includes(format) ? format : "png";
try {
const result = await takeScreenshot({
url,
format: safeFormat as "png" | "jpeg" | "webp",
width: safeWidth,
height: safeHeight,
fullPage: false,
quality: safeFormat === "png" ? undefined : 70,
deviceScale: 1,
});
// Add watermark
const watermarked = await addWatermark(result.buffer, safeWidth, safeHeight);
res.setHeader("Content-Type", result.contentType);
res.setHeader("Content-Length", watermarked.length);
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Playground", "true");
res.send(watermarked);
} catch (err: any) {
logger.error({ err: err.message, url }, "Playground screenshot failed");
if (err.message === "QUEUE_FULL") {
res.status(503).json({ error: "Service busy. Try again shortly." });
return;
}
if (err.message === "SCREENSHOT_TIMEOUT") {
res.status(504).json({ error: "Screenshot timed out." });
return;
}
if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL")) {
res.status(400).json({ error: err.message });
return;
}
res.status(500).json({ error: "Screenshot failed" });
}
});

50
src/routes/screenshot.ts Normal file
View file

@ -0,0 +1,50 @@
import { Router } from "express";
import { takeScreenshot } from "../services/screenshot.js";
import logger from "../services/logger.js";
export const screenshotRouter = Router();
screenshotRouter.post("/", async (req: any, res) => {
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay } = req.body;
if (!url || typeof url !== "string") {
res.status(400).json({ error: "Missing required parameter: url" });
return;
}
try {
const result = await takeScreenshot({
url,
format: format || "png",
width: width ? parseInt(width, 10) : undefined,
height: height ? parseInt(height, 10) : undefined,
fullPage: fullPage === true || fullPage === "true",
quality: quality ? parseInt(quality, 10) : undefined,
waitForSelector,
deviceScale: deviceScale ? parseFloat(deviceScale) : undefined,
delay: delay ? parseInt(delay, 10) : undefined,
});
res.setHeader("Content-Type", result.contentType);
res.setHeader("Content-Length", result.buffer.length);
res.setHeader("Cache-Control", "no-store");
res.send(result.buffer);
} catch (err: any) {
logger.error({ err: err.message, url }, "Screenshot failed");
if (err.message === "QUEUE_FULL") {
res.status(503).json({ error: "Service busy. Try again shortly." });
return;
}
if (err.message === "SCREENSHOT_TIMEOUT") {
res.status(504).json({ error: "Screenshot timed out. The page may be too slow to load." });
return;
}
if (err.message.includes("blocked") || err.message.includes("not allowed") || err.message.includes("Invalid URL")) {
res.status(400).json({ error: err.message });
return;
}
res.status(500).json({ error: "Screenshot failed", details: err.message });
}
});

29
src/routes/signup.ts Normal file
View file

@ -0,0 +1,29 @@
import { Router } from "express";
import { createKey } from "../services/keys.js";
import logger from "../services/logger.js";
export const signupRouter = Router();
// Simple signup: email → instant API key (no verification for now)
signupRouter.post("/free", async (req, res) => {
const { email } = req.body;
if (!email || typeof email !== "string" || !email.includes("@")) {
res.status(400).json({ error: "Valid email required" });
return;
}
try {
const key = await createKey(email.toLowerCase().trim(), "free");
logger.info({ email: email.slice(0, 3) + "***" }, "Free signup");
res.json({
apiKey: key.key,
tier: "free",
limit: 100,
message: "Your API key is ready! 100 free screenshots/month.",
});
} catch (err: any) {
logger.error({ err }, "Signup failed");
res.status(500).json({ error: "Signup failed" });
}
});

9
src/routes/status.ts Normal file
View file

@ -0,0 +1,9 @@
import { Router } from "express";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const router = Router();
router.get("/", (_req, res) => {
res.sendFile(path.join(__dirname, "../../public/status.html"));
});
export { router as statusRouter };

144
src/services/browser.ts Normal file
View file

@ -0,0 +1,144 @@
import puppeteer, { Browser, Page } from "puppeteer";
import logger from "./logger.js";
const BROWSER_COUNT = parseInt(process.env.BROWSER_COUNT || "2", 10);
const PAGES_PER_BROWSER = parseInt(process.env.PAGES_PER_BROWSER || "4", 10);
const RESTART_AFTER = 500;
const RESTART_AFTER_MS = 60 * 60 * 1000;
interface BrowserInstance {
browser: Browser;
availablePages: Page[];
jobCount: number;
lastRestartTime: number;
restarting: boolean;
id: number;
}
const instances: BrowserInstance[] = [];
const waitingQueue: Array<{ resolve: (v: { page: Page; instance: BrowserInstance }) => void }> = [];
let roundRobinIndex = 0;
export function getPoolStats() {
const totalAvailable = instances.reduce((s, i) => s + i.availablePages.length, 0);
return {
browsers: instances.length,
pagesPerBrowser: PAGES_PER_BROWSER,
totalPages: instances.length * PAGES_PER_BROWSER,
availablePages: totalAvailable,
queueDepth: waitingQueue.length,
totalJobs: instances.reduce((s, i) => s + i.jobCount, 0),
};
}
async function recyclePage(page: Page): Promise<void> {
try {
await page.goto("about:blank", { timeout: 5000 }).catch(() => {});
} catch {}
}
async function createPages(b: Browser, count: number): Promise<Page[]> {
const pages: Page[] = [];
for (let i = 0; i < count; i++) pages.push(await b.newPage());
return pages;
}
function pickInstance(): BrowserInstance | null {
for (let i = 0; i < instances.length; i++) {
const idx = (roundRobinIndex + i) % instances.length;
const inst = instances[idx];
if (inst.availablePages.length > 0 && !inst.restarting) {
roundRobinIndex = (idx + 1) % instances.length;
return inst;
}
}
return null;
}
export async function acquirePage(): Promise<{ page: Page; instance: BrowserInstance }> {
for (const inst of instances) {
if (!inst.restarting && (inst.jobCount >= RESTART_AFTER || Date.now() - inst.lastRestartTime >= RESTART_AFTER_MS)) {
scheduleRestart(inst);
}
}
const inst = pickInstance();
if (inst) {
return { page: inst.availablePages.pop()!, instance: inst };
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const idx = waitingQueue.findIndex(w => w.resolve === resolve);
if (idx >= 0) waitingQueue.splice(idx, 1);
reject(new Error("QUEUE_FULL"));
}, 30_000);
waitingQueue.push({ resolve: (v) => { clearTimeout(timer); resolve(v); } });
});
}
export function releasePage(page: Page, inst: BrowserInstance): void {
inst.jobCount++;
const waiter = waitingQueue.shift();
if (waiter) {
recyclePage(page).then(() => waiter.resolve({ page, instance: inst })).catch(() => {
waitingQueue.unshift(waiter);
});
return;
}
recyclePage(page).then(() => inst.availablePages.push(page)).catch(() => {});
}
async function scheduleRestart(inst: BrowserInstance): Promise<void> {
if (inst.restarting) return;
inst.restarting = true;
logger.info(`Scheduling browser ${inst.id} restart`);
// Wait for pages to drain (max 30s)
await Promise.race([
new Promise<void>(resolve => {
const check = () => {
if (inst.availablePages.length === PAGES_PER_BROWSER) resolve();
else setTimeout(check, 100);
};
check();
}),
new Promise<void>(r => setTimeout(r, 30000)),
]);
for (const page of inst.availablePages) await page.close().catch(() => {});
inst.availablePages.length = 0;
try { await inst.browser.close(); } catch {}
inst.browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
inst.availablePages.push(...await createPages(inst.browser, PAGES_PER_BROWSER));
inst.jobCount = 0;
inst.lastRestartTime = Date.now();
inst.restarting = false;
logger.info(`Browser ${inst.id} restarted`);
}
export async function initBrowser(): Promise<void> {
for (let i = 0; i < BROWSER_COUNT; i++) {
const browser = await puppeteer.launch({
headless: true,
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
});
const pages = await createPages(browser, PAGES_PER_BROWSER);
instances.push({ browser, availablePages: pages, jobCount: 0, lastRestartTime: Date.now(), restarting: false, id: i });
}
logger.info(`Browser pool ready (${BROWSER_COUNT}×${PAGES_PER_BROWSER} = ${BROWSER_COUNT * PAGES_PER_BROWSER} pages)`);
}
export async function closeBrowser(): Promise<void> {
for (const inst of instances) {
for (const page of inst.availablePages) await page.close().catch(() => {});
await inst.browser.close().catch(() => {});
}
instances.length = 0;
}

100
src/services/db.ts Normal file
View file

@ -0,0 +1,100 @@
import pg from "pg";
import logger from "./logger.js";
const { Pool } = pg;
const TRANSIENT_ERRORS = new Set([
"ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT",
"57P01", "57P02", "57P03", "08006", "08003", "08001",
]);
const pool = new Pool({
host: process.env.DATABASE_HOST || "main-db-pooler.postgres.svc",
port: parseInt(process.env.DATABASE_PORT || "5432", 10),
database: process.env.DATABASE_NAME || "snapapi",
user: process.env.DATABASE_USER || "docfast",
password: process.env.DATABASE_PASSWORD || "docfast",
max: 10,
idleTimeoutMillis: 10000,
connectionTimeoutMillis: 5000,
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
});
pool.on("error", (err) => {
logger.error({ err }, "Unexpected error on idle PostgreSQL client");
});
function isTransientError(err: any): boolean {
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") || msg.includes("connection terminated") || msg.includes("connection refused")) return true;
return false;
}
export async function queryWithRetry(text: string, params?: any[], maxRetries = 3): Promise<pg.QueryResult> {
let lastError: any;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
let client: pg.PoolClient | undefined;
try {
client = await pool.connect();
const result = await client.query(text, params);
client.release();
return result;
} catch (err: any) {
if (client) try { client.release(true); } catch (_) {}
lastError = err;
if (!isTransientError(err) || attempt === maxRetries) throw err;
const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000);
logger.warn({ err: err.message, attempt: attempt + 1 }, "Transient DB error, retrying...");
await new Promise(r => setTimeout(r, delayMs));
}
}
throw lastError;
}
export async function connectWithRetry(maxRetries = 3): Promise<pg.PoolClient> {
let lastError: any;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const client = await pool.connect();
await client.query("SELECT 1");
return client;
} catch (err: any) {
lastError = err;
if (!isTransientError(err) || attempt === maxRetries) throw err;
await new Promise(r => setTimeout(r, Math.min(1000 * Math.pow(2, attempt), 5000)));
}
}
throw lastError;
}
export async function initDatabase(): Promise<void> {
const client = await connectWithRetry();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS api_keys (
key TEXT PRIMARY KEY,
tier TEXT NOT NULL DEFAULT 'free',
email TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
stripe_customer_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email);
CREATE TABLE IF NOT EXISTS usage (
key TEXT NOT NULL,
month_key TEXT NOT NULL,
count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, month_key)
);
`);
logger.info("PostgreSQL tables initialized");
} finally {
client.release();
}
}
export { pool };
export default pool;

207
src/services/keys.ts Normal file
View file

@ -0,0 +1,207 @@
import { randomBytes } from "crypto";
import logger from "./logger.js";
import { queryWithRetry } from "./db.js";
export interface ApiKey {
key: string;
tier: "free" | "starter" | "pro" | "business";
email: string;
createdAt: string;
stripeCustomerId?: string;
}
let keysCache: ApiKey[] = [];
export async function loadKeys(): Promise<void> {
try {
const result = await queryWithRetry("SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys");
keysCache = result.rows.map(r => ({
key: r.key,
tier: r.tier,
email: r.email,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined,
}));
} catch (err) {
logger.error({ err }, "Failed to load keys");
keysCache = [];
}
// Seed keys from env
const envKeys = process.env.API_KEYS?.split(",").map(k => k.trim()).filter(Boolean) || [];
for (const k of envKeys) {
if (!keysCache.find(e => e.key === k)) {
const entry: ApiKey = { key: k, tier: "business", email: "admin@snapapi.dev", createdAt: new Date().toISOString() };
keysCache.push(entry);
await queryWithRetry(
`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (key) DO NOTHING`,
[k, "business", entry.email, entry.createdAt]
).catch(() => {});
}
}
}
// Cache-aside: check DB if key not in memory (multi-replica support)
async function fetchKeyFromDb(key: string): Promise<ApiKey | undefined> {
try {
const result = await queryWithRetry(
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE key = $1",
[key]
);
if (result.rows.length === 0) return undefined;
const r = result.rows[0];
const entry: ApiKey = {
key: r.key,
tier: r.tier,
email: r.email,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined,
};
// Add to local cache
if (!keysCache.find(k => k.key === entry.key)) {
keysCache.push(entry);
}
return entry;
} catch (err) {
logger.error({ err }, "Failed to fetch key from DB");
return undefined;
}
}
export async function isValidKey(key: string): Promise<boolean> {
if (keysCache.some(k => k.key === key)) return true;
const fetched = await fetchKeyFromDb(key);
return fetched !== undefined;
}
export async function getKeyInfo(key: string): Promise<ApiKey | undefined> {
const cached = keysCache.find(k => k.key === key);
if (cached) return cached;
return fetchKeyFromDb(key);
}
function generateKey(): string {
return `snap_${randomBytes(24).toString("hex")}`;
}
export async function createKey(email: string, tier: ApiKey["tier"] = "free"): Promise<ApiKey> {
// For free tier, check DB too (another pod might have created it)
if (tier === "free") {
const existing = keysCache.find(k => k.email === email && k.tier === "free");
if (existing) return existing;
// Also check DB
try {
const result = await queryWithRetry(
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE email = $1 AND tier = $2",
[email, "free"]
);
if (result.rows.length > 0) {
const r = result.rows[0];
const entry: ApiKey = {
key: r.key, tier: r.tier, email: r.email,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined,
};
if (!keysCache.find(k => k.key === entry.key)) keysCache.push(entry);
return entry;
}
} catch {}
}
const entry: ApiKey = {
key: generateKey(),
tier,
email,
createdAt: new Date().toISOString(),
};
await queryWithRetry(
"INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)",
[entry.key, entry.tier, entry.email, entry.createdAt]
);
keysCache.push(entry);
return entry;
}
export function getAllKeys(): ApiKey[] {
return [...keysCache];
}
export function getTierLimit(tier: string): number {
switch (tier) {
case "free": return 100;
case "starter": return 1000;
case "pro": return 5000;
case "business": return 25000;
default: return 100;
}
}
export async function createPaidKey(email: string, tier: "starter" | "pro" | "business", stripeCustomerId?: string): Promise<ApiKey> {
// Check if customer already has a key
if (stripeCustomerId) {
try {
const result = await queryWithRetry(
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys WHERE stripe_customer_id = $1",
[stripeCustomerId]
);
if (result.rows.length > 0) {
const r = result.rows[0];
const entry: ApiKey = {
key: r.key, tier: r.tier, email: r.email,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined,
};
// Update tier if upgrading
if (entry.tier !== tier) {
await queryWithRetry("UPDATE api_keys SET tier = $1 WHERE stripe_customer_id = $2", [tier, stripeCustomerId]);
entry.tier = tier;
const cached = keysCache.find(k => k.key === entry.key);
if (cached) cached.tier = tier;
}
if (!keysCache.find(k => k.key === entry.key)) keysCache.push(entry);
return entry;
}
} catch {}
}
const entry: ApiKey = {
key: generateKey(),
tier,
email,
createdAt: new Date().toISOString(),
stripeCustomerId,
};
await queryWithRetry(
"INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)",
[entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId || null]
);
keysCache.push(entry);
logger.info({ email, tier, stripeCustomerId }, "Created paid API key");
return entry;
}
export async function downgradeByCustomer(customerId: string): Promise<void> {
await queryWithRetry(
"UPDATE api_keys SET tier = 'free', stripe_customer_id = NULL WHERE stripe_customer_id = $1",
[customerId]
);
for (const k of keysCache) {
if (k.stripeCustomerId === customerId) {
k.tier = "free";
k.stripeCustomerId = undefined;
}
}
logger.info({ customerId }, "Downgraded customer to free");
}
export async function updateEmailByCustomer(customerId: string, newEmail: string): Promise<void> {
await queryWithRetry(
"UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2",
[newEmail, customerId]
);
for (const k of keysCache) {
if (k.stripeCustomerId === customerId) k.email = newEmail;
}
}

3
src/services/logger.ts Normal file
View file

@ -0,0 +1,3 @@
import pino from "pino";
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
export default logger;

View file

@ -0,0 +1,74 @@
import { Page } from "puppeteer";
import { acquirePage, releasePage } from "./browser.js";
import { validateUrl } from "./ssrf.js";
import logger from "./logger.js";
export interface ScreenshotOptions {
url: string;
format?: "png" | "jpeg" | "webp";
width?: number;
height?: number;
fullPage?: boolean;
quality?: number;
waitForSelector?: string;
deviceScale?: number;
delay?: number;
}
export interface ScreenshotResult {
buffer: Buffer;
contentType: string;
}
const MAX_WIDTH = 3840;
const MAX_HEIGHT = 2160;
const TIMEOUT_MS = 30_000;
export async function takeScreenshot(opts: ScreenshotOptions): Promise<ScreenshotResult> {
// Validate URL for SSRF
await validateUrl(opts.url);
const format = opts.format || "png";
const width = Math.min(opts.width || 1280, MAX_WIDTH);
const height = Math.min(opts.height || 800, MAX_HEIGHT);
const fullPage = opts.fullPage ?? false;
const quality = format === "png" ? undefined : Math.min(Math.max(opts.quality || 80, 1), 100);
const deviceScale = Math.min(opts.deviceScale || 1, 3);
const { page, instance } = await acquirePage();
try {
await page.setViewport({ width, height, deviceScaleFactor: deviceScale });
await Promise.race([
(async () => {
await page.goto(opts.url, { waitUntil: "networkidle2", timeout: 20_000 });
if (opts.waitForSelector) {
await page.waitForSelector(opts.waitForSelector, { timeout: 10_000 });
}
if (opts.delay && opts.delay > 0) {
await new Promise(r => setTimeout(r, Math.min(opts.delay!, 5000)));
}
})(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("SCREENSHOT_TIMEOUT")), TIMEOUT_MS)),
]);
const screenshotOpts: any = {
type: format === "webp" ? "webp" : format,
fullPage,
encoding: "binary",
};
if (quality !== undefined) screenshotOpts.quality = quality;
const result = await page.screenshot(screenshotOpts);
const buffer = Buffer.from(result as unknown as ArrayBuffer);
const contentType = format === "png" ? "image/png" : format === "jpeg" ? "image/jpeg" : "image/webp";
return { buffer, contentType };
} finally {
releasePage(page, instance);
}
}

65
src/services/ssrf.ts Normal file
View file

@ -0,0 +1,65 @@
import { lookup } from "dns/promises";
import logger from "./logger.js";
// Block private, loopback, link-local, metadata IPs
const BLOCKED_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^0\./,
/^::1$/,
/^fe80:/i,
/^fc00:/i,
/^fd00:/i,
];
const BLOCKED_HOSTS = [
/\.svc$/,
/\.svc\./,
/\.cluster\.local$/,
/\.internal$/,
/^localhost$/,
/^kubernetes/,
];
export async function validateUrl(urlStr: string): Promise<{ hostname: string; resolvedIp: string }> {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
throw new Error("Invalid URL");
}
if (!["http:", "https:"].includes(parsed.protocol)) {
throw new Error("Only HTTP and HTTPS URLs are allowed");
}
const hostname = parsed.hostname;
// Check blocked hostnames
for (const pattern of BLOCKED_HOSTS) {
if (pattern.test(hostname)) {
throw new Error("URL hostname is not allowed");
}
}
// Resolve DNS and check IP
let ip: string;
try {
const result = await lookup(hostname);
ip = result.address;
} catch {
throw new Error("Could not resolve hostname");
}
for (const pattern of BLOCKED_RANGES) {
if (pattern.test(ip)) {
logger.warn({ hostname, ip }, "SSRF attempt blocked");
throw new Error("URL resolves to a blocked IP range");
}
}
return { hostname, resolvedIp: ip };
}

62
src/services/watermark.ts Normal file
View file

@ -0,0 +1,62 @@
/**
* Adds a text watermark to a PNG/JPEG/WebP screenshot buffer.
* Uses pure SVG overlay composited via Puppeteer's page.evaluate or
* a simpler approach: we re-render an HTML page with the image + watermark overlay.
*
* Since we already have Puppeteer, the simplest reliable approach is to
* render an HTML page with the screenshot as background + CSS text overlay.
* But that's expensive (double render). Instead, use a lightweight approach:
* encode watermark text directly into the PNG using canvas-less SVG trick.
*
* Simplest production approach: use sharp if available, or fallback to
* Puppeteer overlay. Since we don't want to add sharp (large native dep),
* we'll use Puppeteer to composite.
*/
import { acquirePage, releasePage } from "./browser.js";
export async function addWatermark(imageBuffer: Buffer, width: number, height: number): Promise<Buffer> {
const { page, instance } = await acquirePage();
try {
const b64 = imageBuffer.toString("base64");
const dataUrl = `data:image/png;base64,${b64}`;
await page.setViewport({ width, height });
// Render the image with a watermark overlay
await page.setContent(`
<!DOCTYPE html>
<html><head><style>
* { margin: 0; padding: 0; }
body { width: ${width}px; height: ${height}px; position: relative; overflow: hidden; }
img { width: 100%; height: 100%; object-fit: cover; display: block; }
.watermark {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
.watermark-text {
font-family: Arial, Helvetica, sans-serif;
font-size: ${Math.max(width / 20, 24)}px;
font-weight: 900;
color: rgba(255, 255, 255, 0.35);
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.5);
transform: rotate(-30deg);
white-space: nowrap;
letter-spacing: 2px;
user-select: none;
text-transform: uppercase;
}
</style></head><body>
<img src="${dataUrl}">
<div class="watermark">
<div class="watermark-text">snapapi.eu upgrade for clean screenshots</div>
</div>
</body></html>
`, { waitUntil: "load" });
const result = await page.screenshot({ type: "png", encoding: "binary" });
return Buffer.from(result as unknown as ArrayBuffer);
} finally {
releasePage(page, instance);
}
}