Skip to content

Commit f326885

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 55a7568 commit f326885

File tree

13 files changed

+233
-89
lines changed

13 files changed

+233
-89
lines changed

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

Lines changed: 127 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,19 @@ const AVIF = 'image/avif'
3636
const WEBP = 'image/webp'
3737
const PNG = 'image/png'
3838
const JPEG = 'image/jpeg'
39+
const JXL = 'image/jxl'
40+
const JP2 = 'image/jp2'
41+
const HEIC = 'image/heic'
3942
const GIF = 'image/gif'
4043
const SVG = 'image/svg+xml'
4144
const ICO = 'image/x-icon'
4245
const ICNS = 'image/x-icns'
4346
const TIFF = 'image/tiff'
4447
const BMP = 'image/bmp'
48+
const PDF = 'application/pdf'
4549
const CACHE_VERSION = 4
4650
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]
47-
const BYPASS_TYPES = [SVG, ICO, ICNS, BMP]
51+
const BYPASS_TYPES = [SVG, ICO, ICNS, BMP, JXL, HEIC]
4852
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
4953
const BLUR_QUALITY = 70 // should match `next-image-loader`
5054

@@ -152,7 +156,9 @@ async function writeToCacheDir(
152156
* it matches the "magic number" of known file signatures.
153157
* https://en.wikipedia.org/wiki/List_of_file_signatures
154158
*/
155-
export function detectContentType(buffer: Buffer) {
159+
export async function detectContentType(
160+
buffer: Buffer
161+
): Promise<string | null> {
156162
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
157163
return JPEG
158164
}
@@ -198,7 +204,77 @@ export function detectContentType(buffer: Buffer) {
198204
if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) {
199205
return BMP
200206
}
201-
return null
207+
if ([0xff, 0x0a].every((b, i) => buffer[i] === b)) {
208+
return JXL
209+
}
210+
if (
211+
[
212+
0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
213+
].every((b, i) => buffer[i] === b)
214+
) {
215+
return JXL
216+
}
217+
if (
218+
[0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63].every(
219+
(b, i) => !b || buffer[i] === b
220+
)
221+
) {
222+
return HEIC
223+
}
224+
if ([0x25, 0x50, 0x44, 0x46, 0x2d].every((b, i) => buffer[i] === b)) {
225+
return PDF
226+
}
227+
if (
228+
[
229+
0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a,
230+
].every((b, i) => buffer[i] === b)
231+
) {
232+
return JP2
233+
}
234+
235+
const sharp = getSharp(null)
236+
const meta = await sharp(buffer)
237+
.metadata()
238+
.catch((_) => null)
239+
switch (meta?.format) {
240+
case 'avif':
241+
return AVIF
242+
case 'webp':
243+
return WEBP
244+
case 'png':
245+
return PNG
246+
case 'jpeg':
247+
case 'jpg':
248+
return JPEG
249+
case 'gif':
250+
return GIF
251+
case 'svg':
252+
return SVG
253+
case 'jxl':
254+
return JXL
255+
case 'jp2':
256+
return JP2
257+
case 'tiff':
258+
case 'tif':
259+
return TIFF
260+
case 'pdf':
261+
return PDF
262+
case 'dcraw':
263+
case 'dz':
264+
case 'exr':
265+
case 'fits':
266+
case 'heif':
267+
case 'input':
268+
case 'magick':
269+
case 'openslide':
270+
case 'ppm':
271+
case 'rad':
272+
case 'raw':
273+
case 'v':
274+
case undefined:
275+
default:
276+
return null
277+
}
202278
}
203279

204280
export class ImageOptimizerCache {
@@ -703,58 +779,58 @@ export async function imageOptimizer(
703779
getMaxAge(imageUpstream.cacheControl)
704780
)
705781

706-
const upstreamType =
707-
detectContentType(upstreamBuffer) ||
708-
imageUpstream.contentType?.toLowerCase().trim()
709-
710-
if (upstreamType) {
711-
if (
712-
upstreamType.startsWith('image/svg') &&
713-
!nextConfig.images.dangerouslyAllowSVG
714-
) {
715-
if (!opts.silent) {
716-
Log.error(
717-
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
718-
)
719-
}
720-
throw new ImageError(
721-
400,
722-
'"url" parameter is valid but image type is not allowed'
782+
const upstreamType = await detectContentType(upstreamBuffer)
783+
784+
if (
785+
!upstreamType ||
786+
!upstreamType.startsWith('image/') ||
787+
upstreamType.includes(',')
788+
) {
789+
if (!opts.silent) {
790+
Log.error(
791+
"The requested resource isn't a valid image for",
792+
href,
793+
'received',
794+
upstreamType
723795
)
724796
}
725-
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
726-
if (!opts.silent) {
727-
Log.warnOnce(
728-
`The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
729-
)
730-
}
731-
return {
732-
buffer: upstreamBuffer,
733-
contentType: upstreamType,
734-
maxAge,
735-
etag: upstreamEtag,
736-
upstreamEtag,
737-
}
797+
throw new ImageError(400, "The requested resource isn't a valid image.")
798+
}
799+
if (
800+
upstreamType.startsWith('image/svg') &&
801+
!nextConfig.images.dangerouslyAllowSVG
802+
) {
803+
if (!opts.silent) {
804+
Log.error(
805+
`The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled. Consider adding the "unoptimized" property to the <Image>.`
806+
)
738807
}
739-
if (BYPASS_TYPES.includes(upstreamType)) {
740-
return {
741-
buffer: upstreamBuffer,
742-
contentType: upstreamType,
743-
maxAge,
744-
etag: upstreamEtag,
745-
upstreamEtag,
746-
}
808+
throw new ImageError(
809+
400,
810+
'"url" parameter is valid but image type is not allowed'
811+
)
812+
}
813+
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
814+
if (!opts.silent) {
815+
Log.warnOnce(
816+
`The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
817+
)
747818
}
748-
if (!upstreamType.startsWith('image/') || upstreamType.includes(',')) {
749-
if (!opts.silent) {
750-
Log.error(
751-
"The requested resource isn't a valid image for",
752-
href,
753-
'received',
754-
upstreamType
755-
)
756-
}
757-
throw new ImageError(400, "The requested resource isn't a valid image.")
819+
return {
820+
buffer: upstreamBuffer,
821+
contentType: upstreamType,
822+
maxAge,
823+
etag: upstreamEtag,
824+
upstreamEtag,
825+
}
826+
}
827+
if (BYPASS_TYPES.includes(upstreamType)) {
828+
return {
829+
buffer: upstreamBuffer,
830+
contentType: upstreamType,
831+
maxAge,
832+
etag: upstreamEtag,
833+
upstreamEtag,
758834
}
759835
}
760836

@@ -763,7 +839,6 @@ export async function imageOptimizer(
763839
if (mimeType) {
764840
contentType = mimeType
765841
} else if (
766-
upstreamType?.startsWith('image/') &&
767842
getExtension(upstreamType) &&
768843
upstreamType !== WEBP &&
769844
upstreamType !== AVIF

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import send from 'next/dist/compiled/send'
66
send.mime.define({
77
'image/avif': ['avif'],
88
'image/x-icns': ['icns'],
9+
'image/jxl': ['jxl'],
10+
'image/heic': ['heic'],
911
})
1012

1113
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: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -219,25 +219,52 @@ export function runTests(ctx: RunTestsCtx) {
219219
await expectWidth(res, 256)
220220
})
221221

222-
it('should maintain pic/pct', async () => {
223-
const query = { w: ctx.w, q: 90, url: '/test.pic' }
222+
it('should maintain jxl', async () => {
223+
const query = { w: ctx.w, q: 90, url: '/test.jxl' }
224224
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
225225
expect(res.status).toBe(200)
226-
expect(res.headers.get('Content-Type')).toContain('image/x-pict')
226+
expect(res.headers.get('Content-Type')).toContain('image/jxl')
227227
expect(res.headers.get('Cache-Control')).toBe(
228228
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
229229
)
230230
expect(res.headers.get('Vary')).toBe('Accept')
231231
expect(res.headers.get('etag')).toBeTruthy()
232232
expect(res.headers.get('Content-Disposition')).toBe(
233-
`${contentDispositionType}; filename="test.pic"`
233+
`${contentDispositionType}; filename="test.jxl"`
234234
)
235-
const actual = await res.text()
236-
const expected = await fs.readFile(
237-
join(ctx.appDir, 'public', 'test.pic'),
238-
'utf8'
235+
await expectWidth(res, 800)
236+
})
237+
238+
it('should maintain heic', async () => {
239+
const query = { w: ctx.w, q: 90, url: '/test.heic' }
240+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
241+
expect(res.status).toBe(200)
242+
expect(res.headers.get('Content-Type')).toContain('image/heic')
243+
expect(res.headers.get('Cache-Control')).toBe(
244+
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
239245
)
240-
expect(actual).toMatch(expected)
246+
expect(res.headers.get('Vary')).toBe('Accept')
247+
expect(res.headers.get('etag')).toBeTruthy()
248+
expect(res.headers.get('Content-Disposition')).toBe(
249+
`${contentDispositionType}; filename="test.heic"`
250+
)
251+
await expectWidth(res, 400)
252+
})
253+
254+
it('should maintain jp2', async () => {
255+
const query = { w: ctx.w, q: 90, url: '/test.jp2' }
256+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})
257+
expect(res.status).toBe(200)
258+
expect(res.headers.get('Content-Type')).toContain('image/jp2')
259+
expect(res.headers.get('Cache-Control')).toBe(
260+
`public, max-age=${isDev ? 0 : minimumCacheTTL}, must-revalidate`
261+
)
262+
expect(res.headers.get('Vary')).toBe('Accept')
263+
expect(res.headers.get('etag')).toBeTruthy()
264+
expect(res.headers.get('Content-Disposition')).toBe(
265+
`${contentDispositionType}; filename="test.jp2"`
266+
)
267+
await expectWidth(res, 1)
241268
})
242269

243270
it('should maintain animated gif', async () => {
@@ -332,12 +359,6 @@ export function runTests(ctx: RunTestsCtx) {
332359
'utf8'
333360
)
334361
expect(actual).toMatch(expected)
335-
expect(ctx.nextOutput).not.toContain(
336-
`The requested resource isn't a valid image`
337-
)
338-
expect(ctx.nextOutput).not.toContain(
339-
`valid but image type is not allowed`
340-
)
341362
})
342363
} else {
343364
it('should not allow vector svg', async () => {
@@ -374,7 +395,7 @@ export function runTests(ctx: RunTestsCtx) {
374395
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
375396
expect(res.status).toBe(400)
376397
expect(await res.text()).toContain(
377-
'"url" parameter is valid but image type is not allowed'
398+
"The requested resource isn't a valid image"
378399
)
379400
})
380401

@@ -389,6 +410,16 @@ export function runTests(ctx: RunTestsCtx) {
389410
})
390411
}
391412

413+
it('should not allow pdf format', async () => {
414+
const query = { w: ctx.w, q: 90, url: '/test.pdf' }
415+
const opts = { headers: { accept: 'image/webp' } }
416+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
417+
expect(res.status).toBe(400)
418+
expect(await res.text()).toContain(
419+
"The requested resource isn't a valid image"
420+
)
421+
})
422+
392423
it('should maintain ico format', async () => {
393424
const query = { w: ctx.w, q: 90, url: `/test.ico` }
394425
const opts = { headers: { accept: 'image/webp' } }
@@ -1085,8 +1116,8 @@ export function runTests(ctx: RunTestsCtx) {
10851116
const opts = { headers: { accept: 'image/webp' } }
10861117
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
10871118
expect(res.status).toBe(400)
1088-
expect(await res.text()).toBe(
1089-
`Unable to optimize image and unable to fallback to upstream image`
1119+
expect(await res.text()).toContain(
1120+
"The requested resource isn't a valid image"
10901121
)
10911122
})
10921123

Lines changed: 11 additions & 9 deletions
Loading

0 commit comments

Comments
 (0)