Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(next/image): add support for images.remotePatterns.search #70302

Merged
merged 3 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions docs/02-app/02-api-reference/01-components/image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -503,15 +503,16 @@ module.exports = {
hostname: 'example.com',
port: '',
pathname: '/account123/**',
search: '',
},
],
},
}
```

> **Good to know**: 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.
> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `https://example.com/account123/` and must not have a query string. 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:
Below is an example of the `remotePatterns` property in the `next.config.js` file using a wildcard pattern in the `hostname`:

```js filename="next.config.js"
module.exports = {
Expand All @@ -521,13 +522,14 @@ module.exports = {
protocol: 'https',
hostname: '**.example.com',
port: '',
search: '',
},
],
},
}
```

> **Good to know**: 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, port, or unmatched hostname will respond with 400 Bad Request.
> **Good to know**: 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. It cannot have a port or query string. 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:

Expand All @@ -536,7 +538,25 @@ Wildcard patterns can be used for both `pathname` and `hostname` and have the fo

The `**` syntax does not work in the middle of the pattern.

> **Good to know**: When omitting `protocol`, `port` or `pathname`, then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend.
> **Good to know**: When omitting `protocol`, `port`, `pathname`, or `search` then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend.

Below is an example of the `remotePatterns` property in the `next.config.js` file using `search`:

```js filename="next.config.js"
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'assets.example.com',
search: '?v=1727111025337',
},
],
},
}
```

> **Good to know**: The example above will ensure the `src` property of `next/image` must start with `https://assets.example.com` and must have the exact query string `?v=1727111025337`. Any other protocol or query string will respond with 400 Bad Request.

### `domains`

Expand Down
28 changes: 24 additions & 4 deletions docs/03-pages/02-api-reference/01-components/image-legacy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -362,15 +362,16 @@ module.exports = {
hostname: 'example.com',
port: '',
pathname: '/account123/**',
search: '',
},
],
},
}
```

> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://example.com/account123/`. Any other protocol, hostname, port, or unmatched path will respond with 400 Bad Request.
> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://example.com/account123/` and must not have a query string. 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:
Below is an example of the `remotePatterns` property in the `next.config.js` file using a wildcard pattern in the `hostname`:

```js filename="next.config.js"
module.exports = {
Expand All @@ -380,13 +381,14 @@ module.exports = {
protocol: 'https',
hostname: '**.example.com',
port: '',
search: '',
},
],
},
}
```

> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://img1.example.com` or `https://me.avatar.example.com` or any number of subdomains. Any other protocol, port, or unmatched hostname will respond with 400 Bad Request.
> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://img1.example.com` or `https://me.avatar.example.com` or any number of subdomains. It cannot have a port or query string. 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:

Expand All @@ -395,7 +397,25 @@ Wildcard patterns can be used for both `pathname` and `hostname` and have the fo

The `**` syntax does not work in the middle of the pattern.

> **Good to know**: When omitting `protocol`, `port` or `pathname`, then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend.
> **Good to know**: When omitting `protocol`, `port`, `pathname`, or `search` then the wildcard `**` is implied. This is not recommended because it may allow malicious actors to optimize urls you did not intend.

Below is an example of the `remotePatterns` property in the `next.config.js` file using `search`:

```js filename="next.config.js"
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'assets.example.com',
search: '?v=1727111025337',
},
],
},
}
```

> **Good to know**: The example above will ensure the `src` property of `next/legacy/image` must start with `https://assets.example.com` and must have the exact query string `?v=1727111025337`. Any other protocol or query string will respond with 400 Bad Request.

### Domains

Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,11 +501,12 @@ async function writeImagesManifest(
const { deviceSizes, imageSizes } = images
;(images as any).sizes = [...deviceSizes, ...imageSizes]
images.remotePatterns = (config?.images?.remotePatterns || []).map((p) => ({
// Should be the same as matchRemotePattern()
// Modifying the manifest should also modify matchRemotePattern()
protocol: p.protocol,
hostname: makeRe(p.hostname).source,
port: p.port,
pathname: makeRe(p.pathname ?? '**', { dot: true }).source,
search: p.search,
}))

await writeManifest(path.join(distDir, IMAGES_MANIFEST), {
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
pathname: z.string().optional(),
port: z.string().max(5).optional(),
protocol: z.enum(['http', 'https']).optional(),
search: z.string().optional(),
})
)
.max(50)
Expand Down
6 changes: 6 additions & 0 deletions packages/next/src/shared/lib/image-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export type RemotePattern = {
* Double `**` matches any number of path segments.
*/
pathname?: string

/**
* Can be literal query string such as `?v=1` or
* empty string meaning no query string.
*/
search?: string
}

type ImageFormat = 'image/avif' | 'image/webp'
Expand Down
8 changes: 8 additions & 0 deletions packages/next/src/shared/lib/match-remote-pattern.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RemotePattern } from './image-config'
import { makeRe } from 'next/dist/compiled/picomatch'

// Modifying this function should also modify writeImagesManifest()
export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean {
if (pattern.protocol !== undefined) {
const actualProto = url.protocol.slice(0, -1)
Expand All @@ -24,6 +25,13 @@ export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean {
}
}

if (pattern.search !== undefined) {
if (pattern.search !== url.search) {
return false
}
}

// Should be the same as writeImagesManifest()
if (!makeRe(pattern.pathname ?? '**', { dot: true }).test(url.pathname)) {
return false
}
Expand Down
87 changes: 87 additions & 0 deletions test/unit/image-optimizer/match-remote-pattern.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ describe('matchRemotePattern', () => {
expect(m(p, new URL('http://example.com:81/path/to/file'))).toBe(false)
})

it('should match literal protocol, hostname, no port, no search', () => {
const p = {
protocol: 'https',
hostname: 'example.com',
port: '',
search: '',
} 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(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)
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',
Expand Down Expand Up @@ -107,6 +127,73 @@ describe('matchRemotePattern', () => {
expect(m(p, new URL('https://example.com:81/path/to/file?q=1'))).toBe(false)
})

it('should match literal protocol, hostname, port, pathname, search', () => {
const p = {
protocol: 'https',
hostname: 'example.com',
port: '42',
pathname: '/path/to/file',
search: '?q=1&a=two&s=!@$^&-_+/()[]{};:~',
} 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(false)
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)
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('https://example.com:42/path/to/file?q=1'))).toBe(false)
expect(m(p, new URL('https://example.com:42/path/to/file?q=1&a=two'))).toBe(
false
)
expect(
m(p, new URL('https://example.com:42/path/to/file?q=1&a=two&s'))
).toBe(false)
expect(
m(p, new URL('https://example.com:42/path/to/file?q=1&a=two&s='))
).toBe(false)
expect(
m(p, new URL('https://example.com:42/path/to/file?q=1&a=two&s=!@'))
).toBe(false)
expect(
m(
p,
new URL(
'https://example.com:42/path/to/file?q=1&a=two&s=!@$^&-_+/()[]{};:~'
)
)
).toBe(true)
expect(
m(
p,
new URL(
'https://example.com:42/path/to/file?q=1&s=!@$^&-_+/()[]{};:~&a=two'
)
)
).toBe(false)
expect(
m(
p,
new URL(
'https://example.com:42/path/to/file?a=two&q=1&s=!@$^&-_+/()[]{};:~'
)
)
).toBe(false)
})

it('should match hostname pattern with single asterisk by itself', () => {
const p = { hostname: 'avatars.*.example.com' } as const
expect(m(p, new URL('https://com'))).toBe(false)
Expand Down
Loading