mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: implement html-to-image API route with SSRF protection
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { capturePage, isBrowserAvailable } from "../../lib/browser-service.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { validateFetchUrl } from "../../lib/ssrf.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
|
||||
const DEVICE_PRESETS = {
|
||||
desktop: { width: 1280, height: 720, isMobile: false },
|
||||
tablet: { width: 768, height: 1024, isMobile: false },
|
||||
mobile: { width: 375, height: 812, isMobile: true },
|
||||
} as const;
|
||||
|
||||
const settingsSchema = z.object({
|
||||
url: z.string().url(),
|
||||
format: z.enum(["jpg", "png", "webp"]).default("png"),
|
||||
quality: z.number().min(1).max(100).default(90),
|
||||
fullPage: z.boolean().default(false),
|
||||
devicePreset: z.enum(["desktop", "tablet", "mobile", "custom"]).default("desktop"),
|
||||
viewportWidth: z.number().min(320).max(3840).default(1280),
|
||||
viewportHeight: z.number().min(320).max(2160).default(720),
|
||||
});
|
||||
|
||||
export function registerHtmlToImage(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/html-to-image",
|
||||
{
|
||||
config: {
|
||||
rateLimit: { max: 120, timeWindow: "1 hour" },
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const parsed = settingsSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: formatZodErrors(parsed.error.issues),
|
||||
});
|
||||
}
|
||||
|
||||
const settings = parsed.data;
|
||||
|
||||
if (!isBrowserAvailable()) {
|
||||
return reply.status(503).send({
|
||||
error: "Screenshot service is not available. Chromium is not installed.",
|
||||
code: "BROWSER_NOT_AVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await validateFetchUrl(settings.url);
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "URL is not allowed",
|
||||
details: err instanceof Error ? err.message : "URL validation failed",
|
||||
});
|
||||
}
|
||||
|
||||
const preset =
|
||||
settings.devicePreset !== "custom"
|
||||
? DEVICE_PRESETS[settings.devicePreset]
|
||||
: {
|
||||
width: settings.viewportWidth,
|
||||
height: settings.viewportHeight,
|
||||
isMobile: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const buffer = await capturePage(settings.url, {
|
||||
format: settings.format,
|
||||
quality: settings.quality,
|
||||
fullPage: settings.fullPage,
|
||||
viewportWidth: preset.width,
|
||||
viewportHeight: preset.height,
|
||||
isMobile: preset.isMobile,
|
||||
});
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const ext = settings.format;
|
||||
const filename = `screenshot.${ext}`;
|
||||
await writeFile(join(workspacePath, "output", filename), buffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
||||
originalSize: 0,
|
||||
processedSize: buffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
if (message.includes("Timeout") || message.includes("timeout")) {
|
||||
return reply.status(504).send({
|
||||
error: "Page took too long to load",
|
||||
details: stripInternalPaths(message),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("permanently disabled") ||
|
||||
message.includes("temporarily unavailable")
|
||||
) {
|
||||
return reply.status(503).send({
|
||||
error: "Screenshot service is temporarily unavailable",
|
||||
code: "BROWSER_CRASHED",
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(422).send({
|
||||
error: "Screenshot capture failed",
|
||||
details: stripInternalPaths(message),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { registerEraseObject } from "./erase-object.js";
|
||||
import { registerFavicon } from "./favicon.js";
|
||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||
import { registerGifTools } from "./gif-tools.js";
|
||||
import { registerHtmlToImage } from "./html-to-image.js";
|
||||
import { registerImageEnhancement } from "./image-enhancement.js";
|
||||
import { registerImageToBase64 } from "./image-to-base64.js";
|
||||
import { registerImageToPdf } from "./image-to-pdf.js";
|
||||
@@ -113,6 +114,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
{ id: "find-duplicates", register: registerFindDuplicates },
|
||||
{ id: "color-palette", register: registerColorPalette },
|
||||
{ id: "qr-generate", register: registerQrGenerate },
|
||||
{ id: "html-to-image", register: registerHtmlToImage },
|
||||
{ id: "barcode-read", register: registerBarcodeRead },
|
||||
{ id: "image-to-base64", register: registerImageToBase64 },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user