mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(svg-to-raster): add progress tracking, clientJobId, sanitizeFilename to batch
- Add clientJobId field support for SSE progress correlation
- Add updateJobProgress calls matching generic batch route pattern
- Use sanitizeFilename() instead of basename() for security
- Map Zod errors to {path, message} format for consistency
- Include errors array in all-failed response body
This commit is contained in:
@@ -7,9 +7,11 @@ import PQueue from "p-queue";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
|
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
import { updateJobProgress } from "../progress.js";
|
||||||
|
|
||||||
const NON_PREVIEWABLE = new Set(["tiff", "heif"]);
|
const NON_PREVIEWABLE = new Set(["tiff", "heif"]);
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
app.post("/api/v1/tools/svg-to-raster/batch", async (request, reply) => {
|
app.post("/api/v1/tools/svg-to-raster/batch", async (request, reply) => {
|
||||||
const files: ParsedSvgFile[] = [];
|
const files: ParsedSvgFile[] = [];
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
|
let clientJobId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
@@ -111,11 +114,13 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
if (buf.length > 0) {
|
if (buf.length > 0) {
|
||||||
files.push({
|
files.push({
|
||||||
buffer: buf,
|
buffer: buf,
|
||||||
filename: basename(part.filename ?? "output"),
|
filename: sanitizeFilename(part.filename ?? "output"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
|
} else if (part.fieldname === "clientJobId") {
|
||||||
|
clientJobId = part.value as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -140,44 +145,99 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
const result = settingsSchema.safeParse(parsed);
|
const result = settingsSchema.safeParse(parsed);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
|
return reply.status(400).send({
|
||||||
|
error: "Invalid settings",
|
||||||
|
details: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
settings = result.data;
|
||||||
} catch {
|
} catch {
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jobId = clientJobId || randomUUID();
|
||||||
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS });
|
const queue = new PQueue({ concurrency: env.CONCURRENT_JOBS });
|
||||||
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
|
const results: ({ buffer: Buffer; filename: string } | null)[] = new Array(files.length).fill(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
let failedCount = 0;
|
const errors: { filename: string; error: string }[] = [];
|
||||||
|
let completedFiles = 0;
|
||||||
|
|
||||||
|
updateJobProgress({
|
||||||
|
jobId,
|
||||||
|
status: "processing",
|
||||||
|
totalFiles: files.length,
|
||||||
|
completedFiles: 0,
|
||||||
|
failedFiles: 0,
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
|
||||||
const tasks = files.map((file, index) =>
|
const tasks = files.map((file, index) =>
|
||||||
queue.add(async () => {
|
queue.add(async () => {
|
||||||
// Sanitize each SVG individually
|
updateJobProgress({
|
||||||
|
jobId,
|
||||||
|
status: "processing",
|
||||||
|
totalFiles: files.length,
|
||||||
|
completedFiles,
|
||||||
|
failedFiles: errors.length,
|
||||||
|
errors,
|
||||||
|
currentFile: file.filename,
|
||||||
|
});
|
||||||
|
|
||||||
let sanitized: Buffer;
|
let sanitized: Buffer;
|
||||||
try {
|
try {
|
||||||
sanitized = sanitizeSvg(file.buffer);
|
sanitized = sanitizeSvg(file.buffer);
|
||||||
} catch {
|
} catch (err) {
|
||||||
failedCount++;
|
errors.push({
|
||||||
|
filename: file.filename,
|
||||||
|
error: err instanceof Error ? err.message : "Invalid SVG",
|
||||||
|
});
|
||||||
|
completedFiles++;
|
||||||
|
updateJobProgress({
|
||||||
|
jobId,
|
||||||
|
status: "processing",
|
||||||
|
totalFiles: files.length,
|
||||||
|
completedFiles,
|
||||||
|
failedFiles: errors.length,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await convertSvg(sanitized, file.filename, settings);
|
const result = await convertSvg(sanitized, file.filename, settings);
|
||||||
results[index] = { buffer: result.buffer, filename: result.filename };
|
results[index] = { buffer: result.buffer, filename: result.filename };
|
||||||
} catch {
|
} catch (err) {
|
||||||
failedCount++;
|
errors.push({
|
||||||
|
filename: file.filename,
|
||||||
|
error: err instanceof Error ? err.message : "Conversion failed",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
completedFiles++;
|
||||||
|
updateJobProgress({
|
||||||
|
jobId,
|
||||||
|
status: "processing",
|
||||||
|
totalFiles: files.length,
|
||||||
|
completedFiles,
|
||||||
|
failedFiles: errors.length,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
|
|
||||||
// If every file failed, return an error instead of an empty ZIP
|
updateJobProgress({
|
||||||
if (failedCount === files.length) {
|
jobId,
|
||||||
return reply.status(422).send({ error: "All files failed processing" });
|
status: errors.length === files.length ? "failed" : "completed",
|
||||||
|
totalFiles: files.length,
|
||||||
|
completedFiles,
|
||||||
|
failedFiles: errors.length,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors.length === files.length) {
|
||||||
|
return reply.status(422).send({ error: "All files failed processing", errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate filenames and build X-File-Results header
|
// Deduplicate filenames and build X-File-Results header
|
||||||
@@ -210,8 +270,6 @@ export function registerSvgToRaster(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = randomUUID();
|
|
||||||
|
|
||||||
// Hijack and stream the ZIP response
|
// Hijack and stream the ZIP response
|
||||||
reply.hijack();
|
reply.hijack();
|
||||||
reply.raw.writeHead(200, {
|
reply.raw.writeHead(200, {
|
||||||
|
|||||||
Reference in New Issue
Block a user