feat(svg-to-raster): add batch processing endpoint with SVG-specific sanitization

The generic batch route validates image buffers by magic bytes, which
fails for SVG input. This adds a dedicated /batch endpoint that sanitizes
each SVG individually before conversion, using p-queue for concurrency
and streaming the results as a ZIP with X-File-Results header.

Also extracts shared conversion logic into a reusable convertSvg helper
to avoid duplication between single-file and batch endpoints.
This commit is contained in:
Siddharth Kumar Sah
2026-04-13 12:40:23 +08:00
parent 3713cf91fe
commit 2f0f0930c4
+221 -51
View File
@@ -1,9 +1,12 @@
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import PQueue from "p-queue";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -22,11 +25,224 @@ const settingsSchema = z.object({
outputFormat: z.enum(["png", "jpg", "webp", "avif", "tiff", "gif", "heif"]).default("png"),
});
interface ParsedSvgFile {
buffer: Buffer;
filename: string;
}
/** Convert a sanitized SVG buffer to the requested raster format. */
async function convertSvg(
svgBuffer: Buffer,
filename: string,
settings: z.infer<typeof settingsSchema>,
): Promise<{ buffer: Buffer; filename: string; ext: string }> {
let image = sharp(svgBuffer, { density: settings.dpi });
if (settings.width || settings.height) {
image = image.resize(settings.width, settings.height, { fit: "inside" });
}
if (settings.backgroundColor !== "#00000000") {
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } });
}
let buffer: Buffer;
let ext: string;
switch (settings.outputFormat) {
case "jpg":
buffer = await image.jpeg({ quality: settings.quality }).toBuffer();
ext = "jpg";
break;
case "webp":
buffer = await image.webp({ quality: settings.quality }).toBuffer();
ext = "webp";
break;
case "avif":
buffer = await image.avif({ quality: settings.quality }).toBuffer();
ext = "avif";
break;
case "tiff":
buffer = await image.tiff({ quality: settings.quality }).toBuffer();
ext = "tiff";
break;
case "gif":
buffer = await image.gif().toBuffer();
ext = "gif";
break;
case "heif": {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
ext = "heif";
break;
}
default:
buffer = await image.png().toBuffer();
ext = "png";
break;
}
const baseName = basename(filename).replace(/\.svg$/i, "");
return { buffer, filename: `${baseName}.${ext}`, ext };
}
/**
* SVG to raster conversion.
* Custom route since input is SVG (not validated as image by magic bytes).
*/
export function registerSvgToRaster(app: FastifyInstance) {
// --- Batch endpoint (registered first for route priority) ---
app.post("/api/v1/tools/svg-to-raster/batch", async (request, reply) => {
const files: ParsedSvgFile[] = [];
let settingsRaw: string | null = null;
try {
const parts = request.parts();
for await (const part of parts) {
if (part.type === "file") {
const chunks: Buffer[] = [];
for await (const chunk of part.file) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? "output"),
});
}
} else if (part.fieldname === "settings") {
settingsRaw = part.value as string;
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length === 0) {
return reply.status(400).send({ error: "No SVG files provided" });
}
if (files.length > env.MAX_BATCH_SIZE) {
return reply.status(400).send({
error: `Too many files. Maximum batch size is ${env.MAX_BATCH_SIZE}`,
});
}
let settings: z.infer<typeof settingsSchema>;
try {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
}
settings = result.data;
} catch {
return reply.status(400).send({ error: "Settings must be valid JSON" });
}
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS });
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
null,
);
let failedCount = 0;
const tasks = files.map((file, index) =>
queue.add(async () => {
// Sanitize each SVG individually
let sanitized: Buffer;
try {
sanitized = sanitizeSvg(file.buffer);
} catch {
failedCount++;
return;
}
try {
const result = await convertSvg(sanitized, file.filename, settings);
results[index] = { buffer: result.buffer, filename: result.filename };
} catch {
failedCount++;
}
}),
);
await Promise.all(tasks);
// If every file failed, return an error instead of an empty ZIP
if (failedCount === files.length) {
return reply.status(422).send({ error: "All files failed processing" });
}
// Deduplicate filenames and build X-File-Results header
const usedNames = new Set<string>();
function getUniqueName(name: string): string {
if (!usedNames.has(name)) {
usedNames.add(name);
return name;
}
const dotIdx = name.lastIndexOf(".");
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
let counter = 1;
let candidate = `${base}_${counter}${ext}`;
while (usedNames.has(candidate)) {
counter++;
candidate = `${base}_${counter}${ext}`;
}
usedNames.add(candidate);
return candidate;
}
const fileResultsMap: Record<string, string> = {};
for (let i = 0; i < results.length; i++) {
const entry = results[i];
if (entry) {
const uniqueName = getUniqueName(entry.filename);
entry.filename = uniqueName;
fileResultsMap[String(i)] = uniqueName;
}
}
const jobId = randomUUID();
// Hijack and stream the ZIP response
reply.hijack();
reply.raw.writeHead(200, {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="batch-svg-to-raster-${jobId.slice(0, 8)}.zip"`,
"Transfer-Encoding": "chunked",
"X-Job-Id": jobId,
"X-File-Results": JSON.stringify(fileResultsMap),
});
const archive = archiver("zip", { zlib: { level: 5 } });
archive.on("error", (err) => {
request.log.error({ err }, "Archiver error during SVG batch processing");
if (!reply.raw.writableEnded) {
reply.raw.end();
}
});
archive.pipe(reply.raw);
for (const result of results) {
if (result) {
archive.append(result.buffer, { name: result.filename });
}
}
await archive.finalize();
});
// --- Single-file endpoint ---
app.post("/api/v1/tools/svg-to-raster", async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "output";
@@ -79,57 +295,11 @@ export function registerSvgToRaster(app: FastifyInstance) {
}
try {
let image = sharp(fileBuffer, { density: settings.dpi });
if (settings.width || settings.height) {
image = image.resize(settings.width, settings.height, { fit: "inside" });
}
if (settings.backgroundColor !== "#00000000") {
const bgR = parseInt(settings.backgroundColor.slice(1, 3), 16);
const bgG = parseInt(settings.backgroundColor.slice(3, 5), 16);
const bgB = parseInt(settings.backgroundColor.slice(5, 7), 16);
image = image.flatten({ background: { r: bgR, g: bgG, b: bgB } });
}
let buffer: Buffer;
let ext: string;
switch (settings.outputFormat) {
case "jpg":
buffer = await image.jpeg({ quality: settings.quality }).toBuffer();
ext = "jpg";
break;
case "webp":
buffer = await image.webp({ quality: settings.quality }).toBuffer();
ext = "webp";
break;
case "avif":
buffer = await image.avif({ quality: settings.quality }).toBuffer();
ext = "avif";
break;
case "tiff":
buffer = await image.tiff({ quality: settings.quality }).toBuffer();
ext = "tiff";
break;
case "gif":
buffer = await image.gif().toBuffer();
ext = "gif";
break;
case "heif": {
// Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc.
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
ext = "heif";
break;
}
default:
buffer = await image.png().toBuffer();
ext = "png";
break;
}
const outFilename = `${filename}.${ext}`;
const {
buffer,
filename: outFilename,
ext,
} = await convertSvg(fileBuffer, filename, settings);
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const outputPath = join(workspacePath, "output", outFilename);