All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m41s
145 lines
5.5 KiB
TypeScript
145 lines
5.5 KiB
TypeScript
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" });
|
||
}
|
||
});
|