Skip to content

Commit 910f96e

Browse files
authored
fix(standard-server): handle empty filename during file deserialization (#320)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced file upload processing with improved content disposition headers that now support UTF-8 encoded filenames and default naming when no filename is provided. - **Chores** - Removed an external dependency to streamline package management and reduce overhead. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent c0ca4c7 commit 910f96e

File tree

9 files changed

+220
-54
lines changed

9 files changed

+220
-54
lines changed

packages/standard-server-fetch/src/body.test.ts

+69-8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { isAsyncIteratorObject } from '@orpc/shared'
2+
import * as StandardServerModule from '@orpc/standard-server'
23
import { toFetchBody, toStandardBody } from './body'
3-
44
import * as EventIteratorModule from './event-iterator'
55

6+
const generateContentDispositionSpy = vi.spyOn(StandardServerModule, 'generateContentDisposition')
7+
const getFilenameFromContentDispositionSpy = vi.spyOn(StandardServerModule, 'getFilenameFromContentDisposition')
68
const toEventStreamSpy = vi.spyOn(EventIteratorModule, 'toEventStream')
79

10+
beforeEach(() => {
11+
vi.clearAllMocks()
12+
})
13+
814
describe('toStandardBody', () => {
915
it('undefined', async () => {
1016
const request = new Request('https://example.com', {
@@ -113,29 +119,56 @@ describe('toStandardBody', () => {
113119
expect(standardBlob.name).toBe('blob')
114120
expect(standardBlob.type).toBe('application/pdf')
115121
expect(await standardBlob.text()).toBe('foo')
122+
123+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledTimes(0)
116124
})
117125

118126
it('file', async () => {
119127
const request = new Request('https://example.com', {
120128
method: 'POST',
121-
body: new Blob(['foo'], { type: 'application/pdf' }),
129+
body: new Blob(['{"value":123}'], { type: 'application/json' }),
122130
headers: {
123131
'content-disposition': 'attachment; filename="foo.pdf"',
124132
},
125133
})
126134

135+
getFilenameFromContentDispositionSpy.mockReturnValue('__name__')
136+
127137
const standardFile = await toStandardBody(request) as any
128138
expect(standardFile).toBeInstanceOf(File)
129-
expect(standardFile.name).toBe('foo.pdf')
130-
expect(standardFile.type).toBe('application/pdf')
131-
expect(await standardFile.text()).toBe('foo')
139+
expect(standardFile.name).toBe('__name__')
140+
expect(standardFile.type).toBe('application/json')
141+
expect(await standardFile.text()).toBe('{"value":123}')
142+
143+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledTimes(1)
144+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledWith('attachment; filename="foo.pdf"')
145+
})
146+
147+
it('file with content-disposition (no filename)', async () => {
148+
const request = new Request('https://example.com', {
149+
method: 'POST',
150+
body: new Blob(['{"value":123}'], { type: 'application/json' }),
151+
headers: {
152+
'content-disposition': 'attachment',
153+
},
154+
})
155+
156+
getFilenameFromContentDispositionSpy.mockReturnValue(undefined)
157+
158+
const standardFile = await toStandardBody(request) as any
159+
expect(standardFile).toBeInstanceOf(File)
160+
expect(standardFile.name).toBe('blob')
161+
expect(standardFile.type).toBe('application/json')
162+
expect(await standardFile.text()).toBe('{"value":123}')
163+
164+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledTimes(1)
165+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledWith('attachment')
132166
})
133167
})
134168

135169
describe('toFetchBody', () => {
136170
const baseHeaders = new Headers({
137171
'content-type': 'application/json',
138-
'content-disposition': 'attachment; filename="foo.pdf"',
139172
'x-custom-header': 'custom-value',
140173
})
141174

@@ -190,30 +223,58 @@ describe('toFetchBody', () => {
190223
const headers = new Headers(baseHeaders)
191224
const blob = new Blob(['foo'], { type: 'application/pdf' })
192225

226+
generateContentDispositionSpy.mockReturnValue('__mocked__')
227+
193228
const body = toFetchBody(blob, headers, {})
194229

195230
expect(body).toBe(blob)
196231
expect([...headers]).toEqual([
197-
['content-disposition', 'inline; filename="blob"'],
232+
['content-disposition', '__mocked__'],
198233
['content-length', '3'],
199234
['content-type', 'application/pdf'],
200235
['x-custom-header', 'custom-value'],
201236
])
237+
238+
expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
239+
expect(generateContentDispositionSpy).toHaveBeenCalledWith('blob')
202240
})
203241

204242
it('file', () => {
205243
const headers = new Headers(baseHeaders)
206244
const blob = new File(['foo'], 'foo.pdf', { type: 'application/pdf' })
207245

246+
generateContentDispositionSpy.mockReturnValue('__mocked__')
247+
248+
const body = toFetchBody(blob, headers, {})
249+
250+
expect(body).toBe(blob)
251+
expect([...headers]).toEqual([
252+
['content-disposition', '__mocked__'],
253+
['content-length', '3'],
254+
['content-type', 'application/pdf'],
255+
['x-custom-header', 'custom-value'],
256+
])
257+
258+
expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
259+
expect(generateContentDispositionSpy).toHaveBeenCalledWith('foo.pdf')
260+
})
261+
262+
it('file with content-disposition', () => {
263+
const headers = new Headers(baseHeaders)
264+
headers.set('content-disposition', 'attachment; filename="foo.pdf"')
265+
const blob = new File(['foo'], 'foo.pdf', { type: 'application/pdf' })
266+
208267
const body = toFetchBody(blob, headers, {})
209268

210269
expect(body).toBe(blob)
211270
expect([...headers]).toEqual([
212-
['content-disposition', 'inline; filename="foo.pdf"'],
271+
['content-disposition', 'attachment; filename="foo.pdf"'],
213272
['content-length', '3'],
214273
['content-type', 'application/pdf'],
215274
['x-custom-header', 'custom-value'],
216275
])
276+
277+
expect(generateContentDispositionSpy).toHaveBeenCalledTimes(0)
217278
})
218279

219280
it('async generator', async () => {

packages/standard-server-fetch/src/body.ts

+10-11
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import type { StandardBody } from '@orpc/standard-server'
21
import type { ToEventStreamOptions } from './event-iterator'
32
import { isAsyncIteratorObject, parseEmptyableJSON, stringifyJSON } from '@orpc/shared'
4-
import { contentDisposition, parseContentDisposition } from '@orpc/standard-server'
3+
import { generateContentDisposition, getFilenameFromContentDisposition, type StandardBody } from '@orpc/standard-server'
54
import { toEventIterator, toEventStream } from './event-iterator'
65

76
export async function toStandardBody(re: Request | Response): Promise<StandardBody> {
@@ -11,15 +10,13 @@ export async function toStandardBody(re: Request | Response): Promise<StandardBo
1110

1211
const contentDisposition = re.headers.get('content-disposition')
1312

14-
if (contentDisposition) {
15-
const fileName = parseContentDisposition(contentDisposition).parameters.filename
13+
if (typeof contentDisposition === 'string') {
14+
const fileName = getFilenameFromContentDisposition(contentDisposition) ?? 'blob'
1615

17-
if (typeof fileName === 'string') {
18-
const blob = await re.blob()
19-
return new File([blob], fileName, {
20-
type: blob.type,
21-
})
22-
}
16+
const blob = await re.blob()
17+
return new File([blob], fileName, {
18+
type: blob.type,
19+
})
2320
}
2421

2522
const contentType = re.headers.get('content-type')
@@ -63,6 +60,8 @@ export function toFetchBody(
6360
headers: Headers,
6461
options: ToFetchBodyOptions = {},
6562
): string | Blob | FormData | URLSearchParams | undefined | ReadableStream<Uint8Array> {
63+
const currentContentDisposition = headers.get('content-disposition')
64+
6665
headers.delete('content-type')
6766
headers.delete('content-disposition')
6867

@@ -75,7 +74,7 @@ export function toFetchBody(
7574
headers.set('content-length', body.size.toString())
7675
headers.set(
7776
'content-disposition',
78-
contentDisposition(body instanceof File ? body.name : 'blob', { type: 'inline' }),
77+
currentContentDisposition ?? generateContentDisposition(body instanceof File ? body.name : 'blob'),
7978
)
8079

8180
return body

packages/standard-server-node/src/body.test.ts

+79-8
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
33
import { Buffer } from 'node:buffer'
44
import { Readable } from 'node:stream'
55
import { isAsyncIteratorObject } from '@orpc/shared'
6+
import * as StandardServerModule from '@orpc/standard-server'
67
import request from 'supertest'
78
import { toNodeHttpBody, toStandardBody } from './body'
89
import * as EventIteratorModule from './event-iterator'
910

1011
const toEventStreamSpy = vi.spyOn(EventIteratorModule, 'toEventStream')
12+
const generateContentDispositionSpy = vi.spyOn(StandardServerModule, 'generateContentDisposition')
13+
const getFilenameFromContentDispositionSpy = vi.spyOn(StandardServerModule, 'getFilenameFromContentDisposition')
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks()
17+
})
1118

1219
describe('toStandardBody', () => {
1320
it('undefined', async () => {
@@ -131,31 +138,60 @@ describe('toStandardBody', () => {
131138
expect(standardBody.name).toBe('blob')
132139
expect(standardBody.type).toBe('application/pdf')
133140
expect(await standardBody.text()).toBe('foo')
141+
142+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledTimes(0)
134143
})
135144

136145
it('file', async () => {
137146
let standardBody: any
138147

148+
getFilenameFromContentDispositionSpy.mockReturnValue('__name__')
149+
139150
await request(async (req: IncomingMessage, res: ServerResponse) => {
140151
standardBody = await toStandardBody(req)
141152
res.end()
142153
})
143154
.delete('/')
144-
.type('application/pdf')
155+
.type('application/json')
145156
.set('content-disposition', 'attachment; filename="foo.pdf"')
146-
.send(Buffer.from('foo'))
157+
.send({ value: 123 })
147158

148159
expect(standardBody).toBeInstanceOf(File)
149-
expect(standardBody.name).toBe('foo.pdf')
150-
expect(standardBody.type).toBe('application/pdf')
151-
expect(await standardBody.text()).toBe('foo')
160+
expect(standardBody.name).toBe('__name__')
161+
expect(standardBody.type).toBe('application/json')
162+
expect(await standardBody.text()).toBe('{"value":123}')
163+
164+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledTimes(1)
165+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledWith('attachment; filename="foo.pdf"')
166+
})
167+
168+
it('file with content-disposition (no filename)', async () => {
169+
let standardBody: any
170+
171+
getFilenameFromContentDispositionSpy.mockReturnValue(undefined)
172+
173+
await request(async (req: IncomingMessage, res: ServerResponse) => {
174+
standardBody = await toStandardBody(req)
175+
res.end()
176+
})
177+
.delete('/')
178+
.type('application/json')
179+
.set('content-disposition', 'attachment')
180+
.send({ value: 123 })
181+
182+
expect(standardBody).toBeInstanceOf(File)
183+
expect(standardBody.name).toBe('blob')
184+
expect(standardBody.type).toBe('application/json')
185+
expect(await standardBody.text()).toBe('{"value":123}')
186+
187+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledTimes(1)
188+
expect(getFilenameFromContentDispositionSpy).toHaveBeenCalledWith('attachment')
152189
})
153190
})
154191

155192
describe('toNodeHttpBody', () => {
156193
const baseHeaders = {
157194
'content-type': 'application/json',
158-
'content-disposition': 'attachment; filename="foo.pdf"',
159195
'x-custom-header': 'custom-value',
160196
}
161197

@@ -220,16 +256,21 @@ describe('toNodeHttpBody', () => {
220256
const headers = { ...baseHeaders }
221257
const blob = new Blob(['foo'], { type: 'application/pdf' })
222258

259+
generateContentDispositionSpy.mockReturnValue('__mocked__')
260+
223261
const body = toNodeHttpBody(blob, headers, {})
224262

225263
expect(body).toBeInstanceOf(Readable)
226264
expect(headers).toEqual({
227-
'content-disposition': 'inline; filename="blob"',
265+
'content-disposition': '__mocked__',
228266
'content-length': '3',
229267
'content-type': 'application/pdf',
230268
'x-custom-header': 'custom-value',
231269
})
232270

271+
expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
272+
expect(generateContentDispositionSpy).toHaveBeenCalledWith('blob')
273+
233274
const response = new Response(body, {
234275
headers,
235276
})
@@ -243,16 +284,46 @@ describe('toNodeHttpBody', () => {
243284
const headers = { ...baseHeaders }
244285
const blob = new File(['foo'], 'foo.pdf', { type: 'application/pdf' })
245286

287+
generateContentDispositionSpy.mockReturnValue('__mocked__')
288+
289+
const body = toNodeHttpBody(blob, headers, {})
290+
291+
expect(body).instanceOf(Readable)
292+
expect(headers).toEqual({
293+
'content-disposition': '__mocked__',
294+
'content-length': '3',
295+
'content-type': 'application/pdf',
296+
'x-custom-header': 'custom-value',
297+
})
298+
299+
expect(generateContentDispositionSpy).toHaveBeenCalledTimes(1)
300+
expect(generateContentDispositionSpy).toHaveBeenCalledWith('foo.pdf')
301+
302+
const response = new Response(body, {
303+
headers,
304+
})
305+
const resBlob = await response.blob()
306+
307+
expect(resBlob.type).toBe('application/pdf')
308+
expect(await resBlob.text()).toBe('foo')
309+
})
310+
311+
it('file with content-disposition', async () => {
312+
const headers = { ...baseHeaders, 'content-disposition': 'attachment; filename="foo.pdf"' }
313+
const blob = new File(['foo'], 'foo.pdf', { type: 'application/pdf' })
314+
246315
const body = toNodeHttpBody(blob, headers, {})
247316

248317
expect(body).instanceOf(Readable)
249318
expect(headers).toEqual({
250-
'content-disposition': 'inline; filename="foo.pdf"',
319+
'content-disposition': 'attachment; filename="foo.pdf"',
251320
'content-length': '3',
252321
'content-type': 'application/pdf',
253322
'x-custom-header': 'custom-value',
254323
})
255324

325+
expect(generateContentDispositionSpy).toHaveBeenCalledTimes(0)
326+
256327
const response = new Response(body, {
257328
headers,
258329
})

0 commit comments

Comments
 (0)