Skip to content

Commit 4fd61cc

Browse files
committed
feat
1 parent 362a27c commit 4fd61cc

9 files changed

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

spec/Utils.spec.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,69 @@ 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+
178241
describe('createSanitizedError', () => {
179242
it('should return "Permission denied" when enableSanitizedErrorResponse is true', () => {
180243
const config = { enableSanitizedErrorResponse: true };

src/Adapters/Files/FilesAdapter.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class FilesAdapter {
2626
/** Responsible for storing the file in order to be retrieved later by its filename
2727
*
2828
* @param {string} filename - the filename to save
29-
* @param {*} data - the buffer of data from the file
29+
* @param {Buffer|import('stream').Readable} data - the file data as a Buffer, or a Readable stream if the adapter supports streaming (see supportsStreaming)
3030
* @param {string} contentType - the supposed contentType
3131
* @discussion the contentType can be undefined if the controller was not able to determine it
3232
* @param {object} options - (Optional) options to be passed to file adapter (S3 File Adapter Only)
@@ -38,6 +38,16 @@ export class FilesAdapter {
3838
*/
3939
createFile(filename: string, data, contentType: string, options: Object): Promise {}
4040

41+
/** Whether this adapter supports receiving Readable streams in createFile().
42+
* If false (default), streams are buffered to a Buffer before being passed.
43+
* Override and return true to receive Readable streams directly.
44+
*
45+
* @return {boolean}
46+
*/
47+
get supportsStreaming() {
48+
return false;
49+
}
50+
4151
/** Responsible for deleting the specified file
4252
*
4353
* @param {string} filename - the filename to delete

0 commit comments

Comments
 (0)