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 { registerFavicon } from "./favicon.js";
|
||||||
import { registerFindDuplicates } from "./find-duplicates.js";
|
import { registerFindDuplicates } from "./find-duplicates.js";
|
||||||
import { registerGifTools } from "./gif-tools.js";
|
import { registerGifTools } from "./gif-tools.js";
|
||||||
|
import { registerHtmlToImage } from "./html-to-image.js";
|
||||||
import { registerImageEnhancement } from "./image-enhancement.js";
|
import { registerImageEnhancement } from "./image-enhancement.js";
|
||||||
import { registerImageToBase64 } from "./image-to-base64.js";
|
import { registerImageToBase64 } from "./image-to-base64.js";
|
||||||
import { registerImageToPdf } from "./image-to-pdf.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: "find-duplicates", register: registerFindDuplicates },
|
||||||
{ id: "color-palette", register: registerColorPalette },
|
{ id: "color-palette", register: registerColorPalette },
|
||||||
{ id: "qr-generate", register: registerQrGenerate },
|
{ id: "qr-generate", register: registerQrGenerate },
|
||||||
|
{ id: "html-to-image", register: registerHtmlToImage },
|
||||||
{ id: "barcode-read", register: registerBarcodeRead },
|
{ id: "barcode-read", register: registerBarcodeRead },
|
||||||
{ id: "image-to-base64", register: registerImageToBase64 },
|
{ id: "image-to-base64", register: registerImageToBase64 },
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user