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
+36 -1
View File
@@ -9,7 +9,10 @@
* Returns a ZIP file containing all processed images. * Returns a ZIP file containing all processed images.
*/ */
import { randomUUID } from "node:crypto"; 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 archiver from "archiver";
import type { FlowJob } from "bullmq"; import type { FlowJob } from "bullmq";
import { eq } from "drizzle-orm"; 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 { decodeHeic } from "../lib/heic-converter.js";
import { getObjectStream, putObject } from "../lib/object-storage.js"; import { getObjectStream, putObject } from "../lib/object-storage.js";
import { resolveToolPool } from "../lib/pool.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 { getAuthUser } from "../plugins/auth.js";
import { updateJobProgress } from "./progress.js"; import { updateJobProgress } from "./progress.js";
import { getToolConfig } from "./tool-factory.js"; import { getToolConfig } from "./tool-factory.js";
@@ -175,11 +180,18 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
const preFailures: Array<{ originalIndex: number; filename: string; error: string }> = []; const preFailures: Array<{ originalIndex: number; filename: string; error: string }> = [];
let flowChildIndex = 0; 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++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
let processBuffer = file.buffer; let processBuffer = file.buffer;
let processFilename = file.filename; let processFilename = file.filename;
if (modality === "image") {
const validation = await validateImageBuffer(processBuffer, processFilename); const validation = await validateImageBuffer(processBuffer, processFilename);
if (!validation.valid) { if (!validation.valid) {
preFailures.push({ preFailures.push({
@@ -226,6 +238,29 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
if (!skipPreprocess) { if (!skipPreprocess) {
processBuffer = await autoOrient(processBuffer); 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 // Upload decoded file to object storage
const childId = `${parentId}-f${flowChildIndex}`; const childId = `${parentId}-f${flowChildIndex}`;
+71 -3
View File
@@ -8,7 +8,10 @@
* POST /api/v1/pipeline/batch -- Batch pipeline execution (ZIP output) * POST /api/v1/pipeline/batch -- Batch pipeline execution (ZIP output)
*/ */
import { randomUUID } from "node:crypto"; 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 archiver from "archiver";
import type { FlowJob } from "bullmq"; import type { FlowJob } from "bullmq";
import { eq } from "drizzle-orm"; 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 { getObjectStream, putObject } from "../lib/object-storage.js";
import { resolveToolPool } from "../lib/pool.js"; import { resolveToolPool } from "../lib/pool.js";
import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.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 { hasEffectivePermission } from "../permissions.js";
import { getAuthUser, requireAuth } from "../plugins/auth.js"; import { getAuthUser, requireAuth } from "../plugins/auth.js";
import { updateJobProgress, updateSingleFileProgress } from "./progress.js"; import { updateJobProgress, updateSingleFileProgress } from "./progress.js";
@@ -232,9 +237,24 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
} }
if (!fileBuffer || fileBuffer.length === 0) { if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" }); return reply.status(400).send({ error: "No file provided" });
} }
// 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 });
if (inputModality === "image") {
// Validate the initial image // Validate the initial image
const validation = await validateImageBuffer(fileBuffer, filename); const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) { if (!validation.valid) {
@@ -279,6 +299,23 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
} else { } else {
fileBuffer = await autoOrient(fileBuffer); fileBuffer = await autoOrient(fileBuffer);
} }
} else {
// 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 // Parse and validate the pipeline definition
if (!pipelineRaw) { if (!pipelineRaw) {
@@ -787,11 +824,20 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
const preFailures: Array<{ originalIndex: number; filename: string; error: string }> = []; const preFailures: Array<{ originalIndex: number; filename: string; error: string }> = [];
let flowChildIndex = 0; 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++) { for (let fi = 0; fi < files.length; fi++) {
const file = files[fi]; const file = files[fi];
let processBuffer = file.buffer; let processBuffer = file.buffer;
let processFilename = file.filename; let processFilename = file.filename;
if (batchModality === "image") {
const fileValidation = await validateImageBuffer(processBuffer, processFilename); const fileValidation = await validateImageBuffer(processBuffer, processFilename);
if (!fileValidation.valid) { if (!fileValidation.valid) {
preFailures.push({ preFailures.push({
@@ -821,7 +867,11 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
if (needsCliDecode(fileValidation.format)) { if (needsCliDecode(fileValidation.format)) {
try { try {
const fileExt = processFilename.split(".").pop()?.toLowerCase(); const fileExt = processFilename.split(".").pop()?.toLowerCase();
processBuffer = await decodeToSharpCompat(processBuffer, fileValidation.format, fileExt); processBuffer = await decodeToSharpCompat(
processBuffer,
fileValidation.format,
fileExt,
);
const ext = processFilename.match(/\.[^.]+$/)?.[0]; const ext = processFilename.match(/\.[^.]+$/)?.[0];
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`; if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
} catch { } catch {
@@ -834,6 +884,24 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
} else { } else {
processBuffer = await autoOrient(processBuffer); processBuffer = await autoOrient(processBuffer);
} }
} else {
// 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 // Upload decoded file
const perFileJobId = `${parentId}-f${flowChildIndex}`; const perFileJobId = `${parentId}-f${flowChildIndex}`;