diff --git a/package-lock.json b/package-lock.json index 656f46627e..646f0c7bd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "mongodb": "7.1.0", "mustache": "4.2.0", "otpauth": "9.4.0", - "parse": "8.3.0", + "parse": "8.4.0", "path-to-regexp": "8.3.0", "pg-monitor": "3.1.0", "pg-promise": "12.6.0", @@ -18402,9 +18402,9 @@ } }, "node_modules/parse": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse/-/parse-8.3.0.tgz", - "integrity": "sha512-llyOFZfxXZcgy1EpLoxNmClT1+nwp/RmF5mgYQ0/Nj5sl6Acw4oLSjFfckk1gJLAl7FRDsI8TGhhiIJYq41bAQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.4.0.tgz", + "integrity": "sha512-Pfb0Oedh9PHU0ZQ54EupCgh9zZR0OqitiXSHJ6oyGIqCsaOZ+ENAuw6Nr06T0FqzYb/fYkq4cC4hdte2QmjjNA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "7.28.6", @@ -35512,9 +35512,9 @@ } }, "parse": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse/-/parse-8.3.0.tgz", - "integrity": "sha512-llyOFZfxXZcgy1EpLoxNmClT1+nwp/RmF5mgYQ0/Nj5sl6Acw4oLSjFfckk1gJLAl7FRDsI8TGhhiIJYq41bAQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/parse/-/parse-8.4.0.tgz", + "integrity": "sha512-Pfb0Oedh9PHU0ZQ54EupCgh9zZR0OqitiXSHJ6oyGIqCsaOZ+ENAuw6Nr06T0FqzYb/fYkq4cC4hdte2QmjjNA==", "requires": { "@babel/runtime": "7.28.6", "@babel/runtime-corejs3": "7.29.0", diff --git a/package.json b/package.json index a29e39bd9c..afd07afa3e 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "mongodb": "7.1.0", "mustache": "4.2.0", "otpauth": "9.4.0", - "parse": "8.3.0", + "parse": "8.4.0", "path-to-regexp": "8.3.0", "pg-monitor": "3.1.0", "pg-promise": "12.6.0", diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 63e90fcb96..20689aec1c 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -2311,6 +2311,208 @@ describe('Parse.File testing', () => { expect(b.url).toBeDefined(); }); + it('saves file with directory via streaming upload (header)', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': 'stream-dir-test', + }; + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-header.txt', + body: 'stream directory header content', + }); + const b = response.data; + expect(b.name).toMatch(/^stream-dir-test\/.*_stream-header.txt$/); + expect(b.url).toBeDefined(); + }); + + it('rejects directory header without master key for streaming upload', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': 'no-master', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-header.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.OPERATION_FORBIDDEN); + } + }); + + it('validates directory header for streaming upload', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': '../etc', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-header.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_FILE_NAME); + } + }); + + it('saves file with metadata and tags via streaming upload headers', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Metadata': JSON.stringify({ key1: 'value1' }), + 'X-Parse-File-Tags': JSON.stringify({ tag1: 'tagValue1' }), + }; + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-meta.txt', + body: 'stream with metadata content', + }); + const b = response.data; + expect(b.name).toMatch(/_stream-meta.txt$/); + expect(b.url).toBeDefined(); + const options = FilesController.prototype.createFile.calls.argsFor(0)[4]; + expect(options.metadata).toEqual({ key1: 'value1' }); + expect(options.tags).toEqual({ tag1: 'tagValue1' }); + }); + + it('saves file with directory, metadata, and tags via streaming upload headers', async () => { + spyOn(FilesController.prototype, 'createFile').and.callThrough(); + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Directory': 'uploads', + 'X-Parse-File-Metadata': JSON.stringify({ author: 'test' }), + 'X-Parse-File-Tags': JSON.stringify({ env: 'test' }), + }; + const response = await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-all.txt', + body: 'stream with all file data', + }); + const b = response.data; + expect(b.name).toMatch(/^uploads\/.*_stream-all.txt$/); + expect(b.url).toBeDefined(); + const options = FilesController.prototype.createFile.calls.argsFor(0)[4]; + expect(options.metadata).toEqual({ author: 'test' }); + expect(options.tags).toEqual({ env: 'test' }); + }); + + it('rejects invalid JSON in metadata header', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Metadata': 'not-json', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + } + }); + + it('rejects invalid JSON in tags header', async () => { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Tags': '{bad', + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail('should have thrown'); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + } + }); + + it('rejects non-object metadata header', async () => { + const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true']; + for (const value of invalidValues) { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Metadata': value, + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail(`should have thrown for metadata: ${value}`); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Metadata header.'); + } + } + }); + + it('rejects non-object tags header', async () => { + const invalidValues = ['"a string"', '[1,2]', 'null', '42', 'true']; + for (const value of invalidValues) { + const headers = { + 'Content-Type': 'text/plain', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + 'X-Parse-Upload-Mode': 'stream', + 'X-Parse-File-Tags': value, + }; + try { + await request({ + method: 'POST', + headers, + url: 'http://localhost:8378/1/files/stream-bad.txt', + body: 'should fail', + }); + fail(`should have thrown for tags: ${value}`); + } catch (error) { + expect(error.data.code).toEqual(Parse.Error.INVALID_JSON); + expect(error.data.error).toBe('Invalid JSON in X-Parse-File-Tags header.'); + } + } + }); + it('validates directory - rejects trailing slash', async () => { const file = new Parse.File('hello.txt', data, 'text/plain'); file.setDirectory('trailing/'); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 6171af56f9..ef25c86a89 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -314,6 +314,38 @@ export class FilesRouter { } } + // For streaming uploads, read file data from headers since the body is the raw stream + if (req.get('X-Parse-Upload-Mode') === 'stream') { + req.fileData = {}; + if (req.get('X-Parse-File-Directory')) { + req.fileData.directory = req.get('X-Parse-File-Directory'); + } + if (req.get('X-Parse-File-Metadata')) { + try { + const parsed = JSON.parse(req.get('X-Parse-File-Metadata')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(); + } + req.fileData.metadata = parsed; + } catch { + next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Metadata header.')); + return; + } + } + if (req.get('X-Parse-File-Tags')) { + try { + const parsed = JSON.parse(req.get('X-Parse-File-Tags')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(); + } + req.fileData.tags = parsed; + } catch { + next(new Parse.Error(Parse.Error.INVALID_JSON, 'Invalid JSON in X-Parse-File-Tags header.')); + return; + } + } + } + // Validate directory option (requires master key) const directory = req.fileData?.directory; if (directory !== undefined) {