From a86129187ca2481a471dcc9e93a628a6f01db281 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 23:58:08 +0800 Subject: [PATCH] fix: add exotic format decoding to favicon generator The favicon route bypassed createToolRoute and only handled HEIC/HEIF via ensureSharpCompat. Exotic formats (PSD, BMP, JXL, JP2, EXR, HDR, DNG, ICO, TGA, etc.) passed validation but crashed Sharp after reply.hijack(), causing the response stream to hang silently until the 5-minute XHR timeout. Now decodes all formats before hijacking the response, matching the pipeline used by createToolRoute. Files that fail to decode are skipped with a skipped-files.txt manifest in the ZIP rather than aborting the entire batch. --- apps/api/src/routes/tools/favicon.ts | 89 +++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 16 deletions(-) diff --git a/apps/api/src/routes/tools/favicon.ts b/apps/api/src/routes/tools/favicon.ts index 7e875fa3..22d53379 100644 --- a/apps/api/src/routes/tools/favicon.ts +++ b/apps/api/src/routes/tools/favicon.ts @@ -7,7 +7,9 @@ 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 { ensureSharpCompat } from "../../lib/heic-converter.js"; +import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; +import { decodeHeic } from "../../lib/heic-converter.js"; +import { decompressSvgz, sanitizeSvg } from "../../lib/svg-sanitize.js"; const settingsSchema = z.object({}).passthrough(); @@ -56,14 +58,69 @@ export function registerFavicon(app: FastifyInstance) { return reply.status(400).send({ error: "No image file provided" }); } - // Validate all uploaded files + // Validate and decode all files before streaming the ZIP response. + // This ensures format errors are caught before reply.hijack() is called. + // Files that fail to decode are skipped (noted in the ZIP) rather than + // aborting the entire batch. + interface DecodedFile { + buffer: Buffer; + filename: string; + } + const decodedFiles: DecodedFile[] = []; + const skippedFiles: { filename: string; reason: string }[] = []; + for (const file of uploadedFiles) { const validation = await validateImageBuffer(file.buffer, file.filename); if (!validation.valid) { - return reply - .status(400) - .send({ error: `Invalid file "${file.filename}": ${validation.reason}` }); + skippedFiles.push({ filename: file.filename, reason: validation.reason }); + continue; } + + let buf = file.buffer; + const fileExt = file.filename.split(".").pop()?.toLowerCase(); + + try { + // Decode HEIC/HEIF via system decoder + if (validation.format === "heif") { + buf = await decodeHeic(buf); + } + + // Decode exotic formats (PSD, EXR, HDR, BMP, JXL, JP2, RAW, etc.) + if (needsCliDecode(validation.format)) { + try { + buf = await decodeToSharpCompat(buf, validation.format, fileExt); + } catch { + await sharp(buf).metadata(); + } + } + + // Sanitize SVG input + if (validation.format === "svg") { + buf = decompressSvgz(buf); + buf = sanitizeSvg(buf); + } + + // Auto-orient EXIF rotation (skip for SVG) + if (validation.format !== "svg") { + buf = await autoOrient(buf); + } + + decodedFiles.push({ buffer: buf, filename: file.filename }); + } catch (err) { + const reason = err instanceof Error ? err.message : "Unknown decode error"; + skippedFiles.push({ filename: file.filename, reason }); + } + } + + if (decodedFiles.length === 0) { + const first = skippedFiles[0]; + return reply.status(400).send({ + error: + skippedFiles.length === 1 + ? `Invalid file "${first.filename}": ${first.reason}` + : "No images could be decoded", + skipped: skippedFiles.length > 1 ? skippedFiles : undefined, + }); } if (settingsRaw) { @@ -82,7 +139,7 @@ export function registerFavicon(app: FastifyInstance) { try { const jobId = randomUUID(); - const isSingleFile = uploadedFiles.length === 1; + const isSingleFile = decodedFiles.length === 1; reply.hijack(); reply.raw.writeHead(200, { @@ -94,27 +151,21 @@ export function registerFavicon(app: FastifyInstance) { const archive = archiver("zip", { zlib: { level: 5 } }); archive.pipe(reply.raw); - for (const file of uploadedFiles) { - // Decode HEIC/HEIF if needed, then normalize EXIF orientation - const decoded = await autoOrient(await ensureSharpCompat(file.buffer)); + for (const file of decodedFiles) { const stem = sanitizeFilename(file.filename).replace(/\.[^.]+$/, ""); - // Single file: flat structure. Multiple files: per-image folders. const prefix = isSingleFile ? "" : `${stem}/`; - // Generate each size for (const icon of FAVICON_SIZES) { - const buffer = await sharp(decoded) + const buffer = await sharp(file.buffer) .resize(icon.size, icon.size, { fit: "cover" }) .png() .toBuffer(); archive.append(buffer, { name: `${prefix}${icon.name}` }); } - // Generate ICO (32x32 PNG as ICO) - const ico32 = await sharp(decoded).resize(32, 32, { fit: "cover" }).png().toBuffer(); + const ico32 = await sharp(file.buffer).resize(32, 32, { fit: "cover" }).png().toBuffer(); archive.append(ico32, { name: `${prefix}favicon.ico` }); - // Generate manifest.json (for PWA) const manifest = { name: stem, short_name: stem, @@ -128,7 +179,6 @@ export function registerFavicon(app: FastifyInstance) { }; archive.append(JSON.stringify(manifest, null, 2), { name: `${prefix}manifest.json` }); - // Generate HTML snippet const htmlSnippet = ` @@ -139,6 +189,13 @@ export function registerFavicon(app: FastifyInstance) { archive.append(htmlSnippet, { name: `${prefix}favicon-snippet.html` }); } + if (skippedFiles.length > 0) { + const lines = skippedFiles.map((s) => `- ${s.filename}: ${s.reason}`); + archive.append(`The following files could not be processed:\n\n${lines.join("\n")}\n`, { + name: "skipped-files.txt", + }); + } + await archive.finalize(); } catch (err) { if (!reply.raw.headersSent) {