mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: allow SVG files in the convert tool
SVG files were rejected by the convert endpoint because validateImageBuffer only recognized raster magic bytes. This adds text-based SVG detection, sanitization in the tool factory, and proper Sharp density handling so SVG-to-raster conversion works through the standard convert route.
This commit is contained in:
@@ -1,8 +1,18 @@
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
|
import { isSvgBuffer } from "./svg-sanitize.js";
|
||||||
|
|
||||||
/** Formats we accept as input. */
|
/** Formats we accept as input. */
|
||||||
const SUPPORTED_INPUT_FORMATS = new Set(["jpeg", "png", "webp", "gif", "tiff", "bmp", "avif"]);
|
const SUPPORTED_INPUT_FORMATS = new Set([
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"webp",
|
||||||
|
"gif",
|
||||||
|
"tiff",
|
||||||
|
"bmp",
|
||||||
|
"avif",
|
||||||
|
"svg",
|
||||||
|
]);
|
||||||
|
|
||||||
interface MagicEntry {
|
interface MagicEntry {
|
||||||
bytes: number[];
|
bytes: number[];
|
||||||
@@ -56,8 +66,8 @@ export async function validateImageBuffer(
|
|||||||
return { valid: false, reason: "File contains no image data" };
|
return { valid: false, reason: "File contains no image data" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Magic byte detection
|
// 2. Format detection (magic bytes for raster, text check for SVG)
|
||||||
const detectedFormat = detectMagicBytes(buffer);
|
const detectedFormat = detectMagicBytes(buffer) || (isSvgBuffer(buffer) ? "svg" : null);
|
||||||
if (!detectedFormat) {
|
if (!detectedFormat) {
|
||||||
return { valid: false, reason: "Unrecognized image format" };
|
return { valid: false, reason: "Unrecognized image format" };
|
||||||
}
|
}
|
||||||
@@ -72,7 +82,8 @@ export async function validateImageBuffer(
|
|||||||
|
|
||||||
// 4. Dimensions check via sharp metadata
|
// 4. Dimensions check via sharp metadata
|
||||||
try {
|
try {
|
||||||
const metadata = await sharp(buffer).metadata();
|
const sharpOpts = detectedFormat === "svg" ? { density: 72 } : undefined;
|
||||||
|
const metadata = await sharp(buffer, sharpOpts).metadata();
|
||||||
const width = metadata.width ?? 0;
|
const width = metadata.width ?? 0;
|
||||||
const height = metadata.height ?? 0;
|
const height = metadata.height ?? 0;
|
||||||
const megapixels = (width * height) / 1_000_000;
|
const megapixels = (width * height) / 1_000_000;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface WorkerInput {
|
|||||||
inputBuffer: Buffer;
|
inputBuffer: Buffer;
|
||||||
settings: unknown;
|
settings: unknown;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
inputFormat?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkerOutput {
|
export interface WorkerOutput {
|
||||||
@@ -50,7 +51,8 @@ export default async function processInWorker(input: WorkerInput): Promise<Worke
|
|||||||
throw new Error(`Tool "${input.toolId}" not found in worker registry`);
|
throw new Error(`Tool "${input.toolId}" not found in worker registry`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const oriented = await autoOrient(Buffer.from(input.inputBuffer));
|
const buf = Buffer.from(input.inputBuffer);
|
||||||
|
const oriented = input.inputFormat === "svg" ? buf : await autoOrient(buf);
|
||||||
const result = await config.process(oriented, input.settings, input.filename);
|
const result = await config.process(oriented, input.settings, input.filename);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize an SVG buffer to prevent XXE, SSRF, and script injection.
|
||||||
|
* Throws if the SVG exceeds the maximum allowed size.
|
||||||
|
*/
|
||||||
|
export function sanitizeSvg(buffer: Buffer): Buffer {
|
||||||
|
if (buffer.length > MAX_SVG_SIZE) {
|
||||||
|
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
|
||||||
|
}
|
||||||
|
let svg = buffer.toString("utf-8");
|
||||||
|
// Remove DOCTYPE (XXE prevention, including internal subsets)
|
||||||
|
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
|
||||||
|
// Remove XML processing instructions except <?xml version...?>
|
||||||
|
svg = svg.replace(/<\?(?!xml\s)[^?]*\?>/gi, "");
|
||||||
|
// Remove XInclude elements and namespace declarations
|
||||||
|
svg = svg.replace(/<[^>]*xi:include[^>]*\/?>/gi, "");
|
||||||
|
svg = svg.replace(/xmlns:xi\s*=\s*["'][^"']*["']/gi, "");
|
||||||
|
// Remove script tags
|
||||||
|
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
|
||||||
|
// Remove foreignObject elements (can embed arbitrary HTML)
|
||||||
|
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "");
|
||||||
|
svg = svg.replace(/<foreignObject[^>]*\/>/gi, "");
|
||||||
|
// Remove event handlers (onload, onclick, onerror, etc.)
|
||||||
|
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
|
||||||
|
// Block dangerous URI schemes in href attributes
|
||||||
|
svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,');
|
||||||
|
svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,');
|
||||||
|
svg = svg.replace(/href\s*=\s*["']javascript:/gi, 'href="data:,');
|
||||||
|
svg = svg.replace(/href\s*=\s*["']data:text\/html/gi, 'href="data:,');
|
||||||
|
svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,');
|
||||||
|
// Block use elements referencing external resources
|
||||||
|
svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,');
|
||||||
|
svg = svg.replace(/url\s*\(\s*["']?file:/gi, 'url("data:,');
|
||||||
|
return Buffer.from(svg, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a buffer looks like SVG content.
|
||||||
|
* Examines the first 4KB for an <svg tag.
|
||||||
|
*/
|
||||||
|
export function isSvgBuffer(buffer: Buffer): boolean {
|
||||||
|
const head = buffer.subarray(0, 4096).toString("utf-8").trim();
|
||||||
|
return head.startsWith("<svg") || (head.startsWith("<?xml") && head.includes("<svg"));
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { autoOrient } from "../lib/auto-orient.js";
|
|||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
|
import type { WorkerInput, WorkerOutput } from "../lib/image-worker.js";
|
||||||
|
import { sanitizeSvg } from "../lib/svg-sanitize.js";
|
||||||
import { getWorkerPool } from "../lib/worker-pool.js";
|
import { getWorkerPool } from "../lib/worker-pool.js";
|
||||||
import { createWorkspace } from "../lib/workspace.js";
|
import { createWorkspace } from "../lib/workspace.js";
|
||||||
|
|
||||||
@@ -144,6 +145,18 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize SVG input to prevent XXE, SSRF, and script injection
|
||||||
|
const isSvg = validation.format === "svg";
|
||||||
|
if (isSvg) {
|
||||||
|
try {
|
||||||
|
fileBuffer = sanitizeSvg(fileBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: err instanceof Error ? err.message : "Invalid SVG",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parse and validate settings
|
// Parse and validate settings
|
||||||
let settings: T;
|
let settings: T;
|
||||||
try {
|
try {
|
||||||
@@ -179,6 +192,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
inputBuffer: fileBuffer,
|
inputBuffer: fileBuffer,
|
||||||
settings,
|
settings,
|
||||||
filename,
|
filename,
|
||||||
|
inputFormat: validation.format,
|
||||||
};
|
};
|
||||||
const workerResult: WorkerOutput = await pool.run(workerInput);
|
const workerResult: WorkerOutput = await pool.run(workerInput);
|
||||||
result = {
|
result = {
|
||||||
@@ -192,12 +206,12 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
{ workerErr, toolId: config.toolId },
|
{ workerErr, toolId: config.toolId },
|
||||||
"Worker processing failed, falling back to main thread",
|
"Worker processing failed, falling back to main thread",
|
||||||
);
|
);
|
||||||
const processBuffer = await autoOrient(fileBuffer);
|
const processBuffer = isSvg ? fileBuffer : await autoOrient(fileBuffer);
|
||||||
result = await config.process(processBuffer, settings, filename);
|
result = await config.process(processBuffer, settings, filename);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// AI tools: always main thread (they use Python bridge)
|
// AI tools: always main thread (they use Python bridge)
|
||||||
const processBuffer = await autoOrient(fileBuffer);
|
const processBuffer = isSvg ? fileBuffer : await autoOrient(fileBuffer);
|
||||||
result = await config.process(processBuffer, settings, filename);
|
result = await config.process(processBuffer, settings, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { convert } from "@stirling-image/image-engine";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
|
|
||||||
const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||||
@@ -24,7 +25,8 @@ export function registerConvert(app: FastifyInstance) {
|
|||||||
toolId: "convert",
|
toolId: "convert",
|
||||||
settingsSchema,
|
settingsSchema,
|
||||||
process: async (inputBuffer, settings, filename) => {
|
process: async (inputBuffer, settings, filename) => {
|
||||||
const image = sharp(inputBuffer);
|
const sharpOpts = isSvgBuffer(inputBuffer) ? { density: 300 } : undefined;
|
||||||
|
const image = sharp(inputBuffer, sharpOpts);
|
||||||
const result = await convert(image, settings);
|
const result = await convert(image, settings);
|
||||||
const buffer = await result.toBuffer();
|
const buffer = await result.toBuffer();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -16,39 +17,6 @@ const settingsSchema = z.object({
|
|||||||
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
|
outputFormat: z.enum(["png", "jpg", "webp"]).default("png"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAX_SVG_SIZE = 10 * 1024 * 1024; // 10MB
|
|
||||||
|
|
||||||
function sanitizeSvg(buffer: Buffer): Buffer {
|
|
||||||
if (buffer.length > MAX_SVG_SIZE) {
|
|
||||||
throw new Error(`SVG exceeds maximum size of ${MAX_SVG_SIZE / 1024 / 1024}MB`);
|
|
||||||
}
|
|
||||||
let svg = buffer.toString("utf-8");
|
|
||||||
// Remove DOCTYPE (XXE prevention, including internal subsets)
|
|
||||||
svg = svg.replace(/<!DOCTYPE[^>[]*(?:\[[^\]]*\])?>/gi, "");
|
|
||||||
// Remove XML processing instructions except <?xml version...?>
|
|
||||||
svg = svg.replace(/<\?(?!xml\s)[^?]*\?>/gi, "");
|
|
||||||
// Remove XInclude elements and namespace declarations
|
|
||||||
svg = svg.replace(/<[^>]*xi:include[^>]*\/?>/gi, "");
|
|
||||||
svg = svg.replace(/xmlns:xi\s*=\s*["'][^"']*["']/gi, "");
|
|
||||||
// Remove script tags
|
|
||||||
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, "");
|
|
||||||
// Remove foreignObject elements (can embed arbitrary HTML)
|
|
||||||
svg = svg.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "");
|
|
||||||
svg = svg.replace(/<foreignObject[^>]*\/>/gi, "");
|
|
||||||
// Remove event handlers (onload, onclick, onerror, etc.)
|
|
||||||
svg = svg.replace(/\bon\w+\s*=/gi, "data-removed=");
|
|
||||||
// Block dangerous URI schemes in href attributes
|
|
||||||
svg = svg.replace(/xlink:href\s*=\s*["']https?:\/\//gi, 'xlink:href="data:,');
|
|
||||||
svg = svg.replace(/href\s*=\s*["']https?:\/\//gi, 'href="data:,');
|
|
||||||
svg = svg.replace(/href\s*=\s*["']javascript:/gi, 'href="data:,');
|
|
||||||
svg = svg.replace(/href\s*=\s*["']data:text\/html/gi, 'href="data:,');
|
|
||||||
svg = svg.replace(/href\s*=\s*["']file:/gi, 'href="data:,');
|
|
||||||
// Block use elements referencing external resources
|
|
||||||
svg = svg.replace(/url\s*\(\s*["']?https?:\/\//gi, 'url("data:,');
|
|
||||||
svg = svg.replace(/url\s*\(\s*["']?file:/gi, 'url("data:,');
|
|
||||||
return Buffer.from(svg, "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SVG to raster conversion.
|
* SVG to raster conversion.
|
||||||
* Custom route since input is SVG (not validated as image by magic bytes).
|
* Custom route since input is SVG (not validated as image by magic bytes).
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" fill="#4a90d9"/>
|
||||||
|
<circle cx="50" cy="50" r="30" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 195 B |
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Comprehensive format conversion tests.
|
||||||
|
*
|
||||||
|
* Verifies that every supported input format can be converted to every
|
||||||
|
* supported output format via the /api/v1/tools/convert endpoint.
|
||||||
|
* Also tests SVG-to-raster via the dedicated endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||||
|
|
||||||
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||||
|
|
||||||
|
// Output formats accepted by the convert tool
|
||||||
|
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let testApp: TestApp;
|
||||||
|
let app: TestApp["app"];
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
// Input buffers generated from the PNG fixture (created in beforeAll)
|
||||||
|
const inputs: Record<string, { buffer: Buffer; filename: string; contentType: string }> = {};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await buildTestApp();
|
||||||
|
app = testApp.app;
|
||||||
|
adminToken = await loginAsAdmin(app);
|
||||||
|
|
||||||
|
// Base fixture
|
||||||
|
const png = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||||
|
|
||||||
|
// Generate all raster input formats from the PNG fixture
|
||||||
|
inputs.png = { buffer: png, filename: "test.png", contentType: "image/png" };
|
||||||
|
inputs.jpg = {
|
||||||
|
buffer: await sharp(png).jpeg().toBuffer(),
|
||||||
|
filename: "test.jpg",
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
};
|
||||||
|
inputs.webp = {
|
||||||
|
buffer: await sharp(png).webp().toBuffer(),
|
||||||
|
filename: "test.webp",
|
||||||
|
contentType: "image/webp",
|
||||||
|
};
|
||||||
|
inputs.avif = {
|
||||||
|
buffer: await sharp(png).avif().toBuffer(),
|
||||||
|
filename: "test.avif",
|
||||||
|
contentType: "image/avif",
|
||||||
|
};
|
||||||
|
inputs.tiff = {
|
||||||
|
buffer: await sharp(png).tiff().toBuffer(),
|
||||||
|
filename: "test.tiff",
|
||||||
|
contentType: "image/tiff",
|
||||||
|
};
|
||||||
|
inputs.gif = {
|
||||||
|
buffer: await sharp(png).gif().toBuffer(),
|
||||||
|
filename: "test.gif",
|
||||||
|
contentType: "image/gif",
|
||||||
|
};
|
||||||
|
inputs.svg = {
|
||||||
|
buffer: readFileSync(join(FIXTURES, "test-100x100.svg")),
|
||||||
|
filename: "test.svg",
|
||||||
|
contentType: "image/svg+xml",
|
||||||
|
};
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.cleanup();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Raster-to-raster conversions via /api/v1/tools/convert
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("Format conversion matrix", () => {
|
||||||
|
const rasterInputs = ["png", "jpg", "webp", "avif", "tiff", "gif"];
|
||||||
|
|
||||||
|
for (const inputFmt of rasterInputs) {
|
||||||
|
for (const outputFmt of OUTPUT_FORMATS) {
|
||||||
|
it(`converts ${inputFmt} -> ${outputFmt}`, async () => {
|
||||||
|
const input = inputs[inputFmt];
|
||||||
|
const { body: payload, contentType } = createMultipartPayload([
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
filename: input.filename,
|
||||||
|
contentType: input.contentType,
|
||||||
|
content: input.buffer,
|
||||||
|
},
|
||||||
|
{ name: "settings", content: JSON.stringify({ format: outputFmt }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/convert",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
||||||
|
expect(body.processedSize).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG-to-raster conversions via /api/v1/tools/convert
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("SVG via convert tool", () => {
|
||||||
|
for (const outputFmt of OUTPUT_FORMATS) {
|
||||||
|
it(`converts svg -> ${outputFmt}`, async () => {
|
||||||
|
const input = inputs.svg;
|
||||||
|
const { body: payload, contentType } = createMultipartPayload([
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
filename: input.filename,
|
||||||
|
contentType: input.contentType,
|
||||||
|
content: input.buffer,
|
||||||
|
},
|
||||||
|
{ name: "settings", content: JSON.stringify({ format: outputFmt }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/convert",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
||||||
|
expect(body.processedSize).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG-to-raster via dedicated endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("SVG via dedicated svg-to-raster endpoint", () => {
|
||||||
|
for (const outputFmt of ["png", "jpg", "webp"] as const) {
|
||||||
|
it(`converts svg -> ${outputFmt} via svg-to-raster`, async () => {
|
||||||
|
const input = inputs.svg;
|
||||||
|
const { body: payload, contentType } = createMultipartPayload([
|
||||||
|
{
|
||||||
|
name: "file",
|
||||||
|
filename: input.filename,
|
||||||
|
contentType: input.contentType,
|
||||||
|
content: input.buffer,
|
||||||
|
},
|
||||||
|
{ name: "settings", content: JSON.stringify({ outputFormat: outputFmt, width: 200 }) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/svg-to-raster",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${adminToken}`,
|
||||||
|
"content-type": contentType,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
||||||
|
expect(body.processedSize).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user