fix: validate non-image inputs by modality (batch + pipeline) (#244)

* fix(batch): validate non-image inputs by modality

Batch processing ran validateImageBuffer and the image-only decode chain
on every uploaded file, so audio, video, and document tools rejected all
inputs with "Invalid image: Unrecognized image format" and returned
422 "All files failed processing".

Resolve the tool's modality and route non-image files through their own
input handler (inputHandlerFor(modality).prepare), mirroring the
single-file path. The image batch path is unchanged.

* fix(pipeline): validate non-image inputs by modality

The pipeline /execute and /batch routes validated every upload with
validateImageBuffer and ran the image-only decode chain, so audio,
video, and document pipelines were rejected with "Invalid image:
Unrecognized image format".

Resolve the input modality from the first step's tool and route
non-image inputs through their modality handler, mirroring the batch
and single-file paths. Image pipelines are unchanged.
This commit is contained in:
SnapOtter
2026-06-16 14:33:23 +08:00
committed by GitHub
parent d8cf979d4b
commit 03e71236f9
2 changed files with 205 additions and 102 deletions
+69 -34
View File
@@ -9,7 +9,10 @@
* Returns a ZIP file containing all processed images.
*/
import { randomUUID } from "node:crypto";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import archiver from "archiver";
import type { FlowJob } from "bullmq";
import { eq } from "drizzle-orm";
@@ -30,6 +33,8 @@ import { decodeToSharpCompat, needsCliDecode } from "../lib/format-decoders.js";
import { decodeHeic } from "../lib/heic-converter.js";
import { getObjectStream, putObject } from "../lib/object-storage.js";
import { resolveToolPool } from "../lib/pool.js";
import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { getAuthUser } from "../plugins/auth.js";
import { updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js";
@@ -175,56 +180,86 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
const preFailures: Array<{ originalIndex: number; filename: string; error: string }> = [];
let flowChildIndex = 0;
// Resolve the tool's modality so non-image files (audio/video/document)
// validate through their own handler instead of the image validator.
const modality = TOOLS.find((t) => t.id === toolId)?.modality ?? "image";
const batchScratch = join(tmpdir(), "snapotter-scratch", `batch-${parentId}`);
await mkdir(batchScratch, { recursive: true });
for (let i = 0; i < files.length; i++) {
const file = files[i];
let processBuffer = file.buffer;
let processFilename = file.filename;
const validation = await validateImageBuffer(processBuffer, processFilename);
if (!validation.valid) {
preFailures.push({
originalIndex: i,
filename: file.filename,
error: `Invalid image: ${validation.reason}`,
});
continue;
}
// Decode chain (skip for metadata tools that handle all formats natively)
const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata";
if (!skipPreprocess && validation.format === "heif") {
try {
processBuffer = await decodeHeic(processBuffer);
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
} catch {
if (modality === "image") {
const validation = await validateImageBuffer(processBuffer, processFilename);
if (!validation.valid) {
preFailures.push({
originalIndex: i,
filename: file.filename,
error: "Failed to decode HEIC file",
error: `Invalid image: ${validation.reason}`,
});
continue;
}
}
if (!skipPreprocess && needsCliDecode(validation.format)) {
try {
const fileExt = processFilename.split(".").pop()?.toLowerCase();
processBuffer = await decodeToSharpCompat(processBuffer, validation.format, fileExt);
} catch {
// Decode chain (skip for metadata tools that handle all formats natively)
const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata";
if (!skipPreprocess && validation.format === "heif") {
try {
await sharp(processBuffer).metadata();
processBuffer = await decodeHeic(processBuffer);
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
} catch {
// Neither CLI decode nor Sharp can handle it; upload raw
preFailures.push({
originalIndex: i,
filename: file.filename,
error: "Failed to decode HEIC file",
});
continue;
}
}
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
}
if (!skipPreprocess) {
processBuffer = await autoOrient(processBuffer);
if (!skipPreprocess && needsCliDecode(validation.format)) {
try {
const fileExt = processFilename.split(".").pop()?.toLowerCase();
processBuffer = await decodeToSharpCompat(processBuffer, validation.format, fileExt);
} catch {
try {
await sharp(processBuffer).metadata();
} catch {
// Neither CLI decode nor Sharp can handle it; upload raw
}
}
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
}
if (!skipPreprocess) {
processBuffer = await autoOrient(processBuffer);
}
} else {
// Non-image modalities (audio/video/document/file): validate and
// decode through the tool's own input handler, mirroring the
// single-file path. Previously batch validated everything as an
// image, which rejected all audio/video/document inputs.
try {
const prepared = await inputHandlerFor(modality).prepare(
processBuffer,
processFilename,
{
scratchDir: batchScratch,
},
);
processBuffer = prepared.buffer;
processFilename = prepared.filename;
} catch (err) {
if (err instanceof InputValidationError) {
preFailures.push({ originalIndex: i, filename: file.filename, error: err.message });
continue;
}
throw err;
}
}
// Upload decoded file to object storage
+136 -68
View File
@@ -8,7 +8,10 @@
* POST /api/v1/pipeline/batch -- Batch pipeline execution (ZIP output)
*/
import { randomUUID } from "node:crypto";
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import { mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ANALYTICS_EVENTS, getBundleForTool, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import archiver from "archiver";
import type { FlowJob } from "bullmq";
import { eq } from "drizzle-orm";
@@ -31,6 +34,8 @@ import { decodeHeic } from "../lib/heic-converter.js";
import { getObjectStream, putObject } from "../lib/object-storage.js";
import { resolveToolPool } from "../lib/pool.js";
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
import { InputValidationError } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
import { hasEffectivePermission } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js";
import { updateJobProgress, updateSingleFileProgress } from "./progress.js";
@@ -232,52 +237,84 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
return reply.status(400).send({ error: "No file provided" });
}
// Validate the initial image
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid image: ${validation.reason}`,
});
// The first pipeline step determines the input modality, so non-image
// inputs (audio/video/document) get validated by the right handler instead
// of always being forced through image validation/decoding.
let firstToolId: string | undefined;
try {
firstToolId = (JSON.parse(pipelineRaw ?? "{}") as { steps?: Array<{ toolId?: string }> })
?.steps?.[0]?.toolId;
} catch {
// Malformed pipeline JSON is reported when the definition is parsed below.
}
const inputModality = TOOLS.find((t) => t.id === firstToolId)?.modality ?? "image";
const pipelineScratch = join(tmpdir(), "snapotter-scratch", `pipeline-${randomUUID()}`);
await mkdir(pipelineScratch, { recursive: true });
// Decode HEIC/HEIF input via system heif-dec
if (validation.format === "heif") {
try {
fileBuffer = await decodeHeic(fileBuffer);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
} catch (err) {
return reply.status(422).send({
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
if (inputModality === "image") {
// Validate the initial image
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
return reply.status(400).send({
error: `Invalid image: ${validation.reason}`,
});
}
}
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
try {
const fileExt = filename.split(".").pop()?.toLowerCase();
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
} catch (err) {
return reply.status(422).send({
error: `Failed to decode ${validation.format} file`,
details: err instanceof Error ? err.message : String(err),
});
// Decode HEIC/HEIF input via system heif-dec
if (validation.format === "heif") {
try {
fileBuffer = await decodeHeic(fileBuffer);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
} catch (err) {
return reply.status(422).send({
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
details: err instanceof Error ? err.message : String(err),
});
}
}
}
// Sanitize SVG input and normalize EXIF orientation
const isSvg = isSvgBuffer(fileBuffer);
if (isSvg) {
fileBuffer = sanitizeSvg(fileBuffer);
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
if (needsCliDecode(validation.format)) {
try {
const fileExt = filename.split(".").pop()?.toLowerCase();
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
const ext = filename.match(/\.[^.]+$/)?.[0];
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
} catch (err) {
return reply.status(422).send({
error: `Failed to decode ${validation.format} file`,
details: err instanceof Error ? err.message : String(err),
});
}
}
// Sanitize SVG input and normalize EXIF orientation
const isSvg = isSvgBuffer(fileBuffer);
if (isSvg) {
fileBuffer = sanitizeSvg(fileBuffer);
} else {
fileBuffer = await autoOrient(fileBuffer);
}
} else {
fileBuffer = await autoOrient(fileBuffer);
// Non-image input: validate/decode via the tool's modality handler.
try {
const prepared = await inputHandlerFor(inputModality).prepare(fileBuffer, filename, {
scratchDir: pipelineScratch,
});
fileBuffer = prepared.buffer;
filename = prepared.filename;
} catch (err) {
if (err instanceof InputValidationError) {
const body: Record<string, string> = { error: err.message };
if (err.details) body.details = err.details;
return reply.status(err.statusCode).send(body);
}
throw err;
}
}
// Parse and validate the pipeline definition
@@ -787,52 +824,83 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
const preFailures: Array<{ originalIndex: number; filename: string; error: string }> = [];
let flowChildIndex = 0;
// The first step's modality drives input validation for every file, so
// audio, video, and document pipelines are not rejected by the image
// validator.
const batchModality =
TOOLS.find((t) => t.id === pipeline.steps[0]?.toolId)?.modality ?? "image";
const pipelineBatchScratch = join(tmpdir(), "snapotter-scratch", `pipeline-batch-${parentId}`);
await mkdir(pipelineBatchScratch, { recursive: true });
for (let fi = 0; fi < files.length; fi++) {
const file = files[fi];
let processBuffer = file.buffer;
let processFilename = file.filename;
const fileValidation = await validateImageBuffer(processBuffer, processFilename);
if (!fileValidation.valid) {
preFailures.push({
originalIndex: fi,
filename: file.filename,
error: `Invalid image: ${fileValidation.reason}`,
});
continue;
}
// Decode chain
if (fileValidation.format === "heif") {
try {
processBuffer = await decodeHeic(processBuffer);
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
} catch {
if (batchModality === "image") {
const fileValidation = await validateImageBuffer(processBuffer, processFilename);
if (!fileValidation.valid) {
preFailures.push({
originalIndex: fi,
filename: file.filename,
error: "Failed to decode HEIC file",
error: `Invalid image: ${fileValidation.reason}`,
});
continue;
}
}
if (needsCliDecode(fileValidation.format)) {
try {
const fileExt = processFilename.split(".").pop()?.toLowerCase();
processBuffer = await decodeToSharpCompat(processBuffer, fileValidation.format, fileExt);
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
} catch {
// Fall through -- tool might handle it
// Decode chain
if (fileValidation.format === "heif") {
try {
processBuffer = await decodeHeic(processBuffer);
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
} catch {
preFailures.push({
originalIndex: fi,
filename: file.filename,
error: "Failed to decode HEIC file",
});
continue;
}
}
}
if (isSvgBuffer(processBuffer)) {
processBuffer = sanitizeSvg(processBuffer);
if (needsCliDecode(fileValidation.format)) {
try {
const fileExt = processFilename.split(".").pop()?.toLowerCase();
processBuffer = await decodeToSharpCompat(
processBuffer,
fileValidation.format,
fileExt,
);
const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
} catch {
// Fall through -- tool might handle it
}
}
if (isSvgBuffer(processBuffer)) {
processBuffer = sanitizeSvg(processBuffer);
} else {
processBuffer = await autoOrient(processBuffer);
}
} else {
processBuffer = await autoOrient(processBuffer);
// Non-image input: validate/decode via the tool's modality handler.
try {
const prepared = await inputHandlerFor(batchModality).prepare(
processBuffer,
processFilename,
{ scratchDir: pipelineBatchScratch },
);
processBuffer = prepared.buffer;
processFilename = prepared.filename;
} catch (err) {
if (err instanceof InputValidationError) {
preFailures.push({ originalIndex: fi, filename: file.filename, error: err.message });
continue;
}
throw err;
}
}
// Upload decoded file