Skip to content

Commit ed2a6c7

Browse files
ztannerstyfle
andauthored
[backport]: fix(next/image): improve and simplify detect-content-type (#82118) (#82174)
Backports: - #82118 Co-authored-by: Steven <[email protected]>
1 parent f00fcc9 commit ed2a6c7

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 {
@@ -702,58 +778,58 @@ export async function imageOptimizer(
702778
getMaxAge(imageUpstream.cacheControl)
703779
)
704780

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

@@ -762,7 +838,6 @@ export async function imageOptimizer(
762838
if (mimeType) {
763839
contentType = mimeType
764840
} else if (
765-
upstreamType?.startsWith('image/') &&
766841
getExtension(upstreamType) &&
767842
upstreamType !== WEBP &&
768843
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 () => {
@@ -339,12 +366,6 @@ export function runTests(ctx: RunTestsCtx) {
339366
'utf8'
340367
)
341368
expect(actual).toMatch(expected)
342-
expect(ctx.nextOutput).not.toContain(
343-
`The requested resource isn't a valid image`
344-
)
345-
expect(ctx.nextOutput).not.toContain(
346-
`valid but image type is not allowed`
347-
)
348369
})
349370
} else {
350371
it('should not allow vector svg', async () => {
@@ -381,7 +402,7 @@ export function runTests(ctx: RunTestsCtx) {
381402
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
382403
expect(res.status).toBe(400)
383404
expect(await res.text()).toContain(
384-
'"url" parameter is valid but image type is not allowed'
405+
"The requested resource isn't a valid image"
385406
)
386407
})
387408

@@ -396,6 +417,16 @@ export function runTests(ctx: RunTestsCtx) {
396417
})
397418
}
398419

420+
it('should not allow pdf format', async () => {
421+
const query = { w: ctx.w, q: 90, url: '/test.pdf' }
422+
const opts = { headers: { accept: 'image/webp' } }
423+
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
424+
expect(res.status).toBe(400)
425+
expect(await res.text()).toContain(
426+
"The requested resource isn't a valid image"
427+
)
428+
})
429+
399430
it('should maintain ico format', async () => {
400431
const query = { w: ctx.w, q: 90, url: `/test.ico` }
401432
const opts = { headers: { accept: 'image/webp' } }
@@ -1092,8 +1123,8 @@ export function runTests(ctx: RunTestsCtx) {
10921123
const opts = { headers: { accept: 'image/webp' } }
10931124
const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, opts)
10941125
expect(res.status).toBe(400)
1095-
expect(await res.text()).toBe(
1096-
`Unable to optimize image and unable to fallback to upstream image`
1126+
expect(await res.text()).toContain(
1127+
"The requested resource isn't a valid image"
10971128
)
10981129
})
10991130

Lines changed: 11 additions & 9 deletions
Loading

0 commit comments

Comments
 (0)