diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index dedee45bfb57..043f2487963f 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.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. | @@ -313,9 +314,64 @@ Other properties on the `` component will be passed to the underlying ## Configuration Options +### Remote Patterns + +> 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: + +```js +module.exports = { + experimental: { + 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 = { + experimental: { + 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 at the end or subdomains at the beginning + +The `**` syntax does not work in the middle of the pattern. + ### 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. + +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..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](#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#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,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 hostnames you intend to allow remote access. -```js -module.exports = { - images: { - domains: ['example.com', 'example2.com'], - }, -} -``` +> Learn more about [`domains`](/docs/api-reference/next/image.md#domains) 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#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) 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/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), diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index b7afc07101fe..346ec8fb31a0 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1467,6 +1467,8 @@ export default async function getBaseWebpackConfig( ? { // pass domains in development to allow validating on the client domains: config.images.domains, + experimentalRemotePatterns: + config.experimental?.images?.remotePatterns, } : {}), }), diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index 35fbb6adbd0c..052e38fe58b6 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 = false, 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) { + if ( + !src.startsWith('/') && + (config.domains || experimentalRemotePatterns) + ) { let parsedSrc: URL try { parsedSrc = new URL(src) @@ -1074,14 +1077,15 @@ 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 !== '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)) { + 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 ebde47189cc0..5d3b4b9e1528 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 @@ -115,6 +116,7 @@ export interface ExperimentalConfig { outputStandalone?: boolean images?: { layoutRaw: boolean + remotePatterns: RemotePattern[] } middlewareSourceMaps?: boolean emotion?: @@ -501,6 +503,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 4ab419fa84fe..40b74df35c10 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -234,6 +234,43 @@ function assignDefaults(userConfig: { [key: string]: any }) { ) } } + + const remotePatterns = result.experimental?.images?.remotePatterns + if (remotePatterns) { + if (!Array.isArray(remotePatterns)) { + throw new Error( + `Specified images.remotePatterns should be an Array received ${typeof remotePatterns}.\nSee more info here: https://nextjs.org/docs/messages/invalid-images-config` + ) + } + + if (remotePatterns.length > 50) { + throw new Error( + `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 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)) + ) + if (invalidPatterns.length > 0) { + throw new Error( + `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` + ) + } + } + if (images.deviceSizes) { const { deviceSizes } = images if (!Array.isArray(deviceSizes)) { diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index ce8017b066f0..f177efbe4599 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -17,6 +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 } from '../shared/lib/match-remote-pattern' type XCacheHeader = 'MISS' | 'HIT' | 'STALE' @@ -75,6 +76,7 @@ export class ImageOptimizerCache { minimumCacheTTL = 60, formats = ['image/webp'], } = imageData + const remotePatterns = nextConfig.experimental.images?.remotePatterns || [] const { url, w, q } = query let href: string @@ -104,7 +106,7 @@ export class ImageOptimizerCache { return { errorMessage: '"url" parameter is invalid' } } - if (!domains || !domains.includes(hrefParsed.hostname)) { + if (!hasMatch(domains, remotePatterns, hrefParsed)) { return { errorMessage: '"url" parameter is not allowed' } } } diff --git a/packages/next/shared/lib/image-config.ts b/packages/next/shared/lib/image-config.ts index 001745447c1a..d60e966ccf30 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' /** @@ -28,7 +55,9 @@ 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) */ + /** + * @see [Image domains configuration](https://nextjs.org/docs/api-reference/next/image#domains) + */ domains: string[] /** @see [Cache behavior](https://nextjs.org/docs/api-reference/next/image#caching-behavior) */ 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..4db09ee4451e --- /dev/null +++ b/packages/next/shared/lib/match-remote-pattern.ts @@ -0,0 +1,84 @@ +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. 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] === '*') { + // 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) { + 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() + 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. 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] === '*') { + // 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 { + return ( + domains.some((domain) => url.hostname === domain) || + remotePatterns.some((p) => matchRemotePattern(p, url)) + ) +} diff --git a/packages/next/telemetry/events/version.ts b/packages/next/telemetry/events/version.ts index c7928e2cda71..f8c313363e41 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 imageFormats: string | null @@ -65,6 +66,7 @@ export function eventCliSession( | 'localeDomainsCount' | 'localeDetectionEnabled' | 'imageDomainsCount' + | 'imageRemotePatternsCount' | 'imageSizes' | 'imageLoader' | 'imageFormats' @@ -77,7 +79,7 @@ export function eventCliSession( return [] } - const { images, i18n } = nextConfig || {} + const { images, i18n, experimental } = nextConfig || {} const payload: EventCliSessionStarted = { nextVersion: process.env.__NEXT_VERSION, @@ -97,6 +99,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: experimental?.images?.remotePatterns + ? experimental.images.remotePatterns.length + : null, imageSizes: images?.imageSizes ? images.imageSizes.join(',') : null, imageLoader: images?.loader, imageFormats: images?.formats ? images.formats.join(',') : null, 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` } diff --git a/test/integration/image-component/unicode/next.config.js b/test/integration/image-component/unicode/next.config.js index eb554b46c10c..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: { - domains: ['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 bcac0f864552..bd21d869e6cc 100644 --- a/test/integration/image-optimizer/test/index.test.js +++ b/test/integration/image-optimizer/test/index.test.js @@ -48,6 +48,89 @@ describe('Image Optimizer', () => { ) }) + it('should error when remotePatterns length exceeds 50', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + experimental: { + 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 prop', async () => { + await nextConfig.replace( + '{ /* replaceme */ }', + JSON.stringify({ + experimental: { + images: { + remotePatterns: [{ hostname: 'example.com', foo: 'bar' }], + }, + }, + }) + ) + 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{"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"}' + ) + }) + 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 1d4613dbbd5c..be4e2bdfc528 100644 --- a/test/integration/image-optimizer/test/util.js +++ b/test/integration/image-optimizer/test/util.js @@ -136,7 +136,7 @@ export function runTests(ctx) { slowImageServer.stop() }) - if (ctx.domains.includes('localhost')) { + if (ctx.domains?.length > 0) { it('should normalize invalid status codes', async () => { const url = `http://localhost:${ slowImageServer.port @@ -583,7 +583,7 @@ export function runTests(ctx) { }) } - if (ctx.domains.includes('localhost')) { + 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 } @@ -762,7 +762,7 @@ export function runTests(ctx) { ) }) - if (ctx.domains.includes('localhost')) { + 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 } @@ -1119,7 +1119,7 @@ export function runTests(ctx) { expect(await res.text()).toBe("The requested resource isn't a valid image.") }) - if (ctx.domains.length) { + if (ctx.domains?.length > 0) { it('should handle concurrent requests', async () => { await cleanImagesDir(ctx) const delay = 500 diff --git a/test/integration/telemetry/next.config.i18n-images b/test/integration/telemetry/next.config.i18n-images index 0af3a77e421a..4b7a8d7ce6ef 100644 --- a/test/integration/telemetry/next.config.i18n-images +++ b/test/integration/telemetry/next.config.i18n-images @@ -3,7 +3,12 @@ module.exports = phase => { images: { formats: ['image/avif', 'image/webp'], imageSizes: [64, 128, 256, 512, 1024], - domains: ['example.com'], + domains: ['example.com', 'another.com'], + }, + experimental: { + images: { + 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 fbd4fa1a6732..de7e41d6790f 100644 --- a/test/integration/telemetry/test/index.test.js +++ b/test/integration/telemetry/test/index.test.js @@ -502,7 +502,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(/"imageFormats": "image\/avif,image\/webp"/) expect(event1).toMatch(/"trailingSlashEnabled": false/) @@ -538,7 +539,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/) 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..cf4505507706 --- /dev/null +++ b/test/unit/image-optimizer/match-remote-pattern.test.ts @@ -0,0 +1,236 @@ +/* eslint-env jest */ +import { + matchRemotePattern as m, + hasMatch, +} from 'next/dist/shared/lib/match-remote-pattern' + +describe('matchRemotePattern', () => { + it('should match literal hostname', () => { + const p = { hostname: 'example.com' } as const + 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(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(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 42', () => { + const p = { + protocol: 'https', + hostname: 'example.com', + port: '42', + } as const + 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: '42', + pathname: '/path/to/file', + } as const + 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(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(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/*/pic.jpg', + } as const + 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(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) + }) + + 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"}' + ) + }) + + 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', () => { + 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) + }) +})