diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 6e17e9548a..7c17eca414 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,19 +1,30 @@ -Check out [this issue](https://github.com/ParsePlatform/parse-server/issues/1271) for an ideal bug report. The closer your issue report is to that one, the more likely we are to be able to help, and the more likely we will be to fix the issue quickly! +Check out https://github.com/ParsePlatform/parse-server/issues/1271 for an ideal bug report. The closer your issue report is to that one, the more likely we are to be able to help, and the more likely we will be to fix the issue quickly! -For implementation related questions or technical support, please refer to the [Stack Overflow](http://stackoverflow.com/questions/tagged/parse.com) and [Server Fault](https://serverfault.com/tags/parse) communities. For database migration help, please [file a bug report](https://parse.com/help#report). +Many members of the community use Stack Overflow and Server Fault to ask questions. Read through the existing questions or ask your own! + +- Stack Overflow: http://stackoverflow.com/questions/tagged/parse.com +- Server Fault: https://serverfault.com/tags/parse + +For database migration help, please file a bug report at https://parse.com/help#report Make sure these boxes are checked before submitting your issue -- thanks for reporting issues back to Parse Server! -- [ ] You've met the [prerequisites](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#prerequisites). +- [ ] You've met the prerequisites: https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#prerequisites. -- [ ] You're running the [latest version](https://github.com/ParsePlatform/parse-server/releases) of Parse Server. +- [ ] You're running the latest version of Parse Server: https://github.com/ParsePlatform/parse-server/releases -- [ ] You've searched through [existing issues](https://github.com/ParsePlatform/parse-server/issues?utf8=%E2%9C%93&q=). Chances are that your issue has been reported or resolved before. +- [ ] You've searched through existing issues: https://github.com/ParsePlatform/Parse-Server/issues?utf8=%E2%9C%93&q=is%3Aissue Chances are that your issue has been reported or resolved before. #### Environment Setup +- Server: parse-server version, operating system, hardware, local or remote? +- Database: version, storage engine, hardware, local or remote? #### Steps to reproduce +- Can this issue be reproduced using the Parse Server REST API? Include curl commands when applicable. +- What was the expected result? What is the actual outcome? #### Logs/Trace + +- You can turn on additional logging by configuring VERBOSE=1 in your environment. diff --git a/.travis.yml b/.travis.yml index 6c79fd16cf..e6248cfd7e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,14 @@ language: node_js node_js: - '4.3' +- '6.1' env: global: - COVERAGE_OPTION='./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**' matrix: - MONGODB_VERSION=2.6.11 - MONGODB_VERSION=3.0.8 + - MONGODB_VERSION=3.2.6 branches: only: - master diff --git a/CHANGELOG.md b/CHANGELOG.md index d8812172a9..2d1dcf2a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ ## Parse Server Changelog +### 2.2.10 (5/15/2016) + +* Fix: Write legacy ACLs to Mongo so that clients that still go through Parse.com can read them, thanks to [Tyler Brock](https://github.com/TylerBrock) and [carmenlau](https://github.com/carmenlau) +* Fix: Querying installations with limit = 0 and count = 1 now works, thanks to [ssk7833](https://github.com/ssk7833) +* Fix: Return correct error when violating unique index, thanks to [Marco Cheung](https://github.com/Marco129) +* Fix: Allow unsetting user's email, thanks to [Marco Cheung](https://github.com/Marco129) +* New: Support for Node 6.1 + +### 2.2.9 (5/9/2016) + +* Fix: Fix a regression that caused Parse Server to crash when a null parameter is passed to a Cloud function + +### 2.2.8 (5/8/2016) + +* New: Support for Pointer Permissions +* New: Expose logger in Cloud Code +* New: Option to revoke sessions on password reset +* New: Option to expire inactive sessions +* Perf: Improvements in ACL checking query +* Fix: Issues when sending pushes to list of devices that contains invalid values +* Fix: Issues caused by using babel-polyfill outside of Parse Server, but in the same express app +* Fix: Remove creation of extra session tokens +* Fix: Return authData when querying with master key +* Fix: Bugs when deleting webhooks +* Fix: Ignore _RevocableSession header, which might be sent by the JS SDK +* Fix: Issues with querying via URL params +* Fix: Properly encode "Date" parameters to cloud code functions + + ### 2.2.7 (4/15/2016) * Adds support for --verbose and verbose option when running ParseServer [\#1414](https://github.com/ParsePlatform/parse-server/pull/1414) ([flovilmart](https://github.com/flovilmart)) diff --git a/README.md b/README.md index de6b43f74c..586cc9d3b9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ That's it! You are now running a standalone version of Parse Server on your mach **Using a remote MongoDB?** Pass the `--databaseURI DATABASE_URI` parameter when starting `parse-server`. Learn more about configuring Parse Server [here](#configuration). For a full list of available options, run `parse-server --help`. +**Want logs to be in placed in other folder?** Pass the `PARSE_SERVER_LOGS_FOLDER` environment variable when starting `parse-server`. Usage :- `PARSE_SERVER_LOGS_FOLDER='' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` + ### Saving your first object Now that you're running Parse Server, it is time to save your first object. We'll use the [REST API](https://parse.com/docs/rest/guide), but you can easily do the same using any of the [Parse SDKs](https://parseplatform.github.io/#sdks). Run the following: diff --git a/bootstrap.sh b/bootstrap.sh index 25f9199bfa..4fb2fab3c1 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -103,7 +103,7 @@ cat > ./config.json << EOF "masterKey": "$MASTER_KEY", "appName": "$APP_NAME", "cloud": "./cloud/main", - "mongodbURI": "$MONGODB_URI" + "databaseURI": "$MONGODB_URI" } EOF echo "${CHECK} Created config.json" diff --git a/package.json b/package.json index da3bb7a67a..c767ca7177 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "2.2.7", + "version": "2.2.10", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { @@ -31,7 +31,7 @@ "lru-cache": "^4.0.0", "mailgun-js": "^0.7.7", "mime": "^1.3.4", - "mongodb": "~2.1.0", + "mongodb": "2.1.18", "multer": "^1.1.0", "parse": "^1.8.0", "parse-server-fs-adapter": "^1.0.0", @@ -57,17 +57,15 @@ "codecov": "^1.0.1", "cross-env": "^1.0.7", "deep-diff": "^0.3.3", - "flow-bin": "^0.22.0", - "gaze": "^0.5.2", + "gaze": "^1.0.0", "jasmine": "^2.3.2", - "mongodb-runner": "3.1.15", - "nodemon": "^1.8.1", - "parse-server-gcs-adapter": "^1.0.0" + "mongodb-runner": "^3.3.2", + "nodemon": "^1.8.1" }, "scripts": { "dev": "npm run build && node bin/dev", "build": "./node_modules/.bin/babel src/ -d lib/", - "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", + "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.2.6} MONGODB_STORAGE_ENGINE=mmapv1 ./node_modules/.bin/mongodb-runner start", "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node $COVERAGE_OPTION ./node_modules/jasmine/bin/jasmine.js", "test:win": "npm run pretest && cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/** ./node_modules/jasmine/bin/jasmine.js && npm run posttest", "posttest": "./node_modules/.bin/mongodb-runner stop", diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index 81e057c51f..250a8a7c49 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -2,7 +2,6 @@ var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; var FilesAdapter = require("parse-server-fs-adapter").default; var S3Adapter = require("parse-server-s3-adapter").default; -var GCSAdapter = require("parse-server-gcs-adapter").default; var ParsePushAdapter = require("parse-server-push-adapter").default; describe("AdapterLoader", ()=>{ @@ -114,13 +113,4 @@ describe("AdapterLoader", ()=>{ }).not.toThrow(); done(); }) - - it("should load GCSAdapter from direct passing", (done) => { - var gcsAdapter = new GCSAdapter("projectId", "path/to/keyfile", "bucket") - expect(() => { - var adapter = loadAdapter(gcsAdapter, FilesAdapter); - expect(adapter).toBe(gcsAdapter); - }).not.toThrow(); - done(); - }) }); diff --git a/spec/CacheController.spec.js b/spec/CacheController.spec.js new file mode 100644 index 0000000000..1e02d59e2f --- /dev/null +++ b/spec/CacheController.spec.js @@ -0,0 +1,74 @@ +var CacheController = require('../src/Controllers/CacheController.js').default; + +describe('CacheController', function() { + var FakeCacheAdapter; + var FakeAppID = 'foo'; + var KEY = 'hello'; + + beforeEach(() => { + FakeCacheAdapter = { + get: () => Promise.resolve(null), + put: jasmine.createSpy('put'), + del: jasmine.createSpy('del'), + clear: jasmine.createSpy('clear') + } + + spyOn(FakeCacheAdapter, 'get').and.callThrough(); + }); + + + it('should expose role and user caches', (done) => { + var cache = new CacheController(FakeCacheAdapter, FakeAppID); + + expect(cache.role).not.toEqual(null); + expect(cache.role.get).not.toEqual(null); + expect(cache.user).not.toEqual(null); + expect(cache.user.get).not.toEqual(null); + + done(); + }); + + + ['role', 'user'].forEach((cacheName) => { + it('should prefix ' + cacheName + ' cache', () => { + var cache = new CacheController(FakeCacheAdapter, FakeAppID)[cacheName]; + + cache.put(KEY, 'world'); + var firstPut = FakeCacheAdapter.put.calls.first(); + expect(firstPut.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); + + cache.get(KEY); + var firstGet = FakeCacheAdapter.get.calls.first(); + expect(firstGet.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); + + cache.del(KEY); + var firstDel = FakeCacheAdapter.del.calls.first(); + expect(firstDel.args[0]).toEqual([FakeAppID, cacheName, KEY].join(':')); + }); + }); + + it('should clear the entire cache', () => { + var cache = new CacheController(FakeCacheAdapter, FakeAppID); + + cache.clear(); + expect(FakeCacheAdapter.clear.calls.count()).toEqual(1); + + cache.user.clear(); + expect(FakeCacheAdapter.clear.calls.count()).toEqual(2); + + cache.role.clear(); + expect(FakeCacheAdapter.clear.calls.count()).toEqual(3); + }); + + it('should handle cache rejections', (done) => { + + FakeCacheAdapter.get = () => Promise.reject(); + + var cache = new CacheController(FakeCacheAdapter, FakeAppID); + + cache.get('foo').then(done, () => { + fail('Promise should not be rejected.'); + }); + }); + +}); diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js new file mode 100644 index 0000000000..8c2802d3e5 --- /dev/null +++ b/spec/CloudCode.spec.js @@ -0,0 +1,560 @@ +"use strict" +const Parse = require("parse/node"); +const request = require('request'); +const InMemoryCacheAdapter = require('../src/Adapters/Cache/InMemoryCacheAdapter').InMemoryCacheAdapter; + +describe('Cloud Code', () => { + it('can load absolute cloud code file', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + masterKey: 'test', + cloud: __dirname + '/cloud/cloudCodeRelativeFile.js' + }); + Parse.Cloud.run('cloudCodeInFile', {}, result => { + expect(result).toEqual('It is possible to define cloud code in a file.'); + done(); + }); + }); + + it('can load relative cloud code file', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + masterKey: 'test', + cloud: './spec/cloud/cloudCodeAbsoluteFile.js' + }); + Parse.Cloud.run('cloudCodeInFile', {}, result => { + expect(result).toEqual('It is possible to define cloud code in a file.'); + done(); + }); + }); + + it('can create functions', done => { + Parse.Cloud.define('hello', (req, res) => { + res.success('Hello world!'); + }); + + Parse.Cloud.run('hello', {}, result => { + expect(result).toEqual('Hello world!'); + done(); + }); + }); + + it('is cleared cleared after the previous test', done => { + Parse.Cloud.run('hello', {}) + .catch(error => { + expect(error.code).toEqual(141); + done(); + }); + }); + + it('basic beforeSave rejection', function(done) { + Parse.Cloud.beforeSave('BeforeSaveFail', function(req, res) { + res.error('You shall not pass!'); + }); + + var obj = new Parse.Object('BeforeSaveFail'); + obj.set('foo', 'bar'); + obj.save().then(() => { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, () => { + done(); + }) + }); + + it('basic beforeSave rejection via promise', function(done) { + Parse.Cloud.beforeSave('BeforeSaveFailWithPromise', function (req, res) { + var query = new Parse.Query('Yolo'); + query.find().then(() => { + res.error('Nope'); + }, () => { + res.success(); + }); + }); + + var obj = new Parse.Object('BeforeSaveFailWithPromise'); + obj.set('foo', 'bar'); + obj.save().then(function() { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, function(error) { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + done(); + }) + }); + + it('test beforeSave changed object success', function(done) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + req.object.set('foo', 'baz'); + res.success(); + }); + + var obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bar'); + obj.save().then(function() { + var query = new Parse.Query('BeforeSaveChanged'); + query.get(obj.id).then(function(objAgain) { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }, function(error) { + fail(error); + done(); + }); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test beforeSave returns value on create and update', (done) => { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + req.object.set('foo', 'baz'); + res.success(); + }); + + var obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bing'); + obj.save().then(() => { + expect(obj.get('foo')).toEqual('baz'); + obj.set('foo', 'bar'); + return obj.save().then(() => { + expect(obj.get('foo')).toEqual('baz'); + done(); + }) + }) + }); + + it('test afterSave ran and created an object', function(done) { + Parse.Cloud.afterSave('AfterSaveTest', function(req) { + var obj = new Parse.Object('AfterSaveProof'); + obj.set('proof', req.object.id); + obj.save(); + }); + + var obj = new Parse.Object('AfterSaveTest'); + obj.save(); + + setTimeout(function() { + var query = new Parse.Query('AfterSaveProof'); + query.equalTo('proof', obj.id); + query.find().then(function(results) { + expect(results.length).toEqual(1); + done(); + }, function(error) { + fail(error); + done(); + }); + }, 500); + }); + + it('test beforeSave happens on update', function(done) { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + req.object.set('foo', 'baz'); + res.success(); + }); + + var obj = new Parse.Object('BeforeSaveChanged'); + obj.set('foo', 'bar'); + obj.save().then(function() { + obj.set('foo', 'bar'); + return obj.save(); + }).then(function() { + var query = new Parse.Query('BeforeSaveChanged'); + return query.get(obj.id).then(function(objAgain) { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test beforeDelete failure', function(done) { + Parse.Cloud.beforeDelete('BeforeDeleteFail', function(req, res) { + res.error('Nope'); + }); + + var obj = new Parse.Object('BeforeDeleteFail'); + var id; + obj.set('foo', 'bar'); + obj.save().then(() => { + id = obj.id; + return obj.destroy(); + }).then(() => { + fail('obj.destroy() should have failed, but it succeeded'); + done(); + }, (error) => { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + + var objAgain = new Parse.Object('BeforeDeleteFail', {objectId: id}); + return objAgain.fetch(); + }).then((objAgain) => { + if (objAgain) { + expect(objAgain.get('foo')).toEqual('bar'); + } else { + fail("unable to fetch the object ", id); + } + done(); + }, (error) => { + // We should have been able to fetch the object again + fail(error); + }); + }); + + it('basic beforeDelete rejection via promise', function(done) { + Parse.Cloud.beforeSave('BeforeDeleteFailWithPromise', function (req, res) { + var query = new Parse.Query('Yolo'); + query.find().then(() => { + res.error('Nope'); + }, () => { + res.success(); + }); + }); + + var obj = new Parse.Object('BeforeDeleteFailWithPromise'); + obj.set('foo', 'bar'); + obj.save().then(function() { + fail('Should not have been able to save BeforeSaveFailure class.'); + done(); + }, function(error) { + expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); + expect(error.message).toEqual('Nope'); + + done(); + }) + }); + + it('test afterDelete ran and created an object', function(done) { + Parse.Cloud.afterDelete('AfterDeleteTest', function(req) { + var obj = new Parse.Object('AfterDeleteProof'); + obj.set('proof', req.object.id); + obj.save(); + }); + + var obj = new Parse.Object('AfterDeleteTest'); + obj.save().then(function() { + obj.destroy(); + }); + + setTimeout(function() { + var query = new Parse.Query('AfterDeleteProof'); + query.equalTo('proof', obj.id); + query.find().then(function(results) { + expect(results.length).toEqual(1); + done(); + }, function(error) { + fail(error); + done(); + }); + }, 500); + }); + + it('test cloud function return types', function(done) { + Parse.Cloud.define('foo', function(req, res) { + res.success({ + object: { + __type: 'Object', + className: 'Foo', + objectId: '123', + x: 2, + relation: { + __type: 'Object', + className: 'Bar', + objectId: '234', + x: 3 + } + }, + array: [{ + __type: 'Object', + className: 'Bar', + objectId: '345', + x: 2 + }], + a: 2 + }); + }); + + Parse.Cloud.run('foo').then((result) => { + expect(result.object instanceof Parse.Object).toBeTruthy(); + if (!result.object) { + fail("Unable to run foo"); + done(); + return; + } + expect(result.object.className).toEqual('Foo'); + expect(result.object.get('x')).toEqual(2); + var bar = result.object.get('relation'); + expect(bar instanceof Parse.Object).toBeTruthy(); + expect(bar.className).toEqual('Bar'); + expect(bar.get('x')).toEqual(3); + expect(Array.isArray(result.array)).toEqual(true); + expect(result.array[0] instanceof Parse.Object).toBeTruthy(); + expect(result.array[0].get('x')).toEqual(2); + done(); + }); + }); + + it('test cloud function should echo keys', function(done) { + Parse.Cloud.define('echoKeys', function(req, res){ + return res.success({ + applicationId: Parse.applicationId, + masterKey: Parse.masterKey, + javascriptKey: Parse.javascriptKey + }) + }); + + Parse.Cloud.run('echoKeys').then((result) => { + expect(result.applicationId).toEqual(Parse.applicationId); + expect(result.masterKey).toEqual(Parse.masterKey); + expect(result.javascriptKey).toEqual(Parse.javascriptKey); + done(); + }); + }); + + it('should properly create an object in before save', done => { + Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { + req.object.set('foo', 'baz'); + res.success(); + }); + + Parse.Cloud.define('createBeforeSaveChangedObject', function(req, res){ + var obj = new Parse.Object('BeforeSaveChanged'); + obj.save().then(() => { + res.success(obj); + }) + }) + + Parse.Cloud.run('createBeforeSaveChangedObject').then((res) => { + expect(res.get('foo')).toEqual('baz'); + done(); + }); + }); + + it('dirtyKeys are set on update', done => { + let triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.beforeSave('GameScore', (req, res) => { + var object = req.object; + expect(object instanceof Parse.Object).toBeTruthy(); + expect(object.get('fooAgain')).toEqual('barAgain'); + if (triggerTime == 0) { + // Create + expect(object.get('foo')).toEqual('bar'); + } else if (triggerTime == 1) { + // Update + expect(object.dirtyKeys()).toEqual(['foo']); + expect(object.dirty('foo')).toBeTruthy(); + expect(object.get('foo')).toEqual('baz'); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + let obj = new Parse.Object('GameScore'); + obj.set('foo', 'bar'); + obj.set('fooAgain', 'barAgain'); + obj.save().then(() => { + // We only update foo + obj.set('foo', 'baz'); + return obj.save(); + }).then(() => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + done(); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test beforeSave unchanged success', function(done) { + Parse.Cloud.beforeSave('BeforeSaveUnchanged', function(req, res) { + res.success(); + }); + + var obj = new Parse.Object('BeforeSaveUnchanged'); + obj.set('foo', 'bar'); + obj.save().then(function() { + done(); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test beforeDelete success', function(done) { + Parse.Cloud.beforeDelete('BeforeDeleteTest', function(req, res) { + res.success(); + }); + + var obj = new Parse.Object('BeforeDeleteTest'); + obj.set('foo', 'bar'); + obj.save().then(function() { + return obj.destroy(); + }).then(function() { + var objAgain = new Parse.Object('BeforeDeleteTest', obj.id); + return objAgain.fetch().then(fail, done); + }, function(error) { + fail(error); + done(); + }); + }); + + it('test save triggers get user', function(done) { + Parse.Cloud.beforeSave('SaveTriggerUser', function(req, res) { + if (req.user && req.user.id) { + res.success(); + } else { + res.error('No user present on request object for beforeSave.'); + } + }); + + Parse.Cloud.afterSave('SaveTriggerUser', function(req) { + if (!req.user || !req.user.id) { + console.log('No user present on request object for afterSave.'); + } + }); + + var user = new Parse.User(); + user.set("password", "asdf"); + user.set("email", "asdf@example.com"); + user.set("username", "zxcv"); + user.signUp(null, { + success: function() { + var obj = new Parse.Object('SaveTriggerUser'); + obj.save().then(function() { + done(); + }, function(error) { + fail(error); + done(); + }); + } + }); + }); + + it('beforeSave change propagates through the save response', (done) => { + Parse.Cloud.beforeSave('ChangingObject', function(request, response) { + request.object.set('foo', 'baz'); + response.success(); + }); + let obj = new Parse.Object('ChangingObject'); + obj.save({ foo: 'bar' }).then((objAgain) => { + expect(objAgain.get('foo')).toEqual('baz'); + done(); + }, (e) => { + fail('Should not have failed to save.'); + done(); + }); + }); + + it('test cloud function parameter validation success', (done) => { + // Register a function with validation + Parse.Cloud.define('functionWithParameterValidation', (req, res) => { + res.success('works'); + }, (request) => { + return request.params.success === 100; + }); + + Parse.Cloud.run('functionWithParameterValidation', {"success":100}).then((s) => { + done(); + }, (e) => { + fail('Validation should not have failed.'); + done(); + }); + }); + + it('doesnt receive stale user in cloud code functions after user has been updated with master key (regression test for #1836)', done => { + Parse.Cloud.define('testQuery', function(request, response) { + response.success(request.user.get('data')); + }); + + Parse.User.signUp('user', 'pass') + .then(user => { + user.set('data', 'AAA'); + return user.save(); + }) + .then(() => Parse.Cloud.run('testQuery')) + .then(result => { + expect(result).toEqual('AAA'); + Parse.User.current().set('data', 'BBB'); + return Parse.User.current().save(null, {useMasterKey: true}); + }) + .then(() => Parse.Cloud.run('testQuery')) + .then(result => { + expect(result).toEqual('BBB'); + done(); + }); + }); + + it('clears out the user cache for all sessions when the user is changed', done => { + const cacheAdapter = new InMemoryCacheAdapter({ ttl: 100000000 }); + setServerConfiguration(Object.assign({}, defaultConfiguration, { cacheAdapter: cacheAdapter })); + Parse.Cloud.define('checkStaleUser', (request, response) => { + response.success(request.user.get('data')); + }); + + let user = new Parse.User(); + user.set('username', 'test'); + user.set('password', 'moon-y'); + user.set('data', 'first data'); + user.signUp() + .then(user => { + let session1 = user.getSessionToken(); + request.get({ + url: 'http://localhost:8378/1/login?username=test&password=moon-y', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + }, (error, response, body) => { + let session2 = body.sessionToken; + + //Ensure both session tokens are in the cache + Parse.Cloud.run('checkStaleUser') + .then(() => { + request.post({ + url: 'http://localhost:8378/1/functions/checkStaleUser', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': session2, + } + }, (error, response, body) => { + Parse.Promise.all([cacheAdapter.get('test:user:' + session1), cacheAdapter.get('test:user:' + session2)]) + .then(cachedVals => { + expect(cachedVals[0].objectId).toEqual(user.id); + expect(cachedVals[1].objectId).toEqual(user.id); + + //Change with session 1 and then read with session 2. + user.set('data', 'second data'); + user.save() + .then(() => { + request.post({ + url: 'http://localhost:8378/1/functions/checkStaleUser', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Session-Token': session2, + } + }, (error, response, body) => { + expect(body.result).toEqual('second data'); + done(); + }) + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/spec/CloudCodeLogger.spec.js b/spec/CloudCodeLogger.spec.js index 23fc967e0c..a556762a8f 100644 --- a/spec/CloudCodeLogger.spec.js +++ b/spec/CloudCodeLogger.spec.js @@ -14,7 +14,6 @@ describe("Cloud Code Logger", () => { }); Parse.Cloud.run('loggerTest').then(() => { - Parse.Cloud._removeHook('Functions', 'logTest'); return logController.getLogs({from: Date.now() - 500, size: 1000}); }).then((res) => { expect(res.length).not.toBe(0); @@ -42,7 +41,6 @@ describe("Cloud Code Logger", () => { let obj = new Parse.Object('MyObject'); obj.save().then(() => { - Parse.Cloud._removeHook('Triggers', 'beforeSave', 'MyObject'); return logController.getLogs({from: Date.now() - 500, size: 1000}) }).then((res) => { expect(res.length).not.toBe(0); diff --git a/spec/FileLoggerAdapter.spec.js b/spec/FileLoggerAdapter.spec.js index fb4d6d5572..f259422df6 100644 --- a/spec/FileLoggerAdapter.spec.js +++ b/spec/FileLoggerAdapter.spec.js @@ -1,5 +1,8 @@ +'use strict'; + var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; var Parse = require('parse/node').Parse; +var request = require('request'); describe('info logs', () => { @@ -45,3 +48,55 @@ describe('error logs', () => { }); }); }); + +describe('verbose logs', () => { + + it("mask sensitive information in _User class", (done) => { + let customConfig = Object.assign({}, defaultConfiguration, {verbose: true}); + setServerConfiguration(customConfig); + createTestUser().then(() => { + let fileLoggerAdapter = new FileLoggerAdapter(); + return fileLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose' + }); + }).then((results) => { + expect(results[1].message.includes('"password": "********"')).toEqual(true); + var headers = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.get({ + headers: headers, + url: 'http://localhost:8378/1/login?username=test&password=moon-y' + }, (error, response, body) => { + let fileLoggerAdapter = new FileLoggerAdapter(); + return fileLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose' + }).then((results) => { + expect(results[1].message.includes('password=********')).toEqual(true); + done(); + }); + }); + }); + }); + + it("should not mask information in non _User class", (done) => { + let obj = new Parse.Object('users'); + obj.set('password', 'pw'); + obj.save().then(() => { + let fileLoggerAdapter = new FileLoggerAdapter(); + return fileLoggerAdapter.query({ + from: new Date(Date.now() - 500), + size: 100, + level: 'verbose' + }); + }).then((results) => { + expect(results[1].message.includes('"password": "pw"')).toEqual(true); + done(); + }); + }); +}); diff --git a/spec/InMemoryCache.spec.js b/spec/InMemoryCache.spec.js new file mode 100644 index 0000000000..3c0fb47bbb --- /dev/null +++ b/spec/InMemoryCache.spec.js @@ -0,0 +1,74 @@ +const InMemoryCache = require('../src/Adapters/Cache/InMemoryCache').default; + + +describe('InMemoryCache', function() { + var BASE_TTL = { + ttl: 10 + }; + var NO_EXPIRE_TTL = { + ttl: NaN + }; + var KEY = 'hello'; + var KEY_2 = KEY + '_2'; + + var VALUE = 'world'; + + + function wait(sleep) { + return new Promise(function(resolve, reject) { + setTimeout(resolve, sleep); + }) + } + + it('should destroy a expire items in the cache', (done) => { + var cache = new InMemoryCache(BASE_TTL); + + cache.put(KEY, VALUE); + + var value = cache.get(KEY); + expect(value).toEqual(VALUE); + + wait(BASE_TTL.ttl * 5).then(() => { + value = cache.get(KEY) + expect(value).toEqual(null); + done(); + }); + }); + + it('should delete items', (done) => { + var cache = new InMemoryCache(NO_EXPIRE_TTL); + cache.put(KEY, VALUE); + cache.put(KEY_2, VALUE); + expect(cache.get(KEY)).toEqual(VALUE); + expect(cache.get(KEY_2)).toEqual(VALUE); + + cache.del(KEY); + expect(cache.get(KEY)).toEqual(null); + expect(cache.get(KEY_2)).toEqual(VALUE); + + cache.del(KEY_2); + expect(cache.get(KEY)).toEqual(null); + expect(cache.get(KEY_2)).toEqual(null); + done(); + }); + + it('should clear all items', (done) => { + var cache = new InMemoryCache(NO_EXPIRE_TTL); + cache.put(KEY, VALUE); + cache.put(KEY_2, VALUE); + + expect(cache.get(KEY)).toEqual(VALUE); + expect(cache.get(KEY_2)).toEqual(VALUE); + cache.clear(); + + expect(cache.get(KEY)).toEqual(null); + expect(cache.get(KEY_2)).toEqual(null); + done(); + }); + + it('should deafult TTL to 5 seconds', () => { + var cache = new InMemoryCache({}); + expect(cache.ttl).toEqual(5 * 1000); + }); + +}); diff --git a/spec/InMemoryCacheAdapter.spec.js b/spec/InMemoryCacheAdapter.spec.js new file mode 100644 index 0000000000..405da6f7ad --- /dev/null +++ b/spec/InMemoryCacheAdapter.spec.js @@ -0,0 +1,59 @@ +var InMemoryCacheAdapter = require('../src/Adapters/Cache/InMemoryCacheAdapter').default; + +describe('InMemoryCacheAdapter', function() { + var KEY = 'hello'; + var VALUE = 'world'; + + function wait(sleep) { + return new Promise(function(resolve, reject) { + setTimeout(resolve, sleep); + }) + } + + it('should expose promisifyed methods', (done) => { + var cache = new InMemoryCacheAdapter({ + ttl: NaN + }); + + var noop = () => {}; + + // Verify all methods return promises. + Promise.all([ + cache.put(KEY, VALUE), + cache.del(KEY), + cache.get(KEY), + cache.clear() + ]).then(() => { + done(); + }); + }); + + it('should get/set/clear', (done) => { + var cache = new InMemoryCacheAdapter({ + ttl: NaN + }); + + cache.put(KEY, VALUE) + .then(() => cache.get(KEY)) + .then((value) => expect(value).toEqual(VALUE)) + .then(() => cache.clear()) + .then(() => cache.get(KEY)) + .then((value) => expect(value).toEqual(null)) + .then(done); + }); + + it('should expire after ttl', (done) => { + var cache = new InMemoryCacheAdapter({ + ttl: 10 + }); + + cache.put(KEY, VALUE) + .then(() => cache.get(KEY)) + .then((value) => expect(value).toEqual(VALUE)) + .then(wait.bind(null, 50)) + .then(() => cache.get(KEY)) + .then((value) => expect(value).toEqual(null)) + .then(done); + }) + +}); diff --git a/spec/InstallationsRouter.spec.js b/spec/InstallationsRouter.spec.js new file mode 100644 index 0000000000..82416aa42f --- /dev/null +++ b/spec/InstallationsRouter.spec.js @@ -0,0 +1,170 @@ +var auth = require('../src/Auth'); +var Config = require('../src/Config'); +var rest = require('../src/rest'); +var InstallationsRouter = require('../src/Routers/InstallationsRouter').InstallationsRouter; + +var config = new Config('test'); + +describe('InstallationsRouter', () => { + it('uses find condition from request.body', (done) => { + var androidDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abc', + 'deviceType': 'android' + }; + var iosDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abd', + 'deviceType': 'ios' + }; + var request = { + config: config, + auth: auth.master(config), + body: { + where: { + deviceType: 'android' + } + }, + query: {} + }; + + var router = new InstallationsRouter(); + rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }).then(() => { + return router.handleFind(request); + }).then((res) => { + var results = res.response.results; + expect(results.length).toEqual(1); + done(); + }); + }); + + it('uses find condition from request.query', (done) => { + var androidDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abc', + 'deviceType': 'android' + }; + var iosDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abd', + 'deviceType': 'ios' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + where: { + deviceType: 'android' + } + } + }; + + var router = new InstallationsRouter(); + rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }).then(() => { + return router.handleFind(request); + }).then((res) => { + var results = res.response.results; + expect(results.length).toEqual(1); + done(); + }); + }); + + it('query installations with limit = 0', (done) => { + var androidDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abc', + 'deviceType': 'android' + }; + var iosDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abd', + 'deviceType': 'ios' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + limit: 0 + } + }; + + var router = new InstallationsRouter(); + rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }).then(() => { + return router.handleFind(request); + }).then((res) => { + var response = res.response; + expect(response.results.length).toEqual(0); + done(); + }); + }); + + it('query installations with count = 1', (done) => { + var androidDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abc', + 'deviceType': 'android' + }; + var iosDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abd', + 'deviceType': 'ios' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + count: 1 + } + }; + + var router = new InstallationsRouter(); + rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }).then(() => { + return router.handleFind(request); + }).then((res) => { + var response = res.response; + expect(response.results.length).toEqual(2); + expect(response.count).toEqual(2); + done(); + }); + }); + + it('query installations with limit = 0 and count = 1', (done) => { + var androidDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abc', + 'deviceType': 'android' + }; + var iosDeviceRequest = { + 'installationId': '12345678-abcd-abcd-abcd-123456789abd', + 'deviceType': 'ios' + }; + var request = { + config: config, + auth: auth.master(config), + body: {}, + query: { + limit: 0, + count: 1 + } + }; + + var router = new InstallationsRouter(); + rest.create(config, auth.nobody(config), '_Installation', androidDeviceRequest) + .then(() => { + return rest.create(config, auth.nobody(config), '_Installation', iosDeviceRequest); + }).then(() => { + return router.handleFind(request); + }).then((res) => { + var response = res.response; + expect(response.results.length).toEqual(0); + expect(response.count).toEqual(2); + done(); + }); + }); +}); diff --git a/spec/MongoSchemaCollectionAdapter.spec.js b/spec/MongoSchemaCollectionAdapter.spec.js index 00a5b1900c..ba22c1fab3 100644 --- a/spec/MongoSchemaCollectionAdapter.spec.js +++ b/spec/MongoSchemaCollectionAdapter.spec.js @@ -28,6 +28,15 @@ describe('MongoSchemaCollection', () => { "deviceType":"string", "channels":"array", "user":"*_User", + "pushType":"string", + "GCMSenderId":"string", + "timeZone":"string", + "localeIdentifier":"string", + "badge":"number", + "appVersion":"string", + "appName":"string", + "appIdentifier":"string", + "parseVersion":"string", })).toEqual({ className: '_Installation', fields: { @@ -36,6 +45,15 @@ describe('MongoSchemaCollection', () => { deviceType: { type: 'String' }, channels: { type: 'Array' }, user: { type: 'Pointer', targetClass: '_User' }, + pushType: { type: 'String' }, + GCMSenderId: { type: 'String' }, + timeZone: { type: 'String' }, + localeIdentifier: { type: 'String' }, + badge: { type: 'Number' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + appIdentifier: { type: 'String' }, + parseVersion: { type: 'String' }, ACL: { type: 'ACL' }, createdAt: { type: 'Date' }, updatedAt: { type: 'Date' }, diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 59fee087b6..905b7647c1 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -106,7 +106,7 @@ describe('parseObjectToMongoObjectForCreate', () => { describe('transformWhere', () => { it('objectId', (done) => { - var out = transform.transformWhere(dummySchema, null, {objectId: 'foo'}); + var out = transform.transformWhere(null, {objectId: 'foo'}); expect(out._id).toEqual('foo'); done(); }); @@ -115,7 +115,7 @@ describe('transformWhere', () => { var input = { objectId: {'$in': ['one', 'two', 'three']}, }; - var output = transform.transformWhere(dummySchema, null, input); + var output = transform.transformWhere(null, input); jequal(input.objectId, output._id); done(); }); @@ -191,17 +191,6 @@ describe('untransformObject', () => { }); }); -describe('transformKey', () => { - it('throws out _password', (done) => { - try { - transform.transformKey(dummySchema, '_User', '_password'); - fail('should have thrown'); - } catch (e) { - done(); - } - }); -}); - describe('transform schema key changes', () => { it('changes new pointer key', (done) => { @@ -244,6 +233,21 @@ describe('transform schema key changes', () => { done(); }); + it('writes the old ACL format in addition to rperm and wperm', (done) => { + var input = { + ACL: { + "*": { "read": true }, + "Kevin": { "write": true } + } + }; + + var output = transform.parseObjectToMongoObjectForCreate(dummySchema, null, input); + expect(typeof output._acl).toEqual('object'); + expect(output._acl["Kevin"].w).toBeTruthy(); + expect(output._acl["Kevin"].r).toBeUndefined(); + done(); + }) + it('untransforms from _rperm and _wperm to ACL', (done) => { var input = { _rperm: ["*"], diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index 79364b0ce4..ac25793b8f 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -1182,7 +1182,6 @@ describe('Parse.ACL', () => { done(); }, error => { expect(error.code).toEqual(Parse.Error.OBJECT_NOT_FOUND); - Parse.Cloud._removeHook('Triggers', 'afterSave', Parse.User.className); done(); }); }); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index a1bb412f54..21603a0956 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -5,6 +5,7 @@ var DatabaseAdapter = require('../src/DatabaseAdapter'); var request = require('request'); const Parse = require("parse/node"); +let Config = require('../src/Config'); describe('miscellaneous', function() { it('create a GameScore object', function(done) { @@ -163,56 +164,22 @@ describe('miscellaneous', function() { }); }); - it('test cloud function', function(done) { - Parse.Cloud.run('hello', {}, function(result) { - expect(result).toEqual('Hello world!'); - done(); + it('test beforeSave set object acl success', function(done) { + var acl = new Parse.ACL({ + '*': { read: true, write: false } }); - }); - - it('basic beforeSave rejection', function(done) { - var obj = new Parse.Object('BeforeSaveFail'); - obj.set('foo', 'bar'); - obj.save().then(() => { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, () => { - done(); - }) - }); - - it('basic beforeSave rejection via promise', function(done) { - var obj = new Parse.Object('BeforeSaveFailWithPromise'); - obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, function(error) { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); - - done(); - }) - }); - - it('test beforeSave unchanged success', function(done) { - var obj = new Parse.Object('BeforeSaveUnchanged'); - obj.set('foo', 'bar'); - obj.save().then(function() { - done(); - }, function(error) { - fail(error); - done(); + Parse.Cloud.beforeSave('BeforeSaveAddACL', function(req, res) { + req.object.setACL(acl); + res.success(); }); - }); - it('test beforeSave changed object success', function(done) { - var obj = new Parse.Object('BeforeSaveChanged'); - obj.set('foo', 'bar'); + var obj = new Parse.Object('BeforeSaveAddACL'); + obj.set('lol', true); obj.save().then(function() { - var query = new Parse.Query('BeforeSaveChanged'); + var query = new Parse.Query('BeforeSaveAddACL'); query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('foo')).toEqual('baz'); + expect(objAgain.get('lol')).toBeTruthy(); + expect(objAgain.getACL().equals(acl)); done(); }, function(error) { fail(error); @@ -224,185 +191,6 @@ describe('miscellaneous', function() { }); }); - it('test beforeSave returns value on create and update', (done) => { - var obj = new Parse.Object('BeforeSaveChanged'); - obj.set('foo', 'bing'); - obj.save().then(() => { - expect(obj.get('foo')).toEqual('baz'); - obj.set('foo', 'bar'); - return obj.save().then(() => { - expect(obj.get('foo')).toEqual('baz'); - done(); - }) - }) - }); - - it('test afterSave ran and created an object', function(done) { - var obj = new Parse.Object('AfterSaveTest'); - obj.save(); - - setTimeout(function() { - var query = new Parse.Query('AfterSaveProof'); - query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(1); - done(); - }, function(error) { - fail(error); - done(); - }); - }, 500); - }); - - it('test beforeSave happens on update', function(done) { - var obj = new Parse.Object('BeforeSaveChanged'); - obj.set('foo', 'bar'); - obj.save().then(function() { - obj.set('foo', 'bar'); - return obj.save(); - }).then(function() { - var query = new Parse.Query('BeforeSaveChanged'); - return query.get(obj.id).then(function(objAgain) { - expect(objAgain.get('foo')).toEqual('baz'); - done(); - }); - }, function(error) { - fail(error); - done(); - }); - }); - - it('test beforeDelete failure', function(done) { - var obj = new Parse.Object('BeforeDeleteFail'); - var id; - obj.set('foo', 'bar'); - obj.save().then(() => { - id = obj.id; - return obj.destroy(); - }).then(() => { - fail('obj.destroy() should have failed, but it succeeded'); - done(); - }, (error) => { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); - - var objAgain = new Parse.Object('BeforeDeleteFail', {objectId: id}); - return objAgain.fetch(); - }).then((objAgain) => { - if (objAgain) { - expect(objAgain.get('foo')).toEqual('bar'); - } else { - fail("unable to fetch the object ", id); - } - done(); - }, (error) => { - // We should have been able to fetch the object again - fail(error); - }); - }); - - it('basic beforeDelete rejection via promise', function(done) { - var obj = new Parse.Object('BeforeDeleteFailWithPromise'); - obj.set('foo', 'bar'); - obj.save().then(function() { - fail('Should not have been able to save BeforeSaveFailure class.'); - done(); - }, function(error) { - expect(error.code).toEqual(Parse.Error.SCRIPT_FAILED); - expect(error.message).toEqual('Nope'); - - done(); - }) - }); - - it('test beforeDelete success', function(done) { - var obj = new Parse.Object('BeforeDeleteTest'); - obj.set('foo', 'bar'); - obj.save().then(function() { - return obj.destroy(); - }).then(function() { - var objAgain = new Parse.Object('BeforeDeleteTest', obj.id); - return objAgain.fetch().then(fail, done); - }, function(error) { - fail(error); - done(); - }); - }); - - it('test afterDelete ran and created an object', function(done) { - var obj = new Parse.Object('AfterDeleteTest'); - obj.save().then(function() { - obj.destroy(); - }); - - setTimeout(function() { - var query = new Parse.Query('AfterDeleteProof'); - query.equalTo('proof', obj.id); - query.find().then(function(results) { - expect(results.length).toEqual(1); - done(); - }, function(error) { - fail(error); - done(); - }); - }, 500); - }); - - it('test save triggers get user', function(done) { - var user = new Parse.User(); - user.set("password", "asdf"); - user.set("email", "asdf@example.com"); - user.set("username", "zxcv"); - user.signUp(null, { - success: function() { - var obj = new Parse.Object('SaveTriggerUser'); - obj.save().then(function() { - done(); - }, function(error) { - fail(error); - done(); - }); - } - }); - }); - - it('test cloud function return types', function(done) { - Parse.Cloud.run('foo').then((result) => { - expect(result.object instanceof Parse.Object).toBeTruthy(); - if (!result.object) { - fail("Unable to run foo"); - done(); - return; - } - expect(result.object.className).toEqual('Foo'); - expect(result.object.get('x')).toEqual(2); - var bar = result.object.get('relation'); - expect(bar instanceof Parse.Object).toBeTruthy(); - expect(bar.className).toEqual('Bar'); - expect(bar.get('x')).toEqual(3); - expect(Array.isArray(result.array)).toEqual(true); - expect(result.array[0] instanceof Parse.Object).toBeTruthy(); - expect(result.array[0].get('x')).toEqual(2); - done(); - }); - }); - - it('test cloud function should echo keys', function(done) { - Parse.Cloud.run('echoKeys').then((result) => { - expect(result.applicationId).toEqual(Parse.applicationId); - expect(result.masterKey).toEqual(Parse.masterKey); - expect(result.javascriptKey).toEqual(Parse.javascriptKey); - done(); - }); - }); - - it('should properly create an object in before save', (done) => { - Parse.Cloud.run('createBeforeSaveChangedObject').then((res) => { - expect(res.get('foo')).toEqual('baz'); - done(); - }); - }) - it('test rest_create_app', function(done) { var appId; Parse._request('POST', 'rest_create_app').then((res) => { @@ -427,17 +215,6 @@ describe('miscellaneous', function() { }); describe('beforeSave', () => { - beforeEach(done => { - // Make sure the required mock for all tests is unset. - Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); - done(); - }); - afterEach(done => { - // Make sure the required mock for all tests is unset. - Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); - done(); - }); - it('object is set on create and update', done => { let triggerTime = 0; // Register a mock beforeSave hook @@ -482,45 +259,6 @@ describe('miscellaneous', function() { }); }); - it('dirtyKeys are set on update', done => { - let triggerTime = 0; - // Register a mock beforeSave hook - Parse.Cloud.beforeSave('GameScore', (req, res) => { - var object = req.object; - expect(object instanceof Parse.Object).toBeTruthy(); - expect(object.get('fooAgain')).toEqual('barAgain'); - if (triggerTime == 0) { - // Create - expect(object.get('foo')).toEqual('bar'); - } else if (triggerTime == 1) { - // Update - expect(object.dirtyKeys()).toEqual(['foo']); - expect(object.dirty('foo')).toBeTruthy(); - expect(object.get('foo')).toEqual('baz'); - } else { - res.error(); - } - triggerTime++; - res.success(); - }); - - let obj = new Parse.Object('GameScore'); - obj.set('foo', 'bar'); - obj.set('fooAgain', 'barAgain'); - obj.save().then(() => { - // We only update foo - obj.set('foo', 'baz'); - return obj.save(); - }).then(() => { - // Make sure the checking has been triggered - expect(triggerTime).toBe(2); - done(); - }, function(error) { - fail(error); - done(); - }); - }); - it('original object is set on update', done => { let triggerTime = 0; // Register a mock beforeSave hook @@ -644,7 +382,6 @@ describe('miscellaneous', function() { return obj.save(); }).then((obj) => { expect(obj.get('point').id).toEqual(pointId); - Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); done(); }) }); @@ -682,8 +419,6 @@ describe('miscellaneous', function() { }).then(function() { // Make sure the checking has been triggered expect(triggerTime).toBe(2); - // Clear mock beforeSave - Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); done(); }, function(error) { fail(error); @@ -735,8 +470,6 @@ describe('miscellaneous', function() { }).then(function() { // Make sure the checking has been triggered expect(triggerTime).toBe(2); - // Clear mock afterSave - Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); done(); }, function(error) { console.error(error); @@ -785,8 +518,6 @@ describe('miscellaneous', function() { }).then(function() { // Make sure the checking has been triggered expect(triggerTime).toBe(2); - // Clear mock afterSave - Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); done(); }, function(error) { console.error(error); @@ -825,8 +556,6 @@ describe('miscellaneous', function() { }).then(() => { // Make sure the checking has been triggered expect(triggerTime).toBe(2); - // Clear mock afterSave - Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); done(); }, error => { console.error(error); @@ -867,8 +596,6 @@ describe('miscellaneous', function() { }).then(() => { // Make sure the checking has been triggered expect(triggerTime).toBe(2); - // Clear mock afterSave - Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); done(); }, error => { console.error(error); @@ -909,8 +636,6 @@ describe('miscellaneous', function() { }).then(() => { // Make sure the checking has been triggered expect(triggerTime).toBe(2); - // Clear mock afterSave - Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); done(); }, error => { console.error(error); @@ -970,12 +695,10 @@ describe('miscellaneous', function() { }); Parse.Cloud.run('willFail').then((s) => { fail('Should not have succeeded.'); - Parse.Cloud._removeHook("Functions", "willFail"); done(); }, (e) => { expect(e.code).toEqual(141); expect(e.message).toEqual('noway'); - Parse.Cloud._removeHook("Functions", "willFail"); done(); }); }); @@ -1007,9 +730,6 @@ describe('miscellaneous', function() { }, (error, response, body) => { expect(error).toBe(null); expect(triggerTime).toEqual(2); - - Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); - Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); done(); }); }); @@ -1046,9 +766,6 @@ describe('miscellaneous', function() { }, (error, response, body) => { expect(error).toBe(null); expect(triggerTime).toEqual(2); - - Parse.Cloud._removeHook("Triggers", "beforeDelete", "GameScore"); - Parse.Cloud._removeHook("Triggers", "afterDelete", "GameScore"); done(); }); }); @@ -1078,24 +795,6 @@ describe('miscellaneous', function() { // Make sure query string params override body params expect(res.other).toEqual('2'); expect(res.foo).toEqual("bar"); - Parse.Cloud._removeHook("Functions",'echoParams'); - done(); - }); - }); - - it('test cloud function parameter validation success', (done) => { - // Register a function with validation - Parse.Cloud.define('functionWithParameterValidation', (req, res) => { - res.success('works'); - }, (request) => { - return request.params.success === 100; - }); - - Parse.Cloud.run('functionWithParameterValidation', {"success":100}).then((s) => { - Parse.Cloud._removeHook("Functions", "functionWithParameterValidation"); - done(); - }, (e) => { - fail('Validation should not have failed.'); done(); }); }); @@ -1110,7 +809,6 @@ describe('miscellaneous', function() { Parse.Cloud.run('functionWithParameterValidationFailure', {"success":500}).then((s) => { fail('Validation should not have succeeded'); - Parse.Cloud._removeHook("Functions", "functionWithParameterValidationFailure"); done(); }, (e) => { expect(e.code).toEqual(141); @@ -1119,6 +817,21 @@ describe('miscellaneous', function() { }); }); + it('can handle null params in cloud functions (regression test for #1742)', done => { + Parse.Cloud.define('func', (request, response) => { + expect(request.params.nullParam).toEqual(null); + response.success('yay'); + }); + + Parse.Cloud.run('func', {nullParam: null}) + .then(() => { + done() + }, e => { + fail('cloud code call failed'); + done(); + }); + }); + it('fails on invalid client key', done => { var headers = { 'Content-Type': 'application/octet-stream', @@ -1198,23 +911,6 @@ describe('miscellaneous', function() { }); }); - it('beforeSave change propagates through the save response', (done) => { - Parse.Cloud.beforeSave('ChangingObject', function(request, response) { - request.object.set('foo', 'baz'); - response.success(); - }); - let obj = new Parse.Object('ChangingObject'); - obj.save({ foo: 'bar' }).then((objAgain) => { - expect(objAgain.get('foo')).toEqual('baz'); - Parse.Cloud._removeHook("Triggers", "beforeSave", "ChangingObject"); - done(); - }, (e) => { - Parse.Cloud._removeHook("Triggers", "beforeSave", "ChangingObject"); - fail('Should not have failed to save.'); - done(); - }); - }); - it('dedupes an installation properly and returns updatedAt', (done) => { let headers = { 'Content-Type': 'application/json', @@ -1371,4 +1067,57 @@ describe('miscellaneous', function() { }) }); }); + + it('fail when create duplicate value in unique field', (done) => { + let obj = new Parse.Object('UniqueField'); + obj.set('unique', 'value'); + obj.save().then(() => { + expect(obj.id).not.toBeUndefined(); + let config = new Config('test'); + return config.database.adapter.adaptiveCollection('UniqueField') + }).then(collection => { + return collection._mongoCollection.createIndex({ 'unique': 1 }, { unique: true }) + }).then(() => { + let obj = new Parse.Object('UniqueField'); + obj.set('unique', 'value'); + return obj.save() + }).then(() => { + return Promise.reject(); + }, error => { + expect(error.code === Parse.Error.DUPLICATE_VALUE); + done(); + }); + }); + + it('doesnt convert interior keys of objects that use special names', done => { + let obj = new Parse.Object('Obj'); + obj.set('val', { createdAt: 'a', updatedAt: 1 }); + obj.save() + .then(obj => new Parse.Query('Obj').get(obj.id)) + .then(obj => { + expect(obj.get('val').createdAt).toEqual('a'); + expect(obj.get('val').updatedAt).toEqual(1); + done(); + }); + }); + + it('bans interior keys containing . or $', done => { + new Parse.Object('Obj').save({innerObj: {'key with a $': 'fails'}}) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({innerObj: {'key with a .': 'fails'}}); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({innerObj: {innerInnerObj: {'key with $': 'fails'}}}); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + return new Parse.Object('Obj').save({innerObj: {innerInnerObj: {'key with .': 'fails'}}}); + }) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_NESTED_KEY); + done(); + }) + }); }); diff --git a/spec/ParseHooks.spec.js b/spec/ParseHooks.spec.js index 2a0cec5a72..d3c04f6900 100644 --- a/spec/ParseHooks.spec.js +++ b/spec/ParseHooks.spec.js @@ -76,7 +76,7 @@ describe('Hooks', () => { }) }); - it("should CRUD a trigger registration", (done) => { + it("should CRUD a trigger registration", (done) => { // Create Parse.Hooks.createTrigger("MyClass","beforeDelete", "http://someurl").then((res) => { expect(res.className).toBe("MyClass"); diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index 8ff6c6c37b..6b79743bb3 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -696,20 +696,16 @@ describe('Parse.Relation testing', () => { admins.first({ useMasterKey: true }) .then(user => { if (user) { - Parse.Cloud._removeHook('Functions', 'isAdmin'); done(); } else { - Parse.Cloud._removeHook('Functions', 'isAdmin'); fail('Should have found admin user, found nothing instead'); done(); } }, error => { - Parse.Cloud._removeHook('Functions', 'isAdmin'); fail('User not admin'); done(); }) }, error => { - Parse.Cloud._removeHook('Functions', 'isAdmin'); fail('Should have found admin user, errored instead'); fail(error); done(); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 0873f4426f..f333a714e1 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1156,12 +1156,10 @@ describe('Parse.User testing', () => { Parse.User._logInWith("facebook", { success: function(innerModel) { - Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); done(); }, error: function(model, error) { ok(undefined, error); - Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); done(); } }); @@ -1584,8 +1582,6 @@ describe('Parse.User testing', () => { Parse.User._registerAuthenticationProvider(provider); Parse.User._logInWith("facebook", { success: function(model) { - Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); - Parse.Cloud._removeHook('Triggers', 'afterSave', Parse.User.className); done(); } }); @@ -1834,6 +1830,22 @@ describe('Parse.User testing', () => { }); }); + it('unset user email', (done) => { + var user = new Parse.User(); + user.set('username', 'test'); + user.set('password', 'test'); + user.set('email', 'test@test.com'); + user.signUp().then(() => { + user.unset('email'); + return user.save(); + }).then(() => { + return Parse.User.logIn('test', 'test'); + }).then((user) => { + expect(user.getEmail()).toBeUndefined(); + done(); + }); + }); + it('create session from user', (done) => { Parse.Promise.as().then(() => { return Parse.User.signUp("finn", "human", { foo: "bar" }); @@ -2197,11 +2209,9 @@ describe('Parse.User testing', () => { }).then((user) => { expect(typeof user).toEqual('object'); expect(user.authData).toBeUndefined(); - Parse.Cloud._removeHook('Triggers', 'beforeSave', '_User'); done(); }).catch((err) => { fail('no request should fail: ' + JSON.stringify(err)); - Parse.Cloud._removeHook('Triggers', 'beforeSave', '_User'); done(); }); }); @@ -2221,7 +2231,6 @@ describe('Parse.User testing', () => { user.set('hello', 'world'); return user.save(); }).then(() => { - Parse.Cloud._removeHook('Triggers', 'afterSave', '_User'); done(); }); }); @@ -2379,7 +2388,6 @@ describe('Parse.User testing', () => { serverURL: 'http://localhost:8378/1', appId: 'test', masterKey: 'test', - cloud: './spec/cloud/main.js', revokeSessionOnPasswordReset: false, }) request.post({ diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index ffc19e4f26..13efc95f0a 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -352,4 +352,31 @@ describe('rest create', () => { done(); }); }); + + it("can create a session with no expiration", (done) => { + var user = { + username: 'asdf', + password: 'zxcv', + foo: 'bar' + }; + config.expireInactiveSessions = false; + + rest.create(config, auth.nobody(config), '_User', user) + .then((r) => { + expect(Object.keys(r.response).length).toEqual(3); + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + return rest.find(config, auth.master(config), + '_Session', {sessionToken: r.response.sessionToken}); + }) + .then((r) => { + expect(r.results.length).toEqual(1); + + var session = r.results[0]; + expect(session.expiresAt).toBeUndefined(); + + done(); + }); + }); }); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 67367b4653..e30ed6da49 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -124,7 +124,6 @@ describe('SchemaController', () => { var obj; createTestUser() .then(user => { - console.log(user); return config.database.loadSchema() // Create a valid class .then(schema => schema.validateObject('Stuff', {foo: 'bar'})) @@ -454,6 +453,10 @@ describe('SchemaController', () => { timeZone: { type: 'String' }, localeIdentifier: { type: 'String' }, badge: { type: 'Number' }, + appVersion: { type: 'String' }, + appName: { type: 'String' }, + appIdentifier: { type: 'String' }, + parseVersion: { type: 'String' }, }, classLevelPermissions: { find: { '*': true }, diff --git a/spec/cloud/cloudCodeAbsoluteFile.js b/spec/cloud/cloudCodeAbsoluteFile.js new file mode 100644 index 0000000000..f5fcf2b856 --- /dev/null +++ b/spec/cloud/cloudCodeAbsoluteFile.js @@ -0,0 +1,3 @@ +Parse.Cloud.define('cloudCodeInFile', (req, res) => { + res.success('It is possible to define cloud code in a file.'); +}); diff --git a/spec/cloud/cloudCodeRelativeFile.js b/spec/cloud/cloudCodeRelativeFile.js new file mode 100644 index 0000000000..f5fcf2b856 --- /dev/null +++ b/spec/cloud/cloudCodeRelativeFile.js @@ -0,0 +1,3 @@ +Parse.Cloud.define('cloudCodeInFile', (req, res) => { + res.success('It is possible to define cloud code in a file.'); +}); diff --git a/spec/cloud/main.js b/spec/cloud/main.js deleted file mode 100644 index 0785c0a624..0000000000 --- a/spec/cloud/main.js +++ /dev/null @@ -1,117 +0,0 @@ -Parse.Cloud.define('hello', function(req, res) { - res.success('Hello world!'); -}); - -Parse.Cloud.beforeSave('BeforeSaveFail', function(req, res) { - res.error('You shall not pass!'); -}); - -Parse.Cloud.beforeSave('BeforeSaveFailWithPromise', function (req, res) { - var query = new Parse.Query('Yolo'); - query.find().then(() => { - res.error('Nope'); - }, () => { - res.success(); - }); -}); - -Parse.Cloud.beforeSave('BeforeSaveUnchanged', function(req, res) { - res.success(); -}); - -Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) { - req.object.set('foo', 'baz'); - res.success(); -}); - -Parse.Cloud.afterSave('AfterSaveTest', function(req) { - var obj = new Parse.Object('AfterSaveProof'); - obj.set('proof', req.object.id); - obj.save(); -}); - -Parse.Cloud.beforeDelete('BeforeDeleteFail', function(req, res) { - res.error('Nope'); -}); - -Parse.Cloud.beforeSave('BeforeDeleteFailWithPromise', function (req, res) { - var query = new Parse.Query('Yolo'); - query.find().then(() => { - res.error('Nope'); - }, () => { - res.success(); - }); -}); - -Parse.Cloud.beforeDelete('BeforeDeleteTest', function(req, res) { - res.success(); -}); - -Parse.Cloud.afterDelete('AfterDeleteTest', function(req) { - var obj = new Parse.Object('AfterDeleteProof'); - obj.set('proof', req.object.id); - obj.save(); -}); - -Parse.Cloud.beforeSave('SaveTriggerUser', function(req, res) { - if (req.user && req.user.id) { - res.success(); - } else { - res.error('No user present on request object for beforeSave.'); - } -}); - -Parse.Cloud.afterSave('SaveTriggerUser', function(req) { - if (!req.user || !req.user.id) { - console.log('No user present on request object for afterSave.'); - } -}); - -Parse.Cloud.define('foo', function(req, res) { - res.success({ - object: { - __type: 'Object', - className: 'Foo', - objectId: '123', - x: 2, - relation: { - __type: 'Object', - className: 'Bar', - objectId: '234', - x: 3 - } - }, - array: [{ - __type: 'Object', - className: 'Bar', - objectId: '345', - x: 2 - }], - a: 2 - }); -}); - -Parse.Cloud.define('bar', function(req, res) { - res.error('baz'); -}); - -Parse.Cloud.define('requiredParameterCheck', function(req, res) { - res.success(); -}, function(params) { - return params.name; -}); - -Parse.Cloud.define('echoKeys', function(req, res){ - return res.success({ - applicationId: Parse.applicationId, - masterKey: Parse.masterKey, - javascriptKey: Parse.javascriptKey - }) -}); - -Parse.Cloud.define('createBeforeSaveChangedObject', function(req, res){ - var obj = new Parse.Object('BeforeSaveChanged'); - obj.save().then(() => { - res.success(obj); - }) -}) diff --git a/spec/helper.js b/spec/helper.js index 0d6379fbf8..5d804d12e9 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -1,6 +1,6 @@ // Sets up a Parse API server for testing. -jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; var cache = require('../src/cache').default; var DatabaseAdapter = require('../src/DatabaseAdapter'); @@ -12,13 +12,11 @@ var TestUtils = require('../src/index').TestUtils; var MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); var databaseURI = process.env.DATABASE_URI; -var cloudMain = process.env.CLOUD_CODE_MAIN || './spec/cloud/main.js'; var port = 8378; // Default server configuration for tests. var defaultConfiguration = { databaseURI: databaseURI, - cloud: cloudMain, serverURL: 'http://localhost:' + port + '/1', appId: 'test', javascriptKey: 'test', @@ -63,7 +61,7 @@ const setServerConfiguration = configuration => { DatabaseAdapter.clearDatabaseSettings(); currentConfiguration = configuration; server.close(); - cache.clearCache(); + cache.clear(); app = express(); api = new ParseServer(configuration); app.use('/1', api); @@ -94,6 +92,7 @@ var mongoAdapter = new MongoStorageAdapter({ }) afterEach(function(done) { + Parse.Cloud._removeAllHooks(); mongoAdapter.getAllSchemas() .then(allSchemas => { allSchemas.forEach((schema) => { diff --git a/spec/index.spec.js b/spec/index.spec.js index d2c8984d71..6ea648423a 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -13,6 +13,30 @@ describe('server', () => { done(); }); + it('support http basic authentication with masterkey', done => { + request.get({ + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'Authorization': 'Basic ' + new Buffer('test:' + 'test').toString('base64') + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + done(); + }); + }); + + it('support http basic authentication with javascriptKey', done => { + request.get({ + url: 'http://localhost:8378/1/classes/TestObject', + headers: { + 'Authorization': 'Basic ' + new Buffer('test:javascript-key=' + 'test').toString('base64') + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + done(); + }); + }); + it('fails if database is unreachable', done => { setServerConfiguration({ databaseURI: 'mongodb://fake:fake@ds043605.mongolab.com:43605/drew3', @@ -175,26 +199,6 @@ describe('server', () => { }) }); - it('can load absolute cloud code file', done => { - setServerConfiguration({ - serverURL: 'http://localhost:8378/1', - appId: 'test', - masterKey: 'test', - cloud: __dirname + '/cloud/main.js' - }); - done(); - }); - - it('can load relative cloud code file', done => { - setServerConfiguration({ - serverURL: 'http://localhost:8378/1', - appId: 'test', - masterKey: 'test', - cloud: './spec/cloud/main.js' - }); - done(); - }); - it('can create a parse-server', done => { var parseServer = new ParseServer.default({ appId: "aTestApp", @@ -332,6 +336,29 @@ describe('server', () => { sessionLength: '0' })).toThrow('Session length must be a value greater than 0.'); done(); + }); + + it('ignores the session length when expireInactiveSessions set to false', (done) => { + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + masterKey: 'test', + sessionLength: '-33', + expireInactiveSessions: false + })).not.toThrow(); + + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + masterKey: 'test', + sessionLength: '0', + expireInactiveSessions: false + })).not.toThrow(); + done(); }) it('fails if you try to set revokeSessionOnPasswordReset to non-boolean', done => { diff --git a/src/Adapters/Cache/CacheAdapter.js b/src/Adapters/Cache/CacheAdapter.js new file mode 100644 index 0000000000..7d65381763 --- /dev/null +++ b/src/Adapters/Cache/CacheAdapter.js @@ -0,0 +1,27 @@ +export class CacheAdapter { + /** + * Get a value in the cache + * @param key Cache key to get + * @return Promise that will eventually resolve to the value in the cache. + */ + get(key) {} + + /** + * Set a value in the cache + * @param key Cache key to set + * @param value Value to set the key + * @param ttl Optional TTL + */ + put(key, value, ttl) {} + + /** + * Remove a value from the cache. + * @param key Cache key to remove + */ + del(key) {} + + /** + * Empty a cache + */ + clear() {} +} diff --git a/src/Adapters/Cache/InMemoryCache.js b/src/Adapters/Cache/InMemoryCache.js new file mode 100644 index 0000000000..2d44292a0a --- /dev/null +++ b/src/Adapters/Cache/InMemoryCache.js @@ -0,0 +1,65 @@ +const DEFAULT_CACHE_TTL = 5 * 1000; + + +export class InMemoryCache { + constructor({ + ttl = DEFAULT_CACHE_TTL + }) { + this.ttl = ttl; + this.cache = Object.create(null); + } + + get(key) { + let record = this.cache[key]; + if (record == null) { + return null; + } + + // Has Record and isnt expired + if (isNaN(record.expire) || record.expire >= Date.now()) { + return record.value; + } + + // Record has expired + delete this.cache[key]; + return null; + } + + put(key, value, ttl = this.ttl) { + if (ttl < 0 || isNaN(ttl)) { + ttl = NaN; + } + + var record = { + value: value, + expire: ttl + Date.now() + } + + if (!isNaN(record.expire)) { + record.timeout = setTimeout(() => { + this.del(key); + }, ttl); + } + + this.cache[key] = record; + } + + del(key) { + var record = this.cache[key]; + if (record == null) { + return; + } + + if (record.timeout) { + clearTimeout(record.timeout); + } + delete this.cache[key]; + } + + clear() { + this.cache = Object.create(null); + } + +} + +export default InMemoryCache; diff --git a/src/Adapters/Cache/InMemoryCacheAdapter.js b/src/Adapters/Cache/InMemoryCacheAdapter.js new file mode 100644 index 0000000000..09e1c12a11 --- /dev/null +++ b/src/Adapters/Cache/InMemoryCacheAdapter.js @@ -0,0 +1,36 @@ +import {InMemoryCache} from './InMemoryCache'; + +export class InMemoryCacheAdapter { + + constructor(ctx) { + this.cache = new InMemoryCache(ctx) + } + + get(key) { + return new Promise((resolve, reject) => { + let record = this.cache.get(key); + if (record == null) { + return resolve(null); + } + + return resolve(JSON.parse(record)); + }) + } + + put(key, value, ttl) { + this.cache.put(key, JSON.stringify(value), ttl); + return Promise.resolve(); + } + + del(key) { + this.cache.del(key); + return Promise.resolve(); + } + + clear() { + this.cache.clear(); + return Promise.resolve(); + } +} + +export default InMemoryCacheAdapter; diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index e29b68754b..bf41582b19 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -17,7 +17,7 @@ export default class MongoCollection { return this._rawFind(query, { skip, limit, sort }) .catch(error => { // Check for "no geoindex" error - if (error.code != 17007 || !error.message.match(/unable to find index for .geoNear/)) { + if (error.code != 17007 && !error.message.match(/unable to find index for .geoNear/)) { throw error; } // Figure out what key needs an index diff --git a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js index a51edc2a65..11db8a377e 100644 --- a/src/Adapters/Storage/Mongo/MongoSchemaCollection.js +++ b/src/Adapters/Storage/Mongo/MongoSchemaCollection.js @@ -25,6 +25,7 @@ function mongoFieldToParseSchemaField(type) { case 'array': return {type: 'Array'}; case 'geopoint': return {type: 'GeoPoint'}; case 'file': return {type: 'File'}; + case 'bytes': return {type: 'Bytes'}; } } diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 803b3c63b7..81af4e970c 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -166,23 +166,25 @@ export class MongoStorageAdapter { createObject(className, object, schemaController, parseFormatSchema) { const mongoObject = transform.parseObjectToMongoObjectForCreate(schemaController, className, object, parseFormatSchema); return this.adaptiveCollection(className) - .then(collection => collection.insertOne(mongoObject)); + .then(collection => collection.insertOne(mongoObject)) + .catch(error => { + if (error.code === 11000) { // Duplicate value + throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided'); + } + return Promise.reject(error); + }); } // Remove all objects that match the given parse query. Parse Query should be in Parse Format. // If no objects match, reject with OBJECT_NOT_FOUND. If objects are found and deleted, resolve with undefined. // If there is some other error, reject with INTERNAL_SERVER_ERROR. - // Currently accepts the schemaController, and validate for lecacy reasons - deleteObjectsByQuery(className, query, schemaController, validate) { + // Currently accepts validate for legacy reasons. Currently accepts the schema, that may not actually be necessary. + deleteObjectsByQuery(className, query, validate, schema) { return this.adaptiveCollection(className) .then(collection => { - let mongoWhere = transform.transformWhere( - schemaController, - className, - query, - { validate } - ); + let mongoWhere = transform.transformWhere(className, query, { validate }, schema); return collection.deleteMany(mongoWhere) }) .then(({ result }) => { diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 34f0f15779..31c3a9192c 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -3,31 +3,23 @@ import _ from 'lodash'; var mongodb = require('mongodb'); var Parse = require('parse/node').Parse; -// Transforms a key-value pair from REST API form to Mongo form. -// This is the main entry point for converting anything from REST form -// to Mongo form; no conversion should happen that doesn't pass -// through this function. -// Schema should already be loaded. -// -// There are several options that can help transform: -// -// query: true indicates that query constraints like $lt are allowed in -// the value. -// -// update: true indicates that __op operators like Add and Delete -// in the value are converted to a mongo update form. Otherwise they are -// converted to static data. -// -// validate: true indicates that key names are to be validated. -// -// Returns an object with {key: key, value: value}. -export function transformKeyValue(schema, className, restKey, restValue, { - inArray, - inObject, - query, - update, - validate, -} = {}) { +const transformKey = (className, fieldName, schema) => { + // Check if the schema is known since it's a built-in field. + switch(fieldName) { + case 'objectId': return '_id'; + case 'createdAt': return '_created_at'; + case 'updatedAt': return '_updated_at'; + case 'sessionToken': return '_session_token'; + } + + if (schema.fields[fieldName] && schema.fields[fieldName].__type == 'Pointer') { + fieldName = '_p_' + fieldName; + } + + return fieldName; +} + +const transformKeyValueForUpdate = (schema, className, restKey, restValue) => { // Check if the schema is known since it's a built-in field. var key = restKey; var timeField = false; @@ -66,47 +58,14 @@ export function transformKeyValue(schema, className, restKey, restValue, { return {key: key, value: restValue}; break; case '$or': - if (!query) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'you can only use $or in queries'); - } - if (!(restValue instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'bad $or format - use an array value'); - } - var mongoSubqueries = restValue.map((s) => { - return transformWhere(schema, className, s); - }); - return {key: '$or', value: mongoSubqueries}; + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'you can only use $or in queries'); case '$and': - if (!query) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'you can only use $and in queries'); - } - if (!(restValue instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'bad $and format - use an array value'); - } - var mongoSubqueries = restValue.map((s) => { - return transformWhere(schema, className, s); - }); - return {key: '$and', value: mongoSubqueries}; + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'you can only use $and in queries'); default: // Other auth data var authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); if (authDataMatch) { - if (query) { - var provider = authDataMatch[1]; - // Special-case auth data. - return {key: '_auth_data_'+provider+'.id', value: restValue}; - } - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'can only query on ' + key); - break; - }; - if (validate && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'invalid key name: ' + key); + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'can only query on ' + key); } } @@ -117,85 +76,161 @@ export function transformKeyValue(schema, className, restKey, restValue, { if (schema && schema.getExpectedType) { expected = schema.getExpectedType(className, key); } - if ((expected && expected.type == 'Pointer') || - (!expected && restValue && restValue.__type == 'Pointer')) { + if ((expected && expected.type == 'Pointer') || (!expected && restValue && restValue.__type == 'Pointer')) { key = '_p_' + key; } - var expectedTypeIsArray = (expected && expected.type === 'Array'); - - // Handle query constraints - if (query) { - value = transformConstraint(restValue, expectedTypeIsArray); - if (value !== CannotTransform) { - return {key: key, value: value}; - } - } - - if (expectedTypeIsArray && query && !(restValue instanceof Array)) { - return { - key: key, value: { '$all' : [restValue] } - }; - } // Handle atomic values - var value = transformAtom(restValue, false, { inArray, inObject }); + var value = transformTopLevelAtom(restValue); if (value !== CannotTransform) { if (timeField && (typeof value === 'string')) { value = new Date(value); } - return {key: key, value: value}; + return {key, value}; } - // ACLs are handled before this method is called - // If an ACL key still exists here, something is wrong. - if (key === 'ACL') { - throw 'There was a problem transforming an ACL.'; + // Handle arrays + if (restValue instanceof Array) { + value = restValue.map(transformInteriorValue); + return {key, value}; + } + + // Handle update operators + if (typeof restValue === 'object' && '__op' in restValue) { + return {key, value: transformUpdateOperator(restValue, false)}; + } + + // Handle normal objects by recursing + if (Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { + throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + } + value = _.mapValues(restValue, transformInteriorValue); + return {key, value}; +} + +const transformInteriorValue = restValue => { + if (typeof restValue === 'object' && Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { + throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + } + // Handle atomic values + var value = transformInteriorAtom(restValue); + if (value !== CannotTransform) { + return value; } // Handle arrays if (restValue instanceof Array) { - if (query) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'cannot use array as query param'); - } - value = restValue.map((restObj) => { - var out = transformKeyValue(schema, className, restKey, restObj, { inArray: true }); - return out.value; - }); - return {key: key, value: value}; + return restValue.map(transformInteriorValue); } // Handle update operators - value = transformUpdateOperator(restValue, !update); - if (value !== CannotTransform) { - return {key: key, value: value}; + if (typeof restValue === 'object' && '__op' in restValue) { + return transformUpdateOperator(restValue, true); } // Handle normal objects by recursing - value = {}; - for (var subRestKey in restValue) { - var subRestValue = restValue[subRestKey]; - var out = transformKeyValue(schema, className, subRestKey, subRestValue, { inObject: true }); - // For recursed objects, keep the keys in rest format - value[subRestKey] = out.value; - } - return {key: key, value: value}; + return _.mapValues(restValue, transformInteriorValue); } +const valueAsDate = value => { + if (typeof value === 'string') { + return new Date(value); + } else if (value instanceof Date) { + return value; + } + return false; +} + +function transformQueryKeyValue(className, key, value, { validate } = {}, schema) { + switch(key) { + case 'createdAt': + if (valueAsDate(value)) { + return {key: '_created_at', value: valueAsDate(value)} + } + key = '_created_at'; + break; + case 'updatedAt': + if (valueAsDate(value)) { + return {key: '_updated_at', value: valueAsDate(value)} + } + key = '_updated_at'; + break; + case 'expiresAt': + if (valueAsDate(value)) { + return {key: 'expiresAt', value: valueAsDate(value)} + } + break; + case 'objectId': return {key: '_id', value} + case 'sessionToken': return {key: '_session_token', value} + case '_rperm': + case '_wperm': + case '_perishable_token': + case '_email_verify_token': return {key, value} + case '$or': + if (!(value instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad $or format - use an array value'); + } + return {key: '$or', value: value.map(subQuery => transformWhere(className, subQuery, {}, schema))}; + case '$and': + if (!(value instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, 'bad $and format - use an array value'); + } + return {key: '$and', value: value.map(subQuery => transformWhere(className, subQuery, {}, schema))}; + default: + // Other auth data + const authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); + if (authDataMatch) { + const provider = authDataMatch[1]; + // Special-case auth data. + return {key: `_auth_data_${provider}.id`, value}; + } + if (validate && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'invalid key name: ' + key); + } + } + + const expectedTypeIsArray = + schema && + schema.fields[key] && + schema.fields[key].type === 'Array'; + + const expectedTypeIsPointer = + schema && + schema.fields[key] && + schema.fields[key].type === 'Pointer'; + + if (expectedTypeIsPointer || !schema && value && value.__type === 'Pointer') { + key = '_p_' + key; + } + + // Handle query constraints + if (transformConstraint(value, expectedTypeIsArray) !== CannotTransform) { + return {key, value: transformConstraint(value, expectedTypeIsArray)}; + } + + if (expectedTypeIsArray && !(value instanceof Array)) { + return {key, value: { '$all' : [value] }}; + } + + // Handle atomic values + if (transformTopLevelAtom(value) !== CannotTransform) { + return {key, value: transformTopLevelAtom(value)}; + } else { + throw new Parse.Error(Parse.Error.INVALID_JSON, `You cannot use ${value} as a query parameter.`); + } +} // Main exposed method to help run queries. // restWhere is the "where" clause in REST API form. // Returns the mongo form of the query. // Throws a Parse.Error if the input query is invalid. -function transformWhere(schema, className, restWhere, options = {validate: true}) { +function transformWhere(className, restWhere, { validate = true } = {}, schema) { let mongoWhere = {}; if (restWhere['ACL']) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Cannot query on ACL.'); } - let transformKeyOptions = {query: true}; - transformKeyOptions.validate = options.validate; for (let restKey in restWhere) { - let out = transformKeyValue(schema, className, restKey, restWhere[restKey], transformKeyOptions); + let out = transformQueryKeyValue(className, restKey, restWhere[restKey], { validate }, schema); mongoWhere[out.key] = out.value; } return mongoWhere; @@ -214,15 +249,15 @@ const parseObjectKeyValueToMongoObjectKeyValue = ( switch(restKey) { case 'objectId': return {key: '_id', value: restValue}; case 'createdAt': - transformedValue = transformAtom(restValue, false); + transformedValue = transformTopLevelAtom(restValue); coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue return {key: '_created_at', value: coercedToDate}; case 'updatedAt': - transformedValue = transformAtom(restValue, false); + transformedValue = transformTopLevelAtom(restValue); coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue return {key: '_updated_at', value: coercedToDate}; case 'expiresAt': - transformedValue = transformAtom(restValue, false); + transformedValue = transformTopLevelAtom(restValue); coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue return {key: 'expiresAt', value: coercedToDate}; case '_rperm': @@ -241,7 +276,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = ( return {key: restKey, value: restValue}; } } - //skip straight to transformAtom for Bytes, they don't show up in the schema for some reason + //skip straight to transformTopLevelAtom for Bytes, they don't show up in the schema for some reason if (restValue && restValue.__type !== 'Bytes') { //Note: We may not know the type of a field here, as the user could be saving (null) to a field //That never existed before, meaning we can't infer the type. @@ -251,7 +286,7 @@ const parseObjectKeyValueToMongoObjectKeyValue = ( } // Handle atomic values - var value = transformAtom(restValue, false, { inArray: false, inObject: false }); + var value = transformTopLevelAtom(restValue); if (value !== CannotTransform) { return {key: restKey, value: value}; } @@ -264,28 +299,21 @@ const parseObjectKeyValueToMongoObjectKeyValue = ( // Handle arrays if (restValue instanceof Array) { - value = restValue.map((restObj) => { - var out = transformKeyValue(schema, className, restKey, restObj, { inArray: true }); - return out.value; - }); + value = restValue.map(transformInteriorValue); return {key: restKey, value: value}; } // Handle update operators. TODO: handle within Parse Server. DB adapter shouldn't see update operators in creates. - value = transformUpdateOperator(restValue, true); - if (value !== CannotTransform) { - return {key: restKey, value: value}; + if (typeof restValue === 'object' && '__op' in restValue) { + return {key: restKey, value: transformUpdateOperator(restValue, true)}; } // Handle normal objects by recursing - value = {}; - for (var subRestKey in restValue) { - var subRestValue = restValue[subRestKey]; - var out = transformKeyValue(schema, className, subRestKey, subRestValue, { inObject: true }); - // For recursed objects, keep the keys in rest format - value[subRestKey] = out.value; - } - return {key: restKey, value: value}; + if (Object.keys(restValue).some(key => key.includes('$') || key.includes('.'))) { + throw new Parse.Error(Parse.Error.INVALID_NESTED_KEY, "Nested keys should not contain the '$' or '.' characters"); + } + value = _.mapValues(restValue, transformInteriorValue); + return {key: restKey, value}; } // Main exposed method to create new objects. @@ -321,7 +349,7 @@ function transformUpdate(schema, className, restUpdate) { var mongoUpdate = {}; var acl = transformACL(restUpdate); - if (acl._rperm || acl._wperm) { + if (acl._rperm || acl._wperm || acl._acl) { mongoUpdate['$set'] = {}; if (acl._rperm) { mongoUpdate['$set']['_rperm'] = acl._rperm; @@ -329,16 +357,18 @@ function transformUpdate(schema, className, restUpdate) { if (acl._wperm) { mongoUpdate['$set']['_wperm'] = acl._wperm; } + if (acl._acl) { + mongoUpdate['$set']['_acl'] = acl._acl; + } } for (var restKey in restUpdate) { - var out = transformKeyValue(schema, className, restKey, restUpdate[restKey], {update: true}); + var out = transformKeyValueForUpdate(schema, className, restKey, restUpdate[restKey]); // If the output value is an object with any $ keys, it's an // operator that needs to be lifted onto the top level update // object. - if (typeof out.value === 'object' && out.value !== null && - out.value.__op) { + if (typeof out.value === 'object' && out.value !== null && out.value.__op) { mongoUpdate[out.value.__op] = mongoUpdate[out.value.__op] || {}; mongoUpdate[out.value.__op][out.key] = out.value.arg; } else { @@ -377,16 +407,23 @@ function transformACL(restObject) { var acl = restObject['ACL']; var rperm = []; var wperm = []; + var _acl = {}; // old format + for (var entry in acl) { if (acl[entry].read) { rperm.push(entry); + _acl[entry] = _acl[entry] || {}; + _acl[entry]['r'] = true; } if (acl[entry].write) { wperm.push(entry); + _acl[entry] = _acl[entry] || {}; + _acl[entry]['w'] = true; } } output._rperm = rperm; output._wperm = wperm; + output._acl = _acl; delete restObject.ACL; return output; } @@ -421,29 +458,37 @@ function untransformACL(mongoObject) { return output; } -// Transforms a key used in the REST API format to its mongo format. -function transformKey(schema, className, key) { - return transformKeyValue(schema, className, key, null, {validate: true}).key; -} - // A sentinel value that helper transformations return when they // cannot perform a transformation function CannotTransform() {} +const transformInteriorAtom = atom => { + // TODO: check validity harder for the __type-defined types + if (typeof atom === 'object' && atom && !(atom instanceof Date) && atom.__type === 'Pointer') { + return { + __type: 'Pointer', + className: atom.className, + objectId: atom.objectId + }; + } else if (typeof atom === 'function' || typeof atom === 'symbol') { + throw new Parse.Error(Parse.Error.INVALID_JSON, `cannot transform value: ${atom}`); + } else if (DateCoder.isValidJSON(atom)) { + return DateCoder.JSONToDatabase(atom); + } else if (BytesCoder.isValidJSON(atom)) { + return BytesCoder.JSONToDatabase(atom); + } else { + return atom; + } +} + // Helper function to transform an atom from REST format to Mongo format. // An atom is anything that can't contain other expressions. So it // includes things where objects are used to represent other // datatypes, like pointers and dates, but it does not include objects // or arrays with generic stuff inside. -// If options.inArray is true, we'll leave it in REST format. -// If options.inObject is true, we'll leave files in REST format. // Raises an error if this cannot possibly be valid REST format. -// Returns CannotTransform if it's just not an atom, or if force is -// true, throws an error. -function transformAtom(atom, force, { - inArray, - inObject, -} = {}) { +// Returns CannotTransform if it's just not an atom +function transformTopLevelAtom(atom) { switch(typeof atom) { case 'string': case 'number': @@ -467,14 +512,7 @@ function transformAtom(atom, force, { // TODO: check validity harder for the __type-defined types if (atom.__type == 'Pointer') { - if (!inArray && !inObject) { - return `${atom.className}$${atom.objectId}`; - } - return { - __type: 'Pointer', - className: atom.className, - objectId: atom.objectId - }; + return `${atom.className}$${atom.objectId}`; } if (DateCoder.isValidJSON(atom)) { return DateCoder.JSONToDatabase(atom); @@ -483,17 +521,10 @@ function transformAtom(atom, force, { return BytesCoder.JSONToDatabase(atom); } if (GeoPointCoder.isValidJSON(atom)) { - return (inArray || inObject ? atom : GeoPointCoder.JSONToDatabase(atom)); + return GeoPointCoder.JSONToDatabase(atom); } if (FileCoder.isValidJSON(atom)) { - return (inArray || inObject ? atom : FileCoder.JSONToDatabase(atom)); - } - if (inArray || inObject) { - return atom; - } - - if (force) { - throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${atom}`); + return FileCoder.JSONToDatabase(atom); } return CannotTransform; @@ -528,19 +559,24 @@ function transformConstraint(constraint, inArray) { case '$exists': case '$ne': case '$eq': - answer[key] = transformAtom(constraint[key], true, - {inArray: inArray}); + answer[key] = inArray ? transformInteriorAtom(constraint[key]) : transformTopLevelAtom(constraint[key]); + if (answer[key] === CannotTransform) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${atom}`); + } break; case '$in': case '$nin': var arr = constraint[key]; if (!(arr instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad ' + key + ' value'); + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } - answer[key] = arr.map((v) => { - return transformAtom(v, true, { inArray: inArray }); + answer[key] = arr.map(value => { + let result = inArray ? transformInteriorAtom(value) : transformTopLevelAtom(value); + if (result === CannotTransform) { + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad atom: ${atom}`); + } + return result; }); break; @@ -550,9 +586,7 @@ function transformConstraint(constraint, inArray) { throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad ' + key + ' value'); } - answer[key] = arr.map((v) => { - return transformAtom(v, true, { inArray: true }); - }); + answer[key] = arr.map(transformInteriorAtom); break; case '$regex': @@ -635,14 +669,14 @@ function transformConstraint(constraint, inArray) { // The output for a non-flattened operator is a hash with __op being // the mongo op, and arg being the argument. // The output for a flattened operator is just a value. -// Returns CannotTransform if this cannot transform it. // Returns undefined if this should be a no-op. -function transformUpdateOperator(operator, flatten) { - if (typeof operator !== 'object' || !operator.__op) { - return CannotTransform; - } - switch(operator.__op) { +function transformUpdateOperator({ + __op, + amount, + objects, +}, flatten) { + switch(__op) { case 'Delete': if (flatten) { return undefined; @@ -651,43 +685,36 @@ function transformUpdateOperator(operator, flatten) { } case 'Increment': - if (typeof operator.amount !== 'number') { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'incrementing must provide a number'); + if (typeof amount !== 'number') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'incrementing must provide a number'); } if (flatten) { - return operator.amount; + return amount; } else { - return {__op: '$inc', arg: operator.amount}; + return {__op: '$inc', arg: amount}; } case 'Add': case 'AddUnique': - if (!(operator.objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'objects to add must be an array'); + if (!(objects instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to add must be an array'); } - var toAdd = operator.objects.map((obj) => { - return transformAtom(obj, true, { inArray: true }); - }); + var toAdd = objects.map(transformInteriorAtom); if (flatten) { return toAdd; } else { var mongoOp = { Add: '$push', AddUnique: '$addToSet' - }[operator.__op]; + }[__op]; return {__op: mongoOp, arg: {'$each': toAdd}}; } case 'Remove': - if (!(operator.objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'objects to remove must be an array'); + if (!(objects instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'objects to remove must be an array'); } - var toRemove = operator.objects.map((obj) => { - return transformAtom(obj, true, { inArray: true }); - }); + var toRemove = objects.map(transformInteriorAtom); if (flatten) { return []; } else { @@ -695,9 +722,7 @@ function transformUpdateOperator(operator, flatten) { } default: - throw new Parse.Error( - Parse.Error.COMMAND_UNAVAILABLE, - 'the ' + operator.__op + ' op is not supported yet'); + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, `The ${__op} operator is not supported yet.`); } } diff --git a/src/Auth.js b/src/Auth.js index bcee1783fe..634a839b9a 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -2,8 +2,6 @@ var deepcopy = require('deepcopy'); var Parse = require('parse/node').Parse; var RestQuery = require('./RestQuery'); -import cache from './cache'; - // An Auth object tells you who is requesting something and whether // the master key was used. // userObject is a Parse.User and can be null if there's no user. @@ -42,40 +40,41 @@ function nobody(config) { return new Auth({ config, isMaster: false }); } + // Returns a promise that resolves to an Auth object var getAuthForSessionToken = function({ config, sessionToken, installationId } = {}) { - var cachedUser = cache.users.get(sessionToken); - if (cachedUser) { - return Promise.resolve(new Auth({ config, isMaster: false, installationId, user: cachedUser })); - } - var restOptions = { - limit: 1, - include: 'user' - }; - var restWhere = { - _session_token: sessionToken - }; - var query = new RestQuery(config, master(config), '_Session', - restWhere, restOptions); - return query.execute().then((response) => { - var results = response.results; - if (results.length !== 1 || !results[0]['user']) { - return nobody(config); + return config.cacheController.user.get(sessionToken).then((userJSON) => { + if (userJSON) { + let cachedUser = Parse.Object.fromJSON(userJSON); + return Promise.resolve(new Auth({config, isMaster: false, installationId, user: cachedUser})); } - var now = new Date(), + var restOptions = { + limit: 1, + include: 'user' + }; + + var query = new RestQuery(config, master(config), '_Session', {sessionToken}, restOptions); + return query.execute().then((response) => { + var results = response.results; + if (results.length !== 1 || !results[0]['user']) { + return nobody(config); + } + + var now = new Date(), expiresAt = results[0].expiresAt ? new Date(results[0].expiresAt.iso) : undefined; - if(expiresAt < now) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token is expired.'); - } - var obj = results[0]['user']; - delete obj.password; - obj['className'] = '_User'; - obj['sessionToken'] = sessionToken; - let userObject = Parse.Object.fromJSON(obj); - cache.users.set(sessionToken, userObject); - return new Auth({ config, isMaster: false, installationId, user: userObject }); + if (expiresAt < now) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token is expired.'); + } + var obj = results[0]['user']; + delete obj.password; + obj['className'] = '_User'; + obj['sessionToken'] = sessionToken; + config.cacheController.user.put(sessionToken, obj); + let userObject = Parse.Object.fromJSON(obj); + return new Auth({config, isMaster: false, installationId, user: userObject}); + }); }); }; @@ -96,39 +95,50 @@ Auth.prototype.getUserRoles = function() { // Iterates through the role tree and compiles a users roles Auth.prototype._loadRoles = function() { - var restWhere = { - 'users': { - __type: 'Pointer', - className: '_User', - objectId: this.user.id - } - }; - // First get the role ids this user is directly a member of - var query = new RestQuery(this.config, master(this.config), '_Role', - restWhere, {}); - return query.execute().then((response) => { - var results = response.results; - if (!results.length) { - this.userRoles = []; - this.fetchedRoles = true; - this.rolePromise = null; - return Promise.resolve(this.userRoles); + var cacheAdapter = this.config.cacheController; + return cacheAdapter.role.get(this.user.id).then((cachedRoles) => { + if (cachedRoles != null) { + this.fetchedroles = true; + return Promise.resolve(cachedRoles); } - var rolesMap = results.reduce((m, r) => { - m.names.push(r.name); - m.ids.push(r.objectId); - return m; - }, {ids: [], names: []}); - // run the recursive finding - return this._getAllRolesNamesForRoleIds(rolesMap.ids, rolesMap.names) - .then((roleNames) => { - this.userRoles = roleNames.map((r) => { - return 'role:' + r; - }); - this.fetchedRoles = true; - this.rolePromise = null; - return Promise.resolve(this.userRoles); + var restWhere = { + 'users': { + __type: 'Pointer', + className: '_User', + objectId: this.user.id + } + }; + // First get the role ids this user is directly a member of + var query = new RestQuery(this.config, master(this.config), '_Role', restWhere, {}); + return query.execute().then((response) => { + var results = response.results; + if (!results.length) { + this.userRoles = []; + this.fetchedRoles = true; + this.rolePromise = null; + + cacheAdapter.role.put(this.user.id, this.userRoles); + return Promise.resolve(this.userRoles); + } + var rolesMap = results.reduce((m, r) => { + m.names.push(r.name); + m.ids.push(r.objectId); + return m; + }, {ids: [], names: []}); + + // run the recursive finding + return this._getAllRolesNamesForRoleIds(rolesMap.ids, rolesMap.names) + .then((roleNames) => { + this.userRoles = roleNames.map((r) => { + return 'role:' + r; + }); + this.fetchedRoles = true; + this.rolePromise = null; + + cacheAdapter.role.put(this.user.id, this.userRoles); + return Promise.resolve(this.userRoles); + }); }); }); }; diff --git a/src/Config.js b/src/Config.js index 5c3ca4e08e..faaa6c2235 100644 --- a/src/Config.js +++ b/src/Config.js @@ -2,7 +2,7 @@ // configured. // mount is the URL for the root of the API; includes http, domain, etc. -import cache from './cache'; +import AppCache from './cache'; function removeTrailingSlash(str) { if (!str) { @@ -17,7 +17,7 @@ function removeTrailingSlash(str) { export class Config { constructor(applicationId: string, mount: string) { let DatabaseAdapter = require('./DatabaseAdapter'); - let cacheInfo = cache.apps.get(applicationId); + let cacheInfo = AppCache.get(applicationId); if (!cacheInfo) { return; } @@ -38,6 +38,7 @@ export class Config { this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; + this.cacheController = cacheInfo.cacheController; this.hooksController = cacheInfo.hooksController; this.filesController = cacheInfo.filesController; this.pushController = cacheInfo.pushController; @@ -48,6 +49,7 @@ export class Config { this.mount = removeTrailingSlash(mount); this.liveQueryController = cacheInfo.liveQueryController; this.sessionLength = cacheInfo.sessionLength; + this.expireInactiveSessions = cacheInfo.expireInactiveSessions; this.generateSessionExpiresAt = this.generateSessionExpiresAt.bind(this); this.revokeSessionOnPasswordReset = cacheInfo.revokeSessionOnPasswordReset; } @@ -69,7 +71,7 @@ export class Config { } } - this.validateSessionLength(options.sessionLength); + this.validateSessionConfiguration(options.sessionLength, options.expireInactiveSessions); } static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) { @@ -95,16 +97,21 @@ export class Config { this._mount = newValue; } - static validateSessionLength(sessionLength) { - if(isNaN(sessionLength)) { - throw 'Session length must be a valid number.'; - } - else if(sessionLength <= 0) { - throw 'Session length must be a value greater than 0.' + static validateSessionConfiguration(sessionLength, expireInactiveSessions) { + if (expireInactiveSessions) { + if (isNaN(sessionLength)) { + throw 'Session length must be a valid number.'; + } + else if (sessionLength <= 0) { + throw 'Session length must be a value greater than 0.' + } } } generateSessionExpiresAt() { + if (!this.expireInactiveSessions) { + return undefined; + } var now = new Date(); return new Date(now.getTime() + (this.sessionLength*1000)); } @@ -132,7 +139,7 @@ export class Config { get verifyEmailURL() { return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`; } -}; +} export default Config; module.exports = Config; diff --git a/src/Controllers/CacheController.js b/src/Controllers/CacheController.js new file mode 100644 index 0000000000..27dc4936f7 --- /dev/null +++ b/src/Controllers/CacheController.js @@ -0,0 +1,75 @@ +import AdaptableController from './AdaptableController'; +import CacheAdapter from '../Adapters/Cache/CacheAdapter'; + +const KEY_SEPARATOR_CHAR = ':'; + +function joinKeys(...keys) { + return keys.join(KEY_SEPARATOR_CHAR); +} + +/** + * Prefix all calls to the cache via a prefix string, useful when grouping Cache by object type. + * + * eg "Role" or "Session" + */ +export class SubCache { + constructor(prefix, cacheController) { + this.prefix = prefix; + this.cache = cacheController; + } + + get(key) { + let cacheKey = joinKeys(this.prefix, key); + return this.cache.get(cacheKey); + } + + put(key, value, ttl) { + let cacheKey = joinKeys(this.prefix, key); + return this.cache.put(cacheKey, value, ttl); + } + + del(key) { + let cacheKey = joinKeys(this.prefix, key); + return this.cache.del(cacheKey); + } + + clear() { + return this.cache.clear(); + } +} + + +export class CacheController extends AdaptableController { + + constructor(adapter, appId, options = {}) { + super(adapter, appId, options); + + this.role = new SubCache('role', this); + this.user = new SubCache('user', this); + } + + get(key) { + let cacheKey = joinKeys(this.appId, key); + return this.adapter.get(cacheKey).then(null, () => Promise.resolve(null)); + } + + put(key, value, ttl) { + let cacheKey = joinKeys(this.appId, key); + return this.adapter.put(cacheKey, value, ttl); + } + + del(key) { + let cacheKey = joinKeys(this.appId, key); + return this.adapter.del(cacheKey); + } + + clear() { + return this.adapter.clear(); + } + + expectedAdapterType() { + return CacheAdapter; + } +} + +export default CacheController; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index bda0b06601..fefd5afe6b 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -158,20 +158,15 @@ DatabaseController.prototype.update = function(className, query, update, { var isMaster = acl === undefined; var aclGroup = acl || []; - var mongoUpdate, schema; + var mongoUpdate; return this.loadSchema() - .then(s => { - schema = s; - if (!isMaster) { - return schema.validatePermission(className, aclGroup, 'update'); - } - return Promise.resolve(); - }) + .then(schemaController => { + return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, 'update')) .then(() => this.handleRelationUpdates(className, query.objectId, update)) .then(() => this.adapter.adaptiveCollection(className)) .then(collection => { if (!isMaster) { - query = this.addPointerPermissions(schema, className, 'update', query, aclGroup); + query = this.addPointerPermissions(schemaController, className, 'update', query, aclGroup); } if (!query) { return Promise.resolve(); @@ -179,26 +174,42 @@ DatabaseController.prototype.update = function(className, query, update, { if (acl) { query = addWriteACL(query, acl); } - var mongoWhere = this.transform.transformWhere(schema, className, query, {validate: !this.skipValidation}); - mongoUpdate = this.transform.transformUpdate(schema, className, update, {validate: !this.skipValidation}); - if (many) { - return collection.updateMany(mongoWhere, mongoUpdate); - } else if (upsert) { - return collection.upsertOne(mongoWhere, mongoUpdate); - } else { - return collection.findOneAndUpdate(mongoWhere, mongoUpdate); - } + return schemaController.getOneSchema(className) + .catch(error => { + // If the schema doesn't exist, pretend it exists with no fields. This behaviour + // will likely need revisiting. + if (error === undefined) { + return { fields: {} }; + } + throw error; + }) + .then(parseFormatSchema => { + var mongoWhere = this.transform.transformWhere(className, query, {validate: !this.skipValidation}, parseFormatSchema); + mongoUpdate = this.transform.transformUpdate( + schemaController, + className, + update, + {validate: !this.skipValidation} + ); + if (many) { + return collection.updateMany(mongoWhere, mongoUpdate); + } else if (upsert) { + return collection.upsertOne(mongoWhere, mongoUpdate); + } else { + return collection.findOneAndUpdate(mongoWhere, mongoUpdate); + } + }); }) .then(result => { if (!result) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); + return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.')); } if (this.skipValidation) { return Promise.resolve(result); } return sanitizeDatabaseResult(originalUpdate, result); }); + }); }; function sanitizeDatabaseResult(originalObject, result) { @@ -317,7 +328,16 @@ DatabaseController.prototype.destroy = function(className, query, { acl } = {}) if (acl) { query = addWriteACL(query, acl); } - return this.adapter.deleteObjectsByQuery(className, query, schemaController, !this.skipValidation) + return schemaController.getOneSchema(className) + .catch(error => { + // If the schema doesn't exist, pretend it exists with no fields. This behaviour + // will likely need revisiting. + if (error === undefined) { + return { fields: {} }; + } + throw error; + }) + .then(parseFormatSchema => this.adapter.deleteObjectsByQuery(className, query, !this.skipValidation, parseFormatSchema)) .catch(error => { // When deleting sessions while changing passwords, don't throw an error if they don't have any sessions. if (className === "_Session" && error.code === Parse.Error.OBJECT_NOT_FOUND) { @@ -593,56 +613,75 @@ DatabaseController.prototype.find = function(className, query, { } let isMaster = acl === undefined; let aclGroup = acl || []; - let schema = null; - let op = typeof query.objectId == 'string' && Object.keys(query).length === 1 ? - 'get' : - 'find'; - return this.loadSchema().then(s => { - schema = s; - if (sort) { - mongoOptions.sort = {}; - for (let key in sort) { - let mongoKey = this.transform.transformKey(schema, className, key); - mongoOptions.sort[mongoKey] = sort[key]; + let op = typeof query.objectId == 'string' && Object.keys(query).length === 1 ? 'get' : 'find'; + return this.loadSchema() + .then(schemaController => { + return schemaController.getOneSchema(className) + .catch(error => { + // If the schema doesn't exist, pretend it exists with no fields. This behaviour + // will likely need revisiting. + if (error === undefined) { + return { fields: {} }; } - } + throw error; + }) + .then(schema => { + if (sort) { + mongoOptions.sort = {}; + for (let fieldName in sort) { + // Parse.com treats queries on _created_at and _updated_at as if they were queries on createdAt and updatedAt, + // so duplicate that behaviour here. + if (fieldName === '_created_at') { + fieldName = 'createdAt'; + sort['createdAt'] = sort['_created_at']; + } else if (fieldName === '_updated_at') { + fieldName = 'updatedAt'; + sort['updatedAt'] = sort['_updated_at']; + } - if (!isMaster) { - return schema.validatePermission(className, aclGroup, op); - } - return Promise.resolve(); - }) - .then(() => this.reduceRelationKeys(className, query)) - .then(() => this.reduceInRelation(className, query, schema)) - .then(() => this.adapter.adaptiveCollection(className)) - .then(collection => { - if (!isMaster) { - query = this.addPointerPermissions(schema, className, op, query, aclGroup); - } - if (!query) { - if (op == 'get') { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); - } else { - return Promise.resolve([]); + if (!SchemaController.fieldNameIsValid(fieldName)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); + } + if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Cannot sort by ${fieldName}`); + } + const mongoKey = this.transform.transformKey(className, fieldName, schema); + mongoOptions.sort[mongoKey] = sort[fieldName]; + } } - } - if (!isMaster) { - query = addReadACL(query, aclGroup); - } - let mongoWhere = this.transform.transformWhere(schema, className, query); - if (count) { - delete mongoOptions.limit; - return collection.count(mongoWhere, mongoOptions); - } else { - return collection.find(mongoWhere, mongoOptions) - .then((mongoResults) => { - return mongoResults.map((r) => { - return this.untransformObject( - schema, isMaster, aclGroup, className, r); + return (isMaster ? Promise.resolve() : schemaController.validatePermission(className, aclGroup, op)) + .then(() => this.reduceRelationKeys(className, query)) + .then(() => this.reduceInRelation(className, query, schemaController)) + .then(() => this.adapter.adaptiveCollection(className)) + .then(collection => { + if (!isMaster) { + query = this.addPointerPermissions(schemaController, className, op, query, aclGroup); + } + if (!query) { + if (op == 'get') { + return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.')); + } else { + return Promise.resolve([]); + } + } + if (!isMaster) { + query = addReadACL(query, aclGroup); + } + let mongoWhere = this.transform.transformWhere(className, query, {}, schema); + if (count) { + delete mongoOptions.limit; + return collection.count(mongoWhere, mongoOptions); + } else { + return collection.find(mongoWhere, mongoOptions) + .then(mongoResults => { + return mongoResults.map(result => { + return this.untransformObject(schemaController, isMaster, aclGroup, className, result); + }); }); - }); - } + } + }); + }); }); }; diff --git a/src/Controllers/SchemaController.js b/src/Controllers/SchemaController.js index af56610b99..b3fdc7bb77 100644 --- a/src/Controllers/SchemaController.js +++ b/src/Controllers/SchemaController.js @@ -33,7 +33,7 @@ const defaultColumns = Object.freeze({ "email": {type:'String'}, "emailVerified": {type:'Boolean'}, }, - // The additional default columns for the _User collection (in addition to DefaultCols) + // The additional default columns for the _Installation collection (in addition to DefaultCols) _Installation: { "installationId": {type:'String'}, "deviceToken": {type:'String'}, @@ -43,15 +43,19 @@ const defaultColumns = Object.freeze({ "GCMSenderId": {type:'String'}, "timeZone": {type:'String'}, "localeIdentifier": {type:'String'}, - "badge": {type:'Number'} + "badge": {type:'Number'}, + "appVersion": {type:'String'}, + "appName": {type:'String'}, + "appIdentifier": {type:'String'}, + "parseVersion": {type:'String'}, }, - // The additional default columns for the _User collection (in addition to DefaultCols) + // The additional default columns for the _Role collection (in addition to DefaultCols) _Role: { "name": {type:'String'}, "users": {type:'Relation', targetClass:'_User'}, "roles": {type:'Relation', targetClass:'_Role'} }, - // The additional default columns for the _User collection (in addition to DefaultCols) + // The additional default columns for the _Session collection (in addition to DefaultCols) _Session: { "restricted": {type:'Boolean'}, "user": {type:'Pointer', targetClass:'_User'}, @@ -253,7 +257,7 @@ class SchemaController { this.data[schema.className] = schema.fields; this.perms[schema.className] = schema.classLevelPermissions; }); - + // Inject the in-memory classes volatileClasses.forEach(className => { this.data[className] = injectDefaultSchema({ @@ -466,15 +470,16 @@ class SchemaController { // If 'freeze' is true, refuse to update the schema for this field. validateField(className, fieldName, type, freeze) { return this.reloadData().then(() => { - // Just to check that the fieldName is valid - this._collection.transform.transformKey(this, className, fieldName); - - if( fieldName.indexOf(".") > 0 ) { + if (fieldName.indexOf(".") > 0) { // subdocument key (x.y) => ok if x is of type 'object' fieldName = fieldName.split(".")[ 0 ]; type = 'Object'; } + if (!fieldNameIsValid(fieldName)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); + } + let expected = this.data[className][fieldName]; if (expected) { expected = (expected === 'map' ? 'Object' : expected); @@ -847,6 +852,7 @@ function getObjectType(obj) { export { load, classNameIsValid, + fieldNameIsValid, invalidClassNameMessage, buildMergedSchemaObject, systemClasses, diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js index 73bc09334f..25ce69b422 100644 --- a/src/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -60,7 +60,7 @@ function getDatabaseConnection(appId: string, collectionPrefix: string) { uri: appDatabaseURIs[appId], //may be undefined if the user didn't supply a URI, in which case the default will be used } - dbConnections[appId] = new DatabaseController(new MongoStorageAdapter(mongoAdapterOptions)); + dbConnections[appId] = new DatabaseController(new MongoStorageAdapter(mongoAdapterOptions), {appId: appId}); return dbConnections[appId]; } diff --git a/src/ParseServer.js b/src/ParseServer.js index ad8efd19da..85b2b40c02 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -15,8 +15,8 @@ if (!global._babelPolyfill) { } import { logger, - configureLogger } from './logger'; -import cache from './cache'; + configureLogger } from './logger'; +import AppCache from './cache'; import Config from './Config'; import parseServerPackage from '../package.json'; import PromiseRouter from './PromiseRouter'; @@ -24,6 +24,8 @@ import requiredParameter from './requiredParameter'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FeaturesRouter } from './Routers/FeaturesRouter'; +import { InMemoryCacheAdapter } from './Adapters/Cache/InMemoryCacheAdapter'; +import { CacheController } from './Controllers/CacheController'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { FilesController } from './Controllers/FilesController'; import { FilesRouter } from './Routers/FilesRouter'; @@ -104,6 +106,7 @@ class ParseServer { serverURL = requiredParameter('You must provide a serverURL!'), maxUploadSize = '20mb', verifyUserEmails = false, + cacheAdapter, emailAdapter, publicServerURL, customPages = { @@ -114,6 +117,7 @@ class ParseServer { }, liveQuery = {}, sessionLength = 31536000, // 1 Year in seconds + expireInactiveSessions = true, verbose = false, revokeSessionOnPasswordReset = true, }) { @@ -155,6 +159,8 @@ class ParseServer { const pushControllerAdapter = loadAdapter(push && push.adapter, ParsePushAdapter, push); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); const emailControllerAdapter = loadAdapter(emailAdapter); + const cacheControllerAdapter = loadAdapter(cacheAdapter, InMemoryCacheAdapter, {appId: appId}); + // We pass the options and the base class for the adatper, // Note that passing an instance would work too const filesController = new FilesController(filesControllerAdapter, appId); @@ -163,8 +169,9 @@ class ParseServer { const hooksController = new HooksController(appId, collectionPrefix); const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); const liveQueryController = new LiveQueryController(liveQuery); + const cacheController = new CacheController(cacheControllerAdapter, appId); - cache.apps.set(appId, { + AppCache.put(appId, { masterKey: masterKey, serverURL: serverURL, collectionPrefix: collectionPrefix, @@ -174,6 +181,7 @@ class ParseServer { restAPIKey: restAPIKey, fileKey: fileKey, facebookAppIds: facebookAppIds, + cacheController: cacheController, filesController: filesController, pushController: pushController, loggerController: loggerController, @@ -188,16 +196,17 @@ class ParseServer { maxUploadSize: maxUploadSize, liveQueryController: liveQueryController, sessionLength: Number(sessionLength), + expireInactiveSessions: expireInactiveSessions, revokeSessionOnPasswordReset }); // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { - cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); + AppCache.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } - Config.validate(cache.apps.get(appId)); - this.config = cache.apps.get(appId); + Config.validate(AppCache.get(appId)); + this.config = AppCache.get(appId); hooksController.load(); } diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index 099b2474b4..b159ef0768 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -6,6 +6,7 @@ // components that external developers may be modifying. import express from 'express'; +import url from 'url'; import log from './logger'; export default class PromiseRouter { @@ -154,8 +155,8 @@ export default class PromiseRouter { function makeExpressHandler(promiseHandler) { return function(req, res, next) { try { - log.verbose(req.method, req.originalUrl, req.headers, - JSON.stringify(req.body, null, 2)); + log.verbose(req.method, maskSensitiveUrl(req), req.headers, + JSON.stringify(maskSensitiveBody(req), null, 2)); promiseHandler(req).then((result) => { if (!result.response && !result.location && !result.text) { log.error('the handler did not include a "response" or a "location" field'); @@ -194,3 +195,34 @@ function makeExpressHandler(promiseHandler) { } } } + +function maskSensitiveBody(req) { + let maskBody = Object.assign({}, req.body); + let shouldMaskBody = (req.method === 'POST' && req.originalUrl.endsWith('/users') + && !req.originalUrl.includes('classes')) || + (req.method === 'PUT' && /users\/\w+$/.test(req.originalUrl) + && !req.originalUrl.includes('classes')) || + (req.originalUrl.includes('classes/_User')); + if (shouldMaskBody) { + for (let key of Object.keys(maskBody)) { + if (key == 'password') { + maskBody[key] = '********'; + break; + } + } + } + return maskBody; +} + +function maskSensitiveUrl(req) { + let maskUrl = req.originalUrl.toString(); + let shouldMaskUrl = req.method === 'GET' && req.originalUrl.includes('/login') + && !req.originalUrl.includes('classes'); + if (shouldMaskUrl) { + let password = url.parse(req.originalUrl, true).query.password; + if (password) { + maskUrl = maskUrl.replace('password=' + password, 'password=********') + } + } + return maskUrl; +} diff --git a/src/RestWrite.js b/src/RestWrite.js index 03138e44f4..be460d4c48 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -2,7 +2,6 @@ // that writes to the database. // This could be either a "create" or an "update". -import cache from './cache'; var SchemaController = require('./Controllers/SchemaController'); var deepcopy = require('deepcopy'); @@ -12,6 +11,7 @@ var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var Parse = require('parse/node'); var triggers = require('./triggers'); +import RestQuery from './RestQuery'; // query and data are both provided in REST API format. So data // types are encoded by plain old objects. @@ -310,6 +310,7 @@ RestWrite.prototype.handleAuthData = function(authData) { }); } + // The non-third-party parts of User transformation RestWrite.prototype.transformUser = function() { if (this.className !== '_User') { @@ -318,9 +319,17 @@ RestWrite.prototype.transformUser = function() { var promise = Promise.resolve(); - // If we're updating a _User object, clear the user cache for the session - if (this.query && this.auth.user && this.auth.user.getSessionToken()) { - cache.users.remove(this.auth.user.getSessionToken()); + if (this.query) { + // If we're updating a _User object, we need to clear out the cache for that user. Find all their + // session tokens, and remove them from the cache. + promise = new RestQuery(this.config, Auth.master(this.config), '_Session', { user: { + __type: "Pointer", + className: "_User", + objectId: this.objectId(), + }}).execute() + .then(results => { + results.results.forEach(session => this.config.cacheController.user.del(session.sessionToken)); + }); } return promise.then(() => { @@ -356,7 +365,7 @@ RestWrite.prototype.transformUser = function() { return Promise.resolve(); }); }).then(() => { - if (!this.data.email) { + if (!this.data.email || this.data.email.__op === 'Delete') { return; } // Validate basic email address format @@ -413,8 +422,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = function() { if (this.response && this.response.response) { this.response.response.sessionToken = token; } - var create = new RestWrite(this.config, Auth.master(this.config), - '_Session', null, sessionData); + var create = new RestWrite(this.config, Auth.master(this.config), '_Session', null, sessionData); return create.execute(); } @@ -441,24 +449,6 @@ RestWrite.prototype.handleFollowup = function() { } }; -// Handles the _Role class specialness. -// Does nothing if this isn't a role object. -RestWrite.prototype.handleRole = function() { - if (this.response || this.className !== '_Role') { - return; - } - - if (!this.auth.user && !this.auth.isMaster) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); - } - - if (!this.data.name) { - throw new Parse.Error(Parse.Error.INVALID_ROLE_NAME, - 'Invalid role name.'); - } -}; - // Handles the _Session class specialness. // Does nothing if this isn't an installation object. RestWrite.prototype.handleSession = function() { @@ -499,8 +489,7 @@ RestWrite.prototype.handleSession = function() { } sessionData[key] = this.data[key]; } - var create = new RestWrite(this.config, Auth.master(this.config), - '_Session', null, sessionData); + var create = new RestWrite(this.config, Auth.master(this.config), '_Session', null, sessionData); return create.execute().then((results) => { if (!results.response) { throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, @@ -716,6 +705,10 @@ RestWrite.prototype.runDatabaseOperation = function() { return; } + if (this.className === '_Role') { + this.config.cacheController.role.clear(); + } + if (this.className === '_User' && this.query && !this.auth.couldUpdateUserId(this.query.objectId)) { diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index cda604d41d..9c3ad7619b 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -39,6 +39,7 @@ export class FeaturesRouter extends PromiseRouter { clearAllDataFromClass: false, exportClass: false, editClassLevelPermissions: true, + editPointerPermissions: true, }, }; diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index b405b90eb5..ea614b4946 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -33,6 +33,14 @@ export class FunctionsRouter extends PromiseRouter { var theValidator = triggers.getValidator(req.params.functionName, applicationId); if (theFunction) { const params = Object.assign({}, req.body, req.query); + for (var key in params) { + if (params.hasOwnProperty(key)) { + var value = params[key]; + if (value && value.__type == 'Date') { + params[key] = new Date(value.iso); + } + } + } var request = { params: params, master: req.auth && req.auth.isMaster, diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js index c6a82dbbf4..ab49852273 100644 --- a/src/Routers/GlobalConfigRouter.js +++ b/src/Routers/GlobalConfigRouter.js @@ -1,12 +1,12 @@ // global_config.js -import PromiseRouter from '../PromiseRouter'; +import PromiseRouter from '../PromiseRouter'; import * as middleware from "../middlewares"; export class GlobalConfigRouter extends PromiseRouter { getGlobalConfig(req) { let database = req.config.database.WithoutValidation(); - return database.find('_GlobalConfig', { '_id': 1 }, { limit: 1 }).then((results) => { + return database.find('_GlobalConfig', { objectId: 1 }, { limit: 1 }).then((results) => { if (results.length != 1) { // If there is no config in the database - return empty config. return { response: { params: {} } }; diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 9c21f005bf..4a9efc3f64 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -6,25 +6,27 @@ import rest from '../rest'; export class InstallationsRouter extends ClassesRouter { handleFind(req) { + let body = Object.assign(req.body, ClassesRouter.JSONFromQuery(req.query)); var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); + + if (body.skip) { + options.skip = Number(body.skip); } - if (req.body.limit) { - options.limit = Number(req.body.limit); + if (body.limit || body.limit === 0) { + options.limit = Number(body.limit); } - if (req.body.order) { - options.order = String(req.body.order); + if (body.order) { + options.order = String(body.order); } - if (req.body.count) { + if (body.count) { options.count = true; } - if (req.body.include) { - options.include = String(req.body.include); + if (body.include) { + options.include = String(body.include); } return rest.find(req.config, req.auth, - '_Installation', req.body.where, options) + '_Installation', body.where, options) .then((response) => { return {response: response}; }); diff --git a/src/Routers/SessionsRouter.js b/src/Routers/SessionsRouter.js index 1a8d8cbfb9..1bae334464 100644 --- a/src/Routers/SessionsRouter.js +++ b/src/Routers/SessionsRouter.js @@ -1,8 +1,8 @@ import ClassesRouter from './ClassesRouter'; import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import Auth from '../Auth'; +import rest from '../rest'; +import Auth from '../Auth'; export class SessionsRouter extends ClassesRouter { handleFind(req) { @@ -36,7 +36,7 @@ export class SessionsRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token required.'); } - return rest.find(req.config, Auth.master(req.config), '_Session', { _session_token: req.info.sessionToken }) + return rest.find(req.config, Auth.master(req.config), '_Session', { sessionToken: req.info.sessionToken }) .then((response) => { if (!response.results || response.results.length == 0) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 4d4cb5034c..a5e6299c58 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -46,7 +46,7 @@ export class UsersRouter extends ClassesRouter { } let sessionToken = req.info.sessionToken; return rest.find(req.config, Auth.master(req.config), '_Session', - { _session_token: sessionToken }, + { sessionToken }, { include: 'user' }) .then((response) => { if (!response.results || @@ -85,6 +85,7 @@ export class UsersRouter extends ClassesRouter { user = results[0]; return passwordCrypto.compare(req.body.password, user.password); }).then((correct) => { + if (!correct) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } @@ -139,7 +140,7 @@ export class UsersRouter extends ClassesRouter { let success = {response: {}}; if (req.info && req.info.sessionToken) { return rest.find(req.config, Auth.master(req.config), '_Session', - { _session_token: req.info.sessionToken } + { sessionToken: req.info.sessionToken } ).then((records) => { if (records.results && records.results.length) { return rest.del(req.config, Auth.master(req.config), '_Session', diff --git a/src/cache.js b/src/cache.js index 8893f29b1b..96b00b4534 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,35 +1,4 @@ -/** @flow weak */ +import {InMemoryCache} from './Adapters/Cache/InMemoryCache'; -export function CacheStore() { - let dataStore: {[id:KeyType]:ValueType} = {}; - return { - get: (key: KeyType): ValueType => { - return dataStore[key]; - }, - set(key: KeyType, value: ValueType): void { - dataStore[key] = value; - }, - remove(key: KeyType): void { - delete dataStore[key]; - }, - clear(): void { - dataStore = {}; - } - }; -} - -const apps = CacheStore(); -const users = CacheStore(); - -//So far used only in tests -export function clearCache(): void { - apps.clear(); - users.clear(); -} - -export default { - apps, - users, - clearCache, - CacheStore -}; +export var AppCache = new InMemoryCache({ttl: NaN}); +export default AppCache; diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index e1b9ec3ab2..ca323f0ac5 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -1,4 +1,4 @@ -import { Parse } from 'parse/node'; +import { Parse } from 'parse/node'; import * as triggers from '../triggers'; function validateClassNameForTriggers(className) { @@ -40,12 +40,16 @@ ParseCloud.afterDelete = function(parseClass, handler) { var className = getClassName(parseClass); triggers.addTrigger(triggers.Types.afterDelete, className, handler, Parse.applicationId); }; - + ParseCloud._removeHook = function(category, name, type, applicationId) { applicationId = applicationId || Parse.applicationId; triggers._unregister(applicationId, category, name, type); }; +ParseCloud._removeAllHooks = () => { + triggers._unregisterAll(); +} + ParseCloud.httpRequest = require("./httpRequest"); module.exports = ParseCloud; diff --git a/src/logger.js b/src/logger.js index e0556bf7d9..d5b81e9ecf 100644 --- a/src/logger.js +++ b/src/logger.js @@ -9,6 +9,8 @@ if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { LOGS_FOLDER = './test_logs/' } +LOGS_FOLDER = process.env.PARSE_SERVER_LOGS_FOLDER || LOGS_FOLDER; + let currentLogsFolder = LOGS_FOLDER; function generateTransports(level) { diff --git a/src/middlewares.js b/src/middlewares.js index 10115d6853..e46eb62557 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -1,5 +1,5 @@ -import cache from './cache'; -import log from './logger'; +import AppCache from './cache'; +import log from './logger'; var Parse = require('parse/node').Parse; @@ -28,6 +28,14 @@ function handleParseHeaders(req, res, next) { restAPIKey: req.get('X-Parse-REST-API-Key') }; + var basicAuth = httpAuth(req); + + if (basicAuth) { + info.appId = basicAuth.appId + info.masterKey = basicAuth.masterKey || info.masterKey; + info.javascriptKey = basicAuth.javascriptKey || info.javascriptKey; + } + if (req.body) { // Unity SDK sends a _noBody key which needs to be removed. // Unclear at this point if action needs to be taken. @@ -36,7 +44,7 @@ function handleParseHeaders(req, res, next) { var fileViaJSON = false; - if (!info.appId || !cache.apps.get(info.appId)) { + if (!info.appId || !AppCache.get(info.appId)) { // See if we can find the app id on the body. if (req.body instanceof Buffer) { // The only chance to find the app id is if this is a file @@ -51,8 +59,8 @@ function handleParseHeaders(req, res, next) { if (req.body && req.body._ApplicationId && - cache.apps.get(req.body._ApplicationId) && - (!info.masterKey || cache.apps.get(req.body._ApplicationId).masterKey === info.masterKey) + AppCache.get(req.body._ApplicationId) && + (!info.masterKey || AppCache.get(req.body._ApplicationId).masterKey === info.masterKey) ) { info.appId = req.body._ApplicationId; info.javascriptKey = req.body._JavaScriptKey || ''; @@ -87,7 +95,7 @@ function handleParseHeaders(req, res, next) { req.body = new Buffer(base64, 'base64'); } - info.app = cache.apps.get(info.appId); + info.app = AppCache.get(info.appId); req.config = new Config(info.appId, mount); req.info = info; @@ -144,6 +152,45 @@ function handleParseHeaders(req, res, next) { }); } +function httpAuth(req) { + if (!(req.req || req).headers.authorization) + return ; + + var header = (req.req || req).headers.authorization; + var appId, masterKey, javascriptKey; + + // parse header + var authPrefix = 'basic '; + + var match = header.toLowerCase().indexOf(authPrefix); + + if (match == 0) { + var encodedAuth = header.substring(authPrefix.length, header.length); + var credentials = decodeBase64(encodedAuth).split(':'); + + if (credentials.length == 2) { + appId = credentials[0]; + var key = credentials[1]; + + var jsKeyPrefix = 'javascript-key='; + + var matchKey = key.indexOf(jsKeyPrefix) + if (matchKey == 0) { + javascriptKey = key.substring(jsKeyPrefix.length, key.length); + } + else { + masterKey = key; + } + } + } + + return {appId: appId, masterKey: masterKey, javascriptKey: javascriptKey}; +} + +function decodeBase64(str) { + return new Buffer(str, 'base64').toString() +} + var allowCrossDomain = function(req, res, next) { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); diff --git a/src/pushStatusHandler.js b/src/pushStatusHandler.js index dfe887f3ea..934e8fa27f 100644 --- a/src/pushStatusHandler.js +++ b/src/pushStatusHandler.js @@ -96,7 +96,7 @@ export default function pushStatusHandler(config) { status: 'failed', updatedAt: new Date() } - logger.error('error while sending push', err); + logger.info('warning: error while sending push', err); return database.update(PUSH_STATUS_COLLECTION, { objectId }, update); } diff --git a/src/rest.js b/src/rest.js index 60f017213e..d1894165a6 100644 --- a/src/rest.js +++ b/src/rest.js @@ -8,8 +8,7 @@ // things. var Parse = require('parse/node').Parse; -import cache from './cache'; -import Auth from './Auth'; +import Auth from './Auth'; var RestQuery = require('./RestQuery'); var RestWrite = require('./RestWrite'); @@ -48,7 +47,9 @@ function del(config, auth, className, objectId) { .then((response) => { if (response && response.results && response.results.length) { response.results[0].className = className; - cache.users.remove(response.results[0].sessionToken); + + var cacheAdapter = config.cacheController; + cacheAdapter.user.del(response.results[0].sessionToken); inflatedObject = Parse.Object.fromJSON(response.results[0]); // Notify LiveQuery server if possible config.liveQueryController.onAfterDelete(inflatedObject.className, inflatedObject); @@ -96,6 +97,7 @@ function create(config, auth, className, restObject) { // Usually, this is just updatedAt. function update(config, auth, className, objectId, restObject) { enforceRoleSecurity('update', className, auth); + return Promise.resolve().then(() => { if (triggers.getTrigger(className, triggers.Types.beforeSave, config.applicationId) || triggers.getTrigger(className, triggers.Types.afterSave, config.applicationId) || diff --git a/src/testing-routes.js b/src/testing-routes.js index 04b5cf83ed..eee022d9f9 100644 --- a/src/testing-routes.js +++ b/src/testing-routes.js @@ -1,5 +1,5 @@ // testing-routes.js -import cache from './cache'; +import AppCache from './cache'; import * as middlewares from './middlewares'; import { ParseServer } from './index'; import { Parse } from 'parse/node'; @@ -47,7 +47,7 @@ function dropApp(req, res) { return res.status(401).send({ "error": "unauthorized" }); } return req.config.database.deleteEverything().then(() => { - cache.apps.remove(req.config.applicationId); + AppCache.del(req.config.applicationId); res.status(200).send({}); }); } diff --git a/src/triggers.js b/src/triggers.js index 7ab1ea1902..0827de7a13 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -1,6 +1,6 @@ // triggers.js -import Parse from 'parse/node'; -import cache from './cache'; +import Parse from 'parse/node'; +import AppCache from './cache'; export const Types = { beforeSave: 'beforeSave', @@ -49,15 +49,19 @@ export function removeTrigger(type, className, applicationId) { delete _triggerStore[applicationId].Triggers[type][className] } -export function _unregister(a,b,c,d) { - if (d) { - removeTrigger(c,d,a); - delete _triggerStore[a][b][c][d]; +export function _unregister(appId,category,className,type) { + if (type) { + removeTrigger(className,type,appId); + delete _triggerStore[appId][category][className][type]; } else { - delete _triggerStore[a][b][c]; + delete _triggerStore[appId][category][className]; } } +export function _unregisterAll() { + Object.keys(_triggerStore).forEach(appId => delete _triggerStore[appId]); +} + export function getTrigger(className, triggerType, applicationId) { if (!applicationId) { throw "Missing ApplicationID";