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) {