Skip to content

Commit f0feb48

Browse files
authored
feat: Add support for streaming file upload via Buffer, Readable, ReadableStream (#10065)
1 parent 8a8006c commit f0feb48

9 files changed

Lines changed: 708 additions & 14 deletions

File tree

spec/GridFSBucketStorageAdapter.spec.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,31 @@ describe_only_db('mongo')('GridFSBucket', () => {
476476
}
477477
});
478478

479+
it('reports supportsStreaming as true', () => {
480+
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
481+
expect(gfsAdapter.supportsStreaming).toBe(true);
482+
});
483+
484+
it('creates file from Readable stream', async () => {
485+
const { Readable } = require('stream');
486+
const gfsAdapter = new GridFSBucketAdapter(databaseURI);
487+
const data = Buffer.from('streamed file content');
488+
const stream = Readable.from(data);
489+
await gfsAdapter.createFile('streamFile.txt', stream);
490+
const result = await gfsAdapter.getFileData('streamFile.txt');
491+
expect(result.toString('utf8')).toBe('streamed file content');
492+
});
493+
494+
it('creates encrypted file from Readable stream (buffers for encryption)', async () => {
495+
const { Readable } = require('stream');
496+
const gfsAdapter = new GridFSBucketAdapter(databaseURI, {}, 'test-encryption-key');
497+
const data = Buffer.from('encrypted streamed content');
498+
const stream = Readable.from(data);
499+
await gfsAdapter.createFile('encryptedStream.txt', stream);
500+
const result = await gfsAdapter.getFileData('encryptedStream.txt');
501+
expect(result.toString('utf8')).toBe('encrypted streamed content');
502+
});
503+
479504
describe('MongoDB Client Metadata', () => {
480505
it('should not pass metadata to MongoClient by default', async () => {
481506
const gfsAdapter = new GridFSBucketAdapter(databaseURI);

spec/ParseFile.spec.js

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1878,4 +1878,279 @@ describe('Parse.File testing', () => {
18781878
).toBeRejectedWith(jasmine.objectContaining({ status: 400 }));
18791879
});
18801880
});
1881+
1882+
describe('streaming binary uploads', () => {
1883+
afterEach(() => {
1884+
Parse.Cloud._removeAllHooks();
1885+
});
1886+
1887+
describe('createSizeLimitedStream', () => {
1888+
const { createSizeLimitedStream } = require('../lib/Routers/FilesRouter');
1889+
const { Readable } = require('stream');
1890+
1891+
it('passes data through when under limit', async () => {
1892+
const input = Readable.from(Buffer.from('hello'));
1893+
const limited = createSizeLimitedStream(input, 100);
1894+
const chunks = [];
1895+
for await (const chunk of limited) {
1896+
chunks.push(chunk);
1897+
}
1898+
expect(Buffer.concat(chunks).toString()).toBe('hello');
1899+
});
1900+
1901+
it('destroys stream when data exceeds limit', async () => {
1902+
const input = Readable.from(Buffer.from('hello world, this is too long'));
1903+
const limited = createSizeLimitedStream(input, 5);
1904+
const chunks = [];
1905+
try {
1906+
for await (const chunk of limited) {
1907+
chunks.push(chunk);
1908+
}
1909+
fail('should have thrown');
1910+
} catch (e) {
1911+
expect(e.message).toContain('exceeds');
1912+
}
1913+
});
1914+
1915+
});
1916+
1917+
it('streams binary upload with X-Parse-Upload-Mode header', async () => {
1918+
const headers = {
1919+
'Content-Type': 'application/octet-stream',
1920+
'X-Parse-Application-Id': 'test',
1921+
'X-Parse-REST-API-Key': 'rest',
1922+
'X-Parse-Upload-Mode': 'stream',
1923+
};
1924+
let response;
1925+
try {
1926+
response = await request({
1927+
method: 'POST',
1928+
headers: headers,
1929+
url: 'http://localhost:8378/1/files/stream-test.txt',
1930+
body: 'streaming file content',
1931+
});
1932+
} catch (e) {
1933+
fail('Request failed: status=' + e.status + ' text=' + e.text + ' data=' + JSON.stringify(e.data));
1934+
return;
1935+
}
1936+
const b = response.data;
1937+
expect(b.name).toMatch(/_stream-test.txt$/);
1938+
expect(b.url).toMatch(/stream-test\.txt$/);
1939+
const getResponse = await request({ url: b.url });
1940+
expect(getResponse.text).toEqual('streaming file content');
1941+
});
1942+
1943+
it('infers content type from extension when Content-Type header is missing', async () => {
1944+
const headers = {
1945+
'X-Parse-Application-Id': 'test',
1946+
'X-Parse-REST-API-Key': 'rest',
1947+
'X-Parse-Upload-Mode': 'stream',
1948+
};
1949+
const response = await request({
1950+
method: 'POST',
1951+
headers: headers,
1952+
url: 'http://localhost:8378/1/files/inferred.txt',
1953+
body: 'inferred content type',
1954+
});
1955+
const b = response.data;
1956+
expect(b.name).toMatch(/_inferred.txt$/);
1957+
const getResponse = await request({ url: b.url });
1958+
expect(getResponse.text).toEqual('inferred content type');
1959+
});
1960+
1961+
it('uses buffered path without X-Parse-Upload-Mode header', async () => {
1962+
const headers = {
1963+
'Content-Type': 'application/octet-stream',
1964+
'X-Parse-Application-Id': 'test',
1965+
'X-Parse-REST-API-Key': 'rest',
1966+
};
1967+
const response = await request({
1968+
method: 'POST',
1969+
headers: headers,
1970+
url: 'http://localhost:8378/1/files/buffered-test.txt',
1971+
body: 'buffered file content',
1972+
});
1973+
const b = response.data;
1974+
expect(b.name).toMatch(/_buffered-test.txt$/);
1975+
const getResponse = await request({ url: b.url });
1976+
expect(getResponse.text).toEqual('buffered file content');
1977+
});
1978+
1979+
it('rejects streaming upload exceeding size limit', async () => {
1980+
await reconfigureServer({ maxUploadSize: '10b' });
1981+
const headers = {
1982+
'Content-Type': 'application/octet-stream',
1983+
'X-Parse-Application-Id': 'test',
1984+
'X-Parse-REST-API-Key': 'rest',
1985+
'X-Parse-Upload-Mode': 'stream',
1986+
};
1987+
try {
1988+
await request({
1989+
method: 'POST',
1990+
headers: headers,
1991+
url: 'http://localhost:8378/1/files/big-file.txt',
1992+
body: 'this content is definitely longer than 10 bytes',
1993+
});
1994+
fail('should have thrown');
1995+
} catch (response) {
1996+
expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR);
1997+
expect(response.data.error).toContain('exceeds');
1998+
}
1999+
});
2000+
2001+
it('rejects streaming upload with Content-Length exceeding limit', async () => {
2002+
await reconfigureServer({ maxUploadSize: '10b' });
2003+
const headers = {
2004+
'Content-Type': 'application/octet-stream',
2005+
'X-Parse-Application-Id': 'test',
2006+
'X-Parse-REST-API-Key': 'rest',
2007+
'X-Parse-Upload-Mode': 'stream',
2008+
'Content-Length': '99999',
2009+
};
2010+
try {
2011+
await request({
2012+
method: 'POST',
2013+
headers: headers,
2014+
url: 'http://localhost:8378/1/files/big-file.txt',
2015+
body: 'hi',
2016+
});
2017+
fail('should have thrown');
2018+
} catch (response) {
2019+
expect(response.data.code).toBe(Parse.Error.FILE_SAVE_ERROR);
2020+
expect(response.data.error).toContain('exceeds');
2021+
}
2022+
});
2023+
2024+
it('fires beforeSave trigger with request.stream = true on streaming upload', async () => {
2025+
let receivedStream;
2026+
let receivedData;
2027+
Parse.Cloud.beforeSave(Parse.File, (request) => {
2028+
receivedStream = request.stream;
2029+
receivedData = request.file._data;
2030+
request.file.addMetadata('source', 'stream');
2031+
request.file.addTag('env', 'test');
2032+
});
2033+
const headers = {
2034+
'Content-Type': 'application/octet-stream',
2035+
'X-Parse-Application-Id': 'test',
2036+
'X-Parse-REST-API-Key': 'rest',
2037+
'X-Parse-Upload-Mode': 'stream',
2038+
};
2039+
const response = await request({
2040+
method: 'POST',
2041+
headers: headers,
2042+
url: 'http://localhost:8378/1/files/trigger-test.txt',
2043+
body: 'trigger test content',
2044+
});
2045+
expect(response.data.name).toMatch(/_trigger-test.txt$/);
2046+
expect(receivedStream).toBe(true);
2047+
expect(receivedData).toBeFalsy();
2048+
const getResponse = await request({ url: response.data.url });
2049+
expect(getResponse.text).toEqual('trigger test content');
2050+
});
2051+
2052+
it('rejects streaming upload when beforeSave trigger throws', async () => {
2053+
Parse.Cloud.beforeSave(Parse.File, () => {
2054+
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Upload rejected');
2055+
});
2056+
const headers = {
2057+
'Content-Type': 'application/octet-stream',
2058+
'X-Parse-Application-Id': 'test',
2059+
'X-Parse-REST-API-Key': 'rest',
2060+
'X-Parse-Upload-Mode': 'stream',
2061+
};
2062+
try {
2063+
await request({
2064+
method: 'POST',
2065+
headers: headers,
2066+
url: 'http://localhost:8378/1/files/rejected.txt',
2067+
body: 'rejected content',
2068+
});
2069+
fail('should have thrown');
2070+
} catch (response) {
2071+
expect(response.data.code).toBe(Parse.Error.SCRIPT_FAILED);
2072+
expect(response.data.error).toBe('Upload rejected');
2073+
}
2074+
});
2075+
2076+
it('skips save when beforeSave trigger returns Parse.File with URL on streaming upload', async () => {
2077+
Parse.Cloud.beforeSave(Parse.File, () => {
2078+
return Parse.File.fromJSON({
2079+
__type: 'File',
2080+
name: 'existing.txt',
2081+
url: 'http://example.com/existing.txt',
2082+
});
2083+
});
2084+
const headers = {
2085+
'Content-Type': 'application/octet-stream',
2086+
'X-Parse-Application-Id': 'test',
2087+
'X-Parse-REST-API-Key': 'rest',
2088+
'X-Parse-Upload-Mode': 'stream',
2089+
};
2090+
const response = await request({
2091+
method: 'POST',
2092+
headers: headers,
2093+
url: 'http://localhost:8378/1/files/skip-save.txt',
2094+
body: 'should not be saved',
2095+
});
2096+
expect(response.data.url).toBe('http://example.com/existing.txt');
2097+
expect(response.data.name).toBe('existing.txt');
2098+
});
2099+
2100+
it('fires afterSave trigger with request.stream = true on streaming upload', async () => {
2101+
let afterSaveStream;
2102+
let afterSaveData;
2103+
let afterSaveUrl;
2104+
Parse.Cloud.afterSave(Parse.File, (request) => {
2105+
afterSaveStream = request.stream;
2106+
afterSaveData = request.file._data;
2107+
afterSaveUrl = request.file._url;
2108+
});
2109+
const headers = {
2110+
'Content-Type': 'application/octet-stream',
2111+
'X-Parse-Application-Id': 'test',
2112+
'X-Parse-REST-API-Key': 'rest',
2113+
'X-Parse-Upload-Mode': 'stream',
2114+
};
2115+
const response = await request({
2116+
method: 'POST',
2117+
headers: headers,
2118+
url: 'http://localhost:8378/1/files/after-save.txt',
2119+
body: 'after save content',
2120+
});
2121+
expect(response.data.name).toMatch(/_after-save.txt$/);
2122+
expect(afterSaveStream).toBe(true);
2123+
expect(afterSaveData).toBeFalsy();
2124+
expect(afterSaveUrl).toBeTruthy();
2125+
});
2126+
2127+
it('verifies FilesAdapter default supportsStreaming is false', () => {
2128+
const { FilesAdapter } = require('../lib/Adapters/Files/FilesAdapter');
2129+
const adapter = new FilesAdapter();
2130+
expect(adapter.supportsStreaming).toBe(false);
2131+
});
2132+
2133+
it('legacy JSON-wrapped upload still works', async () => {
2134+
await reconfigureServer({
2135+
fileUpload: {
2136+
enableForPublic: true,
2137+
fileExtensions: ['*'],
2138+
},
2139+
});
2140+
const response = await request({
2141+
method: 'POST',
2142+
url: 'http://localhost:8378/1/files/legacy.txt',
2143+
body: JSON.stringify({
2144+
_ApplicationId: 'test',
2145+
_JavaScriptKey: 'test',
2146+
_ContentType: 'text/plain',
2147+
base64: Buffer.from('legacy content').toString('base64'),
2148+
}),
2149+
});
2150+
const b = response.data;
2151+
expect(b.name).toMatch(/_legacy.txt$/);
2152+
const getResponse = await request({ url: b.url });
2153+
expect(getResponse.text).toEqual('legacy content');
2154+
});
2155+
});
18812156
});

spec/Utils.spec.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,81 @@ describe('Utils', () => {
175175
});
176176
});
177177

178+
describe('parseSizeToBytes', () => {
179+
it('parses megabyte string', () => {
180+
expect(Utils.parseSizeToBytes('20mb')).toBe(20 * 1024 * 1024);
181+
});
182+
183+
it('parses Mb string (case-insensitive)', () => {
184+
expect(Utils.parseSizeToBytes('20Mb')).toBe(20 * 1024 * 1024);
185+
});
186+
187+
it('parses kilobyte string', () => {
188+
expect(Utils.parseSizeToBytes('512kb')).toBe(512 * 1024);
189+
});
190+
191+
it('parses gigabyte string', () => {
192+
expect(Utils.parseSizeToBytes('1gb')).toBe(1 * 1024 * 1024 * 1024);
193+
});
194+
195+
it('parses bytes suffix', () => {
196+
expect(Utils.parseSizeToBytes('100b')).toBe(100);
197+
});
198+
199+
it('parses plain number as bytes', () => {
200+
expect(Utils.parseSizeToBytes(1048576)).toBe(1048576);
201+
});
202+
203+
it('parses numeric string as bytes', () => {
204+
expect(Utils.parseSizeToBytes('1048576')).toBe(1048576);
205+
});
206+
207+
it('parses decimal value and floors result', () => {
208+
expect(Utils.parseSizeToBytes('1.5mb')).toBe(Math.floor(1.5 * 1024 * 1024));
209+
});
210+
211+
it('trims whitespace around value', () => {
212+
expect(Utils.parseSizeToBytes(' 20mb ')).toBe(20 * 1024 * 1024);
213+
});
214+
215+
it('allows whitespace between number and unit', () => {
216+
expect(Utils.parseSizeToBytes('20 mb')).toBe(20 * 1024 * 1024);
217+
});
218+
219+
it('parses zero', () => {
220+
expect(Utils.parseSizeToBytes('0')).toBe(0);
221+
expect(Utils.parseSizeToBytes(0)).toBe(0);
222+
});
223+
224+
it('throws on invalid string', () => {
225+
expect(() => Utils.parseSizeToBytes('abc')).toThrow();
226+
});
227+
228+
it('throws on negative value', () => {
229+
expect(() => Utils.parseSizeToBytes('-5mb')).toThrow();
230+
});
231+
232+
it('throws on empty string', () => {
233+
expect(() => Utils.parseSizeToBytes('')).toThrow();
234+
});
235+
236+
it('throws on unsupported unit', () => {
237+
expect(() => Utils.parseSizeToBytes('10tb')).toThrow();
238+
});
239+
240+
it('throws on NaN', () => {
241+
expect(() => Utils.parseSizeToBytes(NaN)).toThrow();
242+
});
243+
244+
it('throws on Infinity', () => {
245+
expect(() => Utils.parseSizeToBytes(Infinity)).toThrow();
246+
});
247+
248+
it('throws on negative number', () => {
249+
expect(() => Utils.parseSizeToBytes(-1)).toThrow();
250+
});
251+
});
252+
178253
describe('createSanitizedError', () => {
179254
it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => {
180255
const config = { enableSanitizedErrorResponse: true };

0 commit comments

Comments
 (0)