Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions lib/api/image-optimization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import sharp from "sharp";

export interface OptimizeImageResult {
buffer: Buffer;
contentType: string;
originalSize: number;
optimizedSize: number;
originalDimensions?: { width: number; height: number };
optimizedDimensions?: { width: number; height: number };
format?: string;
}

export interface OptimizeImageOptions {
width?: number;
height?: number;
quality?: number;
effort?: number;
}

/**
* Optimizes an image by resizing and converting to WebP format
* @param imageBuffer - The original image buffer
* @param options - Optimization options
* @returns Optimized image buffer and metadata, or original buffer if optimization fails
*/
export async function optimizeImage(
imageBuffer: ArrayBuffer,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing parameter validation in optimizeImage function allows Sharp to throw unhandled errors when invalid width, height, quality, or effort values are provided

Fix on Vercel

options: OptimizeImageOptions = {}
): Promise<OptimizeImageResult> {
const { width = 96, height = 96, quality = 75, effort = 6 } = options;

const originalSize = imageBuffer.byteLength;
const originalBuffer = Buffer.from(imageBuffer);

Comment on lines +30 to +34
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optimizeImage processes untrusted, externally-fetched image bytes with Sharp without any explicit limits. Consider adding guardrails (max input byte size and/or limitInputPixels) to reduce risk of decompression bombs / CPU+memory DoS, and skip optimization (or reject) when limits are exceeded.

Copilot uses AI. Check for mistakes.
// Get original image metadata
let originalMetadata;
try {
originalMetadata = await sharp(originalBuffer).metadata();
} catch {
Comment on lines +35 to +39
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sharp(originalBuffer).metadata() is called and then the image is decoded again during the resize/WebP conversion. This doubles decoding work for every request. Consider reusing a single Sharp pipeline (or making original dimension capture optional) to avoid the extra decode.

Copilot uses AI. Check for mistakes.
// Metadata extraction failed, but we can still proceed with optimization
originalMetadata = undefined;
}

// Optimize image: resize and convert to WebP
try {
const optimizedBuffer = await sharp(originalBuffer)
.resize(width, height, {
fit: "cover",
withoutEnlargement: true, // Don't upscale small images
})
.webp({ quality, effort })
.toBuffer();

const optimizedMetadata = await sharp(optimizedBuffer).metadata();

return {
buffer: optimizedBuffer,
contentType: "image/webp",
originalSize,
optimizedSize: optimizedBuffer.length,
originalDimensions: originalMetadata
? {
width: originalMetadata.width || 0,
height: originalMetadata.height || 0,
}
: undefined,
optimizedDimensions: optimizedMetadata
? {
width: optimizedMetadata.width || 0,
height: optimizedMetadata.height || 0,
}
: undefined,
format: optimizedMetadata?.format,
};
} catch {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image optimization fallback hardcodes 'image/jpeg' content-type regardless of actual image format, causing wrong content-type headers for PNG, GIF, and other non-JPEG images when optimization fails

Fix on Vercel

// Fallback to original image if optimization fails
// This is expected for some edge cases (unsupported formats, corrupted images, etc.
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment typo: the parenthesis is unclosed in “(unsupported formats, corrupted images, etc.”.

Suggested change
// This is expected for some edge cases (unsupported formats, corrupted images, etc.
// This is expected for some edge cases (unsupported formats, corrupted images, etc.)

Copilot uses AI. Check for mistakes.
// Return original image as fallback
return {
buffer: originalBuffer,
contentType: "image/jpeg", // Default fallback
originalSize,
Comment on lines +79 to +82
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the fallback path, contentType is hard-coded to image/jpeg. If this utility is reused elsewhere, callers may inadvertently serve non-JPEG bytes with the wrong Content-Type. Prefer returning contentType: null/undefined on fallback (forcing the caller to supply the original), or accept the original content-type as an input and echo it back when optimization fails.

Copilot uses AI. Check for mistakes.
optimizedSize: originalSize,
originalDimensions: originalMetadata
? {
width: originalMetadata.width || 0,
height: originalMetadata.height || 0,
}
: undefined,
};
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"rehype-raw": "^7.0.0",
"remark-gfm": "4.0.1",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.5",
"swr": "^2.3.7",
"viem": "^2.38.5",
"wagmi": "^2.19.1",
Expand Down
24 changes: 23 additions & 1 deletion pages/api/ens-data/image/[name].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
methodNotAllowed,
notFound,
} from "@lib/api/errors";
import { optimizeImage } from "@lib/api/image-optimization";
import { l1PublicClient } from "@lib/chains";
import { parseArweaveTxId, parseCid } from "livepeer/utils";
import { NextApiRequest, NextApiResponse } from "next";
Expand Down Expand Up @@ -48,9 +49,30 @@ const handler = async (

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch(imageUrl) is followed by unconditional response.arrayBuffer() and optimization. If the upstream returns a 404/500 or a non-image content-type (e.g. text/html), this route will still return 200 with that body (and may attempt Sharp decoding). Add a response.ok check and validate Content-Type starts with image/ before reading/processing; otherwise return notFound/externalApiError appropriately.

Suggested change
// Ensure the upstream request succeeded
if (!response.ok) {
if (response.status === 404) {
return notFound(res, "ENS avatar not found");
}
return internalError(
res,
new Error(
`Failed to fetch ENS avatar: ${response.status} ${response.statusText}`
)
);
}
// Ensure the upstream response is an image
const upstreamContentType =
response.headers.get("content-type")?.toLowerCase() || "";
if (!upstreamContentType.startsWith("image/")) {
return notFound(res, "ENS avatar not found");
}

Copilot uses AI. Check for mistakes.
const arrayBuffer = await response.arrayBuffer();

// Optimize image using utility
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing response status check allows error responses to be processed as image data, causing Sharp to fail silently and return HTML/JSON with image content-type headers

Fix on Vercel

const optimizationResult = await optimizeImage(arrayBuffer, {
width: 96,
height: 96,
quality: 75,
effort: 6,
Comment on lines +53 to +57
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint now always returns WebP on success. To avoid breaking clients/crawlers that don't send Accept: image/webp, consider negotiating based on the request Accept header (and returning the original bytes otherwise), and set Vary: Accept when the response format can change.

Copilot uses AI. Check for mistakes.
});

// Set appropriate content type (fallback to original if optimization failed)
if (optimizationResult.contentType === "image/jpeg") {
const originalContentType =
response.headers.get("content-type") || "image/jpeg";
res.setHeader("Content-Type", originalContentType);
} else {
res.setHeader("Content-Type", optimizationResult.contentType);
}
Comment on lines +60 to +67
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using optimizationResult.contentType === "image/jpeg" as a sentinel for "optimization failed" is brittle and couples the route to the utility's implementation details. Consider returning an explicit flag (e.g., optimized: boolean) and/or originalContentType from optimizeImage so the caller can set headers without relying on a magic content type.

Suggested change
// Set appropriate content type (fallback to original if optimization failed)
if (optimizationResult.contentType === "image/jpeg") {
const originalContentType =
response.headers.get("content-type") || "image/jpeg";
res.setHeader("Content-Type", originalContentType);
} else {
res.setHeader("Content-Type", optimizationResult.contentType);
}
// Set appropriate content type, preferring optimized result and falling back to original if needed
const resolvedContentType =
optimizationResult.contentType ||
response.headers.get("content-type") ||
"image/jpeg";
res.setHeader("Content-Type", resolvedContentType);

Copilot uses AI. Check for mistakes.

res.setHeader(
"Content-Length",
optimizationResult.buffer.length.toString()
);
res.setHeader("Cache-Control", getCacheControlHeader("week"));

return res.end(Buffer.from(arrayBuffer));
return res.end(optimizationResult.buffer);
} catch (e) {
console.error(e);
return notFound(res, "ENS avatar not found");
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading