Skip to content

Commit

Permalink
feat: add support for NextJS remotePatterns (#11)
Browse files Browse the repository at this point in the history
* feat: refactor to include check for match

* feat: add correct comparison for url and remotePatterns

* feat: allow check for domains and remotePatterns instead of either/or

* chore: add to readme

* chore: reword

* chore: remove comment
  • Loading branch information
sarahetter authored Jun 9, 2022
1 parent e2c54c5 commit 06ffaf9
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 20 deletions.
55 changes: 50 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,70 @@ yarn add --dev @netlify/ipx
Create `netlify/functions/ipx.ts`:

```ts
import { createIPXHandler } from '@netlify/ipx'
import { createIPXHandler } from "@netlify/ipx";

export const handler = createIPXHandler({
domains: ['images.unsplash.com']
})
domains: ["images.unsplash.com"],
});
```

Now you can use IPX to optimize both local and remote assets ✨

Resize `/test.jpg` (in `dist`):

```html
<img src="/.netlify/functions/ipx/w_200/static/test.jpg"/>
<img src="/.netlify/functions/ipx/w_200/static/test.jpg" />
```

Resize and change format for a remote url:

```html
<img src="/.netlify/functions/ipx/f_webp,w_450/https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba"/>
<img
src="/.netlify/functions/ipx/f_webp,w_450/https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba"
/>
```

## Remote Patterns

Instead of setting an allowlist on `domains`, you may wish to use the option `remotePatterns`. This method allows wildcards in `hostname` and `pathname` segments.

`remotePatterns` is an array that contains RemotePattern objects:

```ts
remotePatterns: [
{
protocol: 'https' // or 'http' - not required
hostname: 'example.com' // required
port: '3000' // not required
pathname: '/blog/**' // not required
}
]
```

To use remote patterns, create `netlify/functions/ipx.ts`:

```ts
import { createIPXHandler } from "@netlify/ipx";

export const handler = createIPXHandler({
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
});
```

`hostname` and `pathname` may contain wildcards:

```ts
remotePatterns: [
{
hostname: '*.example.com' // * = match a single path segment or subdomain
pathname: '/blog/**' // ** = match any number of path segments or subdomains
}
]
```

## Local development
Expand Down
7 changes: 6 additions & 1 deletion example/netlify/functions/ipx.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { createIPXHandler } from '@netlify/ipx'

export const handler = createIPXHandler({
domains: ['images.unsplash.com'],
remotePatterns: [
{
protocol: 'https',
hostname: '*.unsplash.com'
}
],
basePath: '/.netlify/builders/ipx/'
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"etag": "^1.8.1",
"fs-extra": "^10.0.0",
"ipx": "^0.9.4",
"micromatch": "^4.0.5",
"mkdirp": "^1.0.4",
"murmurhash": "^2.0.0",
"node-fetch": "^2.0.0",
Expand Down
48 changes: 36 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ import { builder, Handler } from '@netlify/functions'
import { parseURL } from 'ufo'
import etag from 'etag'
import { loadSourceImage } from './http'
import { decodeBase64Params } from './utils'

import { decodeBase64Params, doPatternsMatchUrl, RemotePattern } from './utils'
export interface IPXHandlerOptions extends Partial<IPXOptions> {
cacheDir?: string
basePath?: string
propsEncoding?: 'base64' | undefined
bypassDomainCheck?: boolean
remotePatterns?: RemotePattern[]
}

export function createIPXHandler ({
cacheDir = join(tmpdir(), 'ipx-cache'),
basePath = '/_ipx/',
propsEncoding,
bypassDomainCheck,
remotePatterns,
...opts
}: IPXHandlerOptions = {}) {
const ipx = createIPX({ ...opts, dir: join(cacheDir, 'cache') })
Expand All @@ -29,6 +30,7 @@ export function createIPXHandler ({
const host = event.headers.host
const protocol = event.headers['x-forwarded-proto'] || 'http'
let domains = opts.domains || []
const remoteURLPatterns = remotePatterns || []
const requestEtag = event.headers['if-none-match']
const url = event.path.replace(basePath, '')

Expand Down Expand Up @@ -59,12 +61,6 @@ export function createIPXHandler ({
requestHeaders.authorization = event.headers.authorization
}
} else {
if (typeof domains === 'string') {
domains = (domains as string).split(',').map(s => s.trim())
}

const hosts = domains.map(domain => parseURL(domain, 'https://').host)

// Parse id as URL
const parsedUrl = parseURL(id, 'https://')

Expand All @@ -75,10 +71,38 @@ export function createIPXHandler ({
body: 'Hostname is missing: ' + id
}
}
if (!bypassDomainCheck && !hosts.find(host => parsedUrl.host === host)) {
return {
statusCode: 403,
body: 'Hostname not on allowlist: ' + parsedUrl.host

if (!bypassDomainCheck) {
let domainAllowed = true
let remotePatternAllowed = true

if (domains.length > 0) {
if (typeof domains === 'string') {
domains = (domains as string).split(',').map(s => s.trim())
}

const hosts = domains.map(domain => parseURL(domain, 'https://').host)

if (!hosts.find(host => parsedUrl.host === host)) {
domainAllowed = false
}
}

if (remoteURLPatterns.length > 0) {
const matchingRemotePattern = remoteURLPatterns.find((remotePattern) => {
return doPatternsMatchUrl(remotePattern, parsedUrl)
})

if (!matchingRemotePattern) {
remotePatternAllowed = false
}
}

if (!domainAllowed || !remotePatternAllowed) {
return {
statusCode: 403,
body: 'URL not on allowlist: ' + id
}
}
}
}
Expand Down
34 changes: 34 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { ParsedURL } from 'ufo'

import { makeRe } from 'micromatch'
/**
* Support for Gatsby-style base64-encoded URLs
*/
Expand Down Expand Up @@ -48,3 +51,34 @@ export function decodeBase64Params (path:string) {

return { id, modifiers: modifiers.join(',') }
}

export interface RemotePattern {
protocol?: 'http' | 'https';
hostname: string;
port?: string;
pathname?: string;
}

export function doPatternsMatchUrl (remotePattern: RemotePattern, parsedUrl: ParsedURL) {
if (remotePattern.protocol) {
// parsedUrl.protocol contains the : after the http/https, remotePattern does not
if (remotePattern.protocol !== parsedUrl.protocol.slice(0, -1)) {
return false
}
}

// ufo's ParsedURL doesn't separate out ports from hostname, so this formats next's RemotePattern to match that
const hostAndPort = remotePattern.port ? `${remotePattern.hostname}:${remotePattern.port}` : remotePattern.hostname

if (!makeRe(hostAndPort).test(parsedUrl.host)) {
return false
}

if (remotePattern.pathname) {
if (!makeRe(remotePattern.pathname).test(parsedUrl.pathname)) {
return false
}
}

return true
}
12 changes: 10 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"

braces@^3.0.1, braces@~3.0.2:
braces@^3.0.1, braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
Expand Down Expand Up @@ -2532,6 +2532,14 @@ micromatch@^4.0.4:
braces "^3.0.1"
picomatch "^2.2.3"

micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
dependencies:
braces "^3.0.2"
picomatch "^2.3.1"

[email protected]:
version "1.50.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.50.0.tgz#abd4ac94e98d3c0e185016c67ab45d5fde40c11f"
Expand Down Expand Up @@ -2954,7 +2962,7 @@ pathe@^0.2.0:
resolved "https://registry.yarnpkg.com/pathe/-/pathe-0.2.0.tgz#30fd7bbe0a0d91f0e60bae621f5d19e9e225c339"
integrity sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==

picomatch@^2.0.4, picomatch@^2.2.1:
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
Expand Down

0 comments on commit 06ffaf9

Please sign in to comment.