Skip to content

Commit d00e21a

Browse files
styfleztanner
authored andcommitted
fix(next/image): improve and simplify detect-content-type (#82118)
Add support for detecting more src image formats via magic number. The src image formats are handled as follows: - `image/jxl` - serve as is (since safari can render it) - `image/heic` - serve as is (since safari can render it) - `image/jp2` - serve as is (since safari can render it) - `application/pdf` - error (since no browser will render it) - `image/pic` - error (since no browser will render it) We also fallback to `sharp().metadata()` if we can't detect the magic number to ensure correctness.
1 parent 243072b commit d00e21a

File tree

13 files changed

+279
-60
lines changed

13 files changed

+279
-60
lines changed

packages/next/src/server/image-optimizer.ts

Lines changed: 132 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,20 @@ const AVIF = 'image/avif'
3737
const WEBP = 'image/webp'
3838
const PNG = 'image/png'
3939
const JPEG = 'image/jpeg'
40+
const JXL = 'image/jxl'
41+
const JP2 = 'image/jp2'
42+
const HEIC = 'image/heic'
4043
const GIF = 'image/gif'
4144
const SVG = 'image/svg+xml'
4245
const ICO = 'image/x-icon'
46+
const ICNS = 'image/x-icns'
47+
const TIFF = 'image/tiff'
48+
const BMP = 'image/bmp'
49+
const PDF = 'application/pdf'
4350
const CACHE_VERSION = 3
4451
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
4552
const VECTOR_TYPES = [SVG]
53+
const BYPASS_TYPES = [SVG, ICO, ICNS, BMP, JXL, HEIC]
4654
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
4755
const BLUR_QUALITY = 70 // should match `next-image-loader`
4856

@@ -118,7 +126,9 @@ async function writeToCacheDir(
118126
* it matches the "magic number" of known file signatures.
119127
* https://en.wikipedia.org/wiki/List_of_file_signatures
120128
*/
121-
export function detectContentType(buffer: Buffer) {
129+
export async function detectContentType(
130+
buffer: Buffer
131+
): Promise<string | null> {
122132
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
123133
return JPEG
124134
}
@@ -152,6 +162,89 @@ export function detectContentType(buffer: Buffer) {
152162
if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) {
153163
return ICO
154164
}
165+
if ([0x69, 0x63, 0x6e, 0x73].every((b, i) => buffer[i] === b)) {
166+
return ICNS
167+
}
168+
if ([0x49, 0x49, 0x2a, 0x00].every((b, i) => buffer[i] === b)) {
169+
return TIFF
170+
}
171+
if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) {
172+
return BMP
173+
}
174+
if ([0xff, 0x0a].every((b, i) => buffer[i] === b)) {
175+
return JXL
176+
}
177+
if (
178+
[
179+
0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
180+
].every((b, i) => buffer[i] === b)
181+
) {
182+
return JXL
183+
}
184+
if (
185+
[0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63].every(
186+
(b, i) => !b || buffer[i] === b
187+
)
188+
) {
189+
return HEIC
190+
}
191+
if ([0x25, 0x50, 0x44, 0x46, 0x2d].every((b, i) => buffer[i] === b)) {
192+
return PDF
193+
}
194+
if (
195+
[
196+
0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
197+
].every((b, i) => buffer[i] === b)
198+
) {
199+
return JP2
200+
}
201+
202+
// Fallback to sharp if available
203+
if (sharp) {
204+
const meta = await sharp(buffer)
205+
.metadata()
206+
.catch((_) => null)
207+
switch (meta?.format) {
208+
case 'avif':
209+
return AVIF
210+
case 'webp':
211+
return WEBP
212+
case 'png':
213+
return PNG
214+
case 'jpeg':
215+
case 'jpg':
216+
return JPEG
217+
case 'gif':
218+
return GIF
219+
case 'svg':
220+
return SVG
221+
case 'jxl':
222+
return JXL
223+
case 'jp2':
224+
return JP2
225+
case 'tiff':
226+
case 'tif':
227+
return TIFF
228+
case 'pdf':
229+
return PDF
230+
case 'dcraw':
231+
case 'dz':
232+
case 'exr':
233+
case 'fits':
234+
case 'heif':
235+
case 'input':
236+
case 'magick':
237+
case 'openslide':
238+
case 'ppm':
239+
case 'rad':
240+
case 'raw':
241+
case 'v':
242+
case undefined:
243+
default:
244+
return null
245+
}
246+
}
247+
155248
return null
156249
}
157250

@@ -639,54 +732,52 @@ export async function imageOptimizer(
639732
): Promise<{ buffer: Buffer; contentType: string; maxAge: number }> {
640733
const { href, quality, width, mimeType } = paramsResult
641734
const upstreamBuffer = imageUpstream.buffer
642-
const maxAge = getMaxAge(imageUpstream.cacheControl)
643-
const upstreamType =
644-
detectContentType(upstreamBuffer) ||
645-
imageUpstream.contentType?.toLowerCase().trim()
646-
647-
if (upstreamType) {
648-
if (
649-
upstreamType.startsWith('image/svg') &&
650-
!nextConfig.images.dangerouslyAllowSVG
651-
) {
652-
Log.error(
653-
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
654-
)
655-
throw new ImageError(
656-
400,
657-
'"url" parameter is valid but image type is not allowed'
658-
)
659-
}
735+
const maxAge = Math.max(
736+
nextConfig.images.minimumCacheTTL,
737+
getMaxAge(imageUpstream.cacheControl)
738+
)
739+
const upstreamType = await detectContentType(upstreamBuffer)
660740

661-
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
662-
Log.warnOnce(
663-
`The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
664-
)
665-
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
666-
}
667-
if (VECTOR_TYPES.includes(upstreamType)) {
668-
// We don't warn here because we already know that "dangerouslyAllowSVG"
669-
// was enabled above, therefore the user explicitly opted in.
670-
// If we add more VECTOR_TYPES besides SVG, perhaps we could warn for those.
671-
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
672-
}
673-
if (!upstreamType.startsWith('image/') || upstreamType.includes(',')) {
674-
Log.error(
675-
"The requested resource isn't a valid image for",
676-
href,
677-
'received',
678-
upstreamType
679-
)
680-
throw new ImageError(400, "The requested resource isn't a valid image.")
681-
}
741+
if (
742+
!upstreamType ||
743+
!upstreamType.startsWith('image/') ||
744+
upstreamType.includes(',')
745+
) {
746+
Log.error(
747+
"The requested resource isn't a valid image for",
748+
href,
749+
'received',
750+
upstreamType
751+
)
752+
throw new ImageError(400, "The requested resource isn't a valid image.")
753+
}
754+
if (
755+
upstreamType.startsWith('image/svg') &&
756+
!nextConfig.images.dangerouslyAllowSVG
757+
) {
758+
Log.error(
759+
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
760+
)
761+
throw new ImageError(
762+
400,
763+
'"url" parameter is valid but image type is not allowed'
764+
)
765+
}
766+
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
767+
Log.warnOnce(
768+
`The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
769+
)
770+
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
771+
}
772+
if (BYPASS_TYPES.includes(upstreamType)) {
773+
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
682774
}
683775

684776
let contentType: string
685777

686778
if (mimeType) {
687779
contentType = mimeType
688780
} else if (
689-
upstreamType?.startsWith('image/') &&
690781
getExtension(upstreamType) &&
691782
upstreamType !== WEBP &&
692783
upstreamType !== AVIF

packages/next/src/server/serve-static.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import send from 'next/dist/compiled/send'
55
// Although "mime" has already add avif in version 2.4.7, "send" is still using [email protected]
66
send.mime.define({
77
'image/avif': ['avif'],
8+
'image/x-icns': ['icns'],
9+
'image/jxl': ['jxl'],
10+
'image/heic': ['heic'],
811
})
912

1013
export function serveStatic(
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

test/integration/image-optimizer/test/util.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,70 @@ export function runTests(ctx) {
196196
expect(res.status).toBe(200)
197197
})
198198

199+
it('should maintain icns', async () => {
200+
const query = { w: ctx.w, q: 90, url: '/test.icns' }
201+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
202+
expect(res.status).toBe(200)
203+
expect(res.headers.get('Content-Type')).toContain('image/x-icns')
204+
expect(res.headers.get('Cache-Control')).toBe(
205+
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
206+
)
207+
expect(res.headers.get('Vary')).toBe('Accept')
208+
expect(res.headers.get('etag')).toBeTruthy()
209+
expect(res.headers.get('Content-Disposition')).toBe(
210+
`${contentDispositionType}; filename="test.icns"`
211+
)
212+
await expectWidth(res, 256)
213+
})
214+
215+
it('should maintain jxl', async () => {
216+
const query = { w: ctx.w, q: 90, url: '/test.jxl' }
217+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
218+
expect(res.status).toBe(200)
219+
expect(res.headers.get('Content-Type')).toContain('image/jxl')
220+
expect(res.headers.get('Cache-Control')).toBe(
221+
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
222+
)
223+
expect(res.headers.get('Vary')).toBe('Accept')
224+
expect(res.headers.get('etag')).toBeTruthy()
225+
expect(res.headers.get('Content-Disposition')).toBe(
226+
`${contentDispositionType}; filename="test.jxl"`
227+
)
228+
await expectWidth(res, 800)
229+
})
230+
231+
it('should maintain heic', async () => {
232+
const query = { w: ctx.w, q: 90, url: '/test.heic' }
233+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
234+
expect(res.status).toBe(200)
235+
expect(res.headers.get('Content-Type')).toContain('image/heic')
236+
expect(res.headers.get('Cache-Control')).toBe(
237+
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
238+
)
239+
expect(res.headers.get('Vary')).toBe('Accept')
240+
expect(res.headers.get('etag')).toBeTruthy()
241+
expect(res.headers.get('Content-Disposition')).toBe(
242+
`${contentDispositionType}; filename="test.heic"`
243+
)
244+
await expectWidth(res, 400)
245+
})
246+
247+
it('should maintain jp2', async () => {
248+
const query = { w: ctx.w, q: 90, url: '/test.jp2' }
249+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
250+
expect(res.status).toBe(200)
251+
expect(res.headers.get('Content-Type')).toContain('image/jp2')
252+
expect(res.headers.get('Cache-Control')).toBe(
253+
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
254+
)
255+
expect(res.headers.get('Vary')).toBe('Accept')
256+
expect(res.headers.get('etag')).toBeTruthy()
257+
expect(res.headers.get('Content-Disposition')).toBe(
258+
`${contentDispositionType}; filename="test.jp2"`
259+
)
260+
await expectWidth(res, 1)
261+
})
262+
199263
it('should maintain animated gif', async () => {
200264
const query = { w: ctx.w, q: 90, url: '/animated.gif' }
201265
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
@@ -288,7 +352,6 @@ export function runTests(ctx) {
288352
'utf8'
289353
)
290354
expect(actual).toMatch(expected)
291-
expect(ctx.nextOutput).not.toContain('The requested resource')
292355
})
293356
} else {
294357
it('should not allow vector svg', async () => {
@@ -325,11 +388,21 @@ export function runTests(ctx) {
325388
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
326389
expect(res.status).toBe(400)
327390
expect(await res.text()).toContain(
328-
'"url" parameter is valid but image type is not allowed'
391+
"The requested resource isn't a valid image"
329392
)
330393
})
331394
}
332395

396+
it('should not allow pdf format', async () => {
397+
const query = { w: ctx.w, q: 90, url: '/test.pdf' }
398+
const opts = { headers: { accept: 'image/webp' } }
399+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
400+
expect(res.status).toBe(400)
401+
expect(await res.text()).toContain(
402+
"The requested resource isn't a valid image"
403+
)
404+
})
405+
333406
it('should maintain ico format', async () => {
334407
const query = { w: ctx.w, q: 90, url: `/test.ico` }
335408
const opts = { headers: { accept: 'image/webp' } }
@@ -933,8 +1006,8 @@ export function runTests(ctx) {
9331006
const opts = { headers: { accept: 'image/webp' } }
9341007
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
9351008
expect(res.status).toBe(400)
936-
expect(await res.text()).toBe(
937-
`Unable to optimize image and unable to fallback to upstream image`
1009+
expect(await res.text()).toContain(
1010+
"The requested resource isn't a valid image"
9381011
)
9391012
})
9401013

Lines changed: 11 additions & 9 deletions
Loading

0 commit comments

Comments
 (0)