diff --git a/public/index.html b/public/index.html index 1ef4ebd..efbe7b2 100644 --- a/public/index.html +++ b/public/index.html @@ -85,7 +85,7 @@ nav{position:sticky;top:0;z-index:100;background:rgba(10,14,23,0.85);backdrop-fi .stat:last-child{border-right:none} .stat .number{font-size:2rem;font-weight:800;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text} .stat .label{font-size:.82rem;color:var(--muted);margin-top:4px;font-weight:500} -.features-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin-top:48px} +.features-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;margin-top:48px} .feature-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:36px 28px;transition:all .3s} .feature-card:hover{border-color:var(--border-light);background:var(--card-hover);transform:translateY(-2px)} .feature-icon{width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:1.5rem;margin-bottom:20px} @@ -270,6 +270,7 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var(
+
@@ -282,6 +283,19 @@ footer{border-top:1px solid var(--border);padding:48px 24px 32px;background:var( -H "Content-Type: application/json" \ -d '{"url":"https://example.com","format":"png"}' \ -o screenshot.png + + +
+
+

Response Caching

+

Automatic 5-minute caching for repeat requests. Faster responses and reduced server load. Bypass with cache=false.

+
+
+
🔗
+

GET Request Support

+

Direct image embedding with GET requests. Perfect for <img> tags and markdown. API key via query parameter.

+
diff --git a/src/docs/openapi.ts b/src/docs/openapi.ts index 40025e0..ca9884d 100644 --- a/src/docs/openapi.ts +++ b/src/docs/openapi.ts @@ -10,7 +10,10 @@ const options: swaggerJsdoc.Options = { "## Authentication\n" + "API screenshot requests require an API key:\n" + "- `Authorization: Bearer YOUR_API_KEY` header, or\n" + - "- `X-API-Key: YOUR_API_KEY` header\n\n" + + "- `X-API-Key: YOUR_API_KEY` header, or\n" + + "- `?key=YOUR_API_KEY` query parameter (for GET requests)\n\n" + + "## Response Caching\n" + + "Screenshot responses are cached for 5 minutes (authenticated requests only). Add `?cache=false` or `\"cache\": false` to bypass cache. Cache status is indicated via `X-Cache` header (HIT/MISS).\n\n" + "## Playground\n" + "The `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n" + "## Rate Limits\n" + @@ -37,6 +40,7 @@ const options: swaggerJsdoc.Options = { securitySchemes: { BearerAuth: { type: "http", scheme: "bearer" }, ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" }, + QueryKeyAuth: { type: "apiKey", in: "query", name: "key" }, }, schemas: { Error: { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index b9af880..951c081 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -4,13 +4,15 @@ import { isValidKey, getKeyInfo } from "../services/keys.js"; export async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise { const header = req.headers.authorization; const xApiKey = req.headers["x-api-key"] as string | undefined; + const queryKey = req.query.key as string | undefined; let key: string | undefined; if (header?.startsWith("Bearer ")) key = header.slice(7); else if (xApiKey) key = xApiKey; + else if (queryKey) key = queryKey; if (!key) { - res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer or X-API-Key: " }); + res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer , X-API-Key: , or ?key=" }); return; } if (!(await isValidKey(key))) { diff --git a/src/routes/screenshot.ts b/src/routes/screenshot.ts index e3fcc67..bcd4207 100644 --- a/src/routes/screenshot.ts +++ b/src/routes/screenshot.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { takeScreenshot } from "../services/screenshot.js"; +import { screenshotCache } from "../services/cache.js"; import logger from "../services/logger.js"; export const screenshotRouter = Router(); @@ -128,32 +129,217 @@ export const screenshotRouter = Router(); * content: * application/json: * schema: { $ref: "#/components/schemas/Error" } + * get: + * tags: [Screenshots] + * summary: Take a screenshot via GET request (authenticated) + * description: > + * Capture a pixel-perfect, unwatermarked screenshot using GET request. + * All parameters are passed via query string. Perfect for image embeds: + * `` + * operationId: takeScreenshotGet + * security: + * - BearerAuth: [] + * - ApiKeyAuth: [] + * - QueryKeyAuth: [] + * parameters: + * - name: url + * in: query + * required: true + * schema: + * type: string + * format: uri + * description: URL to capture + * example: https://example.com + * - name: key + * in: query + * schema: + * type: string + * description: API key (alternative to header auth) + * example: sk_test_1234567890abcdef + * - name: format + * in: query + * schema: + * type: string + * enum: [png, jpeg, webp] + * default: png + * description: Output image format + * - name: width + * in: query + * schema: + * type: integer + * minimum: 320 + * maximum: 3840 + * default: 1280 + * description: Viewport width in pixels + * - name: height + * in: query + * schema: + * type: integer + * minimum: 200 + * maximum: 2160 + * default: 800 + * description: Viewport height in pixels + * - name: fullPage + * in: query + * schema: + * type: boolean + * default: false + * description: Capture full scrollable page instead of viewport only + * - name: quality + * in: query + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 80 + * description: JPEG/WebP quality (ignored for PNG) + * - name: waitForSelector + * in: query + * schema: + * type: string + * description: CSS selector to wait for before capturing + * - name: deviceScale + * in: query + * schema: + * type: number + * minimum: 1 + * maximum: 3 + * default: 1 + * description: Device scale factor (2 = Retina) + * - name: delay + * in: query + * schema: + * type: integer + * minimum: 0 + * maximum: 5000 + * default: 0 + * description: Extra delay in ms after page load before capturing + * - name: waitUntil + * in: query + * schema: + * type: string + * enum: [load, domcontentloaded, networkidle0, networkidle2] + * default: domcontentloaded + * description: Page load event to wait for before capturing + * - name: cache + * in: query + * schema: + * type: boolean + * default: true + * description: Enable response caching (5-minute TTL) + * responses: + * 200: + * description: Screenshot image binary + * headers: + * X-Cache: + * description: Cache status + * schema: + * type: string + * enum: [HIT, MISS] + * 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 (bad URL, blocked domain, etc.) + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 401: + * description: Missing API key + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 403: + * description: Invalid API key + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 429: + * description: Rate or usage limit exceeded + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 503: + * description: Service busy (queue full) + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 504: + * description: Screenshot timed out + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } */ -screenshotRouter.post("/", async (req: any, res) => { - const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay, waitUntil } = req.body; +// Shared handler for both GET and POST requests +async function handleScreenshotRequest(req: any, res: any) { + // Extract parameters from both query (GET) and body (POST) + const source = req.method === "GET" ? req.query : req.body; + + const { + url, + format, + width, + height, + fullPage, + quality, + waitForSelector, + deviceScale, + delay, + waitUntil, + cache, + } = source; if (!url || typeof url !== "string") { res.status(400).json({ error: "Missing required parameter: url" }); return; } + // Normalize parameters + const params = { + 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, + waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"].includes(waitUntil) ? waitUntil : undefined, + cache, + }; + 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, - waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"].includes(waitUntil) ? waitUntil : undefined, - }); + // Check cache first (if not bypassed) + let cacheHit = false; + if (!screenshotCache.shouldBypass(params)) { + const cached = screenshotCache.get(params); + if (cached) { + res.setHeader("Content-Type", cached.contentType); + res.setHeader("Content-Length", cached.buffer.length); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Cache", "HIT"); + res.send(cached.buffer); + return; + } + } + + // Take new screenshot + const result = await takeScreenshot(params); + + // Cache the result (if not bypassed) + if (!screenshotCache.shouldBypass(params)) { + screenshotCache.put(params, result.buffer, result.contentType); + } res.setHeader("Content-Type", result.contentType); res.setHeader("Content-Length", result.buffer.length); res.setHeader("Cache-Control", "no-store"); + res.setHeader("X-Cache", "MISS"); res.send(result.buffer); } catch (err: any) { logger.error({ err: err.message, url }, "Screenshot failed"); @@ -173,4 +359,8 @@ screenshotRouter.post("/", async (req: any, res) => { res.status(500).json({ error: "Screenshot failed", details: err.message }); } -}); +} + +// Register both GET and POST routes +screenshotRouter.get("/", handleScreenshotRequest); +screenshotRouter.post("/", handleScreenshotRequest); diff --git a/src/services/cache.ts b/src/services/cache.ts new file mode 100644 index 0000000..e58a57e --- /dev/null +++ b/src/services/cache.ts @@ -0,0 +1,195 @@ +import crypto from "crypto"; +import logger from "./logger.js"; + +interface CacheItem { + buffer: Buffer; + contentType: string; + timestamp: number; + lastAccessed: number; + size: number; +} + +export class ScreenshotCache { + private cache = new Map(); + private ttlMs: number; + private maxSizeBytes: number; + private currentSizeBytes = 0; + + constructor() { + // Default TTL: 5 minutes (configurable via env) + this.ttlMs = parseInt(process.env.CACHE_TTL_MS || "300000", 10); + // Default max size: 100MB (configurable via env) + this.maxSizeBytes = parseInt(process.env.CACHE_MAX_MB || "100", 10) * 1024 * 1024; + + // Start cleanup interval every minute + setInterval(() => this.cleanup(), 60 * 1000); + } + + /** + * Generate cache key from URL and screenshot parameters + */ + private generateKey(params: any): string { + // Hash all parameters that affect the screenshot result + const keyData = { + url: params.url, + format: params.format || "png", + width: params.width, + height: params.height, + fullPage: params.fullPage, + quality: params.quality, + waitForSelector: params.waitForSelector, + deviceScale: params.deviceScale, + delay: params.delay, + waitUntil: params.waitUntil, + }; + + const hash = crypto.createHash("sha256"); + hash.update(JSON.stringify(keyData)); + return hash.digest("hex"); + } + + /** + * Get cached screenshot if available and not expired + */ + get(params: any): CacheItem | null { + const key = this.generateKey(params); + const item = this.cache.get(key); + + if (!item) { + return null; + } + + // Check if expired + if (Date.now() - item.timestamp > this.ttlMs) { + this.cache.delete(key); + this.currentSizeBytes -= item.size; + return null; + } + + // Update last accessed time for LRU + item.lastAccessed = Date.now(); + return item; + } + + /** + * Store screenshot in cache + */ + put(params: any, buffer: Buffer, contentType: string): void { + const key = this.generateKey(params); + const size = buffer.length; + const now = Date.now(); + + // Don't cache if item is larger than 50% of max cache size + if (size > this.maxSizeBytes * 0.5) { + logger.warn({ size, maxSize: this.maxSizeBytes }, "Screenshot too large to cache"); + return; + } + + // Make room if needed + this.evictToFit(size); + + const item: CacheItem = { + buffer, + contentType, + timestamp: now, + lastAccessed: now, + size, + }; + + this.cache.set(key, item); + this.currentSizeBytes += size; + + logger.debug({ + key: key.substring(0, 8), + size, + totalItems: this.cache.size, + totalSize: this.currentSizeBytes + }, "Screenshot cached"); + } + + /** + * Check if caching should be bypassed + */ + shouldBypass(params: any): boolean { + // Check for cache=false in params (both GET query and POST body) + return params.cache === false || params.cache === "false"; + } + + /** + * Evict items to make room for new item + */ + private evictToFit(newItemSize: number): void { + // Calculate how much space we need + const availableSpace = this.maxSizeBytes - this.currentSizeBytes; + if (availableSpace >= newItemSize) { + return; // No eviction needed + } + + const spaceNeeded = newItemSize - availableSpace; + let spaceFreed = 0; + + // Sort items by last accessed time (oldest first) + const items = Array.from(this.cache.entries()).sort( + ([, a], [, b]) => a.lastAccessed - b.lastAccessed + ); + + for (const [key, item] of items) { + if (spaceFreed >= spaceNeeded) { + break; + } + + this.cache.delete(key); + this.currentSizeBytes -= item.size; + spaceFreed += item.size; + + logger.debug({ + key: key.substring(0, 8), + size: item.size, + spaceFreed, + spaceNeeded + }, "Evicted cache item"); + } + } + + /** + * Clean up expired items + */ + private cleanup(): void { + const now = Date.now(); + let expiredCount = 0; + let freedBytes = 0; + + for (const [key, item] of this.cache.entries()) { + if (now - item.timestamp > this.ttlMs) { + this.cache.delete(key); + this.currentSizeBytes -= item.size; + expiredCount++; + freedBytes += item.size; + } + } + + if (expiredCount > 0) { + logger.debug({ + expiredCount, + freedBytes, + remainingItems: this.cache.size, + totalSize: this.currentSizeBytes + }, "Cache cleanup completed"); + } + } + + /** + * Get cache statistics + */ + getStats() { + return { + items: this.cache.size, + sizeBytes: this.currentSizeBytes, + maxSizeBytes: this.maxSizeBytes, + ttlMs: this.ttlMs, + }; + } +} + +// Export singleton instance +export const screenshotCache = new ScreenshotCache(); \ No newline at end of file