diff --git a/apps/web/app/api/logo/route.ts b/apps/web/app/api/logo/route.ts index f4cbc5c894..8745295492 100644 --- a/apps/web/app/api/logo/route.ts +++ b/apps/web/app/api/logo/route.ts @@ -193,25 +193,27 @@ async function getHandler(request: NextRequest) { const response = await fetch(filteredLogo); const arrayBuffer = await response.arrayBuffer(); let buffer: Buffer = Buffer.from(arrayBuffer); + let contentType = response.headers.get("content-type") || "image/png"; - // If we need to resize the team logos (via Next.js' built-in image processing) + // Resize the team logos if needed if (teamLogos[logoDefinition.source] && logoDefinition.w) { - const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer"); - - buffer = await optimizeImage({ + const { resizeImage } = await import("@calcom/lib/server/imageUtils"); + const { buffer: outBuffer, contentType: outContentType } = await resizeImage({ buffer, - contentType: (await detectContentType(buffer)) ?? "image/jpeg", - quality: 100, width: logoDefinition.w, - height: logoDefinition.h, // optional + height: logoDefinition.h, + quality: 100, + contentType, }); + buffer = outBuffer; + contentType = outContentType; } // Create a new response with the image buffer const imageResponse = new NextResponse(buffer as BodyInit); // Set the appropriate headers - imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png"); + imageResponse.headers.set("Content-Type", contentType); imageResponse.headers.set("Cache-Control", "s-maxage=86400, stale-while-revalidate=60"); return imageResponse; diff --git a/packages/lib/server/imageUtils.ts b/packages/lib/server/imageUtils.ts index 5254f9fa09..b7128858fd 100644 --- a/packages/lib/server/imageUtils.ts +++ b/packages/lib/server/imageUtils.ts @@ -3,6 +3,13 @@ import sharp from "sharp"; // Maximum allowed size for SVG data (5MB) const MAX_SVG_SIZE = 5 * 1024 * 1024; +const JPEG = "image/jpeg"; +const PNG = "image/png"; +const WEBP = "image/webp"; +const AVIF = "image/avif"; +const GIF = "image/gif"; +const SVG = "image/svg+xml"; + /** * Converts an SVG image to PNG format * @param data Base64 encoded image data @@ -29,3 +36,110 @@ export const convertSvgToPng = async (data: string) => { } return data; }; + +/** + * Detect content type from image buffer. + * Simplified version of Next.js image optimizer's detectContentType function (https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/server/image-optimizer.ts#L160). + * Supports common web image formats (JPEG, PNG, GIF, WEBP, AVIF, SVG) and drops + * irrelevant formats like PDF, ICO, TIFF, etc. that aren't used for logos. + */ +export async function detectContentType(buffer: Buffer): Promise { + if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) { + return JPEG; + } + if ([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => buffer[i] === b)) { + return PNG; + } + if ([0x47, 0x49, 0x46, 0x38].every((b, i) => buffer[i] === b)) { + return GIF; + } + if ([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50].every((b, i) => !b || buffer[i] === b)) { + return WEBP; + } + if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) { + return SVG; + } + if ([0x3c, 0x73, 0x76, 0x67].every((b, i) => buffer[i] === b)) { + return SVG; + } + if ([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every((b, i) => !b || buffer[i] === b)) { + return AVIF; + } + + // Fallback to sharp metadata detection + try { + const meta = await sharp(buffer).metadata(); + switch (meta?.format) { + case "avif": + return AVIF; + case "webp": + return WEBP; + case "png": + return PNG; + case "jpeg": + case "jpg": + return JPEG; + case "gif": + return GIF; + case "svg": + return SVG; + default: + return null; + } + } catch { + return null; + } +} + +/** + * Resize an image buffer while preserving the original format. + * Simplified version of Next.js image optimizer's optimizeImage function (https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/server/image-optimizer.ts#L640). + * Supports common web image formats (JPEG, PNG, WEBP, AVIF) with format-specific + * optimization settings. Drops advanced options like limitInputPixels, sequentialRead, + * and timeout that aren't needed for logo processing. Uses failOnError: false for + * better robustness with potentially malformed images. + */ +export async function resizeImage(params: { + buffer: Buffer; + width: number; + height?: number; + quality?: number; + contentType?: string; +}): Promise<{ buffer: Buffer; contentType: string }> { + const { buffer, width, height, quality = 100 } = params; + let { contentType } = params; + + // Auto-detect content type if not provided + if (!contentType) { + contentType = (await detectContentType(buffer)) ?? PNG; + } + + const transformer = sharp(buffer).rotate(); + + if (height) { + transformer.resize(width, height); + } else { + transformer.resize(width, undefined, { withoutEnlargement: true }); + } + + // Apply format-specific optimization (preserving original format) + if (contentType === AVIF) { + transformer.avif({ + quality: Math.max(quality - 20, 1), + effort: 3, + }); + } else if (contentType === WEBP) { + transformer.webp({ quality }); + } else if (contentType === PNG) { + transformer.png({ quality }); + } else if (contentType === JPEG) { + transformer.jpeg({ quality, mozjpeg: true }); + } else { + // For unknown formats, default to PNG + transformer.png({ quality }); + contentType = PNG; + } + + const optimizedBuffer = await transformer.toBuffer(); + return { buffer: optimizedBuffer, contentType }; +}