Skip to content

Commit 8282031

Browse files
authored
feat: expose multipart/form-data parsing options (#13766)
When sending REST API requests with multipart/form-data, e.g. PATCH or POST within the admin panel, a request body larger than 1MB throws the following error: ``` Unterminated string in JSON at position... ``` This is because there are sensible defaults imposed by the HTML form data parser (currently using [busboy](https://github.com/fastify/busboy)). If your documents exceed this limit, you may run into this error when editing them within the admin panel. To support large documents over 1MB, use the new `bodyParser` property on the root config: ```ts import { buildConfig } from 'payload' const config = buildConfig({ // ... bodyParser: { limits: { fieldSize: 2 * 1024 * 1024, // This will allow requests containing up to 2MB of multipart/form-data } } } ``` --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211317005907885
1 parent 3af546e commit 8282031

File tree

12 files changed

+190
-22
lines changed

12 files changed

+190
-22
lines changed

packages/next/src/routes/rest/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const handlerBuilder =
1414
): Promise<Response> => {
1515
const awaitedConfig = await config
1616

17-
// Add this endpoint only when using Next.js, still can be overriden.
17+
// Add this endpoint only when using Next.js, still can be overridden.
1818
if (
1919
initedOGEndpoint === false &&
2020
!awaitedConfig.endpoints.some(

packages/payload/src/config/types.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export type InitOptions = {
275275
disableOnInit?: boolean
276276

277277
importMap?: ImportMap
278+
278279
/**
279280
* A function that is called immediately following startup that receives the Payload instance as it's only argument.
280281
*/
@@ -980,6 +981,7 @@ export type Config = {
980981
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
981982
user?: string
982983
}
984+
983985
/**
984986
* Configure authentication-related Payload-wide settings.
985987
*/
@@ -993,6 +995,15 @@ export type Config = {
993995
/** Custom Payload bin scripts can be injected via the config. */
994996
bin?: BinScriptConfig[]
995997
blocks?: Block[]
998+
/**
999+
* Pass additional options to the parser used to process `multipart/form-data` requests.
1000+
* For example, a PATCH request containing HTML form data.
1001+
* For example, you may want to increase the `limits` imposed by the parser.
1002+
* Currently using @link {https://www.npmjs.com/package/busboy|busboy} under the hood.
1003+
*
1004+
* @experimental This property is experimental and may change in future releases. Use at your own discretion.
1005+
*/
1006+
bodyParser?: Partial<BusboyConfig>
9961007
/**
9971008
* Manage the datamodel of your application
9981009
*
@@ -1024,10 +1035,8 @@ export type Config = {
10241035
cors?: '*' | CORSConfig | string[]
10251036
/** A whitelist array of URLs to allow Payload cookies to be accepted from as a form of CSRF protection. */
10261037
csrf?: string[]
1027-
10281038
/** Extension point to add your custom data. Server only. */
10291039
custom?: Record<string, any>
1030-
10311040
/** Pass in a database adapter for use on this project. */
10321041
db: DatabaseAdapterResult
10331042
/** Enable to expose more detailed error information. */

packages/payload/src/uploads/fetchAPI-multipart/index.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { isEligibleRequest } from './isEligibleRequest.js'
77
import { processMultipart } from './processMultipart.js'
88
import { debugLog } from './utilities.js'
99

10-
const DEFAULT_OPTIONS: FetchAPIFileUploadOptions = {
10+
const DEFAULT_UPLOAD_OPTIONS: FetchAPIFileUploadOptions = {
1111
abortOnLimit: false,
1212
createParentPath: false,
1313
debug: false,
@@ -53,16 +53,22 @@ type FetchAPIFileUpload = (args: {
5353
options?: FetchAPIFileUploadOptions
5454
request: Request
5555
}) => Promise<FetchAPIFileUploadResponse>
56-
export const fetchAPIFileUpload: FetchAPIFileUpload = async ({ options, request }) => {
57-
const uploadOptions: FetchAPIFileUploadOptions = { ...DEFAULT_OPTIONS, ...options }
56+
57+
export const processMultipartFormdata: FetchAPIFileUpload = async ({
58+
options: incomingOptions,
59+
request,
60+
}) => {
61+
const options: FetchAPIFileUploadOptions = { ...DEFAULT_UPLOAD_OPTIONS, ...incomingOptions }
62+
5863
if (!isEligibleRequest(request)) {
59-
debugLog(uploadOptions, 'Request is not eligible for file upload!')
64+
debugLog(options, 'Request is not eligible for file upload!')
65+
6066
return {
6167
error: new APIError('Request is not eligible for file upload', 500),
6268
fields: undefined!,
6369
files: undefined!,
6470
}
6571
} else {
66-
return processMultipart({ options: uploadOptions, request })
72+
return processMultipart({ options, request })
6773
}
6874
}

packages/payload/src/utilities/addDataAndFileToRequest.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { PayloadRequest } from '../types/index.js'
22

33
import { APIError } from '../errors/APIError.js'
4-
import { fetchAPIFileUpload } from '../uploads/fetchAPI-multipart/index.js'
4+
import { processMultipartFormdata } from '../uploads/fetchAPI-multipart/index.js'
55

66
type AddDataAndFileToRequest = (req: PayloadRequest) => Promise<void>
77

@@ -24,12 +24,15 @@ export const addDataAndFileToRequest: AddDataAndFileToRequest = async (req) => {
2424
req.payload.logger.error(error)
2525
} finally {
2626
req.data = data
27-
// @ts-expect-error
27+
// @ts-expect-error attach json method to request
2828
req.json = () => Promise.resolve(data)
2929
}
3030
} else if (bodyByteSize && contentType?.includes('multipart/')) {
31-
const { error, fields, files } = await fetchAPIFileUpload({
32-
options: payload.config.upload,
31+
const { error, fields, files } = await processMultipartFormdata({
32+
options: {
33+
...(payload.config.bodyParser || {}),
34+
...(payload.config.upload || {}),
35+
},
3336
request: req as Request,
3437
})
3538

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
export const largeDocumentsCollectionSlug = 'large-documents'
4+
5+
export const LargeDocuments: CollectionConfig = {
6+
slug: largeDocumentsCollectionSlug,
7+
fields: [
8+
{
9+
name: 'array',
10+
type: 'array',
11+
fields: [
12+
{
13+
name: 'text',
14+
type: 'text',
15+
},
16+
],
17+
},
18+
],
19+
}

test/collections-rest/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { APIError, type CollectionConfig, type Endpoint } from 'payload'
66

77
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
88
import { devUser } from '../credentials.js'
9+
import { LargeDocuments } from './collections/LargeDocuments.js'
910

1011
export interface Relation {
1112
id: string
@@ -280,7 +281,13 @@ export default buildConfigWithDefaults({
280281
],
281282
disableBulkEdit: true,
282283
},
284+
LargeDocuments,
283285
],
286+
bodyParser: {
287+
limits: {
288+
fieldSize: 2 * 1024 * 1024, // 2MB
289+
},
290+
},
284291
endpoints: [
285292
{
286293
handler: async ({ payload }) => {

test/collections-rest/int.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Payload, SanitizedCollectionConfig } from 'payload'
22

33
import { randomBytes, randomUUID } from 'crypto'
4+
import { serialize } from 'object-to-formdata'
45
import path from 'path'
56
import { APIError, NotFound } from 'payload'
67
import { fileURLToPath } from 'url'
@@ -9,7 +10,9 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js'
910
import type { Relation } from './config.js'
1011
import type { Post } from './payload-types.js'
1112

13+
import { getFormDataSize } from '../helpers/getFormDataSize.js'
1214
import { initPayloadInt } from '../helpers/initPayloadInt.js'
15+
import { largeDocumentsCollectionSlug } from './collections/LargeDocuments.js'
1316
import {
1417
customIdNumberSlug,
1518
customIdSlug,
@@ -119,6 +122,47 @@ describe('collections-rest', () => {
119122
expect(doc.description).toEqual(description) // Check was not modified
120123
})
121124

125+
it('can handle REST API requests with over 1mb of multipart/form-data', async () => {
126+
const doc = await payload.create({
127+
collection: largeDocumentsCollectionSlug,
128+
data: {},
129+
})
130+
131+
const arrayData = new Array(500).fill({ text: randomUUID().repeat(100) })
132+
133+
// Now use the REST API and attempt to PATCH the document with a payload over 1mb
134+
const dataToSerialize: Record<string, unknown> = {
135+
_payload: JSON.stringify({
136+
title: 'Hello, world!',
137+
// fill with long, random string of text to exceed 1mb
138+
array: arrayData,
139+
}),
140+
}
141+
142+
const formData: FormData = serialize(dataToSerialize, {
143+
indices: true,
144+
nullsAsUndefineds: false,
145+
})
146+
147+
// Ensure the form data we are about to send is greater than the default limit (1mb)
148+
// But less than the increased limit that we've set in the root config (2mb)
149+
const docSize = getFormDataSize(formData)
150+
expect(docSize).toBeGreaterThan(1 * 1024 * 1024)
151+
expect(docSize).toBeLessThan(2 * 1024 * 1024)
152+
153+
// This request should not fail with error: "Unterminated string in JSON at position..."
154+
// This is because we set `bodyParser.limits.fieldSize` to 2mb in the root config
155+
const res = await restClient
156+
.PATCH(`/${largeDocumentsCollectionSlug}/${doc.id}?limit=1`, {
157+
body: formData,
158+
})
159+
.then((res) => res.json())
160+
161+
expect(res).not.toHaveProperty('errors')
162+
expect(res.doc.id).toEqual(doc.id)
163+
expect(res.doc.array[0].text).toEqual(arrayData[0].text)
164+
})
165+
122166
describe('Bulk operations', () => {
123167
it('should bulk update', async () => {
124168
for (let i = 0; i < 11; i++) {

test/collections-rest/payload-types.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface Config {
7676
'error-on-hooks': ErrorOnHook;
7777
endpoints: Endpoint;
7878
'disabled-bulk-edit-docs': DisabledBulkEditDoc;
79+
'large-documents': LargeDocument;
7980
users: User;
8081
'payload-locked-documents': PayloadLockedDocument;
8182
'payload-preferences': PayloadPreference;
@@ -92,6 +93,7 @@ export interface Config {
9293
'error-on-hooks': ErrorOnHooksSelect<false> | ErrorOnHooksSelect<true>;
9394
endpoints: EndpointsSelect<false> | EndpointsSelect<true>;
9495
'disabled-bulk-edit-docs': DisabledBulkEditDocsSelect<false> | DisabledBulkEditDocsSelect<true>;
96+
'large-documents': LargeDocumentsSelect<false> | LargeDocumentsSelect<true>;
9597
users: UsersSelect<false> | UsersSelect<true>;
9698
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
9799
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -259,6 +261,21 @@ export interface DisabledBulkEditDoc {
259261
updatedAt: string;
260262
createdAt: string;
261263
}
264+
/**
265+
* This interface was referenced by `Config`'s JSON-Schema
266+
* via the `definition` "large-documents".
267+
*/
268+
export interface LargeDocument {
269+
id: string;
270+
array?:
271+
| {
272+
text?: string | null;
273+
id?: string | null;
274+
}[]
275+
| null;
276+
updatedAt: string;
277+
createdAt: string;
278+
}
262279
/**
263280
* This interface was referenced by `Config`'s JSON-Schema
264281
* via the `definition` "users".
@@ -274,6 +291,13 @@ export interface User {
274291
hash?: string | null;
275292
loginAttempts?: number | null;
276293
lockUntil?: string | null;
294+
sessions?:
295+
| {
296+
id: string;
297+
createdAt?: string | null;
298+
expiresAt: string;
299+
}[]
300+
| null;
277301
password?: string | null;
278302
}
279303
/**
@@ -319,6 +343,10 @@ export interface PayloadLockedDocument {
319343
relationTo: 'disabled-bulk-edit-docs';
320344
value: string | DisabledBulkEditDoc;
321345
} | null)
346+
| ({
347+
relationTo: 'large-documents';
348+
value: string | LargeDocument;
349+
} | null)
322350
| ({
323351
relationTo: 'users';
324352
value: string | User;
@@ -471,6 +499,20 @@ export interface DisabledBulkEditDocsSelect<T extends boolean = true> {
471499
updatedAt?: T;
472500
createdAt?: T;
473501
}
502+
/**
503+
* This interface was referenced by `Config`'s JSON-Schema
504+
* via the `definition` "large-documents_select".
505+
*/
506+
export interface LargeDocumentsSelect<T extends boolean = true> {
507+
array?:
508+
| T
509+
| {
510+
text?: T;
511+
id?: T;
512+
};
513+
updatedAt?: T;
514+
createdAt?: T;
515+
}
474516
/**
475517
* This interface was referenced by `Config`'s JSON-Schema
476518
* via the `definition` "users_select".
@@ -485,6 +527,13 @@ export interface UsersSelect<T extends boolean = true> {
485527
hash?: T;
486528
loginAttempts?: T;
487529
lockUntil?: T;
530+
sessions?:
531+
| T
532+
| {
533+
id?: T;
534+
createdAt?: T;
535+
expiresAt?: T;
536+
};
488537
}
489538
/**
490539
* This interface was referenced by `Config`'s JSON-Schema

test/helpers/NextRESTClient.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import * as qs from 'qs-esm'
1313

1414
import { devUser } from '../credentials.js'
15+
import { getFormDataSize } from './getFormDataSize.js'
1516

1617
type ValidPath = `/${string}`
1718
type RequestOptions = {
@@ -94,17 +95,26 @@ export class NextRESTClient {
9495
}
9596

9697
private buildHeaders(options: FileArg & RequestInit & RequestOptions): Headers {
97-
const defaultHeaders = {
98-
'Content-Type': 'application/json',
98+
// Only set `Content-Type` to `application/json` if body is not `FormData`
99+
const isFormData =
100+
options &&
101+
typeof options.body !== 'undefined' &&
102+
typeof FormData !== 'undefined' &&
103+
options.body instanceof FormData
104+
105+
const headers = new Headers(options.headers || {})
106+
107+
if (options?.file) {
108+
headers.set('Content-Length', options.file.size.toString())
109+
}
110+
111+
if (isFormData) {
112+
headers.set('Content-Length', getFormDataSize(options.body as FormData).toString())
113+
}
114+
115+
if (!isFormData && !headers.has('Content-Type')) {
116+
headers.set('Content-Type', 'application/json')
99117
}
100-
const headers = new Headers({
101-
...(options?.file
102-
? {
103-
'Content-Length': options.file.size.toString(),
104-
}
105-
: defaultHeaders),
106-
...(options?.headers || {}),
107-
})
108118

109119
if (options.auth !== false && this.token) {
110120
headers.set('Authorization', `JWT ${this.token}`)
@@ -213,6 +223,7 @@ export class NextRESTClient {
213223
headers: this.buildHeaders(options),
214224
method: 'PATCH',
215225
})
226+
216227
return this._PATCH(request, { params: Promise.resolve({ slug }) })
217228
}
218229

0 commit comments

Comments
 (0)