diff --git a/packages/next/next-server/server/image-optimizer.ts b/packages/next/next-server/server/image-optimizer.ts index b5407c63d711d..1e789489ce954 100644 --- a/packages/next/next-server/server/image-optimizer.ts +++ b/packages/next/next-server/server/image-optimizer.ts @@ -198,7 +198,9 @@ export async function imageOptimizer( res.statusCode = upstreamRes.status upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer()) - upstreamType = upstreamRes.headers.get('Content-Type') + upstreamType = + detectContentType(upstreamBuffer) || + upstreamRes.headers.get('Content-Type') maxAge = getMaxAge(upstreamRes.headers.get('Cache-Control')) } else { try { @@ -252,7 +254,8 @@ export async function imageOptimizer( res.statusCode = mockRes.statusCode upstreamBuffer = Buffer.concat(resBuffers) - upstreamType = mockRes.getHeader('Content-Type') + upstreamType = + detectContentType(upstreamBuffer) || mockRes.getHeader('Content-Type') maxAge = getMaxAge(mockRes.getHeader('Cache-Control')) } catch (err) { res.statusCode = 500 @@ -273,7 +276,6 @@ export async function imageOptimizer( return { finished: true } } - // If upstream type is not a valid image type, return 400 error. if (!upstreamType.startsWith('image/')) { res.statusCode = 400 res.end("The requested resource isn't a valid image.") @@ -426,6 +428,38 @@ function parseCacheControl(str: string | null): Map { return map } +/** + * Inspects the first few bytes of a buffer to determine if + * it matches the "magic number" of known file signatures. + * https://en.wikipedia.org/wiki/List_of_file_signatures + */ +function detectContentType(buffer: 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 + } + return null +} + export function getMaxAge(str: string | null): number { const minimum = 60 const map = parseCacheControl(str) diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 2a5d8ecdaab14..e553e0a31dc19 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -330,6 +330,26 @@ function runTests({ w, isDev, domains }) { expect(res.headers.get('etag')).toBeTruthy() await expectWidth(res, w) }) + + it('should automatically detect image type when content-type is octet-stream', async () => { + const url = + 'https://image-optimization-test.vercel.app/png-as-octet-stream' + const resOrig = await fetch(url) + expect(resOrig.status).toBe(200) + expect(resOrig.headers.get('Content-Type')).toBe( + 'application/octet-stream' + ) + const query = { url, w, q: 80 } + const opts = { headers: { accept: 'image/webp' } } + const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/webp') + expect(res.headers.get('cache-control')).toBe( + 'public, max-age=0, must-revalidate' + ) + expect(res.headers.get('etag')).toBeTruthy() + await expectWidth(res, w) + }) } it('should fail when url has file protocol', async () => { @@ -697,7 +717,11 @@ describe('Image Optimizer', () => { }) // domains for testing - const domains = ['localhost', 'example.com'] + const domains = [ + 'localhost', + 'example.com', + 'image-optimization-test.vercel.app', + ] describe('dev support w/o next.config.js', () => { const size = 384 // defaults defined in server/config.ts