diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index b76859f575f06..411b136b2b22d 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -194,6 +194,7 @@ export async function imageOptimizer( const result = setResponseHeaders( req, res, + url, etag, maxAge, contentType, @@ -310,6 +311,7 @@ export async function imageOptimizer( sendResponse( req, res, + url, maxAge, upstreamType, upstreamBuffer, @@ -430,6 +432,7 @@ export async function imageOptimizer( sendResponse( req, res, + url, maxAge, contentType, optimizedBuffer, @@ -443,6 +446,7 @@ export async function imageOptimizer( sendResponse( req, res, + url, maxAge, upstreamType, upstreamBuffer, @@ -473,9 +477,25 @@ async function writeToCacheDir( await promises.writeFile(filename, buffer) } +function getFileNameWithExtension( + url: string, + contentType: string | null +): string | void { + const [urlWithoutQueryParams] = url.split('?') + const fileNameWithExtension = urlWithoutQueryParams.split('/').pop() + if (!contentType || !fileNameWithExtension) { + return + } + + const [fileName] = fileNameWithExtension.split('.') + const extension = getExtension(contentType) + return `${fileName}.${extension}` +} + function setResponseHeaders( req: IncomingMessage, res: ServerResponse, + url: string, etag: string, maxAge: number, contentType: string | null, @@ -496,12 +516,19 @@ function setResponseHeaders( if (contentType) { res.setHeader('Content-Type', contentType) } + + const fileName = getFileNameWithExtension(url, contentType) + if (fileName) { + res.setHeader('Content-Disposition', `inline; filename="${fileName}"`) + } + return { finished: false } } function sendResponse( req: IncomingMessage, res: ServerResponse, + url: string, maxAge: number, contentType: string | null, buffer: Buffer, @@ -512,6 +539,7 @@ function sendResponse( const result = setResponseHeaders( req, res, + url, etag, maxAge, contentType, diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 9bde62dc650f2..80731b6b7305f 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -65,6 +65,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="animated.gif"` + ) expect(isAnimated(await res.buffer())).toBe(true) }) @@ -78,6 +81,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="animated.png"` + ) expect(isAnimated(await res.buffer())).toBe(true) }) @@ -91,6 +97,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="animated.webp"` + ) expect(isAnimated(await res.buffer())).toBe(true) }) @@ -107,6 +116,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { // compression expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/) expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.svg"` + ) const actual = await res.text() const expected = await fs.readFile( join(appDir, 'public', 'test.svg'), @@ -116,7 +128,7 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { }) it('should maintain ico format', async () => { - const query = { w, q: 90, url: '/test.ico' } + const query = { w, q: 90, url: `/test.ico` } const opts = { headers: { accept: 'image/webp' } } const res = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res.status).toBe(200) @@ -126,6 +138,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/) expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.ico"` + ) const actual = await res.text() const expected = await fs.readFile( join(appDir, 'public', 'test.ico'), @@ -147,6 +162,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.jpeg"` + ) }) it('should maintain png format for old Safari', async () => { @@ -162,6 +180,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.png"` + ) }) it('should fail when url is missing', async () => { @@ -260,6 +281,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) await expectWidth(res, w) }) @@ -274,6 +298,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.png"` + ) await expectWidth(res, w) }) @@ -288,6 +315,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.png"` + ) await expectWidth(res, w) }) @@ -302,6 +332,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.gif"` + ) // FIXME: await expectWidth(res, w) }) @@ -316,6 +349,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.tiff"` + ) // FIXME: await expectWidth(res, w) }) @@ -332,6 +368,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) await expectWidth(res, w) }) @@ -348,6 +387,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) await expectWidth(res, w) }) @@ -369,6 +411,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="png-as-octet-stream.webp"` + ) await expectWidth(res, w) }) } @@ -412,12 +457,18 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res1.status).toBe(200) expect(res1.headers.get('Content-Type')).toBe('image/webp') + expect(res1.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) const json1 = await fsToJson(imagesDir) expect(Object.keys(json1).length).toBe(1) const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) expect(res2.headers.get('Content-Type')).toBe('image/webp') + expect(res2.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) const json2 = await fsToJson(imagesDir) expect(json2).toStrictEqual(json1) @@ -427,6 +478,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { const res3 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res3.status).toBe(200) expect(res3.headers.get('Content-Type')).toBe('image/webp') + expect(res3.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) const json3 = await fsToJson(imagesDir) expect(json3).not.toStrictEqual(json1) expect(Object.keys(json3).length).toBe(1) @@ -442,12 +496,18 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res1.status).toBe(200) expect(res1.headers.get('Content-Type')).toBe('image/svg+xml') + expect(res1.headers.get('Content-Disposition')).toBe( + `inline; filename="test.svg"` + ) const json1 = await fsToJson(imagesDir) expect(Object.keys(json1).length).toBe(1) const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) expect(res2.headers.get('Content-Type')).toBe('image/svg+xml') + expect(res2.headers.get('Content-Disposition')).toBe( + `inline; filename="test.svg"` + ) const json2 = await fsToJson(imagesDir) expect(json2).toStrictEqual(json1) }) @@ -461,12 +521,18 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { const res1 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res1.status).toBe(200) expect(res1.headers.get('Content-Type')).toBe('image/gif') + expect(res1.headers.get('Content-Disposition')).toBe( + `inline; filename="animated.gif"` + ) const json1 = await fsToJson(imagesDir) expect(Object.keys(json1).length).toBe(1) const res2 = await fetchViaHTTP(appPort, '/_next/image', query, opts) expect(res2.status).toBe(200) expect(res2.headers.get('Content-Type')).toBe('image/gif') + expect(res2.headers.get('Content-Disposition')).toBe( + `inline; filename="animated.gif"` + ) const json2 = await fsToJson(imagesDir) expect(json2).toStrictEqual(json1) }) @@ -484,6 +550,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { expect(res1.headers.get('Vary')).toBe('Accept') const etag = res1.headers.get('Etag') expect(etag).toBeTruthy() + expect(res1.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) await expectWidth(res1, w) const opts2 = { headers: { accept: 'image/webp', 'if-none-match': etag } } @@ -495,6 +564,7 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { `public, max-age=0, must-revalidate` ) expect(res2.headers.get('Vary')).toBe('Accept') + expect(res2.headers.get('Content-Disposition')).toBeFalsy() expect((await res2.buffer()).length).toBe(0) const query3 = { url: '/test.jpg', w, q: 25 } @@ -507,6 +577,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { expect(res3.headers.get('Vary')).toBe('Accept') expect(res3.headers.get('Etag')).toBeTruthy() expect(res3.headers.get('Etag')).not.toBe(etag) + expect(res3.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) await expectWidth(res3, w) }) @@ -526,6 +599,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { // compression expect(res.headers.get('Vary')).toMatch(/^Accept(,|$)/) expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.bmp"` + ) const json2 = await fsToJson(imagesDir) expect(json2).toStrictEqual(json1) @@ -542,6 +618,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { ) expect(res.headers.get('Vary')).toBe('Accept') expect(res.headers.get('etag')).toBeTruthy() + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) await expectWidth(res, 400) }) @@ -560,6 +639,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { `public, max-age=0, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="grayscale.png"` + ) const png = await res.buffer() @@ -572,9 +654,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { it('should set cache-control to immutable for static images', async () => { if (!isDev) { + const filename = 'test' const query = { - url: - '/_next/static/image/public/test.480a01e5ea850d0231aec0fa94bd23a0.jpg', + url: `/_next/static/image/public/${filename}.480a01e5ea850d0231aec0fa94bd23a0.jpg`, w, q: 100, } @@ -586,6 +668,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { 'public, max-age=315360000, immutable' ) expect(res1.headers.get('Vary')).toBe('Accept') + expect(res1.headers.get('Content-Disposition')).toBe( + `inline; filename="${filename}.webp"` + ) await expectWidth(res1, w) // Ensure subsequent request also has immutable header @@ -595,6 +680,9 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { 'public, max-age=315360000, immutable' ) expect(res2.headers.get('Vary')).toBe('Accept') + expect(res2.headers.get('Content-Disposition')).toBe( + `inline; filename="${filename}.webp"` + ) await expectWidth(res2, w) } }) @@ -618,7 +706,13 @@ function runTests({ w, isDev, domains = [], ttl, isSharp }) { expect(res1.status).toBe(200) expect(res2.status).toBe(200) expect(res1.headers.get('Content-Type')).toBe('image/webp') + expect(res1.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) expect(res2.headers.get('Content-Type')).toBe('image/webp') + expect(res2.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) await expectWidth(res1, w) await expectWidth(res2, w) @@ -867,6 +961,9 @@ describe('Image Optimizer', () => { expect(res.headers.get('Cache-Control')).toBe( `public, max-age=86400, must-revalidate` ) + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) }) it('should not set max-age header when not matching next.config.js', async () => { @@ -877,6 +974,9 @@ describe('Image Optimizer', () => { expect(res.headers.get('Cache-Control')).toBe( `public, max-age=0, must-revalidate` ) + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="test.webp"` + ) }) }) @@ -939,6 +1039,9 @@ describe('Image Optimizer', () => { `public, max-age=31536000, must-revalidate` ) expect(res.headers.get('Vary')).toBe('Accept') + expect(res.headers.get('Content-Disposition')).toBe( + `inline; filename="next-js-bg.webp"` + ) await expectWidth(res, 64) }) })