fix: verbose error handling, batch processing, and multi-file support

- Replace [object Object] errors with readable messages across all 20+ API
  routes by normalizing Zod validation errors to strings (formatZodErrors)
- Add parseApiError() on frontend to defensively handle any details type
- Add global Fastify error handler with full stack traces in logs
- Fix image-to-pdf auth: Object.entries(headers) → headers.forEach()
- Fix passport-photo: safeParse + formatZodErrors, safe error extraction
- Fix OCR silent fallbacks: log exception type/message when falling back,
  include actual engine used in API response and Docker logs
- Fix split tool: process all uploaded images, combine into ZIP with
  subfolders per image
- Fix batch support for blur-faces, strip-metadata, edit-metadata,
  vectorize: add processAllFiles branch for multi-file uploads
- Docker: LOG_LEVEL=debug, PYTHONWARNINGS=default for visibility
- Add Playwright e2e tests verifying all fixes against Docker container
This commit is contained in:
ashim-hq
2026-04-17 14:15:27 +08:00
parent 2e2dbbb8e0
commit 32239600ae
39 changed files with 936 additions and 163 deletions
+12
View File
@@ -38,6 +38,18 @@ const app = Fastify({
bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
});
app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => {
const statusCode = error.statusCode ?? 500;
request.log.error(
{ err: error, url: request.url, method: request.method },
"Unhandled request error",
);
reply.status(statusCode).send({
error: statusCode >= 500 ? "Internal server error" : error.message,
details: error.stack ?? error.message,
});
});
// Plugins
await app.register(cors, {
origin: env.CORS_ORIGIN
+7
View File
@@ -0,0 +1,7 @@
import type { ZodIssue } from "zod";
export function formatZodErrors(issues: ZodIssue[]): string {
return issues
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
.join("; ");
}
+2 -6
View File
@@ -13,6 +13,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import PQueue from "p-queue";
import { env } from "../config.js";
import { autoOrient } from "../lib/auto-orient.js";
import { formatZodErrors } from "../lib/errors.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
@@ -88,12 +89,7 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map(
(i: { path: (string | number)[]; message: string }) => ({
path: i.path.join("."),
message: i.message,
}),
),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+3 -8
View File
@@ -17,6 +17,7 @@ import { z } from "zod";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { autoOrient } from "../lib/auto-orient.js";
import { formatZodErrors } from "../lib/errors.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
@@ -130,10 +131,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
if (!result.success) {
return reply.status(400).send({
error: "Invalid pipeline definition",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
pipeline = result.data;
@@ -431,10 +429,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
if (!result.success) {
return reply.status(400).send({
error: "Invalid pipeline definition",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
pipeline = result.data;
+2 -4
View File
@@ -7,6 +7,7 @@ import sharp from "sharp";
import type { z } from "zod";
import { db, schema } from "../db/index.js";
import { autoOrient } from "../lib/auto-orient.js";
import { formatZodErrors } from "../lib/errors.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { decodeHeic } from "../lib/heic-converter.js";
@@ -185,10 +186,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+4 -1
View File
@@ -3,6 +3,7 @@ import { basename, extname } from "node:path";
import archiver from "archiver";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
const settingsSchema = z.object({
pattern: z.string().min(1).max(200).default("image-{{index}}"),
@@ -53,7 +54,9 @@ export function registerBulkRename(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -467,7 +468,9 @@ export function registerCollage(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -74,7 +75,9 @@ export function registerCompose(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
@@ -5,6 +5,7 @@ import { seamCarve } from "@ashim/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -82,10 +83,7 @@ export function registerContentAwareResize(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+2 -4
View File
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import {
buildTagArgs,
type EditMetadataSettings,
@@ -150,10 +151,7 @@ export function registerEditMetadata(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+4 -1
View File
@@ -6,6 +6,7 @@ import PDFDocument from "pdfkit";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -62,7 +63,9 @@ export function registerImageToPdf(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+10 -1
View File
@@ -3,6 +3,7 @@ import { basename } from "node:path";
import { extractText } from "@ashim/ai";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
@@ -66,7 +67,7 @@ export function registerOcr(app: FastifyInstance) {
if (!result.success) {
return reply
.status(400)
.send({ error: "Invalid settings", details: result.error.issues });
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
@@ -135,10 +136,18 @@ export function registerOcr(app: FastifyInstance) {
});
}
if (result.engine && result.engine !== tier) {
request.log.warn(
{ toolId: "ocr", requested: tier, actual: result.engine },
`OCR engine fallback: requested ${tier} but used ${result.engine}`,
);
}
return reply.send({
jobId,
filename,
text: result.text,
engine: result.engine,
});
} catch (err) {
lastError = err;
@@ -4,6 +4,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeHeic } from "../../lib/heic-converter.js";
@@ -117,10 +118,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
+5 -5
View File
@@ -7,6 +7,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
@@ -275,15 +276,14 @@ export function registerPassportPhoto(app: FastifyInstance) {
app.post(
"/api/v1/tools/passport-photo/generate",
async (request: FastifyRequest, reply: FastifyReply) => {
let parsed: z.infer<typeof generateSettingsSchema>;
try {
parsed = generateSettingsSchema.parse(request.body);
} catch (err) {
const parseResult = generateSettingsSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.status(400).send({
error: "Invalid settings",
details: err instanceof Error ? err.message : String(err),
details: formatZodErrors(parseResult.error.issues),
});
}
const parsed = parseResult.data;
const {
jobId,
+4 -1
View File
@@ -7,6 +7,7 @@ import type { FastifyInstance } from "fastify";
import * as mupdf from "mupdf";
import sharp from "sharp";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -290,7 +291,9 @@ export function registerPdfToImage(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+2 -4
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import QRCode from "qrcode";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
@@ -37,10 +38,7 @@ export function registerQrGenerate(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
details: formatZodErrors(result.error.issues),
});
}
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -67,7 +68,9 @@ export function registerSplit(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -91,7 +92,9 @@ export function registerStitch(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+5 -2
View File
@@ -7,6 +7,7 @@ import PQueue from "p-queue";
import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
@@ -147,7 +148,7 @@ export function registerSvgToRaster(app: FastifyInstance) {
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
details: formatZodErrors(result.error.issues),
});
}
settings = result.data;
@@ -365,7 +366,9 @@ export function registerSvgToRaster(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -7,6 +7,7 @@ import potrace from "potrace";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -82,7 +83,9 @@ export function registerVectorize(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {
+4 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -56,7 +57,9 @@ export function registerWatermarkImage(app: FastifyInstance) {
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
const result = settingsSchema.safeParse(parsed);
if (!result.success) {
return reply.status(400).send({ error: "Invalid settings", details: result.error.issues });
return reply
.status(400)
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
}
settings = result.data;
} catch {