feat: initial codebase v0.4.1
Some checks failed
Deploy to Staging / build-and-deploy (push) Failing after 9m44s
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:
commit
b58f634318
28 changed files with 5669 additions and 0 deletions
157
src/index.ts
Normal file
157
src/index.ts
Normal 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
22
src/middleware/auth.ts
Normal 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();
|
||||
}
|
||||
11
src/middleware/compression.ts
Normal file
11
src/middleware/compression.ts
Normal 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
91
src/middleware/usage.ts
Normal 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
276
src/routes/billing.ts
Normal 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
14
src/routes/health.ts
Normal 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
68
src/routes/playground.ts
Normal 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
50
src/routes/screenshot.ts
Normal 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
29
src/routes/signup.ts
Normal 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
9
src/routes/status.ts
Normal 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
144
src/services/browser.ts
Normal 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
100
src/services/db.ts
Normal 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
207
src/services/keys.ts
Normal 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
3
src/services/logger.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import pino from "pino";
|
||||
const logger = pino({ level: process.env.LOG_LEVEL || "info" });
|
||||
export default logger;
|
||||
74
src/services/screenshot.ts
Normal file
74
src/services/screenshot.ts
Normal 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
65
src/services/ssrf.ts
Normal 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
62
src/services/watermark.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue