From 184d1dd1d9b1729ad6c8e1bf8a83807cf8ef6577 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Apr 2022 11:47:15 -0400 Subject: [PATCH 01/33] Add support for wildcard `images.remotePatterns` config --- packages/next/server/image-optimizer.ts | 68 +++- packages/next/shared/lib/image-config.ts | 31 ++ .../match-remote-pattern.test.ts | 383 ++++++++++++++++++ 3 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 test/unit/image-optimizer/match-remote-pattern.test.ts diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 8a84ce7a8bb5..bb392dcdd172 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -10,6 +10,7 @@ import contentDisposition from 'next/dist/compiled/content-disposition' import { join } from 'path' import nodeUrl, { UrlWithParsedQuery } from 'url' import { NextConfigComplete } from './config-shared' +import type { RemotePattern } from '../shared/lib/image-config' import { processBuffer, decodeBuffer, Operation } from './lib/squoosh/main' import { sendEtagResponse } from './send-payload' import { getContentType, getExtension } from './serve-static' @@ -72,6 +73,7 @@ export class ImageOptimizerCache { deviceSizes = [], imageSizes = [], domains = [], + remotePatterns = [], minimumCacheTTL = 60, formats = ['image/webp'], } = imageData @@ -104,7 +106,15 @@ export class ImageOptimizerCache { return { errorMessage: '"url" parameter is invalid' } } - if (!domains || !domains.includes(hrefParsed.hostname)) { + if (!domains && !remotePatterns) { + return { errorMessage: '"url" parameter is not allowed' } + } + + if (!domains.includes(hrefParsed.hostname)) { + return { errorMessage: '"url" parameter is not allowed' } + } + + if (remotePatterns.some((p) => !matchRemotePattern(p, hrefParsed))) { return { errorMessage: '"url" parameter is not allowed' } } } @@ -788,6 +798,62 @@ export async function getImageSize( return { width, height } } +export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { + if (pattern.protocol !== undefined) { + const actualProto = url.protocol.slice(0, -1) + if (pattern.protocol !== actualProto) { + return false + } + } + if (pattern.port !== undefined) { + if (pattern.port !== url.port) { + return false + } + } + if (pattern.pathname !== undefined) { + const patternParts = pattern.pathname.split('/') + const actualParts = url.pathname.split('/') + const len = Math.max(patternParts.length, actualParts.length) + for (let i = 0; i < len; i++) { + if (patternParts[i] === '**' && actualParts[i] !== undefined) { + // Double asterisk means "match everything until the end of the path" + // so we can break the loop early + break + } + if (patternParts[i] === '*') { + // Single asterisk means "match this part" so we can + // continue to the next part of the loop + continue + } + if (patternParts[i] !== actualParts[i]) { + return false + } + } + } + + if (pattern.hostname !== undefined) { + const patternParts = pattern.hostname.split('.').reverse() + const actualParts = url.hostname.split('.').reverse() + const len = Math.max(patternParts.length, actualParts.length) + for (let i = 0; i < len; i++) { + if (patternParts[i] === '**' && actualParts[i] !== undefined) { + // Double asterisk means "match every subdomain" + // so we can break the loop early + break + } + if (patternParts[i] === '*') { + // Single asterisk means "match this subdomain" so we can + // continue to the next part of the loop + continue + } + if (patternParts[i] !== actualParts[i]) { + return false + } + } + } + return true +} + export class Deferred { promise: Promise resolve!: (value: T) => void diff --git a/packages/next/shared/lib/image-config.ts b/packages/next/shared/lib/image-config.ts index 001745447c1a..0ea1df892c32 100644 --- a/packages/next/shared/lib/image-config.ts +++ b/packages/next/shared/lib/image-config.ts @@ -8,6 +8,33 @@ export const VALID_LOADERS = [ export type LoaderValue = typeof VALID_LOADERS[number] +export type RemotePattern = { + /** + * Must be `http` or `https`. + */ + protocol?: 'http' | 'https' + + /** + * Can be literal or wildcard. + * Single `*` matches a single subdomain. + * Double `**` matches any number of subdomains. + */ + hostname: string + + /** + * Can be literal port such as `8080` or empty string + * meaning no port. + */ + port?: string + + /** + * Can be literal or wildcard. + * Single `*` matches a single path segment. + * Double `**` matches any number of path segments. + */ + pathname?: string +} + type ImageFormat = 'image/avif' | 'image/webp' /** @@ -31,6 +58,9 @@ export type ImageConfigComplete = { /** @see [Image domains configuration](https://nextjs.org/docs/basic-features/image-optimization#domains) */ domains: string[] + /** @see [Remote Images](https://nextjs.org/docs/basic-features/image-optimization#remote-images) */ + remotePatterns: RemotePattern[] + /** @see [Cache behavior](https://nextjs.org/docs/api-reference/next/image#caching-behavior) */ disableStaticImages: boolean @@ -55,6 +85,7 @@ export const imageConfigDefault: ImageConfigComplete = { path: '/_next/image', loader: 'default', domains: [], + remotePatterns: [], disableStaticImages: false, minimumCacheTTL: 60, formats: ['image/webp'], diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts new file mode 100644 index 000000000000..886fe61336a6 --- /dev/null +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -0,0 +1,383 @@ +/* eslint-env jest */ +import { matchRemotePattern } from 'next/dist/server/image-optimizer' + +describe('matchRemotePattern', () => { + it('should match literal hostname', () => { + const p = { hostname: 'example.com' } + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.net'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com/path'))).toBe( + true + ) + expect(matchRemotePattern(p, new URL('https://example.com/path/to'))).toBe( + true + ) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://example.com:8080/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com:8080/path/to/file?q=1') + ) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('http://example.com:8080/path/to/file')) + ).toBe(true) + }) + + it('should match literal protocol and hostname', () => { + const p = { protocol: 'https', hostname: 'example.com' } + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com/path/to'))).toBe( + true + ) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://example.com:8080/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com:8080/path/to/file?q=1') + ) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('http://example.com:8080/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('ftp://example.com:8080/path/to/file')) + ).toBe(false) + }) + + it('should match literal protocol, hostname, no port', () => { + const p = { protocol: 'https', hostname: 'example.com', port: '' } + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file?q=1')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('http://example.com/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('ftp://example.com/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com:8080/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern( + p, + new URL('https://example.com:8080/path/to/file?q=1') + ) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('http://example.com:8080/path/to/file')) + ).toBe(false) + }) + + it('should match literal protocol, hostname, port 5000', () => { + const p = { protocol: 'https', hostname: 'example.com', port: '5000' } + expect(matchRemotePattern(p, new URL('https://example.com:5000'))).toBe( + true + ) + expect(matchRemotePattern(p, new URL('https://example.com.uk:5000'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://sub.example.com:5000'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://com:5000'))).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com:5000/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com:5000/path/to/file?q=1') + ) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('http://example.com:5000/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('ftp://example.com:5000/path/to/file')) + ).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file?q=1')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('http://example.com/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('ftp://example.com/path/to/file')) + ).toBe(false) + }) + + it('should match literal protocol, hostname, port, pathname', () => { + const p = { + protocol: 'https', + hostname: 'example.com', + port: '5000', + pathname: '/path/to/file', + } + expect(matchRemotePattern(p, new URL('https://example.com:5000'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://example.com.uk:5000'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://sub.example.com:5000'))).toBe( + false + ) + expect( + matchRemotePattern(p, new URL('https://example.com:5000/path')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com:5000/path/to')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com:5000/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com:5000/path/to/file')) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com:5000/path/to/file?q=1') + ) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('http://example.com:5000/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('ftp://example.com:5000/path/to/file')) + ).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://example.com/path'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://example.com/path/to'))).toBe( + false + ) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com/path/to/file?q=1')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('http://example.com/path/to/file')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('ftp://example.com/path/to/file')) + ).toBe(false) + }) + + it('should match hostname pattern with single asterisk', () => { + const p = { hostname: 'avatars.*.example.com' } + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com.uk'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://avatars.example.com'))).toBe( + false + ) + expect( + matchRemotePattern(p, new URL('https://avatars.sfo1.example.com')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://avatars.iad1.example.com')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://more.avatars.iad1.example.com')) + ).toBe(false) + }) + + it('should match hostname pattern with double asterisk', () => { + const p = { hostname: '**.example.com' } + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe(true) + expect(matchRemotePattern(p, new URL('https://deep.sub.example.com'))).toBe( + true + ) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com.uk'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://avatars.example.com'))).toBe( + true + ) + expect( + matchRemotePattern(p, new URL('https://avatars.sfo1.example.com')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://avatars.iad1.example.com')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://more.avatars.iad1.example.com')) + ).toBe(true) + }) + + it('should match pathname pattern with single asterisk', () => { + const p = { hostname: 'example.com', pathname: '/act123/*/avatar.jpg' } + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com/act123'))).toBe( + false + ) + expect( + matchRemotePattern(p, new URL('https://example.com/act123/usr4')) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com/act123/usr4/avatar')) + ).toBe(false) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr4/avatarsjpg') + ) + ).toBe(false) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr4/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr5/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr6/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/team/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act456/team/avatar.jpg') + ) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com/team/avatar.jpg')) + ).toBe(false) + }) + + it('should match pathname pattern with double asterisk', () => { + const p = { hostname: 'example.com', pathname: '/act123/**' } + expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( + false + ) + expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) + expect(matchRemotePattern(p, new URL('https://example.com/act123'))).toBe( + false + ) + expect( + matchRemotePattern(p, new URL('https://example.com/act123/usr4')) + ).toBe(true) + expect( + matchRemotePattern(p, new URL('https://example.com/act123/usr4/avatar')) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr4/avatarsjpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr4/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr5/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/usr6/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act123/team/avatar.jpg') + ) + ).toBe(true) + expect( + matchRemotePattern( + p, + new URL('https://example.com/act456/team/avatar.jpg') + ) + ).toBe(false) + expect( + matchRemotePattern(p, new URL('https://example.com/team/avatar.jpg')) + ).toBe(false) + }) +}) From 8d66c2c46acd787c125d592e467b9910cb69040d Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Apr 2022 12:37:27 -0400 Subject: [PATCH 02/33] Fix lint --- .../match-remote-pattern.test.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 886fe61336a6..9335c2d81088 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -3,7 +3,7 @@ import { matchRemotePattern } from 'next/dist/server/image-optimizer' describe('matchRemotePattern', () => { it('should match literal hostname', () => { - const p = { hostname: 'example.com' } + const p = { hostname: 'example.com' } as const expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) expect(matchRemotePattern(p, new URL('https://example.net'))).toBe(false) @@ -35,7 +35,7 @@ describe('matchRemotePattern', () => { }) it('should match literal protocol and hostname', () => { - const p = { protocol: 'https', hostname: 'example.com' } + const p = { protocol: 'https', hostname: 'example.com' } as const expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( @@ -69,7 +69,7 @@ describe('matchRemotePattern', () => { }) it('should match literal protocol, hostname, no port', () => { - const p = { protocol: 'https', hostname: 'example.com', port: '' } + const p = { protocol: 'https', hostname: 'example.com', port: '' } as const expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( @@ -103,7 +103,11 @@ describe('matchRemotePattern', () => { }) it('should match literal protocol, hostname, port 5000', () => { - const p = { protocol: 'https', hostname: 'example.com', port: '5000' } + const p = { + protocol: 'https', + hostname: 'example.com', + port: '5000', + } as const expect(matchRemotePattern(p, new URL('https://example.com:5000'))).toBe( true ) @@ -155,7 +159,7 @@ describe('matchRemotePattern', () => { hostname: 'example.com', port: '5000', pathname: '/path/to/file', - } + } as const expect(matchRemotePattern(p, new URL('https://example.com:5000'))).toBe( false ) @@ -215,7 +219,7 @@ describe('matchRemotePattern', () => { }) it('should match hostname pattern with single asterisk', () => { - const p = { hostname: 'avatars.*.example.com' } + const p = { hostname: 'avatars.*.example.com' } as const expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( @@ -240,7 +244,7 @@ describe('matchRemotePattern', () => { }) it('should match hostname pattern with double asterisk', () => { - const p = { hostname: '**.example.com' } + const p = { hostname: '**.example.com' } as const expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe(true) @@ -266,7 +270,10 @@ describe('matchRemotePattern', () => { }) it('should match pathname pattern with single asterisk', () => { - const p = { hostname: 'example.com', pathname: '/act123/*/avatar.jpg' } + const p = { + hostname: 'example.com', + pathname: '/act123/*/avatar.jpg', + } as const expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( @@ -324,7 +331,7 @@ describe('matchRemotePattern', () => { }) it('should match pathname pattern with double asterisk', () => { - const p = { hostname: 'example.com', pathname: '/act123/**' } + const p = { hostname: 'example.com', pathname: '/act123/**' } as const expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( From 5bdae633ea3170ca057a8aafff7ddd1fdcda9d9f Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Apr 2022 12:45:19 -0400 Subject: [PATCH 03/33] Run lint --- .../match-remote-pattern.test.ts | 463 +++++------------- 1 file changed, 120 insertions(+), 343 deletions(-) diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 9335c2d81088..311fa0b042ab 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -1,390 +1,167 @@ /* eslint-env jest */ -import { matchRemotePattern } from 'next/dist/server/image-optimizer' +import { matchRemotePattern as m } from 'next/dist/server/image-optimizer' describe('matchRemotePattern', () => { it('should match literal hostname', () => { const p = { hostname: 'example.com' } as const - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.net'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com/path'))).toBe( - true - ) - expect(matchRemotePattern(p, new URL('https://example.com/path/to'))).toBe( - true - ) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://example.com:8080/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com:8080/path/to/file?q=1') - ) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('http://example.com:8080/path/to/file')) - ).toBe(true) + expect(m(p, new URL('https://example.com'))).toBe(true) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://example.net'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com/path'))).toBe(true) + expect(m(p, new URL('https://example.com/path/to'))).toBe(true) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com:81/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com:81/path/to/file?q=1'))).toBe(true) + expect(m(p, new URL('http://example.com:81/path/to/file'))).toBe(true) }) it('should match literal protocol and hostname', () => { const p = { protocol: 'https', hostname: 'example.com' } as const - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com/path/to'))).toBe( - true - ) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://example.com:8080/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com:8080/path/to/file?q=1') - ) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('http://example.com:8080/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('ftp://example.com:8080/path/to/file')) - ).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(true) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to'))).toBe(true) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com:81/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com:81/path/to/file?q=1'))).toBe(true) + expect(m(p, new URL('http://example.com:81/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com:81/path/to/file'))).toBe(false) }) it('should match literal protocol, hostname, no port', () => { const p = { protocol: 'https', hostname: 'example.com', port: '' } as const - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(true) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file?q=1')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('http://example.com/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('ftp://example.com/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com:8080/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern( - p, - new URL('https://example.com:8080/path/to/file?q=1') - ) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('http://example.com:8080/path/to/file')) - ).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(true) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com/path/to/file?q=1'))).toBe(true) + expect(m(p, new URL('http://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com:81/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com:81/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('http://example.com:81/path/to/file'))).toBe(false) }) - it('should match literal protocol, hostname, port 5000', () => { + it('should match literal protocol, hostname, port 42', () => { const p = { protocol: 'https', hostname: 'example.com', - port: '5000', + port: '42', } as const - expect(matchRemotePattern(p, new URL('https://example.com:5000'))).toBe( - true - ) - expect(matchRemotePattern(p, new URL('https://example.com.uk:5000'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://sub.example.com:5000'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://com:5000'))).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com:5000/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com:5000/path/to/file?q=1') - ) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('http://example.com:5000/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('ftp://example.com:5000/path/to/file')) - ).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file?q=1')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('http://example.com/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('ftp://example.com/path/to/file')) - ).toBe(false) + expect(m(p, new URL('https://example.com:42'))).toBe(true) + expect(m(p, new URL('https://example.com.uk:42'))).toBe(false) + expect(m(p, new URL('https://sub.example.com:42'))).toBe(false) + expect(m(p, new URL('https://com:42'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com:42/path/to/file?q=1'))).toBe(true) + expect(m(p, new URL('http://example.com:42/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com:42/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(false) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('http://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com/path/to/file'))).toBe(false) }) it('should match literal protocol, hostname, port, pathname', () => { const p = { protocol: 'https', hostname: 'example.com', - port: '5000', + port: '42', pathname: '/path/to/file', } as const - expect(matchRemotePattern(p, new URL('https://example.com:5000'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://example.com.uk:5000'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://sub.example.com:5000'))).toBe( - false - ) - expect( - matchRemotePattern(p, new URL('https://example.com:5000/path')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com:5000/path/to')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com:5000/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com:5000/path/to/file')) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com:5000/path/to/file?q=1') - ) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('http://example.com:5000/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('ftp://example.com:5000/path/to/file')) - ).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://example.com/path'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://example.com/path/to'))).toBe( - false - ) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com/path/to/file?q=1')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('http://example.com/path/to/file')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('ftp://example.com/path/to/file')) - ).toBe(false) + expect(m(p, new URL('https://example.com:42'))).toBe(false) + expect(m(p, new URL('https://example.com.uk:42'))).toBe(false) + expect(m(p, new URL('https://sub.example.com:42'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path/to'))).toBe(false) + expect(m(p, new URL('https://example.com:42/file'))).toBe(false) + expect(m(p, new URL('https://example.com:42/path/to/file'))).toBe(true) + expect(m(p, new URL('https://example.com:42/path/to/file?q=1'))).toBe(true) + expect(m(p, new URL('http://example.com:42/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com:42/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(false) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://example.com/path'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('https://example.com/path/to/file?q=1'))).toBe(false) + expect(m(p, new URL('http://example.com/path/to/file'))).toBe(false) + expect(m(p, new URL('ftp://example.com/path/to/file'))).toBe(false) }) it('should match hostname pattern with single asterisk', () => { const p = { hostname: 'avatars.*.example.com' } as const - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com.uk'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://avatars.example.com'))).toBe( - false - ) - expect( - matchRemotePattern(p, new URL('https://avatars.sfo1.example.com')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://avatars.iad1.example.com')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://more.avatars.iad1.example.com')) - ).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com.uk'))).toBe(false) + expect(m(p, new URL('https://avatars.example.com'))).toBe(false) + expect(m(p, new URL('https://avatars.sfo1.example.com'))).toBe(true) + expect(m(p, new URL('https://avatars.iad1.example.com'))).toBe(true) + expect(m(p, new URL('https://more.avatars.iad1.example.com'))).toBe(false) }) it('should match hostname pattern with double asterisk', () => { const p = { hostname: '**.example.com' } as const - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe(true) - expect(matchRemotePattern(p, new URL('https://deep.sub.example.com'))).toBe( - true - ) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com.uk'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://avatars.example.com'))).toBe( - true - ) - expect( - matchRemotePattern(p, new URL('https://avatars.sfo1.example.com')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://avatars.iad1.example.com')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://more.avatars.iad1.example.com')) - ).toBe(true) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(true) + expect(m(p, new URL('https://deep.sub.example.com'))).toBe(true) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://sub.example.com.uk'))).toBe(false) + expect(m(p, new URL('https://avatars.example.com'))).toBe(true) + expect(m(p, new URL('https://avatars.sfo1.example.com'))).toBe(true) + expect(m(p, new URL('https://avatars.iad1.example.com'))).toBe(true) + expect(m(p, new URL('https://more.avatars.iad1.example.com'))).toBe(true) }) it('should match pathname pattern with single asterisk', () => { const p = { hostname: 'example.com', - pathname: '/act123/*/avatar.jpg', + pathname: '/act123/*/pic.jpg', } as const - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com/act123'))).toBe( - false - ) - expect( - matchRemotePattern(p, new URL('https://example.com/act123/usr4')) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com/act123/usr4/avatar')) - ).toBe(false) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr4/avatarsjpg') - ) - ).toBe(false) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr4/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr5/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr6/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/team/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act456/team/avatar.jpg') - ) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com/team/avatar.jpg')) - ).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://example.com/act123'))).toBe(false) + expect(m(p, new URL('https://example.com/act123/usr4'))).toBe(false) + expect(m(p, new URL('https://example.com/act123/usr4/pic'))).toBe(false) + expect(m(p, new URL('https://example.com/act123/usr4/picsjpg'))).toBe(false) + expect(m(p, new URL('https://example.com/act123/usr4/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/usr5/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/usr6/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/team/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act456/team/pic.jpg'))).toBe(false) + expect(m(p, new URL('https://example.com/team/pic.jpg'))).toBe(false) }) it('should match pathname pattern with double asterisk', () => { const p = { hostname: 'example.com', pathname: '/act123/**' } as const - expect(matchRemotePattern(p, new URL('https://com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://sub.example.com'))).toBe( - false - ) - expect(matchRemotePattern(p, new URL('https://example.com.uk'))).toBe(false) - expect(matchRemotePattern(p, new URL('https://example.com/act123'))).toBe( - false - ) - expect( - matchRemotePattern(p, new URL('https://example.com/act123/usr4')) - ).toBe(true) - expect( - matchRemotePattern(p, new URL('https://example.com/act123/usr4/avatar')) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr4/avatarsjpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr4/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr5/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/usr6/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act123/team/avatar.jpg') - ) - ).toBe(true) - expect( - matchRemotePattern( - p, - new URL('https://example.com/act456/team/avatar.jpg') - ) - ).toBe(false) - expect( - matchRemotePattern(p, new URL('https://example.com/team/avatar.jpg')) - ).toBe(false) + expect(m(p, new URL('https://com'))).toBe(false) + expect(m(p, new URL('https://example.com'))).toBe(false) + expect(m(p, new URL('https://sub.example.com'))).toBe(false) + expect(m(p, new URL('https://example.com.uk'))).toBe(false) + expect(m(p, new URL('https://example.com/act123'))).toBe(false) + expect(m(p, new URL('https://example.com/act123/usr4'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/usr4/pic'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/usr4/picsjpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/usr4/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/usr5/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/usr6/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act123/team/pic.jpg'))).toBe(true) + expect(m(p, new URL('https://example.com/act456/team/pic.jpg'))).toBe(false) + expect(m(p, new URL('https://example.com/team/pic.jpg'))).toBe(false) }) }) From b80be6e4693b9b7becdcbcde018a32bc7c40ecae Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Apr 2022 18:35:55 -0400 Subject: [PATCH 04/33] Add test and telemetry --- errors/invalid-images-config.md | 2 + errors/next-image-unconfigured-host.md | 21 +++++++- packages/next/build/webpack-config.ts | 1 + packages/next/client/image.tsx | 21 ++++---- packages/next/server/config.ts | 46 ++++++++++++++++ packages/next/shared/lib/image-config.ts | 5 +- packages/next/telemetry/events/version.ts | 5 ++ .../image-from-node-modules/next.config.js | 2 +- .../image-component/unicode/next.config.js | 2 +- .../image-optimizer/test/index.test.js | 52 +++++++++++++++++++ test/integration/image-optimizer/test/util.js | 28 +++++----- .../telemetry/next.config.i18n-images | 3 +- test/integration/telemetry/test/index.test.js | 3 +- 13 files changed, 161 insertions(+), 30 deletions(-) diff --git a/errors/invalid-images-config.md b/errors/invalid-images-config.md index b409f6056830..8a03674a31df 100644 --- a/errors/invalid-images-config.md +++ b/errors/invalid-images-config.md @@ -17,6 +17,8 @@ module.exports = { imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // limit of 50 domains values domains: [], + // limit of 50 objects + remotePatterns: [], // path prefix for Image Optimization API, useful with `loader` path: '/_next/image', // loader can be 'default', 'imgix', 'cloudinary', 'akamai', or 'custom' diff --git a/errors/next-image-unconfigured-host.md b/errors/next-image-unconfigured-host.md index e69525ad03d2..a9aa17f51723 100644 --- a/errors/next-image-unconfigured-host.md +++ b/errors/next-image-unconfigured-host.md @@ -2,11 +2,28 @@ #### Why This Error Occurred -On one of your pages that leverages the `next/image` component, you passed a `src` value that uses a hostname in the URL that isn't defined in the `images.domains` config in `next.config.js`. +On one of your pages that leverages the `next/image` component, you passed a `src` value that uses a hostname in the URL that isn't defined in the `images.remotePatterns` or `images.domains` config in `next.config.js`. #### Possible Ways to Fix It -Add the hostname of your URL to the `images.domains` config in `next.config.js`: +For Next.js 12.2.0 or newer, add the hostname of your URL to the `images.remotePatterns` config in `next.config.js`: + +```js +// next.config.js +module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'assets.example.com', + port: '', + }, + ], + }, +} +``` + +For older versions of Next.js, add the hostname of your URL to the `images.domains` config in `next.config.js`: ```js // next.config.js diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 488c64fa5ea7..62d6e84ad95c 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1377,6 +1377,7 @@ export default async function getBaseWebpackConfig( ? { // pass domains in development to allow validating on the client domains: config.images.domains, + remotePatterns: config.images.remotePatterns, } : {}), }), diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 6a793cdae1cd..8d484f2fcbd1 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -1059,7 +1059,7 @@ function defaultLoader({ ) } - if (!src.startsWith('/') && config.domains) { + if (!src.startsWith('/') && (config.domains || config.remotePatterns)) { let parsedSrc: URL try { parsedSrc = new URL(src) @@ -1070,14 +1070,17 @@ function defaultLoader({ ) } - if ( - process.env.NODE_ENV !== 'test' && - !config.domains.includes(parsedSrc.hostname) - ) { - throw new Error( - `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + - `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` - ) + if (process.env.NODE_ENV === 'development') { + const domains = config.remotePatterns + .map((p) => p.hostname) + .concat(config.domains) + // TODO: can we utilize the backend to get the error message, perhaps from onError()? + if (!domains.includes(parsedSrc.hostname)) { + throw new Error( + `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` + ) + } } } } diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 6a049172c5ae..a8a423f219f0 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -235,6 +235,52 @@ function assignDefaults(userConfig: { [key: string]: any }) { ) } } + + if (images.remotePatterns) { + if (!Array.isArray(images.remotePatterns)) { + throw new Error( + `Specified images.remotePatterns should be an Array received ${typeof images.remotePatterns}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + + // static images are automatically prefixed with assetPrefix + // so we need to ensure _next/image allows downloading from + // this resource + if (config.assetPrefix?.startsWith('http')) { + const { + protocol: proto, + hostname, + pathname, + } = new URL(config.assetPrefix) + const protocol = proto === 'https:' ? 'https' : 'http' + images.remotePatterns.push({ protocol, hostname, pathname }) + } + + if (images.remotePatterns.length > 50) { + throw new Error( + `Specified images.remotePatterns exceeds length of 50, received length (${images.remotePatterns.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + + const validProps = new Set(['protocol', 'hostname', 'pathname', 'port']) + const invalidIndex = images.remotePatterns.findIndex( + (d: unknown) => + !d || + typeof d !== 'object' || + Object.entries(d).some( + ([k, v]) => !validProps.has(k) || typeof v !== 'string' + ) + ) + const invalid = images.remotePatterns[invalidIndex] + if (invalid) { + throw new Error( + `Specified images.remotePatterns[${invalidIndex}] should be RemotePattern object received invalid value (${JSON.stringify( + invalid + )}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + } + if (images.deviceSizes) { const { deviceSizes } = images if (!Array.isArray(deviceSizes)) { diff --git a/packages/next/shared/lib/image-config.ts b/packages/next/shared/lib/image-config.ts index 0ea1df892c32..efb8a09efca9 100644 --- a/packages/next/shared/lib/image-config.ts +++ b/packages/next/shared/lib/image-config.ts @@ -55,7 +55,10 @@ export type ImageConfigComplete = { /** @see [Image loader configuration](https://nextjs.org/docs/api-reference/next/image#loader-configuration) */ path: string - /** @see [Image domains configuration](https://nextjs.org/docs/basic-features/image-optimization#domains) */ + /** + * @deprecated Use `remotePatterns` instead for finer control. + * @see [Image domains configuration](https://nextjs.org/docs/basic-features/image-optimization#domains) + */ domains: string[] /** @see [Remote Images](https://nextjs.org/docs/basic-features/image-optimization#remote-images) */ diff --git a/packages/next/telemetry/events/version.ts b/packages/next/telemetry/events/version.ts index da0c7e55aee3..b9679c102df3 100644 --- a/packages/next/telemetry/events/version.ts +++ b/packages/next/telemetry/events/version.ts @@ -21,6 +21,7 @@ type EventCliSessionStarted = { localeDomainsCount: number | null localeDetectionEnabled: boolean | null imageDomainsCount: number | null + imageRemotePatternsCount: number | null imageSizes: string | null imageLoader: string | null trailingSlashEnabled: boolean @@ -64,6 +65,7 @@ export function eventCliSession( | 'localeDomainsCount' | 'localeDetectionEnabled' | 'imageDomainsCount' + | 'imageRemotePatternsCount' | 'imageSizes' | 'imageLoader' | 'trailingSlashEnabled' @@ -95,6 +97,9 @@ export function eventCliSession( localeDomainsCount: i18n?.domains ? i18n.domains.length : null, localeDetectionEnabled: !i18n ? null : i18n.localeDetection !== false, imageDomainsCount: images?.domains ? images.domains.length : null, + imageRemotePatternsCount: images?.remotePatterns + ? images.remotePatterns.length + : null, imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null, imageLoader: images?.loader, trailingSlashEnabled: !!nextConfig?.trailingSlash, diff --git a/test/integration/image-component/image-from-node-modules/next.config.js b/test/integration/image-component/image-from-node-modules/next.config.js index a62705edb891..717032ba105f 100644 --- a/test/integration/image-component/image-from-node-modules/next.config.js +++ b/test/integration/image-component/image-from-node-modules/next.config.js @@ -1,5 +1,5 @@ module.exports = { images: { - domains: ['i.imgur.com'], + remotePatterns: [{ protocol: 'https', hostname: 'i.imgur.com', port: '' }], }, } diff --git a/test/integration/image-component/unicode/next.config.js b/test/integration/image-component/unicode/next.config.js index eb554b46c10c..dd7057b8a353 100644 --- a/test/integration/image-component/unicode/next.config.js +++ b/test/integration/image-component/unicode/next.config.js @@ -1,5 +1,5 @@ module.exports = { images: { - domains: ['image-optimization-test.vercel.app'], + remotePatterns: [{ hostname: 'image-optimization-test.vercel.app' }], }, } diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index bcac0f864552..cef35f03d5f7 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -48,6 +48,58 @@ describe('Image Optimizer', () => { ) }) + it('should error when remotePatterns length exceeds 50', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + remotePatterns: Array.from({ length: 51 }).map((_) => ({ + hostname: 'example.com', + })), + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + 'Specified images.remotePatterns exceeds length of 50, received length (51), please reduce the length of the array to continue' + ) + }) + + it('should error when remotePatterns has invalid object', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + images: { + remotePatterns: [{ foo: 'example.com' }], + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + 'Specified images.remotePatterns[0] should be RemotePattern object received invalid value ({"foo":"example.com"})' + ) + }) + it('should error when sizes length exceeds 25', async () => { await nextConfig.replace( '{ /* replaceme */ }', diff --git a/test/integration/image-optimizer/test/util.js b/test/integration/image-optimizer/test/util.js index ee83f9e93b18..96587a05c3ac 100644 --- a/test/integration/image-optimizer/test/util.js +++ b/test/integration/image-optimizer/test/util.js @@ -133,7 +133,7 @@ export function runTests(ctx) { slowImageServer.stop() }) - if (ctx.domains.includes('localhost')) { + if (ctx.remotePatterns.length > 0) { it('should normalize invalid status codes', async () => { const url = `http://localhost:${ slowImageServer.port @@ -577,7 +577,7 @@ export function runTests(ctx) { }) } - if (ctx.domains.includes('localhost')) { + if (ctx.remotePatterns.length > 0) { it('should resize absolute url from localhost', async () => { const url = `http://localhost:${ctx.appPort}/test.png` const query = { url, w: ctx.w, q: 80 } @@ -756,7 +756,7 @@ export function runTests(ctx) { ) }) - if (ctx.domains.includes('localhost')) { + if (ctx.remotePatterns.length > 0) { it('should fail when url fails to load an image', async () => { const url = `http://localhost:${ctx.appPort}/not-an-image` const query = { w: ctx.w, url, q: 100 } @@ -1113,7 +1113,7 @@ export function runTests(ctx) { expect(await res.text()).toBe("The requested resource isn't a valid image.") }) - if (ctx.domains.length) { + if (ctx.remotePatterns.length > 0) { it('should handle concurrent requests', async () => { await cleanImagesDir(ctx) const delay = 500 @@ -1194,12 +1194,12 @@ export function runTests(ctx) { export const setupTests = (ctx) => { const nextConfig = new File(join(ctx.appDir, 'next.config.js')) - if (!ctx.domains) { - ctx.domains = [ - 'localhost', - 'example.com', - 'assets.vercel.com', - 'image-optimization-test.vercel.app', + if (!ctx.remotePatterns) { + ctx.remotePatterns = [ + { protocol: 'http', hostname: 'localhost' }, + { hostname: 'example.com' }, + { protocol: 'https', hostname: 'assets.vercel.com' }, + { protocol: 'https', hostname: 'image-optimization-test.vercel.app' }, ] } @@ -1211,7 +1211,7 @@ export const setupTests = (ctx) => { ...ctx, w: size, isDev: true, - domains: [], + remotePatterns: [], avifEnabled: false, } @@ -1251,7 +1251,7 @@ export const setupTests = (ctx) => { images: { deviceSizes: [largeSize], imageSizes: [size], - domains: curCtx.domains, + remotePatterns: curCtx.remotePatterns, formats: ['image/avif', 'image/webp'], }, }) @@ -1285,7 +1285,7 @@ export const setupTests = (ctx) => { ...ctx, w: size, isDev: false, - domains: [], + remotePatterns: [], } beforeAll(async () => { curCtx.nextOutput = '' @@ -1325,7 +1325,7 @@ export const setupTests = (ctx) => { images: { formats: ['image/avif', 'image/webp'], deviceSizes: [size, largeSize], - domains: ctx.domains, + remotePatterns: ctx.remotePatterns, }, }) curCtx.nextOutput = '' diff --git a/test/integration/telemetry/next.config.i18n-images b/test/integration/telemetry/next.config.i18n-images index 000e75dfc4bb..c68ff71b3006 100644 --- a/test/integration/telemetry/next.config.i18n-images +++ b/test/integration/telemetry/next.config.i18n-images @@ -2,7 +2,8 @@ module.exports = phase => { return { images: { imageSizes: [64, 128, 256, 512, 1024], - domains: ['example.com'], + domains: ['example.com', 'another.com'], + remotePatterns: [{ protocol: 'https', hostname: '**.example.com' }], }, i18n: { locales: ['en','nl','fr'], diff --git a/test/integration/telemetry/test/index.test.js b/test/integration/telemetry/test/index.test.js index 9a2d3624427a..32c379ddc257 100644 --- a/test/integration/telemetry/test/index.test.js +++ b/test/integration/telemetry/test/index.test.js @@ -483,7 +483,8 @@ describe('Telemetry CLI', () => { expect(event1).toMatch(/"locales": "en,nl,fr"/) expect(event1).toMatch(/"localeDomainsCount": 2/) expect(event1).toMatch(/"localeDetectionEnabled": true/) - expect(event1).toMatch(/"imageDomainsCount": 1/) + expect(event1).toMatch(/"imageDomainsCount": 2/) + expect(event1).toMatch(/"imageRemotePatternsCount": 1/) expect(event1).toMatch(/"imageSizes": "64,128,256,512,1024"/) expect(event1).toMatch(/"trailingSlashEnabled": false/) expect(event1).toMatch(/"reactStrictMode": false/) From 12c2ffa3eb556dbbd7ef432d68c500ba80d9d6f8 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 Apr 2022 19:23:25 -0400 Subject: [PATCH 05/33] Simplify remote pattern assetPrefix --- packages/next/server/config.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index a8a423f219f0..82f978e88ba4 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -211,13 +211,6 @@ function assignDefaults(userConfig: { [key: string]: any }) { ) } - // static images are automatically prefixed with assetPrefix - // so we need to ensure _next/image allows downloading from - // this resource - if (config.assetPrefix?.startsWith('http')) { - images.domains.push(new URL(config.assetPrefix).hostname) - } - if (images.domains.length > 50) { throw new Error( `Specified images.domains exceeds length of 50, received length (${images.domains.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` From 7f9d656a134580d5aab12534bc80ac227999c3fb Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 19 Apr 2022 18:19:23 -0400 Subject: [PATCH 06/33] Fix test --- test/integration/telemetry/test/index.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/telemetry/test/index.test.js b/test/integration/telemetry/test/index.test.js index 32c379ddc257..3203052634fd 100644 --- a/test/integration/telemetry/test/index.test.js +++ b/test/integration/telemetry/test/index.test.js @@ -519,7 +519,8 @@ describe('Telemetry CLI', () => { expect(event2).toMatch(/"locales": "en,nl,fr"/) expect(event2).toMatch(/"localeDomainsCount": 2/) expect(event2).toMatch(/"localeDetectionEnabled": true/) - expect(event2).toMatch(/"imageDomainsCount": 1/) + expect(event2).toMatch(/"imageDomainsCount": 2/) + expect(event2).toMatch(/"imageRemotePatternsCount": 1/) expect(event2).toMatch(/"imageSizes": "64,128,256,512,1024"/) expect(event2).toMatch(/"trailingSlashEnabled": false/) expect(event2).toMatch(/"reactStrictMode": false/) From 98f37387866b98820181353769db897df8de08c3 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 19 Apr 2022 18:58:18 -0400 Subject: [PATCH 07/33] Fix validation --- packages/next/server/image-optimizer.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index bb392dcdd172..96e36e9bfc6e 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -110,11 +110,10 @@ export class ImageOptimizerCache { return { errorMessage: '"url" parameter is not allowed' } } - if (!domains.includes(hrefParsed.hostname)) { - return { errorMessage: '"url" parameter is not allowed' } - } - - if (remotePatterns.some((p) => !matchRemotePattern(p, hrefParsed))) { + if ( + !domains.includes(hrefParsed.hostname) && + remotePatterns.some((p) => !matchRemotePattern(p, hrefParsed)) + ) { return { errorMessage: '"url" parameter is not allowed' } } } From 0feee394cf79da6067aaabdab9cd640cc62803d0 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 20 Apr 2022 19:49:21 -0400 Subject: [PATCH 08/33] Add docs for `remotePatterns` --- docs/api-reference/next/image.md | 53 ++++++++++++++++++++++- docs/basic-features/image-optimization.md | 14 ++---- packages/next/shared/lib/image-config.ts | 6 +-- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 9f6db8cd9282..2c4aa66400ed 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -43,7 +43,7 @@ Must be one of the following: or an internal path depending on the [loader](#loader) prop or [loader configuration](#loader-configuration). When using an external URL, you must add it to -[domains](#domains) in +[remotePatterns](#remote-patterns) in `next.config.js`. ### width @@ -312,9 +312,58 @@ Other properties on the `` component will be passed to the underlying ## Configuration Options +### Remote Patterns + +To protect your application from malicious users, configuration is required in order to use external images. This ensures that only external images from your account can be served from the Next.js Image Optimization API. These external images can be configured with the `remotePatterns` property in your `next.config.js` file, as shown below: + +```js +module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'example.com', + port: '', + pathname: '/account123/**', + }, + ], + }, +} +``` + +> Note: The example above will ensure the `src` property of `next/image` must start with `https://example.com/account123/`. Any other protocol, hostname, port, or unmatched path will respond with 400 Bad Request. + +Below is another example of the `remotePatterns` property in the `next.config.js` file: + +```js +module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**.example.com', + }, + ], + }, +} +``` + +> Note: The example above will ensure the `src` property of `next/image` must start with `https://img1.example.com` or `https://me.avatar.example.com` or any number of subdomains. Any other protocol or unmatched hostname will respond with 400 Bad Request. + +Wildcard patterns can be used for both `pathname` and `hostname` and have the following syntax: + +- `*` match a single path segment or subdomain +- `**` match any number of path segments or subdomains + ### Domains -To protect your application from malicious users, you must define a list of image provider domains that you want to be served from the Next.js Image Optimization API. This is configured in with the `domains` property in your `next.config.js` file, as shown below: +Similar to [`remotePatterns`](#remote-patterns), the `domains` configuration can be used to provide a list of allowed hostnames for external images. + +However, the `domains` configuration does not support wildcard pattern matching and it cannot restrict protocol, port, or pathname. + +In most cases, you should use [`remotePatterns`](#remote-patterns) instead for more granular configuration. + +Below is an example of the `domains` property in the `next.config.js` file: ```js module.exports = { diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index 0c55837410ec..c4660861db83 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -66,7 +66,7 @@ function Home() { ### Remote Images -To use a remote image, the `src` property should be a URL string, which can be [relative](#loaders) or [absolute](#domains). Because Next.js does not have access to remote files during the build process, you'll need to provide the [`width`](/docs/api-reference/next/image.md#width), [`height`](/docs/api-reference/next/image.md#height) and optional [`blurDataURL`](/docs/api-reference/next/image.md#blurdataurl) props manually: +To use a remote image, the `src` property should be a URL string, which can be [relative](#loaders) or [absolute](/docs/api-reference/next/image.md#remote-patterns). Because Next.js does not have access to remote files during the build process, you'll need to provide the [`width`](/docs/api-reference/next/image.md#width), [`height`](/docs/api-reference/next/image.md#height) and optional [`blurDataURL`](/docs/api-reference/next/image.md#blurdataurl) props manually: ```jsx import Image from 'next/image' @@ -93,15 +93,9 @@ export default function Home() { Sometimes you may want to access a remote image, but still use the built-in Next.js Image Optimization API. To do this, leave the `loader` at its default setting and enter an absolute URL for the Image `src`. -To protect your application from malicious users, you must define a list of remote domains that you intend to access this way. This is configured in your `next.config.js` file, as shown below: +To protect your application from malicious users, you must define a list of remote image patterns you intend to allow remote access. -```js -module.exports = { - images: { - domains: ['example.com', 'example2.com'], - }, -} -``` +> Learn more about [`remotePatterns`](/docs/api-reference/next/image.md#remote-patterns) configuration. ### Loaders @@ -207,7 +201,7 @@ For examples of the Image component used with the various fill modes, see the [I ## Configuration -The `next/image` component and Next.js Image Optimization API can be configured in the [`next.config.js` file](/docs/api-reference/next.config.js/introduction.md). These configurations allow you to [enable remote domains](/docs/api-reference/next/image.md#domains), [define custom image breakpoints](/docs/api-reference/next/image.md#device-sizes), [change caching behavior](/docs/api-reference/next/image.md#caching-behavior) and more. +The `next/image` component and Next.js Image Optimization API can be configured in the [`next.config.js` file](/docs/api-reference/next.config.js/introduction.md). These configurations allow you to [enable remote images](/docs/api-reference/next/image.md#remote-patterns), [define custom image breakpoints](/docs/api-reference/next/image.md#device-sizes), [change caching behavior](/docs/api-reference/next/image.md#caching-behavior) and more. [**Read the full image configuration documentation for more information.**](/docs/api-reference/next/image.md#configuration-options) diff --git a/packages/next/shared/lib/image-config.ts b/packages/next/shared/lib/image-config.ts index efb8a09efca9..0bfa52ac83eb 100644 --- a/packages/next/shared/lib/image-config.ts +++ b/packages/next/shared/lib/image-config.ts @@ -56,12 +56,12 @@ export type ImageConfigComplete = { path: string /** - * @deprecated Use `remotePatterns` instead for finer control. - * @see [Image domains configuration](https://nextjs.org/docs/basic-features/image-optimization#domains) + * @deprecated Use `remotePatterns` instead for more granular configuration. + * @see [Image domains configuration](https://nextjs.org/docs/api-reference/next/image#domains) */ domains: string[] - /** @see [Remote Images](https://nextjs.org/docs/basic-features/image-optimization#remote-images) */ + /** @see [Remote Images](https://nextjs.org/docs/api-reference/next/image#remote-patterns) */ remotePatterns: RemotePattern[] /** @see [Cache behavior](https://nextjs.org/docs/api-reference/next/image#caching-behavior) */ From 00b0bbcb925dac6ba81cba0d31cf5259869ea80e Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 21 Apr 2022 11:31:44 -0400 Subject: [PATCH 09/33] Fix bug --- packages/next/server/image-optimizer.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 96e36e9bfc6e..55efd0abc042 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -106,14 +106,13 @@ export class ImageOptimizerCache { return { errorMessage: '"url" parameter is invalid' } } - if (!domains && !remotePatterns) { - return { errorMessage: '"url" parameter is not allowed' } - } - - if ( - !domains.includes(hrefParsed.hostname) && - remotePatterns.some((p) => !matchRemotePattern(p, hrefParsed)) - ) { + const allPatterns = remotePatterns.concat( + domains.map((hostname) => ({ hostname })) + ) + const hasMatch = allPatterns.some((p) => + matchRemotePattern(p, hrefParsed) + ) + if (!hasMatch) { return { errorMessage: '"url" parameter is not allowed' } } } From 022db8c6d5cb02ddcf04c2770948603dd9ec0fa1 Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 21 Apr 2022 12:35:45 -0400 Subject: [PATCH 10/33] Handle case when remotePatterns is undefined --- test/integration/image-optimizer/test/util.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/image-optimizer/test/util.js b/test/integration/image-optimizer/test/util.js index 96587a05c3ac..202bd424a4ae 100644 --- a/test/integration/image-optimizer/test/util.js +++ b/test/integration/image-optimizer/test/util.js @@ -133,7 +133,7 @@ export function runTests(ctx) { slowImageServer.stop() }) - if (ctx.remotePatterns.length > 0) { + if (ctx.remotePatterns?.length > 0) { it('should normalize invalid status codes', async () => { const url = `http://localhost:${ slowImageServer.port @@ -577,7 +577,7 @@ export function runTests(ctx) { }) } - if (ctx.remotePatterns.length > 0) { + if (ctx.remotePatterns?.length > 0) { it('should resize absolute url from localhost', async () => { const url = `http://localhost:${ctx.appPort}/test.png` const query = { url, w: ctx.w, q: 80 } @@ -756,7 +756,7 @@ export function runTests(ctx) { ) }) - if (ctx.remotePatterns.length > 0) { + if (ctx.remotePatterns?.length > 0) { it('should fail when url fails to load an image', async () => { const url = `http://localhost:${ctx.appPort}/not-an-image` const query = { w: ctx.w, url, q: 100 } @@ -1113,7 +1113,7 @@ export function runTests(ctx) { expect(await res.text()).toBe("The requested resource isn't a valid image.") }) - if (ctx.remotePatterns.length > 0) { + if (ctx.remotePatterns?.length > 0) { it('should handle concurrent requests', async () => { await cleanImagesDir(ctx) const delay = 500 From c56637a2a6fe7cd0e19f91014fa112a319dc5a1c Mon Sep 17 00:00:00 2001 From: Steven Date: Thu, 21 Apr 2022 12:36:47 -0400 Subject: [PATCH 11/33] Add version to table --- docs/api-reference/next/image.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 2c4aa66400ed..fc9b9f78777b 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -16,6 +16,7 @@ description: Enable Image Optimization with the built-in Image component. | Version | Changes | | --------- | ----------------------------------------------------------------------------------------------------- | +| `v12.2.0` | `remotePatterns` configuration added and `domains` configuration deprecated. | | `v12.1.1` | `style` prop added. Experimental[\*](#experimental-raw-layout-mode) support for `layout="raw"` added. | | `v12.1.0` | `dangerouslyAllowSVG` and `contentSecurityPolicy` configuration added. | | `v12.0.9` | `lazyRoot` prop added. | From 853c58c7c87140e5e9730e9463ae128ce4ffeed8 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 2 May 2022 17:00:19 -0400 Subject: [PATCH 12/33] Move match-remote-patterns to shared --- packages/next/client/image.tsx | 8 +-- packages/next/server/image-optimizer.ts | 68 ++----------------- .../next/shared/lib/match-remote-pattern.ts | 68 +++++++++++++++++++ .../match-remote-pattern.test.ts | 2 +- 4 files changed, 77 insertions(+), 69 deletions(-) create mode 100644 packages/next/shared/lib/match-remote-pattern.ts diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index a908dd605884..a944f22d677e 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -1075,11 +1075,9 @@ function defaultLoader({ } if (process.env.NODE_ENV === 'development') { - const domains = config.remotePatterns - .map((p) => p.hostname) - .concat(config.domains) - // TODO: can we utilize the backend to get the error message, perhaps from onError()? - if (!domains.includes(parsedSrc.hostname)) { + // We use dynamic require because this should only error in development + const { hasMatch } = require('../shared/lib/match-remote-pattern') + if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) { throw new Error( `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 55efd0abc042..4bb1f932acd1 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -18,6 +18,10 @@ import chalk from 'next/dist/compiled/chalk' import { NextUrlWithParsedQuery } from './request-meta' import { IncrementalCacheEntry, IncrementalCacheValue } from './response-cache' import { mockRequest } from './lib/mock-request' +import { + hasMatch, + matchRemotePattern, +} from '../shared/lib/match-remote-pattern' type XCacheHeader = 'MISS' | 'HIT' | 'STALE' @@ -106,13 +110,7 @@ export class ImageOptimizerCache { return { errorMessage: '"url" parameter is invalid' } } - const allPatterns = remotePatterns.concat( - domains.map((hostname) => ({ hostname })) - ) - const hasMatch = allPatterns.some((p) => - matchRemotePattern(p, hrefParsed) - ) - if (!hasMatch) { + if (!hasMatch(domains, remotePatterns, hrefParsed)) { return { errorMessage: '"url" parameter is not allowed' } } } @@ -796,62 +794,6 @@ export async function getImageSize( return { width, height } } -export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { - if (pattern.protocol !== undefined) { - const actualProto = url.protocol.slice(0, -1) - if (pattern.protocol !== actualProto) { - return false - } - } - if (pattern.port !== undefined) { - if (pattern.port !== url.port) { - return false - } - } - if (pattern.pathname !== undefined) { - const patternParts = pattern.pathname.split('/') - const actualParts = url.pathname.split('/') - const len = Math.max(patternParts.length, actualParts.length) - for (let i = 0; i < len; i++) { - if (patternParts[i] === '**' && actualParts[i] !== undefined) { - // Double asterisk means "match everything until the end of the path" - // so we can break the loop early - break - } - if (patternParts[i] === '*') { - // Single asterisk means "match this part" so we can - // continue to the next part of the loop - continue - } - if (patternParts[i] !== actualParts[i]) { - return false - } - } - } - - if (pattern.hostname !== undefined) { - const patternParts = pattern.hostname.split('.').reverse() - const actualParts = url.hostname.split('.').reverse() - const len = Math.max(patternParts.length, actualParts.length) - for (let i = 0; i < len; i++) { - if (patternParts[i] === '**' && actualParts[i] !== undefined) { - // Double asterisk means "match every subdomain" - // so we can break the loop early - break - } - if (patternParts[i] === '*') { - // Single asterisk means "match this subdomain" so we can - // continue to the next part of the loop - continue - } - if (patternParts[i] !== actualParts[i]) { - return false - } - } - } - return true -} - export class Deferred { promise: Promise resolve!: (value: T) => void diff --git a/packages/next/shared/lib/match-remote-pattern.ts b/packages/next/shared/lib/match-remote-pattern.ts new file mode 100644 index 000000000000..b032d25ae4a3 --- /dev/null +++ b/packages/next/shared/lib/match-remote-pattern.ts @@ -0,0 +1,68 @@ +import type { RemotePattern } from './image-config' + +export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { + if (pattern.protocol !== undefined) { + const actualProto = url.protocol.slice(0, -1) + if (pattern.protocol !== actualProto) { + return false + } + } + if (pattern.port !== undefined) { + if (pattern.port !== url.port) { + return false + } + } + if (pattern.pathname !== undefined) { + const patternParts = pattern.pathname.split('/') + const actualParts = url.pathname.split('/') + const len = Math.max(patternParts.length, actualParts.length) + for (let i = 0; i < len; i++) { + if (patternParts[i] === '**' && actualParts[i] !== undefined) { + // Double asterisk means "match everything until the end of the path" + // so we can break the loop early + break + } + if (patternParts[i] === '*') { + // Single asterisk means "match this part" so we can + // continue to the next part of the loop + continue + } + if (patternParts[i] !== actualParts[i]) { + return false + } + } + } + + if (pattern.hostname !== undefined) { + const patternParts = pattern.hostname.split('.').reverse() + const actualParts = url.hostname.split('.').reverse() + const len = Math.max(patternParts.length, actualParts.length) + for (let i = 0; i < len; i++) { + if (patternParts[i] === '**' && actualParts[i] !== undefined) { + // Double asterisk means "match every subdomain" + // so we can break the loop early + break + } + if (patternParts[i] === '*') { + // Single asterisk means "match this subdomain" so we can + // continue to the next part of the loop + continue + } + if (patternParts[i] !== actualParts[i]) { + return false + } + } + } + return true +} + +export function hasMatch( + domains: string[], + remotePatterns: RemotePattern[], + url: URL +): boolean { + const allPatterns = remotePatterns.concat( + domains.map((hostname) => ({ hostname })) + ) + return allPatterns.some((p) => matchRemotePattern(p, url)) +} diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 311fa0b042ab..25109e4fa85f 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -1,5 +1,5 @@ /* eslint-env jest */ -import { matchRemotePattern as m } from 'next/dist/server/image-optimizer' +import { matchRemotePattern as m } from 'next/dist/shared/lib/match-remote-pattern.js' describe('matchRemotePattern', () => { it('should match literal hostname', () => { From 9acea016e65b8e9bbe462828c8cddf4530aafc5d Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 2 May 2022 17:09:43 -0400 Subject: [PATCH 13/33] Fix lint --- packages/next/server/image-optimizer.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 4bb1f932acd1..36ff5b51e70e 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -10,7 +10,6 @@ import contentDisposition from 'next/dist/compiled/content-disposition' import { join } from 'path' import nodeUrl, { UrlWithParsedQuery } from 'url' import { NextConfigComplete } from './config-shared' -import type { RemotePattern } from '../shared/lib/image-config' import { processBuffer, decodeBuffer, Operation } from './lib/squoosh/main' import { sendEtagResponse } from './send-payload' import { getContentType, getExtension } from './serve-static' @@ -18,10 +17,7 @@ import chalk from 'next/dist/compiled/chalk' import { NextUrlWithParsedQuery } from './request-meta' import { IncrementalCacheEntry, IncrementalCacheValue } from './response-cache' import { mockRequest } from './lib/mock-request' -import { - hasMatch, - matchRemotePattern, -} from '../shared/lib/match-remote-pattern' +import { hasMatch } from '../shared/lib/match-remote-pattern' type XCacheHeader = 'MISS' | 'HIT' | 'STALE' From 2ff43da86b9356a775f44cedf248225cd99108a7 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 2 May 2022 18:09:10 -0400 Subject: [PATCH 14/33] Fix assetPrefix usage --- packages/next/server/config.ts | 14 +++++--------- .../image-component/asset-prefix/next.config.js | 1 + 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 82f978e88ba4..97cdb0c25ca6 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -236,17 +236,13 @@ function assignDefaults(userConfig: { [key: string]: any }) { ) } - // static images are automatically prefixed with assetPrefix - // so we need to ensure _next/image allows downloading from - // this resource + // Static images are automatically prefixed with assetPrefix + // so we must add to allowed remotePatterns which is used by + // the default Image Optimization API. if (config.assetPrefix?.startsWith('http')) { - const { - protocol: proto, - hostname, - pathname, - } = new URL(config.assetPrefix) + const { protocol: proto, hostname, port } = new URL(config.assetPrefix) const protocol = proto === 'https:' ? 'https' : 'http' - images.remotePatterns.push({ protocol, hostname, pathname }) + images.remotePatterns.push({ protocol, hostname, port }) } if (images.remotePatterns.length > 50) { diff --git a/test/integration/image-component/asset-prefix/next.config.js b/test/integration/image-component/asset-prefix/next.config.js index d6798dc737bc..f277a6b3d133 100644 --- a/test/integration/image-component/asset-prefix/next.config.js +++ b/test/integration/image-component/asset-prefix/next.config.js @@ -1,3 +1,4 @@ module.exports = { assetPrefix: 'https://example.com/pre', + // Intentionally omit `domains` and `remotePatterns` } From c374ffa48d4506ba5d9f6457697131b82d42aad3 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 18:52:41 -0400 Subject: [PATCH 15/33] Change config to experimental --- docs/api-reference/next/image.md | 44 ++++++++++--------- errors/next-image-unconfigured-host.md | 21 +-------- packages/next/build/index.ts | 2 + packages/next/build/webpack-config.ts | 3 +- packages/next/client/image.tsx | 11 +++-- packages/next/server/config-shared.ts | 3 ++ packages/next/server/config.ts | 31 +++++++------ packages/next/server/image-optimizer.ts | 2 +- packages/next/shared/lib/image-config.ts | 5 --- packages/next/telemetry/events/version.ts | 4 +- .../image-from-node-modules/next.config.js | 9 +++- .../image-component/unicode/next.config.js | 6 ++- .../image-optimizer/test/index.test.js | 16 ++++--- test/integration/image-optimizer/test/util.js | 28 ++++++------ .../telemetry/next.config.i18n-images | 6 ++- 15 files changed, 98 insertions(+), 93 deletions(-) diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 968b8e681fe5..bc197d509325 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -16,7 +16,7 @@ description: Enable Image Optimization with the built-in Image component. | Version | Changes | | --------- | ----------------------------------------------------------------------------------------------------- | -| `v12.2.0` | `remotePatterns` configuration added and `domains` configuration deprecated. | +| `v12.1.7` | Experimental `remotePatterns` configuration added. | | `v12.1.1` | `style` prop added. Experimental[\*](#experimental-raw-layout-mode) support for `layout="raw"` added. | | `v12.1.0` | `dangerouslyAllowSVG` and `contentSecurityPolicy` configuration added. | | `v12.0.9` | `lazyRoot` prop added. | @@ -44,7 +44,7 @@ Must be one of the following: or an internal path depending on the [loader](#loader) prop or [loader configuration](#loader-configuration). When using an external URL, you must add it to -[remotePatterns](#remote-patterns) in +[domains](#domains) in `next.config.js`. ### width @@ -316,19 +316,23 @@ Other properties on the `` component will be passed to the underlying ### Remote Patterns +> Note: The `remotePatterns` feature is currently **experimental**. + To protect your application from malicious users, configuration is required in order to use external images. This ensures that only external images from your account can be served from the Next.js Image Optimization API. These external images can be configured with the `remotePatterns` property in your `next.config.js` file, as shown below: ```js module.exports = { - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'example.com', - port: '', - pathname: '/account123/**', - }, - ], + experimental: { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'example.com', + port: '', + pathname: '/account123/**', + }, + ], + }, }, } ``` @@ -339,13 +343,15 @@ Below is another example of the `remotePatterns` property in the `next.config.js ```js module.exports = { - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: '**.example.com', - }, - ], + experimental: { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**.example.com', + }, + ], + }, }, } ``` @@ -363,8 +369,6 @@ Similar to [`remotePatterns`](#remote-patterns), the `domains` configuration can However, the `domains` configuration does not support wildcard pattern matching and it cannot restrict protocol, port, or pathname. -In most cases, you should use [`remotePatterns`](#remote-patterns) instead for more granular configuration. - Below is an example of the `domains` property in the `next.config.js` file: ```js diff --git a/errors/next-image-unconfigured-host.md b/errors/next-image-unconfigured-host.md index a9aa17f51723..3999d2c2cd20 100644 --- a/errors/next-image-unconfigured-host.md +++ b/errors/next-image-unconfigured-host.md @@ -2,28 +2,11 @@ #### Why This Error Occurred -On one of your pages that leverages the `next/image` component, you passed a `src` value that uses a hostname in the URL that isn't defined in the `images.remotePatterns` or `images.domains` config in `next.config.js`. +On one of your pages that leverages the `next/image` component, you passed a `src` value that uses a hostname in the URL that isn't defined in `images.domains` config in `next.config.js`. #### Possible Ways to Fix It -For Next.js 12.2.0 or newer, add the hostname of your URL to the `images.remotePatterns` config in `next.config.js`: - -```js -// next.config.js -module.exports = { - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'assets.example.com', - port: '', - }, - ], - }, -} -``` - -For older versions of Next.js, add the hostname of your URL to the `images.domains` config in `next.config.js`: +Add the hostname of your URL to the `images.domains` config in `next.config.js`: ```js // next.config.js diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index c230b63cf834..8702f25a71f6 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -2023,6 +2023,8 @@ export default async function build( const images = { ...config.images } const { deviceSizes, imageSizes } = images ;(images as any).sizes = [...deviceSizes, ...imageSizes] + ;(images as any).remotePatterns = + config?.experimental?.images?.remotePatterns || [] await promises.writeFile( path.join(distDir, IMAGES_MANIFEST), diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index f367d175790e..d4bd98e889e7 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1425,7 +1425,8 @@ export default async function getBaseWebpackConfig( ? { // pass domains in development to allow validating on the client domains: config.images.domains, - remotePatterns: config.images.remotePatterns, + experimentalRemotePatterns: + config.experimental?.images?.remotePatterns, } : {}), }), diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index a944f22d677e..fdfceebf8c37 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -18,8 +18,8 @@ import { ImageConfigContext } from '../shared/lib/image-config-context' import { warnOnce } from '../shared/lib/utils' import { normalizePathTrailingSlash } from './normalize-trailing-slash' -const experimentalLayoutRaw = (process.env.__NEXT_IMAGE_OPTS as any) - ?.experimentalLayoutRaw +const { experimentalLayoutRaw, experimentalRemotePatterns } = + (process.env.__NEXT_IMAGE_OPTS as any) || {} const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete const loadedImageURLs = new Set() const allImgs = new Map< @@ -1063,7 +1063,10 @@ function defaultLoader({ ) } - if (!src.startsWith('/') && (config.domains || config.remotePatterns)) { + if ( + !src.startsWith('/') && + (config.domains || experimentalRemotePatterns) + ) { let parsedSrc: URL try { parsedSrc = new URL(src) @@ -1077,7 +1080,7 @@ function defaultLoader({ if (process.env.NODE_ENV === 'development') { // We use dynamic require because this should only error in development const { hasMatch } = require('../shared/lib/match-remote-pattern') - if (!hasMatch(config.domains, config.remotePatterns, parsedSrc)) { + if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { throw new Error( `Invalid src prop (${src}) on \`next/image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`next.config.js\`\n` + `See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host` diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index e6a783bfee39..fdea911ec068 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -5,6 +5,7 @@ import { ImageConfig, ImageConfigComplete, imageConfigDefault, + RemotePattern, } from '../shared/lib/image-config' export type PageRuntime = 'nodejs' | 'edge' | undefined @@ -114,6 +115,7 @@ export interface ExperimentalConfig { outputStandalone?: boolean images?: { layoutRaw: boolean + remotePatterns: RemotePattern[] } middlewareSourceMaps?: boolean emotion?: @@ -498,6 +500,7 @@ export const defaultConfig: NextConfig = { outputStandalone: !!process.env.NEXT_PRIVATE_STANDALONE, images: { layoutRaw: false, + remotePatterns: [], }, }, } diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 97cdb0c25ca6..fd9916179cc5 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -211,6 +211,13 @@ function assignDefaults(userConfig: { [key: string]: any }) { ) } + // static images are automatically prefixed with assetPrefix + // so we need to ensure _next/image allows downloading from + // this resource + if (config.assetPrefix?.startsWith('http')) { + images.domains.push(new URL(config.assetPrefix).hostname) + } + if (images.domains.length > 50) { throw new Error( `Specified images.domains exceeds length of 50, received length (${images.domains.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` @@ -229,30 +236,22 @@ function assignDefaults(userConfig: { [key: string]: any }) { } } - if (images.remotePatterns) { - if (!Array.isArray(images.remotePatterns)) { + const remotePatterns = result.experimental?.images?.remotePatterns + if (remotePatterns) { + if (!Array.isArray(remotePatterns)) { throw new Error( - `Specified images.remotePatterns should be an Array received ${typeof images.remotePatterns}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + `Specified images.remotePatterns should be an Array received ${typeof remotePatterns}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` ) } - // Static images are automatically prefixed with assetPrefix - // so we must add to allowed remotePatterns which is used by - // the default Image Optimization API. - if (config.assetPrefix?.startsWith('http')) { - const { protocol: proto, hostname, port } = new URL(config.assetPrefix) - const protocol = proto === 'https:' ? 'https' : 'http' - images.remotePatterns.push({ protocol, hostname, port }) - } - - if (images.remotePatterns.length > 50) { + if (remotePatterns.length > 50) { throw new Error( - `Specified images.remotePatterns exceeds length of 50, received length (${images.remotePatterns.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + `Specified images.remotePatterns exceeds length of 50, received length (${remotePatterns.length}), please reduce the length of the array to continue.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` ) } const validProps = new Set(['protocol', 'hostname', 'pathname', 'port']) - const invalidIndex = images.remotePatterns.findIndex( + const invalidIndex = remotePatterns.findIndex( (d: unknown) => !d || typeof d !== 'object' || @@ -260,7 +259,7 @@ function assignDefaults(userConfig: { [key: string]: any }) { ([k, v]) => !validProps.has(k) || typeof v !== 'string' ) ) - const invalid = images.remotePatterns[invalidIndex] + const invalid = remotePatterns[invalidIndex] if (invalid) { throw new Error( `Specified images.remotePatterns[${invalidIndex}] should be RemotePattern object received invalid value (${JSON.stringify( diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index 36ff5b51e70e..e16af1c98dcd 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -73,10 +73,10 @@ export class ImageOptimizerCache { deviceSizes = [], imageSizes = [], domains = [], - remotePatterns = [], minimumCacheTTL = 60, formats = ['image/webp'], } = imageData + const remotePatterns = nextConfig.experimental.images?.remotePatterns || [] const { url, w, q } = query let href: string diff --git a/packages/next/shared/lib/image-config.ts b/packages/next/shared/lib/image-config.ts index 0bfa52ac83eb..d60e966ccf30 100644 --- a/packages/next/shared/lib/image-config.ts +++ b/packages/next/shared/lib/image-config.ts @@ -56,14 +56,10 @@ export type ImageConfigComplete = { path: string /** - * @deprecated Use `remotePatterns` instead for more granular configuration. * @see [Image domains configuration](https://nextjs.org/docs/api-reference/next/image#domains) */ domains: string[] - /** @see [Remote Images](https://nextjs.org/docs/api-reference/next/image#remote-patterns) */ - remotePatterns: RemotePattern[] - /** @see [Cache behavior](https://nextjs.org/docs/api-reference/next/image#caching-behavior) */ disableStaticImages: boolean @@ -88,7 +84,6 @@ export const imageConfigDefault: ImageConfigComplete = { path: '/_next/image', loader: 'default', domains: [], - remotePatterns: [], disableStaticImages: false, minimumCacheTTL: 60, formats: ['image/webp'], diff --git a/packages/next/telemetry/events/version.ts b/packages/next/telemetry/events/version.ts index 11159d306364..297025b74b6f 100644 --- a/packages/next/telemetry/events/version.ts +++ b/packages/next/telemetry/events/version.ts @@ -99,8 +99,8 @@ export function eventCliSession( localeDomainsCount: i18n?.domains ? i18n.domains.length : null, localeDetectionEnabled: !i18n ? null : i18n.localeDetection !== false, imageDomainsCount: images?.domains ? images.domains.length : null, - imageRemotePatternsCount: images?.remotePatterns - ? images.remotePatterns.length + imageRemotePatternsCount: nextConfig.experimental?.images?.remotePatterns + ? nextConfig.experimental?.images?.remotePatterns.length : null, imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null, imageLoader: images?.loader, diff --git a/test/integration/image-component/image-from-node-modules/next.config.js b/test/integration/image-component/image-from-node-modules/next.config.js index 717032ba105f..f232e1be16aa 100644 --- a/test/integration/image-component/image-from-node-modules/next.config.js +++ b/test/integration/image-component/image-from-node-modules/next.config.js @@ -1,5 +1,10 @@ module.exports = { - images: { - remotePatterns: [{ protocol: 'https', hostname: 'i.imgur.com', port: '' }], + experimental: { + images: { + layoutRaw: true, + remotePatterns: [ + { protocol: 'https', hostname: 'i.imgur.com', port: '' }, + ], + }, }, } diff --git a/test/integration/image-component/unicode/next.config.js b/test/integration/image-component/unicode/next.config.js index dd7057b8a353..e4ff44f0fe11 100644 --- a/test/integration/image-component/unicode/next.config.js +++ b/test/integration/image-component/unicode/next.config.js @@ -1,5 +1,7 @@ module.exports = { - images: { - remotePatterns: [{ hostname: 'image-optimization-test.vercel.app' }], + experimental: { + images: { + remotePatterns: [{ hostname: 'image-optimization-test.vercel.app' }], + }, }, } diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index cef35f03d5f7..8fdc9a270652 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -52,10 +52,12 @@ describe('Image Optimizer', () => { await nextConfig.replace( '{ /* replaceme */ }', JSON.stringify({ - images: { - remotePatterns: Array.from({ length: 51 }).map((_) => ({ - hostname: 'example.com', - })), + experimental: { + images: { + remotePatterns: Array.from({ length: 51 }).map((_) => ({ + hostname: 'example.com', + })), + }, }, }) ) @@ -79,8 +81,10 @@ describe('Image Optimizer', () => { await nextConfig.replace( '{ /* replaceme */ }', JSON.stringify({ - images: { - remotePatterns: [{ foo: 'example.com' }], + experimental: { + images: { + remotePatterns: [{ foo: 'example.com' }], + }, }, }) ) diff --git a/test/integration/image-optimizer/test/util.js b/test/integration/image-optimizer/test/util.js index 202bd424a4ae..4bced518d674 100644 --- a/test/integration/image-optimizer/test/util.js +++ b/test/integration/image-optimizer/test/util.js @@ -133,7 +133,7 @@ export function runTests(ctx) { slowImageServer.stop() }) - if (ctx.remotePatterns?.length > 0) { + if (ctx.domains?.length > 0) { it('should normalize invalid status codes', async () => { const url = `http://localhost:${ slowImageServer.port @@ -577,7 +577,7 @@ export function runTests(ctx) { }) } - if (ctx.remotePatterns?.length > 0) { + if (ctx.domains?.length > 0) { it('should resize absolute url from localhost', async () => { const url = `http://localhost:${ctx.appPort}/test.png` const query = { url, w: ctx.w, q: 80 } @@ -756,7 +756,7 @@ export function runTests(ctx) { ) }) - if (ctx.remotePatterns?.length > 0) { + if (ctx.domains?.length > 0) { it('should fail when url fails to load an image', async () => { const url = `http://localhost:${ctx.appPort}/not-an-image` const query = { w: ctx.w, url, q: 100 } @@ -1113,7 +1113,7 @@ export function runTests(ctx) { expect(await res.text()).toBe("The requested resource isn't a valid image.") }) - if (ctx.remotePatterns?.length > 0) { + if (ctx.domains?.length > 0) { it('should handle concurrent requests', async () => { await cleanImagesDir(ctx) const delay = 500 @@ -1194,12 +1194,12 @@ export function runTests(ctx) { export const setupTests = (ctx) => { const nextConfig = new File(join(ctx.appDir, 'next.config.js')) - if (!ctx.remotePatterns) { - ctx.remotePatterns = [ - { protocol: 'http', hostname: 'localhost' }, - { hostname: 'example.com' }, - { protocol: 'https', hostname: 'assets.vercel.com' }, - { protocol: 'https', hostname: 'image-optimization-test.vercel.app' }, + if (!ctx.domains) { + ctx.domains = [ + 'localhost', + 'example.com', + 'assets.vercel.com', + 'image-optimization-test.vercel.app', ] } @@ -1211,7 +1211,7 @@ export const setupTests = (ctx) => { ...ctx, w: size, isDev: true, - remotePatterns: [], + domains: [], avifEnabled: false, } @@ -1251,7 +1251,7 @@ export const setupTests = (ctx) => { images: { deviceSizes: [largeSize], imageSizes: [size], - remotePatterns: curCtx.remotePatterns, + domains: curCtx.domains, formats: ['image/avif', 'image/webp'], }, }) @@ -1285,7 +1285,7 @@ export const setupTests = (ctx) => { ...ctx, w: size, isDev: false, - remotePatterns: [], + domains: [], } beforeAll(async () => { curCtx.nextOutput = '' @@ -1325,7 +1325,7 @@ export const setupTests = (ctx) => { images: { formats: ['image/avif', 'image/webp'], deviceSizes: [size, largeSize], - remotePatterns: ctx.remotePatterns, + domains: ctx.domains, }, }) curCtx.nextOutput = '' diff --git a/test/integration/telemetry/next.config.i18n-images b/test/integration/telemetry/next.config.i18n-images index 0a7be628c6de..8fa28f5f7ac1 100644 --- a/test/integration/telemetry/next.config.i18n-images +++ b/test/integration/telemetry/next.config.i18n-images @@ -4,8 +4,12 @@ module.exports = phase => { formats: ['image/avif', 'image/webp'], imageSizes: [64, 128, 256, 512, 1024], domains: ['example.com', 'another.com'], - remotePatterns: [{ protocol: 'https', hostname: '**.example.com' }], }, + experimental: { + images: { + remotePatterns: [{ protocol: 'https', hostname: '**.example.com' }], + }, + } i18n: { locales: ['en','nl','fr'], defaultLocale: 'en', From 342bb7d9eec64f5548ed7ed710cd96ad730f758f Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 18:56:54 -0400 Subject: [PATCH 16/33] Revert build config --- packages/next/build/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 8702f25a71f6..c230b63cf834 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -2023,8 +2023,6 @@ export default async function build( const images = { ...config.images } const { deviceSizes, imageSizes } = images ;(images as any).sizes = [...deviceSizes, ...imageSizes] - ;(images as any).remotePatterns = - config?.experimental?.images?.remotePatterns || [] await promises.writeFile( path.join(distDir, IMAGES_MANIFEST), From 591d8c5f06c8c461f54bf2b320cc6670c7676867 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 18:57:33 -0400 Subject: [PATCH 17/33] Add back the build config --- packages/next/build/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index fa4aaad02ecc..e48f42b2f77d 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -2132,6 +2132,8 @@ export default async function build( const images = { ...config.images } const { deviceSizes, imageSizes } = images ;(images as any).sizes = [...deviceSizes, ...imageSizes] + ;(images as any).remotePatterns = + config?.experimental?.images?.remotePatterns || [] await promises.writeFile( path.join(distDir, IMAGES_MANIFEST), From 32263da969205bdc87bb29bbc4d7f1b078df0165 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 19:12:30 -0400 Subject: [PATCH 18/33] Revert check for NODE_ENV --- packages/next/client/image.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index fdfceebf8c37..a3ce41ec1295 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -1077,7 +1077,7 @@ function defaultLoader({ ) } - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV !== 'test') { // We use dynamic require because this should only error in development const { hasMatch } = require('../shared/lib/match-remote-pattern') if (!hasMatch(config.domains, experimentalRemotePatterns, parsedSrc)) { From fa9892361b57ba8630a29043556105c848eeee32 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 19:16:39 -0400 Subject: [PATCH 19/33] Revert image-from-node-modules test --- .../image-from-node-modules/next.config.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/test/integration/image-component/image-from-node-modules/next.config.js b/test/integration/image-component/image-from-node-modules/next.config.js index f232e1be16aa..a62705edb891 100644 --- a/test/integration/image-component/image-from-node-modules/next.config.js +++ b/test/integration/image-component/image-from-node-modules/next.config.js @@ -1,10 +1,5 @@ module.exports = { - experimental: { - images: { - layoutRaw: true, - remotePatterns: [ - { protocol: 'https', hostname: 'i.imgur.com', port: '' }, - ], - }, + images: { + domains: ['i.imgur.com'], }, } From e26fbabd30d1d067de8e3b5232194bf9f22692d5 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 20:08:57 -0400 Subject: [PATCH 20/33] Fix default value --- packages/next/client/image.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index a3ce41ec1295..052e38fe58b6 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -18,7 +18,7 @@ import { ImageConfigContext } from '../shared/lib/image-config-context' import { warnOnce } from '../shared/lib/utils' import { normalizePathTrailingSlash } from './normalize-trailing-slash' -const { experimentalLayoutRaw, experimentalRemotePatterns } = +const { experimentalLayoutRaw = false, experimentalRemotePatterns = [] } = (process.env.__NEXT_IMAGE_OPTS as any) || {} const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete const loadedImageURLs = new Set() From 4be0719476e667f7e2caf04cec9ede68d44005ab Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 20:40:56 -0400 Subject: [PATCH 21/33] Fix nullable check --- packages/next/telemetry/events/version.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next/telemetry/events/version.ts b/packages/next/telemetry/events/version.ts index 297025b74b6f..9857094dacf2 100644 --- a/packages/next/telemetry/events/version.ts +++ b/packages/next/telemetry/events/version.ts @@ -99,8 +99,8 @@ export function eventCliSession( localeDomainsCount: i18n?.domains ? i18n.domains.length : null, localeDetectionEnabled: !i18n ? null : i18n.localeDetection !== false, imageDomainsCount: images?.domains ? images.domains.length : null, - imageRemotePatternsCount: nextConfig.experimental?.images?.remotePatterns - ? nextConfig.experimental?.images?.remotePatterns.length + imageRemotePatternsCount: nextConfig?.experimental?.images?.remotePatterns + ? nextConfig.experimental.images.remotePatterns.length : null, imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null, imageLoader: images?.loader, From 33e13854d8baeb0bb6d3c5eca17242d1ecb8f97c Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 20:47:24 -0400 Subject: [PATCH 22/33] Fix typo --- test/integration/telemetry/next.config.i18n-images | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/telemetry/next.config.i18n-images b/test/integration/telemetry/next.config.i18n-images index 8fa28f5f7ac1..4b7a8d7ce6ef 100644 --- a/test/integration/telemetry/next.config.i18n-images +++ b/test/integration/telemetry/next.config.i18n-images @@ -9,7 +9,7 @@ module.exports = phase => { images: { remotePatterns: [{ protocol: 'https', hostname: '**.example.com' }], }, - } + }, i18n: { locales: ['en','nl','fr'], defaultLocale: 'en', From e685198672e36c57629c331d4765feb1e894d57a Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 3 May 2022 21:08:29 -0400 Subject: [PATCH 23/33] Revert missing "the" in docs --- errors/next-image-unconfigured-host.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/errors/next-image-unconfigured-host.md b/errors/next-image-unconfigured-host.md index 3999d2c2cd20..e69525ad03d2 100644 --- a/errors/next-image-unconfigured-host.md +++ b/errors/next-image-unconfigured-host.md @@ -2,7 +2,7 @@ #### Why This Error Occurred -On one of your pages that leverages the `next/image` component, you passed a `src` value that uses a hostname in the URL that isn't defined in `images.domains` config in `next.config.js`. +On one of your pages that leverages the `next/image` component, you passed a `src` value that uses a hostname in the URL that isn't defined in the `images.domains` config in `next.config.js`. #### Possible Ways to Fix It From 81e387cc29cf082daf6d06a365af894fa769debd Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 09:52:19 -0400 Subject: [PATCH 24/33] Update docs --- docs/basic-features/image-optimization.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/basic-features/image-optimization.md b/docs/basic-features/image-optimization.md index c4660861db83..16c75ffe5533 100644 --- a/docs/basic-features/image-optimization.md +++ b/docs/basic-features/image-optimization.md @@ -66,7 +66,7 @@ function Home() { ### Remote Images -To use a remote image, the `src` property should be a URL string, which can be [relative](#loaders) or [absolute](/docs/api-reference/next/image.md#remote-patterns). Because Next.js does not have access to remote files during the build process, you'll need to provide the [`width`](/docs/api-reference/next/image.md#width), [`height`](/docs/api-reference/next/image.md#height) and optional [`blurDataURL`](/docs/api-reference/next/image.md#blurdataurl) props manually: +To use a remote image, the `src` property should be a URL string, which can be [relative](#loaders) or [absolute](/docs/api-reference/next/image.md#domains). Because Next.js does not have access to remote files during the build process, you'll need to provide the [`width`](/docs/api-reference/next/image.md#width), [`height`](/docs/api-reference/next/image.md#height) and optional [`blurDataURL`](/docs/api-reference/next/image.md#blurdataurl) props manually: ```jsx import Image from 'next/image' @@ -93,9 +93,9 @@ export default function Home() { Sometimes you may want to access a remote image, but still use the built-in Next.js Image Optimization API. To do this, leave the `loader` at its default setting and enter an absolute URL for the Image `src`. -To protect your application from malicious users, you must define a list of remote image patterns you intend to allow remote access. +To protect your application from malicious users, you must define a list of remote hostnames you intend to allow remote access. -> Learn more about [`remotePatterns`](/docs/api-reference/next/image.md#remote-patterns) configuration. +> Learn more about [`domains`](/docs/api-reference/next/image.md#domains) configuration. ### Loaders @@ -201,7 +201,7 @@ For examples of the Image component used with the various fill modes, see the [I ## Configuration -The `next/image` component and Next.js Image Optimization API can be configured in the [`next.config.js` file](/docs/api-reference/next.config.js/introduction.md). These configurations allow you to [enable remote images](/docs/api-reference/next/image.md#remote-patterns), [define custom image breakpoints](/docs/api-reference/next/image.md#device-sizes), [change caching behavior](/docs/api-reference/next/image.md#caching-behavior) and more. +The `next/image` component and Next.js Image Optimization API can be configured in the [`next.config.js` file](/docs/api-reference/next.config.js/introduction.md). These configurations allow you to [enable remote images](/docs/api-reference/next/image.md#domains), [define custom image breakpoints](/docs/api-reference/next/image.md#device-sizes), [change caching behavior](/docs/api-reference/next/image.md#caching-behavior) and more. [**Read the full image configuration documentation for more information.**](/docs/api-reference/next/image.md#configuration-options) From 66e344acbf7c7d5b6834aa7ef962ac110d3b1685 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 09:54:09 -0400 Subject: [PATCH 25/33] Update experimental note --- docs/api-reference/next/image.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index bc197d509325..b3158fc054ce 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -316,7 +316,7 @@ Other properties on the `` component will be passed to the underlying ### Remote Patterns -> Note: The `remotePatterns` feature is currently **experimental**. +> Note: The `remotePatterns` configuration is currently **experimental** and subject to change. Please use [`domains`](#domains) for production use cases. To protect your application from malicious users, configuration is required in order to use external images. This ensures that only external images from your account can be served from the Next.js Image Optimization API. These external images can be configured with the `remotePatterns` property in your `next.config.js` file, as shown below: From 7a087f14dce8f8451929448e03563b3f2e3dd72b Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 16:49:35 -0400 Subject: [PATCH 26/33] Fix case when domains has wildcard even though it should be exact match --- .../next/shared/lib/match-remote-pattern.ts | 6 +-- .../match-remote-pattern.test.ts | 49 ++++++++++++++++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/next/shared/lib/match-remote-pattern.ts b/packages/next/shared/lib/match-remote-pattern.ts index b032d25ae4a3..2af0c8ffa0c3 100644 --- a/packages/next/shared/lib/match-remote-pattern.ts +++ b/packages/next/shared/lib/match-remote-pattern.ts @@ -61,8 +61,8 @@ export function hasMatch( remotePatterns: RemotePattern[], url: URL ): boolean { - const allPatterns = remotePatterns.concat( - domains.map((hostname) => ({ hostname })) + return ( + domains.some((domain) => url.hostname === domain) || + remotePatterns.some((p) => matchRemotePattern(p, url)) ) - return allPatterns.some((p) => matchRemotePattern(p, url)) } diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 25109e4fa85f..45edc178f9ad 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -1,5 +1,8 @@ /* eslint-env jest */ -import { matchRemotePattern as m } from 'next/dist/shared/lib/match-remote-pattern.js' +import { + matchRemotePattern as m, + hasMatch, +} from 'next/dist/shared/lib/match-remote-pattern.js' describe('matchRemotePattern', () => { it('should match literal hostname', () => { @@ -164,4 +167,48 @@ describe('matchRemotePattern', () => { expect(m(p, new URL('https://example.com/act456/team/pic.jpg'))).toBe(false) expect(m(p, new URL('https://example.com/team/pic.jpg'))).toBe(false) }) + + it('should properly work with hasMatch', () => { + const url = new URL('https://example.com') + expect(hasMatch([], [], url)).toBe(false) + expect(hasMatch(['foo.com'], [], url)).toBe(false) + expect(hasMatch(['example.com'], [], url)).toBe(true) + expect(hasMatch(['**.example.com'], [], url)).toBe(false) + expect(hasMatch(['*.example.com'], [], url)).toBe(false) + expect(hasMatch(['*.example.com'], [], url)).toBe(false) + expect(hasMatch([], [{ hostname: 'foo.com' }], url)).toBe(false) + expect( + hasMatch([], [{ hostname: 'foo.com' }, { hostname: 'example.com' }], url) + ).toBe(true) + expect( + hasMatch([], [{ hostname: 'example.com', pathname: '/act123/**' }], url) + ).toBe(false) + expect( + hasMatch( + ['example.com'], + [{ hostname: 'example.com', pathname: '/act123/**' }], + url + ) + ).toBe(true) + expect( + hasMatch([], [{ protocol: 'https', hostname: 'example.com' }], url) + ).toBe(true) + expect( + hasMatch([], [{ protocol: 'http', hostname: 'example.com' }], url) + ).toBe(false) + expect( + hasMatch( + ['example.com'], + [{ protocol: 'http', hostname: 'example.com' }], + url + ) + ).toBe(true) + expect( + hasMatch( + ['foo.com'], + [{ protocol: 'http', hostname: 'example.com' }], + url + ) + ).toBe(false) + }) }) From 55679a381f034e0fcd2f3d4acb634f24ae439e30 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 16:51:05 -0400 Subject: [PATCH 27/33] Deconstruct experimental from nextConfig --- packages/next/telemetry/events/version.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/next/telemetry/events/version.ts b/packages/next/telemetry/events/version.ts index 9857094dacf2..f8c313363e41 100644 --- a/packages/next/telemetry/events/version.ts +++ b/packages/next/telemetry/events/version.ts @@ -79,7 +79,7 @@ export function eventCliSession( return [] } - const { images, i18n } = nextConfig || {} + const { images, i18n, experimental } = nextConfig || {} const payload: EventCliSessionStarted = { nextVersion: process.env.__NEXT_VERSION, @@ -99,8 +99,8 @@ export function eventCliSession( localeDomainsCount: i18n?.domains ? i18n.domains.length : null, localeDetectionEnabled: !i18n ? null : i18n.localeDetection !== false, imageDomainsCount: images?.domains ? images.domains.length : null, - imageRemotePatternsCount: nextConfig?.experimental?.images?.remotePatterns - ? nextConfig.experimental.images.remotePatterns.length + imageRemotePatternsCount: experimental?.images?.remotePatterns + ? experimental.images.remotePatterns.length : null, imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null, imageLoader: images?.loader, From 2c1f723136d4c2d302b62888a68d111d91220a51 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 19:47:05 -0400 Subject: [PATCH 28/33] Remove `.js` suffix from test Co-authored-by: JJ Kasper --- test/unit/image-optimizer/match-remote-pattern.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 45edc178f9ad..1b58c03301b9 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -2,7 +2,7 @@ import { matchRemotePattern as m, hasMatch, -} from 'next/dist/shared/lib/match-remote-pattern.js' +} from 'next/dist/shared/lib/match-remote-pattern' describe('matchRemotePattern', () => { it('should match literal hostname', () => { From 8d9e8fa4c5ef292b917ffac22694cb8a7b94d829 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 20:01:56 -0400 Subject: [PATCH 29/33] Add runtime check for hostname missing --- packages/next/shared/lib/match-remote-pattern.ts | 4 +++- test/unit/image-optimizer/match-remote-pattern.test.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/next/shared/lib/match-remote-pattern.ts b/packages/next/shared/lib/match-remote-pattern.ts index 2af0c8ffa0c3..b9b8e445da60 100644 --- a/packages/next/shared/lib/match-remote-pattern.ts +++ b/packages/next/shared/lib/match-remote-pattern.ts @@ -33,7 +33,9 @@ export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { } } - if (pattern.hostname !== undefined) { + if (pattern.hostname === undefined) { + throw new Error(`invariant: hostname is missing ${JSON.stringify(pattern)}`) + } else { const patternParts = pattern.hostname.split('.').reverse() const actualParts = url.hostname.split('.').reverse() const len = Math.max(patternParts.length, actualParts.length) diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 1b58c03301b9..0a38ad8783d6 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -168,6 +168,11 @@ describe('matchRemotePattern', () => { expect(m(p, new URL('https://example.com/team/pic.jpg'))).toBe(false) }) + it('should throw when hostname is missing', () => { + const p = { protocol: 'https' } as const + expect(() => m(p, new URL('https://example.com'))).toThrow('invariant') + }) + it('should properly work with hasMatch', () => { const url = new URL('https://example.com') expect(hasMatch([], [], url)).toBe(false) From 163eae932234fc8768370f76f6b878e25652de61 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 20:20:50 -0400 Subject: [PATCH 30/33] Validate remotePatterns config has hostname prop --- packages/next/server/config.ts | 17 ++++++---- .../image-optimizer/test/index.test.js | 33 +++++++++++++++++-- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 55da05cc7840..40b74df35c10 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -250,20 +250,23 @@ function assignDefaults(userConfig: { [key: string]: any }) { } const validProps = new Set(['protocol', 'hostname', 'pathname', 'port']) - const invalidIndex = remotePatterns.findIndex( + const requiredProps = ['hostname'] + const invalidPatterns = remotePatterns.filter( (d: unknown) => !d || typeof d !== 'object' || Object.entries(d).some( ([k, v]) => !validProps.has(k) || typeof v !== 'string' - ) + ) || + requiredProps.some((k) => !(k in d)) ) - const invalid = remotePatterns[invalidIndex] - if (invalid) { + if (invalidPatterns.length > 0) { throw new Error( - `Specified images.remotePatterns[${invalidIndex}] should be RemotePattern object received invalid value (${JSON.stringify( - invalid - )}).\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + `Invalid images.remotePatterns values:\n${invalidPatterns + .map((item) => JSON.stringify(item)) + .join( + '\n' + )}\n\nremotePatterns value must follow format { protocol: 'https', hostname: 'example.com', port: '', pathname: '/imgs/**' }.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` ) } } diff --git a/test/integration/image-optimizer/test/index.test.js b/test/integration/image-optimizer/test/index.test.js index 8fdc9a270652..bd21d869e6cc 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -77,13 +77,13 @@ describe('Image Optimizer', () => { ) }) - it('should error when remotePatterns has invalid object', async () => { + it('should error when remotePatterns has invalid prop', async () => { await nextConfig.replace( '{ /* replaceme */ }', JSON.stringify({ experimental: { images: { - remotePatterns: [{ foo: 'example.com' }], + remotePatterns: [{ hostname: 'example.com', foo: 'bar' }], }, }, }) @@ -100,7 +100,34 @@ describe('Image Optimizer', () => { await nextConfig.restore() expect(stderr).toContain( - 'Specified images.remotePatterns[0] should be RemotePattern object received invalid value ({"foo":"example.com"})' + 'Invalid images.remotePatterns values:\n{"hostname":"example.com","foo":"bar"}' + ) + }) + + it('should error when remotePatterns is missing hostname', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + experimental: { + images: { + remotePatterns: [{ protocol: 'https' }], + }, + }, + }) + ) + let stderr = '' + + app = await launchApp(appDir, await findPort(), { + onStderr(msg) { + stderr += msg || '' + }, + }) + await waitFor(1000) + await killApp(app).catch(() => {}) + await nextConfig.restore() + + expect(stderr).toContain( + 'Invalid images.remotePatterns values:\n{"protocol":"https"}' ) }) From c2c9ebb1133c1d34d61d3a57e5c559b41f1d4740 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 20:47:57 -0400 Subject: [PATCH 31/33] Validate double asterisks --- .../next/shared/lib/match-remote-pattern.ts | 20 ++++++++++++++++--- .../match-remote-pattern.test.ts | 18 ++++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/next/shared/lib/match-remote-pattern.ts b/packages/next/shared/lib/match-remote-pattern.ts index b9b8e445da60..4db09ee4451e 100644 --- a/packages/next/shared/lib/match-remote-pattern.ts +++ b/packages/next/shared/lib/match-remote-pattern.ts @@ -19,7 +19,13 @@ export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { for (let i = 0; i < len; i++) { if (patternParts[i] === '**' && actualParts[i] !== undefined) { // Double asterisk means "match everything until the end of the path" - // so we can break the loop early + // so we can break the loop early. But we throw + // if the double asterisk is not the last part. + if (patternParts.length - 1 > i) { + throw new Error( + `Pattern can only contain ** at end of pathname but found "${pattern.pathname}"` + ) + } break } if (patternParts[i] === '*') { @@ -34,7 +40,9 @@ export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { } if (pattern.hostname === undefined) { - throw new Error(`invariant: hostname is missing ${JSON.stringify(pattern)}`) + throw new Error( + `Pattern should define hostname but found\n${JSON.stringify(pattern)}` + ) } else { const patternParts = pattern.hostname.split('.').reverse() const actualParts = url.hostname.split('.').reverse() @@ -42,7 +50,13 @@ export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { for (let i = 0; i < len; i++) { if (patternParts[i] === '**' && actualParts[i] !== undefined) { // Double asterisk means "match every subdomain" - // so we can break the loop early + // so we can break the loop early. But we throw + // if the double asterisk is not the last part. + if (patternParts.length - 1 > i) { + throw new Error( + `Pattern can only contain ** at start of hostname but found "${pattern.hostname}"` + ) + } break } if (patternParts[i] === '*') { diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 0a38ad8783d6..7185dbde486e 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -170,7 +170,23 @@ describe('matchRemotePattern', () => { it('should throw when hostname is missing', () => { const p = { protocol: 'https' } as const - expect(() => m(p, new URL('https://example.com'))).toThrow('invariant') + expect(() => m(p, new URL('https://example.com'))).toThrow( + 'Pattern should define hostname but found\n{"protocol":"https"}' + ) + }) + + it('should throw when hostname has double asterisk in the middle', () => { + const p = { hostname: 'example.**.com' } as const + expect(() => m(p, new URL('https://example.com'))).toThrow( + 'Pattern can only contain ** at start of hostname but found "example.**.com"' + ) + }) + + it('should throw when pathname has double asterisk in the middle', () => { + const p = { hostname: 'example.com', pathname: '/**/img' } as const + expect(() => m(p, new URL('https://example.com'))).toThrow( + 'Pattern can only contain ** at end of pathname but found "/**/img"' + ) }) it('should properly work with hasMatch', () => { From f67daa0cb85c0ef473824e40eccb9d8b18cd9309 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 20:51:08 -0400 Subject: [PATCH 32/33] Update docs --- docs/api-reference/next/image.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index b3158fc054ce..043f2487963f 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -361,7 +361,9 @@ module.exports = { Wildcard patterns can be used for both `pathname` and `hostname` and have the following syntax: - `*` match a single path segment or subdomain -- `**` match any number of path segments or subdomains +- `**` match any number of path segments at the end or subdomains at the beginning + +The `**` syntax does not work in the middle of the pattern. ### Domains From 72db8745b8b85cb4ec95d2224f5f6b81ab4905a6 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 4 May 2022 21:54:11 -0400 Subject: [PATCH 33/33] Add ts-ignore Co-authored-by: JJ Kasper --- test/unit/image-optimizer/match-remote-pattern.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/image-optimizer/match-remote-pattern.test.ts b/test/unit/image-optimizer/match-remote-pattern.test.ts index 7185dbde486e..cf4505507706 100644 --- a/test/unit/image-optimizer/match-remote-pattern.test.ts +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -170,6 +170,7 @@ describe('matchRemotePattern', () => { it('should throw when hostname is missing', () => { const p = { protocol: 'https' } as const + // @ts-ignore testing invalid input expect(() => m(p, new URL('https://example.com'))).toThrow( 'Pattern should define hostname but found\n{"protocol":"https"}' )