Skip to content

Commit c150ef8

Browse files
fix: handle multipart uploads without content-length (#16301)
## Summary - accept multipart requests when a body stream is present even if `Content-Length` is missing - add base-case coverage for multipart eligibility and request parsing without `Content-Length` - wait for multipart parser completion before returning parsed files to avoid temp-file race/empty-file reads - stop forcing manual FormData `Content-Length` in the test REST client helper ## What This Fixes The Azure streaming upload MIME test intermittently failed because multipart FormData requests could be skipped or race file reads in certain no-`Content-Length` paths, producing empty-file upload behavior. This test would fail: ```ts TypeError: Cannot read properties of undefined (reading 'url') ❯ test/storage-azure/streamingUploads.int.spec.ts:61:56 59| }) 60| ).json() 61| const response = await restClient.GET(newMedia.doc.url.replace(/^\… | ^ 62| expect(response.headers.get('content-type')).toEqual('image/png') 63| }) ``` ## Validation - `pnpm exec vitest run packages/payload/src/uploads/fetchAPI-multipart/isEligibleRequest.spec.ts packages/payload/src/utilities/addDataAndFileToRequest.spec.ts` - `pnpm exec vitest run --project int test/storage-azure/streamingUploads.int.spec.ts -t "preserves mime type when uploaded via rest endpoint"` (5 consecutive passes)
1 parent 2f7fe20 commit c150ef8

6 files changed

Lines changed: 100 additions & 10 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { isEligibleRequest } from './isEligibleRequest.js'
4+
5+
describe('isEligibleRequest', () => {
6+
it('should accept multipart POST requests with a body stream when content-length is absent', () => {
7+
const formData = new FormData()
8+
formData.append('file', new Blob(['hello world'], { type: 'text/plain' }), 'hello.txt')
9+
10+
const request = new Request('http://localhost/api/upload', {
11+
body: formData,
12+
method: 'POST',
13+
})
14+
15+
expect(request.headers.get('content-length')).toBeNull()
16+
expect(isEligibleRequest(request)).toBe(true)
17+
})
18+
19+
it('should reject multipart POST requests when no body is present', () => {
20+
const request = new Request('http://localhost/api/upload', {
21+
headers: {
22+
'content-type': 'multipart/form-data; boundary=----test-boundary',
23+
},
24+
method: 'POST',
25+
})
26+
27+
expect(isEligibleRequest(request)).toBe(false)
28+
})
29+
})

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ const ACCEPTABLE_CONTENT_TYPE = /multipart\/['"()+-_]+(?:; ?['"()+-_]*)+$/i
33
const UNACCEPTABLE_METHODS = new Set(['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'TRACE'])
44

55
const hasBody = (req: Request): boolean => {
6+
const contentLength = req.headers.get('content-length')
7+
68
return Boolean(
7-
req.headers.get('transfer-encoding') ||
8-
(req.headers.get('content-length') && req.headers.get('content-length') !== '0'),
9+
req.body || req.headers.get('transfer-encoding') || (contentLength && contentLength !== '0'),
910
)
1011
}
1112

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,19 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
3333
let filesCompleted = 0
3434
let allFilesHaveResolved: (value?: unknown) => void
3535
let failedResolvingFiles: (err: Error) => void
36+
let busboyFinishedResolve: () => void
37+
let busboyFinishedReject: (err: Error) => void
3638

3739
const allFilesComplete = new Promise((res, rej) => {
3840
allFilesHaveResolved = res
3941
failedResolvingFiles = rej
4042
})
4143

44+
const busboyFinished = new Promise<void>((resolve, reject) => {
45+
busboyFinishedResolve = resolve
46+
busboyFinishedReject = reject
47+
})
48+
4249
const result: FetchAPIFileUploadResponse = {
4350
fields: undefined!,
4451
files: undefined!,
@@ -195,14 +202,19 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
195202
}
196203
}
197204

198-
return result
205+
busboyFinishedResolve()
199206
})
200207

201208
busboy.on(
202209
'error',
203210
(err = new APIError('Busboy error parsing multipart request', httpStatus.BAD_REQUEST)) => {
204211
debugLog(options, `Busboy error`)
205-
throw err
212+
const busboyError =
213+
err instanceof Error
214+
? err
215+
: new APIError('Busboy error parsing multipart request', httpStatus.BAD_REQUEST)
216+
217+
busboyFinishedReject(busboyError)
206218
},
207219
)
208220

@@ -211,6 +223,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
211223

212224
if (done) {
213225
parsingRequest = false
226+
busboy.end()
214227
}
215228

216229
if (value && !shouldAbortProccessing) {
@@ -224,5 +237,7 @@ export const processMultipart: ProcessMultipart = async ({ options, request }) =
224237
})
225238
}
226239

240+
await busboyFinished
241+
227242
return result
228243
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { PayloadRequest } from '../types/index.js'
2+
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { addDataAndFileToRequest } from './addDataAndFileToRequest.js'
6+
7+
type MinimalReq = Pick<PayloadRequest, 'body' | 'headers' | 'method' | 'payload'> & {
8+
file?: PayloadRequest['file']
9+
}
10+
11+
const createReqWithMultipartBody = (): MinimalReq => {
12+
const formData = new FormData()
13+
formData.append('file', new Blob(['hello world'], { type: 'text/plain' }), 'hello.txt')
14+
15+
const request = new Request('http://localhost/api/upload', {
16+
body: formData,
17+
method: 'POST',
18+
})
19+
20+
return {
21+
body: request.body,
22+
headers: request.headers,
23+
method: request.method,
24+
payload: {
25+
collections: {},
26+
config: {
27+
bodyParser: {},
28+
upload: {},
29+
},
30+
logger: {
31+
error: () => {},
32+
},
33+
} as unknown as PayloadRequest['payload'],
34+
}
35+
}
36+
37+
describe('addDataAndFileToRequest', () => {
38+
it('should parse multipart form-data even when content-length is absent', async () => {
39+
const req = createReqWithMultipartBody()
40+
41+
expect(req.headers.get('content-length')).toBeNull()
42+
43+
await addDataAndFileToRequest(req as PayloadRequest)
44+
45+
expect(req.file).toBeDefined()
46+
expect(req.file?.name).toBe('hello.txt')
47+
expect(req.file?.mimetype).toBe('text/plain')
48+
})
49+
})

packages/payload/src/utilities/addDataAndFileToRequest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const addDataAndFileToRequest: AddDataAndFileToRequest = async (req) => {
1414
if (method && ['PATCH', 'POST', 'PUT'].includes(method.toUpperCase()) && body) {
1515
const [contentType] = (headers.get('Content-Type') || '').split(';', 1)
1616
const bodyByteSize = parseInt(req.headers.get('Content-Length') || '0', 10)
17+
const hasBodyStream = req.body !== null
1718

1819
if (contentType === 'application/json') {
1920
try {
@@ -29,7 +30,7 @@ export const addDataAndFileToRequest: AddDataAndFileToRequest = async (req) => {
2930
req.payload.logger.error(error)
3031
throw error
3132
}
32-
} else if (bodyByteSize && contentType?.includes('multipart/')) {
33+
} else if ((bodyByteSize || hasBodyStream) && contentType?.includes('multipart/')) {
3334
const { error, fields, files } = await processMultipartFormdata({
3435
options: {
3536
...(payload.config.bodyParser || {}),

test/__helpers/shared/NextRESTClient.ts

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

1414
import { devUser } from '../../credentials.js'
15-
import { getFormDataSize } from './getFormDataSize.js'
1615

1716
type ValidPath = `/${string}`
1817
type RequestOptions = {
@@ -108,10 +107,6 @@ export class NextRESTClient {
108107
headers.set('Content-Length', options.file.size.toString())
109108
}
110109

111-
if (isFormData) {
112-
headers.set('Content-Length', getFormDataSize(options.body as FormData).toString())
113-
}
114-
115110
if (!isFormData && !headers.has('Content-Type')) {
116111
headers.set('Content-Type', 'application/json')
117112
}

0 commit comments

Comments
 (0)