Skip to content

Commit 38a06e7

Browse files
authored
feat: adds support for both client-side and server-side remote URL uploads fetching (#10004)
### What? The `pasteURL` feature for Upload fields has been updated to support both **client-side** and **server-side** URL fetching. Previously, users could only paste URLs from the same domain as their Payload instance (internal) or public domains, which led to **CORS** errors when trying to fetch files from external URLs. Now, users can choose between **client-side fetching** (default) and **server-side fetching** using the new `pasteURL` option in the Upload collection config. ### How? - By default, Payload will attempt to fetch the file client-side directly in the browser. - To enable server-side fetching, you can configure the new `pasteURL` option with an `allowList` of trusted domains. - The new `/api/:collectionSlug/paste-url` endpoint is used to fetch files server-side and stream them back to the browser. #### Example ``` import type { CollectionConfig } from 'payload' export const Media: CollectionConfig = { slug: 'media', upload: { // pasteURL: false, // Can now disable the pasteURL option entirely by passing "false". pasteURL: { allowList: [ { hostname: 'payloadcms.com', // required pathname: '', port: '', protocol: 'https', // defaults to https - options: "https" | "http" search: '' }, { hostname: 'example.com', pathname: '/images/*', }, ], }, }, } ``` ### Why This update provides more flexibility for users to paste URLs into Upload fields without running into **CORS errors** and allows Payload to securely fetch files from trusted domains.
1 parent 28b7c04 commit 38a06e7

File tree

13 files changed

+493
-52
lines changed

13 files changed

+493
-52
lines changed

docs/upload/overview.mdx

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -88,26 +88,27 @@ export const Media: CollectionConfig = {
8888

8989
_An asterisk denotes that an option is required._
9090

91-
| Option | Description |
92-
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93-
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
94-
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
95-
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
96-
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
97-
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
98-
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
99-
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. |
100-
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
101-
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
102-
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
103-
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
104-
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
105-
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
106-
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
107-
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
108-
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
109-
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
110-
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
91+
| Option | Description |
92+
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93+
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
94+
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
95+
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
96+
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
97+
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
98+
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
99+
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. |
100+
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
101+
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
102+
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
103+
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
104+
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
105+
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
106+
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
107+
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
108+
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
109+
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
110+
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
111+
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
111112

112113

113114
### Payload-wide Upload Options
@@ -327,6 +328,64 @@ fetch('api/:upload-slug', {
327328
})
328329
```
329330

331+
## Uploading Files from Remote URLs
332+
333+
The `pasteURL` option allows users to fetch files from remote URLs by pasting them into an Upload field. This option is **enabled by default** and can be configured to either **allow unrestricted client-side fetching** or **restrict server-side fetching** to specific trusted domains.
334+
335+
By default, Payload uses **client-side fetching**, where the browser downloads the file directly from the provided URL. However, **client-side fetching will fail if the URL’s server has CORS restrictions**, making it suitable only for internal URLs or public URLs without CORS blocks.
336+
337+
To fetch files from **restricted URLs** that would otherwise be blocked by CORS, use **server-side fetching** by configuring the `pasteURL` option with an `allowList` of trusted domains. This method ensures that Payload downloads the file on the server and streams it to the browser. However, for security reasons, only URLs that match the specified `allowList` will be allowed.
338+
339+
#### Configuration Example
340+
341+
Here’s how to configure the pasteURL option to control remote URL fetching:
342+
343+
```
344+
import type { CollectionConfig } from 'payload'
345+
346+
export const Media: CollectionConfig = {
347+
slug: 'media',
348+
upload: {
349+
pasteURL: {
350+
allowList: [
351+
{
352+
hostname: 'payloadcms.com', // required
353+
pathname: '',
354+
port: '',
355+
protocol: 'https',
356+
search: ''
357+
},
358+
{
359+
hostname: 'example.com',
360+
pathname: '/images/*',
361+
},
362+
],
363+
},
364+
},
365+
}
366+
```
367+
368+
##### Accepted Values for `pasteURL`
369+
370+
| Option | Description |
371+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
372+
| **`undefined`** | Default behavior. Enables client-side fetching for internal or public URLs. |
373+
| **`false`** | Disables the ability to paste URLs into Upload fields. |
374+
| **`allowList`** | Enables server-side fetching for specific trusted URLs. Requires an array of objects defining trusted domains. See the table below for details on `AllowItem`. |
375+
376+
##### `AllowItem` Properties
377+
378+
_An asterisk denotes that an option is required._
379+
380+
| Option | Description | Example |
381+
| ---------------- | ---------------------------------------------------------------------------------------------------- | ------------- |
382+
| **`hostname`** * | The hostname of the allowed URL. This is required to ensure the URL is coming from a trusted source. | `example.com` |
383+
| **`pathname`** | The path portion of the URL. Supports wildcards to match multiple paths. | `/images/*` |
384+
| **`port`** | The port number of the URL. If not specified, the default port for the protocol will be used. | `3000` |
385+
| **`protocol`** | The protocol to match. Must be either `http` or `https`. Defaults to `https`. | `https` |
386+
| **`search`** | The query string of the URL. If specified, the URL must match this exact query string. | `?version=1` |
387+
388+
330389
## Access Control
331390

332391
All files that are uploaded to each Collection automatically support the `read` [Access Control](/docs/access-control/overview) function from the Collection itself. You can use this to control who should be allowed to see your uploads, and who should not.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { PayloadHandler } from '../../config/types.js'
2+
3+
import executeAccess from '../../auth/executeAccess.js'
4+
import { APIError } from '../../errors/APIError.js'
5+
import { Forbidden } from '../../errors/Forbidden.js'
6+
import { getRequestCollectionWithID } from '../../utilities/getRequestEntity.js'
7+
import { isURLAllowed } from '../../utilities/isURLAllowed.js'
8+
9+
// If doc id is provided, it means we are updating the doc
10+
// /:collectionSlug/paste-url/:doc-id?src=:fileUrl
11+
12+
// If doc id is not provided, it means we are creating a new doc
13+
// /:collectionSlug/paste-url?src=:fileUrl
14+
15+
export const getFileFromURLHandler: PayloadHandler = async (req) => {
16+
const { id, collection } = getRequestCollectionWithID(req, { optionalID: true })
17+
18+
if (!req.user) {
19+
throw new Forbidden(req.t)
20+
}
21+
22+
const config = collection?.config
23+
24+
if (id) {
25+
// updating doc
26+
const accessResult = await executeAccess({ req }, config.access.update)
27+
if (!accessResult) {
28+
throw new Forbidden(req.t)
29+
}
30+
} else {
31+
// creating doc
32+
const accessResult = await executeAccess({ req }, config.access?.create)
33+
if (!accessResult) {
34+
throw new Forbidden(req.t)
35+
}
36+
}
37+
try {
38+
if (!req.url) {
39+
throw new APIError('Request URL is missing.', 400)
40+
}
41+
42+
const { searchParams } = new URL(req.url)
43+
const src = searchParams.get('src')
44+
45+
if (!src || typeof src !== 'string') {
46+
throw new APIError('A valid URL string is required.', 400)
47+
}
48+
49+
const validatedUrl = new URL(src)
50+
51+
if (
52+
typeof config.upload?.pasteURL === 'object' &&
53+
!isURLAllowed(validatedUrl.href, config.upload.pasteURL.allowList)
54+
) {
55+
throw new APIError(`The provided URL (${validatedUrl.href}) is not allowed.`, 400)
56+
}
57+
58+
// Fetch the file with no compression
59+
const response = await fetch(validatedUrl.href, {
60+
headers: {
61+
'Accept-Encoding': 'identity',
62+
},
63+
})
64+
65+
if (!response.ok) {
66+
throw new APIError(`Failed to fetch file from ${validatedUrl.href}`, response.status)
67+
}
68+
69+
const decodedFileName = decodeURIComponent(validatedUrl.pathname.split('/').pop() || '')
70+
71+
return new Response(response.body, {
72+
headers: {
73+
'Content-Disposition': `attachment; filename="${decodedFileName}"`,
74+
'Content-Length': response.headers.get('content-length') || '',
75+
'Content-Type': response.headers.get('content-type') || 'application/octet-stream',
76+
},
77+
})
78+
} catch (error) {
79+
throw new APIError(`Error fetching file: ${error.message}`, 500)
80+
}
81+
}

packages/payload/src/collections/endpoints/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { findByIDHandler } from './findByID.js'
1313
import { findVersionByIDHandler } from './findVersionByID.js'
1414
import { findVersionsHandler } from './findVersions.js'
1515
import { getFileHandler } from './getFile.js'
16+
import { getFileFromURLHandler } from './getFileFromURL.js'
1617
import { previewHandler } from './preview.js'
1718
import { restoreVersionHandler } from './restoreVersion.js'
1819
import { updateHandler } from './update.js'
@@ -46,6 +47,11 @@ export const defaultCollectionEndpoints: Endpoint[] = [
4647
method: 'post',
4748
path: '/access/:id?',
4849
},
50+
{
51+
handler: getFileFromURLHandler,
52+
method: 'get',
53+
path: '/paste-url/:id?',
54+
},
4955
{
5056
handler: findVersionsHandler,
5157
method: 'get',

packages/payload/src/uploads/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ export type ImageSize = {
8383

8484
export type GetAdminThumbnail = (args: { doc: Record<string, unknown> }) => false | null | string
8585

86+
export type AllowList = Array<{
87+
hostname: string
88+
pathname?: string
89+
port?: string
90+
protocol?: 'http' | 'https'
91+
search?: string
92+
}>
93+
8694
export type UploadConfig = {
8795
/**
8896
* The adapter name to use for uploads. Used for storage adapter telemetry.
@@ -175,6 +183,17 @@ export type UploadConfig = {
175183
* @default undefined
176184
*/
177185
modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers
186+
/**
187+
* Controls the behavior of pasting/uploading files from URLs.
188+
* If set to `false`, fetching from remote URLs is disabled.
189+
* If an allowList is provided, server-side fetching will be enabled for specified URLs.
190+
* @default true (client-side fetching enabled)
191+
*/
192+
pasteURL?:
193+
| {
194+
allowList: AllowList
195+
}
196+
| false
178197
/**
179198
* Sharp resize options for the original image.
180199
* @link https://sharp.pixelplumbing.com/api-resize#resize

0 commit comments

Comments
 (0)