diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ccc74bdc..9c6be5a0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,8 @@ ___ - Fix select and excludeKey queries to properly accept JSON string arrays. Also allow nested fields in exclude (Corey Baker) [#7242](https://github.com/parse-community/parse-server/pull/7242) - Fix LiveQuery server crash when using $all query operator on a missing object key (Jason Posthuma) [#7421](https://github.com/parse-community/parse-server/pull/7421) - Added runtime deprecation warnings (Manuel Trezza) [#7451](https://github.com/parse-community/parse-server/pull/7451) +- Add ability to pass context of an object via a header, X-Parse-Cloud-Context, for Cloud Code triggers. The header addition allows client SDK's to add context without injecting _context in the body of JSON objects (Corey Baker) [#7437](https://github.com/parse-community/parse-server/pull/7437) + ___ ## 4.5.0 [Full Changelog](https://github.com/parse-community/parse-server/compare/4.4.0...4.5.0) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index c53a284273..86a7627427 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -2519,6 +2519,201 @@ describe('afterFind hooks', () => { }); }); + it('should throw error if context header is malformed', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': 'key', + }, + body: { + foo: 'bar', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + + it('should throw error if context header is string "1"', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '1', + }, + body: { + foo: 'bar', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + + it('should expose context in beforeSave/afterSave via header', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(1); + expect(req.context.key).toBe('value'); + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(1); + expect(req.context.key).toBe('value'); + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + }, + }); + await req; + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); + + it('should override header context with body context in beforeSave/afterSave', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(10); + expect(req.context.key).toBe('hello'); + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.object.get('foo')).toEqual('bar'); + expect(req.context.otherKey).toBe(10); + expect(req.context.key).toBe('hello'); + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: '{"key":"hello","otherKey":10}', + }, + }); + await req; + expect(calledBefore).toBe(true); + expect(calledAfter).toBe(true); + }); + + it('should throw error if context body is malformed', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: 'key', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + + it('should throw error if context body is string "true"', async () => { + let calledBefore = false; + let calledAfter = false; + Parse.Cloud.beforeSave('TestObject', () => { + calledBefore = true; + }); + Parse.Cloud.afterSave('TestObject', () => { + calledAfter = true; + }); + const req = request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Cloud-Context': '{"key":"value","otherKey":1}', + }, + body: { + foo: 'bar', + _ApplicationId: 'test', + _context: 'true', + }, + }); + try { + await req; + fail('Should have thrown error'); + } catch (e) { + expect(e).toBeDefined(); + expect(e.data.code).toEqual(Parse.Error.INVALID_JSON); + } + expect(calledBefore).toBe(false); + expect(calledAfter).toBe(false); + }); + it('should expose context in before and afterSave', async () => { let calledBefore = false; let calledAfter = false; @@ -2804,6 +2999,26 @@ describe('afterLogin hook', () => { done(); }); + it('context options should override _context object property when saving a new object', async () => { + Parse.Cloud.beforeSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + expect(req.context.hello).not.toBeDefined(); + expect(req._context).not.toBeDefined(); + expect(req.object._context).not.toBeDefined(); + expect(req.object.context).not.toBeDefined(); + }); + Parse.Cloud.afterSave('TestObject', req => { + expect(req.context.a).toEqual('a'); + expect(req.context.hello).not.toBeDefined(); + expect(req._context).not.toBeDefined(); + expect(req.object._context).not.toBeDefined(); + expect(req.object.context).not.toBeDefined(); + }); + const obj = new TestObject(); + obj.set('_context', { hello: 'world' }); + await obj.save(null, { context: { a: 'a' } }); + }); + it('should have access to context when saving a new object', async () => { Parse.Cloud.beforeSave('TestObject', req => { expect(req.context.a).toEqual('a'); diff --git a/src/middlewares.js b/src/middlewares.js index 1c0a372031..88de107264 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -25,6 +25,17 @@ const getMountForRequest = function (req) { export function handleParseHeaders(req, res, next) { var mount = getMountForRequest(req); + let context = {}; + if (req.get('X-Parse-Cloud-Context') != null) { + try { + context = JSON.parse(req.get('X-Parse-Cloud-Context')); + if (Object.prototype.toString.call(context) !== '[object Object]') { + throw 'Context is not an object'; + } + } catch (e) { + return malformedContext(req, res); + } + } var info = { appId: req.get('X-Parse-Application-Id'), sessionToken: req.get('X-Parse-Session-Token'), @@ -35,7 +46,7 @@ export function handleParseHeaders(req, res, next) { dotNetKey: req.get('X-Parse-Windows-Key'), restAPIKey: req.get('X-Parse-REST-API-Key'), clientVersion: req.get('X-Parse-Client-Version'), - context: {}, + context: context, }; var basicAuth = httpAuth(req); @@ -105,8 +116,19 @@ export function handleParseHeaders(req, res, next) { info.masterKey = req.body._MasterKey; delete req.body._MasterKey; } - if (req.body._context && req.body._context instanceof Object) { - info.context = req.body._context; + if (req.body._context) { + if (req.body._context instanceof Object) { + info.context = req.body._context; + } else { + try { + info.context = JSON.parse(req.body._context); + if (Object.prototype.toString.call(info.context) !== '[object Object]') { + throw 'Context is not an object'; + } + } catch (e) { + return malformedContext(req, res); + } + } delete req.body._context; } if (req.body._ContentType) { @@ -454,3 +476,8 @@ function invalidRequest(req, res) { res.status(403); res.end('{"error":"unauthorized"}'); } + +function malformedContext(req, res) { + res.status(400); + res.json({ code: Parse.Error.INVALID_JSON, error: 'Invalid object for context.' }); +}