mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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,56 +180,86 @@ 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;
|
||||||
|
|
||||||
const validation = await validateImageBuffer(processBuffer, processFilename);
|
if (modality === "image") {
|
||||||
if (!validation.valid) {
|
const validation = await validateImageBuffer(processBuffer, processFilename);
|
||||||
preFailures.push({
|
if (!validation.valid) {
|
||||||
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 {
|
|
||||||
preFailures.push({
|
preFailures.push({
|
||||||
originalIndex: i,
|
originalIndex: i,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
error: "Failed to decode HEIC file",
|
error: `Invalid image: ${validation.reason}`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!skipPreprocess && needsCliDecode(validation.format)) {
|
// Decode chain (skip for metadata tools that handle all formats natively)
|
||||||
try {
|
const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata";
|
||||||
const fileExt = processFilename.split(".").pop()?.toLowerCase();
|
|
||||||
processBuffer = await decodeToSharpCompat(processBuffer, validation.format, fileExt);
|
if (!skipPreprocess && validation.format === "heif") {
|
||||||
} catch {
|
|
||||||
try {
|
try {
|
||||||
await sharp(processBuffer).metadata();
|
processBuffer = await decodeHeic(processBuffer);
|
||||||
|
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) processFilename = `${processFilename.slice(0, -ext.length)}.png`;
|
||||||
} catch {
|
} 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) {
|
if (!skipPreprocess && needsCliDecode(validation.format)) {
|
||||||
processBuffer = await autoOrient(processBuffer);
|
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
|
// Upload decoded file to object storage
|
||||||
|
|||||||
+136
-68
@@ -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,52 +237,84 @@ 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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the initial image
|
// The first pipeline step determines the input modality, so non-image
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
// inputs (audio/video/document) get validated by the right handler instead
|
||||||
if (!validation.valid) {
|
// of always being forced through image validation/decoding.
|
||||||
return reply.status(400).send({
|
let firstToolId: string | undefined;
|
||||||
error: `Invalid image: ${validation.reason}`,
|
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 (inputModality === "image") {
|
||||||
if (validation.format === "heif") {
|
// Validate the initial image
|
||||||
try {
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
if (!validation.valid) {
|
||||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
return reply.status(400).send({
|
||||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
error: `Invalid image: ${validation.reason}`,
|
||||||
} 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),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
// Decode HEIC/HEIF input via system heif-dec
|
||||||
if (needsCliDecode(validation.format)) {
|
if (validation.format === "heif") {
|
||||||
try {
|
try {
|
||||||
const fileExt = filename.split(".").pop()?.toLowerCase();
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
||||||
if (ext) filename = `${filename.slice(0, -ext.length)}.png`;
|
} catch (err) {
|
||||||
} catch (err) {
|
return reply.status(422).send({
|
||||||
return reply.status(422).send({
|
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||||
error: `Failed to decode ${validation.format} file`,
|
details: err instanceof Error ? err.message : String(err),
|
||||||
details: err instanceof Error ? err.message : String(err),
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize SVG input and normalize EXIF orientation
|
// Decode CLI-decoded formats (RAW, TGA, PSD, EXR, HDR)
|
||||||
const isSvg = isSvgBuffer(fileBuffer);
|
if (needsCliDecode(validation.format)) {
|
||||||
if (isSvg) {
|
try {
|
||||||
fileBuffer = sanitizeSvg(fileBuffer);
|
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 {
|
} 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
|
// 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 }> = [];
|
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;
|
||||||
|
|
||||||
const fileValidation = await validateImageBuffer(processBuffer, processFilename);
|
if (batchModality === "image") {
|
||||||
if (!fileValidation.valid) {
|
const fileValidation = await validateImageBuffer(processBuffer, processFilename);
|
||||||
preFailures.push({
|
if (!fileValidation.valid) {
|
||||||
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 {
|
|
||||||
preFailures.push({
|
preFailures.push({
|
||||||
originalIndex: fi,
|
originalIndex: fi,
|
||||||
filename: file.filename,
|
filename: file.filename,
|
||||||
error: "Failed to decode HEIC file",
|
error: `Invalid image: ${fileValidation.reason}`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (needsCliDecode(fileValidation.format)) {
|
// Decode chain
|
||||||
try {
|
if (fileValidation.format === "heif") {
|
||||||
const fileExt = processFilename.split(".").pop()?.toLowerCase();
|
try {
|
||||||
processBuffer = await decodeToSharpCompat(processBuffer, fileValidation.format, fileExt);
|
processBuffer = await decodeHeic(processBuffer);
|
||||||
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 {
|
||||||
// Fall through -- tool might handle it
|
preFailures.push({
|
||||||
|
originalIndex: fi,
|
||||||
|
filename: file.filename,
|
||||||
|
error: "Failed to decode HEIC file",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isSvgBuffer(processBuffer)) {
|
if (needsCliDecode(fileValidation.format)) {
|
||||||
processBuffer = sanitizeSvg(processBuffer);
|
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 {
|
} 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
|
// Upload decoded file
|
||||||
|
|||||||
Reference in New Issue
Block a user