/** * Fetches an images. * * Local images (starting with a '/' as fetched using the passed fetcher). * Remote images should match the configured remote patterns or a 404 response is returned. */ export async function fetchImage(fetcher, imageUrl, ctx) { // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L208 if (!imageUrl || imageUrl.length > 3072 || imageUrl.startsWith("//")) { return getUrlErrorResponse(); } // Local if (imageUrl.startsWith("/")) { let pathname; let url; try { // We only need pathname and search url = new URL(imageUrl, "http://n"); pathname = decodeURIComponent(url.pathname); } catch { return getUrlErrorResponse(); } if (/\/_next\/image($|\/)/.test(pathname)) { return getUrlErrorResponse(); } // If localPatterns are not defined all local images are allowed. if (__IMAGES_LOCAL_PATTERNS__.length > 0 && !__IMAGES_LOCAL_PATTERNS__.some((p) => matchLocalPattern(p, url))) { return getUrlErrorResponse(); } return fetcher?.fetch(`http://assets.local${imageUrl}`); } // Remote let url; try { url = new URL(imageUrl); } catch { return getUrlErrorResponse(); } if (url.protocol !== "http:" && url.protocol !== "https:") { return getUrlErrorResponse(); } // The remotePatterns is used to allow images from specific remote external paths and block all others. if (!__IMAGES_REMOTE_PATTERNS__.some((p) => matchRemotePattern(p, url))) { return getUrlErrorResponse(); } const imgResponse = await fetch(imageUrl, { cf: { cacheEverything: true } }); if (!imgResponse.body) { return imgResponse; } const buffer = new ArrayBuffer(32); try { let contentType; // respBody is eventually used for the response // contentBody is used to detect the content type const [respBody, contentBody] = imgResponse.body.tee(); const reader = contentBody.getReader({ mode: "byob" }); const { value } = await reader.read(new Uint8Array(buffer)); // Release resources by calling `reader.cancel()` // `ctx.waitUntil` keeps the runtime running until the promise settles without having to wait here. ctx.waitUntil(reader.cancel()); if (value) { contentType = detectContentType(value); } if (!contentType) { // Fallback to upstream header when the type can not be detected // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L748 contentType = imgResponse.headers.get("content-type") ?? ""; } // Sanitize the content type: // - Accept images only // - Reject multiple content types if (!contentType.startsWith("image/") || contentType.includes(",")) { contentType = undefined; } if (contentType && !(contentType === SVG && !__IMAGES_ALLOW_SVG__)) { const headers = new Headers(imgResponse.headers); headers.set("content-type", contentType); headers.set("content-disposition", __IMAGES_CONTENT_DISPOSITION__); headers.set("content-security-policy", __IMAGES_CONTENT_SECURITY_POLICY__); return new Response(respBody, { ...imgResponse, headers }); } // Cancel the unused stream ctx.waitUntil(respBody.cancel()); return new Response('"url" parameter is valid but image type is not allowed', { status: 400, }); } catch { return new Response('"url" parameter is valid but upstream response is invalid', { status: 400, }); } } export function matchRemotePattern(pattern, url) { // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/shared/lib/match-remote-pattern.ts if (pattern.protocol !== undefined && pattern.protocol.replace(/:$/, "") !== url.protocol.replace(/:$/, "")) { return false; } if (pattern.port !== undefined && pattern.port !== url.port) { return false; } if (pattern.hostname === undefined || !new RegExp(pattern.hostname).test(url.hostname)) { return false; } if (pattern.search !== undefined && pattern.search !== url.search) { return false; } // Should be the same as writeImagesManifest() return new RegExp(pattern.pathname).test(url.pathname); } export function matchLocalPattern(pattern, url) { // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/shared/lib/match-local-pattern.ts if (pattern.search !== undefined && pattern.search !== url.search) { return false; } return new RegExp(pattern.pathname).test(url.pathname); } /** * @returns same error as Next.js when the url query parameter is not accepted. */ function getUrlErrorResponse() { return new Response(`"url" parameter is not allowed`, { status: 400 }); } const AVIF = "image/avif"; const WEBP = "image/webp"; const PNG = "image/png"; const JPEG = "image/jpeg"; const JXL = "image/jxl"; const JP2 = "image/jp2"; const HEIC = "image/heic"; const GIF = "image/gif"; const SVG = "image/svg+xml"; const ICO = "image/x-icon"; const ICNS = "image/x-icns"; const TIFF = "image/tiff"; const BMP = "image/bmp"; // pdf will be rejected (not an `image/...` type) const PDF = "application/pdf"; /** * Detects the content type by looking at the first few bytes of a file * * Based on https://github.com/vercel/next.js/blob/72c9635/packages/next/src/server/image-optimizer.ts#L155 * * @param buffer The image bytes * @returns a content type of undefined for unsupported content */ export function detectContentType(buffer) { 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; } if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) { return ICO; } if ([0x69, 0x63, 0x6e, 0x73].every((b, i) => buffer[i] === b)) { return ICNS; } if ([0x49, 0x49, 0x2a, 0x00].every((b, i) => buffer[i] === b)) { return TIFF; } if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) { return BMP; } if ([0xff, 0x0a].every((b, i) => buffer[i] === b)) { return JXL; } if ([0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a].every((b, i) => buffer[i] === b)) { return JXL; } if ([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63].every((b, i) => !b || buffer[i] === b)) { return HEIC; } if ([0x25, 0x50, 0x44, 0x46, 0x2d].every((b, i) => buffer[i] === b)) { return PDF; } if ([0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a].every((b, i) => buffer[i] === b)) { return JP2; } }