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", }); /** * @openapi * /v1/playground: * post: * tags: [Playground] * summary: Free demo screenshot (watermarked) * description: > * Take a watermarked screenshot without authentication. * Limited to 5 requests per hour per IP, max 1920×1080 resolution. * Perfect for evaluating the API before purchasing a plan. * operationId: playgroundScreenshot * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [url] * properties: * url: * type: string * format: uri * description: URL to capture * example: "https://example.com" * format: * type: string * enum: [png, jpeg, webp] * default: png * description: Output image format * width: * type: integer * minimum: 320 * maximum: 1920 * default: 1280 * description: Viewport width (clamped to 1920 max) * height: * type: integer * minimum: 200 * maximum: 1080 * default: 800 * description: Viewport height (clamped to 1080 max) * responses: * 200: * description: Watermarked screenshot image * content: * image/png: * schema: { type: string, format: binary } * image/jpeg: * schema: { type: string, format: binary } * image/webp: * schema: { type: string, format: binary } * 400: * description: Invalid request * content: * application/json: * schema: { $ref: "#/components/schemas/Error" } * 429: * description: Rate limit exceeded (5/hr) * content: * application/json: * schema: { $ref: "#/components/schemas/Error" } * 503: * description: Service busy * content: * application/json: * schema: { $ref: "#/components/schemas/Error" } */ playgroundRouter.post("/", playgroundLimiter, async (req, res) => { const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, waitUntil } = 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"; const safeFullPage = fullPage === true; const safeQuality = safeFormat === "png" ? undefined : Math.min(Math.max(parseInt(quality, 10) || 80, 1), 100); const safeDeviceScale = Math.min(Math.max(parseInt(deviceScale, 10) || 1, 1), 3); const validWaitUntil = ["load", "domcontentloaded", "networkidle0", "networkidle2"]; const safeWaitUntil = validWaitUntil.includes(waitUntil) ? waitUntil : "domcontentloaded"; // Sanitize waitForSelector — allow simple CSS selectors only (no script injection) const safeWaitForSelector = typeof waitForSelector === "string" && /^[a-zA-Z0-9\s\-_.#\[\]=:"'>,+~()]+$/.test(waitForSelector) && waitForSelector.length <= 200 ? waitForSelector : undefined; try { const result = await takeScreenshot({ url, format: safeFormat as "png" | "jpeg" | "webp", width: safeWidth, height: safeHeight, fullPage: safeFullPage, quality: safeQuality, deviceScale: safeDeviceScale, waitUntil: safeWaitUntil as any, waitForSelector: safeWaitForSelector, }); // 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") || err.message.includes("Could not resolve")) { res.status(400).json({ error: err.message }); return; } res.status(500).json({ error: "Screenshot failed" }); } });