feat: implement html-to-image API route with SSRF protection

This commit is contained in:
SnapOtter
2026-06-06 21:45:38 +08:00
parent b4eec49103
commit d9959d6686
2 changed files with 122 additions and 0 deletions
+120
View File
@@ -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),
});
}
},
);
}
+2
View File
@@ -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 },