SnapAPI/src/routes/playground.ts
OpenClawd b07b9cfd25
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 9m41s
fix: return 400 for invalid protocols and unresolvable hostnames (was 500)
2026-02-24 14:00:55 +00:00

145 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" });
}
});