From fdc85cc5c2ff20e5fd249f12866cd21c69490b7d Mon Sep 17 00:00:00 2001 From: Eric Watson Date: Thu, 4 Feb 2016 11:39:11 -0600 Subject: [PATCH 01/84] Ignore '_metadata', convert 'map' to Object Legacy Parse platform databases have additional fields that database-to-api-response conversion. This commit accounts for - the '_metadata' field, which doesn't appear in the api-response version of Schema, and whose value (an object), crashes the conversion function (which expects only string values) - the 'map' type, which appears in legacy database representations to describe Objects --- schemas.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/schemas.js b/schemas.js index 88b0da38..9b74f270 100644 --- a/schemas.js +++ b/schemas.js @@ -23,6 +23,7 @@ function mongoFieldTypeToApiResponseType(type) { case 'string': return {type: 'String'}; case 'boolean': return {type: 'Boolean'}; case 'date': return {type: 'Date'}; + case 'map': case 'object': return {type: 'Object'}; case 'array': return {type: 'Array'}; case 'geopoint': return {type: 'GeoPoint'}; @@ -31,7 +32,7 @@ function mongoFieldTypeToApiResponseType(type) { } function mongoSchemaAPIResponseFields(schema) { - fieldNames = Object.keys(schema).filter(key => key !== '_id'); + fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata'); response = {}; fieldNames.forEach(fieldName => { response[fieldName] = mongoFieldTypeToApiResponseType(schema[fieldName]); From 53dfca2d6f899086bc988202afc00a8e494580fb Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 14:56:11 -0800 Subject: [PATCH 02/84] First part of schemas POST --- schemas.js | 50 +++++++++++++- spec/schemas.spec.js | 161 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 196 insertions(+), 15 deletions(-) diff --git a/schemas.js b/schemas.js index 875967cd..e374a127 100644 --- a/schemas.js +++ b/schemas.js @@ -1,7 +1,9 @@ // schemas.js var express = require('express'), - PromiseRouter = require('./PromiseRouter'); + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + Schema = require('./Schema'); var router = new PromiseRouter(); @@ -54,7 +56,7 @@ function getAllSchemas(req) { if (!req.auth.isMaster) { return Promise.resolve({ status: 401, - response: {error: 'unauthorized'}, + response: {error: 'master key not specified'}, }); } return req.config.database.collection('_SCHEMA') @@ -83,7 +85,51 @@ function getOneSchema(req) { })); } +function createSchema(req) { + if (!req.auth.isMaster) { + return Promise.resolve({ + status: 401, + response: {error: 'master key not specified'}, + }); + } + if (req.params.className && req.body.className) { + if (req.params.className != req.body.className) { + return Promise.resolve({ + status: 400, + response: { + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class name mismatch between ' + req.body.className + ' and ' + req.params.className, + }, + }); + } + } + var className = req.params.className || req.body.className; + if (!className) { + return Promise.resolve({ + status: 400, + response: { + code: 135, + error: 'POST ' + req.path + ' needs class name', + }, + }); + } + return req.config.database.collection('_SCHEMA') + .then(coll => Schema.load(coll)) + .then(schema => schema.validateClassName(req.body.className)) + .catch(error => { + console.log(arguments); + return {response: error}; + }) + .then(newSchema => { + for (key in newSchema.data) { + } + return {response: {}}; + }); +} + router.route('GET', '/schemas', getAllSchemas); router.route('GET', '/schemas/:className', getOneSchema); +router.route('POST', '/schemas', createSchema); +router.route('POST', '/schemas/:className', createSchema); module.exports = router; diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 8c7434da..92f9716e 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1,5 +1,7 @@ +var Parse = require('parse/node').Parse; var request = require('request'); var dd = require('deep-diff'); + var hasAllPODobject = () => { var obj = new Parse.Object('HasAllPOD'); obj.set('aNumber', 5); @@ -56,17 +58,30 @@ var expectedResponseforHasPointersAndRelations = { }, } +var noAuthHeaders = { + 'X-Parse-Application-Id': 'test', +}; + +var restKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', +}; + +var masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', +}; + describe('schemas', () => { it('requires the master key to get all schemas', (done) => { request.get({ url: 'http://localhost:8378/1/schemas', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, + headers: noAuthHeaders, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); + //api.parse.com uses status code 401, but due to the lack of keys + //being necessary in parse-server, 403 makes more sense + expect(response.statusCode).toEqual(403); expect(body.error).toEqual('unauthorized'); done(); }); @@ -87,14 +102,23 @@ describe('schemas', () => { }); }); + it('asks for the master key if you use the rest key', (done) => { + request.get({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: restKeyHeaders, + }, (error, response, body) => { + expect(response.statusCode).toEqual(401); + expect(body.error).toEqual('master key not specified'); + done(); + }); + }); + it('responds with empty list when there are no schemas', done => { request.get({ url: 'http://localhost:8378/1/schemas', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { expect(body.results).toEqual([]); done(); @@ -113,10 +137,7 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { var expected = { results: [expectedResponseForHasAllPOD,expectedResponseforHasPointersAndRelations] @@ -164,4 +185,118 @@ describe('schemas', () => { }); }); }); + + it('requires the master key to create a schema', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: noAuthHeaders, + body: { + className: 'MyClass', + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized'); + done(); + }); + }); + + it('asks for the master key if you use the rest key', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: restKeyHeaders, + body: { + className: 'MyClass', + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(401); + expect(body.error).toEqual('master key not specified'); + done(); + }); + }); + + it('sends an error if you use mismatching class names', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/A', + headers: masterKeyHeaders, + json: true, + body: { + className: 'B', + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body).toEqual({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class name mismatch between B and A', + }); + done(); + }); + }); + + it('sends an error if you use no class name', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: {}, + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body).toEqual({ + code: 135, + error: 'POST /schemas needs class name', + }); + done(); + }) + }); + + it('sends an error if you try to create the same class twice', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: 'A', + }, + }, (error, response, body) => { + expect(error).toEqual(null); + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: 'A', + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body).toEqual({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class A already exists', + }); + done(); + }); + }); + }); + + it('responds with all fields when you create a class', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: "NewClass", + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + } + }); + done(); + }); + }); }); From 0b5cfb2a6ab7797812e3387f8e1b506a1dd58e84 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 18:36:23 -0800 Subject: [PATCH 03/84] Schemas POST fix tests --- schemas.js | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/schemas.js b/schemas.js index e374a127..6849a6a4 100644 --- a/schemas.js +++ b/schemas.js @@ -113,18 +113,13 @@ function createSchema(req) { }, }); } - return req.config.database.collection('_SCHEMA') - .then(coll => Schema.load(coll)) - .then(schema => schema.validateClassName(req.body.className)) - .catch(error => { - console.log(arguments); - return {response: error}; - }) - .then(newSchema => { - for (key in newSchema.data) { - } - return {response: {}}; - }); + return req.config.database.loadSchema() + .then(schema => schema.addClassIfNotExists(className, req.body.fields)) + .then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) })) + .catch(error => ({ + status: 400, + response: error, + })); } router.route('GET', '/schemas', getAllSchemas); From b9bc904aad58d3eeae469b817e1ed1ea5ccf2dab Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Fri, 5 Feb 2016 20:38:58 -0800 Subject: [PATCH 04/84] Add tests to get to 100% branch coverage --- spec/schemas.spec.js | 51 ++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 92f9716e..2378caf5 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -18,7 +18,7 @@ var hasAllPODobject = () => { return obj; } -var expectedResponseForHasAllPOD = { +var plainOldDataSchema = { className: 'HasAllPOD', fields: { //Default fields @@ -38,7 +38,7 @@ var expectedResponseForHasAllPOD = { }, }; -var expectedResponseforHasPointersAndRelations = { +var pointersAndRelationsSchema = { className: 'HasPointersAndRelations', fields: { //Default fields @@ -91,10 +91,7 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas/SomeSchema', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, + headers: restKeyHeaders, }, (error, response, body) => { expect(response.statusCode).toEqual(401); expect(body.error).toEqual('unauthorized'); @@ -140,7 +137,7 @@ describe('schemas', () => { headers: masterKeyHeaders, }, (error, response, body) => { var expected = { - results: [expectedResponseForHasAllPOD,expectedResponseforHasPointersAndRelations] + results: [plainOldDataSchema,pointersAndRelationsSchema] }; expect(body).toEqual(expected); done(); @@ -154,12 +151,9 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas/HasAllPOD', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { - expect(body).toEqual(expectedResponseForHasAllPOD); + expect(body).toEqual(plainOldDataSchema); done(); }); }); @@ -171,10 +165,7 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas/HASALLPOD', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ @@ -283,6 +274,34 @@ describe('schemas', () => { url: 'http://localhost:8378/1/schemas', headers: masterKeyHeaders, json: true, + body: { + className: "NewClass", + fields: { + foo: {type: 'Number'}, + ptr: {type: 'Pointer', targetClass: 'SomeClass'} + } + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + foo: {type: 'Number'}, + ptr: {type: 'Pointer', targetClass: 'SomeClass'}, + } + }); + done(); + }); + }); + + it('lets you specify class name in both places', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, body: { className: "NewClass", } From 3b4515ac439cde1a552d33a664e7e08eb9112e77 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Sun, 7 Feb 2016 00:13:42 +0100 Subject: [PATCH 05/84] Add file for handling GET/POST to /config --- global_config.js | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 global_config.js diff --git a/global_config.js b/global_config.js new file mode 100644 index 00000000..56eafc3c --- /dev/null +++ b/global_config.js @@ -0,0 +1,35 @@ +// global_config.js + +var Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + +// Returns a promise for a {response} object. +function handleUpdateGlobalConfig(req) { + return rest.update(req.config, req.auth, + '_GlobalConfig', 1, req.body) + .then((response) => { + return {response: response}; + }); +} + +// Returns a promise for a {response} object. +function handleGetGlobalConfig(req) { + return rest.find(req.config, req.auth, '_GlobalConfig', 1) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + // only return 'params' attribute of response + return {response: { params: response.results[0].params }}; + } + }); +} + +router.route('GET','/config', handleGetGlobalConfig); +router.route('POST','/config', handleUpdateGlobalConfig); + +module.exports = router; \ No newline at end of file From 0dfb4ac665981b93bd7b6118b9d308ef5130c7f3 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Sun, 7 Feb 2016 00:13:56 +0100 Subject: [PATCH 06/84] Add route for handling /config --- index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/index.js b/index.js index 37a88b89..95b390e3 100644 --- a/index.js +++ b/index.js @@ -115,6 +115,7 @@ function ParseServer(args) { router.merge(require('./installations')); router.merge(require('./functions')); router.merge(require('./schemas')); + router.merge(require('./global_config')); batch.mountOnto(router); From ba1104f6edfb4f391802378172603e09b96840da Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Sun, 7 Feb 2016 00:14:23 +0100 Subject: [PATCH 07/84] Open _GlobalConfig for querying --- Schema.js | 1 + 1 file changed, 1 insertion(+) diff --git a/Schema.js b/Schema.js index 25e301cd..2fe7a2de 100644 --- a/Schema.js +++ b/Schema.js @@ -75,6 +75,7 @@ function classNameIsValid(className) { className === '_Session' || className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects. className === '_Role' || + className === '_GlobalConfig' || joinClassRegex.test(className) || //Class names have the same constraints as field names, but also allow the previous additional names. fieldNameIsValid(className) From c8792b4ec2556b61b592828d828efcde7a94e348 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Sun, 7 Feb 2016 01:19:44 +0100 Subject: [PATCH 08/84] Add default column for _GlobalConfig --- Schema.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Schema.js b/Schema.js index 2fe7a2de..dfba4713 100644 --- a/Schema.js +++ b/Schema.js @@ -60,6 +60,9 @@ defaultColumns = { "expiresAt": {type:'Date'}, "createdWith": {type:'Object'}, }, + _GlobalConfig: { + "params": {type:'Object'} + }, } // Valid classes must: From 6fe050b397dc310c955a2fdffe0313047e06db01 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Sun, 7 Feb 2016 01:20:15 +0100 Subject: [PATCH 09/84] Add read/write test for _GlobalConfig --- spec/ParseGlobalConfig.spec.js | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 spec/ParseGlobalConfig.spec.js diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js new file mode 100644 index 00000000..dd04a997 --- /dev/null +++ b/spec/ParseGlobalConfig.spec.js @@ -0,0 +1,49 @@ +// run test when changing related file using +// $ TESTING=1 node_modules/jasmine/bin/jasmine.js spec/ParseGlobalConfig.spec.js + +var auth = require('../Auth'); +var cache = require('../cache'); +var Config = require('../Config'); +var DatabaseAdapter = require('../DatabaseAdapter'); +var Parse = require('parse/node').Parse; +var rest = require('../rest'); + +var config = new Config('test'); +var database = DatabaseAdapter.getDatabaseConnection('test'); + +describe('GlobalConfig', () => { + beforeEach(function() { + database.create('_GlobalConfig', { objectId: 1, params: { mostValuableCompany: 'Apple' } }, {}); + }); + + it('find existing values', (done) => { + rest.find(config, auth.nobody(config), '_GlobalConfig', 1) + .then(() => { + return database.mongoFind('_GlobalConfig', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.params.mostValuableCompany).toEqual('Apple'); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('update with a new value', (done) => { + var input = { + params: { + mostValuableCompany: 'Alphabet' + } + }; + rest.update(config, auth.nobody(config), '_GlobalConfig', 1, input) + .then(() => { + return database.mongoFind('_GlobalConfig', {}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + var obj = results[0]; + expect(obj.params.mostValuableCompany).toEqual('Alphabet'); + done(); + }).catch((error) => { console.log(error); }); + }); + + +}); From 4f05cfc5629503c6b89c514303c29bafd84f9826 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Sat, 6 Feb 2016 18:49:16 -0800 Subject: [PATCH 10/84] Fixed querying objects with equal constraint on array columns. --- spec/ParseQuery.spec.js | 18 +++++++++++++++++- transform.js | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 88c1f53a..f5b6dc1a 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -2056,7 +2056,7 @@ describe('Parse.Query testing', () => { }); }); - it('query match on array value', (done) => { + it('query match on array with single object', (done) => { var target = {__type: 'Pointer', className: 'TestObject', objectId: 'abc123'}; var obj = new Parse.Object('TestObject'); obj.set('someObjs', [target]); @@ -2072,4 +2072,20 @@ describe('Parse.Query testing', () => { }); }); + it('query match on array with multiple objects', (done) => { + var target1 = {__type: 'Pointer', className: 'TestObject', objectId: 'abc'}; + var target2 = {__type: 'Pointer', className: 'TestObject', objectId: '123'}; + var obj= new Parse.Object('TestObject'); + obj.set('someObjs', [target1, target2]); + obj.save().then(() => { + var query = new Parse.Query('TestObject'); + query.equalTo('someObjs', target1); + return query.find(); + }).then((results) => { + expect(results.length).toEqual(1); + done(); + }, (error) => { + console.log(error); + }); + }); }); diff --git a/transform.js b/transform.js index 051bc75a..48b02c75 100644 --- a/transform.js +++ b/transform.js @@ -126,7 +126,7 @@ function transformKeyValue(schema, className, restKey, restValue, options) { if (inArray && options.query && !(restValue instanceof Array)) { return { - key: key, value: [restValue] + key: key, value: { '$all' : [restValue] } }; } From 3b0ab809653dba2b0f47370c563b5cdd8bd0fc5c Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Sun, 7 Feb 2016 13:51:32 +0100 Subject: [PATCH 11/84] Require masterKey when performing config updates --- global_config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/global_config.js b/global_config.js index 56eafc3c..cba5c790 100644 --- a/global_config.js +++ b/global_config.js @@ -8,6 +8,10 @@ var router = new PromiseRouter(); // Returns a promise for a {response} object. function handleUpdateGlobalConfig(req) { + if (!req.auth.isMaster) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Config updates requires valid masterKey.'); + } + return rest.update(req.config, req.auth, '_GlobalConfig', 1, req.body) .then((response) => { From 86d6bc268f80e7666270e8eeea222fcbf052370f Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 7 Feb 2016 09:01:49 -0500 Subject: [PATCH 12/84] Adds cache for mongodb directories --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index e34b4a44..dc081ced 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,4 +8,7 @@ node_js: env: - MONGODB_VERSION=2.6.11 - MONGODB_VERSION=3.0.8 +cache: + directories: + - $HOME/.mongodb/versions/downloads after_success: ./node_modules/.bin/codecov From 7733ab96254f936e1988e2309b4df31c2db06ee6 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Mon, 8 Feb 2016 09:01:21 +0100 Subject: [PATCH 13/84] Remove unnecessary comment about testing --- spec/ParseGlobalConfig.spec.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index dd04a997..c5c52d90 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -1,5 +1,3 @@ -// run test when changing related file using -// $ TESTING=1 node_modules/jasmine/bin/jasmine.js spec/ParseGlobalConfig.spec.js var auth = require('../Auth'); var cache = require('../cache'); From f504cbb7865349b2f2cbe3a791ea2a83d395a58a Mon Sep 17 00:00:00 2001 From: Taylor Stine Date: Mon, 8 Feb 2016 08:05:58 -0500 Subject: [PATCH 14/84] reverted uri encoding --- ExportAdapter.js | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/ExportAdapter.js b/ExportAdapter.js index df417ac8..c21042fb 100644 --- a/ExportAdapter.js +++ b/ExportAdapter.js @@ -34,21 +34,8 @@ ExportAdapter.prototype.connect = function() { return this.connectionPromise; } - //http://regexr.com/3cncm - if (!this.mongoURI.match(/^mongodb:\/\/((.+):(.+)@)?([^:@]+):{0,1}([^:]+)\/(.+?)$/gm)) { - throw new Error("Invalid mongoURI: " + this.mongoURI) - } - var usernameStart = this.mongoURI.indexOf('://') + 3; - var lastAtIndex = this.mongoURI.lastIndexOf('@'); - var encodedMongoURI = this.mongoURI; - var split = null; - if (lastAtIndex > 0) { - split = this.mongoURI.slice(usernameStart, lastAtIndex).split(':'); - encodedMongoURI = this.mongoURI.slice(0, usernameStart) + encodeURIComponent(split[0]) + ':' + encodeURIComponent(split[1]) + this.mongoURI.slice(lastAtIndex); - } - this.connectionPromise = Promise.resolve().then(() => { - return MongoClient.connect(encodedMongoURI, {uri_decode_auth:true}); + return MongoClient.connect(this.mongoURI); }).then((db) => { this.db = db; }); From 8b3f8751f4e61964549a33776e2458e6fe44e478 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Tue, 9 Feb 2016 00:36:45 +0100 Subject: [PATCH 15/84] Uses rawCollection() for direct db access Updated tests accordingly to changed access --- ExportAdapter.js | 4 ++ Schema.js | 1 - global_config.js | 57 ++++++++++++++----------- spec/ParseGlobalConfig.spec.js | 78 ++++++++++++++++++++-------------- spec/helper.js | 2 +- 5 files changed, 83 insertions(+), 59 deletions(-) diff --git a/ExportAdapter.js b/ExportAdapter.js index df417ac8..f8619d5e 100644 --- a/ExportAdapter.js +++ b/ExportAdapter.js @@ -64,6 +64,10 @@ ExportAdapter.prototype.collection = function(className) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className); } + return this.rawCollection(className); +}; + +ExportAdapter.prototype.rawCollection = function(className) { return this.connect().then(() => { return this.db.collection(this.collectionPrefix + className); }); diff --git a/Schema.js b/Schema.js index dfba4713..2715f46a 100644 --- a/Schema.js +++ b/Schema.js @@ -78,7 +78,6 @@ function classNameIsValid(className) { className === '_Session' || className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects. className === '_Role' || - className === '_GlobalConfig' || joinClassRegex.test(className) || //Class names have the same constraints as field names, but also allow the previous additional names. fieldNameIsValid(className) diff --git a/global_config.js b/global_config.js index cba5c790..773b2597 100644 --- a/global_config.js +++ b/global_config.js @@ -1,39 +1,46 @@ // global_config.js var Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); + PromiseRouter = require('./PromiseRouter'); var router = new PromiseRouter(); -// Returns a promise for a {response} object. -function handleUpdateGlobalConfig(req) { +function updateGlobalConfig(req) { if (!req.auth.isMaster) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'Config updates requires valid masterKey.'); + return Promise.resolve({ + status: 401, + response: {error: 'unauthorized'}, + }); } - return rest.update(req.config, req.auth, - '_GlobalConfig', 1, req.body) - .then((response) => { - return {response: response}; - }); + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body }, { returnOriginal: false })) + .then(response => { + return { response: { params: response.value.params } } + }) + .catch(() => ({ + status: 404, + response: { + code: 103, + error: 'config cannot be updated', + } + })); } -// Returns a promise for a {response} object. -function handleGetGlobalConfig(req) { - return rest.find(req.config, req.auth, '_GlobalConfig', 1) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - // only return 'params' attribute of response - return {response: { params: response.results[0].params }}; - } - }); +function getGlobalConfig(req) { + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOne({'_id': 1})) + .then(globalConfig => ({response: { params: globalConfig.params }})) + .catch(() => ({ + status: 404, + response: { + code: 103, + error: 'config does not exist', + } + })); } -router.route('GET','/config', handleGetGlobalConfig); -router.route('POST','/config', handleUpdateGlobalConfig); +router.route('GET', '/config', getGlobalConfig); +router.route('POST', '/config', updateGlobalConfig); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index c5c52d90..1258ae99 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -1,47 +1,61 @@ -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); +var request = require('request'); var DatabaseAdapter = require('../DatabaseAdapter'); -var Parse = require('parse/node').Parse; -var rest = require('../rest'); -var config = new Config('test'); var database = DatabaseAdapter.getDatabaseConnection('test'); -describe('GlobalConfig', () => { - beforeEach(function() { - database.create('_GlobalConfig', { objectId: 1, params: { mostValuableCompany: 'Apple' } }, {}); +describe('a GlobalConfig', () => { + beforeEach(function(done) { + database.rawCollection('_GlobalConfig') + .then(coll => coll.updateOne({ '_id': 1}, { $set: { params: { companies: ['US', 'DK'] } } }, { upsert: true })) + .then(done()); }); - it('find existing values', (done) => { - rest.find(config, auth.nobody(config), '_GlobalConfig', 1) - .then(() => { - return database.mongoFind('_GlobalConfig', {}, {}); - }).then((results) => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.params.mostValuableCompany).toEqual('Apple'); + it('can be retrieved', (done) => { + request.get({ + url: 'http://localhost:8378/1/config', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(body.params.companies).toEqual(['US', 'DK']); done(); - }).catch((error) => { console.log(error); }); + }); }); - it('update with a new value', (done) => { - var input = { - params: { - mostValuableCompany: 'Alphabet' - } - }; - rest.update(config, auth.nobody(config), '_GlobalConfig', 1, input) - .then(() => { - return database.mongoFind('_GlobalConfig', {}, {}); - }).then((results) => { - expect(results.length).toEqual(1); - var obj = results[0]; - expect(obj.params.mostValuableCompany).toEqual('Alphabet'); + it('can be updated when a master key exists', (done) => { + request.post({ + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { companies: ['US', 'DK', 'SE'] } }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test' + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(body.params.companies).toEqual(['US', 'DK', 'SE']); done(); - }).catch((error) => { console.log(error); }); + }); }); + it('fail to update if master key is missing', (done) => { + request.post({ + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { companies: [] } }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(401); + expect(body.error).toEqual('unauthorized'); + done(); + }); + }); }); diff --git a/spec/helper.js b/spec/helper.js index cca4d1a5..5727c6b5 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -192,7 +192,7 @@ function mockFacebook() { function clearData() { var promises = []; for (var conn in DatabaseAdapter.dbConnections) { - promises.push(DatabaseAdapter.dbConnections[conn].deleteEverything()); + // promises.push(DatabaseAdapter.dbConnections[conn].deleteEverything()); } return Promise.all(promises); } From 307a5d8157aa3023fe12a4d293137ea238e96d49 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Tue, 9 Feb 2016 00:39:31 +0100 Subject: [PATCH 16/84] Revert commented out line --- spec/helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/helper.js b/spec/helper.js index 5727c6b5..cca4d1a5 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -192,7 +192,7 @@ function mockFacebook() { function clearData() { var promises = []; for (var conn in DatabaseAdapter.dbConnections) { - // promises.push(DatabaseAdapter.dbConnections[conn].deleteEverything()); + promises.push(DatabaseAdapter.dbConnections[conn].deleteEverything()); } return Promise.all(promises); } From b989bbcaae264c45bbebb2a73977678a629ed9b2 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 19:41:07 -0800 Subject: [PATCH 17/84] Move all source files into 'src' folder. --- APNS.js => src/APNS.js | 0 Auth.js => src/Auth.js | 0 Config.js => src/Config.js | 0 DatabaseAdapter.js => src/DatabaseAdapter.js | 0 ExportAdapter.js => src/ExportAdapter.js | 0 FilesAdapter.js => src/FilesAdapter.js | 0 GCM.js => src/GCM.js | 0 GridStoreAdapter.js => src/GridStoreAdapter.js | 0 PromiseRouter.js => src/PromiseRouter.js | 0 README.md => src/README.md | 0 RestQuery.js => src/RestQuery.js | 0 RestWrite.js => src/RestWrite.js | 0 S3Adapter.js => src/S3Adapter.js | 0 Schema.js => src/Schema.js | 2 +- analytics.js => src/analytics.js | 0 batch.js => src/batch.js | 0 cache.js => src/cache.js | 0 classes.js => src/classes.js | 0 {cloud => src/cloud}/main.js | 0 facebook.js => src/facebook.js | 0 files.js => src/files.js | 0 functions.js => src/functions.js | 0 httpRequest.js => src/httpRequest.js | 0 index.js => src/index.js | 0 installations.js => src/installations.js | 0 middlewares.js => src/middlewares.js | 0 password.js => src/password.js | 0 push.js => src/push.js | 0 rest.js => src/rest.js | 0 roles.js => src/roles.js | 0 schemas.js => src/schemas.js | 0 sessions.js => src/sessions.js | 0 testing-routes.js => src/testing-routes.js | 0 transform.js => src/transform.js | 2 +- triggers.js => src/triggers.js | 0 users.js => src/users.js | 0 36 files changed, 2 insertions(+), 2 deletions(-) rename APNS.js => src/APNS.js (100%) rename Auth.js => src/Auth.js (100%) rename Config.js => src/Config.js (100%) rename DatabaseAdapter.js => src/DatabaseAdapter.js (100%) rename ExportAdapter.js => src/ExportAdapter.js (100%) rename FilesAdapter.js => src/FilesAdapter.js (100%) rename GCM.js => src/GCM.js (100%) rename GridStoreAdapter.js => src/GridStoreAdapter.js (100%) rename PromiseRouter.js => src/PromiseRouter.js (100%) rename README.md => src/README.md (100%) rename RestQuery.js => src/RestQuery.js (100%) rename RestWrite.js => src/RestWrite.js (100%) rename S3Adapter.js => src/S3Adapter.js (100%) rename Schema.js => src/Schema.js (99%) rename analytics.js => src/analytics.js (100%) rename batch.js => src/batch.js (100%) rename cache.js => src/cache.js (100%) rename classes.js => src/classes.js (100%) rename {cloud => src/cloud}/main.js (100%) rename facebook.js => src/facebook.js (100%) rename files.js => src/files.js (100%) rename functions.js => src/functions.js (100%) rename httpRequest.js => src/httpRequest.js (100%) rename index.js => src/index.js (100%) rename installations.js => src/installations.js (100%) rename middlewares.js => src/middlewares.js (100%) rename password.js => src/password.js (100%) rename push.js => src/push.js (100%) rename rest.js => src/rest.js (100%) rename roles.js => src/roles.js (100%) rename schemas.js => src/schemas.js (100%) rename sessions.js => src/sessions.js (100%) rename testing-routes.js => src/testing-routes.js (100%) rename transform.js => src/transform.js (99%) rename triggers.js => src/triggers.js (100%) rename users.js => src/users.js (100%) diff --git a/APNS.js b/src/APNS.js similarity index 100% rename from APNS.js rename to src/APNS.js diff --git a/Auth.js b/src/Auth.js similarity index 100% rename from Auth.js rename to src/Auth.js diff --git a/Config.js b/src/Config.js similarity index 100% rename from Config.js rename to src/Config.js diff --git a/DatabaseAdapter.js b/src/DatabaseAdapter.js similarity index 100% rename from DatabaseAdapter.js rename to src/DatabaseAdapter.js diff --git a/ExportAdapter.js b/src/ExportAdapter.js similarity index 100% rename from ExportAdapter.js rename to src/ExportAdapter.js diff --git a/FilesAdapter.js b/src/FilesAdapter.js similarity index 100% rename from FilesAdapter.js rename to src/FilesAdapter.js diff --git a/GCM.js b/src/GCM.js similarity index 100% rename from GCM.js rename to src/GCM.js diff --git a/GridStoreAdapter.js b/src/GridStoreAdapter.js similarity index 100% rename from GridStoreAdapter.js rename to src/GridStoreAdapter.js diff --git a/PromiseRouter.js b/src/PromiseRouter.js similarity index 100% rename from PromiseRouter.js rename to src/PromiseRouter.js diff --git a/README.md b/src/README.md similarity index 100% rename from README.md rename to src/README.md diff --git a/RestQuery.js b/src/RestQuery.js similarity index 100% rename from RestQuery.js rename to src/RestQuery.js diff --git a/RestWrite.js b/src/RestWrite.js similarity index 100% rename from RestWrite.js rename to src/RestWrite.js diff --git a/S3Adapter.js b/src/S3Adapter.js similarity index 100% rename from S3Adapter.js rename to src/S3Adapter.js diff --git a/Schema.js b/src/Schema.js similarity index 99% rename from Schema.js rename to src/Schema.js index 25e301cd..69c923dd 100644 --- a/Schema.js +++ b/src/Schema.js @@ -17,7 +17,7 @@ var Parse = require('parse/node').Parse; var transform = require('./transform'); -defaultColumns = { +var defaultColumns = { // Contain the default columns for every parse object type (except _Join collection) _Default: { "objectId": {type:'String'}, diff --git a/analytics.js b/src/analytics.js similarity index 100% rename from analytics.js rename to src/analytics.js diff --git a/batch.js b/src/batch.js similarity index 100% rename from batch.js rename to src/batch.js diff --git a/cache.js b/src/cache.js similarity index 100% rename from cache.js rename to src/cache.js diff --git a/classes.js b/src/classes.js similarity index 100% rename from classes.js rename to src/classes.js diff --git a/cloud/main.js b/src/cloud/main.js similarity index 100% rename from cloud/main.js rename to src/cloud/main.js diff --git a/facebook.js b/src/facebook.js similarity index 100% rename from facebook.js rename to src/facebook.js diff --git a/files.js b/src/files.js similarity index 100% rename from files.js rename to src/files.js diff --git a/functions.js b/src/functions.js similarity index 100% rename from functions.js rename to src/functions.js diff --git a/httpRequest.js b/src/httpRequest.js similarity index 100% rename from httpRequest.js rename to src/httpRequest.js diff --git a/index.js b/src/index.js similarity index 100% rename from index.js rename to src/index.js diff --git a/installations.js b/src/installations.js similarity index 100% rename from installations.js rename to src/installations.js diff --git a/middlewares.js b/src/middlewares.js similarity index 100% rename from middlewares.js rename to src/middlewares.js diff --git a/password.js b/src/password.js similarity index 100% rename from password.js rename to src/password.js diff --git a/push.js b/src/push.js similarity index 100% rename from push.js rename to src/push.js diff --git a/rest.js b/src/rest.js similarity index 100% rename from rest.js rename to src/rest.js diff --git a/roles.js b/src/roles.js similarity index 100% rename from roles.js rename to src/roles.js diff --git a/schemas.js b/src/schemas.js similarity index 100% rename from schemas.js rename to src/schemas.js diff --git a/sessions.js b/src/sessions.js similarity index 100% rename from sessions.js rename to src/sessions.js diff --git a/testing-routes.js b/src/testing-routes.js similarity index 100% rename from testing-routes.js rename to src/testing-routes.js diff --git a/transform.js b/src/transform.js similarity index 99% rename from transform.js rename to src/transform.js index 48b02c75..802bf075 100644 --- a/transform.js +++ b/src/transform.js @@ -21,7 +21,7 @@ var Parse = require('parse/node').Parse; // validate: true indicates that key names are to be validated. // // Returns an object with {key: key, value: value}. -function transformKeyValue(schema, className, restKey, restValue, options) { +export function transformKeyValue(schema, className, restKey, restValue, options) { options = options || {}; // Check if the schema is known since it's a built-in field. diff --git a/triggers.js b/src/triggers.js similarity index 100% rename from triggers.js rename to src/triggers.js diff --git a/users.js b/src/users.js similarity index 100% rename from users.js rename to src/users.js From cc481911119862fdda0de7a03ac4e8e1ec8b0453 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 19:42:00 -0800 Subject: [PATCH 18/84] Add .babelrc --- .babelrc | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .babelrc diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..3c078e9f --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": [ + "es2015" + ] +} From 7b270c2c83e60e95397ad70175697d5fa5f72e56 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 19:42:20 -0800 Subject: [PATCH 19/84] Add lib folder to ignore file. --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2d9748d6..318fed20 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,7 @@ node_modules *~ # WebStorm/IntelliJ -.idea \ No newline at end of file +.idea + +# Babel.js +lib/ From 07aa00f382263425f445ef18f8976fd3f74ca5a9 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 20:19:49 -0800 Subject: [PATCH 20/84] Fix invalid JavaScript. --- src/APNS.js | 2 +- src/Schema.js | 25 ++++++++++++------------- src/classes.js | 2 +- src/schemas.js | 4 ++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/APNS.js b/src/APNS.js index 5fc73ab0..85c97401 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -60,7 +60,7 @@ APNS.prototype.send = function(data, deviceTokens) { var generateNotification = function(coreData, expirationTime) { var notification = new apn.notification(); var payload = {}; - for (key in coreData) { + for (var key in coreData) { switch (key) { case 'alert': notification.setAlertText(coreData.alert); diff --git a/src/Schema.js b/src/Schema.js index 69c923dd..3656507a 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -43,13 +43,13 @@ var defaultColumns = { "GCMSenderId": {type:'String'}, "timeZone": {type:'String'}, "localeIdentifier": {type:'String'}, - "badge": {type:'Number'}, + "badge": {type:'Number'} }, // The additional default columns for the _User collection (in addition to DefaultCols) _Role: { "name": {type:'String'}, "users": {type:'Relation',className:'_User'}, - "roles": {type:'Relation',className:'_Role'}, + "roles": {type:'Relation',className:'_Role'} }, // The additional default columns for the _User collection (in addition to DefaultCols) _Session: { @@ -58,9 +58,9 @@ var defaultColumns = { "installationId": {type:'String'}, "sessionToken": {type:'String'}, "expiresAt": {type:'Date'}, - "createdWith": {type:'Object'}, - }, -} + "createdWith": {type:'Object'} + } +}; // Valid classes must: // Be one of _User, _Installation, _Role, _Session OR @@ -221,7 +221,7 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { error: invalidClassNameMessage(className), }); } - for (fieldName in fields) { + for (var fieldName in fields) { if (!fieldNameIsValid(fieldName)) { return Promise.reject({ code: Parse.Error.INVALID_KEY_NAME, @@ -240,18 +240,18 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { _id: className, objectId: 'string', updatedAt: 'string', - createdAt: 'string', + createdAt: 'string' }; - for (fieldName in defaultColumns[className]) { - validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]); + for (var fieldName in defaultColumns[className]) { + var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]); if (validatedField.code) { return Promise.reject(validatedField); } mongoObject[fieldName] = validatedField.result; } - for (fieldName in fields) { - validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]); + for (var fieldName in fields) { + var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]); if (validatedField.code) { return Promise.reject(validatedField); } @@ -259,7 +259,6 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { } var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint'); - if (geoPoints.length > 1) { return Promise.reject({ code: Parse.Error.INCORRECT_TYPE, @@ -278,7 +277,7 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { } return Promise.reject(error); }); -} +}; // Returns a promise that resolves successfully to the new schema // object or fails with a reason. diff --git a/src/classes.js b/src/classes.js index 98e94871..f1400914 100644 --- a/src/classes.js +++ b/src/classes.js @@ -42,7 +42,7 @@ function handleFind(req) { req.params.className, body.where, options) .then((response) => { if (response && response.results) { - for (result of response.results) { + for (var result of response.results) { if (result.sessionToken) { result.sessionToken = req.info.sessionToken || result.sessionToken; } diff --git a/src/schemas.js b/src/schemas.js index 6145b7e6..837224ab 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -34,8 +34,8 @@ function mongoFieldTypeToSchemaAPIType(type) { } function mongoSchemaAPIResponseFields(schema) { - fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata'); - response = fieldNames.reduce((obj, fieldName) => { + var fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata'); + var response = fieldNames.reduce((obj, fieldName) => { obj[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName]) return obj; }, {}); From 3271b45102c0c1952f0ca5b98b8b5fb26673e5a7 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 20:20:08 -0800 Subject: [PATCH 21/84] Update imports in all specs. --- spec/APNS.spec.js | 2 +- spec/ExportAdapter.spec.js | 2 +- spec/GCM.spec.js | 2 +- spec/ParseACL.spec.js | 3 +++ spec/ParseAPI.spec.js | 2 +- spec/ParseInstallation.spec.js | 10 +++++----- spec/ParseUser.spec.js | 2 +- spec/RestCreate.spec.js | 10 +++++----- spec/RestQuery.spec.js | 8 ++++---- spec/Schema.spec.js | 8 ++++---- spec/helper.js | 8 ++++---- spec/push.spec.js | 2 +- spec/schemas.spec.js | 4 ++-- spec/support/jasmine.json | 2 +- spec/transform.spec.js | 2 +- 15 files changed, 35 insertions(+), 32 deletions(-) diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index c50bb5c9..72490e97 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,4 +1,4 @@ -var APNS = require('../APNS'); +var APNS = require('../src/APNS'); describe('APNS', () => { it('can generate APNS notification', (done) => { diff --git a/spec/ExportAdapter.spec.js b/spec/ExportAdapter.spec.js index 95fbdd21..a4f3f9b6 100644 --- a/spec/ExportAdapter.spec.js +++ b/spec/ExportAdapter.spec.js @@ -1,4 +1,4 @@ -var ExportAdapter = require('../ExportAdapter'); +var ExportAdapter = require('../src/ExportAdapter'); describe('ExportAdapter', () => { it('can be constructed', (done) => { diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index d7484b0e..4bad883e 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -1,4 +1,4 @@ -var GCM = require('../GCM'); +var GCM = require('../src/GCM'); describe('GCM', () => { it('can generate GCM Payload without expiration time', (done) => { diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index fead537e..370550c0 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -251,6 +251,9 @@ describe('Parse.ACL', () => { equal(results.length, 1); var result = results[0]; ok(result); + if (!result) { + return fail(); + } equal(result.id, object.id); equal(result.getACL().getReadAccess(user), true); equal(result.getACL().getWriteAccess(user), true); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 24edf38f..8670bdd2 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -1,7 +1,7 @@ // A bunch of different tests are in here - it isn't very thematic. // It would probably be better to refactor them into different files. -var DatabaseAdapter = require('../DatabaseAdapter'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var request = require('request'); describe('miscellaneous', function() { diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 6d8e6162..91bb9a23 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1,12 +1,12 @@ // These tests check the Installations functionality of the REST API. // Ported from installation_collection_test.go -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var Parse = require('parse/node').Parse; -var rest = require('../rest'); +var rest = require('../src/rest'); var config = new Config('test'); var database = DatabaseAdapter.getDatabaseConnection('test'); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index b364adf1..c9f25bd8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -6,7 +6,7 @@ // Tests that involve sending password reset emails. var request = require('request'); -var passwordCrypto = require('../password'); +var passwordCrypto = require('../src/password'); describe('Parse.User testing', () => { it("user sign up class method", (done) => { diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index 59de11ea..24455507 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -1,10 +1,10 @@ // These tests check the "create" functionality of the REST API. -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var Parse = require('parse/node').Parse; -var rest = require('../rest'); +var rest = require('../src/rest'); var request = require('request'); var config = new Config('test'); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 08d01766..b93a07d5 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -1,8 +1,8 @@ // These tests check the "find" functionality of the REST API. -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var rest = require('../rest'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var rest = require('../src/rest'); var config = new Config('test'); var nobody = auth.nobody(config); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index ccc83525..636311a6 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,6 +1,6 @@ // These tests check that the Schema operates correctly. -var Config = require('../Config'); -var Schema = require('../Schema'); +var Config = require('../src/Config'); +var Schema = require('../src/Schema'); var dd = require('deep-diff'); var config = new Config('test'); @@ -252,7 +252,7 @@ describe('Schema', () => { it('refuses to add fields with invalid pointer types', done => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer'}, + foo: {type: 'Pointer'} })) .catch(error => { expect(error.code).toEqual(135); @@ -398,7 +398,7 @@ describe('Schema', () => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { geo1: {type: 'GeoPoint'}, - geo2: {type: 'GeoPoint'}, + geo2: {type: 'GeoPoint'} })) .catch(error => { expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); diff --git a/spec/helper.js b/spec/helper.js index cca4d1a5..3e6c6d98 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -2,11 +2,11 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; -var cache = require('../cache'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var cache = require('../src/cache'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var express = require('express'); -var facebook = require('../facebook'); -var ParseServer = require('../index').ParseServer; +var facebook = require('../src/facebook'); +var ParseServer = require('../src/index').ParseServer; var databaseURI = process.env.DATABASE_URI; var cloudMain = process.env.CLOUD_CODE_MAIN || './cloud/main.js'; diff --git a/spec/push.spec.js b/spec/push.spec.js index ba5b533b..a2ea41b5 100644 --- a/spec/push.spec.js +++ b/spec/push.spec.js @@ -1,4 +1,4 @@ -var push = require('../push'); +var push = require('../src/push'); describe('push', () => { it('can check valid master key of request', (done) => { diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 2378caf5..68ac31c9 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -16,7 +16,7 @@ var hasAllPODobject = () => { objACL.setPublicWriteAccess(false); obj.setACL(objACL); return obj; -} +}; var plainOldDataSchema = { className: 'HasAllPOD', @@ -35,7 +35,7 @@ var plainOldDataSchema = { aArray: {type: 'Array'}, aGeoPoint: {type: 'GeoPoint'}, aFile: {type: 'File'} - }, + } }; var pointersAndRelationsSchema = { diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index b1aae666..e0347ebf 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -4,7 +4,7 @@ "*spec.js" ], "helpers": [ + "../node_modules/babel-core/register.js", "helper.js" ] } - diff --git a/spec/transform.spec.js b/spec/transform.spec.js index 528c46bf..c7780ffb 100644 --- a/spec/transform.spec.js +++ b/spec/transform.spec.js @@ -1,6 +1,6 @@ // These tests are unit tests designed to only test transform.js. -var transform = require('../transform'); +var transform = require('../src/transform'); var dummySchema = { data: {}, From a0d695709608e58c49dbc1fe943f06e34d0dc4d2 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 20:20:21 -0800 Subject: [PATCH 22/84] Add babel.js compilation steps/dependencies to package.json. --- package.json | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3d145ee4..689110c0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "parse-server", "version": "2.0.7", "description": "An express module providing a Parse-compatible API server", - "main": "index.js", + "main": "lib/index.js", "repository": { "type": "git", "url": "https://github.com/ParsePlatform/parse-server" @@ -11,6 +11,7 @@ "dependencies": { "apn": "^1.7.5", "aws-sdk": "~2.2.33", + "babel-runtime": "^6.5.0", "bcrypt-nodejs": "0.0.3", "body-parser": "^1.14.2", "deepcopy": "^0.6.1", @@ -19,23 +20,29 @@ "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", + "node-gcm": "^0.14.0", "parse": "^1.7.0", "randomstring": "^1.1.3", - "node-gcm": "^0.14.0", "request": "^2.65.0" }, "devDependencies": { + "babel-cli": "^6.5.1", + "babel-core": "^6.5.1", + "babel-istanbul": "^0.6.0", + "babel-preset-es2015": "^6.5.0", + "babel-register": "^6.5.1", "codecov": "^1.0.1", "deep-diff": "^0.3.3", - "istanbul": "^0.4.2", "jasmine": "^2.3.2", "mongodb-runner": "^3.1.15" }, "scripts": { + "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} mongodb-runner start", - "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/istanbul cover --include-all-sources -x **/spec/** ./node_modules/.bin/jasmine", + "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/.bin/babel-istanbul cover -x **/spec/** ./node_modules/.bin/jasmine", "posttest": "mongodb-runner stop", - "start": "./bin/parse-server" + "start": "./bin/parse-server", + "prepublish": "npm run build" }, "engines": { "node": ">=4.1" From 9ce90b06f501754b033ec367824a7182b8f77c1a Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 20:20:33 -0800 Subject: [PATCH 23/84] Update imports in parse-server binary. --- bin/parse-server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/parse-server b/bin/parse-server index c2606f4b..902e43b2 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1,6 +1,6 @@ #!/usr/bin/env node var express = require('express'); -var ParseServer = require("../index").ParseServer; +var ParseServer = require("../lib/index").ParseServer; var app = express(); From 2750a4c25fbde111d0941c22ca94316991222494 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 22:01:42 -0800 Subject: [PATCH 24/84] Clarify run tests step in contributing guidelines. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39762126..6a1923cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ We really want Parse to be yours, to see it grow and thrive in the open source c ##### Please Do's * Take testing seriously! Aim to increase the test coverage with every pull request. -* Run the tests for the file you are working on with `TESTING=1 (repo-root)/node_modules/jasmine/bin/jasmine.js spec/MyFile.spec.js` +* Run the tests for the file you are working on with `npm test spec/MyFile.spec.js` * Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html ##### Code of Conduct From 244febf4a9140f7961f0420902dd38f456e22d23 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 22:52:57 -0800 Subject: [PATCH 25/84] Unmove readme. --- src/README.md => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/README.md => README.md (100%) diff --git a/src/README.md b/README.md similarity index 100% rename from src/README.md rename to README.md From 0bae4ef6ca607155639b0c362a420cfb5d1a9110 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Mon, 8 Feb 2016 23:24:34 -0800 Subject: [PATCH 26/84] Add npmignore --- .npmignore | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .npmignore diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..5e3ded83 --- /dev/null +++ b/.npmignore @@ -0,0 +1,33 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# Emacs +*~ + +# WebStorm/IntelliJ +.idea From 53fdc9bdebe56576910c6f2ec12228318d736b88 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 8 Feb 2016 22:51:58 -0800 Subject: [PATCH 27/84] Refactor FilesAdapter to ES6 style. --- src/FilesAdapter.js | 25 ++++++++------- src/GridStoreAdapter.js | 69 +++++++++++++++++++---------------------- src/files.js | 12 +++---- src/index.js | 8 +++-- 4 files changed, 57 insertions(+), 57 deletions(-) diff --git a/src/FilesAdapter.js b/src/FilesAdapter.js index 427e20d9..94fd2bb8 100644 --- a/src/FilesAdapter.js +++ b/src/FilesAdapter.js @@ -3,27 +3,28 @@ // Allows you to change the file storage mechanism. // // Adapter classes must implement the following functions: -// * create(config, filename, data) -// * get(config, filename) -// * location(config, req, filename) +// * createFileAsync(config, filename, data) +// * getFileDataAsync(config, filename) +// * getFileLocation(config, request, filename) // // Default is GridStoreAdapter, which requires mongo // and for the API server to be using the ExportAdapter // database adapter. -var GridStoreAdapter = require('./GridStoreAdapter'); +let adapter = null; -var adapter = GridStoreAdapter; - -function setAdapter(filesAdapter) { +export function setAdapter(filesAdapter) { adapter = filesAdapter; } -function getAdapter() { +export function getAdapter() { return adapter; } -module.exports = { - getAdapter: getAdapter, - setAdapter: setAdapter -}; +export class FilesAdapter { + createFileAsync(config, filename, data) { } + + getFileDataAsync(config, filename) { } + + getFileLocation(config, request, filename) { } +} diff --git a/src/GridStoreAdapter.js b/src/GridStoreAdapter.js index 0d1e8965..161515c6 100644 --- a/src/GridStoreAdapter.js +++ b/src/GridStoreAdapter.js @@ -3,46 +3,41 @@ // Stores files in Mongo using GridStore // Requires the database adapter to be based on mongoclient -var GridStore = require('mongodb').GridStore; -var path = require('path'); +import { GridStore } from 'mongodb'; -// For a given config object, filename, and data, store a file -// Returns a promise -function create(config, filename, data) { - return config.database.connect().then(() => { - var gridStore = new GridStore(config.database.db, filename, 'w'); - return gridStore.open(); - }).then((gridStore) => { - return gridStore.write(data); - }).then((gridStore) => { - return gridStore.close(); - }); -} +import * as Path from 'path'; +import { FilesAdapter } from './FilesAdapter'; -// Search for and return a file if found by filename -// Resolves a promise that succeeds with the buffer result -// from GridStore -function get(config, filename) { - return config.database.connect().then(() => { - return GridStore.exist(config.database.db, filename); - }).then(() => { - var gridStore = new GridStore(config.database.db, filename, 'r'); - return gridStore.open(); - }).then((gridStore) => { - return gridStore.read(); - }); -} +class GridStoreAdapter extends FilesAdapter { + // For a given config object, filename, and data, store a file + // Returns a promise + createFileAsync(config, filename, data) { + return config.database.connect().then(() => { + let gridStore = new GridStore(config.database.db, filename, 'w'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.write(data); + }).then((gridStore) => { + return gridStore.close(); + }); + } -// Generates and returns the location of a file stored in GridStore for the -// given request and filename -function location(config, req, filename) { - return (req.protocol + '://' + req.get('host') + - path.dirname(req.originalUrl) + '/' + req.config.applicationId + + getFileDataAsync(config, filename) { + return config.database.connect().then(() => { + return GridStore.exist(config.database.db, filename); + }).then(() => { + let gridStore = new GridStore(config.database.db, filename, 'r'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.read(); + }); + } + + getFileLocation(config, request, filename) { + return (request.protocol + '://' + request.get('host') + + Path.dirname(request.originalUrl) + '/' + config.applicationId + '/' + encodeURIComponent(filename)); + } } -module.exports = { - create: create, - get: get, - location: location -}; +export default GridStoreAdapter; diff --git a/src/files.js b/src/files.js index a840e098..86cdbfbe 100644 --- a/src/files.js +++ b/src/files.js @@ -3,12 +3,13 @@ var bodyParser = require('body-parser'), Config = require('./Config'), express = require('express'), - FilesAdapter = require('./FilesAdapter'), middlewares = require('./middlewares.js'), mime = require('mime'), Parse = require('parse/node').Parse, rack = require('hat').rack(); +import { getAdapter as getFilesAdapter } from './FilesAdapter'; + var router = express.Router(); var processCreate = function(req, res, next) { @@ -40,13 +41,13 @@ var processCreate = function(req, res, next) { } var filename = rack() + '_' + req.params.filename + extension; - FilesAdapter.getAdapter().create(req.config, filename, req.body) - .then(() => { + getFilesAdapter().createFileAsync(req.config, filename, req.body).then(() => { res.status(201); - var location = FilesAdapter.getAdapter().location(req.config, req, filename); + var location = getFilesAdapter().getFileLocation(req.config, req, filename); res.set('Location', location); res.json({ url: location, name: filename }); }).catch((error) => { + console.log(error); next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Could not store file.')); }); @@ -54,8 +55,7 @@ var processCreate = function(req, res, next) { var processGet = function(req, res) { var config = new Config(req.params.appId); - FilesAdapter.getAdapter().get(config, req.params.filename) - .then((data) => { + getFilesAdapter().getFileDataAsync(config, req.params.filename).then((data) => { res.status(200); var contentType = mime.lookup(req.params.filename); res.set('Content-type', contentType); diff --git a/src/index.js b/src/index.js index 37a88b89..48d3e8c9 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,6 @@ var batch = require('./batch'), cache = require('./cache'), DatabaseAdapter = require('./DatabaseAdapter'), express = require('express'), - FilesAdapter = require('./FilesAdapter'), S3Adapter = require('./S3Adapter'), middlewares = require('./middlewares'), multer = require('multer'), @@ -13,6 +12,9 @@ var batch = require('./batch'), PromiseRouter = require('./PromiseRouter'), httpRequest = require('./httpRequest'); +import { setAdapter as setFilesAdapter } from './FilesAdapter'; +import { default as GridStoreAdapter } from './GridStoreAdapter'; + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -47,7 +49,9 @@ function ParseServer(args) { DatabaseAdapter.setAdapter(args.databaseAdapter); } if (args.filesAdapter) { - FilesAdapter.setAdapter(args.filesAdapter); + setFilesAdapter(args.filesAdapter); + } else { + setFilesAdapter(new GridStoreAdapter()); } if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); From 25b13ff632e07433ef6ac4c339623f684f4acf58 Mon Sep 17 00:00:00 2001 From: David Keegan Date: Tue, 9 Feb 2016 00:56:00 -0800 Subject: [PATCH 28/84] Remove duplicate dotNetKey --- bin/parse-server | 1 - 1 file changed, 1 deletion(-) diff --git a/bin/parse-server b/bin/parse-server index 902e43b2..7ec17927 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -21,7 +21,6 @@ if (process.env.PARSE_SERVER_OPTIONS) { options.restAPIKey = process.env.PARSE_SERVER_REST_API_KEY; options.dotNetKey = process.env.PARSE_SERVER_DOTNET_KEY; options.javascriptKey = process.env.PARSE_SERVER_JAVASCRIPT_KEY; - options.dotNetKey = process.env.PARSE_SERVER_DOTNET_KEY; options.masterKey = process.env.PARSE_SERVER_MASTER_KEY; options.fileKey = process.env.PARSE_SERVER_FILE_KEY; // Comma separated list of facebook app ids From 7fa4b3bc073e2c6dd38fb6624873afd879a69a20 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Tue, 9 Feb 2016 10:23:55 +0100 Subject: [PATCH 29/84] Merged with master --- .babelrc | 5 + .gitignore | 5 +- .travis.yml | 3 + CONTRIBUTING.md | 2 +- bin/parse-server | 2 +- package.json | 17 +- spec/APNS.spec.js | 2 +- spec/ExportAdapter.spec.js | 2 +- spec/GCM.spec.js | 2 +- spec/ParseACL.spec.js | 3 + spec/ParseAPI.spec.js | 2 +- spec/ParseInstallation.spec.js | 10 +- spec/ParseQuery.spec.js | 18 +- spec/ParseUser.spec.js | 2 +- spec/RestCreate.spec.js | 10 +- spec/RestQuery.spec.js | 8 +- spec/Schema.spec.js | 8 +- spec/helper.js | 8 +- spec/push.spec.js | 2 +- spec/schemas.spec.js | 216 +++++++++++++++--- spec/support/jasmine.json | 2 +- spec/transform.spec.js | 2 +- APNS.js => src/APNS.js | 2 +- Auth.js => src/Auth.js | 0 Config.js => src/Config.js | 0 DatabaseAdapter.js => src/DatabaseAdapter.js | 0 ExportAdapter.js => src/ExportAdapter.js | 15 +- FilesAdapter.js => src/FilesAdapter.js | 0 GCM.js => src/GCM.js | 0 .../GridStoreAdapter.js | 0 PromiseRouter.js => src/PromiseRouter.js | 0 RestQuery.js => src/RestQuery.js | 0 RestWrite.js => src/RestWrite.js | 0 S3Adapter.js => src/S3Adapter.js | 0 Schema.js => src/Schema.js | 30 ++- analytics.js => src/analytics.js | 0 batch.js => src/batch.js | 0 cache.js => src/cache.js | 0 classes.js => src/classes.js | 2 +- {cloud => src/cloud}/main.js | 0 facebook.js => src/facebook.js | 0 files.js => src/files.js | 0 functions.js => src/functions.js | 0 httpRequest.js => src/httpRequest.js | 0 index.js => src/index.js | 0 installations.js => src/installations.js | 0 middlewares.js => src/middlewares.js | 0 password.js => src/password.js | 0 push.js => src/push.js | 0 rest.js => src/rest.js | 0 roles.js => src/roles.js | 0 schemas.js => src/schemas.js | 50 +++- sessions.js => src/sessions.js | 0 testing-routes.js => src/testing-routes.js | 0 transform.js => src/transform.js | 4 +- triggers.js => src/triggers.js | 0 users.js => src/users.js | 0 57 files changed, 325 insertions(+), 109 deletions(-) create mode 100644 .babelrc rename APNS.js => src/APNS.js (99%) rename Auth.js => src/Auth.js (100%) rename Config.js => src/Config.js (100%) rename DatabaseAdapter.js => src/DatabaseAdapter.js (100%) rename ExportAdapter.js => src/ExportAdapter.js (96%) rename FilesAdapter.js => src/FilesAdapter.js (100%) rename GCM.js => src/GCM.js (100%) rename GridStoreAdapter.js => src/GridStoreAdapter.js (100%) rename PromiseRouter.js => src/PromiseRouter.js (100%) rename RestQuery.js => src/RestQuery.js (100%) rename RestWrite.js => src/RestWrite.js (100%) rename S3Adapter.js => src/S3Adapter.js (100%) rename Schema.js => src/Schema.js (97%) rename analytics.js => src/analytics.js (100%) rename batch.js => src/batch.js (100%) rename cache.js => src/cache.js (100%) rename classes.js => src/classes.js (98%) rename {cloud => src/cloud}/main.js (100%) rename facebook.js => src/facebook.js (100%) rename files.js => src/files.js (100%) rename functions.js => src/functions.js (100%) rename httpRequest.js => src/httpRequest.js (100%) rename index.js => src/index.js (100%) rename installations.js => src/installations.js (100%) rename middlewares.js => src/middlewares.js (100%) rename password.js => src/password.js (100%) rename push.js => src/push.js (100%) rename rest.js => src/rest.js (100%) rename roles.js => src/roles.js (100%) rename schemas.js => src/schemas.js (59%) rename sessions.js => src/sessions.js (100%) rename testing-routes.js => src/testing-routes.js (100%) rename transform.js => src/transform.js (99%) rename triggers.js => src/triggers.js (100%) rename users.js => src/users.js (100%) diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..3c078e9f --- /dev/null +++ b/.babelrc @@ -0,0 +1,5 @@ +{ + "presets": [ + "es2015" + ] +} diff --git a/.gitignore b/.gitignore index 2d9748d6..318fed20 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,7 @@ node_modules *~ # WebStorm/IntelliJ -.idea \ No newline at end of file +.idea + +# Babel.js +lib/ diff --git a/.travis.yml b/.travis.yml index e34b4a44..dc081ced 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,4 +8,7 @@ node_js: env: - MONGODB_VERSION=2.6.11 - MONGODB_VERSION=3.0.8 +cache: + directories: + - $HOME/.mongodb/versions/downloads after_success: ./node_modules/.bin/codecov diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39762126..6a1923cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ We really want Parse to be yours, to see it grow and thrive in the open source c ##### Please Do's * Take testing seriously! Aim to increase the test coverage with every pull request. -* Run the tests for the file you are working on with `TESTING=1 (repo-root)/node_modules/jasmine/bin/jasmine.js spec/MyFile.spec.js` +* Run the tests for the file you are working on with `npm test spec/MyFile.spec.js` * Run the tests for the whole project and look at the coverage report to make sure your tests are exhaustive by running `npm test` and looking at (project-root)/lcov-report/parse-server/FileUnderTest.js.html ##### Code of Conduct diff --git a/bin/parse-server b/bin/parse-server index c2606f4b..902e43b2 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1,6 +1,6 @@ #!/usr/bin/env node var express = require('express'); -var ParseServer = require("../index").ParseServer; +var ParseServer = require("../lib/index").ParseServer; var app = express(); diff --git a/package.json b/package.json index 3d145ee4..689110c0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "parse-server", "version": "2.0.7", "description": "An express module providing a Parse-compatible API server", - "main": "index.js", + "main": "lib/index.js", "repository": { "type": "git", "url": "https://github.com/ParsePlatform/parse-server" @@ -11,6 +11,7 @@ "dependencies": { "apn": "^1.7.5", "aws-sdk": "~2.2.33", + "babel-runtime": "^6.5.0", "bcrypt-nodejs": "0.0.3", "body-parser": "^1.14.2", "deepcopy": "^0.6.1", @@ -19,23 +20,29 @@ "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", + "node-gcm": "^0.14.0", "parse": "^1.7.0", "randomstring": "^1.1.3", - "node-gcm": "^0.14.0", "request": "^2.65.0" }, "devDependencies": { + "babel-cli": "^6.5.1", + "babel-core": "^6.5.1", + "babel-istanbul": "^0.6.0", + "babel-preset-es2015": "^6.5.0", + "babel-register": "^6.5.1", "codecov": "^1.0.1", "deep-diff": "^0.3.3", - "istanbul": "^0.4.2", "jasmine": "^2.3.2", "mongodb-runner": "^3.1.15" }, "scripts": { + "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} mongodb-runner start", - "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/istanbul cover --include-all-sources -x **/spec/** ./node_modules/.bin/jasmine", + "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/.bin/babel-istanbul cover -x **/spec/** ./node_modules/.bin/jasmine", "posttest": "mongodb-runner stop", - "start": "./bin/parse-server" + "start": "./bin/parse-server", + "prepublish": "npm run build" }, "engines": { "node": ">=4.1" diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index c50bb5c9..72490e97 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,4 +1,4 @@ -var APNS = require('../APNS'); +var APNS = require('../src/APNS'); describe('APNS', () => { it('can generate APNS notification', (done) => { diff --git a/spec/ExportAdapter.spec.js b/spec/ExportAdapter.spec.js index 95fbdd21..a4f3f9b6 100644 --- a/spec/ExportAdapter.spec.js +++ b/spec/ExportAdapter.spec.js @@ -1,4 +1,4 @@ -var ExportAdapter = require('../ExportAdapter'); +var ExportAdapter = require('../src/ExportAdapter'); describe('ExportAdapter', () => { it('can be constructed', (done) => { diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index d7484b0e..4bad883e 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -1,4 +1,4 @@ -var GCM = require('../GCM'); +var GCM = require('../src/GCM'); describe('GCM', () => { it('can generate GCM Payload without expiration time', (done) => { diff --git a/spec/ParseACL.spec.js b/spec/ParseACL.spec.js index fead537e..370550c0 100644 --- a/spec/ParseACL.spec.js +++ b/spec/ParseACL.spec.js @@ -251,6 +251,9 @@ describe('Parse.ACL', () => { equal(results.length, 1); var result = results[0]; ok(result); + if (!result) { + return fail(); + } equal(result.id, object.id); equal(result.getACL().getReadAccess(user), true); equal(result.getACL().getWriteAccess(user), true); diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 24edf38f..8670bdd2 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -1,7 +1,7 @@ // A bunch of different tests are in here - it isn't very thematic. // It would probably be better to refactor them into different files. -var DatabaseAdapter = require('../DatabaseAdapter'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var request = require('request'); describe('miscellaneous', function() { diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 6d8e6162..91bb9a23 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1,12 +1,12 @@ // These tests check the Installations functionality of the REST API. // Ported from installation_collection_test.go -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var Parse = require('parse/node').Parse; -var rest = require('../rest'); +var rest = require('../src/rest'); var config = new Config('test'); var database = DatabaseAdapter.getDatabaseConnection('test'); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index 88c1f53a..f5b6dc1a 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -2056,7 +2056,7 @@ describe('Parse.Query testing', () => { }); }); - it('query match on array value', (done) => { + it('query match on array with single object', (done) => { var target = {__type: 'Pointer', className: 'TestObject', objectId: 'abc123'}; var obj = new Parse.Object('TestObject'); obj.set('someObjs', [target]); @@ -2072,4 +2072,20 @@ describe('Parse.Query testing', () => { }); }); + it('query match on array with multiple objects', (done) => { + var target1 = {__type: 'Pointer', className: 'TestObject', objectId: 'abc'}; + var target2 = {__type: 'Pointer', className: 'TestObject', objectId: '123'}; + var obj= new Parse.Object('TestObject'); + obj.set('someObjs', [target1, target2]); + obj.save().then(() => { + var query = new Parse.Query('TestObject'); + query.equalTo('someObjs', target1); + return query.find(); + }).then((results) => { + expect(results.length).toEqual(1); + done(); + }, (error) => { + console.log(error); + }); + }); }); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index b364adf1..c9f25bd8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -6,7 +6,7 @@ // Tests that involve sending password reset emails. var request = require('request'); -var passwordCrypto = require('../password'); +var passwordCrypto = require('../src/password'); describe('Parse.User testing', () => { it("user sign up class method", (done) => { diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index 59de11ea..24455507 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -1,10 +1,10 @@ // These tests check the "create" functionality of the REST API. -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var Parse = require('parse/node').Parse; -var rest = require('../rest'); +var rest = require('../src/rest'); var request = require('request'); var config = new Config('test'); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 08d01766..b93a07d5 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -1,8 +1,8 @@ // These tests check the "find" functionality of the REST API. -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var rest = require('../rest'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var rest = require('../src/rest'); var config = new Config('test'); var nobody = auth.nobody(config); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index ccc83525..636311a6 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,6 +1,6 @@ // These tests check that the Schema operates correctly. -var Config = require('../Config'); -var Schema = require('../Schema'); +var Config = require('../src/Config'); +var Schema = require('../src/Schema'); var dd = require('deep-diff'); var config = new Config('test'); @@ -252,7 +252,7 @@ describe('Schema', () => { it('refuses to add fields with invalid pointer types', done => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { - foo: {type: 'Pointer'}, + foo: {type: 'Pointer'} })) .catch(error => { expect(error.code).toEqual(135); @@ -398,7 +398,7 @@ describe('Schema', () => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { geo1: {type: 'GeoPoint'}, - geo2: {type: 'GeoPoint'}, + geo2: {type: 'GeoPoint'} })) .catch(error => { expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); diff --git a/spec/helper.js b/spec/helper.js index cca4d1a5..3e6c6d98 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -2,11 +2,11 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; -var cache = require('../cache'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var cache = require('../src/cache'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var express = require('express'); -var facebook = require('../facebook'); -var ParseServer = require('../index').ParseServer; +var facebook = require('../src/facebook'); +var ParseServer = require('../src/index').ParseServer; var databaseURI = process.env.DATABASE_URI; var cloudMain = process.env.CLOUD_CODE_MAIN || './cloud/main.js'; diff --git a/spec/push.spec.js b/spec/push.spec.js index ba5b533b..a2ea41b5 100644 --- a/spec/push.spec.js +++ b/spec/push.spec.js @@ -1,4 +1,4 @@ -var push = require('../push'); +var push = require('../src/push'); describe('push', () => { it('can check valid master key of request', (done) => { diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 8c7434da..68ac31c9 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1,5 +1,7 @@ +var Parse = require('parse/node').Parse; var request = require('request'); var dd = require('deep-diff'); + var hasAllPODobject = () => { var obj = new Parse.Object('HasAllPOD'); obj.set('aNumber', 5); @@ -14,9 +16,9 @@ var hasAllPODobject = () => { objACL.setPublicWriteAccess(false); obj.setACL(objACL); return obj; -} +}; -var expectedResponseForHasAllPOD = { +var plainOldDataSchema = { className: 'HasAllPOD', fields: { //Default fields @@ -33,10 +35,10 @@ var expectedResponseForHasAllPOD = { aArray: {type: 'Array'}, aGeoPoint: {type: 'GeoPoint'}, aFile: {type: 'File'} - }, + } }; -var expectedResponseforHasPointersAndRelations = { +var pointersAndRelationsSchema = { className: 'HasPointersAndRelations', fields: { //Default fields @@ -56,17 +58,30 @@ var expectedResponseforHasPointersAndRelations = { }, } +var noAuthHeaders = { + 'X-Parse-Application-Id': 'test', +}; + +var restKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', +}; + +var masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', +}; + describe('schemas', () => { it('requires the master key to get all schemas', (done) => { request.get({ url: 'http://localhost:8378/1/schemas', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, + headers: noAuthHeaders, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); + //api.parse.com uses status code 401, but due to the lack of keys + //being necessary in parse-server, 403 makes more sense + expect(response.statusCode).toEqual(403); expect(body.error).toEqual('unauthorized'); done(); }); @@ -76,10 +91,7 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas/SomeSchema', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - }, + headers: restKeyHeaders, }, (error, response, body) => { expect(response.statusCode).toEqual(401); expect(body.error).toEqual('unauthorized'); @@ -87,14 +99,23 @@ describe('schemas', () => { }); }); + it('asks for the master key if you use the rest key', (done) => { + request.get({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: restKeyHeaders, + }, (error, response, body) => { + expect(response.statusCode).toEqual(401); + expect(body.error).toEqual('master key not specified'); + done(); + }); + }); + it('responds with empty list when there are no schemas', done => { request.get({ url: 'http://localhost:8378/1/schemas', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { expect(body.results).toEqual([]); done(); @@ -113,13 +134,10 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { var expected = { - results: [expectedResponseForHasAllPOD,expectedResponseforHasPointersAndRelations] + results: [plainOldDataSchema,pointersAndRelationsSchema] }; expect(body).toEqual(expected); done(); @@ -133,12 +151,9 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas/HasAllPOD', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { - expect(body).toEqual(expectedResponseForHasAllPOD); + expect(body).toEqual(plainOldDataSchema); done(); }); }); @@ -150,10 +165,7 @@ describe('schemas', () => { request.get({ url: 'http://localhost:8378/1/schemas/HASALLPOD', json: true, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test', - }, + headers: masterKeyHeaders, }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ @@ -164,4 +176,146 @@ describe('schemas', () => { }); }); }); + + it('requires the master key to create a schema', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: noAuthHeaders, + body: { + className: 'MyClass', + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized'); + done(); + }); + }); + + it('asks for the master key if you use the rest key', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + json: true, + headers: restKeyHeaders, + body: { + className: 'MyClass', + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(401); + expect(body.error).toEqual('master key not specified'); + done(); + }); + }); + + it('sends an error if you use mismatching class names', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/A', + headers: masterKeyHeaders, + json: true, + body: { + className: 'B', + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body).toEqual({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class name mismatch between B and A', + }); + done(); + }); + }); + + it('sends an error if you use no class name', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: {}, + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body).toEqual({ + code: 135, + error: 'POST /schemas needs class name', + }); + done(); + }) + }); + + it('sends an error if you try to create the same class twice', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: 'A', + }, + }, (error, response, body) => { + expect(error).toEqual(null); + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: 'A', + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body).toEqual({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class A already exists', + }); + done(); + }); + }); + }); + + it('responds with all fields when you create a class', done => { + request.post({ + url: 'http://localhost:8378/1/schemas', + headers: masterKeyHeaders, + json: true, + body: { + className: "NewClass", + fields: { + foo: {type: 'Number'}, + ptr: {type: 'Pointer', targetClass: 'SomeClass'} + } + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + foo: {type: 'Number'}, + ptr: {type: 'Pointer', targetClass: 'SomeClass'}, + } + }); + done(); + }); + }); + + it('lets you specify class name in both places', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + className: "NewClass", + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + } + }); + done(); + }); + }); }); diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index b1aae666..e0347ebf 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -4,7 +4,7 @@ "*spec.js" ], "helpers": [ + "../node_modules/babel-core/register.js", "helper.js" ] } - diff --git a/spec/transform.spec.js b/spec/transform.spec.js index 528c46bf..c7780ffb 100644 --- a/spec/transform.spec.js +++ b/spec/transform.spec.js @@ -1,6 +1,6 @@ // These tests are unit tests designed to only test transform.js. -var transform = require('../transform'); +var transform = require('../src/transform'); var dummySchema = { data: {}, diff --git a/APNS.js b/src/APNS.js similarity index 99% rename from APNS.js rename to src/APNS.js index 5fc73ab0..85c97401 100644 --- a/APNS.js +++ b/src/APNS.js @@ -60,7 +60,7 @@ APNS.prototype.send = function(data, deviceTokens) { var generateNotification = function(coreData, expirationTime) { var notification = new apn.notification(); var payload = {}; - for (key in coreData) { + for (var key in coreData) { switch (key) { case 'alert': notification.setAlertText(coreData.alert); diff --git a/Auth.js b/src/Auth.js similarity index 100% rename from Auth.js rename to src/Auth.js diff --git a/Config.js b/src/Config.js similarity index 100% rename from Config.js rename to src/Config.js diff --git a/DatabaseAdapter.js b/src/DatabaseAdapter.js similarity index 100% rename from DatabaseAdapter.js rename to src/DatabaseAdapter.js diff --git a/ExportAdapter.js b/src/ExportAdapter.js similarity index 96% rename from ExportAdapter.js rename to src/ExportAdapter.js index f8619d5e..139096c9 100644 --- a/ExportAdapter.js +++ b/src/ExportAdapter.js @@ -34,21 +34,8 @@ ExportAdapter.prototype.connect = function() { return this.connectionPromise; } - //http://regexr.com/3cncm - if (!this.mongoURI.match(/^mongodb:\/\/((.+):(.+)@)?([^:@]+):{0,1}([^:]+)\/(.+?)$/gm)) { - throw new Error("Invalid mongoURI: " + this.mongoURI) - } - var usernameStart = this.mongoURI.indexOf('://') + 3; - var lastAtIndex = this.mongoURI.lastIndexOf('@'); - var encodedMongoURI = this.mongoURI; - var split = null; - if (lastAtIndex > 0) { - split = this.mongoURI.slice(usernameStart, lastAtIndex).split(':'); - encodedMongoURI = this.mongoURI.slice(0, usernameStart) + encodeURIComponent(split[0]) + ':' + encodeURIComponent(split[1]) + this.mongoURI.slice(lastAtIndex); - } - this.connectionPromise = Promise.resolve().then(() => { - return MongoClient.connect(encodedMongoURI, {uri_decode_auth:true}); + return MongoClient.connect(this.mongoURI); }).then((db) => { this.db = db; }); diff --git a/FilesAdapter.js b/src/FilesAdapter.js similarity index 100% rename from FilesAdapter.js rename to src/FilesAdapter.js diff --git a/GCM.js b/src/GCM.js similarity index 100% rename from GCM.js rename to src/GCM.js diff --git a/GridStoreAdapter.js b/src/GridStoreAdapter.js similarity index 100% rename from GridStoreAdapter.js rename to src/GridStoreAdapter.js diff --git a/PromiseRouter.js b/src/PromiseRouter.js similarity index 100% rename from PromiseRouter.js rename to src/PromiseRouter.js diff --git a/RestQuery.js b/src/RestQuery.js similarity index 100% rename from RestQuery.js rename to src/RestQuery.js diff --git a/RestWrite.js b/src/RestWrite.js similarity index 100% rename from RestWrite.js rename to src/RestWrite.js diff --git a/S3Adapter.js b/src/S3Adapter.js similarity index 100% rename from S3Adapter.js rename to src/S3Adapter.js diff --git a/Schema.js b/src/Schema.js similarity index 97% rename from Schema.js rename to src/Schema.js index 2715f46a..3656507a 100644 --- a/Schema.js +++ b/src/Schema.js @@ -17,7 +17,7 @@ var Parse = require('parse/node').Parse; var transform = require('./transform'); -defaultColumns = { +var defaultColumns = { // Contain the default columns for every parse object type (except _Join collection) _Default: { "objectId": {type:'String'}, @@ -43,13 +43,13 @@ defaultColumns = { "GCMSenderId": {type:'String'}, "timeZone": {type:'String'}, "localeIdentifier": {type:'String'}, - "badge": {type:'Number'}, + "badge": {type:'Number'} }, // The additional default columns for the _User collection (in addition to DefaultCols) _Role: { "name": {type:'String'}, "users": {type:'Relation',className:'_User'}, - "roles": {type:'Relation',className:'_Role'}, + "roles": {type:'Relation',className:'_Role'} }, // The additional default columns for the _User collection (in addition to DefaultCols) _Session: { @@ -58,12 +58,9 @@ defaultColumns = { "installationId": {type:'String'}, "sessionToken": {type:'String'}, "expiresAt": {type:'Date'}, - "createdWith": {type:'Object'}, - }, - _GlobalConfig: { - "params": {type:'Object'} - }, -} + "createdWith": {type:'Object'} + } +}; // Valid classes must: // Be one of _User, _Installation, _Role, _Session OR @@ -224,7 +221,7 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { error: invalidClassNameMessage(className), }); } - for (fieldName in fields) { + for (var fieldName in fields) { if (!fieldNameIsValid(fieldName)) { return Promise.reject({ code: Parse.Error.INVALID_KEY_NAME, @@ -243,18 +240,18 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { _id: className, objectId: 'string', updatedAt: 'string', - createdAt: 'string', + createdAt: 'string' }; - for (fieldName in defaultColumns[className]) { - validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]); + for (var fieldName in defaultColumns[className]) { + var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]); if (validatedField.code) { return Promise.reject(validatedField); } mongoObject[fieldName] = validatedField.result; } - for (fieldName in fields) { - validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]); + for (var fieldName in fields) { + var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]); if (validatedField.code) { return Promise.reject(validatedField); } @@ -262,7 +259,6 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { } var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint'); - if (geoPoints.length > 1) { return Promise.reject({ code: Parse.Error.INCORRECT_TYPE, @@ -281,7 +277,7 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { } return Promise.reject(error); }); -} +}; // Returns a promise that resolves successfully to the new schema // object or fails with a reason. diff --git a/analytics.js b/src/analytics.js similarity index 100% rename from analytics.js rename to src/analytics.js diff --git a/batch.js b/src/batch.js similarity index 100% rename from batch.js rename to src/batch.js diff --git a/cache.js b/src/cache.js similarity index 100% rename from cache.js rename to src/cache.js diff --git a/classes.js b/src/classes.js similarity index 98% rename from classes.js rename to src/classes.js index 98e94871..f1400914 100644 --- a/classes.js +++ b/src/classes.js @@ -42,7 +42,7 @@ function handleFind(req) { req.params.className, body.where, options) .then((response) => { if (response && response.results) { - for (result of response.results) { + for (var result of response.results) { if (result.sessionToken) { result.sessionToken = req.info.sessionToken || result.sessionToken; } diff --git a/cloud/main.js b/src/cloud/main.js similarity index 100% rename from cloud/main.js rename to src/cloud/main.js diff --git a/facebook.js b/src/facebook.js similarity index 100% rename from facebook.js rename to src/facebook.js diff --git a/files.js b/src/files.js similarity index 100% rename from files.js rename to src/files.js diff --git a/functions.js b/src/functions.js similarity index 100% rename from functions.js rename to src/functions.js diff --git a/httpRequest.js b/src/httpRequest.js similarity index 100% rename from httpRequest.js rename to src/httpRequest.js diff --git a/index.js b/src/index.js similarity index 100% rename from index.js rename to src/index.js diff --git a/installations.js b/src/installations.js similarity index 100% rename from installations.js rename to src/installations.js diff --git a/middlewares.js b/src/middlewares.js similarity index 100% rename from middlewares.js rename to src/middlewares.js diff --git a/password.js b/src/password.js similarity index 100% rename from password.js rename to src/password.js diff --git a/push.js b/src/push.js similarity index 100% rename from push.js rename to src/push.js diff --git a/rest.js b/src/rest.js similarity index 100% rename from rest.js rename to src/rest.js diff --git a/roles.js b/src/roles.js similarity index 100% rename from roles.js rename to src/roles.js diff --git a/schemas.js b/src/schemas.js similarity index 59% rename from schemas.js rename to src/schemas.js index 875967cd..837224ab 100644 --- a/schemas.js +++ b/src/schemas.js @@ -1,7 +1,9 @@ // schemas.js var express = require('express'), - PromiseRouter = require('./PromiseRouter'); + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + Schema = require('./Schema'); var router = new PromiseRouter(); @@ -23,6 +25,7 @@ function mongoFieldTypeToSchemaAPIType(type) { case 'string': return {type: 'String'}; case 'boolean': return {type: 'Boolean'}; case 'date': return {type: 'Date'}; + case 'map': case 'object': return {type: 'Object'}; case 'array': return {type: 'Array'}; case 'geopoint': return {type: 'GeoPoint'}; @@ -31,8 +34,8 @@ function mongoFieldTypeToSchemaAPIType(type) { } function mongoSchemaAPIResponseFields(schema) { - fieldNames = Object.keys(schema).filter(key => key !== '_id'); - response = fieldNames.reduce((obj, fieldName) => { + var fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata'); + var response = fieldNames.reduce((obj, fieldName) => { obj[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName]) return obj; }, {}); @@ -54,7 +57,7 @@ function getAllSchemas(req) { if (!req.auth.isMaster) { return Promise.resolve({ status: 401, - response: {error: 'unauthorized'}, + response: {error: 'master key not specified'}, }); } return req.config.database.collection('_SCHEMA') @@ -83,7 +86,46 @@ function getOneSchema(req) { })); } +function createSchema(req) { + if (!req.auth.isMaster) { + return Promise.resolve({ + status: 401, + response: {error: 'master key not specified'}, + }); + } + if (req.params.className && req.body.className) { + if (req.params.className != req.body.className) { + return Promise.resolve({ + status: 400, + response: { + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class name mismatch between ' + req.body.className + ' and ' + req.params.className, + }, + }); + } + } + var className = req.params.className || req.body.className; + if (!className) { + return Promise.resolve({ + status: 400, + response: { + code: 135, + error: 'POST ' + req.path + ' needs class name', + }, + }); + } + return req.config.database.loadSchema() + .then(schema => schema.addClassIfNotExists(className, req.body.fields)) + .then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) })) + .catch(error => ({ + status: 400, + response: error, + })); +} + router.route('GET', '/schemas', getAllSchemas); router.route('GET', '/schemas/:className', getOneSchema); +router.route('POST', '/schemas', createSchema); +router.route('POST', '/schemas/:className', createSchema); module.exports = router; diff --git a/sessions.js b/src/sessions.js similarity index 100% rename from sessions.js rename to src/sessions.js diff --git a/testing-routes.js b/src/testing-routes.js similarity index 100% rename from testing-routes.js rename to src/testing-routes.js diff --git a/transform.js b/src/transform.js similarity index 99% rename from transform.js rename to src/transform.js index 051bc75a..802bf075 100644 --- a/transform.js +++ b/src/transform.js @@ -21,7 +21,7 @@ var Parse = require('parse/node').Parse; // validate: true indicates that key names are to be validated. // // Returns an object with {key: key, value: value}. -function transformKeyValue(schema, className, restKey, restValue, options) { +export function transformKeyValue(schema, className, restKey, restValue, options) { options = options || {}; // Check if the schema is known since it's a built-in field. @@ -126,7 +126,7 @@ function transformKeyValue(schema, className, restKey, restValue, options) { if (inArray && options.query && !(restValue instanceof Array)) { return { - key: key, value: [restValue] + key: key, value: { '$all' : [restValue] } }; } diff --git a/triggers.js b/src/triggers.js similarity index 100% rename from triggers.js rename to src/triggers.js diff --git a/users.js b/src/users.js similarity index 100% rename from users.js rename to src/users.js From 11082b15b479868c44b310e29345ff25f3dda1c9 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Tue, 9 Feb 2016 11:06:12 +0100 Subject: [PATCH 30/84] Move global_config to new src/ location --- global_config.js => src/global_config.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename global_config.js => src/global_config.js (100%) diff --git a/global_config.js b/src/global_config.js similarity index 100% rename from global_config.js rename to src/global_config.js From bd4546f444232e2fdf014b4809753142f964c4c5 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Tue, 9 Feb 2016 11:10:40 +0100 Subject: [PATCH 31/84] Update error message --- src/global_config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/global_config.js b/src/global_config.js index 773b2597..9d032d7d 100644 --- a/src/global_config.js +++ b/src/global_config.js @@ -21,7 +21,7 @@ function updateGlobalConfig(req) { .catch(() => ({ status: 404, response: { - code: 103, + code: Parse.Error.INVALID_KEY_NAME, error: 'config cannot be updated', } })); @@ -34,7 +34,7 @@ function getGlobalConfig(req) { .catch(() => ({ status: 404, response: { - code: 103, + code: Parse.Error.INVALID_KEY_NAME, error: 'config does not exist', } })); From 84b35eab466acce8d3f40061d9936bd5b31ad242 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Tue, 9 Feb 2016 11:28:54 +0100 Subject: [PATCH 32/84] Fixed reference after source move --- spec/ParseGlobalConfig.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 1258ae99..ff1286e2 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -1,6 +1,6 @@ var request = require('request'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var database = DatabaseAdapter.getDatabaseConnection('test'); From f039b70cf5880ca38edd34210b1879595bf9befd Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Tue, 9 Feb 2016 12:32:00 +0100 Subject: [PATCH 33/84] Add test for uncovered cases --- spec/ParseGlobalConfig.spec.js | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index ff1286e2..78364c6a 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -1,5 +1,6 @@ var request = require('request'); +var Parse = require('parse/node').Parse; var DatabaseAdapter = require('../src/DatabaseAdapter'); var database = DatabaseAdapter.getDatabaseConnection('test'); @@ -58,4 +59,43 @@ describe('a GlobalConfig', () => { }); }); + it('failed getting config when it is missing', (done) => { + database.rawCollection('_GlobalConfig') + .then(coll => coll.deleteOne({ '_id': 1}, {}, {})) + .then(_ => { + request.get({ + url: 'http://localhost:8378/1/config', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(404); + expect(body.code).toEqual(Parse.Error.INVALID_KEY_NAME); + done(); + }); + }); + }); + + it('failed updating config when it is missing', (done) => { + database.rawCollection('_GlobalConfig') + .then(coll => coll.deleteOne({ '_id': 1}, {}, {})) + .then(_ => { + request.post({ + url: 'http://localhost:8378/1/config', + json: true, + body: { params: { companies: ['US', 'DK', 'SE'] } }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test' + }, + }, (error, response, body) => { + expect(response.statusCode).toEqual(404); + expect(body.code).toEqual(Parse.Error.INVALID_KEY_NAME); + done(); + }); + }); + }); + }); From 18f9e5340563833cebab333ee6205d5c8ac6413a Mon Sep 17 00:00:00 2001 From: Alexander Mays Date: Tue, 9 Feb 2016 06:36:36 -0500 Subject: [PATCH 34/84] Added a dev run script Signed-off-by: Alexander Mays --- bin/dev | 26 ++++++++++++++++++++++++++ package.json | 5 ++++- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 bin/dev diff --git a/bin/dev b/bin/dev new file mode 100644 index 00000000..deff0635 --- /dev/null +++ b/bin/dev @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +var nodemon = require('nodemon'); +var babel = require("babel-core"); +var gaze = require('gaze'); +var fs = require('fs'); + +// Watch the src and transpile when changed +gaze('src/**/*', function(err, watcher) { + if (err) throw err; + watcher.on('changed', function(file) { + console.log(file + " has changed"); + fs.writeFile(file.replace(/\/src\//, "/lib/"), babel.transformFileSync(file).code); + }); +}); + +// Run and watch dist +nodemon({ + script: 'bin/parse-server', + ext: 'js json', + watch: 'lib' +}); + +process.once('SIGINT', function() { + process.exit(0); +}); \ No newline at end of file diff --git a/package.json b/package.json index 689110c0..05470b15 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,13 @@ "babel-register": "^6.5.1", "codecov": "^1.0.1", "deep-diff": "^0.3.3", + "gaze": "^0.5.2", "jasmine": "^2.3.2", - "mongodb-runner": "^3.1.15" + "mongodb-runner": "^3.1.15", + "nodemon": "^1.8.1" }, "scripts": { + "dev": "npm run build && node bin/dev", "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} mongodb-runner start", "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/.bin/babel-istanbul cover -x **/spec/** ./node_modules/.bin/jasmine", From 854185ecd591197fb4dc3152b6788d1f6b9097bc Mon Sep 17 00:00:00 2001 From: Alexander Mays Date: Tue, 9 Feb 2016 07:02:57 -0500 Subject: [PATCH 35/84] Added error handling so the process stays alive whenever transpiling fails Signed-off-by: Alexander Mays --- bin/dev | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bin/dev b/bin/dev index deff0635..6230106c 100644 --- a/bin/dev +++ b/bin/dev @@ -10,16 +10,24 @@ gaze('src/**/*', function(err, watcher) { if (err) throw err; watcher.on('changed', function(file) { console.log(file + " has changed"); - fs.writeFile(file.replace(/\/src\//, "/lib/"), babel.transformFileSync(file).code); + try { + fs.writeFile(file.replace(/\/src\//, "/lib/"), babel.transformFileSync(file).code); + } catch (e) { + console.error(e.message, e.stack); + } }); }); -// Run and watch dist -nodemon({ - script: 'bin/parse-server', - ext: 'js json', - watch: 'lib' -}); +try { + // Run and watch dist + nodemon({ + script: 'bin/parse-server', + ext: 'js json', + watch: 'lib' + }); +} catch (e) { + console.error(e.message, e.stack); +} process.once('SIGINT', function() { process.exit(0); From e7e9a536769586f9102171e5bd5b6bde4b750c21 Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Tue, 9 Feb 2016 13:12:09 +0000 Subject: [PATCH 36/84] allow environment variables to be set on Windows --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 689110c0..b2f59c39 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,15 @@ "babel-preset-es2015": "^6.5.0", "babel-register": "^6.5.1", "codecov": "^1.0.1", + "cross-env": "^1.0.7", "deep-diff": "^0.3.3", "jasmine": "^2.3.2", "mongodb-runner": "^3.1.15" }, "scripts": { "build": "./node_modules/.bin/babel src/ -d lib/", - "pretest": "MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} mongodb-runner start", - "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/.bin/babel-istanbul cover -x **/spec/** ./node_modules/.bin/jasmine", + "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", + "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/.bin/babel-istanbul cover -x **/spec/** ./node_modules/.bin/jasmine", "posttest": "mongodb-runner stop", "start": "./bin/parse-server", "prepublish": "npm run build" From d0427b7b8a46b8411bdfc48e369fc10c3d14eed3 Mon Sep 17 00:00:00 2001 From: Alexander Mays Date: Tue, 9 Feb 2016 12:49:18 -0500 Subject: [PATCH 37/84] Make bin/dev executable Signed-off-by: Alexander Mays --- bin/dev | 0 package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 bin/dev diff --git a/bin/dev b/bin/dev old mode 100644 new mode 100755 diff --git a/package.json b/package.json index 05470b15..e3d5214b 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "nodemon": "^1.8.1" }, "scripts": { - "dev": "npm run build && node bin/dev", + "dev": "npm run build && bin/dev", "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} mongodb-runner start", "test": "NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/.bin/babel-istanbul cover -x **/spec/** ./node_modules/.bin/jasmine", From c4be51d7348e8c71add3373d6688870b2ecc5a58 Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Thu, 4 Feb 2016 19:18:33 -0800 Subject: [PATCH 38/84] Removed extra /logout handler --- src/sessions.js | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/sessions.js b/src/sessions.js index 30290a9d..b979de45 100644 --- a/src/sessions.js +++ b/src/sessions.js @@ -41,29 +41,6 @@ function handleGet(req) { }); } -function handleLogout(req) { - // TODO: Verify correct behavior for logout without token - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'Session token required for logout.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - { _session_token: req.info.sessionToken}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token not found.'); - } - return rest.del(req.config, Auth.master(req.config), '_Session', - response.results[0].objectId); - }).then(() => { - return { - status: 200, - response: {} - }; - }); -} - function handleFind(req) { var options = {}; if (req.body.skip) { @@ -111,7 +88,6 @@ function handleMe(req) { }); } -router.route('POST', '/logout', handleLogout); router.route('POST','/sessions', handleCreate); router.route('GET','/sessions/me', handleMe); router.route('GET','/sessions/:objectId', handleGet); @@ -119,4 +95,4 @@ router.route('PUT','/sessions/:objectId', handleUpdate); router.route('GET','/sessions', handleFind); router.route('DELETE','/sessions/:objectId', handleDelete); -module.exports = router; \ No newline at end of file +module.exports = router; From 4f128d761ea158bc684ccace237f085d56930c28 Mon Sep 17 00:00:00 2001 From: ksaldana1 Date: Tue, 9 Feb 2016 15:47:13 -0600 Subject: [PATCH 39/84] Implemented ES6 default parameters where valid in ExportAdapter and RestQuery --- src/ExportAdapter.js | 15 +++++---------- src/RestQuery.js | 5 ++--- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/ExportAdapter.js b/src/ExportAdapter.js index c21042fb..1676ccfb 100644 --- a/src/ExportAdapter.js +++ b/src/ExportAdapter.js @@ -10,9 +10,8 @@ var transform = require('./transform'); // options can contain: // collectionPrefix: the string to put in front of every collection name. -function ExportAdapter(mongoURI, options) { +function ExportAdapter(mongoURI, options = {}) { this.mongoURI = mongoURI; - options = options || {}; this.collectionPrefix = options.collectionPrefix; @@ -63,8 +62,7 @@ function returnsTrue() { // Returns a promise for a schema object. // If we are provided a acceptor, then we run it on the schema. // If the schema isn't accepted, we reload it at most once. -ExportAdapter.prototype.loadSchema = function(acceptor) { - acceptor = acceptor || returnsTrue; +ExportAdapter.prototype.loadSchema = function(acceptor = returnsTrue) { if (!this.schemaPromise) { this.schemaPromise = this.collection('_SCHEMA').then((coll) => { @@ -277,8 +275,7 @@ ExportAdapter.prototype.removeRelation = function(key, fromClassName, // acl: a list of strings. If the object to be updated has an ACL, // one of the provided strings must provide the caller with // write permissions. -ExportAdapter.prototype.destroy = function(className, query, options) { - options = options || {}; +ExportAdapter.prototype.destroy = function(className, query, options = {}) { var isMaster = !('acl' in options); var aclGroup = options.acl || []; @@ -346,8 +343,7 @@ ExportAdapter.prototype.create = function(className, object, options) { // This should only be used for testing - use 'find' for normal code // to avoid Mongo-format dependencies. // Returns a promise that resolves to a list of items. -ExportAdapter.prototype.mongoFind = function(className, query, options) { - options = options || {}; +ExportAdapter.prototype.mongoFind = function(className, query, options = {}) { return this.collection(className).then((coll) => { return coll.find(query, options).toArray(); }); @@ -503,8 +499,7 @@ ExportAdapter.prototype.smartFind = function(coll, where, options) { // TODO: make userIds not needed here. The db adapter shouldn't know // anything about users, ideally. Then, improve the format of the ACL // arg to work like the others. -ExportAdapter.prototype.find = function(className, query, options) { - options = options || {}; +ExportAdapter.prototype.find = function(className, query, options = {}) { var mongoOptions = {}; if (options.skip) { mongoOptions.skip = options.skip; diff --git a/src/RestQuery.js b/src/RestQuery.js index 8c9bf712..d677e2dd 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -11,13 +11,12 @@ var Parse = require('parse/node').Parse; // include // keys // redirectClassNameForKey -function RestQuery(config, auth, className, restWhere, restOptions) { - restOptions = restOptions || {}; +function RestQuery(config, auth, className, restWhere = {}, restOptions = {}) { this.config = config; this.auth = auth; this.className = className; - this.restWhere = restWhere || {}; + this.restWhere = restWhere; this.response = null; this.findOptions = {}; From 53b2d4e1761cf5abbbbd2b3d465200263712281b Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 9 Feb 2016 12:53:02 -0800 Subject: [PATCH 40/84] Refactor files.js into FilesController. --- src/Controllers/FilesController.js | 97 ++++++++++++++++++++++++++++++ src/FilesAdapter.js | 12 +--- src/files.js | 85 -------------------------- src/index.js | 13 ++-- 4 files changed, 105 insertions(+), 102 deletions(-) create mode 100644 src/Controllers/FilesController.js delete mode 100644 src/files.js diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js new file mode 100644 index 00000000..4a929a6e --- /dev/null +++ b/src/Controllers/FilesController.js @@ -0,0 +1,97 @@ +// FilesController.js + +import express from 'express'; +import mime from 'mime'; +import { Parse } from 'parse/node'; +import BodyParser from 'body-parser'; +import hat from 'hat'; +import * as Middlewares from '../middlewares'; +import Config from '../Config'; + +const rack = hat.rack(); + +export class FilesController { + constructor(filesAdapter) { + this._filesAdapter = filesAdapter; + } + + getHandler() { + return (req, res) => { + let config = new Config(req.params.appId); + this._filesAdapter.getFileDataAsync(config, req.params.filename).then((data) => { + res.status(200); + var contentType = mime.lookup(req.params.filename); + res.set('Content-type', contentType); + res.end(data); + }).catch((error) => { + res.status(404); + res.set('Content-type', 'text/plain'); + res.end('File not found.'); + }); + }; + } + + createHandler() { + return (req, res, next) => { + if (!req.body || !req.body.length) { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, + 'Invalid file upload.')); + return; + } + + if (req.params.filename.length > 128) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename too long.')); + return; + } + + if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename contains invalid characters.')); + return; + } + + // If a content-type is included, we'll add an extension so we can + // return the same content-type. + let extension = ''; + let hasExtension = req.params.filename.indexOf('.') > 0; + let contentType = req.get('Content-type'); + if (!hasExtension && contentType && mime.extension(contentType)) { + extension = '.' + mime.extension(contentType); + } + + let filename = rack() + '_' + req.params.filename + extension; + this._filesAdapter.createFileAsync(req.config, filename, req.body).then(() => { + res.status(201); + var location = this._filesAdapter.getFileLocation(req.config, req, filename); + res.set('Location', location); + res.json({ url: location, name: filename }); + }).catch((error) => { + console.log(error); + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, + 'Could not store file.')); + }); + }; + } + + getExpressRouter() { + let router = express.Router(); + router.get('/files/:appId/:filename', this.getHandler()); + + router.post('/files', function(req, res, next) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename not provided.')); + }); + + router.post('/files/:filename', + Middlewares.allowCrossDomain, + BodyParser.raw({type: '*/*', limit: '20mb'}), + Middlewares.handleParseHeaders, + this.createHandler() + ); + + return router; + } +} + +export default FilesController; diff --git a/src/FilesAdapter.js b/src/FilesAdapter.js index 94fd2bb8..62fe0701 100644 --- a/src/FilesAdapter.js +++ b/src/FilesAdapter.js @@ -11,16 +11,6 @@ // and for the API server to be using the ExportAdapter // database adapter. -let adapter = null; - -export function setAdapter(filesAdapter) { - adapter = filesAdapter; -} - -export function getAdapter() { - return adapter; -} - export class FilesAdapter { createFileAsync(config, filename, data) { } @@ -28,3 +18,5 @@ export class FilesAdapter { getFileLocation(config, request, filename) { } } + +export default FilesAdapter; diff --git a/src/files.js b/src/files.js deleted file mode 100644 index 86cdbfbe..00000000 --- a/src/files.js +++ /dev/null @@ -1,85 +0,0 @@ -// files.js - -var bodyParser = require('body-parser'), - Config = require('./Config'), - express = require('express'), - middlewares = require('./middlewares.js'), - mime = require('mime'), - Parse = require('parse/node').Parse, - rack = require('hat').rack(); - -import { getAdapter as getFilesAdapter } from './FilesAdapter'; - -var router = express.Router(); - -var processCreate = function(req, res, next) { - if (!req.body || !req.body.length) { - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, - 'Invalid file upload.')); - return; - } - - if (req.params.filename.length > 128) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename too long.')); - return; - } - - if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename contains invalid characters.')); - return; - } - - // If a content-type is included, we'll add an extension so we can - // return the same content-type. - var extension = ''; - var hasExtension = req.params.filename.indexOf('.') > 0; - var contentType = req.get('Content-type'); - if (!hasExtension && contentType && mime.extension(contentType)) { - extension = '.' + mime.extension(contentType); - } - - var filename = rack() + '_' + req.params.filename + extension; - getFilesAdapter().createFileAsync(req.config, filename, req.body).then(() => { - res.status(201); - var location = getFilesAdapter().getFileLocation(req.config, req, filename); - res.set('Location', location); - res.json({ url: location, name: filename }); - }).catch((error) => { - console.log(error); - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, - 'Could not store file.')); - }); -}; - -var processGet = function(req, res) { - var config = new Config(req.params.appId); - getFilesAdapter().getFileDataAsync(config, req.params.filename).then((data) => { - res.status(200); - var contentType = mime.lookup(req.params.filename); - res.set('Content-type', contentType); - res.end(data); - }).catch((error) => { - res.status(404); - res.set('Content-type', 'text/plain'); - res.end('File not found.'); - }); -}; - -router.get('/files/:appId/:filename', processGet); - -router.post('/files', function(req, res, next) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename not provided.')); -}); - -router.post('/files/:filename', - middlewares.allowCrossDomain, - bodyParser.raw({type: '*/*', limit: '20mb'}), - middlewares.handleParseHeaders, - processCreate); - -module.exports = { - router: router -}; diff --git a/src/index.js b/src/index.js index 48d3e8c9..93d363f7 100644 --- a/src/index.js +++ b/src/index.js @@ -12,8 +12,8 @@ var batch = require('./batch'), PromiseRouter = require('./PromiseRouter'), httpRequest = require('./httpRequest'); -import { setAdapter as setFilesAdapter } from './FilesAdapter'; import { default as GridStoreAdapter } from './GridStoreAdapter'; +import { default as FilesController } from './Controllers/FilesController'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -48,11 +48,9 @@ function ParseServer(args) { if (args.databaseAdapter) { DatabaseAdapter.setAdapter(args.databaseAdapter); } - if (args.filesAdapter) { - setFilesAdapter(args.filesAdapter); - } else { - setFilesAdapter(new GridStoreAdapter()); - } + + let filesAdapter = args.filesAdapter || new GridStoreAdapter(); + if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } @@ -95,7 +93,8 @@ function ParseServer(args) { var api = express(); // File handling needs to be before default middlewares are applied - api.use('/', require('./files').router); + let filesController = new FilesController(filesAdapter); + api.use('/', filesController.getExpressRouter()); // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { From 93219a21f2a7f2c06ae1c0446f540c5e3eaf9dc0 Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Tue, 9 Feb 2016 22:46:47 +0000 Subject: [PATCH 41/84] Add non-breaking test --- spec/RestCreate.spec.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index 24455507..a8b7991e 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -57,6 +57,23 @@ describe('rest create', () => { }); }); + it('handles anonymous user signup', (done) => { + var data = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000000' + } + } + }; + rest.create(config, auth.nobody(config), '_User', data) + .then((r) => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + done(); + }); + }); + it('test facebook signup and login', (done) => { var data = { authData: { From ab12ff76b89558095688e38054681e9e23412544 Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Tue, 9 Feb 2016 15:55:43 -0800 Subject: [PATCH 42/84] Updated logout handling per review. --- src/users.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/users.js b/src/users.js index d769b9c5..a33c32a7 100644 --- a/src/users.js +++ b/src/users.js @@ -167,14 +167,17 @@ function handleDelete(req) { function handleLogOut(req) { var success = {response: {}}; if (req.info && req.info.sessionToken) { - rest.find(req.config, Auth.master(req.config), '_Session', + return rest.find(req.config, Auth.master(req.config), '_Session', {_session_token: req.info.sessionToken} ).then((records) => { if (records.results && records.results.length) { - rest.del(req.config, Auth.master(req.config), '_Session', - records.results[0].id - ); + return rest.del(req.config, Auth.master(req.config), '_Session', + records.results[0].objectId + ).then(() => { + return Promise.resolve(success); + }); } + return Promise.resolve(success); }); } return Promise.resolve(success); From 8ca25cbabe1de7665b7d1cc3f1be2cde19ef2b21 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 9 Feb 2016 19:31:23 -0800 Subject: [PATCH 43/84] Moved getting the url for every file from RestQuery into FilesController. --- src/Config.js | 5 +- src/Controllers/FilesController.js | 37 ++++++++++++-- src/FilesAdapter.js | 2 +- src/GridStoreAdapter.js | 8 +--- src/RestQuery.js | 77 ++++++++++-------------------- src/index.js | 6 ++- 6 files changed, 69 insertions(+), 66 deletions(-) diff --git a/src/Config.js b/src/Config.js index df44f8b1..06d7af94 100644 --- a/src/Config.js +++ b/src/Config.js @@ -13,7 +13,6 @@ function Config(applicationId, mount) { this.applicationId = applicationId; this.collectionPrefix = cacheInfo.collectionPrefix || ''; - this.database = DatabaseAdapter.getDatabaseConnection(applicationId); this.masterKey = cacheInfo.masterKey; this.clientKey = cacheInfo.clientKey; this.javascriptKey = cacheInfo.javascriptKey; @@ -21,6 +20,10 @@ function Config(applicationId, mount) { this.restAPIKey = cacheInfo.restAPIKey; this.fileKey = cacheInfo.fileKey; this.facebookAppIds = cacheInfo.facebookAppIds; + + this.database = DatabaseAdapter.getDatabaseConnection(applicationId); + this.filesController = cacheInfo.filesController; + this.mount = mount; } diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 4a929a6e..a22e2572 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -18,9 +18,10 @@ export class FilesController { getHandler() { return (req, res) => { let config = new Config(req.params.appId); - this._filesAdapter.getFileDataAsync(config, req.params.filename).then((data) => { + let filename = req.params.filename; + this._filesAdapter.getFileDataAsync(config, filename).then((data) => { res.status(200); - var contentType = mime.lookup(req.params.filename); + var contentType = mime.lookup(filename); res.set('Content-type', contentType); res.end(data); }).catch((error) => { @@ -63,17 +64,45 @@ export class FilesController { let filename = rack() + '_' + req.params.filename + extension; this._filesAdapter.createFileAsync(req.config, filename, req.body).then(() => { res.status(201); - var location = this._filesAdapter.getFileLocation(req.config, req, filename); + var location = this._filesAdapter.getFileLocation(req.config, filename); res.set('Location', location); res.json({ url: location, name: filename }); }).catch((error) => { - console.log(error); next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, 'Could not store file.')); }); }; } + /** + * Find file references in REST-format object and adds the url key + * with the current mount point and app id. + * Object may be a single object or list of REST-format objects. + */ + expandFilesInObject(config, object) { + if (object instanceof Array) { + object.map((obj) => this.expandFilesInObject(config, obj)); + return; + } + if (typeof object !== 'object') { + return; + } + for (let key in object) { + let fileObject = object[key]; + if (fileObject && fileObject['__type'] === 'File') { + if (fileObject['url']) { + continue; + } + let filename = fileObject['name']; + if (filename.indexOf('tfss-') === 0) { + fileObject['url'] = 'http://files.parsetfss.com/' + config.fileKey + '/' + encodeURIComponent(filename); + } else { + fileObject['url'] = this._filesAdapter.getFileLocation(config, filename); + } + } + } + } + getExpressRouter() { let router = express.Router(); router.get('/files/:appId/:filename', this.getHandler()); diff --git a/src/FilesAdapter.js b/src/FilesAdapter.js index 62fe0701..dbfc923c 100644 --- a/src/FilesAdapter.js +++ b/src/FilesAdapter.js @@ -16,7 +16,7 @@ export class FilesAdapter { getFileDataAsync(config, filename) { } - getFileLocation(config, request, filename) { } + getFileLocation(config, filename) { } } export default FilesAdapter; diff --git a/src/GridStoreAdapter.js b/src/GridStoreAdapter.js index 161515c6..d221b021 100644 --- a/src/GridStoreAdapter.js +++ b/src/GridStoreAdapter.js @@ -4,8 +4,6 @@ // Requires the database adapter to be based on mongoclient import { GridStore } from 'mongodb'; - -import * as Path from 'path'; import { FilesAdapter } from './FilesAdapter'; class GridStoreAdapter extends FilesAdapter { @@ -33,10 +31,8 @@ class GridStoreAdapter extends FilesAdapter { }); } - getFileLocation(config, request, filename) { - return (request.protocol + '://' + request.get('host') + - Path.dirname(request.originalUrl) + '/' + config.applicationId + - '/' + encodeURIComponent(filename)); + getFileLocation(config, filename) { + return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); } } diff --git a/src/RestQuery.js b/src/RestQuery.js index d677e2dd..91ebe536 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -3,6 +3,8 @@ var Parse = require('parse/node').Parse; +import { default as FilesController } from './Controllers/FilesController'; + // restOptions can include: // skip // limit @@ -316,35 +318,35 @@ RestQuery.prototype.replaceDontSelect = function() { RestQuery.prototype.runFind = function() { return this.config.database.find( this.className, this.restWhere, this.findOptions).then((results) => { - if (this.className == '_User') { - for (var result of results) { - delete result.password; - } + if (this.className == '_User') { + for (var result of results) { + delete result.password; } + } - updateParseFiles(this.config, results); + this.config.filesController.expandFilesInObject(this.config, results); - if (this.keys) { - var keySet = this.keys; - results = results.map((object) => { - var newObject = {}; - for (var key in object) { - if (keySet.has(key)) { - newObject[key] = object[key]; - } + if (this.keys) { + var keySet = this.keys; + results = results.map((object) => { + var newObject = {}; + for (var key in object) { + if (keySet.has(key)) { + newObject[key] = object[key]; } - return newObject; - }); - } - - if (this.redirectClassName) { - for (var r of results) { - r.className = this.redirectClassName; } - } + return newObject; + }); + } - this.response = {results: results}; - }); + if (this.redirectClassName) { + for (var r of results) { + r.className = this.redirectClassName; + } + } + + this.response = {results: results}; + }); }; // Returns a promise for whether it was successful. @@ -497,35 +499,6 @@ function replacePointers(object, path, replace) { return answer; } -// Find file references in REST-format object and adds the url key -// with the current mount point and app id -// Object may be a single object or list of REST-format objects -function updateParseFiles(config, object) { - if (object instanceof Array) { - object.map((obj) => updateParseFiles(config, obj)); - return; - } - if (typeof object !== 'object') { - return; - } - for (var key in object) { - if (object[key] && object[key]['__type'] && - object[key]['__type'] == 'File') { - var filename = object[key]['name']; - var encoded = encodeURIComponent(filename); - encoded = encoded.replace('%40', '@'); - if (filename.indexOf('tfss-') === 0) { - object[key]['url'] = 'http://files.parsetfss.com/' + - config.fileKey + '/' + encoded; - } else { - object[key]['url'] = config.mount + '/files/' + - config.applicationId + '/' + - encoded; - } - } - } -} - // Finds a subobject that has the given key, if there is one. // Returns undefined otherwise. function findObjectWithKey(root, key) { diff --git a/src/index.js b/src/index.js index 93d363f7..d685e79f 100644 --- a/src/index.js +++ b/src/index.js @@ -66,6 +66,8 @@ function ParseServer(args) { } + let filesController = new FilesController(filesAdapter); + cache.apps[args.appId] = { masterKey: args.masterKey, collectionPrefix: args.collectionPrefix || '', @@ -74,7 +76,8 @@ function ParseServer(args) { dotNetKey: args.dotNetKey || '', restAPIKey: args.restAPIKey || '', fileKey: args.fileKey || 'invalid-file-key', - facebookAppIds: args.facebookAppIds || [] + facebookAppIds: args.facebookAppIds || [], + filesController: filesController }; // To maintain compatibility. TODO: Remove in v2.1 @@ -93,7 +96,6 @@ function ParseServer(args) { var api = express(); // File handling needs to be before default middlewares are applied - let filesController = new FilesController(filesAdapter); api.use('/', filesController.getExpressRouter()); // TODO: separate this from the regular ParseServer object From 053ac990e69406f5291e8b2328e3205327380cd4 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 9 Feb 2016 19:31:50 -0800 Subject: [PATCH 44/84] Fixed missing url for files on user login. --- spec/ParseUser.spec.js | 16 ++++++++++++++++ src/users.js | 2 ++ 2 files changed, 18 insertions(+) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index c9f25bd8..6c7ec26b 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -64,6 +64,22 @@ describe('Parse.User testing', () => { }); }); + it("user login with files", (done) => { + "use strict"; + + let file = new Parse.File("yolo.txt", [1,2,3], "text/plain"); + file.save().then((file) => { + return Parse.User.signUp("asdf", "zxcv", { "file" : file }); + }).then(() => { + return Parse.User.logIn("asdf", "zxcv"); + }).then((user) => { + let fileAgain = user.get('file'); + ok(fileAgain.name()); + ok(fileAgain.url()); + done(); + }); + }); + it("become", (done) => { var user = null; var sessionToken = null; diff --git a/src/users.js b/src/users.js index d769b9c5..5f0e01e7 100644 --- a/src/users.js +++ b/src/users.js @@ -58,6 +58,8 @@ function handleLogIn(req) { user.sessionToken = token; delete user.password; + req.config.filesController.expandFilesInObject(req.config, user); + var expiresAt = new Date(); expiresAt.setFullYear(expiresAt.getFullYear() + 1); From 07c9c1d648df650f9fb1a79d4756e265ed1c2b24 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 9 Feb 2016 19:52:25 -0800 Subject: [PATCH 45/84] Cleanup and modernize S3Adapter to ES6 syntax. --- src/{ => Adapters/Files}/FilesAdapter.js | 8 +- src/{ => Adapters/Files}/GridStoreAdapter.js | 6 +- src/Adapters/Files/S3Adapter.js | 83 ++++++++++++++++++++ src/Controllers/FilesController.js | 4 +- src/S3Adapter.js | 77 ------------------ src/index.js | 7 +- 6 files changed, 96 insertions(+), 89 deletions(-) rename src/{ => Adapters/Files}/FilesAdapter.js (70%) rename src/{ => Adapters/Files}/GridStoreAdapter.js (89%) create mode 100644 src/Adapters/Files/S3Adapter.js delete mode 100644 src/S3Adapter.js diff --git a/src/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js similarity index 70% rename from src/FilesAdapter.js rename to src/Adapters/Files/FilesAdapter.js index dbfc923c..9daed517 100644 --- a/src/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -3,8 +3,8 @@ // Allows you to change the file storage mechanism. // // Adapter classes must implement the following functions: -// * createFileAsync(config, filename, data) -// * getFileDataAsync(config, filename) +// * createFile(config, filename, data) +// * getFileData(config, filename) // * getFileLocation(config, request, filename) // // Default is GridStoreAdapter, which requires mongo @@ -12,9 +12,9 @@ // database adapter. export class FilesAdapter { - createFileAsync(config, filename, data) { } + createFile(config, filename, data) { } - getFileDataAsync(config, filename) { } + getFileData(config, filename) { } getFileLocation(config, filename) { } } diff --git a/src/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js similarity index 89% rename from src/GridStoreAdapter.js rename to src/Adapters/Files/GridStoreAdapter.js index d221b021..8c95319d 100644 --- a/src/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -6,10 +6,10 @@ import { GridStore } from 'mongodb'; import { FilesAdapter } from './FilesAdapter'; -class GridStoreAdapter extends FilesAdapter { +export class GridStoreAdapter extends FilesAdapter { // For a given config object, filename, and data, store a file // Returns a promise - createFileAsync(config, filename, data) { + createFile(config, filename, data) { return config.database.connect().then(() => { let gridStore = new GridStore(config.database.db, filename, 'w'); return gridStore.open(); @@ -20,7 +20,7 @@ class GridStoreAdapter extends FilesAdapter { }); } - getFileDataAsync(config, filename) { + getFileData(config, filename) { return config.database.connect().then(() => { return GridStore.exist(config.database.db, filename); }).then(() => { diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js new file mode 100644 index 00000000..2c892246 --- /dev/null +++ b/src/Adapters/Files/S3Adapter.js @@ -0,0 +1,83 @@ +// S3Adapter +// +// Stores Parse files in AWS S3. + +import * as AWS from 'aws-sdk'; +import { FilesAdapter } from './FilesAdapter'; + +const DEFAULT_S3_REGION = "us-east-1"; +const DEFAULT_S3_BUCKET = "parse-files"; + +export class S3Adapter extends FilesAdapter { + // Creates an S3 session. + // Providing AWS access and secret keys is mandatory + // Region and bucket will use sane defaults if omitted + constructor( + accessKey, + secretKey, + { region = DEFAULT_S3_REGION, + bucket = DEFAULT_S3_BUCKET, + bucketPrefix = '', + directAccess = false } = {} + ) { + super(); + + this._region = region; + this._bucket = bucket; + this._bucketPrefix = bucketPrefix; + this._directAccess = directAccess; + + let s3Options = { + accessKeyId: accessKey, + secretAccessKey: secretKey, + params: { Bucket: this._bucket } + }; + AWS.config._region = this._region; + this._s3Client = new AWS.S3(s3Options); + } + + // For a given config object, filename, and data, store a file in S3 + // Returns a promise containing the S3 object creation response + createFile(config, filename, data) { + let params = { + Key: this._bucketPrefix + filename, + Body: data + }; + if (this._directAccess) { + params.ACL = "public-read" + } + return new Promise((resolve, reject) => { + this._s3Client.upload(params, (err, data) => { + if (err !== null) { + return reject(err); + } + resolve(data); + }); + }); + } + + // Search for and return a file if found by filename + // Returns a promise that succeeds with the buffer result from S3 + getFileData(config, filename) { + let params = {Key: this._bucketPrefix + filename}; + return new Promise((resolve, reject) => { + this._s3Client.getObject(params, (err, data) => { + if (err !== null) { + return reject(err); + } + resolve(data.Body); + }); + }); + } + + // Generates and returns the location of a file stored in S3 for the given request and filename + // The location is the direct S3 link if the option is set, otherwise we serve the file through parse-server + getFileLocation(config, filename) { + if (this._directAccess) { + return ('https://' + this.bucket + '._s3Client.amazonaws.com' + '/' + this._bucketPrefix + filename); + } + return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); + } +} + +export default S3Adapter; diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index a22e2572..47454f07 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -19,7 +19,7 @@ export class FilesController { return (req, res) => { let config = new Config(req.params.appId); let filename = req.params.filename; - this._filesAdapter.getFileDataAsync(config, filename).then((data) => { + this._filesAdapter.getFileData(config, filename).then((data) => { res.status(200); var contentType = mime.lookup(filename); res.set('Content-type', contentType); @@ -62,7 +62,7 @@ export class FilesController { } let filename = rack() + '_' + req.params.filename + extension; - this._filesAdapter.createFileAsync(req.config, filename, req.body).then(() => { + this._filesAdapter.createFile(req.config, filename, req.body).then(() => { res.status(201); var location = this._filesAdapter.getFileLocation(req.config, filename); res.set('Location', location); diff --git a/src/S3Adapter.js b/src/S3Adapter.js deleted file mode 100644 index 736ebf8b..00000000 --- a/src/S3Adapter.js +++ /dev/null @@ -1,77 +0,0 @@ -// S3Adapter -// -// Stores Parse files in AWS S3. - -var AWS = require('aws-sdk'); -var path = require('path'); - -var DEFAULT_REGION = "us-east-1"; -var DEFAULT_BUCKET = "parse-files"; - -// Creates an S3 session. -// Providing AWS access and secret keys is mandatory -// Region and bucket will use sane defaults if omitted -function S3Adapter(accessKey, secretKey, options) { - options = options || {}; - - this.region = options.region || DEFAULT_REGION; - this.bucket = options.bucket || DEFAULT_BUCKET; - this.bucketPrefix = options.bucketPrefix || ""; - this.directAccess = options.directAccess || false; - - s3Options = { - accessKeyId: accessKey, - secretAccessKey: secretKey, - params: {Bucket: this.bucket} - }; - AWS.config.region = this.region; - this.s3 = new AWS.S3(s3Options); -} - -// For a given config object, filename, and data, store a file in S3 -// Returns a promise containing the S3 object creation response -S3Adapter.prototype.create = function(config, filename, data) { - var params = { - Key: this.bucketPrefix + filename, - Body: data, - }; - if (this.directAccess) { - params.ACL = "public-read" - } - - return new Promise((resolve, reject) => { - this.s3.upload(params, (err, data) => { - if (err !== null) return reject(err); - resolve(data); - }); - }); -} - -// Search for and return a file if found by filename -// Returns a promise that succeeds with the buffer result from S3 -S3Adapter.prototype.get = function(config, filename) { - var params = {Key: this.bucketPrefix + filename}; - - return new Promise((resolve, reject) => { - this.s3.getObject(params, (err, data) => { - if (err !== null) return reject(err); - resolve(data.Body); - }); - }); -} - -// Generates and returns the location of a file stored in S3 for the given request and -// filename -// The location is the direct S3 link if the option is set, otherwise we serve -// the file through parse-server -S3Adapter.prototype.location = function(config, req, filename) { - if (this.directAccess) { - return ('https://' + this.bucket + '.s3.amazonaws.com' + '/' + - this.bucketPrefix + filename); - } - return (req.protocol + '://' + req.get('host') + - path.dirname(req.originalUrl) + '/' + req.config.applicationId + - '/' + encodeURIComponent(filename)); -} - -module.exports = S3Adapter; diff --git a/src/index.js b/src/index.js index d685e79f..73d48298 100644 --- a/src/index.js +++ b/src/index.js @@ -5,15 +5,16 @@ var batch = require('./batch'), cache = require('./cache'), DatabaseAdapter = require('./DatabaseAdapter'), express = require('express'), - S3Adapter = require('./S3Adapter'), middlewares = require('./middlewares'), multer = require('multer'), Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), httpRequest = require('./httpRequest'); -import { default as GridStoreAdapter } from './GridStoreAdapter'; -import { default as FilesController } from './Controllers/FilesController'; +import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; +import { S3Adapter } from './Adapters/Files/S3Adapter'; + +import { FilesController } from './Controllers/FilesController'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); From 6e222d6356190c0eea14f484eac31e89cf22471f Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Wed, 10 Feb 2016 11:13:59 +0000 Subject: [PATCH 46/84] Fix paths for babel-istanbul and jasmine on Windows --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b2f59c39..6caacbfa 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "scripts": { "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", - "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/.bin/babel-istanbul cover -x **/spec/** ./node_modules/.bin/jasmine", + "test": "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", "posttest": "mongodb-runner stop", "start": "./bin/parse-server", "prepublish": "npm run build" From a6dfd92613b661b607d5789da637a3d7462a3e8a Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Wed, 10 Feb 2016 11:15:37 +0000 Subject: [PATCH 47/84] Add non-breaking test (reverted from commit 93219a21f2a7f2c06ae1c0446f540c5e3eaf9dc0) --- spec/RestCreate.spec.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index a8b7991e..24455507 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -57,23 +57,6 @@ describe('rest create', () => { }); }); - it('handles anonymous user signup', (done) => { - var data = { - authData: { - anonymous: { - id: '00000000-0000-0000-0000-000000000000' - } - } - }; - rest.create(config, auth.nobody(config), '_User', data) - .then((r) => { - expect(typeof r.response.objectId).toEqual('string'); - expect(typeof r.response.createdAt).toEqual('string'); - expect(typeof r.response.sessionToken).toEqual('string'); - done(); - }); - }); - it('test facebook signup and login', (done) => { var data = { authData: { From 5855f8190539ac41d416bcae3dd13c5bf531df0d Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Wed, 10 Feb 2016 11:31:00 +0000 Subject: [PATCH 48/84] Make path consistent --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6caacbfa..1defebe7 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "scripts": { "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", - "test": "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", + "test": "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", "posttest": "mongodb-runner stop", "start": "./bin/parse-server", "prepublish": "npm run build" From 4ddaac36bb4b0887878ae23298e6270818af9426 Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Wed, 10 Feb 2016 16:04:13 +0000 Subject: [PATCH 49/84] Expose installationId to Cloud Code request from req.info --- src/functions.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/functions.js b/src/functions.js index 09e43ed3..f8b8fbc9 100644 --- a/src/functions.js +++ b/src/functions.js @@ -22,6 +22,7 @@ function handleCloudFunction(req) { params: req.body || {}, master: req.auth && req.auth.isMaster, user: req.auth && req.auth.user, + installationId: req.info.installationId }; Parse.Cloud.Functions[req.params.functionName](request, response); }); From 38b8b6e2e0d26b1339cda492fb9e2f7b0095268b Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Wed, 10 Feb 2016 17:41:04 +0000 Subject: [PATCH 50/84] Repurpose newObjectId as more general id generating function. Set username to random string if missing --- src/RestWrite.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/RestWrite.js b/src/RestWrite.js index 446a2db9..e1af0b84 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -56,7 +56,7 @@ function RestWrite(config, auth, className, query, data, originalData) { this.data.updatedAt = this.updatedAt; if (!this.query) { this.data.createdAt = this.updatedAt; - this.data.objectId = newObjectId(); + this.data.objectId = newStringId(10); } } } @@ -319,8 +319,7 @@ RestWrite.prototype.transformUser = function() { // Check for username uniqueness if (!this.data.username) { if (!this.query) { - // TODO: what's correct behavior here - this.data.username = ''; + this.data.username = newStringId(25); } return; } @@ -714,13 +713,13 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; -// Returns a unique string that's usable as an object id. -function newObjectId() { +// Returns a unique string that's usable as an object or other id. +function newStringId(size) { var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789'); var objectId = ''; - var bytes = crypto.randomBytes(10); + var bytes = crypto.randomBytes(size); for (var i = 0; i < bytes.length; ++i) { // Note: there is a slight modulo bias, because chars length // of 62 doesn't divide the number of all bytes (256) evenly. From dfe66eb3700c1a5134ab12ffe0fe21352775f0f3 Mon Sep 17 00:00:00 2001 From: Drew Date: Wed, 10 Feb 2016 13:06:09 -0800 Subject: [PATCH 51/84] Force node 4.3 Node 4.1 has a security vulnerability. Lets update our Node. https://nodejs.org/en/blog/vulnerability/february-2016-security-releases/ --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 689110c0..f4fd34ed 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "prepublish": "npm run build" }, "engines": { - "node": ">=4.1" + "node": ">=4.3" }, "bin": { "parse-server": "./bin/parse-server" From f07836e33f973a385c25bbe5de301623cf6250d2 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 9 Feb 2016 16:23:46 -0800 Subject: [PATCH 52/84] Add validation of deleteField function --- spec/Schema.spec.js | 57 ++++++++++++++++++++++++++++++++++++++++++++- src/Schema.js | 32 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 636311a6..d6310ec8 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,4 +1,3 @@ -// These tests check that the Schema operates correctly. var Config = require('../src/Config'); var Schema = require('../src/Schema'); var dd = require('deep-diff'); @@ -406,4 +405,60 @@ describe('Schema', () => { done(); }); }); + + it('can check if a class exists', done => { + config.database.loadSchema() + .then(schema => { + return schema.addClassIfNotExists('NewClass', {}) + .then(() => { + console.log(Object.getPrototypeOf(schema)); + schema.hasClass('NewClass') + .then(hasClass => { + expect(hasClass).toEqual(true); + done(); + }) + .catch(fail); + + schema.hasClass('NonexistantClass') + .then(hasClass => { + expect(hasClass).toEqual(false); + done(); + }) + .catch(fail); + }) + .catch(error => { + fail('Couldn\'t create class'); + fail(error); + }); + }) + .catch(error => fail('Couldn\'t load schema')); + }); + + it('refuses to delete fields from invalid class names', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('fieldName', 'invalid class name')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + done(); + }); + }); + + it('refuses to delete invalid fields', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('invalid field name', 'ValidClassName')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_KEY_NAME); + done(); + }); + }); + + it('refuses to delete the default fields', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('installationId', '_Installation')) + .catch(error => { + expect(error.code).toEqual(136); + expect(error.error).toEqual('field installationId cannot be changed'); + done(); + }); + }); }); diff --git a/src/Schema.js b/src/Schema.js index 3656507a..9477bd39 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -409,6 +409,38 @@ Schema.prototype.validateField = function(className, key, type, freeze) { }); }; +// Delete a field, and remove that data from all objects. This is intended +// to remove unused fields, if other writers are writing objects that include +// this field, the field may reappear. Returns a Promise that resolves with +// no object on success, or rejects with { code, error } on failure. +Schema.prototype.deleteField = function(fieldName, className) { + if (!classNameIsValid(className)) { + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + error: invalidClassNameMessage(className), + }); + } + + if (!fieldNameIsValid(fieldName)) { + return Promise.reject({ + code: Parse.Error.INVALID_KEY_NAME, + error: 'invalid field name: ' + fieldName, + }); + } + + //Don't allow deleting the default fields. + if (!fieldNameIsValidForClass(fieldName, className)) { + return Promise.reject({ + code: 136, + error: 'field ' + fieldName + ' cannot be changed', + }); + } + + return this.reload() + .then(schema => { + }); +} + // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateField(schemaPromise, className, key, type) { From b0c4b8f6cec32eb8f3d5966ac7840b44c9ec00ce Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 9 Feb 2016 20:16:53 -0800 Subject: [PATCH 53/84] Drop _Join collection when deleting a relation field --- spec/Schema.spec.js | 61 +++++++++++++++++++++++++++++++++++++++++++- src/ExportAdapter.js | 2 -- src/Schema.js | 44 +++++++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index d6310ec8..9524ea1b 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -4,6 +4,22 @@ var dd = require('deep-diff'); var config = new Config('test'); +var hasAllPODobject = () => { + var obj = new Parse.Object('HasAllPOD'); + obj.set('aNumber', 5); + obj.set('aString', 'string'); + obj.set('aBool', true); + obj.set('aDate', new Date()); + obj.set('aObject', {k1: 'value', k2: true, k3: 5}); + obj.set('aArray', ['contents', true, 5]); + obj.set('aGeoPoint', new Parse.GeoPoint({latitude: 0, longitude: 0})); + obj.set('aFile', new Parse.File('f.txt', { base64: 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=' })); + var objACL = new Parse.ACL(); + objACL.setPublicWriteAccess(false); + obj.setACL(objACL); + return obj; +}; + describe('Schema', () => { it('can validate one object', (done) => { config.database.loadSchema().then((schema) => { @@ -411,7 +427,6 @@ describe('Schema', () => { .then(schema => { return schema.addClassIfNotExists('NewClass', {}) .then(() => { - console.log(Object.getPrototypeOf(schema)); schema.hasClass('NewClass') .then(hasClass => { expect(hasClass).toEqual(true); @@ -461,4 +476,48 @@ describe('Schema', () => { done(); }); }); + + it('refuses to delete fields from nonexistant classes', done => { + config.database.loadSchema() + .then(schema => schema.deleteField('field', 'NoClass')) + .catch(error => { + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.error).toEqual('class NoClass does not exist'); + done(); + }); + }); + + it('refuses to delete fields that dont exist', done => { + hasAllPODobject().save() + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('missingField', 'HasAllPOD')) + .fail(error => { + expect(error.code).toEqual(255); + expect(error.error).toEqual('field missingField does not exist, cannot delete'); + done(); + }); + }); + + it('drops related collection when deleting relation field', done => { + var obj1 = hasAllPODobject(); + obj1.save() + .then(savedObj1 => { + var obj2 = new Parse.Object('HasPointersAndRelations'); + obj2.set('aPointer', savedObj1); + var relation = obj2.relation('aRelation'); + relation.add(obj1); + return obj2.save(); + }) + .then(() => { + config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { + expect(err).toEqual(null); + config.database.loadSchema() + .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database.db, 'test_')) + .then(() => config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { + expect(err).not.toEqual(null); + done(); + })) + }); + }) + }); }); diff --git a/src/ExportAdapter.js b/src/ExportAdapter.js index 1676ccfb..7c2bd674 100644 --- a/src/ExportAdapter.js +++ b/src/ExportAdapter.js @@ -43,8 +43,6 @@ ExportAdapter.prototype.connect = function() { // Returns a promise for a Mongo collection. // Generally just for internal use. -var joinRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; -var otherRegex = /^[A-Za-z][A-Za-z0-9_]*$/; ExportAdapter.prototype.collection = function(className) { if (!Schema.classNameIsValid(className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, diff --git a/src/Schema.js b/src/Schema.js index 9477bd39..33dedb99 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -413,7 +413,11 @@ Schema.prototype.validateField = function(className, key, type, freeze) { // to remove unused fields, if other writers are writing objects that include // this field, the field may reappear. Returns a Promise that resolves with // no object on success, or rejects with { code, error } on failure. -Schema.prototype.deleteField = function(fieldName, className) { + +// Passing the database and prefix is necessary in order to drop relation collections +// and remove fields from objects. Ideally the database would belong to +// a database adapter and this fuction would close over it or access it via member. +Schema.prototype.deleteField = function(fieldName, className, database, prefix) { if (!classNameIsValid(className)) { return Promise.reject({ code: Parse.Error.INVALID_CLASS_NAME, @@ -438,6 +442,37 @@ Schema.prototype.deleteField = function(fieldName, className) { return this.reload() .then(schema => { + return schema.hasClass(className) + .then(hasClass => { + if (!hasClass) { + return Promise.reject({ + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class ' + className + ' does not exist', + }); + } + + if (!schema.data[className][fieldName]) { + return Promise.reject({ + code: 255, + error: 'field ' + fieldName + ' does not exist, cannot delete', + }); + } + + let p = null; + if (schema.data[className][fieldName].startsWith('relation')) { + //For relations, drop the _Join table + + p = database.dropCollection(prefix + '_Join:' + fieldName + ':' + className); + } else { + //for non-relations, remove all the data. This is necessary to ensure that the data is still gone + //if they add the same field. + p = Promise.resolve(); + } + return p.then(() => { + //Save the _SCHEMA object + return Promise.resolve(); + }); + }); }); } @@ -509,6 +544,13 @@ Schema.prototype.getExpectedType = function(className, key) { return undefined; }; +// Checks if a given class is in the schema. Needs to load the +// schema first, which is kinda janky. Hopefully we can refactor +// and make this be a regular value. Parse would probably +Schema.prototype.hasClass = function(className) { + return this.reload().then(newSchema => !!newSchema.data[className]); +} + // Helper function to check if a field is a pointer, returns true or false. Schema.prototype.isPointer = function(className, key) { var expected = this.getExpectedType(className, key); From 0a0d4f65efe86ae14fa0a401bacdf83f9cb53868 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 10 Feb 2016 14:11:42 -0800 Subject: [PATCH 54/84] Finish implementation of delete field from schema --- spec/Schema.spec.js | 46 +++++++++++++++++++++++++++++++++++++++++++++ src/Schema.js | 28 +++++++++++++++++++-------- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 9524ea1b..b8222e5f 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -520,4 +520,50 @@ describe('Schema', () => { }); }) }); + + it('can delete string fields and resave as number field', done => { + var obj1 = hasAllPODobject(); + var obj2 = hasAllPODobject(); + var p = Parse.Object.saveAll([obj1, obj2]) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database.db, 'test_')) + .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) + .then(obj1 => { + expect(obj1.get('aString')).toEqual(undefined); + obj1.set('aString', ['not a string', 'this time']); + obj1.save() + .then(obj1 => { + expect(obj1.get('aString')).toEqual(['not a string', 'this time']); + return new Parse.Query('HasAllPOD').get(obj2.id); + }) + .then(obj2 => { + expect(obj2.get('aString')).toEqual(undefined); + done(); + }); + }) + }); + + it('can delete pointer fields and resave as string', done => { + var obj1 = new Parse.Object('NewClass'); + obj1.save() + .then(() => { + obj1.set('aPointer', obj1); + return obj1.save(); + }) + .then(obj1 => { + expect(obj1.get('aPointer').id).toEqual(obj1.id); + }) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aPointer', 'NewClass', config.database.db, 'test_')) + .then(() => new Parse.Query('NewClass').get(obj1.id)) + .then(obj1 => { + expect(obj1.get('aPointer')).toEqual(undefined); + obj1.set('aPointer', 'Now a string'); + return obj1.save(); + }) + .then(obj1 => { + expect(obj1.get('aPointer')).toEqual('Now a string'); + done(); + }); + }); }); diff --git a/src/Schema.js b/src/Schema.js index 33dedb99..ecf1a5b3 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -458,20 +458,32 @@ Schema.prototype.deleteField = function(fieldName, className, database, prefix) }); } - let p = null; if (schema.data[className][fieldName].startsWith('relation')) { //For relations, drop the _Join table - - p = database.dropCollection(prefix + '_Join:' + fieldName + ':' + className); + return database.dropCollection(prefix + '_Join:' + fieldName + ':' + className) + //Save the _SCHEMA object + .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})); } else { //for non-relations, remove all the data. This is necessary to ensure that the data is still gone //if they add the same field. - p = Promise.resolve(); + return new Promise((resolve, reject) => { + database.collection(prefix + className, (err, coll) => { + if (err) { + reject(err); + } else { + return coll.update({}, { + "$unset": { [fieldName] : null }, + }, { + multi: true, + }) + //Save the _SCHEMA object + .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})) + .then(resolve) + .catch(reject); + } + }); + }); } - return p.then(() => { - //Save the _SCHEMA object - return Promise.resolve(); - }); }); }); } From 2f95688bf99078dfcdd4d48d60f9db13c90235c4 Mon Sep 17 00:00:00 2001 From: Drew Date: Wed, 10 Feb 2016 14:49:52 -0800 Subject: [PATCH 55/84] Update .travis.yml --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index dc081ced..27a7b149 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,8 +3,7 @@ branches: - master language: node_js node_js: - - "4.1" - - "4.2" + - "4.3" env: - MONGODB_VERSION=2.6.11 - MONGODB_VERSION=3.0.8 From dee640c7d029da5cdf0852b5108433024e9d1e45 Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Wed, 10 Feb 2016 14:52:42 -0800 Subject: [PATCH 56/84] Added logout test and fixed error in restwrite --- spec/ParseUser.spec.js | 22 ++++++++++++++++++++++ src/RestWrite.js | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index c9f25bd8..d339de7b 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1576,5 +1576,27 @@ describe('Parse.User testing', () => { }); }); + it('ensure logout works', (done) => { + var user = null; + var sessionToken = null; + + Parse.Promise.as().then(function() { + return Parse.User.signUp('log', 'out'); + }).then((newUser) => { + user = newUser; + sessionToken = user.getSessionToken(); + return Parse.User.logOut(); + }).then(() => { + user.set('foo', 'bar'); + return user.save(null, { sessionToken: sessionToken }); + }).then(() => { + fail('Save should have failed.'); + done(); + }, (e) => { + expect(e.code).toEqual(Parse.Error.SESSION_MISSING); + done(); + }); + }) + }); diff --git a/src/RestWrite.js b/src/RestWrite.js index 446a2db9..77a971c3 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -637,7 +637,7 @@ RestWrite.prototype.runDatabaseOperation = function() { this.query && !this.auth.couldUpdateUserId(this.query.objectId)) { throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'cannot modify user ' + this.objectId); + 'cannot modify user ' + this.query.objectId); } // TODO: Add better detection for ACL, ensuring a user can't be locked from From fb6af2cfa0e7d149dd745de4d7a46821b93a0135 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Thu, 11 Feb 2016 01:06:25 +0100 Subject: [PATCH 57/84] Hide /config behind PARSE_EXPERIMENTAL_CONFIG_ENABLED flag --- src/global_config.js | 2 +- src/index.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/global_config.js b/src/global_config.js index 9d032d7d..c7c9266a 100644 --- a/src/global_config.js +++ b/src/global_config.js @@ -41,6 +41,6 @@ function getGlobalConfig(req) { } router.route('GET', '/config', getGlobalConfig); -router.route('POST', '/config', updateGlobalConfig); +router.route('PUT', '/config', updateGlobalConfig); module.exports = router; diff --git a/src/index.js b/src/index.js index 25e13641..ade8527b 100644 --- a/src/index.js +++ b/src/index.js @@ -121,7 +121,9 @@ function ParseServer(args) { router.merge(require('./installations')); router.merge(require('./functions')); router.merge(require('./schemas')); - router.merge(require('./global_config')); + if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED) { + router.merge(require('./global_config')); + } batch.mountOnto(router); From 19777699c9b77392736f80b2b0d2331139cbdf61 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Thu, 11 Feb 2016 01:07:49 +0100 Subject: [PATCH 58/84] Rearrange methods to follow route setup --- src/global_config.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/global_config.js b/src/global_config.js index c7c9266a..b1b8f90e 100644 --- a/src/global_config.js +++ b/src/global_config.js @@ -5,6 +5,19 @@ var Parse = require('parse/node').Parse, var router = new PromiseRouter(); +function getGlobalConfig(req) { + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOne({'_id': 1})) + .then(globalConfig => ({response: { params: globalConfig.params }})) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config does not exist', + } + })); +} + function updateGlobalConfig(req) { if (!req.auth.isMaster) { return Promise.resolve({ @@ -27,19 +40,6 @@ function updateGlobalConfig(req) { })); } -function getGlobalConfig(req) { - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOne({'_id': 1})) - .then(globalConfig => ({response: { params: globalConfig.params }})) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config does not exist', - } - })); -} - router.route('GET', '/config', getGlobalConfig); router.route('PUT', '/config', updateGlobalConfig); From 930573bb4768f0cdf48c166c4acffa3ed487c5a5 Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Thu, 11 Feb 2016 01:10:09 +0100 Subject: [PATCH 59/84] Update PUT response to align with current dashboard --- src/global_config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/global_config.js b/src/global_config.js index b1b8f90e..30314f7a 100644 --- a/src/global_config.js +++ b/src/global_config.js @@ -29,7 +29,7 @@ function updateGlobalConfig(req) { return req.config.database.rawCollection('_GlobalConfig') .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body }, { returnOriginal: false })) .then(response => { - return { response: { params: response.value.params } } + return { response: { result: true } } }) .catch(() => ({ status: 404, From 747f278f2acc3f7ca3c7d7efa2d399892de51a06 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 10 Feb 2016 16:25:28 -0800 Subject: [PATCH 60/84] Enable deleting pointer fields, fix tests --- spec/Schema.spec.js | 20 ++++++++++++-------- src/Schema.js | 5 ++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index b8222e5f..7f59fec2 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -522,28 +522,31 @@ describe('Schema', () => { }); it('can delete string fields and resave as number field', done => { + Parse.Object.disableSingleInstance(); var obj1 = hasAllPODobject(); var obj2 = hasAllPODobject(); var p = Parse.Object.saveAll([obj1, obj2]) .then(() => config.database.loadSchema()) .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database.db, 'test_')) .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) - .then(obj1 => { - expect(obj1.get('aString')).toEqual(undefined); - obj1.set('aString', ['not a string', 'this time']); - obj1.save() - .then(obj1 => { - expect(obj1.get('aString')).toEqual(['not a string', 'this time']); + .then(obj1Reloaded => { + expect(obj1Reloaded.get('aString')).toEqual(undefined); + obj1Reloaded.set('aString', ['not a string', 'this time']); + obj1Reloaded.save() + .then(obj1reloadedAgain => { + expect(obj1reloadedAgain.get('aString')).toEqual(['not a string', 'this time']); return new Parse.Query('HasAllPOD').get(obj2.id); }) - .then(obj2 => { - expect(obj2.get('aString')).toEqual(undefined); + .then(obj2reloaded => { + expect(obj2reloaded.get('aString')).toEqual(undefined); done(); + Parse.Object.enableSingleInstance(); }); }) }); it('can delete pointer fields and resave as string', done => { + Parse.Object.disableSingleInstance(); var obj1 = new Parse.Object('NewClass'); obj1.save() .then(() => { @@ -564,6 +567,7 @@ describe('Schema', () => { .then(obj1 => { expect(obj1.get('aPointer')).toEqual('Now a string'); done(); + Parse.Object.enableSingleInstance(); }); }); }); diff --git a/src/Schema.js b/src/Schema.js index ecf1a5b3..859ad804 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -471,8 +471,11 @@ Schema.prototype.deleteField = function(fieldName, className, database, prefix) if (err) { reject(err); } else { + var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? + '_p_' + fieldName : + fieldName; return coll.update({}, { - "$unset": { [fieldName] : null }, + "$unset": { [mongoFieldName] : null }, }, { multi: true, }) From 92e9db90649a158be7600b73813d632d2002c1ce Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 10 Feb 2016 16:31:07 -0800 Subject: [PATCH 61/84] Fix comment --- src/Schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Schema.js b/src/Schema.js index 859ad804..d8f38499 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -561,7 +561,7 @@ Schema.prototype.getExpectedType = function(className, key) { // Checks if a given class is in the schema. Needs to load the // schema first, which is kinda janky. Hopefully we can refactor -// and make this be a regular value. Parse would probably +// and make this be a regular value. Schema.prototype.hasClass = function(className) { return this.reload().then(newSchema => !!newSchema.data[className]); } From 1d576bcc9fd2f7b0cba9fa86715f99c5a68d14de Mon Sep 17 00:00:00 2001 From: Peter Theill Date: Thu, 11 Feb 2016 01:32:38 +0100 Subject: [PATCH 62/84] Update tests and ensure tests are run regardless of exp flag --- spec/ParseGlobalConfig.spec.js | 26 +++----------------------- src/global_config.js | 2 +- src/index.js | 2 +- 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 78364c6a..0257fe9c 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -28,7 +28,7 @@ describe('a GlobalConfig', () => { }); it('can be updated when a master key exists', (done) => { - request.post({ + request.put({ url: 'http://localhost:8378/1/config', json: true, body: { params: { companies: ['US', 'DK', 'SE'] } }, @@ -38,13 +38,13 @@ describe('a GlobalConfig', () => { }, }, (error, response, body) => { expect(response.statusCode).toEqual(200); - expect(body.params.companies).toEqual(['US', 'DK', 'SE']); + expect(body.result).toEqual(true); done(); }); }); it('fail to update if master key is missing', (done) => { - request.post({ + request.put({ url: 'http://localhost:8378/1/config', json: true, body: { params: { companies: [] } }, @@ -78,24 +78,4 @@ describe('a GlobalConfig', () => { }); }); - it('failed updating config when it is missing', (done) => { - database.rawCollection('_GlobalConfig') - .then(coll => coll.deleteOne({ '_id': 1}, {}, {})) - .then(_ => { - request.post({ - url: 'http://localhost:8378/1/config', - json: true, - body: { params: { companies: ['US', 'DK', 'SE'] } }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-Master-Key': 'test' - }, - }, (error, response, body) => { - expect(response.statusCode).toEqual(404); - expect(body.code).toEqual(Parse.Error.INVALID_KEY_NAME); - done(); - }); - }); - }); - }); diff --git a/src/global_config.js b/src/global_config.js index 30314f7a..3dd1b543 100644 --- a/src/global_config.js +++ b/src/global_config.js @@ -27,7 +27,7 @@ function updateGlobalConfig(req) { } return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body }, { returnOriginal: false })) + .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) .then(response => { return { response: { result: true } } }) diff --git a/src/index.js b/src/index.js index ade8527b..ef29ec7f 100644 --- a/src/index.js +++ b/src/index.js @@ -121,7 +121,7 @@ function ParseServer(args) { router.merge(require('./installations')); router.merge(require('./functions')); router.merge(require('./schemas')); - if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED) { + if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { router.merge(require('./global_config')); } From d8dd57441db8fe3cac8508abc19a4cf22e883712 Mon Sep 17 00:00:00 2001 From: Drew Date: Wed, 10 Feb 2016 19:07:47 -0800 Subject: [PATCH 63/84] Update unsupported cloud code functions in readme. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e11bb351..d0c9cc80 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,4 @@ You can also set up an app on Parse, providing the connection string for your mo ### Not supported * Push - We did not rebuild a new push delivery system for parse-server, but we are open to working on one together with the community. +* `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and saves as a specific used within Cloud Code, you need a session token, which you can pass in the request body, or find with a query. From 21f0a9200735cc91f1d5cdc7719767f3a56ead6a Mon Sep 17 00:00:00 2001 From: Drew Date: Wed, 10 Feb 2016 19:12:22 -0800 Subject: [PATCH 64/84] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d0c9cc80..63afa2a5 100644 --- a/README.md +++ b/README.md @@ -134,4 +134,4 @@ You can also set up an app on Parse, providing the connection string for your mo ### Not supported * Push - We did not rebuild a new push delivery system for parse-server, but we are open to working on one together with the community. -* `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and saves as a specific used within Cloud Code, you need a session token, which you can pass in the request body, or find with a query. +* `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and writes as a specific user within Cloud Code, you need a session token, which you can pass in the request body, or find with a query. From d63ca5c46dff9384d930504bd9768f4ee7038535 Mon Sep 17 00:00:00 2001 From: Prayag Verma Date: Thu, 11 Feb 2016 12:16:20 +0530 Subject: [PATCH 65/84] Fix a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `intialization` → `initialization` --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e11bb351..764b63b1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ We also have an [example project](https://github.com/ParsePlatform/parse-server- #### Client key options: -The client keys used with Parse are no longer necessary with parse-server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at intialization time. Setting any of these keys will require all requests to provide one of the configured keys. +The client keys used with Parse are no longer necessary with parse-server. If you wish to still require them, perhaps to be able to refuse access to older clients, you can set the keys at initialization time. Setting any of these keys will require all requests to provide one of the configured keys. * clientKey * javascriptKey From 6afaeb808b9688d1826b7d0f753478064ec0f908 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Mon, 8 Feb 2016 12:02:07 -0800 Subject: [PATCH 66/84] Add support for push --- spec/APNS.spec.js | 10 +- spec/GCM.spec.js | 22 ++- spec/ParsePushAdapter.spec.js | 237 ++++++++++++++++++++++++++ spec/push.spec.js | 25 +-- src/APNS.js | 34 ++-- src/Adapters/Push/ParsePushAdapter.js | 153 +++++++++++++++++ src/Adapters/Push/PushAdapter.js | 29 ++++ src/GCM.js | 54 +++--- src/index.js | 5 + src/push.js | 25 ++- 10 files changed, 530 insertions(+), 64 deletions(-) create mode 100644 spec/ParsePushAdapter.spec.js create mode 100644 src/Adapters/Push/ParsePushAdapter.js create mode 100644 src/Adapters/Push/PushAdapter.js diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 72490e97..3525fa56 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -43,16 +43,18 @@ describe('APNS', () => { 'alert': 'alert' } } - // Mock registrationTokens - var deviceTokens = ['token']; + // Mock devices + var devices = [ + { deviceToken: 'token' } + ]; - var promise = apns.send(data, deviceTokens); + var promise = apns.send(data, devices); expect(sender.pushNotification).toHaveBeenCalled(); var args = sender.pushNotification.calls.first().args; var notification = args[0]; expect(notification.alert).toEqual(data.data.alert); expect(notification.expiry).toEqual(data['expiration_time']); - expect(args[1]).toEqual(deviceTokens); + expect(args[1]).toEqual(['token']); done(); }); }); diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index 4bad883e..3e2a7947 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -104,14 +104,18 @@ describe('GCM', () => { 'alert': 'alert' } } - // Mock registrationTokens - var registrationTokens = ['token']; + // Mock devices + var devices = [ + { + deviceToken: 'token' + } + ]; - var promise = gcm.send(data, registrationTokens); + var promise = gcm.send(data, devices); expect(sender.send).toHaveBeenCalled(); var args = sender.send.calls.first().args; // It is too hard to verify message of gcm library, we just verify tokens and retry times - expect(args[1].registrationTokens).toEqual(registrationTokens); + expect(args[1].registrationTokens).toEqual(['token']); expect(args[2]).toEqual(5); done(); }); @@ -123,14 +127,16 @@ describe('GCM', () => { send: jasmine.createSpy('send') }; gcm.sender = sender; - // Mock registrationTokens - var registrationTokens = []; + // Mock devices + var devices = []; for (var i = 0; i <= 2000; i++) { - registrationTokens.push(i.toString()); + devices.push({ + deviceToken: i.toString() + }); } expect(function() { - gcm.send({}, registrationTokens); + gcm.send({}, devices); }).toThrow(); done(); }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js new file mode 100644 index 00000000..ca0da190 --- /dev/null +++ b/spec/ParsePushAdapter.spec.js @@ -0,0 +1,237 @@ +var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); + +describe('ParsePushAdapter', () => { + it('can be initialized', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.validPushTypes).toEqual(['ios', 'android']); + done(); + }); + + it('can initialize', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + android: { + senderId: 'senderId', + apiKey: 'apiKey' + }, + ios: [ + { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true + }, + { + cert: 'devCert.pem', + key: 'devKey.pem', + production: false + } + ] + }; + + parsePushAdapter.initialize(pushConfig); + // Check ios + var iosSenders = parsePushAdapter.senders['ios']; + expect(iosSenders.length).toBe(2); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = iosSenders[0].sender.options; + expect(prodApnsOptions.cert).toBe(pushConfig.ios[0].cert); + expect(prodApnsOptions.key).toBe(pushConfig.ios[0].key); + expect(prodApnsOptions.production).toBe(pushConfig.ios[0].production); + var devApnsOptions = iosSenders[1].sender.options; + expect(devApnsOptions.cert).toBe(pushConfig.ios[1].cert); + expect(devApnsOptions.key).toBe(pushConfig.ios[1].key); + expect(devApnsOptions.production).toBe(pushConfig.ios[1].production); + // Check android + var androidSenders = parsePushAdapter.senders['android']; + expect(androidSenders.length).toBe(1); + var androidSender = androidSenders[0]; + // TODO: Remove this checking onec we inject GCM + expect(androidSender.sender.key).toBe(pushConfig.android.apiKey); + done(); + }); + + it('can throw on initializing with unsupported push type', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + win: { + senderId: 'senderId', + apiKey: 'apiKey' + } + }; + + expect(function() { + parsePushAdapter.initialize(pushConfig) + }).toThrow(); + done(); + }); + + it('can throw on initializing with invalid pushConfig', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Make mock config + var pushConfig = { + android: 123 + }; + + expect(function() { + parsePushAdapter.initialize(pushConfig) + }).toThrow(); + done(); + }); + + it('can get push senders', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Mock push senders + var androidSender = {}; + var iosSender = {}; + var iosSenderAgain = {}; + parsePushAdapter.senders = { + android: [ + androidSender + ], + ios: [ + iosSender, + iosSenderAgain + ] + }; + + expect(parsePushAdapter.getPushSenders('android')).toEqual([androidSender]); + expect(parsePushAdapter.getPushSenders('ios')).toEqual([iosSender, iosSenderAgain]); + done(); + }); + + it('can get empty push senders', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.getPushSenders('android')).toEqual([]); + done(); + }); + + it('can get valid push types', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + + expect(parsePushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); + done(); + }); + + it('can classify installation', (done) => { + // Mock installations + var validPushTypes = ['ios', 'android']; + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + + var deviceTokenMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes); + expect(deviceTokenMap['android']).toEqual([makeDevice('androidToken')]); + expect(deviceTokenMap['ios']).toEqual([makeDevice('iosToken')]); + expect(deviceTokenMap['win']).toBe(undefined); + done(); + }); + + it('can slice ios devices', (done) => { + // Mock devices + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; + + var chunkDevices = ParsePushAdapter.sliceDevices('ios', devices, 2); + expect(chunkDevices).toEqual([devices]); + done(); + }); + + it('can slice android devices', (done) => { + // Mock devices + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; + + var chunkDevices = ParsePushAdapter.sliceDevices('android', devices, 3); + expect(chunkDevices).toEqual([ + [makeDevice(1), makeDevice(2), makeDevice(3)], + [makeDevice(4)] + ]); + done(); + }); + + + it('can send push notifications', (done) => { + var parsePushAdapter = new ParsePushAdapter(); + // Mock android ios senders + var androidSender = { + send: jasmine.createSpy('send') + }; + var iosSender = { + send: jasmine.createSpy('send') + }; + var iosSenderAgain = { + send: jasmine.createSpy('send') + }; + var senders = { + ios: [iosSender, iosSenderAgain], + android: [androidSender] + }; + parsePushAdapter.senders = senders; + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + var data = {}; + + parsePushAdapter.send(data, installations); + // Check android sender + expect(androidSender.send).toHaveBeenCalled(); + var args = androidSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('androidToken') + ]); + // Check ios sender + expect(iosSender.send).toHaveBeenCalled(); + args = iosSender.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + expect(iosSenderAgain.send).toHaveBeenCalled(); + args = iosSenderAgain.send.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + done(); + }); + + function makeDevice(deviceToken) { + return { + deviceToken: deviceToken + }; + } +}); diff --git a/spec/push.spec.js b/spec/push.spec.js index a2ea41b5..ba7588f3 100644 --- a/spec/push.spec.js +++ b/spec/push.spec.js @@ -104,10 +104,11 @@ describe('push', () => { it('can validate device type when no device type is set', (done) => { // Make query condition var where = { - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -116,10 +117,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'ios' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -130,10 +132,11 @@ describe('push', () => { 'deviceType': { '$in': ['android', 'ios'] } - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -142,10 +145,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'osx' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where); + push.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -154,10 +158,11 @@ describe('push', () => { // Make query condition var where = { 'deviceType': 'osx' - } + }; + var validPushTypes = ['ios', 'android']; expect(function(){ - push.validateDeviceType(where) + push.validatePushType(where, validPushTypes); }).toThrow(); done(); }); diff --git a/src/APNS.js b/src/APNS.js index 85c97401..e6275369 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -1,7 +1,9 @@ -var Parse = require('parse/node').Parse; +"use strict"; + +const Parse = require('parse/node').Parse; // TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1, // but probably we will replace it in the future. -var apn = require('apn'); +const apn = require('apn'); /** * Create a new connection to the APN service. @@ -33,18 +35,26 @@ function APNS(args) { }); this.sender.on("socketError", console.error); + + this.sender.on("transmitted", function(notification, device) { + console.log("APNS Notification transmitted to:" + device.token.toString("hex")); + }); } /** * Send apns request. * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} deviceTokens A array of device tokens + * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved immediately */ -APNS.prototype.send = function(data, deviceTokens) { - var coreData = data.data; - var expirationTime = data['expiration_time']; - var notification = generateNotification(coreData, expirationTime); +APNS.prototype.send = function(data, devices) { + let coreData = data.data; + let expirationTime = data['expiration_time']; + let notification = generateNotification(coreData, expirationTime); + let deviceTokens = []; + for (let device of devices) { + deviceTokens.push(device.deviceToken); + } this.sender.pushNotification(notification, deviceTokens); // TODO: pushNotification will push the notification to apn's queue. // We do not handle error in V1, we just relies apn to auto retry and send the @@ -57,10 +67,10 @@ APNS.prototype.send = function(data, deviceTokens) { * @param {Object} coreData The data field under api request body * @returns {Object} A apns notification */ -var generateNotification = function(coreData, expirationTime) { - var notification = new apn.notification(); - var payload = {}; - for (var key in coreData) { +let generateNotification = function(coreData, expirationTime) { + let notification = new apn.notification(); + let payload = {}; + for (let key in coreData) { switch (key) { case 'alert': notification.setAlertText(coreData.alert); @@ -73,7 +83,7 @@ var generateNotification = function(coreData, expirationTime) { break; case 'content-available': notification.setNewsstandAvailable(true); - var isAvailable = coreData['content-available'] === 1; + let isAvailable = coreData['content-available'] === 1; notification.setContentAvailable(isAvailable); break; case 'category': diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js new file mode 100644 index 00000000..55b99032 --- /dev/null +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -0,0 +1,153 @@ +"use strict"; +// ParsePushAdapter is the default implementation of +// PushAdapter, it uses GCM for android push and APNS +// for ios push. + +const Parse = require('parse/node').Parse; +const GCM = require('../../GCM'); +const APNS = require('../../APNS'); + +function ParsePushAdapter() { + this.validPushTypes = ['ios', 'android']; + this.senders = {}; +} + +/** + * Register push senders + * @param {Object} pushConfig The push configuration which is given when parse server is initialized + */ +ParsePushAdapter.prototype.initialize = function(pushConfig) { + // Initialize senders + for (let validPushType of this.validPushTypes) { + this.senders[validPushType] = []; + } + + pushConfig = pushConfig || {}; + let pushTypes = Object.keys(pushConfig); + for (let pushType of pushTypes) { + if (this.validPushTypes.indexOf(pushType) < 0) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push to ' + pushTypes + ' is not supported'); + } + + let typePushConfig = pushConfig[pushType]; + let senderArgs = []; + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + if (Array.isArray(typePushConfig)) { + senderArgs = senderArgs.concat(typePushConfig); + } else if (typeof typePushConfig === 'object') { + senderArgs.push(typePushConfig); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push Configuration is invalid'); + } + for (let senderArg of senderArgs) { + let sender; + switch (pushType) { + case 'ios': + sender = new APNS(senderArg); + break; + case 'android': + sender = new GCM(senderArg); + break; + } + this.senders[pushType].push(sender); + } + } +} + +/** + * Get an array of push senders based on the push type. + * @param {String} The push type + * @returns {Array|Undefined} An array of push senders + */ +ParsePushAdapter.prototype.getPushSenders = function(pushType) { + if (!this.senders[pushType]) { + console.log('No push sender for push type %s', pushType); + return []; + } + return this.senders[pushType]; +} + +/** + * Get an array of valid push types. + * @returns {Array} An array of valid push types + */ +ParsePushAdapter.prototype.getValidPushTypes = function() { + return this.validPushTypes; +} + +ParsePushAdapter.prototype.send = function(data, installations) { + let deviceMap = classifyInstallation(installations, this.validPushTypes); + let sendPromises = []; + for (let pushType in deviceMap) { + let senders = this.getPushSenders(pushType); + // Since ios have dev/prod cert, a push type may have multiple senders + for (let sender of senders) { + let devices = deviceMap[pushType]; + if (!sender || devices.length == 0) { + continue; + } + // For android, we can only have 1000 recepients per send + let chunkDevices = sliceDevices(pushType, devices, GCM.GCMRegistrationTokensMax); + for (let chunkDevice of chunkDevices) { + sendPromises.push(sender.send(data, chunkDevice)); + } + } + } + return Parse.Promise.when(sendPromises); +} + +/** + * Classify the device token of installations based on its device type. + * @param {Object} installations An array of installations + * @param {Array} validPushTypes An array of valid push types(string) + * @returns {Object} A map whose key is device type and value is an array of device + */ +function classifyInstallation(installations, validPushTypes) { + // Init deviceTokenMap, create a empty array for each valid pushType + let deviceMap = {}; + for (let validPushType of validPushTypes) { + deviceMap[validPushType] = []; + } + for (let installation of installations) { + // No deviceToken, ignore + if (!installation.deviceToken) { + continue; + } + let pushType = installation.deviceType; + if (deviceMap[pushType]) { + deviceMap[pushType].push({ + deviceToken: installation.deviceToken + }); + } else { + console.log('Unknown push type from installation %j', installation); + } + } + return deviceMap; +} + +/** + * Slice a list of devices to several list of devices with fixed chunk size. + * @param {String} pushType The push type of the given device tokens + * @param {Array} devices An array of devices + * @param {Number} chunkSize The size of the a chunk + * @returns {Array} An array which contaisn several arries of devices with fixed chunk size + */ +function sliceDevices(pushType, devices, chunkSize) { + if (pushType !== 'android') { + return [devices]; + } + let chunkDevices = []; + while (devices.length > 0) { + chunkDevices.push(devices.splice(0, chunkSize)); + } + return chunkDevices; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + ParsePushAdapter.classifyInstallation = classifyInstallation; + ParsePushAdapter.sliceDevices = sliceDevices; +} +module.exports = ParsePushAdapter; diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js new file mode 100644 index 00000000..ab2f7133 --- /dev/null +++ b/src/Adapters/Push/PushAdapter.js @@ -0,0 +1,29 @@ +// Push Adapter +// +// Allows you to change the push notification mechanism. +// +// Adapter classes must implement the following functions: +// * initialize(pushConfig) +// * getPushSenders(parseConfig) +// * getValidPushTypes(parseConfig) +// * send(devices, installations) +// +// Default is ParsePushAdapter, which uses GCM for +// android push and APNS for ios push. + +var ParsePushAdapter = require('./ParsePushAdapter'); + +var adapter = new ParsePushAdapter(); + +function setAdapter(pushAdapter) { + adapter = pushAdapter; +} + +function getAdapter() { + return adapter; +} + +module.exports = { + getAdapter: getAdapter, + setAdapter: setAdapter +}; diff --git a/src/GCM.js b/src/GCM.js index b9d5c728..9dfe1b06 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -1,44 +1,54 @@ -var Parse = require('parse/node').Parse; -var gcm = require('node-gcm'); -var randomstring = require('randomstring'); +"use strict"; -var GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks -var GCMRegistrationTokensMax = 1000; +const Parse = require('parse/node').Parse; +const gcm = require('node-gcm'); +const randomstring = require('randomstring'); -function GCM(apiKey) { - this.sender = new gcm.Sender(apiKey); +const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks +const GCMRegistrationTokensMax = 1000; + +function GCM(args) { + this.sender = new gcm.Sender(args.apiKey); } /** * Send gcm request. * @param {Object} data The data we need to send, the format is the same with api request body - * @param {Array} registrationTokens A array of registration tokens + * @param {Array} devices A array of devices * @returns {Object} A promise which is resolved after we get results from gcm */ -GCM.prototype.send = function (data, registrationTokens) { - if (registrationTokens.length >= GCMRegistrationTokensMax) { +GCM.prototype.send = function(data, devices) { + if (devices.length >= GCMRegistrationTokensMax) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Too many registration tokens for a GCM request.'); } - var pushId = randomstring.generate({ + let pushId = randomstring.generate({ length: 10, charset: 'alphanumeric' }); - var timeStamp = Date.now(); - var expirationTime; + let timeStamp = Date.now(); + let expirationTime; // We handle the expiration_time convertion in push.js, so expiration_time is a valid date // in Unix epoch time in milliseconds here if (data['expiration_time']) { expirationTime = data['expiration_time']; } // Generate gcm payload - var gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); + let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); // Make and send gcm request - var message = new gcm.Message(gcmPayload); - var promise = new Parse.Promise(); - this.sender.send(message, { registrationTokens: registrationTokens }, 5, function (error, response) { + let message = new gcm.Message(gcmPayload); + let promise = new Parse.Promise(); + let registrationTokens = [] + for (let device of devices) { + registrationTokens.push(device.deviceToken); + } + this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { // TODO: Use the response from gcm to generate and save push report // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation + console.log('GCM request and response %j', { + request: message, + response: response + }); promise.resolve(); }); return promise; @@ -52,19 +62,19 @@ GCM.prototype.send = function (data, registrationTokens) { * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined * @returns {Object} A promise which is resolved after we get results from gcm */ -var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { - var payloadData = { +let generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { + let payloadData = { 'time': new Date(timeStamp).toISOString(), 'push_id': pushId, 'data': JSON.stringify(coreData) } - var payload = { + let payload = { priority: 'normal', data: payloadData }; if (expirationTime) { // The timeStamp and expiration is in milliseconds but gcm requires second - var timeToLive = Math.floor((expirationTime - timeStamp) / 1000); + let timeToLive = Math.floor((expirationTime - timeStamp) / 1000); if (timeToLive < 0) { timeToLive = 0; } @@ -76,6 +86,8 @@ var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { return payload; } +GCM.GCMRegistrationTokensMax = GCMRegistrationTokensMax; + if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { GCM.generateGCMPayload = generateGCMPayload; } diff --git a/src/index.js b/src/index.js index ef29ec7f..1bc02dc7 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ var batch = require('./batch'), cache = require('./cache'), DatabaseAdapter = require('./DatabaseAdapter'), express = require('express'), + PushAdapter = require('./Adapters/Push/PushAdapter'), middlewares = require('./middlewares'), multer = require('multer'), Parse = require('parse/node').Parse, @@ -86,6 +87,10 @@ function ParseServer(args) { cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } + // Register push senders + var pushConfig = args.push; + PushAdapter.getAdapter().initialize(pushConfig); + // Initialize the node client SDK automatically Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); if(args.serverURL) { diff --git a/src/push.js b/src/push.js index 29a6a944..013b85d4 100644 --- a/src/push.js +++ b/src/push.js @@ -2,27 +2,34 @@ var Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), + PushAdapter = require('./Adapters/Push/PushAdapter'), rest = require('./rest'); -var validPushTypes = ['ios', 'android']; - function handlePushWithoutQueue(req) { validateMasterKey(req); var where = getQueryCondition(req); - validateDeviceType(where); + var pushAdapter = PushAdapter.getAdapter(); + validatePushType(where, pushAdapter.getValidPushTypes()); // Replace the expiration_time with a valid Unix epoch milliseconds time req.body['expiration_time'] = getExpirationTime(req); - return rest.find(req.config, req.auth, '_Installation', where).then(function(response) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); + // TODO: If the req can pass the checking, we return immediately instead of waiting + // pushes to be sent. We probably change this behaviour in the future. + rest.find(req.config, req.auth, '_Installation', where).then(function(response) { + return pushAdapter.send(req.body, response.results); + }); + return Parse.Promise.as({ + response: { + 'result': true + } }); } /** * Check whether the deviceType parameter in qury condition is valid or not. * @param {Object} where A query condition + * @param {Array} validPushTypes An array of valid push types(string) */ -function validateDeviceType(where) { +function validatePushType(where, validPushTypes) { var where = where || {}; var deviceTypeField = where.deviceType || {}; var deviceTypes = []; @@ -113,12 +120,12 @@ var router = new PromiseRouter(); router.route('POST','/push', handlePushWithoutQueue); module.exports = { - router: router + router: router, } if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { module.exports.getQueryCondition = getQueryCondition; module.exports.validateMasterKey = validateMasterKey; module.exports.getExpirationTime = getExpirationTime; - module.exports.validateDeviceType = validateDeviceType; + module.exports.validatePushType = validatePushType; } From 06b1ee2362b9a3ad08e31a6bc6598ef2962c70bd Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Wed, 10 Feb 2016 12:03:02 -0800 Subject: [PATCH 67/84] Make push follow controller and adapter style --- spec/ParsePushAdapter.spec.js | 16 +--- spec/{push.spec.js => PushController.spec.js} | 32 ++++---- src/Adapters/Push/ParsePushAdapter.js | 12 +-- src/Adapters/Push/PushAdapter.js | 22 ++---- .../PushController.js} | 78 +++++++++++-------- src/index.js | 25 ++++-- 6 files changed, 90 insertions(+), 95 deletions(-) rename spec/{push.spec.js => PushController.spec.js} (80%) rename src/{push.js => Controllers/PushController.js} (67%) diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index ca0da190..1f860a90 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -2,14 +2,6 @@ var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); describe('ParsePushAdapter', () => { it('can be initialized', (done) => { - var parsePushAdapter = new ParsePushAdapter(); - - expect(parsePushAdapter.validPushTypes).toEqual(['ios', 'android']); - done(); - }); - - it('can initialize', (done) => { - var parsePushAdapter = new ParsePushAdapter(); // Make mock config var pushConfig = { android: { @@ -30,7 +22,7 @@ describe('ParsePushAdapter', () => { ] }; - parsePushAdapter.initialize(pushConfig); + var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios var iosSenders = parsePushAdapter.senders['ios']; expect(iosSenders.length).toBe(2); @@ -53,7 +45,6 @@ describe('ParsePushAdapter', () => { }); it('can throw on initializing with unsupported push type', (done) => { - var parsePushAdapter = new ParsePushAdapter(); // Make mock config var pushConfig = { win: { @@ -63,20 +54,19 @@ describe('ParsePushAdapter', () => { }; expect(function() { - parsePushAdapter.initialize(pushConfig) + new ParsePushAdapter(pushConfig); }).toThrow(); done(); }); it('can throw on initializing with invalid pushConfig', (done) => { - var parsePushAdapter = new ParsePushAdapter(); // Make mock config var pushConfig = { android: 123 }; expect(function() { - parsePushAdapter.initialize(pushConfig) + new ParsePushAdapter(pushConfig); }).toThrow(); done(); }); diff --git a/spec/push.spec.js b/spec/PushController.spec.js similarity index 80% rename from spec/push.spec.js rename to spec/PushController.spec.js index ba7588f3..5414eca2 100644 --- a/spec/push.spec.js +++ b/spec/PushController.spec.js @@ -1,6 +1,6 @@ -var push = require('../src/push'); +var PushController = require('../src/Controllers/PushController').PushController; -describe('push', () => { +describe('PushController', () => { it('can check valid master key of request', (done) => { // Make mock request var request = { @@ -13,7 +13,7 @@ describe('push', () => { } expect(() => { - push.validateMasterKey(request); + PushController.validateMasterKey(request); }).not.toThrow(); done(); }); @@ -30,7 +30,7 @@ describe('push', () => { } expect(() => { - push.validateMasterKey(request); + PushController.validateMasterKey(request); }).toThrow(); done(); }); @@ -43,7 +43,7 @@ describe('push', () => { } } - var where = push.getQueryCondition(request); + var where = PushController.getQueryCondition(request); expect(where).toEqual({ 'channels': { '$in': ['Giants', 'Mets'] @@ -62,7 +62,7 @@ describe('push', () => { } } - var where = push.getQueryCondition(request); + var where = PushController.getQueryCondition(request); expect(where).toEqual({ 'injuryReports': true }); @@ -77,7 +77,7 @@ describe('push', () => { } expect(function() { - push.getQueryCondition(request); + PushController.getQueryCondition(request); }).toThrow(); done(); }); @@ -96,7 +96,7 @@ describe('push', () => { } expect(function() { - push.getQueryCondition(request); + PushController.getQueryCondition(request); }).toThrow(); done(); }); @@ -108,7 +108,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -121,7 +121,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -136,7 +136,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).not.toThrow(); done(); }); @@ -149,7 +149,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -162,7 +162,7 @@ describe('push', () => { var validPushTypes = ['ios', 'android']; expect(function(){ - push.validatePushType(where, validPushTypes); + PushController.validatePushType(where, validPushTypes); }).toThrow(); done(); }); @@ -176,7 +176,7 @@ describe('push', () => { } } - var time = push.getExpirationTime(request); + var time = PushController.getExpirationTime(request); expect(time).toEqual(new Date(timeStr).valueOf()); done(); }); @@ -190,7 +190,7 @@ describe('push', () => { } } - var time = push.getExpirationTime(request); + var time = PushController.getExpirationTime(request); expect(time).toEqual(timeNumber * 1000); done(); }); @@ -204,7 +204,7 @@ describe('push', () => { } expect(function(){ - push.getExpirationTime(request); + PushController.getExpirationTime(request); }).toThrow(); done(); }); diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 55b99032..43987bc5 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -7,16 +7,10 @@ const Parse = require('parse/node').Parse; const GCM = require('../../GCM'); const APNS = require('../../APNS'); -function ParsePushAdapter() { - this.validPushTypes = ['ios', 'android']; - this.senders = {}; -} +function ParsePushAdapter(pushConfig) { + this.validPushTypes = ['ios', 'android']; + this.senders = {}; -/** - * Register push senders - * @param {Object} pushConfig The push configuration which is given when parse server is initialized - */ -ParsePushAdapter.prototype.initialize = function(pushConfig) { // Initialize senders for (let validPushType of this.validPushTypes) { this.senders[validPushType] = []; diff --git a/src/Adapters/Push/PushAdapter.js b/src/Adapters/Push/PushAdapter.js index ab2f7133..1e07467f 100644 --- a/src/Adapters/Push/PushAdapter.js +++ b/src/Adapters/Push/PushAdapter.js @@ -3,27 +3,15 @@ // Allows you to change the push notification mechanism. // // Adapter classes must implement the following functions: -// * initialize(pushConfig) -// * getPushSenders(parseConfig) -// * getValidPushTypes(parseConfig) +// * getValidPushTypes() // * send(devices, installations) // // Default is ParsePushAdapter, which uses GCM for // android push and APNS for ios push. +export class PushAdapter { + send(devices, installations) { } -var ParsePushAdapter = require('./ParsePushAdapter'); - -var adapter = new ParsePushAdapter(); - -function setAdapter(pushAdapter) { - adapter = pushAdapter; + getValidPushTypes() { } } -function getAdapter() { - return adapter; -} - -module.exports = { - getAdapter: getAdapter, - setAdapter: setAdapter -}; +export default PushAdapter; diff --git a/src/push.js b/src/Controllers/PushController.js similarity index 67% rename from src/push.js rename to src/Controllers/PushController.js index 013b85d4..9f9252dc 100644 --- a/src/push.js +++ b/src/Controllers/PushController.js @@ -1,27 +1,44 @@ -// push.js +import { Parse } from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; -var Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - PushAdapter = require('./Adapters/Push/PushAdapter'), - rest = require('./rest'); +export class PushController { -function handlePushWithoutQueue(req) { - validateMasterKey(req); - var where = getQueryCondition(req); - var pushAdapter = PushAdapter.getAdapter(); - validatePushType(where, pushAdapter.getValidPushTypes()); - // Replace the expiration_time with a valid Unix epoch milliseconds time - req.body['expiration_time'] = getExpirationTime(req); - // TODO: If the req can pass the checking, we return immediately instead of waiting - // pushes to be sent. We probably change this behaviour in the future. - rest.find(req.config, req.auth, '_Installation', where).then(function(response) { - return pushAdapter.send(req.body, response.results); - }); - return Parse.Promise.as({ - response: { - 'result': true - } - }); + constructor(pushAdapter) { + this._pushAdapter = pushAdapter; + } + + handlePOST(req) { + if (!this._pushAdapter) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Push adapter is not availabe'); + } + + validateMasterKey(req); + var where = getQueryCondition(req); + var pushAdapter = this._pushAdapter; + validatePushType(where, pushAdapter.getValidPushTypes()); + // Replace the expiration_time with a valid Unix epoch milliseconds time + req.body['expiration_time'] = getExpirationTime(req); + // TODO: If the req can pass the checking, we return immediately instead of waiting + // pushes to be sent. We probably change this behaviour in the future. + rest.find(req.config, req.auth, '_Installation', where).then(function(response) { + return pushAdapter.send(req.body, response.results); + }); + return Parse.Promise.as({ + response: { + 'result': true + } + }); + } + + getExpressRouter() { + var router = new PromiseRouter(); + router.route('POST','/push', (req) => { + return this.handlePOST(req); + }); + return router; + } } /** @@ -116,16 +133,11 @@ function validateMasterKey(req) { } } -var router = new PromiseRouter(); -router.route('POST','/push', handlePushWithoutQueue); - -module.exports = { - router: router, -} - if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { - module.exports.getQueryCondition = getQueryCondition; - module.exports.validateMasterKey = validateMasterKey; - module.exports.getExpirationTime = getExpirationTime; - module.exports.validatePushType = validatePushType; + PushController.getQueryCondition = getQueryCondition; + PushController.validateMasterKey = validateMasterKey; + PushController.getExpirationTime = getExpirationTime; + PushController.validatePushType = validatePushType; } + +export default PushController; diff --git a/src/index.js b/src/index.js index 1bc02dc7..16c15098 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,6 @@ var batch = require('./batch'), cache = require('./cache'), DatabaseAdapter = require('./DatabaseAdapter'), express = require('express'), - PushAdapter = require('./Adapters/Push/PushAdapter'), middlewares = require('./middlewares'), multer = require('multer'), Parse = require('parse/node').Parse, @@ -14,9 +13,12 @@ var batch = require('./batch'), import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { S3Adapter } from './Adapters/Files/S3Adapter'; - import { FilesController } from './Controllers/FilesController'; +import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; +import { PushController } from './Controllers/PushController'; + + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -42,6 +44,8 @@ addParseCloud(); // "dotNetKey": optional key from Parse dashboard // "restAPIKey": optional key from Parse dashboard // "javascriptKey": optional key from Parse dashboard +// "push": optional key from configure push + function ParseServer(args) { if (!args.appId || !args.masterKey) { throw 'You must provide an appId and masterKey!'; @@ -51,8 +55,18 @@ function ParseServer(args) { DatabaseAdapter.setAdapter(args.databaseAdapter); } + // Make files adapter let filesAdapter = args.filesAdapter || new GridStoreAdapter(); + // Make push adapter + let pushConfig = args.push; + let pushAdapter; + if (pushConfig && pushConfig.adapter) { + pushAdapter = pushConfig.adapter; + } else if (pushConfig) { + pushAdapter = new ParsePushAdapter(pushConfig) + } + if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } @@ -87,10 +101,6 @@ function ParseServer(args) { cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } - // Register push senders - var pushConfig = args.push; - PushAdapter.getAdapter().initialize(pushConfig); - // Initialize the node client SDK automatically Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); if(args.serverURL) { @@ -122,13 +132,14 @@ function ParseServer(args) { router.merge(require('./sessions')); router.merge(require('./roles')); router.merge(require('./analytics')); - router.merge(require('./push').router); router.merge(require('./installations')); router.merge(require('./functions')); router.merge(require('./schemas')); if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { router.merge(require('./global_config')); } + let pushController = new PushController(pushAdapter); + router.merge(pushController.getExpressRouter()); batch.mountOnto(router); From a3e93c6d65c4ceee717382392069f5a94354f18c Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Thu, 11 Feb 2016 11:58:51 +0000 Subject: [PATCH 68/84] Add test to sign up two anonymous users --- spec/RestCreate.spec.js | 44 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index 24455507..ba810db4 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -57,6 +57,50 @@ describe('rest create', () => { }); }); + it('handles anonymous user signup', (done) => { + var data1 = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000000' + } + } + }; + var data2 = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000001' + } + } + }; + var username1; + rest.create(config, auth.nobody(config), '_User', data1) + .then((r) => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + return rest.create(config, auth.nobody(config), '_User', data1); + }).then((r) => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.username).toEqual('string'); + expect(typeof r.response.updatedAt).toEqual('string'); + username1 = r.response.username; + return rest.create(config, auth.nobody(config), '_User', data2); + }).then((r) => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.sessionToken).toEqual('string'); + return rest.create(config, auth.nobody(config), '_User', data2); + }).then((r) => { + expect(typeof r.response.objectId).toEqual('string'); + expect(typeof r.response.createdAt).toEqual('string'); + expect(typeof r.response.username).toEqual('string'); + expect(typeof r.response.updatedAt).toEqual('string'); + expect(r.response.username).not.toEqual(username1); + done(); + }); + }); + it('test facebook signup and login', (done) => { var data = { authData: { From 0012e666b8d99506d9b5c1065a115a48449a3020 Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Thu, 11 Feb 2016 12:01:06 +0000 Subject: [PATCH 69/84] Make anonymous id consistent with variable names --- spec/RestCreate.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index ba810db4..b769a3b5 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -61,14 +61,14 @@ describe('rest create', () => { var data1 = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000000' + id: '00000000-0000-0000-0000-000000000001' } } }; var data2 = { authData: { anonymous: { - id: '00000000-0000-0000-0000-000000000001' + id: '00000000-0000-0000-0000-000000000002' } } }; From ac62b235af7a45a80df87440648eb4fa4f62e635 Mon Sep 17 00:00:00 2001 From: Drew Date: Thu, 11 Feb 2016 10:35:20 -0800 Subject: [PATCH 70/84] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 63afa2a5..a843d88a 100644 --- a/README.md +++ b/README.md @@ -134,4 +134,4 @@ You can also set up an app on Parse, providing the connection string for your mo ### Not supported * Push - We did not rebuild a new push delivery system for parse-server, but we are open to working on one together with the community. -* `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and writes as a specific user within Cloud Code, you need a session token, which you can pass in the request body, or find with a query. +* `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and writes as a specific user within Cloud Code, you need the user's session token, which is available in `request.user.getSessionToken()`. From 273a20767b41eddd75983bc76b4ea1c0014a78d0 Mon Sep 17 00:00:00 2001 From: wangmengyan95 Date: Thu, 11 Feb 2016 02:13:23 -0800 Subject: [PATCH 71/84] Change APNS multiple certs handling --- spec/APNS.spec.js | 265 +++++++++++++++++++++++++- spec/GCM.spec.js | 52 +++-- spec/ParsePushAdapter.spec.js | 119 ++---------- src/APNS.js | 167 ++++++++++++---- src/Adapters/Push/ParsePushAdapter.js | 93 ++------- src/GCM.js | 62 ++++-- 6 files changed, 505 insertions(+), 253 deletions(-) diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 3525fa56..c56e35d5 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -1,6 +1,65 @@ var APNS = require('../src/APNS'); describe('APNS', () => { + + it('can initialize with single cert', (done) => { + var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleId' + } + var apns = new APNS(args); + + expect(apns.conns.length).toBe(1); + var apnsConnection = apns.conns[0]; + expect(apnsConnection.index).toBe(0); + expect(apnsConnection.bundleId).toBe(args.bundleId); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = apnsConnection.options; + expect(prodApnsOptions.cert).toBe(args.cert); + expect(prodApnsOptions.key).toBe(args.key); + expect(prodApnsOptions.production).toBe(args.production); + done(); + }); + + it('can initialize with multiple certs', (done) => { + var args = [ + { + cert: 'devCert.pem', + key: 'devKey.pem', + production: false, + bundleId: 'bundleId' + }, + { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleIdAgain' + } + ] + + var apns = new APNS(args); + expect(apns.conns.length).toBe(2); + var devApnsConnection = apns.conns[1]; + expect(devApnsConnection.index).toBe(1); + var devApnsOptions = devApnsConnection.options; + expect(devApnsOptions.cert).toBe(args[0].cert); + expect(devApnsOptions.key).toBe(args[0].key); + expect(devApnsOptions.production).toBe(args[0].production); + expect(devApnsConnection.bundleId).toBe(args[0].bundleId); + + var prodApnsConnection = apns.conns[0]; + expect(prodApnsConnection.index).toBe(0); + // TODO: Remove this checking onec we inject APNS + var prodApnsOptions = prodApnsConnection.options; + expect(prodApnsOptions.cert).toBe(args[1].cert); + expect(prodApnsOptions.key).toBe(args[1].key); + expect(prodApnsOptions.production).toBe(args[1].production); + expect(prodApnsOptions.bundleId).toBe(args[1].bundleId); + done(); + }); + it('can generate APNS notification', (done) => { //Mock request data var data = { @@ -29,12 +88,195 @@ describe('APNS', () => { done(); }); - it('can send APNS notification', (done) => { - var apns = new APNS(); - var sender = { - pushNotification: jasmine.createSpy('send') + it('can choose conns for device without appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = {}; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0, 1]); + done(); + }); + + it('can choose conns for device with valid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'bundleId' }; - apns.sender = sender; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([0]); + done(); + }); + + it('can choose conns for device with invalid appIdentifier', (done) => { + // Mock conns + var conns = [ + { + bundleId: 'bundleId' + }, + { + bundleId: 'bundleIdAgain' + } + ]; + // Mock device + var device = { + appIdentifier: 'invalid' + }; + + var qualifiedConns = APNS.chooseConns(conns, device); + expect(qualifiedConns).toEqual([]); + done(); + }); + + it('can handle transmission error when notification is not in cache or device is missing', (done) => { + // Mock conns + var conns = []; + var errorCode = 1; + var notification = undefined; + var device = {}; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + + var notification = {}; + var device = undefined; + + APNS.handleTransmissionError(conns, errorCode, notification, device); + done(); + }); + + it('can handle transmission error when there are other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 0, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when there is no other qualified conns', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + } + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 2, + appIdentifier: 'bundleId1' + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).not.toHaveBeenCalled(); + expect(conns[3].pushNotification).not.toHaveBeenCalled(); + expect(conns[4].pushNotification).toHaveBeenCalled(); + done(); + }); + + it('can handle transmission error when device has no appIdentifier', (done) => { + // Mock conns + var conns = [ + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId1' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId2' + }, + { + pushNotification: jasmine.createSpy('pushNotification'), + bundleId: 'bundleId3' + }, + ]; + var errorCode = 1; + var notification = {}; + var apnDevice = { + connIndex: 1, + }; + + APNS.handleTransmissionError(conns, errorCode, notification, apnDevice); + + expect(conns[0].pushNotification).not.toHaveBeenCalled(); + expect(conns[1].pushNotification).not.toHaveBeenCalled(); + expect(conns[2].pushNotification).toHaveBeenCalled(); + done(); + }); + + it('can send APNS notification', (done) => { + var args = { + cert: 'prodCert.pem', + key: 'prodKey.pem', + production: true, + bundleId: 'bundleId' + } + var apns = new APNS(args); + var conn = { + pushNotification: jasmine.createSpy('send'), + bundleId: 'bundleId' + }; + apns.conns = [ conn ]; // Mock data var expirationTime = 1454571491354 var data = { @@ -45,16 +287,21 @@ describe('APNS', () => { } // Mock devices var devices = [ - { deviceToken: 'token' } + { + deviceToken: '112233', + appIdentifier: 'bundleId' + } ]; var promise = apns.send(data, devices); - expect(sender.pushNotification).toHaveBeenCalled(); - var args = sender.pushNotification.calls.first().args; + expect(conn.pushNotification).toHaveBeenCalled(); + var args = conn.pushNotification.calls.first().args; var notification = args[0]; expect(notification.alert).toEqual(data.data.alert); expect(notification.expiry).toEqual(data['expiration_time']); - expect(args[1]).toEqual(['token']); + var apnDevice = args[1] + expect(apnDevice.connIndex).toEqual(0); + expect(apnDevice.appIdentifier).toEqual('bundleId'); done(); }); }); diff --git a/spec/GCM.spec.js b/spec/GCM.spec.js index 3e2a7947..30f1a997 100644 --- a/spec/GCM.spec.js +++ b/spec/GCM.spec.js @@ -1,6 +1,23 @@ var GCM = require('../src/GCM'); describe('GCM', () => { + it('can initialize', (done) => { + var args = { + apiKey: 'apiKey' + }; + var gcm = new GCM(args); + expect(gcm.sender.key).toBe(args.apiKey); + done(); + }); + + it('can throw on initializing with invalid args', (done) => { + var args = 123 + expect(function() { + new GCM(args); + }).toThrow(); + done(); + }); + it('can generate GCM Payload without expiration time', (done) => { //Mock request data var data = { @@ -90,7 +107,9 @@ describe('GCM', () => { }); it('can send GCM request', (done) => { - var gcm = new GCM('apiKey'); + var gcm = new GCM({ + apiKey: 'apiKey' + }); // Mock gcm sender var sender = { send: jasmine.createSpy('send') @@ -111,7 +130,7 @@ describe('GCM', () => { } ]; - var promise = gcm.send(data, devices); + gcm.send(data, devices); expect(sender.send).toHaveBeenCalled(); var args = sender.send.calls.first().args; // It is too hard to verify message of gcm library, we just verify tokens and retry times @@ -120,24 +139,21 @@ describe('GCM', () => { done(); }); - it('can throw on sending when we have too many registration tokens', (done) => { - var gcm = new GCM('apiKey'); - // Mock gcm sender - var sender = { - send: jasmine.createSpy('send') - }; - gcm.sender = sender; + it('can slice devices', (done) => { // Mock devices - var devices = []; - for (var i = 0; i <= 2000; i++) { - devices.push({ - deviceToken: i.toString() - }); - } + var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; - expect(function() { - gcm.send({}, devices); - }).toThrow(); + var chunkDevices = GCM.sliceDevices(devices, 3); + expect(chunkDevices).toEqual([ + [makeDevice(1), makeDevice(2), makeDevice(3)], + [makeDevice(4)] + ]); done(); }); + + function makeDevice(deviceToken) { + return { + deviceToken: deviceToken + }; + } }); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 1f860a90..4cc4f0f8 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -1,4 +1,6 @@ var ParsePushAdapter = require('../src/Adapters/Push/ParsePushAdapter'); +var APNS = require('../src/APNS'); +var GCM = require('../src/GCM'); describe('ParsePushAdapter', () => { it('can be initialized', (done) => { @@ -12,35 +14,25 @@ describe('ParsePushAdapter', () => { { cert: 'prodCert.pem', key: 'prodKey.pem', - production: true + production: true, + bundleId: 'bundleId' }, { cert: 'devCert.pem', key: 'devKey.pem', - production: false + production: false, + bundleId: 'bundleIdAgain' } ] }; var parsePushAdapter = new ParsePushAdapter(pushConfig); // Check ios - var iosSenders = parsePushAdapter.senders['ios']; - expect(iosSenders.length).toBe(2); - // TODO: Remove this checking onec we inject APNS - var prodApnsOptions = iosSenders[0].sender.options; - expect(prodApnsOptions.cert).toBe(pushConfig.ios[0].cert); - expect(prodApnsOptions.key).toBe(pushConfig.ios[0].key); - expect(prodApnsOptions.production).toBe(pushConfig.ios[0].production); - var devApnsOptions = iosSenders[1].sender.options; - expect(devApnsOptions.cert).toBe(pushConfig.ios[1].cert); - expect(devApnsOptions.key).toBe(pushConfig.ios[1].key); - expect(devApnsOptions.production).toBe(pushConfig.ios[1].production); + var iosSender = parsePushAdapter.senderMap['ios']; + expect(iosSender instanceof APNS).toBe(true); // Check android - var androidSenders = parsePushAdapter.senders['android']; - expect(androidSenders.length).toBe(1); - var androidSender = androidSenders[0]; - // TODO: Remove this checking onec we inject GCM - expect(androidSender.sender.key).toBe(pushConfig.android.apiKey); + var androidSender = parsePushAdapter.senderMap['android']; + expect(androidSender instanceof GCM).toBe(true); done(); }); @@ -59,46 +51,6 @@ describe('ParsePushAdapter', () => { done(); }); - it('can throw on initializing with invalid pushConfig', (done) => { - // Make mock config - var pushConfig = { - android: 123 - }; - - expect(function() { - new ParsePushAdapter(pushConfig); - }).toThrow(); - done(); - }); - - it('can get push senders', (done) => { - var parsePushAdapter = new ParsePushAdapter(); - // Mock push senders - var androidSender = {}; - var iosSender = {}; - var iosSenderAgain = {}; - parsePushAdapter.senders = { - android: [ - androidSender - ], - ios: [ - iosSender, - iosSenderAgain - ] - }; - - expect(parsePushAdapter.getPushSenders('android')).toEqual([androidSender]); - expect(parsePushAdapter.getPushSenders('ios')).toEqual([iosSender, iosSenderAgain]); - done(); - }); - - it('can get empty push senders', (done) => { - var parsePushAdapter = new ParsePushAdapter(); - - expect(parsePushAdapter.getPushSenders('android')).toEqual([]); - done(); - }); - it('can get valid push types', (done) => { var parsePushAdapter = new ParsePushAdapter(); @@ -128,31 +80,10 @@ describe('ParsePushAdapter', () => { } ]; - var deviceTokenMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes); - expect(deviceTokenMap['android']).toEqual([makeDevice('androidToken')]); - expect(deviceTokenMap['ios']).toEqual([makeDevice('iosToken')]); - expect(deviceTokenMap['win']).toBe(undefined); - done(); - }); - - it('can slice ios devices', (done) => { - // Mock devices - var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; - - var chunkDevices = ParsePushAdapter.sliceDevices('ios', devices, 2); - expect(chunkDevices).toEqual([devices]); - done(); - }); - - it('can slice android devices', (done) => { - // Mock devices - var devices = [makeDevice(1), makeDevice(2), makeDevice(3), makeDevice(4)]; - - var chunkDevices = ParsePushAdapter.sliceDevices('android', devices, 3); - expect(chunkDevices).toEqual([ - [makeDevice(1), makeDevice(2), makeDevice(3)], - [makeDevice(4)] - ]); + var deviceMap = ParsePushAdapter.classifyInstallation(installations, validPushTypes); + expect(deviceMap['android']).toEqual([makeDevice('androidToken')]); + expect(deviceMap['ios']).toEqual([makeDevice('iosToken')]); + expect(deviceMap['win']).toBe(undefined); done(); }); @@ -166,14 +97,11 @@ describe('ParsePushAdapter', () => { var iosSender = { send: jasmine.createSpy('send') }; - var iosSenderAgain = { - send: jasmine.createSpy('send') + var senderMap = { + ios: iosSender, + android: androidSender }; - var senders = { - ios: [iosSender, iosSenderAgain], - android: [androidSender] - }; - parsePushAdapter.senders = senders; + parsePushAdapter.senderMap = senderMap; // Mock installations var installations = [ { @@ -210,18 +138,13 @@ describe('ParsePushAdapter', () => { expect(args[1]).toEqual([ makeDevice('iosToken') ]); - expect(iosSenderAgain.send).toHaveBeenCalled(); - args = iosSenderAgain.send.calls.first().args; - expect(args[0]).toEqual(data); - expect(args[1]).toEqual([ - makeDevice('iosToken') - ]); done(); }); - function makeDevice(deviceToken) { + function makeDevice(deviceToken, appIdentifier) { return { - deviceToken: deviceToken + deviceToken: deviceToken, + appIdentifier: appIdentifier }; } }); diff --git a/src/APNS.js b/src/APNS.js index e6275369..500be9e2 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -8,37 +8,77 @@ const apn = require('apn'); /** * Create a new connection to the APN service. * @constructor - * @param {Object} args Arguments to config APNS connection - * @param {String} args.cert The filename of the connection certificate to load from disk, default is cert.pem - * @param {String} args.key The filename of the connection key to load from disk, default is key.pem + * @param {Object|Array} args An argument or a list of arguments to config APNS connection + * @param {String} args.cert The filename of the connection certificate to load from disk + * @param {String} args.key The filename of the connection key to load from disk + * @param {String} args.pfx The filename for private key, certificate and CA certs in PFX or PKCS12 format, it will overwrite cert and key * @param {String} args.passphrase The passphrase for the connection key, if required + * @param {String} args.bundleId The bundleId for cert * @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox */ function APNS(args) { - this.sender = new apn.connection(args); + // Since for ios, there maybe multiple cert/key pairs, + // typePushConfig can be an array. + let apnsArgsList = []; + if (Array.isArray(args)) { + apnsArgsList = apnsArgsList.concat(args); + } else if (typeof args === 'object') { + apnsArgsList.push(args); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'APNS Configuration is invalid'); + } - this.sender.on('connected', function() { - console.log('APNS Connected'); - }); - - this.sender.on('transmissionError', function(errCode, notification, device) { - console.error('APNS Notification caused error: ' + errCode + ' for device ', device, notification); - // TODO: For error caseud by invalid deviceToken, we should mark those installations. - }); - - this.sender.on("timeout", function () { - console.log("APNS Connection Timeout"); - }); - - this.sender.on("disconnected", function() { - console.log("APNS Disconnected"); - }); - - this.sender.on("socketError", console.error); - - this.sender.on("transmitted", function(notification, device) { - console.log("APNS Notification transmitted to:" + device.token.toString("hex")); + this.conns = []; + for (let apnsArgs of apnsArgsList) { + let conn = new apn.Connection(apnsArgs); + if (!apnsArgs.bundleId) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'BundleId is mssing for %j', apnsArgs); + } + conn.bundleId = apnsArgs.bundleId; + // Set the priority of the conns, prod cert has higher priority + if (apnsArgs.production) { + conn.priority = 0; + } else { + conn.priority = 1; + } + + // Set apns client callbacks + conn.on('connected', () => { + console.log('APNS Connection %d Connected', conn.index); + }); + + conn.on('transmissionError', (errCode, notification, apnDevice) => { + handleTransmissionError(this.conns, errCode, notification, apnDevice); + }); + + conn.on('timeout', () => { + console.log('APNS Connection %d Timeout', conn.index); + }); + + conn.on('disconnected', () => { + console.log('APNS Connection %d Disconnected', conn.index); + }); + + conn.on('socketError', () => { + console.log('APNS Connection %d Socket Error', conn.index); + }); + + conn.on('transmitted', function(notification, device) { + console.log('APNS Connection %d Notification transmitted to %s', conn.index, device.token.toString('hex')); + }); + + this.conns.push(conn); + } + // Sort the conn based on priority ascending, high pri first + this.conns.sort((s1, s2) => { + return s1.priority - s2.priority; }); + // Set index of conns + for (let index = 0; index < this.conns.length; index++) { + this.conns[index].index = index; + } } /** @@ -51,23 +91,84 @@ APNS.prototype.send = function(data, devices) { let coreData = data.data; let expirationTime = data['expiration_time']; let notification = generateNotification(coreData, expirationTime); - let deviceTokens = []; for (let device of devices) { - deviceTokens.push(device.deviceToken); + let qualifiedConnIndexs = chooseConns(this.conns, device); + // We can not find a valid conn, just ignore this device + if (qualifiedConnIndexs.length == 0) { + continue; + } + let conn = this.conns[qualifiedConnIndexs[0]]; + let apnDevice = new apn.Device(device.deviceToken); + apnDevice.connIndex = qualifiedConnIndexs[0]; + // Add additional appIdentifier info to apn device instance + if (device.appIdentifier) { + apnDevice.appIdentifier = device.appIdentifier; + } + conn.pushNotification(notification, apnDevice); } - this.sender.pushNotification(notification, deviceTokens); - // TODO: pushNotification will push the notification to apn's queue. - // We do not handle error in V1, we just relies apn to auto retry and send the - // notifications. return Parse.Promise.as(); } +function handleTransmissionError(conns, errCode, notification, apnDevice) { + console.error('APNS Notification caused error: ' + errCode + ' for device ', apnDevice, notification); + // This means the error notification is not in the cache anymore or the recepient is missing, + // we just ignore this case + if (!notification || !apnDevice) { + return + } + + // If currentConn can not send the push notification, we try to use the next available conn. + // Since conns is sorted by priority, the next conn means the next low pri conn. + // If there is no conn available, we give up on sending the notification to that device. + let qualifiedConnIndexs = chooseConns(conns, apnDevice); + let currentConnIndex = apnDevice.connIndex; + + let newConnIndex = -1; + // Find the next element of currentConnIndex in qualifiedConnIndexs + for (let index = 0; index < qualifiedConnIndexs.length - 1; index++) { + if (qualifiedConnIndexs[index] === currentConnIndex) { + newConnIndex = qualifiedConnIndexs[index + 1]; + break; + } + } + // There is no more available conns, we give up in this case + if (newConnIndex < 0 || newConnIndex >= conns.length) { + console.log('APNS can not find vaild connection for %j', apnDevice.token); + return; + } + + let newConn = conns[newConnIndex]; + // Update device conn info + apnDevice.connIndex = newConnIndex; + // Use the new conn to send the notification + newConn.pushNotification(notification, apnDevice); +} + +function chooseConns(conns, device) { + // If device does not have appIdentifier, all conns maybe proper connections. + // Otherwise we try to match the appIdentifier with bundleId + let qualifiedConns = []; + for (let index = 0; index < conns.length; index++) { + let conn = conns[index]; + // If the device we need to send to does not have + // appIdentifier, any conn could be a qualified connection + if (!device.appIdentifier || device.appIdentifier === '') { + qualifiedConns.push(index); + continue; + } + if (device.appIdentifier === conn.bundleId) { + qualifiedConns.push(index); + } + } + return qualifiedConns; +} + /** * Generate the apns notification from the data we get from api request. * @param {Object} coreData The data field under api request body * @returns {Object} A apns notification */ -let generateNotification = function(coreData, expirationTime) { +function generateNotification(coreData, expirationTime) { let notification = new apn.notification(); let payload = {}; for (let key in coreData) { @@ -101,5 +202,7 @@ let generateNotification = function(coreData, expirationTime) { if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { APNS.generateNotification = generateNotification; + APNS.chooseConns = chooseConns; + APNS.handleTransmissionError = handleTransmissionError; } module.exports = APNS; diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 43987bc5..1ae1647f 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -9,12 +9,7 @@ const APNS = require('../../APNS'); function ParsePushAdapter(pushConfig) { this.validPushTypes = ['ios', 'android']; - this.senders = {}; - - // Initialize senders - for (let validPushType of this.validPushTypes) { - this.senders[validPushType] = []; - } + this.senderMap = {}; pushConfig = pushConfig || {}; let pushTypes = Object.keys(pushConfig); @@ -23,47 +18,17 @@ function ParsePushAdapter(pushConfig) { throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Push to ' + pushTypes + ' is not supported'); } - - let typePushConfig = pushConfig[pushType]; - let senderArgs = []; - // Since for ios, there maybe multiple cert/key pairs, - // typePushConfig can be an array. - if (Array.isArray(typePushConfig)) { - senderArgs = senderArgs.concat(typePushConfig); - } else if (typeof typePushConfig === 'object') { - senderArgs.push(typePushConfig); - } else { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Push Configuration is invalid'); - } - for (let senderArg of senderArgs) { - let sender; - switch (pushType) { - case 'ios': - sender = new APNS(senderArg); - break; - case 'android': - sender = new GCM(senderArg); - break; - } - this.senders[pushType].push(sender); + switch (pushType) { + case 'ios': + this.senderMap[pushType] = new APNS(pushConfig[pushType]); + break; + case 'android': + this.senderMap[pushType] = new GCM(pushConfig[pushType]); + break; } } } -/** - * Get an array of push senders based on the push type. - * @param {String} The push type - * @returns {Array|Undefined} An array of push senders - */ -ParsePushAdapter.prototype.getPushSenders = function(pushType) { - if (!this.senders[pushType]) { - console.log('No push sender for push type %s', pushType); - return []; - } - return this.senders[pushType]; -} - /** * Get an array of valid push types. * @returns {Array} An array of valid push types @@ -76,24 +41,18 @@ ParsePushAdapter.prototype.send = function(data, installations) { let deviceMap = classifyInstallation(installations, this.validPushTypes); let sendPromises = []; for (let pushType in deviceMap) { - let senders = this.getPushSenders(pushType); - // Since ios have dev/prod cert, a push type may have multiple senders - for (let sender of senders) { - let devices = deviceMap[pushType]; - if (!sender || devices.length == 0) { - continue; - } - // For android, we can only have 1000 recepients per send - let chunkDevices = sliceDevices(pushType, devices, GCM.GCMRegistrationTokensMax); - for (let chunkDevice of chunkDevices) { - sendPromises.push(sender.send(data, chunkDevice)); - } + let sender = this.senderMap[pushType]; + if (!sender) { + console.log('Can not find sender for push type %s, %j', pushType, data); + continue; } + let devices = deviceMap[pushType]; + sendPromises.push(sender.send(data, devices)); } return Parse.Promise.when(sendPromises); } -/** +/**g * Classify the device token of installations based on its device type. * @param {Object} installations An array of installations * @param {Array} validPushTypes An array of valid push types(string) @@ -113,7 +72,8 @@ function classifyInstallation(installations, validPushTypes) { let pushType = installation.deviceType; if (deviceMap[pushType]) { deviceMap[pushType].push({ - deviceToken: installation.deviceToken + deviceToken: installation.deviceToken, + appIdentifier: installation.appIdentifier }); } else { console.log('Unknown push type from installation %j', installation); @@ -122,26 +82,7 @@ function classifyInstallation(installations, validPushTypes) { return deviceMap; } -/** - * Slice a list of devices to several list of devices with fixed chunk size. - * @param {String} pushType The push type of the given device tokens - * @param {Array} devices An array of devices - * @param {Number} chunkSize The size of the a chunk - * @returns {Array} An array which contaisn several arries of devices with fixed chunk size - */ -function sliceDevices(pushType, devices, chunkSize) { - if (pushType !== 'android') { - return [devices]; - } - let chunkDevices = []; - while (devices.length > 0) { - chunkDevices.push(devices.splice(0, chunkSize)); - } - return chunkDevices; -} - if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { ParsePushAdapter.classifyInstallation = classifyInstallation; - ParsePushAdapter.sliceDevices = sliceDevices; } module.exports = ParsePushAdapter; diff --git a/src/GCM.js b/src/GCM.js index 9dfe1b06..be09f222 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -8,6 +8,10 @@ const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks const GCMRegistrationTokensMax = 1000; function GCM(args) { + if (typeof args !== 'object' || !args.apiKey) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'GCM Configuration is invalid'); + } this.sender = new gcm.Sender(args.apiKey); } @@ -18,10 +22,6 @@ function GCM(args) { * @returns {Object} A promise which is resolved after we get results from gcm */ GCM.prototype.send = function(data, devices) { - if (devices.length >= GCMRegistrationTokensMax) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Too many registration tokens for a GCM request.'); - } let pushId = randomstring.generate({ length: 10, charset: 'alphanumeric' @@ -37,21 +37,30 @@ GCM.prototype.send = function(data, devices) { let gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime); // Make and send gcm request let message = new gcm.Message(gcmPayload); - let promise = new Parse.Promise(); - let registrationTokens = [] - for (let device of devices) { - registrationTokens.push(device.deviceToken); - } - this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { - // TODO: Use the response from gcm to generate and save push report - // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation - console.log('GCM request and response %j', { - request: message, - response: response + + let sendPromises = []; + // For android, we can only have 1000 recepients per send, so we need to slice devices to + // chunk if necessary + let chunkDevices = sliceDevices(devices, GCMRegistrationTokensMax); + for (let chunkDevice of chunkDevices) { + let sendPromise = new Parse.Promise(); + let registrationTokens = [] + for (let device of chunkDevice) { + registrationTokens.push(device.deviceToken); + } + this.sender.send(message, { registrationTokens: registrationTokens }, 5, (error, response) => { + // TODO: Use the response from gcm to generate and save push report + // TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation + console.log('GCM request and response %j', { + request: message, + response: response + }); + sendPromise.resolve(); }); - promise.resolve(); - }); - return promise; + sendPromises.push(sendPromise); + } + + return Parse.Promise.when(sendPromises); } /** @@ -62,7 +71,7 @@ GCM.prototype.send = function(data, devices) { * @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined * @returns {Object} A promise which is resolved after we get results from gcm */ -let generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { +function generateGCMPayload(coreData, pushId, timeStamp, expirationTime) { let payloadData = { 'time': new Date(timeStamp).toISOString(), 'push_id': pushId, @@ -86,9 +95,22 @@ let generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) { return payload; } -GCM.GCMRegistrationTokensMax = GCMRegistrationTokensMax; +/** + * Slice a list of devices to several list of devices with fixed chunk size. + * @param {Array} devices An array of devices + * @param {Number} chunkSize The size of the a chunk + * @returns {Array} An array which contaisn several arries of devices with fixed chunk size + */ +function sliceDevices(devices, chunkSize) { + let chunkDevices = []; + while (devices.length > 0) { + chunkDevices.push(devices.splice(0, chunkSize)); + } + return chunkDevices; +} if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { GCM.generateGCMPayload = generateGCMPayload; + GCM.sliceDevices = sliceDevices; } module.exports = GCM; From ea30a9836b0dee68046e39f5448262cedebb0cf0 Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Thu, 11 Feb 2016 13:12:13 -0800 Subject: [PATCH 72/84] Updates for 2.0.8 --- CHANGELOG.md | 15 +++++++++++++++ README.md | 3 ++- package.json | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..983c7415 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +## Parse Server Changelog + +### 2.0.8 (2/11/2016) + +Add: support for Android and iOS push notifications +Eperimental: Cloud Code validation hooks (can mark as non-experimental after we have docs) +Experimental: support for schemas API (GET and POST only) +Experimental: support for Parse Config (GET and POST only) +Fix: Querying objects with equality constraint on array column +Fix: User logout will remove session token +Fix: Various files related bugs +Fix: Force minimum node version 4.3 due to security issues in earlier version +Performance Improvement: Improved caching + + diff --git a/README.md b/README.md index a9b67dd6..dfa8b0f0 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ We also have an [example project](https://github.com/ParsePlatform/parse-server- * fileKey - For migrated apps, this is necessary to provide access to files already hosted on Parse. * facebookAppIds - An array of valid Facebook application IDs. * serverURL - URL which will be used by Cloud Code functions to make requests against. +* push - Configuration options for APNS and GCM push. See the [wiki entry](https://github.com/ParsePlatform/parse-server/wiki/Push). #### Client key options: @@ -122,6 +123,7 @@ Now you can just run `$ parse-server` from your command line. * Pointers * Users, including Facebook login and anonymous users * Files +* Push Notifications - See the [wiki entry](https://github.com/ParsePlatform/parse-server/wiki/Push). * Installations * Sessions * Geopoints @@ -133,5 +135,4 @@ You can also set up an app on Parse, providing the connection string for your mo ### Not supported -* Push - We did not rebuild a new push delivery system for parse-server, but we are open to working on one together with the community. * `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and writes as a specific user within Cloud Code, you need the user's session token, which is available in `request.user.getSessionToken()`. diff --git a/package.json b/package.json index 987f2c08..521381f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "2.0.7", + "version": "2.0.8", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 1f660d9f7cf6f2dd86bc7f131ec07ab10d1c3b55 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 9 Feb 2016 20:45:02 -0800 Subject: [PATCH 73/84] Restructure ClassesRouter as a class. --- src/Routers/ClassesRouter.js | 87 ++++++++++++++++++++++++++++++ src/classes.js | 101 ----------------------------------- src/index.js | 34 ++++++------ src/users.js | 2 +- 4 files changed, 107 insertions(+), 117 deletions(-) create mode 100644 src/Routers/ClassesRouter.js delete mode 100644 src/classes.js diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js new file mode 100644 index 00000000..11666b20 --- /dev/null +++ b/src/Routers/ClassesRouter.js @@ -0,0 +1,87 @@ + +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; + +export class ClassesRouter { + // Returns a promise that resolves to a {response} object. + handleFind(req) { + let body = Object.assign(req.body, req.query); + let options = {}; + if (body.skip) { + options.skip = Number(body.skip); + } + if (body.limit) { + options.limit = Number(body.limit); + } + if (body.order) { + options.order = String(body.order); + } + if (body.count) { + options.count = true; + } + if (typeof body.keys == 'string') { + options.keys = body.keys; + } + if (body.include) { + options.include = String(body.include); + } + if (body.redirectClassNameForKey) { + options.redirectClassNameForKey = String(body.redirectClassNameForKey); + } + if (typeof body.where === 'string') { + body.where = JSON.parse(body.where); + } + return rest.find(req.config, req.auth, req.params.className, body.where, options) + .then((response) => { + if (response && response.results) { + for (let result of response.results) { + if (result.sessionToken) { + result.sessionToken = req.info.sessionToken || result.sessionToken; + } + } + } + return { response: response }; + }); + } + + // Returns a promise for a {response} object. + handleGet(req) { + return rest.find(req.config, req.auth, req.params.className, {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); + } + return { response: response.results[0] }; + }); + } + + handleCreate(req) { + return rest.create(req.config, req.auth, req.params.className, req.body); + } + + handleUpdate(req) { + return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); + } + + handleDelete(req) { + return rest.del(req.config, req.auth, req.params.className, req.params.objectId) + .then(() => { + return {response: {}}; + }); + } + + getExpressRouter() { + var router = new PromiseRouter(); + router.route('GET', '/classes/:className', (req) => { return this.handleFind(req); }); + router.route('GET', '/classes/:className/:objectId', (req) => { return this.handleGet(req); }); + router.route('POST', '/classes/:className', (req) => { return this.handleCreate(req); }); + router.route('PUT', '/classes/:className/:objectId', (req) => { return this.handleUpdate(req); }); + router.route('DELETE', '/classes/:className/:objectId', (req) => { return this.handleDelete(req); }); + return router; + } +} + +export default ClassesRouter; diff --git a/src/classes.js b/src/classes.js deleted file mode 100644 index f1400914..00000000 --- a/src/classes.js +++ /dev/null @@ -1,101 +0,0 @@ -// These methods handle the 'classes' routes. -// Methods of the form 'handleX' return promises and are intended to -// be used with the PromiseRouter. - -var Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); - -var router = new PromiseRouter(); - -// Returns a promise that resolves to a {response} object. -function handleFind(req) { - var body = Object.assign(req.body, req.query); - var options = {}; - if (body.skip) { - options.skip = Number(body.skip); - } - if (body.limit) { - options.limit = Number(body.limit); - } - if (body.order) { - options.order = String(body.order); - } - if (body.count) { - options.count = true; - } - if (typeof body.keys == 'string') { - options.keys = body.keys; - } - if (body.include) { - options.include = String(body.include); - } - if (body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(body.redirectClassNameForKey); - } - - if(typeof body.where === 'string') { - body.where = JSON.parse(body.where); - } - - return rest.find(req.config, req.auth, - req.params.className, body.where, options) - .then((response) => { - if (response && response.results) { - for (var result of response.results) { - if (result.sessionToken) { - result.sessionToken = req.info.sessionToken || result.sessionToken; - } - } - response.results.sessionToken - } - return {response: response}; - }); -} - -// Returns a promise for a {status, response, location} object. -function handleCreate(req) { - return rest.create(req.config, req.auth, - req.params.className, req.body); -} - -// Returns a promise for a {response} object. -function handleGet(req) { - return rest.find(req.config, req.auth, - req.params.className, {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -// Returns a promise for a {response} object. -function handleDelete(req) { - return rest.del(req.config, req.auth, - req.params.className, req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -// Returns a promise for a {response} object. -function handleUpdate(req) { - return rest.update(req.config, req.auth, - req.params.className, req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -router.route('GET', '/classes/:className', handleFind); -router.route('POST', '/classes/:className', handleCreate); -router.route('GET', '/classes/:className/:objectId', handleGet); -router.route('DELETE', '/classes/:className/:objectId', handleDelete); -router.route('PUT', '/classes/:className/:objectId', handleUpdate); - -module.exports = router; - diff --git a/src/index.js b/src/index.js index 16c15098..32ba636b 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ import { FilesController } from './Controllers/FilesController'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; import { PushController } from './Controllers/PushController'; +import { ClassesRouter } from './Routers/ClassesRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -125,25 +126,28 @@ function ParseServer(args) { api.use(middlewares.allowMethodOverride); api.use(middlewares.handleParseHeaders); - var router = new PromiseRouter(); - - router.merge(require('./classes')); - router.merge(require('./users')); - router.merge(require('./sessions')); - router.merge(require('./roles')); - router.merge(require('./analytics')); - router.merge(require('./installations')); - router.merge(require('./functions')); - router.merge(require('./schemas')); + let routers = [ + new ClassesRouter().getExpressRouter(), + require('./users'), + require('./sessions'), + require('./roles'), + require('./analytics'), + require('./installations'), + require('./functions'), + require('./schemas'), + new PushController(pushAdapter).getExpressRouter() + ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { - router.merge(require('./global_config')); + routers.push(require('./global_config')); } - let pushController = new PushController(pushAdapter); - router.merge(pushController.getExpressRouter()); - batch.mountOnto(router); + let appRouter = new PromiseRouter(); + routers.forEach((router) => { + appRouter.merge(router); + }); + batch.mountOnto(appRouter); - router.mountOnto(api); + appRouter.mountOnto(api); api.use(middlewares.handleParseErrors); diff --git a/src/users.js b/src/users.js index 788e0278..4205c666 100644 --- a/src/users.js +++ b/src/users.js @@ -91,7 +91,7 @@ function handleLogIn(req) { } // Returns a promise that resolves to a {response} object. -// TODO: share code with classes.js +// TODO: share code with ClassesRouter.js function handleFind(req) { var options = {}; if (req.body.skip) { From 4cabeb201f26dbf8920c3e5c4dce8ebb9e6583fc Mon Sep 17 00:00:00 2001 From: Roger Hu Date: Thu, 11 Feb 2016 13:43:14 -0800 Subject: [PATCH 74/84] Update CHANGELOG.md Fix spelling and itemize changes --- CHANGELOG.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983c7415..beb534d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,14 @@ ### 2.0.8 (2/11/2016) -Add: support for Android and iOS push notifications -Eperimental: Cloud Code validation hooks (can mark as non-experimental after we have docs) -Experimental: support for schemas API (GET and POST only) -Experimental: support for Parse Config (GET and POST only) -Fix: Querying objects with equality constraint on array column -Fix: User logout will remove session token -Fix: Various files related bugs -Fix: Force minimum node version 4.3 due to security issues in earlier version -Performance Improvement: Improved caching +* Add: support for Android and iOS push notifications +* Experimental: Cloud Code validation hooks (can mark as non-experimental after we have docs) +* Experimental: support for schemas API (GET and POST only) +* Experimental: support for Parse Config (GET and POST only) +* Fix: Querying objects with equality constraint on array column +* Fix: User logout will remove session token +* Fix: Various files related bugs +* Fix: Force minimum node version 4.3 due to security issues in earlier version +* Performance Improvement: Improved caching From 6a127447ad06c4eb485fd5a158299aab7171fb99 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 10 Feb 2016 21:36:40 -0800 Subject: [PATCH 75/84] Cleanup duplicate logic and refactor installations.js into InstallationsRouter. --- src/Routers/InstallationsRouter.js | 64 ++++++++++++++++++++++++ src/index.js | 3 +- src/installations.js | 80 ------------------------------ 3 files changed, 66 insertions(+), 81 deletions(-) create mode 100644 src/Routers/InstallationsRouter.js delete mode 100644 src/installations.js diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js new file mode 100644 index 00000000..033366b7 --- /dev/null +++ b/src/Routers/InstallationsRouter.js @@ -0,0 +1,64 @@ +// InstallationsRouter.js + +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; + +export class InstallationsRouter extends ClassesRouter { + handleFind(req) { + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (req.body.include) { + options.include = String(req.body.include); + } + + return rest.find(req.config, req.auth, + '_Installation', req.body.where, options) + .then((response) => { + return {response: response}; + }); + } + + handleGet(req) { + req.params.className = '_Installation'; + return super.handleGet(req); + } + + handleCreate(req) { + req.params.className = '_Installation'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_Installation'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_Installation'; + return super.handleDelete(req); + } + + getExpressRouter() { + var router = new PromiseRouter(); + router.route('GET','/installations', (req) => { return this.handleFind(req); }); + router.route('GET','/installations/:objectId', (req) => { return this.handleGet(req); }); + router.route('POST','/installations', (req) => { return this.handleCreate(req); }); + router.route('PUT','/installations/:objectId', (req) => { return this.handleUpdate(req); }); + router.route('DELETE','/installations/:objectId', (req) => { return this.handleDelete(req); }); + return router; + } +} + +export default InstallationsRouter; diff --git a/src/index.js b/src/index.js index 32ba636b..c2993400 100644 --- a/src/index.js +++ b/src/index.js @@ -19,6 +19,7 @@ import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; import { PushController } from './Controllers/PushController'; import { ClassesRouter } from './Routers/ClassesRouter'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -132,7 +133,7 @@ function ParseServer(args) { require('./sessions'), require('./roles'), require('./analytics'), - require('./installations'), + new InstallationsRouter().getExpressRouter(), require('./functions'), require('./schemas'), new PushController(pushAdapter).getExpressRouter() diff --git a/src/installations.js b/src/installations.js deleted file mode 100644 index 517c3b81..00000000 --- a/src/installations.js +++ /dev/null @@ -1,80 +0,0 @@ -// installations.js - -var Parse = require('parse/node').Parse; -var PromiseRouter = require('./PromiseRouter'); -var rest = require('./rest'); - -var router = new PromiseRouter(); - - -// Returns a promise for a {status, response, location} object. -function handleCreate(req) { - return rest.create(req.config, - req.auth, '_Installation', req.body); -} - -// Returns a promise that resolves to a {response} object. -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (req.body.include) { - options.include = String(req.body.include); - } - - return rest.find(req.config, req.auth, - '_Installation', req.body.where, options) - .then((response) => { - return {response: response}; - }); -} - -// Returns a promise for a {response} object. -function handleGet(req) { - return rest.find(req.config, req.auth, '_Installation', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -// Returns a promise for a {response} object. -function handleUpdate(req) { - return rest.update(req.config, req.auth, - '_Installation', req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -// Returns a promise for a {response} object. -function handleDelete(req) { - return rest.del(req.config, req.auth, - '_Installation', req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -router.route('POST','/installations', handleCreate); -router.route('GET','/installations', handleFind); -router.route('GET','/installations/:objectId', handleGet); -router.route('PUT','/installations/:objectId', handleUpdate); -router.route('DELETE','/installations/:objectId', handleDelete); - -module.exports = router; \ No newline at end of file From a75376523ca180bb6bae5effe8e45e492c2b6eea Mon Sep 17 00:00:00 2001 From: Wes Thomas Date: Wed, 10 Feb 2016 18:42:21 -0500 Subject: [PATCH 76/84] file DELETE support --- spec/ParseFile.spec.js | 89 ++++++++++++++++++++++++++ src/Adapters/Files/FilesAdapter.js | 2 + src/Adapters/Files/GridStoreAdapter.js | 11 ++++ src/Adapters/Files/S3Adapter.js | 14 ++++ src/Controllers/FilesController.js | 26 ++++++++ 5 files changed, 142 insertions(+) diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index b65d8f34..7287dd14 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -33,6 +33,95 @@ describe('Parse.File testing', () => { }); }); + it('supports REST end-to-end file create, read, delete, read', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/testfile.txt', + body: 'check one two', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_testfile.txt$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('check one two'); + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(200); + request.get({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: b.url + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(404); + done(); + }); + }); + }); + }); + }); + + it('blocks file deletions with missing or incorrect master-key header', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/thefile.jpg', + body: 'the file body' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); + // missing X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b = JSON.parse(body); + expect(response.statusCode).toEqual(400); + expect(del_b.code).toEqual(119); + // incorrect X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'tryagain' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b2 = JSON.parse(body); + expect(response.statusCode).toEqual(400); + expect(del_b2.code).toEqual(119); + done(); + }); + }); + }); + }); + it('handles other filetypes', done => { var headers = { 'Content-Type': 'image/jpeg', diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index 9daed517..a1d5955f 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -14,6 +14,8 @@ export class FilesAdapter { createFile(config, filename, data) { } + deleteFile(config, filename) { } + getFileData(config, filename) { } getFileLocation(config, filename) { } diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index 8c95319d..21934c9a 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -20,6 +20,17 @@ export class GridStoreAdapter extends FilesAdapter { }); } + deleteFile(config, filename) { + return config.database.connect().then(() => { + let gridStore = new GridStore(config.database.db, filename, 'w'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.unlink(); + }).then((gridStore) => { + return gridStore.close(); + }); + } + getFileData(config, filename) { return config.database.connect().then(() => { return GridStore.exist(config.database.db, filename); diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index 2c892246..b33b66f1 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -56,6 +56,20 @@ export class S3Adapter extends FilesAdapter { }); } + deleteFile(config, filename) { + return new Promise((resolve, reject) => { + let params = { + Key: this._bucketPrefix + filename + }; + this._s3Client.deleteObject(params, (err, data) =>{ + if(err !== null) { + return reject(err); + } + resolve(data); + }); + }); + } + // Search for and return a file if found by filename // Returns a promise that succeeds with the buffer result from S3 getFileData(config, filename) { diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 47454f07..321042b9 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -74,6 +74,26 @@ export class FilesController { }; } + deleteHandler() { + return (req, res, next) => { + // enforce use of master key for file deletions + if(!req.auth.isMaster){ + next(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + 'Master key required for file deletion.')); + return; + } + + this._filesAdapter.deleteFile(req.config, req.params.filename).then(() => { + res.status(200); + // TODO: return useful JSON here? + res.end(); + }).catch((error) => { + next(new Parse.Error(Parse.Error.FILE_DELETE_ERROR, + 'Could not delete file.')); + }); + }; + } + /** * Find file references in REST-format object and adds the url key * with the current mount point and app id. @@ -119,6 +139,12 @@ export class FilesController { this.createHandler() ); + router.delete('/files/:filename', + Middlewares.allowCrossDomain, + Middlewares.handleParseHeaders, + this.deleteHandler() + ); + return router; } } From ab841b5ab475b32a11549c942a9e4663f63b9163 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 20:01:14 -0800 Subject: [PATCH 77/84] Refactor and deduplicate logic in UsersRouter. --- src/Routers/UsersRouter.js | 163 ++++++++++++++++++++++++++++ src/index.js | 3 +- src/users.js | 212 ------------------------------------- 3 files changed, 165 insertions(+), 213 deletions(-) create mode 100644 src/Routers/UsersRouter.js delete mode 100644 src/users.js diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js new file mode 100644 index 00000000..4f22e07a --- /dev/null +++ b/src/Routers/UsersRouter.js @@ -0,0 +1,163 @@ +// These methods handle the User-related routes. + +import hat from 'hat'; +import deepcopy from 'deepcopy'; + +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; +import Auth from '../Auth'; +import passwordCrypto from '../password'; +import RestWrite from '../RestWrite'; + +const rack = hat.rack(); + +export class UsersRouter extends ClassesRouter { + handleFind(req) { + req.params.className = '_User'; + return super.handleFind(req); + } + + handleGet(req) { + req.params.className = '_User'; + return super.handleGet(req); + } + + handleCreate(req) { + let data = deepcopy(req.body); + data.installationId = req.info.installationId; + req.body = data; + req.params.className = '_User'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_User'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_User'; + return super.handleDelete(req); + } + + handleMe(req) { + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken }, + { include: 'user' }) + .then((response) => { + if (!response.results || + response.results.length == 0 || + !response.results[0].user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + let user = response.results[0].user; + return { response: user }; + } + }); + } + + handleLogIn(req) { + // Use query parameters instead if provided in url + if (!req.body.username && req.query.username) { + req.body = req.query; + } + + // TODO: use the right error codes / descriptions. + if (!req.body.username) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username is required.'); + } + if (!req.body.password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); + } + + let user; + return req.database.find('_User', { username: req.body.username }) + .then((results) => { + if (!results.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + 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.'); + } + + let token = 'r:' + rack(); + user.sessionToken = token; + delete user.password; + + req.config.filesController.expandFilesInObject(req.config, user); + + let expiresAt = new Date(); + expiresAt.setFullYear(expiresAt.getFullYear() + 1); + + let sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: user.objectId + }, + createdWith: { + 'action': 'login', + 'authProvider': 'password' + }, + restricted: false, + expiresAt: Parse._encode(expiresAt) + }; + + if (req.info.installationId) { + sessionData.installationId = req.info.installationId + } + + let create = new RestWrite(req.config, Auth.master(req.config), '_Session', null, sessionData); + return create.execute(); + }).then(() => { + return { response: user }; + }); + } + + handleLogOut(req) { + let success = {response: {}}; + if (req.info && req.info.sessionToken) { + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken } + ).then((records) => { + if (records.results && records.results.length) { + return rest.del(req.config, Auth.master(req.config), '_Session', + records.results[0].objectId + ).then(() => { + return Promise.resolve(success); + }); + } + return Promise.resolve(success); + }); + } + return Promise.resolve(success); + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET', '/users', req => { return this.handleFind(req); }); + router.route('POST', '/users', req => { return this.handleCreate(req); }); + router.route('GET', '/users/:objectId', req => { return this.handleGet(req); }); + router.route('PUT', '/users/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); + router.route('GET', '/users/me', req => { return this.handleMe(req); }); + router.route('GET', '/login', req => { return this.handleLogIn(req); }); + router.route('POST', '/logout', req => { return this.handleLogOut(req); }); + router.route('POST', '/requestPasswordReset', () => { + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); + }); + return router; + } +} + +export default UsersRouter; diff --git a/src/index.js b/src/index.js index c2993400..fcc573e2 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ import { PushController } from './Controllers/PushController'; import { ClassesRouter } from './Routers/ClassesRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { UsersRouter } from './Routers/UsersRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -129,7 +130,7 @@ function ParseServer(args) { let routers = [ new ClassesRouter().getExpressRouter(), - require('./users'), + new UsersRouter().getExpressRouter(), require('./sessions'), require('./roles'), require('./analytics'), diff --git a/src/users.js b/src/users.js deleted file mode 100644 index 4205c666..00000000 --- a/src/users.js +++ /dev/null @@ -1,212 +0,0 @@ -// These methods handle the User-related routes. - -var mongodb = require('mongodb'); -var Parse = require('parse/node').Parse; -var rack = require('hat').rack(); - -var Auth = require('./Auth'); -var passwordCrypto = require('./password'); -var facebook = require('./facebook'); -var PromiseRouter = require('./PromiseRouter'); -var rest = require('./rest'); -var RestWrite = require('./RestWrite'); -var deepcopy = require('deepcopy'); - -var router = new PromiseRouter(); - -// Returns a promise for a {status, response, location} object. -function handleCreate(req) { - var data = deepcopy(req.body); - data.installationId = req.info.installationId; - return rest.create(req.config, req.auth, - '_User', data); -} - -// Returns a promise for a {response} object. -function handleLogIn(req) { - - // Use query parameters instead if provided in url - if (!req.body.username && req.query.username) { - req.body = req.query; - } - - // TODO: use the right error codes / descriptions. - if (!req.body.username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'username is required.'); - } - if (!req.body.password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required.'); - } - - var user; - return req.database.find('_User', {username: req.body.username}) - .then((results) => { - if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - 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.'); - } - var token = 'r:' + rack(); - user.sessionToken = token; - delete user.password; - - req.config.filesController.expandFilesInObject(req.config, user); - - var expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: user.objectId - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false, - expiresAt: Parse._encode(expiresAt) - }; - - if (req.info.installationId) { - sessionData.installationId = req.info.installationId - } - - var create = new RestWrite(req.config, Auth.master(req.config), - '_Session', null, sessionData); - return create.execute(); - }).then(() => { - return {response: user}; - }); -} - -// Returns a promise that resolves to a {response} object. -// TODO: share code with ClassesRouter.js -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - if (req.body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); - } - - return rest.find(req.config, req.auth, - '_User', req.body.where, options) - .then((response) => { - return {response: response}; - }); - -} - -// Returns a promise for a {response} object. -function handleGet(req) { - return rest.find(req.config, req.auth, '_User', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -function handleMe(req) { - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken}, - {include: 'user'}) - .then((response) => { - if (!response.results || response.results.length == 0 || - !response.results[0].user) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - var user = response.results[0].user; - return {response: user}; - } - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - req.params.className, req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleLogOut(req) { - var success = {response: {}}; - if (req.info && req.info.sessionToken) { - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken} - ).then((records) => { - if (records.results && records.results.length) { - return rest.del(req.config, Auth.master(req.config), '_Session', - records.results[0].objectId - ).then(() => { - return Promise.resolve(success); - }); - } - return Promise.resolve(success); - }); - } - return Promise.resolve(success); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_User', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); -} - -router.route('POST', '/users', handleCreate); -router.route('GET', '/login', handleLogIn); -router.route('POST', '/logout', handleLogOut); -router.route('GET', '/users/me', handleMe); -router.route('GET', '/users/:objectId', handleGet); -router.route('PUT', '/users/:objectId', handleUpdate); -router.route('GET', '/users', handleFind); -router.route('DELETE', '/users/:objectId', handleDelete); - -router.route('POST', '/requestPasswordReset', notImplementedYet); - -module.exports = router; From 99ac6c11610ea42bde65aac4b1b496d53c6e3bea Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 20:40:15 -0800 Subject: [PATCH 78/84] Refactor and deduplicate logic in SessionsRouter. --- src/Routers/SessionsRouter.js | 63 ++++++++++++++++++++++ src/index.js | 3 +- src/sessions.js | 98 ----------------------------------- 3 files changed, 65 insertions(+), 99 deletions(-) create mode 100644 src/Routers/SessionsRouter.js delete mode 100644 src/sessions.js diff --git a/src/Routers/SessionsRouter.js b/src/Routers/SessionsRouter.js new file mode 100644 index 00000000..ecffd80a --- /dev/null +++ b/src/Routers/SessionsRouter.js @@ -0,0 +1,63 @@ + +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; +import Auth from '../Auth'; + +export class SessionsRouter extends ClassesRouter { + handleFind(req) { + req.params.className = '_Session'; + return super.handleFind(req); + } + + handleGet(req) { + req.params.className = '_Session'; + return super.handleGet(req); + } + + handleCreate(req) { + req.params.className = '_Session'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_Session'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_Session'; + return super.handleDelete(req); + } + + handleMe(req) { + // TODO: Verify correct behavior + if (!req.info || !req.info.sessionToken) { + 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 }) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token not found.'); + } + return { + response: response.results[0] + }; + }); + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET','/sessions/me', req => { return this.handleMe(req); }); + router.route('GET', '/sessions', req => { return this.handleFind(req); }); + router.route('GET', '/sessions/:objectId', req => { return this.handleGet(req); }); + router.route('POST', '/sessions', req => { return this.handleCreate(req); }); + router.route('PUT', '/sessions/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE', '/sessions/:objectId', req => { return this.handleDelete(req); }); + return router; + } +} + +export default SessionsRouter; diff --git a/src/index.js b/src/index.js index fcc573e2..9d9910bd 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ import { PushController } from './Controllers/PushController'; import { ClassesRouter } from './Routers/ClassesRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; import { UsersRouter } from './Routers/UsersRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -131,7 +132,7 @@ function ParseServer(args) { let routers = [ new ClassesRouter().getExpressRouter(), new UsersRouter().getExpressRouter(), - require('./sessions'), + new SessionsRouter().getExpressRouter(), require('./roles'), require('./analytics'), new InstallationsRouter().getExpressRouter(), diff --git a/src/sessions.js b/src/sessions.js deleted file mode 100644 index b979de45..00000000 --- a/src/sessions.js +++ /dev/null @@ -1,98 +0,0 @@ -// sessions.js - -var Auth = require('./Auth'), - Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); - -var router = new PromiseRouter(); - -function handleCreate(req) { - return rest.create(req.config, req.auth, - '_Session', req.body); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_Session', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - '_Session', req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleGet(req) { - return rest.find(req.config, req.auth, '_Session', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - - return rest.find(req.config, req.auth, - '_Session', req.body.where, options) - .then((response) => { - return {response: response}; - }); -} - -function handleMe(req) { - // TODO: Verify correct behavior - if (!req.info || !req.info.sessionToken) { - 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}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token not found.'); - } - return { - response: response.results[0] - }; - }); -} - -router.route('POST','/sessions', handleCreate); -router.route('GET','/sessions/me', handleMe); -router.route('GET','/sessions/:objectId', handleGet); -router.route('PUT','/sessions/:objectId', handleUpdate); -router.route('GET','/sessions', handleFind); -router.route('DELETE','/sessions/:objectId', handleDelete); - -module.exports = router; From b2570a9af85a131f4f86f3683c603133f54557b7 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 23:17:20 -0800 Subject: [PATCH 79/84] Update style in InstallationsRouter. --- src/Routers/InstallationsRouter.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 033366b7..fca703e9 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -51,12 +51,12 @@ export class InstallationsRouter extends ClassesRouter { } getExpressRouter() { - var router = new PromiseRouter(); - router.route('GET','/installations', (req) => { return this.handleFind(req); }); - router.route('GET','/installations/:objectId', (req) => { return this.handleGet(req); }); - router.route('POST','/installations', (req) => { return this.handleCreate(req); }); - router.route('PUT','/installations/:objectId', (req) => { return this.handleUpdate(req); }); - router.route('DELETE','/installations/:objectId', (req) => { return this.handleDelete(req); }); + let router = new PromiseRouter(); + router.route('GET','/installations', req => { return this.handleFind(req); }); + router.route('GET','/installations/:objectId', req => { return this.handleGet(req); }); + router.route('POST','/installations', req => { return this.handleCreate(req); }); + router.route('PUT','/installations/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE','/installations/:objectId', req => { return this.handleDelete(req); }); return router; } } From f53cb60d57f1eddedd3c4846b991ec5b8aa9ae62 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 21:53:32 -0800 Subject: [PATCH 80/84] Add enforceMasterKeyAccess middleware. --- spec/ParseFile.spec.js | 8 ++++---- src/Controllers/FilesController.js | 8 +------- src/middlewares.js | 13 +++++++++++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 7287dd14..8613f3a2 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -101,8 +101,8 @@ describe('Parse.File testing', () => { }, (error, response, body) => { expect(error).toBe(null); var del_b = JSON.parse(body); - expect(response.statusCode).toEqual(400); - expect(del_b.code).toEqual(119); + expect(response.statusCode).toEqual(403); + expect(del_b.error).toMatch(/unauthorized/); // incorrect X-Parse-Master-Key header request.del({ headers: { @@ -114,8 +114,8 @@ describe('Parse.File testing', () => { }, (error, response, body) => { expect(error).toBe(null); var del_b2 = JSON.parse(body); - expect(response.statusCode).toEqual(400); - expect(del_b2.code).toEqual(119); + expect(response.statusCode).toEqual(403); + expect(del_b2.error).toMatch(/unauthorized/); done(); }); }); diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 321042b9..dac6b684 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -76,13 +76,6 @@ export class FilesController { deleteHandler() { return (req, res, next) => { - // enforce use of master key for file deletions - if(!req.auth.isMaster){ - next(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - 'Master key required for file deletion.')); - return; - } - this._filesAdapter.deleteFile(req.config, req.params.filename).then(() => { res.status(200); // TODO: return useful JSON here? @@ -142,6 +135,7 @@ export class FilesController { router.delete('/files/:filename', Middlewares.allowCrossDomain, Middlewares.handleParseHeaders, + Middlewares.enforceMasterKeyAccess, this.deleteHandler() ); diff --git a/src/middlewares.js b/src/middlewares.js index bb251239..a07b2a1b 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -178,15 +178,24 @@ var handleParseErrors = function(err, req, res, next) { } }; +function enforceMasterKeyAccess(req, res, next) { + if (!req.auth.isMaster) { + res.status(403); + res.end('{"error":"unauthorized: master key is required"}'); + return; + } + next(); +} + function invalidRequest(req, res) { res.status(403); res.end('{"error":"unauthorized"}'); } - module.exports = { allowCrossDomain: allowCrossDomain, allowMethodOverride: allowMethodOverride, handleParseErrors: handleParseErrors, - handleParseHeaders: handleParseHeaders + handleParseHeaders: handleParseHeaders, + enforceMasterKeyAccess: enforceMasterKeyAccess }; From 62e671dd9e6e495da79f4d7051935960d2e442d0 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 21:35:56 -0800 Subject: [PATCH 81/84] Refactor and deduplicate RolesRouter, fix missing query on /roles. --- src/Routers/RolesRouter.js | 43 ++++++++++++++++++++++++++++++++++ src/index.js | 3 ++- src/roles.js | 48 -------------------------------------- 3 files changed, 45 insertions(+), 49 deletions(-) create mode 100644 src/Routers/RolesRouter.js delete mode 100644 src/roles.js diff --git a/src/Routers/RolesRouter.js b/src/Routers/RolesRouter.js new file mode 100644 index 00000000..b20a91ee --- /dev/null +++ b/src/Routers/RolesRouter.js @@ -0,0 +1,43 @@ + +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; + +export class RolesRouter extends ClassesRouter { + handleFind(req) { + req.params.className = '_Role'; + return super.handleFind(req); + } + + handleGet(req) { + req.params.className = '_Role'; + return super.handleGet(req); + } + + handleCreate(req) { + req.params.className = '_Role'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_Role'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_Role'; + return super.handleDelete(req); + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET','/roles', req => { return this.handleFind(req); }); + router.route('GET','/roles/:objectId', req => { return this.handleGet(req); }); + router.route('POST','/roles', req => { return this.handleCreate(req); }); + router.route('PUT','/roles/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE','/roles/:objectId', req => { return this.handleDelete(req); }); + return router; + } +} + +export default RolesRouter; diff --git a/src/index.js b/src/index.js index 9d9910bd..952c1c05 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,7 @@ import { ClassesRouter } from './Routers/ClassesRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; import { UsersRouter } from './Routers/UsersRouter'; import { SessionsRouter } from './Routers/SessionsRouter'; +import { RolesRouter } from './Routers/RolesRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -133,7 +134,7 @@ function ParseServer(args) { new ClassesRouter().getExpressRouter(), new UsersRouter().getExpressRouter(), new SessionsRouter().getExpressRouter(), - require('./roles'), + new RolesRouter().getExpressRouter(), require('./analytics'), new InstallationsRouter().getExpressRouter(), require('./functions'), diff --git a/src/roles.js b/src/roles.js deleted file mode 100644 index 6aaf8065..00000000 --- a/src/roles.js +++ /dev/null @@ -1,48 +0,0 @@ -// roles.js - -var Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); - -var router = new PromiseRouter(); - -function handleCreate(req) { - return rest.create(req.config, req.auth, - '_Role', req.body); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_Role', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - '_Role', req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleGet(req) { - return rest.find(req.config, req.auth, '_Role', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -router.route('POST','/roles', handleCreate); -router.route('GET','/roles/:objectId', handleGet); -router.route('PUT','/roles/:objectId', handleUpdate); -router.route('DELETE','/roles/:objectId', handleDelete); - -module.exports = router; \ No newline at end of file From dc4859f561dfae078d4529d8cfa4405ac4daf4c9 Mon Sep 17 00:00:00 2001 From: Peter Shin Date: Thu, 4 Feb 2016 08:18:19 -0800 Subject: [PATCH 82/84] Logs support. Added /logs endpoint with basic logger and LoggerAdapter. --- package.json | 3 +- spec/FileLoggerAdapter.spec.js | 64 +++++++ spec/LoggerController.spec.js | 55 ++++++ src/Adapters/Logger/FileLoggerAdapter.js | 225 +++++++++++++++++++++++ src/Adapters/Logger/LoggerAdapter.js | 17 ++ src/Controllers/LoggerController.js | 78 ++++++++ src/index.js | 9 +- 7 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 spec/FileLoggerAdapter.spec.js create mode 100644 spec/LoggerController.spec.js create mode 100644 src/Adapters/Logger/FileLoggerAdapter.js create mode 100644 src/Adapters/Logger/LoggerAdapter.js create mode 100644 src/Controllers/LoggerController.js diff --git a/package.json b/package.json index 521381f0..97467963 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "node-gcm": "^0.14.0", "parse": "^1.7.0", "randomstring": "^1.1.3", - "request": "^2.65.0" + "request": "^2.65.0", + "winston": "^2.1.1" }, "devDependencies": { "babel-cli": "^6.5.1", diff --git a/spec/FileLoggerAdapter.spec.js b/spec/FileLoggerAdapter.spec.js new file mode 100644 index 00000000..4466e087 --- /dev/null +++ b/spec/FileLoggerAdapter.spec.js @@ -0,0 +1,64 @@ +var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; +var Parse = require('parse/node').Parse; +var request = require('request'); +var fs = require('fs'); + +var LOGS_FOLDER = './test_logs/'; + +var deleteFolderRecursive = function(path) { + if( fs.existsSync(path) ) { + fs.readdirSync(path).forEach(function(file,index){ + var curPath = path + "/" + file; + if(fs.lstatSync(curPath).isDirectory()) { // recurse + deleteFolderRecursive(curPath); + } else { // delete file + fs.unlinkSync(curPath); + } + }); + fs.rmdirSync(path); + } +}; + +describe('info logs', () => { + + afterEach((done) => { + deleteFolderRecursive(LOGS_FOLDER); + done(); + }); + + it("Verify INFO logs", (done) => { + var fileLoggerAdapter = new FileLoggerAdapter({ + logsFolder: LOGS_FOLDER + }); + fileLoggerAdapter.info('testing info logs', () => { + fileLoggerAdapter.query({ + size: 1, + level: 'info' + }, (results) => { + expect(results[0].message).toEqual('testing info logs'); + done(); + }); + }); + }); +}); + +describe('error logs', () => { + + afterEach((done) => { + deleteFolderRecursive(LOGS_FOLDER); + done(); + }); + + it("Verify ERROR logs", (done) => { + var fileLoggerAdapter = new FileLoggerAdapter(); + fileLoggerAdapter.error('testing error logs', () => { + fileLoggerAdapter.query({ + size: 1, + level: 'error' + }, (results) => { + expect(results[0].message).toEqual('testing error logs'); + done(); + }); + }); + }); +}); diff --git a/spec/LoggerController.spec.js b/spec/LoggerController.spec.js new file mode 100644 index 00000000..f23004ab --- /dev/null +++ b/spec/LoggerController.spec.js @@ -0,0 +1,55 @@ +var LoggerController = require('../src/Controllers/LoggerController').LoggerController; +var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; + +describe('LoggerController', () => { + it('can check valid master key of request', (done) => { + // Make mock request + var request = { + auth: { + isMaster: true + }, + query: {} + }; + + var loggerController = new LoggerController(new FileLoggerAdapter()); + + expect(() => { + loggerController.handleGET(request); + }).not.toThrow(); + done(); + }); + + it('can check invalid construction of controller', (done) => { + // Make mock request + var request = { + auth: { + isMaster: true + }, + query: {} + }; + + var loggerController = new LoggerController(); + + expect(() => { + loggerController.handleGET(request); + }).toThrow(); + done(); + }); + + it('can check invalid master key of request', (done) => { + // Make mock request + var request = { + auth: { + isMaster: false + }, + query: {} + }; + + var loggerController = new LoggerController(new FileLoggerAdapter()); + + expect(() => { + loggerController.handleGET(request); + }).toThrow(); + done(); + }); +}); diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js new file mode 100644 index 00000000..4edc4122 --- /dev/null +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -0,0 +1,225 @@ +// Logger +// +// Wrapper around Winston logging library with custom query +// +// expected log entry to be in the shape of: +// {"level":"info","message":"{ '0': 'Your Message' }","timestamp":"2016-02-04T05:59:27.412Z"} +// +import { LoggerAdapter } from './LoggerAdapter'; +import winston from 'winston'; +import fs from 'fs'; +import { Parse } from 'parse/node'; + +const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; +const CACHE_TIME = 1000 * 60; + +let LOGS_FOLDER = './logs/'; + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + LOGS_FOLDER = './test_logs/' +} + +let currentDate = new Date(); + +let simpleCache = { + timestamp: null, + from: null, + until: null, + order: null, + data: [], + level: 'info', +}; + +// returns Date object rounded to nearest day +let _getNearestDay = (date) => { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +// returns Date object of previous day +let _getPrevDay = (date) => { + return new Date(date - MILLISECONDS_IN_A_DAY); +} + +// returns the iso formatted file name +let _getFileName = () => { + return _getNearestDay(currentDate).toISOString() +} + +// check for valid cache when both from and util match. +// cache valid for up to 1 minute +let _hasValidCache = (from, until, level) => { + if (String(from) === String(simpleCache.from) && + String(until) === String(simpleCache.until) && + new Date() - simpleCache.timestamp < CACHE_TIME && + level === simpleCache.level) { + return true; + } + return false; +} + +// renews transports to current date +let _renewTransports = ({infoLogger, errorLogger, logsFolder}) => { + if (infoLogger) { + infoLogger.add(winston.transports.File, { + filename: logsFolder + _getFileName() + '.info', + name: 'info-file', + level: 'info' + }); + } + if (errorLogger) { + errorLogger.add(winston.transports.File, { + filename: logsFolder + _getFileName() + '.error', + name: 'error-file', + level: 'error' + }); + } +}; + +// check that log entry has valid time stamp based on query +let _isValidLogEntry = (from, until, entry) => { + var _entry = JSON.parse(entry), + timestamp = new Date(_entry.timestamp); + return timestamp >= from && timestamp <= until + ? true + : false +}; + +// ensure that file name is up to date +let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => { + if (_getNearestDay(currentDate) !== _getNearestDay(new Date())) { + currentDate = new Date(); + if (infoLogger) { + infoLogger.remove('info-file'); + } + if (errorLogger) { + errorLogger.remove('error-file'); + } + _renewTransports({infoLogger, errorLogger, logsFolder}); + } +} + +export class FileLoggerAdapter extends LoggerAdapter { + constructor(options = {}) { + super(); + + this._logsFolder = options.logsFolder || LOGS_FOLDER; + + // check logs folder exists + if (!fs.existsSync(this._logsFolder)) { + fs.mkdirSync(this._logsFolder); + } + + this._errorLogger = new (winston.Logger)({ + exitOnError: false, + transports: [ + new (winston.transports.File)({ + filename: this._logsFolder + _getFileName() + '.error', + name: 'error-file', + level: 'error' + }) + ] + }); + + this._infoLogger = new (winston.Logger)({ + exitOnError: false, + transports: [ + new (winston.transports.File)({ + filename: this._logsFolder + _getFileName() + '.info', + name: 'info-file', + level: 'info' + }) + ] + }); + } + + info() { + _verifyTransports({infoLogger: this._infoLogger, logsFolder: this._logsFolder}); + return this._infoLogger.info.apply(undefined, arguments); + } + + error() { + _verifyTransports({errorLogger: this._errorLogger, logsFolder: this._logsFolder}); + return this._errorLogger.error.apply(undefined, arguments); + } + + // custom query as winston is currently limited + query(options, callback) { + if (!options) { + options = {}; + } + // defaults to 7 days prior + let from = options.from || new Date(Date.now() - (7 * MILLISECONDS_IN_A_DAY)); + let until = options.until || new Date(); + let size = options.size || 10; + let order = options.order || 'desc'; + let level = options.level || 'info'; + let roundedUntil = _getNearestDay(until); + let roundedFrom = _getNearestDay(from); + + if (_hasValidCache(roundedFrom, roundedUntil, level)) { + let logs = []; + if (order !== simpleCache.order) { + // reverse order of data + simpleCache.data.forEach((entry) => { + logs.unshift(entry); + }); + } else { + logs = simpleCache.data; + } + callback(logs.slice(0, size)); + return; + } + + let curDate = roundedUntil; + let curSize = 0; + let method = order === 'desc' ? 'push' : 'unshift'; + let files = []; + let promises = []; + + // current a batch call, all files with valid dates are read + while (curDate >= from) { + files[method](this._logsFolder + curDate.toISOString() + '.' + level); + curDate = _getPrevDay(curDate); + } + + // read each file and split based on newline char. + // limitation is message cannot contain newline + // TODO: strip out delimiter from logged message + files.forEach(function(file, i) { + let promise = new Parse.Promise(); + fs.readFile(file, 'utf8', function(err, data) { + if (err) { + promise.resolve([]); + } else { + let results = data.split('\n').filter((value) => { + return value.trim() !== ''; + }); + promise.resolve(results); + } + }); + promises[method](promise); + }); + + Parse.Promise.when(promises).then((results) => { + let logs = []; + results.forEach(function(logEntries, i) { + logEntries.forEach(function(entry) { + if (_isValidLogEntry(from, until, entry)) { + logs[method](JSON.parse(entry)); + } + }); + }); + simpleCache = { + timestamp: new Date(), + from: roundedFrom, + until: roundedUntil, + data: logs, + order, + level, + }; + callback(logs.slice(0, size)); + }); + } +} + +export default FileLoggerAdapter; diff --git a/src/Adapters/Logger/LoggerAdapter.js b/src/Adapters/Logger/LoggerAdapter.js new file mode 100644 index 00000000..b1fe31b8 --- /dev/null +++ b/src/Adapters/Logger/LoggerAdapter.js @@ -0,0 +1,17 @@ +// Logger Adapter +// +// Allows you to change the logger mechanism +// +// Adapter classes must implement the following functions: +// * info(obj1 [, obj2, .., objN]) +// * error(obj1 [, obj2, .., objN]) +// * query(options, callback) +// Default is FileLoggerAdapter.js + +export class LoggerAdapter { + info() {} + error() {} + query(options, callback) {} +} + +export default LoggerAdapter; diff --git a/src/Controllers/LoggerController.js b/src/Controllers/LoggerController.js new file mode 100644 index 00000000..d0b8bb28 --- /dev/null +++ b/src/Controllers/LoggerController.js @@ -0,0 +1,78 @@ +import { Parse } from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; + +const Promise = Parse.Promise; +const INFO = 'info'; +const ERROR = 'error'; +const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; + +// only allow request with master key +let enforceSecurity = (auth) => { + if (!auth || !auth.isMaster) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Clients aren\'t allowed to perform the ' + + 'get' + ' operation on logs.' + ); + } +} + +// check that date input is valid +let isValidDateTime = (date) => { + if (!date || isNaN(Number(date))) { + return false; + } +} + +export class LoggerController { + + constructor(loggerAdapter) { + this._loggerAdapter = loggerAdapter; + } + + // Returns a promise for a {response} object. + // query params: + // level (optional) Level of logging you want to query for (info || error) + // from (optional) Start time for the search. Defaults to 1 week ago. + // until (optional) End time for the search. Defaults to current time. + // order (optional) Direction of results returned, either “asc” or “desc”. Defaults to “desc”. + // size (optional) Number of rows returned by search. Defaults to 10 + handleGET(req) { + if (!this._loggerAdapter) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Logger adapter is not availabe'); + } + + let promise = new Parse.Promise(); + let from = (isValidDateTime(req.query.from) && new Date(req.query.from)) || + new Date(Date.now() - 7 * MILLISECONDS_IN_A_DAY); + let until = (isValidDateTime(req.query.until) && new Date(req.query.until)) || new Date(); + let size = Number(req.query.size) || 10; + let order = req.query.order || 'desc'; + let level = req.query.level || INFO; + enforceSecurity(req.auth); + this._loggerAdapter.query({ + from, + until, + size, + order, + level, + }, (result) => { + promise.resolve({ + response: result + }); + }); + return promise; + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET','/logs', (req) => { + return this.handleGET(req); + }); + return router; + } +} + +export default LoggerController; diff --git a/src/index.js b/src/index.js index c2993400..4458f8c0 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,9 @@ import { PushController } from './Controllers/PushController'; import { ClassesRouter } from './Routers/ClassesRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; +import { LoggerController } from './Controllers/LoggerController'; + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -69,6 +72,9 @@ function ParseServer(args) { pushAdapter = new ParsePushAdapter(pushConfig) } + // Make logger adapter + let loggerAdapter = args.loggerAdapter || new FileLoggerAdapter(); + if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } @@ -136,7 +142,8 @@ function ParseServer(args) { new InstallationsRouter().getExpressRouter(), require('./functions'), require('./schemas'), - new PushController(pushAdapter).getExpressRouter() + new PushController(pushAdapter).getExpressRouter(), + new LoggerController(loggerAdapter).getExpressRouter() ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { routers.push(require('./global_config')); From 0c75f6022d32ead36c1ae44eb2dab7ae9ab74485 Mon Sep 17 00:00:00 2001 From: "Peter J. Shin" Date: Fri, 12 Feb 2016 10:30:04 -0800 Subject: [PATCH 83/84] Updating adapter docs. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dfa8b0f0..92b7668f 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ The client keys used with Parse are no longer necessary with parse-server. If y #### Advanced options: -* filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see `FilesAdapter.js`) +* filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see [`FilesAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Files/FilesAdapter.js)) * databaseAdapter (unfinished) - The backing store can be changed by creating an adapter class (see `DatabaseAdapter.js`) +* loggerAdapter - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js)) --- From 62cbc451aa1fc1622064edabd36ceb016f36444b Mon Sep 17 00:00:00 2001 From: Dmitry Chestnykh Date: Fri, 12 Feb 2016 02:02:55 +0100 Subject: [PATCH 84/84] Generate tokens and ids with cryptoUtils module. Move object ID, token, and random string generation into their own module, cryptoUtils. Remove hat dependency, which was used to generate session and some other tokens, because it used non-cryptographic random number generator. Replace it with the cryptographically secure one. The result has the same format (32-character hex string, 128 bits of entropy). Remove randomstring dependency, as we already have this functionality. Add tests. --- package.json | 2 - spec/cryptoUtils.spec.js | 83 ++++++++++++++++++++++++++++++ src/Controllers/FilesController.js | 6 +-- src/GCM.js | 7 +-- src/RestWrite.js | 29 +++-------- src/Routers/UsersRouter.js | 6 +-- src/cryptoUtils.js | 44 ++++++++++++++++ src/testing-routes.js | 6 +-- 8 files changed, 142 insertions(+), 41 deletions(-) create mode 100644 spec/cryptoUtils.spec.js create mode 100644 src/cryptoUtils.js diff --git a/package.json b/package.json index 97467963..ae2b5331 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,11 @@ "body-parser": "^1.14.2", "deepcopy": "^0.6.1", "express": "^4.13.4", - "hat": "~0.0.3", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", "node-gcm": "^0.14.0", "parse": "^1.7.0", - "randomstring": "^1.1.3", "request": "^2.65.0", "winston": "^2.1.1" }, diff --git a/spec/cryptoUtils.spec.js b/spec/cryptoUtils.spec.js new file mode 100644 index 00000000..cd996770 --- /dev/null +++ b/spec/cryptoUtils.spec.js @@ -0,0 +1,83 @@ +var cryptoUtils = require('../src/cryptoUtils'); + +function givesUniqueResults(fn, iterations) { + var results = {}; + for (var i = 0; i < iterations; i++) { + var s = fn(); + if (results[s]) { + return false; + } + results[s] = true; + } + return true; +} + +describe('randomString', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.randomString(10)).toBe('string'); + }); + + it('returns result of the given length', () => { + expect(cryptoUtils.randomString(11).length).toBe(11); + expect(cryptoUtils.randomString(25).length).toBe(25); + }); + + it('throws if requested length is zero', () => { + expect(() => cryptoUtils.randomString(0)).toThrow(); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.randomString(10), 100)).toBe(true); + }); +}); + +describe('randomHexString', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.randomHexString(10)).toBe('string'); + }); + + it('returns result of the given length', () => { + expect(cryptoUtils.randomHexString(10).length).toBe(10); + expect(cryptoUtils.randomHexString(32).length).toBe(32); + }); + + it('throws if requested length is zero', () => { + expect(() => cryptoUtils.randomHexString(0)).toThrow(); + }); + + it('throws if requested length is not even', () => { + expect(() => cryptoUtils.randomHexString(11)).toThrow(); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.randomHexString(20), 100)).toBe(true); + }); +}); + +describe('newObjectId', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.newObjectId()).toBe('string'); + }); + + it('returns result with at least 10 characters', () => { + expect(cryptoUtils.newObjectId().length).toBeGreaterThan(9); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.newObjectId(), 100)).toBe(true); + }); +}); + +describe('newToken', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.newToken()).toBe('string'); + }); + + it('returns result with at least 32 characters', () => { + expect(cryptoUtils.newToken().length).toBeGreaterThan(31); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.newToken(), 100)).toBe(true); + }); +}); diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index dac6b684..6fde54b7 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -4,11 +4,9 @@ import express from 'express'; import mime from 'mime'; import { Parse } from 'parse/node'; import BodyParser from 'body-parser'; -import hat from 'hat'; import * as Middlewares from '../middlewares'; import Config from '../Config'; - -const rack = hat.rack(); +import { randomHexString } from '../cryptoUtils'; export class FilesController { constructor(filesAdapter) { @@ -61,7 +59,7 @@ export class FilesController { extension = '.' + mime.extension(contentType); } - let filename = rack() + '_' + req.params.filename + extension; + let filename = randomHexString(32) + '_' + req.params.filename + extension; this._filesAdapter.createFile(req.config, filename, req.body).then(() => { res.status(201); var location = this._filesAdapter.getFileLocation(req.config, filename); diff --git a/src/GCM.js b/src/GCM.js index be09f222..a13a6751 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -2,7 +2,7 @@ const Parse = require('parse/node').Parse; const gcm = require('node-gcm'); -const randomstring = require('randomstring'); +const cryptoUtils = require('./cryptoUtils'); const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks const GCMRegistrationTokensMax = 1000; @@ -22,10 +22,7 @@ function GCM(args) { * @returns {Object} A promise which is resolved after we get results from gcm */ GCM.prototype.send = function(data, devices) { - let pushId = randomstring.generate({ - length: 10, - charset: 'alphanumeric' - }); + let pushId = cryptoUtils.newObjectId(); let timeStamp = Date.now(); let expirationTime; // We handle the expiration_time convertion in push.js, so expiration_time is a valid date diff --git a/src/RestWrite.js b/src/RestWrite.js index f4bb7353..2a2b0ed2 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -2,13 +2,12 @@ // that writes to the database. // This could be either a "create" or an "update". -var crypto = require('crypto'); var deepcopy = require('deepcopy'); -var rack = require('hat').rack(); var Auth = require('./Auth'); var cache = require('./cache'); var Config = require('./Config'); +var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var facebook = require('./facebook'); var Parse = require('parse/node'); @@ -56,7 +55,7 @@ function RestWrite(config, auth, className, query, data, originalData) { this.data.updatedAt = this.updatedAt; if (!this.query) { this.data.createdAt = this.updatedAt; - this.data.objectId = newStringId(10); + this.data.objectId = cryptoUtils.newObjectId(); } } } @@ -252,7 +251,7 @@ RestWrite.prototype.handleFacebookAuthData = function() { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } else { - this.data.username = rack(); + this.data.username = cryptoUtils.newToken(); } // This FB auth does not already exist, so transform it to a @@ -273,7 +272,7 @@ RestWrite.prototype.transformUser = function() { var promise = Promise.resolve(); if (!this.query) { - var token = 'r:' + rack(); + var token = 'r:' + cryptoUtils.newToken(); this.storage['token'] = token; promise = promise.then(() => { var expiresAt = new Date(); @@ -319,7 +318,7 @@ RestWrite.prototype.transformUser = function() { // Check for username uniqueness if (!this.data.username) { if (!this.query) { - this.data.username = newStringId(25); + this.data.username = cryptoUtils.randomString(25); } return; } @@ -412,7 +411,7 @@ RestWrite.prototype.handleSession = function() { } if (!this.query && !this.auth.isMaster) { - var token = 'r:' + rack(); + var token = 'r:' + cryptoUtils.newToken(); var expiresAt = new Date(); expiresAt.setFullYear(expiresAt.getFullYear() + 1); var sessionData = { @@ -713,20 +712,4 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; -// Returns a unique string that's usable as an object or other id. -function newStringId(size) { - var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + - 'abcdefghijklmnopqrstuvwxyz' + - '0123456789'); - var objectId = ''; - var bytes = crypto.randomBytes(size); - for (var i = 0; i < bytes.length; ++i) { - // Note: there is a slight modulo bias, because chars length - // of 62 doesn't divide the number of all bytes (256) evenly. - // It is acceptable for our purposes. - objectId += chars[bytes.readUInt8(i) % chars.length]; - } - return objectId; -} - module.exports = RestWrite; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 4f22e07a..5b894f75 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,6 +1,5 @@ // These methods handle the User-related routes. -import hat from 'hat'; import deepcopy from 'deepcopy'; import ClassesRouter from './ClassesRouter'; @@ -9,8 +8,7 @@ import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; import RestWrite from '../RestWrite'; - -const rack = hat.rack(); +import { newToken } from '../cryptoUtils'; export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -89,7 +87,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - let token = 'r:' + rack(); + let token = 'r:' + newToken(); user.sessionToken = token; delete user.password; diff --git a/src/cryptoUtils.js b/src/cryptoUtils.js new file mode 100644 index 00000000..2f83defd --- /dev/null +++ b/src/cryptoUtils.js @@ -0,0 +1,44 @@ +import { randomBytes } from 'crypto'; + +// Returns a new random hex string of the given even size. +export function randomHexString(size) { + if (size === 0) { + throw new Error('Zero-length randomHexString is useless.'); + } + if (size % 2 !== 0) { + throw new Error('randomHexString size must be divisible by 2.') + } + return randomBytes(size/2).toString('hex'); +} + +// Returns a new random alphanumeric string of the given size. +// +// Note: to simplify implementation, the result has slight modulo bias, +// because chars length of 62 doesn't divide the number of all bytes +// (256) evenly. Such bias is acceptable for most cases when the output +// length is long enough and doesn't need to be uniform. +export function randomString(size) { + if (size === 0) { + throw new Error('Zero-length randomString is useless.'); + } + var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + 'abcdefghijklmnopqrstuvwxyz' + + '0123456789'); + var objectId = ''; + var bytes = randomBytes(size); + for (var i = 0; i < bytes.length; ++i) { + objectId += chars[bytes.readUInt8(i) % chars.length]; + } + return objectId; +} + +// Returns a new random alphanumeric string suitable for object ID. +export function newObjectId() { + //TODO: increase length to better protect against collisions. + return randomString(10); +} + +// Returns a new random hex string suitable for secure tokens. +export function newToken() { + return randomHexString(32); +} diff --git a/src/testing-routes.js b/src/testing-routes.js index 85db1485..28b02cf4 100644 --- a/src/testing-routes.js +++ b/src/testing-routes.js @@ -3,13 +3,13 @@ var express = require('express'), cache = require('./cache'), middlewares = require('./middlewares'), - rack = require('hat').rack(); + cryptoUtils = require('./cryptoUtils'); var router = express.Router(); // creates a unique app in the cache, with a collection prefix function createApp(req, res) { - var appId = rack(); + var appId = cryptoUtils.randomHexString(32); cache.apps[appId] = { 'collectionPrefix': appId + '_', 'masterKey': 'master' @@ -70,4 +70,4 @@ router.post('/rest_configure_app', module.exports = { router: router -}; \ No newline at end of file +};