From 7ca88367686708fb53bb16dd94804955bcfb908f Mon Sep 17 00:00:00 2001 From: stephentuso Date: Fri, 26 Feb 2016 00:55:36 -0500 Subject: [PATCH 001/102] Treat objectId and installationId matches the same when handling Installation writes --- src/RestWrite.js | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/RestWrite.js b/src/RestWrite.js index d23912a7..344d5959 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -571,6 +571,9 @@ RestWrite.prototype.handleInstallation = function() { var promise = Promise.resolve(); + var idMatch; // Will be a match on either objectId or installationId + var deviceTokenMatches = []; + if (this.query && this.query.objectId) { promise = promise.then(() => { return this.config.database.find('_Installation', { @@ -580,22 +583,22 @@ RestWrite.prototype.handleInstallation = function() { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for update.'); } - var existing = results[0]; - if (this.data.installationId && existing.installationId && - this.data.installationId !== existing.installationId) { + idMatch = results[0]; + if (this.data.installationId && idMatch.installationId && + this.data.installationId !== idMatch.installationId) { throw new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); } - if (this.data.deviceToken && existing.deviceToken && - this.data.deviceToken !== existing.deviceToken && - !this.data.installationId && !existing.installationId) { + if (this.data.deviceToken && idMatch.deviceToken && + this.data.deviceToken !== idMatch.deviceToken && + !this.data.installationId && !idMatch.installationId) { throw new Parse.Error(136, 'deviceToken may not be changed in this ' + 'operation'); } if (this.data.deviceType && this.data.deviceType && - this.data.deviceType !== existing.deviceType) { + this.data.deviceType !== idMatch.deviceType) { throw new Parse.Error(136, 'deviceType may not be changed in this ' + 'operation'); @@ -606,10 +609,8 @@ RestWrite.prototype.handleInstallation = function() { } // Check if we already have installations for the installationId/deviceToken - var installationMatch; - var deviceTokenMatches = []; promise = promise.then(() => { - if (this.data.installationId) { + if (!idMatch && this.data.installationId) { return this.config.database.find('_Installation', { 'installationId': this.data.installationId }); @@ -618,7 +619,7 @@ RestWrite.prototype.handleInstallation = function() { }).then((results) => { if (results && results.length) { // We only take the first match by installationId - installationMatch = results[0]; + idMatch = results[0]; } if (this.data.deviceToken) { return this.config.database.find( @@ -630,7 +631,7 @@ RestWrite.prototype.handleInstallation = function() { if (results) { deviceTokenMatches = results; } - if (!installationMatch) { + if (!idMatch) { if (!deviceTokenMatches.length) { return; } else if (deviceTokenMatches.length == 1 && @@ -668,14 +669,14 @@ RestWrite.prototype.handleInstallation = function() { // Exactly one device token match and it doesn't have an installation // ID. This is the one case where we want to merge with the existing // object. - var delQuery = {objectId: installationMatch.objectId}; + var delQuery = {objectId: idMatch.objectId}; return this.config.database.destroy('_Installation', delQuery) .then(() => { return deviceTokenMatches[0]['objectId']; }); } else { if (this.data.deviceToken && - installationMatch.deviceToken != this.data.deviceToken) { + idMatch.deviceToken != this.data.deviceToken) { // We're setting the device token on an existing installation, so // we should try cleaning out old installations that match this // device token. @@ -691,7 +692,7 @@ RestWrite.prototype.handleInstallation = function() { this.config.database.destroy('_Installation', delQuery); } // In non-merge scenarios, just return the installation match id - return installationMatch.objectId; + return idMatch.objectId; } } }).then((objId) => { From 753bead4ac7d350f7bef773b060ec132743fa9f2 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Fri, 26 Feb 2016 13:35:56 -0500 Subject: [PATCH 002/102] Recursive lookup for roles --- spec/ParseRole.spec.js | 44 ++++++++++++++++++++++++++++++++++++++++++ src/Auth.js | 16 +++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 919e0b55..83a5a59e 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -2,6 +2,8 @@ // Roles are not accessible without the master key, so they are not intended // for use by clients. We can manually test them using the master key. +var Auth = require("../src/Auth").Auth; +var Config = require("../src/Config"); describe('Parse Role testing', () => { @@ -57,6 +59,48 @@ describe('Parse Role testing', () => { }); }); + + it("should recursively load roles", (done) => { + + var rolesNames = ["FooRole", "BarRole", "BazRole"]; + + var createRole = function(name, parent, user) { + var role = new Parse.Object("_Role") + role.set("name", name); + if (user) { + var users = role.relation('users'); + users.add(user); + } + if (parent) { + role.relation('roles').add(parent); + } + return role.save({}, { useMasterKey: true }); + } + var roleIds = {}; + createTestUser().then( (user) => { + + return createRole(rolesNames[0], null, null).then( (aRole) => { + roleIds[aRole.get("name")] = aRole.id; + return createRole(rolesNames[1], aRole, null); + }).then( (anotherRole) => { + roleIds[anotherRole.get("name")] = anotherRole.id; + return createRole(rolesNames[2], anotherRole, user); + }).then( (lastRole) => { + roleIds[lastRole.get("name")] = lastRole.id; + var auth = new Auth(new Config("test") , true, user); + return auth._loadRoles(); + }) + }).then( (roles) => { + expect(roles.length).toEqual(3); + rolesNames.forEach( (name) => { + expect(roles.indexOf('role:'+name)).not.toBe(-1); + }) + done(); + }, function(err){ + fail("should succeed") + done(); + }); + }); }); diff --git a/src/Auth.js b/src/Auth.js index 642f34ab..a51c394a 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -159,6 +159,22 @@ Auth.prototype._getAllRoleNamesForId = function(roleID) { return Promise.resolve([]); } var roleIDs = results.map(r => r.objectId); + + var parentRolesPromises = roleIDs.map( (roleId) => { + return this._getAllRoleNamesForId(roleId); + }); + parentRolesPromises.push(Promise.resolve(roleIDs)); + return Promise.all(parentRolesPromises); + }).then(function(results){ + // Flatten + let roleIDs = results.reduce( (memo, result) => { + if (typeof result == "object") { + memo = memo.concat(result); + } else { + memo.push(result); + } + return memo; + }, []); return Promise.resolve(roleIDs); }); }; From c9d4f7693db511245690e123f19db82f3003dbd7 Mon Sep 17 00:00:00 2001 From: stephentuso Date: Fri, 26 Feb 2016 19:42:47 -0500 Subject: [PATCH 003/102] Should pass tests now --- src/RestWrite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestWrite.js b/src/RestWrite.js index 344d5959..e47773db 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -610,7 +610,7 @@ RestWrite.prototype.handleInstallation = function() { // Check if we already have installations for the installationId/deviceToken promise = promise.then(() => { - if (!idMatch && this.data.installationId) { + if (this.data.installationId) { return this.config.database.find('_Installation', { 'installationId': this.data.installationId }); From aee968a8536f19cced1b67646cc18afc302132d1 Mon Sep 17 00:00:00 2001 From: stephentuso Date: Fri, 26 Feb 2016 21:05:46 -0500 Subject: [PATCH 004/102] Add duplicate device token test --- spec/ParseInstallation.spec.js | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index cef6871e..880497e3 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -445,6 +445,52 @@ describe('Installations', () => { }); }); + it('update android device token with duplicate device token', (done) => { + var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + var input = { + 'installationId': installId1, + 'deviceToken': t, + 'deviceType': 'android' + }; + var firstObject; + var secondObject; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + 'installationId': installId2, + 'deviceType': 'android' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', + {installationId: installId1}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + firstObject = results[0]; + return database.mongoFind('_Installation', + {installationId: installId2}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + secondObject = results[0]; + // Update second installation to conflict with first installation + input = { + 'objectId': secondObject._id, + 'deviceToken': t + }; + return rest.update(config, auth.nobody(config), '_Installation', + secondObject._id, input); + }).then(() => { + // The first object should have been deleted + return database.mongoFind('_Installation', {_id: firstObject._id}, {}); + }).then((results) => { + expect(results.length).toEqual(0); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('update ios device token with duplicate device token', (done) => { var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; From 9bc636dc5c436f55821be1a5f3b48f34466959d4 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Fri, 26 Feb 2016 20:55:17 -0800 Subject: [PATCH 005/102] Cleanup, remove unusued methods and unify cache.js. --- src/Auth.js | 6 ++--- src/Config.js | 9 +++---- src/cache.js | 58 ++++++++++++++++++------------------------- src/index.js | 6 ++--- src/middlewares.js | 14 +++++------ src/rest.js | 2 +- src/testing-routes.js | 7 +++--- src/triggers.js | 4 +-- 8 files changed, 46 insertions(+), 60 deletions(-) diff --git a/src/Auth.js b/src/Auth.js index 642f34ab..d944307f 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -43,7 +43,7 @@ function nobody(config) { // Returns a promise that resolves to an Auth object var getAuthForSessionToken = function(config, sessionToken) { - var cachedUser = cache.getUser(sessionToken); + var cachedUser = cache.users.get(sessionToken); if (cachedUser) { return Promise.resolve(new Auth(config, false, cachedUser)); } @@ -65,8 +65,8 @@ var getAuthForSessionToken = function(config, sessionToken) { delete obj.password; obj['className'] = '_User'; obj['sessionToken'] = sessionToken; - var userObject = Parse.Object.fromJSON(obj); - cache.setUser(sessionToken, userObject); + let userObject = Parse.Object.fromJSON(obj); + cache.users.set(sessionToken, userObject); return new Auth(config, false, userObject); }); }; diff --git a/src/Config.js b/src/Config.js index 8ceeb0e1..988efb1e 100644 --- a/src/Config.js +++ b/src/Config.js @@ -7,15 +7,12 @@ import cache from './cache'; export class Config { constructor(applicationId: string, mount: string) { let DatabaseAdapter = require('./DatabaseAdapter'); - - let cacheInfo = cache.apps[applicationId]; - this.valid = !!cacheInfo; - if (!this.valid) { + let cacheInfo = cache.apps.get(applicationId); + if (!cacheInfo) { return; } this.applicationId = applicationId; - this.collectionPrefix = cacheInfo.collectionPrefix || ''; this.masterKey = cacheInfo.masterKey; this.clientKey = cacheInfo.clientKey; this.javascriptKey = cacheInfo.javascriptKey; @@ -25,7 +22,7 @@ export class Config { this.facebookAppIds = cacheInfo.facebookAppIds; this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; - this.database = DatabaseAdapter.getDatabaseConnection(applicationId, this.collectionPrefix); + this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); this.hooksController = cacheInfo.hooksController; this.filesController = cacheInfo.filesController; this.pushController = cacheInfo.pushController; diff --git a/src/cache.js b/src/cache.js index a737a91d..8893f29b 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,45 +1,35 @@ -export var apps = {}; -export var stats = {}; -export var isLoaded = false; -export var users = {}; +/** @flow weak */ -export function getApp(app, callback) { - if (apps[app]) return callback(true, apps[app]); - return callback(false); +export function CacheStore() { + let dataStore: {[id:KeyType]:ValueType} = {}; + return { + get: (key: KeyType): ValueType => { + return dataStore[key]; + }, + set(key: KeyType, value: ValueType): void { + dataStore[key] = value; + }, + remove(key: KeyType): void { + delete dataStore[key]; + }, + clear(): void { + dataStore = {}; + } + }; } -export function updateStat(key, value) { - stats[key] = value; -} - -export function getUser(sessionToken) { - if (users[sessionToken]) return users[sessionToken]; - return undefined; -} - -export function setUser(sessionToken, userObject) { - users[sessionToken] = userObject; -} - -export function clearUser(sessionToken) { - delete users[sessionToken]; -} +const apps = CacheStore(); +const users = CacheStore(); //So far used only in tests -export function clearCache() { - apps = {}; - stats = {}; - users = {}; +export function clearCache(): void { + apps.clear(); + users.clear(); } export default { apps, - stats, - isLoaded, - getApp, - updateStat, - clearUser, - getUser, - setUser, + users, clearCache, + CacheStore }; diff --git a/src/index.js b/src/index.js index ad715902..93c7ef17 100644 --- a/src/index.js +++ b/src/index.js @@ -126,7 +126,7 @@ function ParseServer({ const loggerController = new LoggerController(loggerControllerAdapter); const hooksController = new HooksController(appId, collectionPrefix); - cache.apps[appId] = { + cache.apps.set(appId, { masterKey: masterKey, collectionPrefix: collectionPrefix, clientKey: clientKey, @@ -142,11 +142,11 @@ function ParseServer({ enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, oauth: oauth - }; + }); // To maintain compatibility. TODO: Remove in v2.1 if (process.env.FACEBOOK_APP_ID) { - cache.apps[appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); + cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } // This app serves the Parse API directly. diff --git a/src/middlewares.js b/src/middlewares.js index 8acece2d..c1a8eb71 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -35,7 +35,7 @@ function handleParseHeaders(req, res, next) { var fileViaJSON = false; - if (!info.appId || !cache.apps[info.appId]) { + if (!info.appId || !cache.apps.get(info.appId)) { // See if we can find the app id on the body. if (req.body instanceof Buffer) { // The only chance to find the app id is if this is a file @@ -44,12 +44,10 @@ function handleParseHeaders(req, res, next) { fileViaJSON = true; } - if (req.body && req.body._ApplicationId - && cache.apps[req.body._ApplicationId] - && ( - !info.masterKey - || - cache.apps[req.body._ApplicationId]['masterKey'] === info.masterKey) + if (req.body && + req.body._ApplicationId && + cache.apps.get(req.body._ApplicationId) && + (!info.masterKey || cache.apps.get(req.body._ApplicationId).masterKey === info.masterKey) ) { info.appId = req.body._ApplicationId; info.javascriptKey = req.body._JavaScriptKey || ''; @@ -84,7 +82,7 @@ function handleParseHeaders(req, res, next) { req.body = new Buffer(base64, 'base64'); } - info.app = cache.apps[info.appId]; + info.app = cache.apps.get(info.appId); req.config = new Config(info.appId, mount); req.database = req.config.database; req.info = info; diff --git a/src/rest.js b/src/rest.js index dbf52616..d624f068 100644 --- a/src/rest.js +++ b/src/rest.js @@ -46,7 +46,7 @@ function del(config, auth, className, objectId) { .then((response) => { if (response && response.results && response.results.length) { response.results[0].className = className; - cache.clearUser(response.results[0].sessionToken); + cache.users.remove(response.results[0].sessionToken); inflatedObject = Parse.Object.fromJSON(response.results[0]); return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config.applicationId); } diff --git a/src/testing-routes.js b/src/testing-routes.js index 3823946f..f36b49e4 100644 --- a/src/testing-routes.js +++ b/src/testing-routes.js @@ -10,10 +10,11 @@ var router = express.Router(); // creates a unique app in the cache, with a collection prefix function createApp(req, res) { var appId = cryptoUtils.randomHexString(32); - cache.apps[appId] = { + // TODO: (nlutsenko) This doesn't work and should die, since there are no controllers on this configuration. + cache.apps.set(appId, { 'collectionPrefix': appId + '_', 'masterKey': 'master' - }; + }); var keys = { 'application_id': appId, 'client_key': 'unused', @@ -42,7 +43,7 @@ function dropApp(req, res) { return res.status(401).send({"error": "unauthorized"}); } req.database.deleteEverything().then(() => { - delete cache.apps[req.config.applicationId]; + cache.apps.remove(req.config.applicationId); res.status(200).send({}); }); } diff --git a/src/triggers.js b/src/triggers.js index 23e8f4a7..5220ce79 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -157,8 +157,8 @@ export function maybeRunTrigger(triggerType, auth, parseObject, originalParseObj var response = getResponseObject(request, resolve, reject); // Force the current Parse app before the trigger Parse.applicationId = applicationId; - Parse.javascriptKey = cache.apps[applicationId].javascriptKey || ''; - Parse.masterKey = cache.apps[applicationId].masterKey; + Parse.javascriptKey = cache.apps.get(applicationId).javascriptKey || ''; + Parse.masterKey = cache.apps.get(applicationId).masterKey; trigger(request, response); }); }; From 49a4b74f7cc6543fa9ca725c83e9ff3b8c29b9f9 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Fri, 26 Feb 2016 21:18:37 -0800 Subject: [PATCH 006/102] Fix missing return type on requiredParameter.js. --- src/requiredParameter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/requiredParameter.js b/src/requiredParameter.js index 0355285c..f6d5dd42 100644 --- a/src/requiredParameter.js +++ b/src/requiredParameter.js @@ -1,2 +1,2 @@ -/* @flow */ -export default (errorMessage: string) => {throw errorMessage} +/** @flow */ +export default (errorMessage: string): any => { throw errorMessage } From daa5f11122c97761897820107dc84bdbdfe89f5c Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Fri, 26 Feb 2016 21:17:04 -0800 Subject: [PATCH 007/102] Remove 'database' field from request and make all database requests go through config. --- src/Routers/UsersRouter.js | 2 +- src/middlewares.js | 1 - src/testing-routes.js | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 592bdc0b..4cba3edb 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -75,7 +75,7 @@ export class UsersRouter extends ClassesRouter { } let user; - return req.database.find('_User', { username: req.body.username }) + return req.config.database.find('_User', { username: req.body.username }) .then((results) => { if (!results.length) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); diff --git a/src/middlewares.js b/src/middlewares.js index c1a8eb71..b9a8d6ec 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -84,7 +84,6 @@ function handleParseHeaders(req, res, next) { info.app = cache.apps.get(info.appId); req.config = new Config(info.appId, mount); - req.database = req.config.database; req.info = info; var isMaster = (info.masterKey === req.config.masterKey); diff --git a/src/testing-routes.js b/src/testing-routes.js index f36b49e4..f91c14a1 100644 --- a/src/testing-routes.js +++ b/src/testing-routes.js @@ -32,7 +32,7 @@ function clearApp(req, res) { if (!req.auth.isMaster) { return res.status(401).send({"error": "unauthorized"}); } - req.database.deleteEverything().then(() => { + return req.config.database.deleteEverything().then(() => { res.status(200).send({}); }); } @@ -42,7 +42,7 @@ function dropApp(req, res) { if (!req.auth.isMaster) { return res.status(401).send({"error": "unauthorized"}); } - req.database.deleteEverything().then(() => { + return req.config.database.deleteEverything().then(() => { cache.apps.remove(req.config.applicationId); res.status(200).send({}); }); From d30c3e90df3657995445188ebd2d2df307c7224c Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Fri, 26 Feb 2016 21:24:42 -0800 Subject: [PATCH 008/102] Remove useless method in index.js. --- src/index.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/index.js b/src/index.js index 93c7ef17..dae92ee4 100644 --- a/src/index.js +++ b/src/index.js @@ -220,13 +220,6 @@ function addParseCloud() { global.Parse = Parse; } -function getClassName(parseClass) { - if (parseClass && parseClass.className) { - return parseClass.className; - } - return parseClass; -} - module.exports = { ParseServer: ParseServer, S3Adapter: S3Adapter From 768a781f989b6b9f6ff9c6c64a62d6c58273c315 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Fri, 26 Feb 2016 22:16:06 -0800 Subject: [PATCH 009/102] Fix wrong order of resetting data in test helper. --- spec/helper.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/helper.js b/spec/helper.js index c7474afe..92231393 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -52,13 +52,13 @@ delete defaultConfiguration.cloud; // Allows testing specific configurations of Parse Server var setServerConfiguration = configuration => { - api = new ParseServer(configuration); - app = express(); - app.use('/1', api); - cache.clearCache(); server.close(); + cache.clearCache(); + app = express(); + api = new ParseServer(configuration); + app.use('/1', api); server = app.listen(port); -} +}; var restoreServerConfiguration = () => setServerConfiguration(defaultConfiguration); From c359d0fb5f2bb6d88913c371747c0170b830b019 Mon Sep 17 00:00:00 2001 From: Marco129 Date: Sat, 27 Feb 2016 15:37:34 +0800 Subject: [PATCH 010/102] Allow create system class even allowClientClassCreation option is false --- README.md | 1 + src/RestQuery.js | 4 +++- src/RestWrite.js | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 640b637e..bec93d93 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo * `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)) * `enableAnonymousUsers` - Defaults to true. Set to false to disable anonymous users. +* `allowClientClassCreation` - Defaults to true. Set to false to disable client class creation. * `oauth` - Used to configure support for [3rd party authentication](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#oauth). * `maxUploadSize` - Defaults to 20mb. Max file size for uploads diff --git a/src/RestQuery.js b/src/RestQuery.js index 86562e9d..b9385eb6 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -165,7 +165,9 @@ RestQuery.prototype.redirectClassNameForKey = function() { // Validates this operation against the allowClientClassCreation config. RestQuery.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster) { + let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product']; + if (this.config.allowClientClassCreation === false && !this.auth.isMaster + && sysClass.indexOf(this.className) === -1) { return this.config.database.loadSchema().then((schema) => { return schema.hasClass(this.className) }).then((hasClass) => { diff --git a/src/RestWrite.js b/src/RestWrite.js index 4922f71d..fbb3c630 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -109,7 +109,9 @@ RestWrite.prototype.getUserAndRoleACL = function() { // Validates this operation against the allowClientClassCreation config. RestWrite.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster) { + let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product']; + if (this.config.allowClientClassCreation === false && !this.auth.isMaster + && sysClass.indexOf(this.className) === -1) { return this.config.database.loadSchema().then((schema) => { return schema.hasClass(this.className) }).then((hasClass) => { From 310650830e571691e472379c4f43714001d17946 Mon Sep 17 00:00:00 2001 From: Mike McDonald Date: Sat, 27 Feb 2016 00:23:17 -0800 Subject: [PATCH 011/102] Adding the GCP logo and quickstart --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 640b637e..e7bc18ad 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Parse Server works with the Express web application framework. It can be added t We have provided a basic [Node.js application](https://github.com/ParsePlatform/parse-server-example) that uses the Parse Server module on Express and can be easily deployed using any of the following buttons: - + ### Parse Server + Express @@ -44,10 +44,10 @@ app.listen(1337, function() { ### Standalone Parse Server -Parse Server can also run as a standalone API server. +Parse Server can also run as a standalone API server. You can configure Parse Server with a configuration file, arguments and environment variables. -To start the server: +To start the server: `npm start -- --appId MYAPP --masterKey MASTER_KEY --serverURL http://localhost:1337/parse`. From 6cafd46d06d1663837a0459c2e01bd7d443de1f7 Mon Sep 17 00:00:00 2001 From: Simon Bengtsson Date: Sat, 27 Feb 2016 15:54:43 +0100 Subject: [PATCH 012/102] Validate authData before triggering beforeSave --- src/RestWrite.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/RestWrite.js b/src/RestWrite.js index 4922f71d..3e1c2f6a 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -67,12 +67,12 @@ RestWrite.prototype.execute = function() { return this.handleInstallation(); }).then(() => { return this.handleSession(); + }).then(() => { + return this.validateAuthData(); }).then(() => { return this.runBeforeTrigger(); }).then(() => { return this.setRequiredFieldsIfNeeded(); - }).then(() => { - return this.validateAuthData(); }).then(() => { return this.transformUser(); }).then(() => { @@ -134,6 +134,10 @@ RestWrite.prototype.validateSchema = function() { // Runs any beforeSave triggers against this operation. // Any change leads to our data being mutated. RestWrite.prototype.runBeforeTrigger = function() { + if (this.response) { + return; + } + // Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class. if (!triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId)) { return Promise.resolve(); From 4eb9bd442f6736a2383a1ea281520d0d2e29e5d8 Mon Sep 17 00:00:00 2001 From: Simon Bengtsson Date: Sat, 27 Feb 2016 15:57:44 +0100 Subject: [PATCH 013/102] Adds test that makes sure beforeSave is not called on User when logging in with a provider --- spec/ParseUser.spec.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 424e4207..a36b3cdc 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1026,6 +1026,32 @@ describe('Parse.User testing', () => { }); }); + it("login with provider should not call beforeSave trigger", (done) => { + var provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + Parse.User.logOut(); + + Parse.Cloud.beforeSave(Parse.User, function(req, res) { + res.error("Before save shouldn't be called on login"); + }); + + Parse.User._logInWith("facebook", { + success: function(innerModel) { + Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); + done(); + }, + error: function(model, error) { + ok(undefined, error); + Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); + done(); + } + }); + } + }); + }); + it("link with provider", (done) => { var provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); From d067c0ba61c79bea210342411a1c9f07fce6f9c9 Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Fri, 26 Feb 2016 16:39:27 +0800 Subject: [PATCH 014/102] add invalid __type check --- spec/ParseObject.spec.js | 5 +++++ src/Schema.js | 46 +++++++++++++++++++++++++--------------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index 5d899655..10a44e9f 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -335,6 +335,11 @@ describe('Parse.Object testing', () => { 'Item should not be updated with invalid key.'); item.save({ "foo^bar": "baz" }).then(fail, done); }); + + it("invalid __type", function(done) { + var item = new Parse.Object("Item"); + item.save({ "foo": {__type: "IvalidName"} }).then(fail, done); + }); it("simple field deletion", function(done) { var simple = new Parse.Object("SimpleObject"); diff --git a/src/Schema.js b/src/Schema.js index 63dc6f37..a0590cf7 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -626,7 +626,7 @@ Schema.prototype.validateRequiredColumns = function(className, object, query) { if (!columns || columns.length == 0) { return Promise.resolve(this); } - + var missingColumns = columns.filter(function(column){ if (query && query.objectId) { if (object[column] && typeof object[column] === "object") { @@ -636,15 +636,15 @@ Schema.prototype.validateRequiredColumns = function(className, object, query) { // Not trying to do anything there return false; } - return !object[column] + return !object[column] }); - + if (missingColumns.length > 0) { throw new Parse.Error( Parse.Error.INCORRECT_TYPE, missingColumns[0]+' is required.'); } - + return Promise.resolve(this); } @@ -731,19 +731,31 @@ function getObjectType(obj) { if (obj instanceof Array) { return 'array'; } - if (obj.__type === 'Pointer' && obj.className) { - return '*' + obj.className; - } - if (obj.__type === 'File' && obj.name) { - return 'file'; - } - if (obj.__type === 'Date' && obj.iso) { - return 'date'; - } - if (obj.__type == 'GeoPoint' && - obj.latitude != null && - obj.longitude != null) { - return 'geopoint'; + if (obj.__type){ + switch(obj.__type) { + case 'Pointer' : + if(obj.className) { + return '*' + obj.className; + } + break; + case 'File' : + if(obj.url && obj.name) { + return 'file'; + } + break; + case 'Date' : + if(obj.iso) { + return 'date'; + } + break; + case 'GeoPoint' : + if(obj.latitude != null && obj.longitude != null) { + return 'geopoint'; + } + break; + default : + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'invalid type: ' + obj.__type); + } } if (obj['$ne']) { return getObjectType(obj['$ne']); From dec95ca4fa9085f5e4ea0bbef52a731a727e512d Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Fri, 26 Feb 2016 17:23:40 +0800 Subject: [PATCH 015/102] Add check of special type --- src/Schema.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Schema.js b/src/Schema.js index a0590cf7..cc438584 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -736,21 +736,30 @@ function getObjectType(obj) { case 'Pointer' : if(obj.className) { return '*' + obj.className; + } else { + throw new Parse.Error(Parse.Error.INVALID_POINTER, JSON.stringify(obj) + " is not a valid Pointer"); } break; case 'File' : if(obj.url && obj.name) { return 'file'; + } else { + let msg = obj.name? JSON.stringify(obj) + " is not a valid File" : "File has no name"; + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, msg); } break; case 'Date' : if(obj.iso) { return 'date'; + } else { + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, JSON.stringify(obj) + " is not a valid Date"); } break; case 'GeoPoint' : if(obj.latitude != null && obj.longitude != null) { return 'geopoint'; + } else { + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, JSON.stringify(obj) + " is not a valid GeoPoint"); } break; default : From ae9f196ec7e18add5b40ae259be53323b0d9b43e Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Fri, 26 Feb 2016 18:40:53 +0800 Subject: [PATCH 016/102] add Bytes to check --- src/Schema.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Schema.js b/src/Schema.js index cc438584..d05d7d7c 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -762,6 +762,11 @@ function getObjectType(obj) { throw new Parse.Error(Parse.Error.INCORRECT_TYPE, JSON.stringify(obj) + " is not a valid GeoPoint"); } break; + case 'Bytes' : + if(!obj.base64) { + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'Bytes type has no base64 field: ' + JSON.stringify(obj)); + } + break; default : throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'invalid type: ' + obj.__type); } From 88b14a914a4d59e537be840504bf77e86daa6a6c Mon Sep 17 00:00:00 2001 From: Jim Lin Date: Sat, 27 Feb 2016 16:05:55 +0800 Subject: [PATCH 017/102] url can & should be constructed during read --- .DS_Store | Bin 0 -> 8196 bytes src/Schema.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a15ba0c2fa3d0d3e557f72123e01ea9fdacc6d8b GIT binary patch literal 8196 zcmeHMOK=oL818TLV8)DO5)(+4O|cTJh_wYFfdoXz<^chLVvD>1S!Q=eGCG-wJF^=! zMpND_MN4^zT3UMWAU;k!^x}boWywV)2eH(HmL9Q64&ue~@99|{3AtI3DyFxl|L*_M zk8i%7uI^=wp)0SiWvq@dCeg#CT0z-u61VebPKpGYQj(x}=8k79CquR0Gvjl-Lq&){ zh(L%yh(L%yh`^nI0PWd4N%QRc!W!lw0wDr-B?A2W5T}RBWFV)7^gkVx`9}bf{0QJD zDyzIeG9JieAg6^S3nhq9k|Gqr5d$Ke^zpzi8OUiNML0un_(0Gzf)fh--l_e#@6M1E zGR#8+LIma_z?V-6b6JjMGZQzT-!0Sinw!5zq@=X0e32yI!+(|XV|}B}M4zX5dACc; z?e}Y@Ze)UTO7ljG<$lMpu9vmUplM8`)tYX{_B7KrayNw@7&2|$ndoxzw(h3Y+EmUc zkatS6jX~2jGp1#FPfIfYrPaF0$(Hr&6Y-YzmZ?O1vc0`I5ntEZHZ>(l^-ZlEJBRGa zW5-XNeB;!cXT)?7d@@+otm&V$94yeoJ%sTR=Nbsj;vz`Lm$dIR({N;$y8-EE1S18&*-$QKJyuabd_#4=U7(C zaZTRL$gpP|@wjb{X?UOO0B|`-cpJfb8n7TJQqPbba)=m5HK6+)gXFhHkIk^f6x!=~t3=vfyT_&q% zr2{%OyW7#ZkM3Kd_0i>$w4dv{a|Y!?t*nTylvPdYcWL~hi6&*0th#dllk`?aXhK;n zsk%I1X&J*3s<{)QyUMNsh@cc@sKzqH(12BFK`Yjy4V%z| zt?0#e?8YF5FoJzJ1QSod!Wi;+4$os6FXD9^!wHG~%Huj>cdS8KUL?tE@(d7TjUy6Y66&HE-+M)DC8wV%iE2}9 zl|;)!jrdh15nIj4ua*d_PikFO%Y?N}*}xZqS}LrYl+B#-YPqmBD4nvbGEr%YRmlGZ zProLX{z4qR%Km2mpd1yb!~Mj}HQ0y_B+*UG+=e|!VK4eHfP+XwLk2qW(#ANRf{SM{ z<@56eyo8tW3SPx)cnfdi9lVS8@F7m)BYc8$IFHZpIWFKLX7JN2rtU0qR7m`MgQ-j#%{Hz_P x=MX(SaldIH{SeA-xd@>8^*;=$94A*ElMLjvkffos;1>bm{vYoDKJ)JG>|btH7wZ53 literal 0 HcmV?d00001 diff --git a/src/Schema.js b/src/Schema.js index d05d7d7c..8c2e40b3 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -743,7 +743,7 @@ function getObjectType(obj) { case 'File' : if(obj.url && obj.name) { return 'file'; - } else { + } else if(!obj.url){ let msg = obj.name? JSON.stringify(obj) + " is not a valid File" : "File has no name"; throw new Parse.Error(Parse.Error.INCORRECT_TYPE, msg); } From f60c6af5e7061f88ed810472f14beddb977eafdf Mon Sep 17 00:00:00 2001 From: Jim Lin Date: Sat, 27 Feb 2016 17:00:52 +0800 Subject: [PATCH 018/102] Fix bug to __type checking --- src/Schema.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Schema.js b/src/Schema.js index 8c2e40b3..46bb9151 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -741,9 +741,9 @@ function getObjectType(obj) { } break; case 'File' : - if(obj.url && obj.name) { + if(obj.name) { return 'file'; - } else if(!obj.url){ + } else { let msg = obj.name? JSON.stringify(obj) + " is not a valid File" : "File has no name"; throw new Parse.Error(Parse.Error.INCORRECT_TYPE, msg); } From 430dd7944e23ad5c2c2d8a7c726d10d7a2fea936 Mon Sep 17 00:00:00 2001 From: Jim Lin Date: Sun, 28 Feb 2016 16:08:26 +0800 Subject: [PATCH 019/102] Add test cases for __type checking --- spec/ParseObject.spec.js | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index 10a44e9f..ccd8920c 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -335,10 +335,37 @@ describe('Parse.Object testing', () => { 'Item should not be updated with invalid key.'); item.save({ "foo^bar": "baz" }).then(fail, done); }); - + it("invalid __type", function(done) { - var item = new Parse.Object("Item"); - item.save({ "foo": {__type: "IvalidName"} }).then(fail, done); + var item = new Parse.Object("Item"); + var types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes']; + var Error = Parse.Error; + var tests = types.map(type => { + var test = new Parse.Object("Item"); + test.set('foo', { + __type: type + }); + return test; + }); + var next = function(index) { + if (index < tests.length) { + tests[index].save().then(fail, error => { + if (types[index] === 'Pointer') { + expect(error.code).toEqual(Parse.Error.INVALID_POINTER); + } else { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + } + next(index + 1); + }); + } else { + done(); + } + } + item.save({ + "foo": { + __type: "IvalidName" + } + }).then(fail, err => next(0)); }); it("simple field deletion", function(done) { From e43c471a7ee8f58735a96dc39efa81d2df5905ca Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 28 Feb 2016 12:38:36 -0500 Subject: [PATCH 020/102] Adds test that ensures the keys are properly set when using cloudcode --- spec/ParseAPI.spec.js | 9 +++++++++ spec/cloud/main.js | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 7bc7aa0a..2d0eed80 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -372,6 +372,15 @@ describe('miscellaneous', function() { done(); }); }); + + it('test cloud function shoud echo keys', function(done) { + Parse.Cloud.run('echoKeys').then((result) => { + expect(result.applicationId).toEqual(Parse.applicationId); + expect(result.masterKey).toEqual(Parse.masterKey); + expect(result.javascriptKey).toEqual(Parse.javascriptKey); + done(); + }); + }); it('test rest_create_app', function(done) { var appId; diff --git a/spec/cloud/main.js b/spec/cloud/main.js index 9e53e637..396fa862 100644 --- a/spec/cloud/main.js +++ b/spec/cloud/main.js @@ -100,3 +100,11 @@ Parse.Cloud.define('requiredParameterCheck', function(req, res) { }, function(params) { return params.name; }); + +Parse.Cloud.define('echoKeys', function(req, res){ + return res.success({ + applicationId: Parse.applicationId, + masterKey: Parse.masterKey, + javascriptKey: Parse.javascriptKey + }) +}); From d78c2746e9c8f37fbe80b5b129a32d30b0eaf0d3 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Sat, 27 Feb 2016 02:02:33 -0800 Subject: [PATCH 021/102] Rename ExportAdapter to DatabaseController. --- spec/DatabaseController.spec.js | 15 ++++++ spec/ExportAdapter.spec.js | 15 ------ src/Adapters/Files/FilesAdapter.js | 2 +- .../DatabaseController.js} | 52 +++++++++---------- src/DatabaseAdapter.js | 6 +-- src/Schema.js | 2 +- src/index.js | 2 +- 7 files changed, 46 insertions(+), 48 deletions(-) create mode 100644 spec/DatabaseController.spec.js delete mode 100644 spec/ExportAdapter.spec.js rename src/{ExportAdapter.js => Controllers/DatabaseController.js} (90%) diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js new file mode 100644 index 00000000..6ec8bfc0 --- /dev/null +++ b/spec/DatabaseController.spec.js @@ -0,0 +1,15 @@ +var DatabaseController = require('../src/Controllers/DatabaseController'); + +describe('DatabaseController', () => { + it('can be constructed', (done) => { + var database = new DatabaseController('mongodb://localhost:27017/test', + { + collectionPrefix: 'test_' + }); + database.connect().then(done, (error) => { + console.log('error', error.stack); + fail(); + }); + }); + +}); diff --git a/spec/ExportAdapter.spec.js b/spec/ExportAdapter.spec.js deleted file mode 100644 index a4f3f9b6..00000000 --- a/spec/ExportAdapter.spec.js +++ /dev/null @@ -1,15 +0,0 @@ -var ExportAdapter = require('../src/ExportAdapter'); - -describe('ExportAdapter', () => { - it('can be constructed', (done) => { - var database = new ExportAdapter('mongodb://localhost:27017/test', - { - collectionPrefix: 'test_' - }); - database.connect().then(done, (error) => { - console.log('error', error.stack); - fail(); - }); - }); - -}); diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index a1d5955f..2ff9fdb2 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -8,7 +8,7 @@ // * getFileLocation(config, request, filename) // // Default is GridStoreAdapter, which requires mongo -// and for the API server to be using the ExportAdapter +// and for the API server to be using the DatabaseController with Mongo // database adapter. export class FilesAdapter { diff --git a/src/ExportAdapter.js b/src/Controllers/DatabaseController.js similarity index 90% rename from src/ExportAdapter.js rename to src/Controllers/DatabaseController.js index 821c69cd..ed6241ce 100644 --- a/src/ExportAdapter.js +++ b/src/Controllers/DatabaseController.js @@ -5,12 +5,12 @@ var mongodb = require('mongodb'); var MongoClient = mongodb.MongoClient; var Parse = require('parse/node').Parse; -var Schema = require('./Schema'); -var transform = require('./transform'); +var Schema = require('./../Schema'); +var transform = require('./../transform'); // options can contain: // collectionPrefix: the string to put in front of every collection name. -function ExportAdapter(mongoURI, options = {}) { +function DatabaseController(mongoURI, options = {}) { this.mongoURI = mongoURI; this.collectionPrefix = options.collectionPrefix; @@ -27,7 +27,7 @@ function ExportAdapter(mongoURI, options = {}) { // connection is successful. // this.db will be populated with a Mongo "Db" object when the // promise resolves successfully. -ExportAdapter.prototype.connect = function() { +DatabaseController.prototype.connect = function() { if (this.connectionPromise) { // There's already a connection in progress. return this.connectionPromise; @@ -43,7 +43,7 @@ ExportAdapter.prototype.connect = function() { // Returns a promise for a Mongo collection. // Generally just for internal use. -ExportAdapter.prototype.collection = function(className) { +DatabaseController.prototype.collection = function(className) { if (!Schema.classNameIsValid(className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className); @@ -51,7 +51,7 @@ ExportAdapter.prototype.collection = function(className) { return this.rawCollection(className); }; -ExportAdapter.prototype.rawCollection = function(className) { +DatabaseController.prototype.rawCollection = function(className) { return this.connect().then(() => { return this.db.collection(this.collectionPrefix + className); }); @@ -64,7 +64,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 = returnsTrue) { +DatabaseController.prototype.loadSchema = function(acceptor = returnsTrue) { if (!this.schemaPromise) { this.schemaPromise = this.collection('_SCHEMA').then((coll) => { @@ -88,8 +88,8 @@ ExportAdapter.prototype.loadSchema = function(acceptor = returnsTrue) { // Returns a promise for the classname that is related to the given // classname through the key. -// TODO: make this not in the ExportAdapter interface -ExportAdapter.prototype.redirectClassNameForKey = function(className, key) { +// TODO: make this not in the DatabaseController interface +DatabaseController.prototype.redirectClassNameForKey = function(className, key) { return this.loadSchema().then((schema) => { var t = schema.getExpectedType(className, key); var match = t.match(/^relation<(.*)>$/); @@ -105,7 +105,7 @@ ExportAdapter.prototype.redirectClassNameForKey = function(className, key) { // Returns a promise that resolves to the new schema. // This does not update this.schema, because in a situation like a // batch request, that could confuse other users of the schema. -ExportAdapter.prototype.validateObject = function(className, object, query) { +DatabaseController.prototype.validateObject = function(className, object, query) { return this.loadSchema().then((schema) => { return schema.validateObject(className, object, query); }); @@ -113,7 +113,7 @@ ExportAdapter.prototype.validateObject = function(className, object, query) { // Like transform.untransformObject but you need to provide a className. // Filters out any data that shouldn't be on this REST-formatted object. -ExportAdapter.prototype.untransformObject = function( +DatabaseController.prototype.untransformObject = function( schema, isMaster, aclGroup, className, mongoObject) { var object = transform.untransformObject(schema, className, mongoObject); @@ -138,7 +138,7 @@ ExportAdapter.prototype.untransformObject = function( // 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.update = function(className, query, update, options) { +DatabaseController.prototype.update = function(className, query, update, options) { var acceptor = function(schema) { return schema.hasKeys(className, Object.keys(query)); }; @@ -196,7 +196,7 @@ ExportAdapter.prototype.update = function(className, query, update, options) { // Returns a promise that resolves successfully when these are // processed. // This mutates update. -ExportAdapter.prototype.handleRelationUpdates = function(className, +DatabaseController.prototype.handleRelationUpdates = function(className, objectId, update) { var pending = []; @@ -243,7 +243,7 @@ ExportAdapter.prototype.handleRelationUpdates = function(className, // Adds a relation. // Returns a promise that resolves successfully iff the add was successful. -ExportAdapter.prototype.addRelation = function(key, fromClassName, +DatabaseController.prototype.addRelation = function(key, fromClassName, fromId, toId) { var doc = { relatedId: toId, @@ -258,7 +258,7 @@ ExportAdapter.prototype.addRelation = function(key, fromClassName, // Removes a relation. // Returns a promise that resolves successfully iff the remove was // successful. -ExportAdapter.prototype.removeRelation = function(key, fromClassName, +DatabaseController.prototype.removeRelation = function(key, fromClassName, fromId, toId) { var doc = { relatedId: toId, @@ -277,7 +277,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 = {}) { +DatabaseController.prototype.destroy = function(className, query, options = {}) { var isMaster = !('acl' in options); var aclGroup = options.acl || []; @@ -320,7 +320,7 @@ ExportAdapter.prototype.destroy = function(className, query, options = {}) { // Inserts an object into the database. // Returns a promise that resolves successfully iff the object saved. -ExportAdapter.prototype.create = function(className, object, options) { +DatabaseController.prototype.create = function(className, object, options) { var schema; var isMaster = !('acl' in options); var aclGroup = options.acl || []; @@ -346,7 +346,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 = {}) { +DatabaseController.prototype.mongoFind = function(className, query, options = {}) { return this.collection(className).then((coll) => { return coll.find(query, options).toArray(); }); @@ -355,7 +355,7 @@ ExportAdapter.prototype.mongoFind = function(className, query, options = {}) { // Deletes everything in the database matching the current collectionPrefix // Won't delete collections in the system namespace // Returns a promise. -ExportAdapter.prototype.deleteEverything = function() { +DatabaseController.prototype.deleteEverything = function() { this.schemaPromise = null; return this.connect().then(() => { @@ -390,7 +390,7 @@ function keysForQuery(query) { // Returns a promise for a list of related ids given an owning id. // className here is the owning className. -ExportAdapter.prototype.relatedIds = function(className, key, owningId) { +DatabaseController.prototype.relatedIds = function(className, key, owningId) { var joinTable = '_Join:' + key + ':' + className; return this.collection(joinTable).then((coll) => { return coll.find({owningId: owningId}).toArray(); @@ -401,7 +401,7 @@ ExportAdapter.prototype.relatedIds = function(className, key, owningId) { // Returns a promise for a list of owning ids given some related ids. // className here is the owning className. -ExportAdapter.prototype.owningIds = function(className, key, relatedIds) { +DatabaseController.prototype.owningIds = function(className, key, relatedIds) { var joinTable = '_Join:' + key + ':' + className; return this.collection(joinTable).then((coll) => { return coll.find({relatedId: {'$in': relatedIds}}).toArray(); @@ -414,7 +414,7 @@ ExportAdapter.prototype.owningIds = function(className, key, relatedIds) { // equal-to-pointer constraints on relation fields. // Returns a promise that resolves when query is mutated // TODO: this only handles one of these at a time - make it handle more -ExportAdapter.prototype.reduceInRelation = function(className, query, schema) { +DatabaseController.prototype.reduceInRelation = function(className, query, schema) { // Search for an in-relation or equal-to-relation for (var key in query) { if (query[key] && @@ -442,7 +442,7 @@ ExportAdapter.prototype.reduceInRelation = function(className, query, schema) { // Modifies query so that it no longer has $relatedTo // Returns a promise that resolves when query is mutated -ExportAdapter.prototype.reduceRelationKeys = function(className, query) { +DatabaseController.prototype.reduceRelationKeys = function(className, query) { var relatedTo = query['$relatedTo']; if (relatedTo) { return this.relatedIds( @@ -461,7 +461,7 @@ ExportAdapter.prototype.reduceRelationKeys = function(className, query) { // none, then build the geoindex. // This could be improved a lot but it's not clear if that's a good // idea. Or even if this behavior is a good idea. -ExportAdapter.prototype.smartFind = function(coll, where, options) { +DatabaseController.prototype.smartFind = function(coll, where, options) { return coll.find(where, options).toArray() .then((result) => { return result; @@ -502,7 +502,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 = {}) { +DatabaseController.prototype.find = function(className, query, options = {}) { var mongoOptions = {}; if (options.skip) { mongoOptions.skip = options.skip; @@ -568,4 +568,4 @@ ExportAdapter.prototype.find = function(className, query, options = {}) { }); }; -module.exports = ExportAdapter; +module.exports = DatabaseController; diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js index 5c957418..f4d43771 100644 --- a/src/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -13,11 +13,9 @@ // * destroy(className, query, options) // * This list is incomplete and the database process is not fully modularized. // -// Default is ExportAdapter, which uses mongo. +// Default is DatabaseController, which uses mongo at this time. -var ExportAdapter = require('./ExportAdapter'); - -var adapter = ExportAdapter; +var adapter = require('./Controllers/DatabaseController'); var dbConnections = {}; var databaseURI = 'mongodb://localhost:27017/parse'; var appDatabaseURIs = {}; diff --git a/src/Schema.js b/src/Schema.js index 63dc6f37..7f7d4701 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -10,7 +10,7 @@ // keeping it this way for now. // // In API-handling code, you should only use the Schema class via the -// ExportAdapter. This will let us replace the schema logic for +// DatabaseController. This will let us replace the schema logic for // different databases. // TODO: hide all schema logic inside the database adapter. diff --git a/src/index.js b/src/index.js index dae92ee4..5062b6b0 100644 --- a/src/index.js +++ b/src/index.js @@ -45,7 +45,7 @@ addParseCloud(); // ParseServer works like a constructor of an express app. // The args that we understand are: -// "databaseAdapter": a class like ExportAdapter providing create, find, +// "databaseAdapter": a class like DatabaseController providing create, find, // update, and delete // "filesAdapter": a class like GridStoreAdapter providing create, get, // and delete From 997da898ebf0e8dbeaed2ac24b665325214fc5e1 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Sat, 27 Feb 2016 02:23:57 -0800 Subject: [PATCH 022/102] Split mongodb connection creation from DatabaseController. --- spec/DatabaseController.spec.js | 16 +++++----- .../Storage/Mongo/MongoStorageAdapter.js | 29 +++++++++++++++++++ src/Controllers/DatabaseController.js | 20 +++++-------- src/DatabaseAdapter.js | 12 ++++++-- 4 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 src/Adapters/Storage/Mongo/MongoStorageAdapter.js diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index 6ec8bfc0..3c55e1dd 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -1,15 +1,17 @@ -var DatabaseController = require('../src/Controllers/DatabaseController'); +'use strict'; + +let DatabaseController = require('../src/Controllers/DatabaseController'); +let MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); describe('DatabaseController', () => { - it('can be constructed', (done) => { - var database = new DatabaseController('mongodb://localhost:27017/test', - { + it('can be constructed', done => { + let adapter = new MongoStorageAdapter('mongodb://localhost:27017/test'); + let databaseController = new DatabaseController(adapter, { collectionPrefix: 'test_' - }); - database.connect().then(done, (error) => { + }); + databaseController.connect().then(done, error => { console.log('error', error.stack); fail(); }); }); - }); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js new file mode 100644 index 00000000..e4995cba --- /dev/null +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -0,0 +1,29 @@ + +let mongodb = require('mongodb'); +let MongoClient = mongodb.MongoClient; + +export class MongoStorageAdapter { + // Private + _uri: string; + // Public + connectionPromise; + database; + + constructor(uri: string) { + this._uri = uri; + } + + connect() { + if (this.connectionPromise) { + return this.connectionPromise; + } + + this.connectionPromise = MongoClient.connect(this._uri).then(database => { + this.database = database; + }); + return this.connectionPromise; + } +} + +export default MongoStorageAdapter; +module.exports = MongoStorageAdapter; // Required for tests diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index ed6241ce..ca1033e9 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -2,7 +2,6 @@ // Parse database. var mongodb = require('mongodb'); -var MongoClient = mongodb.MongoClient; var Parse = require('parse/node').Parse; var Schema = require('./../Schema'); @@ -10,10 +9,10 @@ var transform = require('./../transform'); // options can contain: // collectionPrefix: the string to put in front of every collection name. -function DatabaseController(mongoURI, options = {}) { - this.mongoURI = mongoURI; +function DatabaseController(adapter, { collectionPrefix } = {}) { + this.adapter = adapter; - this.collectionPrefix = options.collectionPrefix; + this.collectionPrefix = collectionPrefix; // We don't want a mutable this.schema, because then you could have // one request that uses different schemas for different parts of @@ -28,17 +27,12 @@ function DatabaseController(mongoURI, options = {}) { // this.db will be populated with a Mongo "Db" object when the // promise resolves successfully. DatabaseController.prototype.connect = function() { - if (this.connectionPromise) { - // There's already a connection in progress. - return this.connectionPromise; + if (this.adapter.connectionPromise) { + return this.adapter.connectionPromise; } - - this.connectionPromise = Promise.resolve().then(() => { - return MongoClient.connect(this.mongoURI); - }).then((db) => { - this.db = db; + return this.adapter.connect().then(() => { + this.db = this.adapter.database; }); - return this.connectionPromise; }; // Returns a promise for a Mongo collection. diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js index f4d43771..feb9311e 100644 --- a/src/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -13,9 +13,12 @@ // * destroy(className, query, options) // * This list is incomplete and the database process is not fully modularized. // -// Default is DatabaseController, which uses mongo at this time. +// Default is MongoStorageAdapter. -var adapter = require('./Controllers/DatabaseController'); +import DatabaseController from './Controllers/DatabaseController'; +import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; + +let adapter = MongoStorageAdapter; var dbConnections = {}; var databaseURI = 'mongodb://localhost:27017/parse'; var appDatabaseURIs = {}; @@ -44,9 +47,12 @@ function getDatabaseConnection(appId: string, collectionPrefix: string) { } var dbURI = (appDatabaseURIs[appId] ? appDatabaseURIs[appId] : databaseURI); - dbConnections[appId] = new adapter(dbURI, { + + let storageAdapter = new adapter(dbURI); + dbConnections[appId] = new DatabaseController(storageAdapter, { collectionPrefix: collectionPrefix }); + dbConnections[appId].connect(); return dbConnections[appId]; } From 7215300c1ed224a76c832287d021d3dead4d6292 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Sat, 27 Feb 2016 02:37:38 -0800 Subject: [PATCH 023/102] Move Mongo database property directly to mongo adapter. --- spec/Schema.spec.js | 10 +++++----- spec/schemas.spec.js | 4 ++-- src/Adapters/Files/GridStoreAdapter.js | 8 ++++---- src/Controllers/DatabaseController.js | 13 +++---------- src/Routers/SchemasRouter.js | 4 ++-- 5 files changed, 16 insertions(+), 23 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index ca427792..6a02009f 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -520,11 +520,11 @@ describe('Schema', () => { return obj2.save(); }) .then(() => { - config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { + config.database.adapter.database.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) => { + .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database.adapter.database, 'test_')) + .then(() => config.database.adapter.database.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { expect(err).not.toEqual(null); done(); })) @@ -538,7 +538,7 @@ describe('Schema', () => { 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(schema => schema.deleteField('aString', 'HasAllPOD', config.database.adapter.database, 'test_')) .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) .then(obj1Reloaded => { expect(obj1Reloaded.get('aString')).toEqual(undefined); @@ -568,7 +568,7 @@ describe('Schema', () => { expect(obj1.get('aPointer').id).toEqual(obj1.id); }) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aPointer', 'NewClass', config.database.db, 'test_')) + .then(schema => schema.deleteField('aPointer', 'NewClass', config.database.adapter.database, 'test_')) .then(() => new Parse.Query('NewClass').get(obj1.id)) .then(obj1 => { expect(obj1.get('aPointer')).toEqual(undefined); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 1a6a3069..36ba7637 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -710,10 +710,10 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual({}); - config.database.db.collection('test__Join:aRelation:MyOtherClass', { strict: true }, (err, coll) => { + config.database.adapter.database.collection('test__Join:aRelation:MyOtherClass', { strict: true }, (err, coll) => { //Expect Join table to be gone expect(err).not.toEqual(null); - config.database.db.collection('test_MyOtherClass', { strict: true }, (err, coll) => { + config.database.adapter.database.collection('test_MyOtherClass', { strict: true }, (err, coll) => { // Expect data table to be gone expect(err).not.toEqual(null); request.get({ diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index 21934c9a..00fd37bc 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -11,7 +11,7 @@ export class GridStoreAdapter extends FilesAdapter { // Returns a promise createFile(config, filename, data) { return config.database.connect().then(() => { - let gridStore = new GridStore(config.database.db, filename, 'w'); + let gridStore = new GridStore(config.database.adapter.database, filename, 'w'); return gridStore.open(); }).then((gridStore) => { return gridStore.write(data); @@ -22,7 +22,7 @@ export class GridStoreAdapter extends FilesAdapter { deleteFile(config, filename) { return config.database.connect().then(() => { - let gridStore = new GridStore(config.database.db, filename, 'w'); + let gridStore = new GridStore(config.database.adapter.database, filename, 'w'); return gridStore.open(); }).then((gridStore) => { return gridStore.unlink(); @@ -33,9 +33,9 @@ export class GridStoreAdapter extends FilesAdapter { getFileData(config, filename) { return config.database.connect().then(() => { - return GridStore.exist(config.database.db, filename); + return GridStore.exist(config.database.adapter.database, filename); }).then(() => { - let gridStore = new GridStore(config.database.db, filename, 'r'); + let gridStore = new GridStore(config.database.adapter.database, filename, 'r'); return gridStore.open(); }).then((gridStore) => { return gridStore.read(); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index ca1033e9..626c7644 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -24,15 +24,8 @@ function DatabaseController(adapter, { collectionPrefix } = {}) { // Connects to the database. Returns a promise that resolves when the // connection is successful. -// this.db will be populated with a Mongo "Db" object when the -// promise resolves successfully. DatabaseController.prototype.connect = function() { - if (this.adapter.connectionPromise) { - return this.adapter.connectionPromise; - } - return this.adapter.connect().then(() => { - this.db = this.adapter.database; - }); + return this.adapter.connect(); }; // Returns a promise for a Mongo collection. @@ -47,7 +40,7 @@ DatabaseController.prototype.collection = function(className) { DatabaseController.prototype.rawCollection = function(className) { return this.connect().then(() => { - return this.db.collection(this.collectionPrefix + className); + return this.adapter.database.collection(this.collectionPrefix + className); }); }; @@ -353,7 +346,7 @@ DatabaseController.prototype.deleteEverything = function() { this.schemaPromise = null; return this.connect().then(() => { - return this.db.collections(); + return this.adapter.database.collections(); }).then((colls) => { var promises = []; for (var coll of colls) { diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index d7388158..a748ad14 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -164,7 +164,7 @@ function modifySchema(req) { .then(() => schema.deleteField( submittedFieldName, className, - req.config.database.db, + req.config.database.adapter.database, req.config.database.collectionPrefix )); deletionPromises.push(promise); @@ -246,7 +246,7 @@ function deleteSchema(req) { //tried to delete non-existant class resolve({ response: {}}); } else { - removeJoinTables(req.config.database.db, req.config.database.collectionPrefix, doc.value) + removeJoinTables(req.config.database.adapter.database, req.config.database.collectionPrefix, doc.value) .then(resolve, reject); } }); From eb892830e6b8b9a5bbe8238668c701765ba333f5 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Sat, 27 Feb 2016 03:02:38 -0800 Subject: [PATCH 024/102] Move and cleanup getting collections into MongoStorageAdapter. --- .../Storage/Mongo/MongoStorageAdapter.js | 20 +++++++++++++++++++ src/Controllers/DatabaseController.js | 18 +++++------------ src/DatabaseAdapter.js | 2 -- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index e4995cba..914d9bb0 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -23,6 +23,26 @@ export class MongoStorageAdapter { }); return this.connectionPromise; } + + collection(name: string) { + return this.connect().then(() => { + return this.database.collection(name); + }); + } + + // Used for testing only right now. + collectionsContaining(match: string) { + return this.connect().then(() => { + return this.database.collections(); + }).then(collections => { + return collections.filter(collection => { + if (collection.namespace.match(/\.system\./)) { + return false; + } + return (collection.collectionName.indexOf(match) == 0); + }); + }); + } } export default MongoStorageAdapter; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 626c7644..954b1a6f 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -39,9 +39,7 @@ DatabaseController.prototype.collection = function(className) { }; DatabaseController.prototype.rawCollection = function(className) { - return this.connect().then(() => { - return this.adapter.database.collection(this.collectionPrefix + className); - }); + return this.adapter.collection(this.collectionPrefix + className); }; function returnsTrue() { @@ -345,16 +343,10 @@ DatabaseController.prototype.mongoFind = function(className, query, options = {} DatabaseController.prototype.deleteEverything = function() { this.schemaPromise = null; - return this.connect().then(() => { - return this.adapter.database.collections(); - }).then((colls) => { - var promises = []; - for (var coll of colls) { - if (!coll.namespace.match(/\.system\./) && - coll.collectionName.indexOf(this.collectionPrefix) === 0) { - promises.push(coll.drop()); - } - } + return this.adapter.collectionsContaining(this.collectionPrefix).then(collections => { + let promises = collections.map(collection => { + return collection.drop(); + }); return Promise.all(promises); }); }; diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js index feb9311e..47b4dbca 100644 --- a/src/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -52,8 +52,6 @@ function getDatabaseConnection(appId: string, collectionPrefix: string) { dbConnections[appId] = new DatabaseController(storageAdapter, { collectionPrefix: collectionPrefix }); - - dbConnections[appId].connect(); return dbConnections[appId]; } From 8dc37b9d304b459a015aa5ab1f642c5e93e0d3e4 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 16 Feb 2016 23:43:09 -0800 Subject: [PATCH 025/102] Exploring the interface of a mail adapter Add some tests and demonstrate the adapter loading interface --- package.json | 1 + spec/MockEmailAdapter.js | 3 + spec/MockEmailAdapterWithOptions.js | 8 + spec/ParseUser.spec.js | 213 ++++++++++++++++++++- spec/index.spec.js | 111 +++++++++++ src/Adapters/AdapterLoader.js | 7 +- src/Adapters/Email/SimpleMailgunAdapter.js | 39 ++++ src/Adapters/loadAdapter.js | 25 +++ src/Config.js | 7 +- src/Routers/UsersRouter.js | 39 +++- src/index.js | 77 +++++--- src/transform.js | 5 +- src/verifyEmail.js | 27 +++ 13 files changed, 525 insertions(+), 37 deletions(-) create mode 100644 spec/MockEmailAdapter.js create mode 100644 spec/MockEmailAdapterWithOptions.js create mode 100644 src/Adapters/Email/SimpleMailgunAdapter.js create mode 100644 src/Adapters/loadAdapter.js create mode 100644 src/verifyEmail.js diff --git a/package.json b/package.json index 9837376d..560e8e99 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "commander": "^2.9.0", "deepcopy": "^0.6.1", "express": "^4.13.4", + "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", diff --git a/spec/MockEmailAdapter.js b/spec/MockEmailAdapter.js new file mode 100644 index 00000000..e06e27cb --- /dev/null +++ b/spec/MockEmailAdapter.js @@ -0,0 +1,3 @@ +module.exports = { + sendVerificationEmail: () => Promise.resolve(); +} diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js new file mode 100644 index 00000000..fe402e06 --- /dev/null +++ b/spec/MockEmailAdapterWithOptions.js @@ -0,0 +1,8 @@ +module.exports = options => { + if (!options) { + throw "Options were not provided" + } + return { + sendVerificationEmail: () => Promise.resolve() + } +} diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index a36b3cdc..23d41fdd 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -49,6 +49,217 @@ describe('Parse.User testing', () => { }); }); + it('sends verification email if email verification is enabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does not send verification email if email verification is disabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: false, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + user.fetch() + .then(() => { + expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('receives the app name and user in the adapter', done => { + var emailAdapter = { + sendVerificationEmail: options => { + expect(options.appName).toEqual('emailing app'); + expect(options.user.get('email')).toEqual('user@parse.com'); + done(); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }) + + it('when you click the link in the email it sets emailVerified to true and redirects you', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/verify_email_success.html?username=zxcv'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(true); + done(); + }); + }); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(); + }); + + it('redirects you to invalid link if you try to verify email incorrecly', done => { + request.get('http://localhost:8378/1/verify_email', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + done() + }); + }); + + it('redirects you to invalid link if you try to validate a nonexistant users email', done => { + request.get('http://localhost:8378/1/verify_email?token=asdfasdf&username=sadfasga', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + done(); + }); + }); + + it('does not update email verified if you use an invalid token', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get('http://localhost:8378/1/verify_email?token=invalid&username=zxcv', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + it("user login wrong username", (done) => { Parse.User.signUp("asdf", "zxcv", null, { success: function(user) { @@ -1704,7 +1915,7 @@ describe('Parse.User testing', () => { done(); }); }); - + it('test parse user become', (done) => { var sessionToken = null; Parse.Promise.as().then(function() { diff --git a/spec/index.spec.js b/spec/index.spec.js index 8b558089..005b9c76 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -1,4 +1,5 @@ var request = require('request'); +var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); describe('server', () => { it('requires a master key and app id', done => { @@ -37,4 +38,114 @@ describe('server', () => { done(); }); }); + + it('can load email adapter via object', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: MockEmailAdapterWithOptions({ + apiKey: 'k', + domain: 'd', + }), + }); + done(); + }); + + it('can load email adapter via class', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + class: MockEmailAdapterWithOptions, + options: { + apiKey: 'k', + domain: 'd', + } + }, + }); + done(); + }); + + it('can load email adapter via module name', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + module: './Email/SimpleMailgunAdapter', + options: { + apiKey: 'k', + domain: 'd', + } + }, + }); + done(); + }); + + it('can load email adapter via only module name', done => { + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: './Email/SimpleMailgunAdapter', + })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); + done(); + }); + + it('throws if you initialize email adapter incorrecly', done => { + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + module: './Email/SimpleMailgunAdapter', + options: { + domain: 'd', + } + }, + })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); + done(); + }); }); diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index cfe51ffd..1557324b 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,4 +1,3 @@ - export function loadAdapter(options, defaultAdapter) { let adapter; @@ -12,7 +11,7 @@ export function loadAdapter(options, defaultAdapter) { adapter = options.adapter; } } - + if (!adapter) { adapter = defaultAdapter; } @@ -26,10 +25,12 @@ export function loadAdapter(options, defaultAdapter) { } } // From there it's either a function or an object - // if it's an function, instanciate and pass the options + // if it's an function, instanciate and pass the options if (typeof adapter === "function") { var Adapter = adapter; adapter = new Adapter(options); } return adapter; } + +module.exports = { loadAdapter } diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js new file mode 100644 index 00000000..2d51173d --- /dev/null +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -0,0 +1,39 @@ +import Mailgun from 'mailgun-js'; + +let SimpleMailgunAdapter = mailgunOptions => { + if (!mailgunOptions || !mailgunOptions.apiKey || !mailgunOptions.domain) { + throw 'SimpleMailgunAdapter requires an API Key and domain.'; + } + let mailgun = Mailgun(mailgunOptions); + + let sendMail = (to, subject, text) => { + let data = { + from: mailgunOptions.fromAddress, + to: to, + subject: subject, + text: text, + } + + return new Promise((resolve, reject) => { + mailgun.messages().send(data, (err, body) => { + if (typeof err !== 'undefined') { + reject(err); + } + resolve(body); + }); + }); + } + + return { + sendVerificationEmail: ({ link, user, appName, }) => { + let verifyMessage = + "Hi,\n\n" + + "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + + "" + + "Click here to confirm it:\n" + link; + return sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage); + } + } +} + +module.exports = SimpleMailgunAdapter diff --git a/src/Adapters/loadAdapter.js b/src/Adapters/loadAdapter.js new file mode 100644 index 00000000..2ab7b350 --- /dev/null +++ b/src/Adapters/loadAdapter.js @@ -0,0 +1,25 @@ +export default options => { + if (!options) { + return undefined; + } + + if (typeof options === 'string') { + //Configuring via module name with no options + return require(options)(); + } + + if (!options.module && !options.class) { + //Configuring via object + return options; + } + + if (options.module) { + //Configuring via module name + options + return require(options.module)(options.options) + } + + if (options.class) { + //Configuring via class + options + return options.class(options.options); + } +} diff --git a/src/Config.js b/src/Config.js index 988efb1e..2391a831 100644 --- a/src/Config.js +++ b/src/Config.js @@ -23,9 +23,14 @@ export class Config { this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); + + this.verifyUserEmails = cacheInfo.verifyUserEmails; + this.emailAdapter = cacheInfo.emailAdapter; + this.appName = cacheInfo.appName; + this.hooksController = cacheInfo.hooksController; this.filesController = cacheInfo.filesController; - this.pushController = cacheInfo.pushController; + this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; this.oauth = cacheInfo.oauth; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 4cba3edb..79dee41c 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,14 +1,15 @@ // These methods handle the User-related routes. -import deepcopy from 'deepcopy'; +import deepcopy from 'deepcopy'; -import ClassesRouter from './ClassesRouter'; -import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import Auth from '../Auth'; +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; +import Auth from '../Auth'; import passwordCrypto from '../password'; -import RestWrite from '../RestWrite'; -import { newToken } from '../cryptoUtils'; +import RestWrite from '../RestWrite'; +let cryptoUtils = require('../cryptoUtils'); +let triggers = require('../triggers'); export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -25,7 +26,26 @@ export class UsersRouter extends ClassesRouter { let data = deepcopy(req.body); req.body = data; req.params.className = '_User'; - return super.handleCreate(req); + + if (req.config.verifyUserEmails) { + req.body._email_verify_token = cryptoUtils.randomString(25); + req.body.emailVerified = false; + } + + let p = super.handleCreate(req); + + if (req.config.verifyUserEmails) { + // Send email as fire-and-forget once the user makes it into the DB. + p.then(() => { + let link = req.config.mount + "/verify_email?token=" + encodeURIComponent(req.body._email_verify_token) + "&username=" + encodeURIComponent(req.body.username); + req.config.emailAdapter.sendVerificationEmail({ + appName: req.config.appName, + link: link, + user: triggers.inflate('_User', req.body), + }); + }); + } + return p; } handleUpdate(req) { @@ -87,7 +107,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - let token = 'r:' + newToken(); + let token = 'r:' + cryptoUtils.newToken(); user.sessionToken = token; delete user.password; @@ -153,6 +173,7 @@ export class UsersRouter extends ClassesRouter { this.route('POST', '/requestPasswordReset', () => { throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); }); + this.route('POST', '/requestPasswordReset', req => this.handleReset(req)); } } diff --git a/src/index.js b/src/index.js index 5062b6b0..247a9274 100644 --- a/src/index.js +++ b/src/index.js @@ -11,32 +11,34 @@ var batch = require('./batch'), Parse = require('parse/node').Parse; import cache from './cache'; -import PromiseRouter from './PromiseRouter'; -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'; - -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'; +//import passwordReset from './passwordReset'; +import PromiseRouter from './PromiseRouter'; +import verifyEmail from './verifyEmail'; +import loadAdapter from './Adapters/loadAdapter'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; -import { FunctionsRouter } from './Routers/FunctionsRouter'; -import { SchemasRouter } from './Routers/SchemasRouter'; -import { IAPValidationRouter } from './Routers/IAPValidationRouter'; -import { PushRouter } from './Routers/PushRouter'; +import { ClassesRouter } from './Routers/ClassesRouter'; +import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; +import { FilesController } from './Controllers/FilesController'; import { FilesRouter } from './Routers/FilesRouter'; +import { FunctionsRouter } from './Routers/FunctionsRouter'; +import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; +import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { LogsRouter } from './Routers/LogsRouter'; import { HooksRouter } from './Routers/HooksRouter'; -import { loadAdapter } from './Adapters/AdapterLoader'; -import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; -import { LoggerController } from './Controllers/LoggerController'; import { HooksController } from './Controllers/HooksController'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { AdapterLoader } from './Adapters/AdapterLoader'; +import { LoggerController } from './Controllers/LoggerController'; +import { PushController } from './Controllers/PushController'; +import { PushRouter } from './Routers/PushRouter'; +import { RolesRouter } from './Routers/RolesRouter'; +import { S3Adapter } from './Adapters/Files/S3Adapter'; +import { SchemasRouter } from './Routers/SchemasRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; +import { UsersRouter } from './Routers/UsersRouter'; import requiredParameter from './requiredParameter'; import { randomString } from './cryptoUtils'; @@ -69,9 +71,24 @@ addParseCloud(); // "javascriptKey": optional key from Parse dashboard // "push": optional key from configure push +let validateEmailConfiguration = (verifyUserEmails, appName, emailAdapter) => { + if (verifyUserEmails) { + if (typeof appName !== 'string') { + throw 'An app name is required when using email verification.'; + } + if (!emailAdapter) { + throw 'User email verification was enabled, but no email adapter was provided'; + } + if (typeof emailAdapter.sendVerificationEmail !== 'function') { + throw 'Invalid email adapter: no sendVerificationEmail() function was provided'; + } + } +} + function ParseServer({ appId = requiredParameter('You must provide an appId!'), masterKey = requiredParameter('You must provide a masterKey!'), + appName, databaseAdapter, filesAdapter, push, @@ -89,7 +106,9 @@ function ParseServer({ allowClientClassCreation = true, oauth = {}, serverURL = requiredParameter('You must provide a serverURL!'), - maxUploadSize = '20mb' + maxUploadSize = '20mb', + verifyUserEmails = false, + emailAdapter, }) { // Initialize the node client SDK automatically @@ -141,10 +160,18 @@ function ParseServer({ hooksController: hooksController, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, - oauth: oauth + oauth: oauth, + appName: appName, }); - // To maintain compatibility. TODO: Remove in v2.1 + if (verifyUserEmails && process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { + emailAdapter = loadAdapter(emailAdapter); + validateEmailConfiguration(verifyUserEmails, appName, emailAdapter); + cache.apps[appId].verifyUserEmails = verifyUserEmails; + cache.apps[appId].emailAdapter = emailAdapter; + } + + // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } @@ -158,6 +185,12 @@ function ParseServer({ maxUploadSize: maxUploadSize })); + if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { + //api.use('/request_password_reset', passwordReset.reset(appName, appId)); + //api.get('/password_reset_success', passwordReset.success); + api.get('/verify_email', verifyEmail(appId, serverURL)); + } + // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { api.use('/', require('./testing-routes').router); @@ -222,5 +255,5 @@ function addParseCloud() { module.exports = { ParseServer: ParseServer, - S3Adapter: S3Adapter + S3Adapter: S3Adapter, }; diff --git a/src/transform.js b/src/transform.js index f254f0d4..7ff570c0 100644 --- a/src/transform.js +++ b/src/transform.js @@ -42,6 +42,9 @@ export function transformKeyValue(schema, className, restKey, restValue, options key = '_updated_at'; timeField = true; break; + case '_email_verify_token': + key = "_email_verify_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; @@ -649,7 +652,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals restObject['authData'][provider] = mongoObject[key]; break; } - + if (key.indexOf('_p_') == 0) { var newKey = key.substring(3); var expected; diff --git a/src/verifyEmail.js b/src/verifyEmail.js new file mode 100644 index 00000000..5bd1da32 --- /dev/null +++ b/src/verifyEmail.js @@ -0,0 +1,27 @@ +function verifyEmail(appId, serverURL) { + var DatabaseAdapter = require('./DatabaseAdapter'); + var database = DatabaseAdapter.getDatabaseConnection(appId); + return (req, res) => { + var token = req.query.token; + var username = req.query.username; + if (!token || !username) { + res.redirect(302, serverURL + '/invalid_link.html'); + return; + } + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + coll.findAndModify({ + username: username, + _email_verify_token: token, + }, null, {$set: {emailVerified: true}}, (err, doc) => { + if (err || !doc.value) { + res.redirect(302, serverURL + '/invalid_link.html'); + } else { + res.redirect(302, serverURL + '/verify_email_success.html?username=' + username); + } + }); + }); + } +} + +module.exports = verifyEmail; From 0b307bc22f19b8eca614f0dd8c760c32b5011133 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 23 Feb 2016 21:05:27 -0500 Subject: [PATCH 026/102] Improves AdapterLoader, enforces configuraiton on Adapters --- spec/AdapterLoader.spec.js | 27 ++++++++-- spec/MockAdapter.js | 6 ++- spec/MockEmailAdapterWithOptions.js | 3 +- spec/OneSignalPushAdapter.spec.js | 33 ++++++++----- spec/ParseUser.spec.js | 15 ++++-- src/Adapters/AdapterLoader.js | 57 ++++++++++++---------- src/Adapters/Email/MailAdapter.js | 6 +++ src/Adapters/Email/SimpleMailgunAdapter.js | 15 ++++-- src/Adapters/Files/S3Adapter.js | 31 +++++++++--- src/Adapters/Logger/FileLoggerAdapter.js | 7 ++- src/Adapters/Push/OneSignalPushAdapter.js | 4 ++ src/Adapters/loadAdapter.js | 25 ---------- src/Config.js | 2 +- src/Controllers/AdaptableController.js | 1 - src/Controllers/MailController.js | 29 +++++++++++ src/Routers/UsersRouter.js | 10 +--- src/index.js | 14 ++---- 17 files changed, 176 insertions(+), 109 deletions(-) create mode 100644 src/Adapters/Email/MailAdapter.js delete mode 100644 src/Adapters/loadAdapter.js create mode 100644 src/Controllers/MailController.js diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index 80f30d6f..f32867e0 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -2,15 +2,17 @@ var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; -describe("AdaptableController", ()=>{ +describe("AdapterLoader", ()=>{ it("should instantiate an adapter from string in object", (done) => { var adapterPath = require('path').resolve("./spec/MockAdapter"); var adapter = loadAdapter({ adapter: adapterPath, - key: "value", - foo: "bar" + options: { + key: "value", + foo: "bar" + } }); expect(adapter instanceof Object).toBe(true); @@ -24,7 +26,6 @@ describe("AdaptableController", ()=>{ var adapter = loadAdapter(adapterPath); expect(adapter instanceof Object).toBe(true); - expect(adapter.options).toBe(adapterPath); done(); }); @@ -65,4 +66,22 @@ describe("AdaptableController", ()=>{ expect(adapter).toBe(originalAdapter); done(); }); + + it("should fail loading an improperly configured adapter", (done) => { + var Adapter = function(options) { + if (!options.foo) { + throw "foo is required for that adapter"; + } + } + var adapterOptions = { + param: "key", + doSomething: function() {} + }; + + expect(() => { + var adapter = loadAdapter(adapterOptions, Adapter); + expect(adapter).toEqual(adapterOptions); + }).not.toThrow("foo is required for that adapter"); + done(); + }); }); diff --git a/spec/MockAdapter.js b/spec/MockAdapter.js index 60d8ef86..c3f55784 100644 --- a/spec/MockAdapter.js +++ b/spec/MockAdapter.js @@ -1,3 +1,5 @@ module.exports = function(options) { - this.options = options; -} + return { + options: options + }; +}; diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js index fe402e06..d5b6141a 100644 --- a/spec/MockEmailAdapterWithOptions.js +++ b/spec/MockEmailAdapterWithOptions.js @@ -3,6 +3,7 @@ module.exports = options => { throw "Options were not provided" } return { - sendVerificationEmail: () => Promise.resolve() + sendVerificationEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } } diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index 2c165c45..f3ae2cdb 100644 --- a/spec/OneSignalPushAdapter.spec.js +++ b/spec/OneSignalPushAdapter.spec.js @@ -1,13 +1,15 @@ var OneSignalPushAdapter = require('../src/Adapters/Push/OneSignalPushAdapter'); var classifyInstallations = require('../src/Adapters/Push/PushAdapterUtils').classifyInstallations; + +// Make mock config +var pushConfig = { + oneSignalAppId:"APP ID", + oneSignalApiKey:"API KEY" +}; + describe('OneSignalPushAdapter', () => { it('can be initialized', (done) => { - // Make mock config - var pushConfig = { - oneSignalAppId:"APP ID", - oneSignalApiKey:"API KEY" - }; var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); @@ -17,9 +19,17 @@ describe('OneSignalPushAdapter', () => { expect(senderMap.android instanceof Function).toBe(true); done(); }); + + it('cannt be initialized if options are missing', (done) => { + + expect(() => { + new OneSignalPushAdapter(); + }).toThrow("Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"); + done(); + }); it('can get valid push types', (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); expect(oneSignalPushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); done(); @@ -56,7 +66,7 @@ describe('OneSignalPushAdapter', () => { it('can send push notifications', (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); // Mock android ios senders var androidSender = jasmine.createSpy('send') @@ -108,7 +118,7 @@ describe('OneSignalPushAdapter', () => { }); it("can send iOS notifications", (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; @@ -135,7 +145,7 @@ describe('OneSignalPushAdapter', () => { }); it("can send Android notifications", (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; @@ -157,10 +167,7 @@ describe('OneSignalPushAdapter', () => { }); it("can post the correct data", (done) => { - var pushConfig = { - oneSignalAppId:"APP ID", - oneSignalApiKey:"API KEY" - }; + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var write = jasmine.createSpy('write'); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 23d41fdd..64477074 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -51,7 +51,8 @@ describe('Parse.User testing', () => { it('sends verification email if email verification is enabled', done => { var emailAdapter = { - sendVerificationEmail: () => Promise.resolve() + sendVerificationEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -89,7 +90,8 @@ describe('Parse.User testing', () => { it('does not send verification email if email verification is disabled', done => { var emailAdapter = { - sendVerificationEmail: () => Promise.resolve() + sendVerificationEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -131,7 +133,8 @@ describe('Parse.User testing', () => { expect(options.appName).toEqual('emailing app'); expect(options.user.get('email')).toEqual('user@parse.com'); done(); - } + }, + sendMail: () => {} } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -175,7 +178,8 @@ describe('Parse.User testing', () => { done(); }); }); - } + }, + sendMail: () => {} } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -232,7 +236,8 @@ describe('Parse.User testing', () => { done(); }); }); - } + }, + sendMail: () => {} } setServerConfiguration({ serverURL: 'http://localhost:8378/1', diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index 1557324b..5b46f22d 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,36 +1,43 @@ -export function loadAdapter(options, defaultAdapter) { - let adapter; +export function loadAdapter(adapter, defaultAdapter, options) { - // We have options and options have adapter key - if (options) { - // Pass an adapter as a module name, a function or an instance - if (typeof options == "string" || typeof options == "function" || options.constructor != Object) { - adapter = options; + if (!adapter) + { + if (!defaultAdapter) { + return options; } - if (options.adapter) { - adapter = options.adapter; + // Load from the default adapter when no adapter is set + return loadAdapter(defaultAdapter, undefined, options); + } else if (typeof adapter === "function") { + try { + return adapter(options); + } catch(e) { + var Adapter = adapter; + return new Adapter(options); } - } - - if (!adapter) { - adapter = defaultAdapter; - } - - // This is a string, require the module - if (typeof adapter === "string") { + } else if (typeof adapter === "string") { adapter = require(adapter); // If it's define as a module, get the default if (adapter.default) { adapter = adapter.default; } + + return loadAdapter(adapter, undefined, options); + } else if (adapter.module) { + return loadAdapter(adapter.module, undefined, adapter.options); + } else if (adapter.class) { + return loadAdapter(adapter.class, undefined, adapter.options); + } else if (adapter.adapter) { + return loadAdapter(adapter.adapter, undefined, adapter.options); + } else { + // Try to load the defaultAdapter with the options + // The default adapter should throw if the options are + // incompatible + try { + return loadAdapter(defaultAdapter, undefined, adapter); + } catch (e) {}; } - // From there it's either a function or an object - // if it's an function, instanciate and pass the options - if (typeof adapter === "function") { - var Adapter = adapter; - adapter = new Adapter(options); - } - return adapter; + // return the adapter as is as it's unusable otherwise + return adapter; } -module.exports = { loadAdapter } +export default loadAdapter; diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js new file mode 100644 index 00000000..ceccf931 --- /dev/null +++ b/src/Adapters/Email/MailAdapter.js @@ -0,0 +1,6 @@ +export class MailAdapter { + sendVerificationEmail(options) {} + sendMail(options) {} +} + +export default MailAdapter; diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index 2d51173d..f2460182 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -6,7 +6,7 @@ let SimpleMailgunAdapter = mailgunOptions => { } let mailgun = Mailgun(mailgunOptions); - let sendMail = (to, subject, text) => { + let sendMail = ({to, subject, text}) => { let data = { from: mailgunOptions.fromAddress, to: to, @@ -24,16 +24,21 @@ let SimpleMailgunAdapter = mailgunOptions => { }); } - return { + return Object.freeze({ sendVerificationEmail: ({ link, user, appName, }) => { let verifyMessage = "Hi,\n\n" + "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + "" + "Click here to confirm it:\n" + link; - return sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage); - } - } + return sendMail({ + to:user.email, + subject: 'Please verify your e-mail for ' + appName, + text: verifyMessage + }); + }, + sendMail: sendMail + }); } module.exports = SimpleMailgunAdapter diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index 0732fbfe..d63880f4 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -4,23 +4,38 @@ import * as AWS from 'aws-sdk'; import { FilesAdapter } from './FilesAdapter'; +import requiredParameter from '../../requiredParameter'; const DEFAULT_S3_REGION = "us-east-1"; +function parseS3AdapterOptions(...options) { + if (options.length === 1 && typeof options[0] == "object") { + return options; + } + + const additionalOptions = options[3] || {}; + + return { + accessKey: options[0], + secretKey: options[1], + bucket: options[2], + region: additionalOptions.region + } +} + 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, - bucket, - { region = DEFAULT_S3_REGION, - bucketPrefix = '', - directAccess = false } = {} - ) { + accessKey = requiredParameter('S3Adapter requires an accessKey'), + secretKey = requiredParameter('S3Adapter requires a secretKey'), + bucket, + { region = DEFAULT_S3_REGION, + bucketPrefix = '', + directAccess = false } = {}) { super(); - + this._region = region; this._bucket = bucket; this._bucketPrefix = bucketPrefix; diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js index 9e308242..5c8bd495 100644 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -99,9 +99,12 @@ let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => { } export class FileLoggerAdapter extends LoggerAdapter { - constructor(options = {}) { + constructor(options) { super(); - + if (options && !options.logsFolder) { + throw "FileLoggerAdapter requires logsFolder"; + } + options = options || {}; this._logsFolder = options.logsFolder || LOGS_FOLDER; // check logs folder exists diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index fe2fcc0b..ae5e8283 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -18,6 +18,10 @@ export class OneSignalPushAdapter extends PushAdapter { this.validPushTypes = ['ios', 'android']; this.senderMap = {}; this.OneSignalConfig = {}; + const { oneSignalAppId, oneSignalApiKey } = pushConfig; + if (!oneSignalAppId || !oneSignalApiKey) { + throw "Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"; + } this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; diff --git a/src/Adapters/loadAdapter.js b/src/Adapters/loadAdapter.js deleted file mode 100644 index 2ab7b350..00000000 --- a/src/Adapters/loadAdapter.js +++ /dev/null @@ -1,25 +0,0 @@ -export default options => { - if (!options) { - return undefined; - } - - if (typeof options === 'string') { - //Configuring via module name with no options - return require(options)(); - } - - if (!options.module && !options.class) { - //Configuring via object - return options; - } - - if (options.module) { - //Configuring via module name + options - return require(options.module)(options.options) - } - - if (options.class) { - //Configuring via class + options - return options.class(options.options); - } -} diff --git a/src/Config.js b/src/Config.js index 2391a831..1203b0a3 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,8 +24,8 @@ export class Config { this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); + this.mailController = cacheInfo.mailController; this.verifyUserEmails = cacheInfo.verifyUserEmails; - this.emailAdapter = cacheInfo.emailAdapter; this.appName = cacheInfo.appName; this.hooksController = cacheInfo.hooksController; diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index ef45b022..83f3f0a0 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -31,7 +31,6 @@ export class AdaptableController { } validateAdapter(adapter) { - if (!adapter) { throw new Error(this.constructor.name+" requires an adapter"); } diff --git a/src/Controllers/MailController.js b/src/Controllers/MailController.js new file mode 100644 index 00000000..ee467fe6 --- /dev/null +++ b/src/Controllers/MailController.js @@ -0,0 +1,29 @@ +import AdaptableController from './AdaptableController'; +import { MailAdapter } from '../Adapters/Email/MailAdapter'; +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; + +export class MailController extends AdaptableController { + setEmailVerificationStatus(user, status) { + if (status == false) { + user._email_verify_token = randomString(25); + } + user.emailVerified = status; + } + sendVerificationEmail(user, config) { + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + let link = `${config.mount}/verify_email?token=${token}&username=${username}`; + this.adapter.sendVerificationEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + } + sendMail(options) { + this.adapter.sendMail(options); + } + expectedAdapterType() { + return MailAdapter; + } +} diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 79dee41c..1e329734 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -28,8 +28,7 @@ export class UsersRouter extends ClassesRouter { req.params.className = '_User'; if (req.config.verifyUserEmails) { - req.body._email_verify_token = cryptoUtils.randomString(25); - req.body.emailVerified = false; + req.config.mailController.setEmailVerificationStatus(req.body, false); } let p = super.handleCreate(req); @@ -37,12 +36,7 @@ export class UsersRouter extends ClassesRouter { if (req.config.verifyUserEmails) { // Send email as fire-and-forget once the user makes it into the DB. p.then(() => { - let link = req.config.mount + "/verify_email?token=" + encodeURIComponent(req.body._email_verify_token) + "&username=" + encodeURIComponent(req.body.username); - req.config.emailAdapter.sendVerificationEmail({ - appName: req.config.appName, - link: link, - user: triggers.inflate('_User', req.body), - }); + req.config.mailController.sendVerificationEmail(req.body, req.config); }); } return p; diff --git a/src/index.js b/src/index.js index 247a9274..74a63bf9 100644 --- a/src/index.js +++ b/src/index.js @@ -16,11 +16,11 @@ import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; //import passwordReset from './passwordReset'; import PromiseRouter from './PromiseRouter'; import verifyEmail from './verifyEmail'; -import loadAdapter from './Adapters/loadAdapter'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { FilesController } from './Controllers/FilesController'; +import { MailController } from './Controllers/MailController'; import { FilesRouter } from './Routers/FilesRouter'; import { FunctionsRouter } from './Routers/FunctionsRouter'; import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; @@ -30,7 +30,7 @@ import { HooksRouter } from './Routers/HooksRouter'; import { HooksController } from './Controllers/HooksController'; import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { AdapterLoader } from './Adapters/AdapterLoader'; +import { loadAdapter } from './Adapters/AdapterLoader'; import { LoggerController } from './Controllers/LoggerController'; import { PushController } from './Controllers/PushController'; import { PushRouter } from './Routers/PushRouter'; @@ -79,9 +79,6 @@ let validateEmailConfiguration = (verifyUserEmails, appName, emailAdapter) => { if (!emailAdapter) { throw 'User email verification was enabled, but no email adapter was provided'; } - if (typeof emailAdapter.sendVerificationEmail !== 'function') { - throw 'Invalid email adapter: no sendVerificationEmail() function was provided'; - } } } @@ -164,11 +161,10 @@ function ParseServer({ appName: appName, }); - if (verifyUserEmails && process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - emailAdapter = loadAdapter(emailAdapter); - validateEmailConfiguration(verifyUserEmails, appName, emailAdapter); + if (verifyUserEmails && (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1)) { + let mailController = new MailController(loadAdapter(emailAdapter)); + cache.apps[appId].mailController = mailController; cache.apps[appId].verifyUserEmails = verifyUserEmails; - cache.apps[appId].emailAdapter = emailAdapter; } // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability From 7dd765256c0d479d9a0ca9dda726726d9a1572b1 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Thu, 25 Feb 2016 19:04:27 -0500 Subject: [PATCH 027/102] Refactors verify_email, adds public html --- public_html/choose_password.html | 175 ++++++++++++++++++++++++ public_html/invalid_link.html | 43 ++++++ public_html/password_reset_success.html | 27 ++++ public_html/verify_email_success.html | 27 ++++ spec/ParseUser.spec.js | 14 +- src/Config.js | 25 +++- src/Controllers/AdaptableController.js | 8 +- src/Controllers/MailController.js | 3 +- src/Controllers/UserController.js | 32 +++++ src/PromiseRouter.js | 37 ++++- src/Routers/PublicAPIRouter.js | 48 +++++++ src/Routers/UsersRouter.js | 8 +- src/index.js | 44 +++--- src/verifyEmail.js | 27 ---- 14 files changed, 455 insertions(+), 63 deletions(-) create mode 100644 public_html/choose_password.html create mode 100644 public_html/invalid_link.html create mode 100644 public_html/password_reset_success.html create mode 100644 public_html/verify_email_success.html create mode 100644 src/Controllers/UserController.js create mode 100644 src/Routers/PublicAPIRouter.js delete mode 100644 src/verifyEmail.js diff --git a/public_html/choose_password.html b/public_html/choose_password.html new file mode 100644 index 00000000..b487862a --- /dev/null +++ b/public_html/choose_password.html @@ -0,0 +1,175 @@ + + + + + Password Reset + + + +

Reset Your Password

+ +
+
+ + + + + + +
+ + + diff --git a/public_html/invalid_link.html b/public_html/invalid_link.html new file mode 100644 index 00000000..66bdc788 --- /dev/null +++ b/public_html/invalid_link.html @@ -0,0 +1,43 @@ + + + + + Invalid Link + + +
+

Invalid Link

+
+ + diff --git a/public_html/password_reset_success.html b/public_html/password_reset_success.html new file mode 100644 index 00000000..774cbb35 --- /dev/null +++ b/public_html/password_reset_success.html @@ -0,0 +1,27 @@ + + + + + Password Reset + + +

Successfully updated your password!

+ + diff --git a/public_html/verify_email_success.html b/public_html/verify_email_success.html new file mode 100644 index 00000000..774ea38a --- /dev/null +++ b/public_html/verify_email_success.html @@ -0,0 +1,27 @@ + + + + + Email Verification + + +

Successfully verified your email!

+ + diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 64477074..475622cf 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -171,7 +171,7 @@ describe('Parse.User testing', () => { followRedirect: false, }, (error, response, body) => { expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/verify_email_success.html?username=zxcv'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=zxcv'); user.fetch() .then(() => { expect(user.get('emailVerified')).toEqual(true); @@ -202,21 +202,21 @@ describe('Parse.User testing', () => { }); it('redirects you to invalid link if you try to verify email incorrecly', done => { - request.get('http://localhost:8378/1/verify_email', { + request.get('http://localhost:8378/1/apps/test/verify_email', { followRedirect: false, }, (error, response, body) => { expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); done() }); }); it('redirects you to invalid link if you try to validate a nonexistant users email', done => { - request.get('http://localhost:8378/1/verify_email?token=asdfasdf&username=sadfasga', { + request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { followRedirect: false, }, (error, response, body) => { expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); done(); }); }); @@ -225,11 +225,11 @@ describe('Parse.User testing', () => { var user = new Parse.User(); var emailAdapter = { sendVerificationEmail: options => { - request.get('http://localhost:8378/1/verify_email?token=invalid&username=zxcv', { + request.get('http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', { followRedirect: false, }, (error, response, body) => { expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); user.fetch() .then(() => { expect(user.get('emailVerified')).toEqual(false); diff --git a/src/Config.js b/src/Config.js index 1203b0a3..c31f62eb 100644 --- a/src/Config.js +++ b/src/Config.js @@ -25,6 +25,8 @@ export class Config { this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); this.mailController = cacheInfo.mailController; + + this.serverURL = cacheInfo.serverURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; @@ -32,11 +34,32 @@ export class Config { this.filesController = cacheInfo.filesController; this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; + this.mailController = cacheInfo.mailController; this.oauth = cacheInfo.oauth; this.mount = mount; } -} + + get invalidLinkURL() { + return `${this.serverURL}/apps/invalid_link.html`; + } + + get verifyEmailSuccessURL() { + return `${this.serverURL}/apps/verify_email_success.html`; + } + + get choosePasswordURL() { + return `${this.serverURL}/apps/choose_password`; + } + + get passwordResetSuccessURL() { + return `${this.serverURL}/apps/password_reset_success.html`; + } + + get verifyEmailURL() { + return `${this.serverURL}/apps/${this.applicationId}/verify_email`; + } +}; export default Config; module.exports = Config; diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index 83f3f0a0..cfb0b9af 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,11 +10,13 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); +import cache from '../cache'; export class AdaptableController { - constructor(adapter) { + constructor(adapter, appId) { this.adapter = adapter; + this.appId = appId; } set adapter(adapter) { @@ -26,6 +28,10 @@ export class AdaptableController { return this[_adapter]; } + get config() { + return cache.apps[this.appId]; + } + expectedAdapterType() { throw new Error("Subclasses should implement expectedAdapterType()"); } diff --git a/src/Controllers/MailController.js b/src/Controllers/MailController.js index ee467fe6..47d008cc 100644 --- a/src/Controllers/MailController.js +++ b/src/Controllers/MailController.js @@ -13,7 +13,8 @@ export class MailController extends AdaptableController { sendVerificationEmail(user, config) { const token = encodeURIComponent(user._email_verify_token); const username = encodeURIComponent(user.username); - let link = `${config.mount}/verify_email?token=${token}&username=${username}`; + + let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; this.adapter.sendVerificationEmail({ appName: config.appName, link: link, diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js new file mode 100644 index 00000000..62d6dd39 --- /dev/null +++ b/src/Controllers/UserController.js @@ -0,0 +1,32 @@ + +var DatabaseAdapter = require('../DatabaseAdapter'); + +export class UserController { + + constructor(appId) { + this.appId = appId; + } + + verifyEmail(username, token) { + var database = DatabaseAdapter.getDatabaseConnection(this.appId); + return new Promise((resolve, reject) => { + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + username: username, + _email_verify_token: token, + }, null, {$set: {emailVerified: true}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); + }); + + }); + + } +} + +export default UserController; diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index 8155c796..c3ca10ec 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -5,6 +5,8 @@ // themselves use our routing information, without disturbing express // components that external developers may be modifying. +import express from 'express'; + export default class PromiseRouter { // Each entry should be an object with: // path: the path to route, in express format @@ -15,8 +17,8 @@ export default class PromiseRouter { // status: optional. the http status code. defaults to 200 // response: a json object with the content of the response // location: optional. a location header - constructor() { - this.routes = []; + constructor(routes = []) { + this.routes = routes; this.mountRoutes(); } @@ -125,6 +127,29 @@ export default class PromiseRouter { } } }; + + expressApp() { + var expressApp = express(); + for (var route of this.routes) { + switch(route.method) { + case 'POST': + expressApp.post(route.path, makeExpressHandler(route.handler)); + break; + case 'GET': + expressApp.get(route.path, makeExpressHandler(route.handler)); + break; + case 'PUT': + expressApp.put(route.path, makeExpressHandler(route.handler)); + break; + case 'DELETE': + expressApp.delete(route.path, makeExpressHandler(route.handler)); + break; + default: + throw 'unexpected code branch'; + } + } + return expressApp; + } } // Global flag. Set this to true to log every request and response. @@ -142,15 +167,19 @@ function makeExpressHandler(promiseHandler) { JSON.stringify(req.body, null, 2)); } promiseHandler(req).then((result) => { - if (!result.response) { - console.log('BUG: the handler did not include a "response" field'); + if (!result.response && !result.location) { + console.log('BUG: the handler did not include a "response" or a "location" field'); throw 'control should not get here'; } if (PromiseRouter.verbose) { console.log('response:', JSON.stringify(result.response, null, 2)); } + var status = result.status || 200; res.status(status); + if (result.location && !result.response) { + return res.redirect(result.location); + } if (result.location) { res.set('Location', result.location); } diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js new file mode 100644 index 00000000..2b75d3f5 --- /dev/null +++ b/src/Routers/PublicAPIRouter.js @@ -0,0 +1,48 @@ +import PromiseRouter from '../PromiseRouter'; +import UserController from '../Controllers/UserController'; +import Config from '../Config'; +import express from 'express'; +import path from 'path'; + +export class PublicAPIRouter extends PromiseRouter { + + verifyEmail(req) { + var token = req.query.token; + var username = req.query.username; + var appId = req.params.appId; + var config = new Config(appId); + + if (!token || !username) { + return Promise.resolve({ + status: 302, + location: config.invalidLinkURL + }); + } + + let userController = new UserController(appId); + return userController.verifyEmail(username, token, appId).then( () => { + return Promise.resolve({ + status: 302, + location: `${config.verifyEmailSuccessURL}?username=${username}` + }); + }, ()=> { + return Promise.resolve({ + status: 302, + location: config.invalidLinkURL + }); + }) + } + + mountRoutes() { + this.route('GET','/apps/:appId/verify_email', req => { return this.verifyEmail(req); }); + } + + expressApp() { + var router = express(); + router.use("/apps", express.static(path.resolve(__dirname, "../../public"))); + router.use(super.expressApp()); + return router; + } +} + +export default PublicAPIRouter; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 1e329734..70a76bf5 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -154,6 +154,11 @@ export class UsersRouter extends ClassesRouter { } return Promise.resolve(success); } + + handleReset(req) { + let userController = req.config.userController; + return userController.requestPasswordReset(); + } mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -164,9 +169,6 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', () => { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); - }); this.route('POST', '/requestPasswordReset', req => this.handleReset(req)); } } diff --git a/src/index.js b/src/index.js index 74a63bf9..219ec156 100644 --- a/src/index.js +++ b/src/index.js @@ -15,7 +15,6 @@ import cache from './cache'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; //import passwordReset from './passwordReset'; import PromiseRouter from './PromiseRouter'; -import verifyEmail from './verifyEmail'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; @@ -27,8 +26,10 @@ import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { LogsRouter } from './Routers/LogsRouter'; import { HooksRouter } from './Routers/HooksRouter'; +import { PublicAPIRouter } from './Routers/PublicAPIRouter'; import { HooksController } from './Controllers/HooksController'; +import { UserController } from './Controllers/UserController'; import { InstallationsRouter } from './Routers/InstallationsRouter'; import { loadAdapter } from './Adapters/AdapterLoader'; import { LoggerController } from './Controllers/LoggerController'; @@ -134,16 +135,23 @@ function ParseServer({ const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter); const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); - + const emailControllerAdapter = loadAdapter(emailAdapter); // We pass the options and the base class for the adatper, // Note that passing an instance would work too - const filesController = new FilesController(filesControllerAdapter); - const pushController = new PushController(pushControllerAdapter); - const loggerController = new LoggerController(loggerControllerAdapter); + const filesController = new FilesController(filesControllerAdapter, appId); + const pushController = new PushController(pushControllerAdapter, appId); + const loggerController = new LoggerController(loggerControllerAdapter, appId); const hooksController = new HooksController(appId, collectionPrefix); + const userController = new UserController(appId); + let mailController; + + if (verifyUserEmails) { + mailController = new MailController(loadAdapter(emailAdapter)); + } cache.apps.set(appId, { masterKey: masterKey, + serverURL: serverURL, collectionPrefix: collectionPrefix, clientKey: clientKey, javascriptKey: javascriptKey, @@ -155,18 +163,14 @@ function ParseServer({ pushController: pushController, loggerController: loggerController, hooksController: hooksController, + mailController: mailController, + verifyUserEmails: verifyUserEmails, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, oauth: oauth, appName: appName, }); - if (verifyUserEmails && (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1)) { - let mailController = new MailController(loadAdapter(emailAdapter)); - cache.apps[appId].mailController = mailController; - cache.apps[appId].verifyUserEmails = verifyUserEmails; - } - // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); @@ -175,18 +179,17 @@ function ParseServer({ // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); - + //api.use("/apps", express.static(__dirname + "/public")); // File handling needs to be before default middlewares are applied api.use('/', new FilesRouter().getExpressRouter({ maxUploadSize: maxUploadSize })); if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - //api.use('/request_password_reset', passwordReset.reset(appName, appId)); - //api.get('/password_reset_success', passwordReset.success); - api.get('/verify_email', verifyEmail(appId, serverURL)); + api.use('/', new PublicAPIRouter().expressApp()); } + // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { api.use('/', require('./testing-routes').router); @@ -218,13 +221,16 @@ function ParseServer({ if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { routers.push(new HooksRouter()); } + + let routes = routers.reduce((memo, router) => { + return memo.concat(router.routes); + }, []); - let appRouter = new PromiseRouter(); - routers.forEach((router) => { - appRouter.merge(router); - }); + let appRouter = new PromiseRouter(routes); + batch.mountOnto(appRouter); + api.use(appRouter.expressApp()); appRouter.mountOnto(api); api.use(middlewares.handleParseErrors); diff --git a/src/verifyEmail.js b/src/verifyEmail.js deleted file mode 100644 index 5bd1da32..00000000 --- a/src/verifyEmail.js +++ /dev/null @@ -1,27 +0,0 @@ -function verifyEmail(appId, serverURL) { - var DatabaseAdapter = require('./DatabaseAdapter'); - var database = DatabaseAdapter.getDatabaseConnection(appId); - return (req, res) => { - var token = req.query.token; - var username = req.query.username; - if (!token || !username) { - res.redirect(302, serverURL + '/invalid_link.html'); - return; - } - database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field - coll.findAndModify({ - username: username, - _email_verify_token: token, - }, null, {$set: {emailVerified: true}}, (err, doc) => { - if (err || !doc.value) { - res.redirect(302, serverURL + '/invalid_link.html'); - } else { - res.redirect(302, serverURL + '/verify_email_success.html?username=' + username); - } - }); - }); - } -} - -module.exports = verifyEmail; From f3bb2c99e0250c0ae2e1dce457b581c7f2f6ff31 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 10:51:12 -0500 Subject: [PATCH 028/102] Refactor and advancements - Drops mailController, centralized in UserController - Adds views folder for change_password - Improves PromiseRouter to support text results - Improves PromiseRouter to support empty responses for redirects - Adds options to AdaptableController - UsersController gracefully fails when no adapter is set - Refactors GlobalConfig into same style for Routers --- spec/MockEmailAdapter.js | 4 +- spec/MockEmailAdapterWithOptions.js | 1 + spec/ParseGlobalConfig.spec.js | 10 +- spec/ParseUser.spec.js | 14 ++ spec/PublicAPI.spec.js | 36 +++++ src/Adapters/Email/MailAdapter.js | 1 + src/Adapters/Email/SimpleMailgunAdapter.js | 13 ++ src/Config.js | 10 +- src/Controllers/AdaptableController.js | 7 +- src/Controllers/MailController.js | 30 ---- src/Controllers/UserController.js | 143 ++++++++++++++++-- src/PromiseRouter.js | 9 +- src/Routers/GlobalConfigRouter.js | 48 ++++++ src/Routers/PublicAPIRouter.js | 68 +++++++-- src/Routers/UsersRouter.js | 29 ++-- src/global_config.js | 46 ------ src/index.js | 15 +- .../choose_password | 3 +- 18 files changed, 349 insertions(+), 138 deletions(-) create mode 100644 spec/PublicAPI.spec.js delete mode 100644 src/Controllers/MailController.js create mode 100644 src/Routers/GlobalConfigRouter.js delete mode 100644 src/global_config.js rename public_html/choose_password.html => views/choose_password (97%) diff --git a/spec/MockEmailAdapter.js b/spec/MockEmailAdapter.js index e06e27cb..b143e37e 100644 --- a/spec/MockEmailAdapter.js +++ b/spec/MockEmailAdapter.js @@ -1,3 +1,5 @@ module.exports = { - sendVerificationEmail: () => Promise.resolve(); + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js index d5b6141a..8a3095e2 100644 --- a/spec/MockEmailAdapterWithOptions.js +++ b/spec/MockEmailAdapterWithOptions.js @@ -4,6 +4,7 @@ module.exports = options => { } return { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } } diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 8c29ee48..8b739a78 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -2,13 +2,12 @@ var request = require('request'); var Parse = require('parse/node').Parse; -var DatabaseAdapter = require('../src/DatabaseAdapter'); - -let database = DatabaseAdapter.getDatabaseConnection('test', 'test_'); +let Config = require('../src/Config'); describe('a GlobalConfig', () => { beforeEach(function(done) { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.updateOne({ '_id': 1}, { $set: { params: { companies: ['US', 'DK'] } } }, { upsert: true })) .then(done()); }); @@ -61,7 +60,8 @@ describe('a GlobalConfig', () => { }); it('failed getting config when it is missing', (done) => { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.deleteOne({ '_id': 1}, {}, {})) .then(_ => { request.get({ diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 475622cf..8698fa36 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -52,6 +52,7 @@ describe('Parse.User testing', () => { it('sends verification email if email verification is enabled', done => { var emailAdapter = { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } setServerConfiguration({ @@ -91,6 +92,7 @@ describe('Parse.User testing', () => { it('does not send verification email if email verification is disabled', done => { var emailAdapter = { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } setServerConfiguration({ @@ -134,6 +136,7 @@ describe('Parse.User testing', () => { expect(options.user.get('email')).toEqual('user@parse.com'); done(); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -176,9 +179,14 @@ describe('Parse.User testing', () => { .then(() => { expect(user.get('emailVerified')).toEqual(true); done(); + }, (err) => { + console.error(err); + fail("this should not fail"); + done(); }); }); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -237,6 +245,7 @@ describe('Parse.User testing', () => { }); }); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -270,6 +279,11 @@ describe('Parse.User testing', () => { success: function(user) { Parse.User.logIn("non_existent_user", "asdf3", expectError(Parse.Error.OBJECT_NOT_FOUND, done)); + }, + error: function(err) { + console.error(err); + fail("Shit should not fail"); + done(); } }); }); diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js new file mode 100644 index 00000000..a61537d0 --- /dev/null +++ b/spec/PublicAPI.spec.js @@ -0,0 +1,36 @@ + +var request = require('request'); + + +describe("public API", () => { + + it("should get invalid_link.html", (done) => { + request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get verify_email_success.html", (done) => { + request('http://localhost:8378/1/apps/verify_email_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get password_reset_success.html", (done) => { + request('http://localhost:8378/1/apps/password_reset_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + +}) \ No newline at end of file diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js index ceccf931..ab8f1571 100644 --- a/src/Adapters/Email/MailAdapter.js +++ b/src/Adapters/Email/MailAdapter.js @@ -1,5 +1,6 @@ export class MailAdapter { sendVerificationEmail(options) {} + sendPasswordResetEmail(options) {} sendMail(options) {} } diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index f2460182..6720962f 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -37,6 +37,19 @@ let SimpleMailgunAdapter = mailgunOptions => { text: verifyMessage }); }, + + sendPasswordResetEmail: ({link,user, appName}) => { + let message = + "Hi,\n\n" + + "You requested to reset your password for " + appName + ".\n\n" + + "" + + "Click here to reset it:\n" + link; + return sendMail({ + to:user.email, + subject: 'Password Reset for ' + appName, + text: message + }); + }, sendMail: sendMail }); } diff --git a/src/Config.js b/src/Config.js index c31f62eb..c3d7317d 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,8 +24,6 @@ export class Config { this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); - this.mailController = cacheInfo.mailController; - this.serverURL = cacheInfo.serverURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; @@ -34,7 +32,7 @@ export class Config { this.filesController = cacheInfo.filesController; this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; - this.mailController = cacheInfo.mailController; + this.userController = cacheInfo.userController; this.oauth = cacheInfo.oauth; this.mount = mount; @@ -49,7 +47,11 @@ export class Config { } get choosePasswordURL() { - return `${this.serverURL}/apps/choose_password`; + return `${this.serverURL}/apps/${this.applicationId}/choose_password`; + } + + get requestResetPasswordURL() { + return `${this.serverURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index cfb0b9af..bfe0705c 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,11 +10,12 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); -import cache from '../cache'; +import Config from '../Config'; export class AdaptableController { - constructor(adapter, appId) { + constructor(adapter, appId, options) { + this.options = options; this.adapter = adapter; this.appId = appId; } @@ -29,7 +30,7 @@ export class AdaptableController { } get config() { - return cache.apps[this.appId]; + return new Config(this.appId); } expectedAdapterType() { diff --git a/src/Controllers/MailController.js b/src/Controllers/MailController.js deleted file mode 100644 index 47d008cc..00000000 --- a/src/Controllers/MailController.js +++ /dev/null @@ -1,30 +0,0 @@ -import AdaptableController from './AdaptableController'; -import { MailAdapter } from '../Adapters/Email/MailAdapter'; -import { randomString } from '../cryptoUtils'; -import { inflate } from '../triggers'; - -export class MailController extends AdaptableController { - setEmailVerificationStatus(user, status) { - if (status == false) { - user._email_verify_token = randomString(25); - } - user.emailVerified = status; - } - sendVerificationEmail(user, config) { - const token = encodeURIComponent(user._email_verify_token); - const username = encodeURIComponent(user.username); - - let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; - this.adapter.sendVerificationEmail({ - appName: config.appName, - link: link, - user: inflate('_User', user), - }); - } - sendMail(options) { - this.adapter.sendMail(options); - } - expectedAdapterType() { - return MailAdapter; - } -} diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 62d6dd39..e9e0551d 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -1,31 +1,142 @@ +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; +import AdaptableController from './AdaptableController'; +import MailAdapter from '../Adapters/Email/MailAdapter'; var DatabaseAdapter = require('../DatabaseAdapter'); -export class UserController { - - constructor(appId) { - this.appId = appId; +export class UserController extends AdaptableController { + + constructor(adapter, appId, options = {}) { + super(adapter, appId, options); + } + + validateAdapter(adapter) { + // Allow no adapter + if (!adapter && !this.shouldVerifyEmails) { + return; + } + super.validateAdapter(adapter); } + expectedAdapterType() { + return MailAdapter; + } + + get shouldVerifyEmails() { + return this.options.verifyUserEmails; + } + + setEmailVerifyToken(user) { + if (this.shouldVerifyEmails) { + user._email_verify_token = randomString(25); + user.emailVerified = false; + } + } + + verifyEmail(username, token) { - var database = DatabaseAdapter.getDatabaseConnection(this.appId); + + return new Promise((resolve, reject) => { + + // Trying to verify email when not enabled + if (!this.shouldVerifyEmails) { + reject(); + return; + } + + var database = this.config.database; + + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + username: username, + _email_verify_token: token, + }, null, {$set: {emailVerified: true}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); + }); + + }); + } + + checkResetTokenValidity(username, token) { + var database = this.config.database; return new Promise((resolve, reject) => { database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field - return coll.findAndModify({ - username: username, - _email_verify_token: token, - }, null, {$set: {emailVerified: true}}, (err, doc) => { - if (err || !doc.value) { - reject(); - } else { - resolve(); - } + // Need direct database access because verification token is not a parse field + return coll.findOne({ + username: username, + _email_reset_token: token, + }, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); }); }); - + } + + setPasswordResetToken(email) { + var database = this.config.database; + var token = randomString(25); + return new Promise((resolve, reject) => { + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + email: email, + }, null, {$set: {_email_reset_token: token}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + console.log(doc); + resolve(token); + } + }); + }); }); + } + + sendVerificationEmail(user, config = this.config) { + if (!this.shouldVerifyEmails) { + return; + } + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + + let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; + this.adapter.sendVerificationEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + } + + sendPasswordResetEmail(user, config = this.config) { + if (!this.adapter) { + return; + } + + const token = encodeURIComponent(user._email_reset_token); + const username = encodeURIComponent(user.username); + + let link = `${config.requestPasswordResetURL}?token=${token}&username=${username}` + this.adapter.sendPasswordResetEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + } + + sendMail(options) { + this.adapter.sendMail(options); } } diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index c3ca10ec..4070f706 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -167,16 +167,21 @@ function makeExpressHandler(promiseHandler) { JSON.stringify(req.body, null, 2)); } promiseHandler(req).then((result) => { - if (!result.response && !result.location) { + if (!result.response && !result.location && !result.text) { console.log('BUG: the handler did not include a "response" or a "location" field'); throw 'control should not get here'; } if (PromiseRouter.verbose) { - console.log('response:', JSON.stringify(result.response, null, 2)); + console.log('response:', JSON.stringify(result, null, 2)); } var status = result.status || 200; res.status(status); + + if (result.text) { + return res.send(result.text); + } + if (result.location && !result.response) { return res.redirect(result.location); } diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js new file mode 100644 index 00000000..1fbde2d5 --- /dev/null +++ b/src/Routers/GlobalConfigRouter.js @@ -0,0 +1,48 @@ +// global_config.js + +var Parse = require('parse/node').Parse; + +import PromiseRouter from '../PromiseRouter'; + +export class GlobalConfigRouter extends PromiseRouter { + 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', + } + })); + } + updateGlobalConfig(req) { + if (!req.auth.isMaster) { + return Promise.resolve({ + status: 401, + response: {error: 'unauthorized'}, + }); + } + + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) + .then(response => { + return { response: { result: true } } + }) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config cannot be updated', + } + })); + } + + mountRoutes() { + this.route('GET', '/config', req => { return this.getGlobalConfig(req) }); + this.route('PUT', '/config', req => { return this.updateGlobalConfig(req) }); + } +} + +export default GlobalConfigRouter; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 2b75d3f5..40c6180b 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -3,6 +3,10 @@ import UserController from '../Controllers/UserController'; import Config from '../Config'; import express from 'express'; import path from 'path'; +import fs from 'fs'; + +let public_html = path.resolve(__dirname, "../../public_html"); +let views = path.resolve(__dirname, '../../views'); export class PublicAPIRouter extends PromiseRouter { @@ -13,33 +17,75 @@ export class PublicAPIRouter extends PromiseRouter { var config = new Config(appId); if (!token || !username) { - return Promise.resolve({ - status: 302, - location: config.invalidLinkURL - }); + return this.invalidLink(req); } - let userController = new UserController(appId); + let userController = config.userController; return userController.verifyEmail(username, token, appId).then( () => { return Promise.resolve({ status: 302, location: `${config.verifyEmailSuccessURL}?username=${username}` }); }, ()=> { - return Promise.resolve({ - status: 302, - location: config.invalidLinkURL - }); + return this.invalidLink(req); }) } + changePassword(req) { + return new Promise((resolve, reject) => { + var config = new Config(req.params.appId); + // Should we keep the file in memory or leave like that? + fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { + if (err) { + return reject(err); + } + data = data.replace("PARSE_SERVER_URL", `'${config.serverURL}'`); + resolve({ + text: data + }) + }); + }); + } + + resetPassword(req) { + var { username, token } = req.params; + + if (!username || !token) { + return this.invalidLink(req); + } + + let config = req.config; + return config.userController.checkResetTokenValidity(username, token).then( () => { + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}` + }) + }, () => { + return this.invalidLink(req); + }) + } + + invalidLink(req) { + return Promise.resolve({ + status: 302, + location: req.config.invalidLinkURL + }); + } + + setConfig(req) { + req.config = new Config(req.params.appId); + return Promise.resolve(); + } + mountRoutes() { - this.route('GET','/apps/:appId/verify_email', req => { return this.verifyEmail(req); }); + this.route('GET','/apps/:appId/verify_email', this.setConfig, req => { return this.verifyEmail(req); }); + this.route('GET','/apps/choose_password', req => { return this.changePassword(req); }); + this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); } expressApp() { var router = express(); - router.use("/apps", express.static(path.resolve(__dirname, "../../public"))); + router.use("/apps", express.static(public_html)); router.use(super.expressApp()); return router; } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 70a76bf5..72d14b3a 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -27,16 +27,14 @@ export class UsersRouter extends ClassesRouter { req.body = data; req.params.className = '_User'; - if (req.config.verifyUserEmails) { - req.config.mailController.setEmailVerificationStatus(req.body, false); - } + req.config.userController.setEmailVerifyToken(req.body); let p = super.handleCreate(req); - - if (req.config.verifyUserEmails) { + + if (req.config.verifyUserEmails) { // Send email as fire-and-forget once the user makes it into the DB. p.then(() => { - req.config.mailController.sendVerificationEmail(req.body, req.config); + req.config.userController.sendVerificationEmail(req.body, req.config); }); } return p; @@ -155,10 +153,23 @@ export class UsersRouter extends ClassesRouter { return Promise.resolve(success); } - handleReset(req) { + handleResetRequest(req) { + + let { email } = req.body.email; + if (!email) { + throw "Missing email"; + } let userController = req.config.userController; - return userController.requestPasswordReset(); + + return userController.sendPasswordResetEmail(email).then((token) => { + return Promise.resolve({ + response: {} + }) + }, (err) => { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `no user found with email ${email}`); + }); } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -169,7 +180,7 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', req => this.handleReset(req)); + this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }) } } diff --git a/src/global_config.js b/src/global_config.js deleted file mode 100644 index 0c005e4d..00000000 --- a/src/global_config.js +++ /dev/null @@ -1,46 +0,0 @@ -// global_config.js - -var Parse = require('parse/node').Parse; - -import PromiseRouter from './PromiseRouter'; -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({ - status: 401, - response: {error: 'unauthorized'}, - }); - } - - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) - .then(response => { - return { response: { result: true } } - }) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config cannot be updated', - } - })); -} - -router.route('GET', '/config', getGlobalConfig); -router.route('PUT', '/config', updateGlobalConfig); - -module.exports = router; diff --git a/src/index.js b/src/index.js index 219ec156..f4146312 100644 --- a/src/index.js +++ b/src/index.js @@ -19,7 +19,6 @@ import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { FilesController } from './Controllers/FilesController'; -import { MailController } from './Controllers/MailController'; import { FilesRouter } from './Routers/FilesRouter'; import { FunctionsRouter } from './Routers/FunctionsRouter'; import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; @@ -27,6 +26,7 @@ import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { LogsRouter } from './Routers/LogsRouter'; import { HooksRouter } from './Routers/HooksRouter'; import { PublicAPIRouter } from './Routers/PublicAPIRouter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; import { HooksController } from './Controllers/HooksController'; import { UserController } from './Controllers/UserController'; @@ -142,12 +142,8 @@ function ParseServer({ const pushController = new PushController(pushControllerAdapter, appId); const loggerController = new LoggerController(loggerControllerAdapter, appId); const hooksController = new HooksController(appId, collectionPrefix); - const userController = new UserController(appId); - let mailController; - - if (verifyUserEmails) { - mailController = new MailController(loadAdapter(emailAdapter)); - } + const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); + cache.apps.set(appId, { masterKey: masterKey, @@ -163,7 +159,7 @@ function ParseServer({ pushController: pushController, loggerController: loggerController, hooksController: hooksController, - mailController: mailController, + userController: userController, verifyUserEmails: verifyUserEmails, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, @@ -215,7 +211,7 @@ function ParseServer({ ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { - routers.push(require('./global_config')); + routers.push(new GlobalConfigRouter()); } if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { @@ -231,7 +227,6 @@ function ParseServer({ batch.mountOnto(appRouter); api.use(appRouter.expressApp()); - appRouter.mountOnto(api); api.use(middlewares.handleParseErrors); diff --git a/public_html/choose_password.html b/views/choose_password similarity index 97% rename from public_html/choose_password.html rename to views/choose_password index b487862a..097cbd20 100644 --- a/public_html/choose_password.html +++ b/views/choose_password @@ -158,7 +158,8 @@ })(); var id = urlParams['id']; - document.getElementById('form').setAttribute('action', '/apps/' + id + '/request_password_reset'); + var base = PARSE_SERVER_URL; + document.getElementById('form').setAttribute('action', base + '/apps/' + id + '/request_password_reset'); document.getElementById('username').value = urlParams['username']; document.getElementById('username_label').appendChild(document.createTextNode(urlParams['username'])); From 91d97241828a46d772a3a434a6728023994a9bd9 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 14:46:29 -0500 Subject: [PATCH 029/102] Adds reset password logic --- spec/ParseUser.spec.js | 225 -------------- spec/PublicAPI.spec.js | 2 +- spec/ValidationAndPasswordsReset.spec.js | 374 +++++++++++++++++++++++ src/Config.js | 2 +- src/Controllers/UserController.js | 103 ++++--- src/RestWrite.js | 1 + src/Routers/PublicAPIRouter.js | 51 +++- src/Routers/UsersRouter.js | 9 +- src/index.js | 3 +- src/transform.js | 3 + 10 files changed, 493 insertions(+), 280 deletions(-) create mode 100644 spec/ValidationAndPasswordsReset.spec.js diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 8698fa36..58c9e8f3 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -49,231 +49,6 @@ describe('Parse.User testing', () => { }); }); - it('sends verification email if email verification is enabled', done => { - var emailAdapter = { - sendVerificationEmail: () => Promise.resolve(), - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } - setServerConfiguration({ - serverURL: 'http://localhost:8378/1', - appId: 'test', - appName: 'unused', - javascriptKey: 'test', - dotNetKey: 'windows', - clientKey: 'client', - restAPIKey: 'rest', - masterKey: 'test', - collectionPrefix: 'test_', - fileKey: 'test', - verifyUserEmails: true, - emailAdapter: emailAdapter, - }); - spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - done(); - }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); - }); - - it('does not send verification email if email verification is disabled', done => { - var emailAdapter = { - sendVerificationEmail: () => Promise.resolve(), - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => Promise.resolve() - } - setServerConfiguration({ - serverURL: 'http://localhost:8378/1', - appId: 'test', - appName: 'unused', - javascriptKey: 'test', - dotNetKey: 'windows', - clientKey: 'client', - restAPIKey: 'rest', - masterKey: 'test', - collectionPrefix: 'test_', - fileKey: 'test', - verifyUserEmails: false, - emailAdapter: emailAdapter, - }); - spyOn(emailAdapter, 'sendVerificationEmail'); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.signUp(null, { - success: function(user) { - user.fetch() - .then(() => { - expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); - expect(user.get('emailVerified')).toEqual(undefined); - done(); - }); - }, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); - }); - - it('receives the app name and user in the adapter', done => { - var emailAdapter = { - sendVerificationEmail: options => { - expect(options.appName).toEqual('emailing app'); - expect(options.user.get('email')).toEqual('user@parse.com'); - done(); - }, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - setServerConfiguration({ - serverURL: 'http://localhost:8378/1', - appId: 'test', - appName: 'emailing app', - javascriptKey: 'test', - dotNetKey: 'windows', - clientKey: 'client', - restAPIKey: 'rest', - masterKey: 'test', - collectionPrefix: 'test_', - fileKey: 'test', - verifyUserEmails: true, - emailAdapter: emailAdapter, - }); - var user = new Parse.User(); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set('email', 'user@parse.com'); - user.signUp(null, { - success: () => {}, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); - }) - - it('when you click the link in the email it sets emailVerified to true and redirects you', done => { - var user = new Parse.User(); - var emailAdapter = { - sendVerificationEmail: options => { - request.get(options.link, { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=zxcv'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - done(); - }, (err) => { - console.error(err); - fail("this should not fail"); - done(); - }); - }); - }, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - setServerConfiguration({ - serverURL: 'http://localhost:8378/1', - appId: 'test', - appName: 'emailing app', - javascriptKey: 'test', - dotNetKey: 'windows', - clientKey: 'client', - restAPIKey: 'rest', - masterKey: 'test', - collectionPrefix: 'test_', - fileKey: 'test', - verifyUserEmails: true, - emailAdapter: emailAdapter, - }); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set('email', 'user@parse.com'); - user.signUp(); - }); - - it('redirects you to invalid link if you try to verify email incorrecly', done => { - request.get('http://localhost:8378/1/apps/test/verify_email', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done() - }); - }); - - it('redirects you to invalid link if you try to validate a nonexistant users email', done => { - request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - done(); - }); - }); - - it('does not update email verified if you use an invalid token', done => { - var user = new Parse.User(); - var emailAdapter = { - sendVerificationEmail: options => { - request.get('http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - done(); - }); - }); - }, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - setServerConfiguration({ - serverURL: 'http://localhost:8378/1', - appId: 'test', - appName: 'emailing app', - javascriptKey: 'test', - dotNetKey: 'windows', - clientKey: 'client', - restAPIKey: 'rest', - masterKey: 'test', - collectionPrefix: 'test_', - fileKey: 'test', - verifyUserEmails: true, - emailAdapter: emailAdapter, - }); - user.setPassword("asdf"); - user.setUsername("zxcv"); - user.set('email', 'user@parse.com'); - user.signUp(null, { - success: () => {}, - error: function(userAgain, error) { - fail('Failed to save user'); - done(); - } - }); - }); - it("user login wrong username", (done) => { Parse.User.signUp("asdf", "zxcv", null, { success: function(user) { diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js index a61537d0..9979c04d 100644 --- a/spec/PublicAPI.spec.js +++ b/spec/PublicAPI.spec.js @@ -12,7 +12,7 @@ describe("public API", () => { }); it("should get choose_password", (done) => { - request('http://localhost:8378/1/apps/choose_password', (err, httpResponse, body) => { + request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { expect(httpResponse.statusCode).toBe(200); done(); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js new file mode 100644 index 00000000..e5e07b34 --- /dev/null +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -0,0 +1,374 @@ +"use strict"; + +var request = require('request'); + +describe("Email Verification", () => { + it('sends verification email if email verification is enabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does not send verification email if email verification is disabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: false, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + user.fetch() + .then(() => { + expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('receives the app name and user in the adapter', done => { + var emailAdapter = { + sendVerificationEmail: options => { + expect(options.appName).toEqual('emailing app'); + expect(options.user.get('email')).toEqual('user@parse.com'); + done(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }) + + it('when you click the link in the email it sets emailVerified to true and redirects you', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=zxcv'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(true); + done(); + }, (err) => { + console.error(err); + fail("this should not fail"); + done(); + }); + }); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(); + }); + + it('redirects you to invalid link if you try to verify email incorrecly', done => { + request.get('http://localhost:8378/1/apps/test/verify_email', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + done() + }); + }); + + it('redirects you to invalid link if you try to validate a nonexistant users email', done => { + request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + done(); + }); + }); + + it('does not update email verified if you use an invalid token', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get('http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); +}); + +describe("Password Reset", () => { + + it('should send a password reset link', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to get the reset link"); + return; + } + expect(response.statusCode).toEqual(302); + var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=zxcv/; + expect(response.body.match(re)).not.toBe(null); + done(); + }); + }, + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: (err) => { + console.error(err); + fail("Should not fail"); + done(); + } + }); + }); + }); + + it('redirects you to invalid link if you try to request password for a nonexistant users email', done => { + request.get('http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + done(); + }); + }); + + it('should programatically reset password', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to get the reset link"); + return; + } + expect(response.statusCode).toEqual(302); + var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; + var match = response.body.match(re); + if (!match) { + fail("should have a token"); + done(); + return; + } + var token = match[1]; + + request.post({ + url: "http://localhost:8378/1/apps/test/request_password_reset" , + body: `new_password=hello&token=${token}&username=zxcv`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to POST request password reset"); + return; + } + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'); + + Parse.User.logIn("zxcv", "hello").then(function(user){ + done(); + }, (err) => { + console.error(err); + fail("should login with new password"); + done(); + }); + + }); + }); + }, + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: (err) => { + console.error(err); + fail("Should not fail"); + done(); + } + }); + }); + }); + +}) + diff --git a/src/Config.js b/src/Config.js index c3d7317d..12059993 100644 --- a/src/Config.js +++ b/src/Config.js @@ -47,7 +47,7 @@ export class Config { } get choosePasswordURL() { - return `${this.serverURL}/apps/${this.applicationId}/choose_password`; + return `${this.serverURL}/apps/choose_password`; } get requestResetPasswordURL() { diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index e9e0551d..2abd7f49 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -4,6 +4,9 @@ import AdaptableController from './AdaptableController'; import MailAdapter from '../Adapters/Email/MailAdapter'; var DatabaseAdapter = require('../DatabaseAdapter'); +var RestWrite = require('../RestWrite'); +var hash = require('../password').hash; +var Auth = require('../Auth'); export class UserController extends AdaptableController { @@ -35,7 +38,7 @@ export class UserController extends AdaptableController { } - verifyEmail(username, token) { + verifyEmail(username, token, config = this.config) { return new Promise((resolve, reject) => { @@ -45,7 +48,7 @@ export class UserController extends AdaptableController { return; } - var database = this.config.database; + var database = config.database; database.collection('_User').then(coll => { // Need direct database access because verification token is not a parse field @@ -64,45 +67,24 @@ export class UserController extends AdaptableController { }); } - checkResetTokenValidity(username, token) { - var database = this.config.database; + checkResetTokenValidity(username, token, config = this.config) { return new Promise((resolve, reject) => { - database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field + return config.database.collection('_User').then(coll => { return coll.findOne({ username: username, - _email_reset_token: token, + _perishable_token: token, }, (err, doc) => { - if (err || !doc.value) { - reject(); + if (err || !doc) { + reject(err); } else { - resolve(); - } - }); - }); - }); - } - - setPasswordResetToken(email) { - var database = this.config.database; - var token = randomString(25); - return new Promise((resolve, reject) => { - database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field - return coll.findAndModify({ - email: email, - }, null, {$set: {_email_reset_token: token}}, (err, doc) => { - if (err || !doc.value) { - reject(); - } else { - console.log(doc); - resolve(token); + resolve(doc); } }); }); }); } + sendVerificationEmail(user, config = this.config) { if (!this.shouldVerifyEmails) { return; @@ -119,25 +101,68 @@ export class UserController extends AdaptableController { }); } - sendPasswordResetEmail(user, config = this.config) { + setPasswordResetToken(email, config = this.config) { + var database = config.database; + var token = randomString(25); + return new Promise((resolve, reject) => { + return database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + email: email, + }, null, {$set: {_perishable_token: token}}, (err, doc) => { + if (err || !doc.value) { + console.error(err); + reject(err); + } else { + doc.value._perishable_token = token; + resolve(doc.value); + } + }); + }); + }); + } + + sendPasswordResetEmail(email, config = this.config) { if (!this.adapter) { + throw "Trying to send a reset password but no adapter is set"; + // TODO: No adapter? return; } - const token = encodeURIComponent(user._email_reset_token); - const username = encodeURIComponent(user.username); - - let link = `${config.requestPasswordResetURL}?token=${token}&username=${username}` - this.adapter.sendPasswordResetEmail({ - appName: config.appName, - link: link, - user: inflate('_User', user), + return this.setPasswordResetToken(email).then((user) => { + + const token = encodeURIComponent(user._perishable_token); + const username = encodeURIComponent(user.username); + let link = `${config.requestResetPasswordURL}?token=${token}&username=${username}` + this.adapter.sendPasswordResetEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + return Promise.resolve(user); + }, (err) => { + return Promise.reject(err); }); } + + updatePassword(username, token, password, config = this.config) { + return this.checkResetTokenValidity(username, token, config).then(() => { + return updateUserPassword(username, token, password, config); + }); + } sendMail(options) { this.adapter.sendMail(options); } } +// Mark this private +function updateUserPassword(username, token, password, config) { + var write = new RestWrite(config, Auth.master(config), '_User', { + username: username, + _perishable_token: token + }, {password: password, _perishable_token: null }, undefined); + return write.execute(); + } + export default UserController; diff --git a/src/RestWrite.js b/src/RestWrite.js index 66ea69ff..31d8f125 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -832,4 +832,5 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; +export default RestWrite; module.exports = RestWrite; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 40c6180b..78565311 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -21,7 +21,7 @@ export class PublicAPIRouter extends PromiseRouter { } let userController = config.userController; - return userController.verifyEmail(username, token, appId).then( () => { + return userController.verifyEmail(username, token).then( () => { return Promise.resolve({ status: 302, location: `${config.verifyEmailSuccessURL}?username=${username}` @@ -33,7 +33,13 @@ export class PublicAPIRouter extends PromiseRouter { changePassword(req) { return new Promise((resolve, reject) => { - var config = new Config(req.params.appId); + var config = new Config(req.query.id); + if (!config.serverURL) { + return Promise.resolve({ + status: 404, + text: 'Not found.' + }); + } // Should we keep the file in memory or leave like that? fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { if (err) { @@ -47,23 +53,51 @@ export class PublicAPIRouter extends PromiseRouter { }); } - resetPassword(req) { - var { username, token } = req.params; + requestResetPassword(req) { + + var { username, token } = req.query; if (!username || !token) { return this.invalidLink(req); } let config = req.config; - return config.userController.checkResetTokenValidity(username, token).then( () => { + return config.userController.checkResetTokenValidity(username, token).then( (user) => { return Promise.resolve({ status: 302, - location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}` + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&app=${config.appName}` }) }, () => { return this.invalidLink(req); }) } + + resetPassword(req) { + var { + username, + token, + new_password + } = req.body; + + if (!username || !token || !new_password) { + return this.invalidLink(req); + } + + let config = req.config; + return config.userController.updatePassword(username, token, new_password).then((result) => { + return Promise.resolve({ + status: 302, + location: config.passwordResetSuccessURL + }); + }, (err) => { + console.error(err); + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&error=${err}&app=${config.appName}` + }); + }); + + } invalidLink(req) { return Promise.resolve({ @@ -80,13 +114,14 @@ export class PublicAPIRouter extends PromiseRouter { mountRoutes() { this.route('GET','/apps/:appId/verify_email', this.setConfig, req => { return this.verifyEmail(req); }); this.route('GET','/apps/choose_password', req => { return this.changePassword(req); }); - this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); + this.route('POST','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); + this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.requestResetPassword(req); }); } expressApp() { var router = express(); router.use("/apps", express.static(public_html)); - router.use(super.expressApp()); + router.use("/", super.expressApp()); return router; } } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 72d14b3a..2d63d701 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -34,7 +34,7 @@ export class UsersRouter extends ClassesRouter { if (req.config.verifyUserEmails) { // Send email as fire-and-forget once the user makes it into the DB. p.then(() => { - req.config.userController.sendVerificationEmail(req.body, req.config); + req.config.userController.sendVerificationEmail(req.body); }); } return p; @@ -154,17 +154,16 @@ export class UsersRouter extends ClassesRouter { } handleResetRequest(req) { - - let { email } = req.body.email; + let { email } = req.body; if (!email) { - throw "Missing email"; + throw new Parse.Error(Parse.Error.EMAIL_MISSING, "you must provide an email"); } let userController = req.config.userController; return userController.sendPasswordResetEmail(email).then((token) => { return Promise.resolve({ response: {} - }) + }); }, (err) => { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `no user found with email ${email}`); }); diff --git a/src/index.js b/src/index.js index f4146312..84ab3f55 100644 --- a/src/index.js +++ b/src/index.js @@ -182,7 +182,8 @@ function ParseServer({ })); if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - api.use('/', new PublicAPIRouter().expressApp()); + // need the body parser for the password reset + api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); } diff --git a/src/transform.js b/src/transform.js index 7ff570c0..8829f394 100644 --- a/src/transform.js +++ b/src/transform.js @@ -45,6 +45,9 @@ export function transformKeyValue(schema, className, restKey, restValue, options case '_email_verify_token': key = "_email_verify_token"; break; + case '_perishable_token': + key = "_perishable_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; From 3ecaa0aa4bb7b284590404d9717653693beed9ff Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 15:24:45 -0500 Subject: [PATCH 030/102] Sends verification email upon set and update email - nits --- spec/OneSignalPushAdapter.spec.js | 4 +- spec/ValidationAndPasswordsReset.spec.js | 121 ++++++++++++++++++++++ src/Adapters/Logger/FileLoggerAdapter.js | 6 +- src/Adapters/Push/OneSignalPushAdapter.js | 2 +- src/Config.js | 19 ++-- src/Controllers/UserController.js | 40 +++---- src/RestWrite.js | 17 ++- src/Routers/UsersRouter.js | 18 ++-- src/index.js | 23 ++-- 9 files changed, 195 insertions(+), 55 deletions(-) diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index f3ae2cdb..a9b853d9 100644 --- a/spec/OneSignalPushAdapter.spec.js +++ b/spec/OneSignalPushAdapter.spec.js @@ -20,11 +20,11 @@ describe('OneSignalPushAdapter', () => { done(); }); - it('cannt be initialized if options are missing', (done) => { + it('cannot be initialized if options are missing', (done) => { expect(() => { new OneSignalPushAdapter(); - }).toThrow("Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"); + }).toThrow("Trying to initialize OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"); done(); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index e5e07b34..0519b887 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -1,6 +1,40 @@ "use strict"; var request = require('request'); +var Config = require("../src/Config"); +describe("Custom Pages Configuration", () => { + it("should set the custom pages", (done) => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + customPages: { + invalidLink: "myInvalidLink", + verifyEmailSuccess: "myVerifyEmailSuccess", + choosePassword: "myChoosePassword", + passwordResetSuccess: "myPasswordResetSuccess" + }, + publicServerURL: "https://my.public.server.com/1" + }); + + var config = new Config("test"); + + expect(config.invalidLinkURL).toEqual("myInvalidLink"); + expect(config.verifyEmailSuccessURL).toEqual("myVerifyEmailSuccess"); + expect(config.choosePasswordURL).toEqual("myChoosePassword"); + expect(config.passwordResetSuccessURL).toEqual("myPasswordResetSuccess"); + expect(config.verifyEmailURL).toEqual("https://my.public.server.com/1/apps/test/verify_email"); + expect(config.requestResetPasswordURL).toEqual("https://my.public.server.com/1/apps/test/request_password_reset"); + done(); + }); +}); describe("Email Verification", () => { it('sends verification email if email verification is enabled', done => { @@ -27,6 +61,7 @@ describe("Email Verification", () => { var user = new Parse.User(); user.setPassword("asdf"); user.setUsername("zxcv"); + user.setEmail('cool_guy@parse.com'); user.signUp(null, { success: function(user) { expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); @@ -42,6 +77,92 @@ describe("Email Verification", () => { } }); }); + + it('does not send verification email when verification is enabled and email is not set', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does send a validation email when updating the email', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user.fetch() + .then((user) => { + user.set("email", "cool_guy@parse.com"); + return user.save(); + }).then((user) => { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + return user.fetch(); + }).then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); it('does not send verification email if email verification is disabled', done => { var emailAdapter = { diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js index 5c8bd495..3d3c192f 100644 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -99,12 +99,8 @@ let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => { } export class FileLoggerAdapter extends LoggerAdapter { - constructor(options) { + constructor(options = {}) { super(); - if (options && !options.logsFolder) { - throw "FileLoggerAdapter requires logsFolder"; - } - options = options || {}; this._logsFolder = options.logsFolder || LOGS_FOLDER; // check logs folder exists diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index ae5e8283..b92d00c5 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -20,7 +20,7 @@ export class OneSignalPushAdapter extends PushAdapter { this.OneSignalConfig = {}; const { oneSignalAppId, oneSignalApiKey } = pushConfig; if (!oneSignalAppId || !oneSignalApiKey) { - throw "Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"; + throw "Trying to initialize OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"; } this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; diff --git a/src/Config.js b/src/Config.js index 12059993..cfa53361 100644 --- a/src/Config.js +++ b/src/Config.js @@ -25,6 +25,7 @@ export class Config { this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); this.serverURL = cacheInfo.serverURL; + this.publicServerURL = cacheInfo.publicServerURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; @@ -34,32 +35,36 @@ export class Config { this.loggerController = cacheInfo.loggerController; this.userController = cacheInfo.userController; this.oauth = cacheInfo.oauth; - + this.customPages = cacheInfo.customPages || {}; this.mount = mount; } + get linksServerURL() { + return this.publicServerURL || this.serverURL; + } + get invalidLinkURL() { - return `${this.serverURL}/apps/invalid_link.html`; + return this.customPages.invalidLink || `${this.linksServerURL}/apps/invalid_link.html`; } get verifyEmailSuccessURL() { - return `${this.serverURL}/apps/verify_email_success.html`; + return this.customPages.verifyEmailSuccess || `${this.linksServerURL}/apps/verify_email_success.html`; } get choosePasswordURL() { - return `${this.serverURL}/apps/choose_password`; + return this.customPages.choosePassword || `${this.linksServerURL}/apps/choose_password`; } get requestResetPasswordURL() { - return `${this.serverURL}/apps/${this.applicationId}/request_password_reset`; + return `${this.linksServerURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { - return `${this.serverURL}/apps/password_reset_success.html`; + return this.customPages.passwordResetSuccess || `${this.linksServerURL}/apps/password_reset_success.html`; } get verifyEmailURL() { - return `${this.serverURL}/apps/${this.applicationId}/verify_email`; + return `${this.linksServerURL}/apps/${this.applicationId}/verify_email`; } }; diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 2abd7f49..786d118e 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -38,7 +38,7 @@ export class UserController extends AdaptableController { } - verifyEmail(username, token, config = this.config) { + verifyEmail(username, token) { return new Promise((resolve, reject) => { @@ -48,7 +48,7 @@ export class UserController extends AdaptableController { return; } - var database = config.database; + var database = this.config.database; database.collection('_User').then(coll => { // Need direct database access because verification token is not a parse field @@ -57,9 +57,9 @@ export class UserController extends AdaptableController { _email_verify_token: token, }, null, {$set: {emailVerified: true}}, (err, doc) => { if (err || !doc.value) { - reject(); + reject(err); } else { - resolve(); + resolve(doc.value); } }); }); @@ -67,9 +67,9 @@ export class UserController extends AdaptableController { }); } - checkResetTokenValidity(username, token, config = this.config) { + checkResetTokenValidity(username, token) { return new Promise((resolve, reject) => { - return config.database.collection('_User').then(coll => { + return this.config.database.collection('_User').then(coll => { return coll.findOne({ username: username, _perishable_token: token, @@ -85,7 +85,7 @@ export class UserController extends AdaptableController { } - sendVerificationEmail(user, config = this.config) { + sendVerificationEmail(user) { if (!this.shouldVerifyEmails) { return; } @@ -93,16 +93,16 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._email_verify_token); const username = encodeURIComponent(user.username); - let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; + let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; this.adapter.sendVerificationEmail({ - appName: config.appName, + appName: this.config.appName, link: link, user: inflate('_User', user), }); } - setPasswordResetToken(email, config = this.config) { - var database = config.database; + setPasswordResetToken(email) { + var database = this.config.database; var token = randomString(25); return new Promise((resolve, reject) => { return database.collection('_User').then(coll => { @@ -122,7 +122,7 @@ export class UserController extends AdaptableController { }); } - sendPasswordResetEmail(email, config = this.config) { + sendPasswordResetEmail(email) { if (!this.adapter) { throw "Trying to send a reset password but no adapter is set"; // TODO: No adapter? @@ -133,27 +133,21 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._perishable_token); const username = encodeURIComponent(user.username); - let link = `${config.requestResetPasswordURL}?token=${token}&username=${username}` + let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` this.adapter.sendPasswordResetEmail({ - appName: config.appName, + appName: this.config.appName, link: link, user: inflate('_User', user), }); return Promise.resolve(user); - }, (err) => { - return Promise.reject(err); }); } - updatePassword(username, token, password, config = this.config) { - return this.checkResetTokenValidity(username, token, config).then(() => { - return updateUserPassword(username, token, password, config); + updatePassword(username, token, password, config) { + return this.checkResetTokenValidity(username, token).then(() => { + return updateUserPassword(username, token, password, this.config); }); } - - sendMail(options) { - this.adapter.sendMail(options); - } } // Mark this private diff --git a/src/RestWrite.js b/src/RestWrite.js index 31d8f125..02815403 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -465,12 +465,18 @@ RestWrite.prototype.transformUser = function() { 'address'); } return Promise.resolve(); - }); + }).then(() => { + // We updated the email, send a new validation + this.storage['sendVerificationEmail'] = true; + this.config.userController.setEmailVerifyToken(this.data); + return Promise.resolve(); + }) }); }; // Handles any followup logic RestWrite.prototype.handleFollowup = function() { + if (this.storage && this.storage['clearSessions']) { var sessionQuery = { user: { @@ -480,9 +486,16 @@ RestWrite.prototype.handleFollowup = function() { } }; delete this.storage['clearSessions']; - return this.config.database.destroy('_Session', sessionQuery) + this.config.database.destroy('_Session', sessionQuery) .then(this.handleFollowup.bind(this)); } + + if (this.storage && this.storage['sendVerificationEmail']) { + delete this.storage['sendVerificationEmail']; + // Fire and forget! + this.config.userController.sendVerificationEmail(this.data); + this.handleFollowup.bind(this); + } }; // Handles the _Role class specialness. diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 2d63d701..21dc80ba 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -27,17 +27,17 @@ export class UsersRouter extends ClassesRouter { req.body = data; req.params.className = '_User'; - req.config.userController.setEmailVerifyToken(req.body); + //req.config.userController.setEmailVerifyToken(req.body); - let p = super.handleCreate(req); + return super.handleCreate(req); - if (req.config.verifyUserEmails) { - // Send email as fire-and-forget once the user makes it into the DB. - p.then(() => { - req.config.userController.sendVerificationEmail(req.body); - }); - } - return p; + // if (req.config.verifyUserEmails) { + // // Send email as fire-and-forget once the user makes it into the DB. + // p.then(() => { + // req.config.userController.sendVerificationEmail(req.body); + // }); + // } + // return p; } handleUpdate(req) { diff --git a/src/index.js b/src/index.js index 84ab3f55..1fa39aa7 100644 --- a/src/index.js +++ b/src/index.js @@ -107,6 +107,13 @@ function ParseServer({ maxUploadSize = '20mb', verifyUserEmails = false, emailAdapter, + publicServerURL, + customPages = { + invalidLink: undefined, + verifyEmailSuccess: undefined, + choosePassword: undefined, + passwordResetSuccess: undefined + }, }) { // Initialize the node client SDK automatically @@ -121,6 +128,12 @@ function ParseServer({ DatabaseAdapter.setAppDatabaseURI(appId, databaseURI); } + if (verifyUserEmails && !publicServerURL && !process.env.TESTING) { + console.warn(""); + console.warn("You should set publicServerURL to serve the public pages"); + console.warn(""); + } + if (cloud) { addParseCloud(); if (typeof cloud === 'function') { @@ -165,6 +178,8 @@ function ParseServer({ allowClientClassCreation: allowClientClassCreation, oauth: oauth, appName: appName, + publicServerURL: publicServerURL, + customPages: customPages, }); // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability @@ -181,12 +196,8 @@ function ParseServer({ maxUploadSize: maxUploadSize })); - if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - // need the body parser for the password reset - api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); - } - - + api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); + // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { api.use('/', require('./testing-routes').router); From 2183b0be82b5bb0e0ce82dc4f0d75fce33aed6ae Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 20:01:12 -0500 Subject: [PATCH 031/102] Allows very simple mail adapters - Fix nasty bug when updating users email and sending verification --- spec/ValidationAndPasswordsReset.spec.js | 59 ++++++++++++++- src/Adapters/Email/MailAdapter.js | 20 ++++- src/Adapters/Email/SimpleMailgunAdapter.js | 25 ------ src/Controllers/UserController.js | 88 ++++++++++++++++++---- 4 files changed, 150 insertions(+), 42 deletions(-) diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 0519b887..91f7ddce 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -150,10 +150,67 @@ describe("Email Verification", () => { user.set("email", "cool_guy@parse.com"); return user.save(); }).then((user) => { - expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); return user.fetch(); }).then(() => { expect(user.get('emailVerified')).toEqual(false); + // Wait as on update emai, we need to fetch the username + setTimeout(function(){ + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + done(); + }, 200); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does send with a simple adapter', done => { + var calls = 0; + var emailAdapter = { + sendMail: function(options){ + expect(options.to).toBe('cool_guy@parse.com'); + if (calls == 0) { + expect(options.subject).toEqual('Please verify your e-mail for My Cool App'); + expect(options.text.match(/verify_email/)).not.toBe(null); + } else if (calls == 1) { + expect(options.subject).toEqual('Password Reset for My Cool App'); + expect(options.text.match(/request_password_reset/)).not.toBe(null); + } + calls++; + return Promise.resolve(); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'My Cool App', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set("email", "cool_guy@parse.com"); + user.signUp(null, { + success: function(user) { + expect(calls).toBe(1); + user.fetch() + .then((user) => { + return user.save(); + }).then((user) => { + return Parse.User.requestPasswordReset("cool_guy@parse.com"); + }).then(() => { + expect(calls).toBe(2); done(); }); }, diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js index ab8f1571..82ea8b34 100644 --- a/src/Adapters/Email/MailAdapter.js +++ b/src/Adapters/Email/MailAdapter.js @@ -1,7 +1,23 @@ + +/* + Mail Adapter prototype + A MailAdapter should implement at least sendMail() + */ export class MailAdapter { - sendVerificationEmail(options) {} - sendPasswordResetEmail(options) {} + /* + * A method for sending mail + * @param options would have the parameters + * - to: the recipient + * - text: the raw text of the message + * - subject: the subject of the email + */ sendMail(options) {} + + /* You can implement those methods if you want + * to provide HTML templates etc... + */ + // sendVerificationEmail({ link, appName, user }) {} + // sendPasswordResetEmail({ link, appName, user }) {} } export default MailAdapter; diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index 6720962f..a90a43d7 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -25,31 +25,6 @@ let SimpleMailgunAdapter = mailgunOptions => { } return Object.freeze({ - sendVerificationEmail: ({ link, user, appName, }) => { - let verifyMessage = - "Hi,\n\n" + - "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + - "" + - "Click here to confirm it:\n" + link; - return sendMail({ - to:user.email, - subject: 'Please verify your e-mail for ' + appName, - text: verifyMessage - }); - }, - - sendPasswordResetEmail: ({link,user, appName}) => { - let message = - "Hi,\n\n" + - "You requested to reset your password for " + appName + ".\n\n" + - "" + - "Click here to reset it:\n" + link; - return sendMail({ - to:user.email, - subject: 'Password Reset for ' + appName, - text: message - }); - }, sendMail: sendMail }); } diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 786d118e..b707e124 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -5,6 +5,7 @@ import MailAdapter from '../Adapters/Email/MailAdapter'; var DatabaseAdapter = require('../DatabaseAdapter'); var RestWrite = require('../RestWrite'); +var RestQuery = require('../RestQuery'); var hash = require('../password').hash; var Auth = require('../Auth'); @@ -84,20 +85,47 @@ export class UserController extends AdaptableController { }); } + getUserIfNeeded(user) { + if (user.username && user.email) { + return Promise.resolve(user); + } + var where = {}; + if (user.username) { + where.username = user.username; + } + if (user.email) { + where.email = user.email; + } + + var query = new RestQuery(this.config, Auth.master(this.config), '_User', where); + return query.execute().then(function(result){ + if (result.results.length != 1) { + return Promise.reject(); + } + return result.results[0]; + }) + } + sendVerificationEmail(user) { if (!this.shouldVerifyEmails) { return; } - - const token = encodeURIComponent(user._email_verify_token); - const username = encodeURIComponent(user.username); - - let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; - this.adapter.sendVerificationEmail({ - appName: this.config.appName, - link: link, - user: inflate('_User', user), + // We may need to fetch the user in case of update email + this.getUserIfNeeded(user).then((user) => { + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; + let options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; + if (this.adapter.sendVerificationEmail) { + this.adapter.sendVerificationEmail(options); + } else { + this.adapter.sendMail(this.defaultVerificationEmail(options)); + } }); } @@ -134,11 +162,23 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._perishable_token); const username = encodeURIComponent(user.username); let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` - this.adapter.sendPasswordResetEmail({ - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }); + + if (!user.username) { + console.log('No username...'); + } + + let options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; + + if (this.adapter.sendPasswordResetEmail) { + this.adapter.sendPasswordResetEmail(options); + } else { + this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + } + return Promise.resolve(user); }); } @@ -148,6 +188,26 @@ export class UserController extends AdaptableController { return updateUserPassword(username, token, password, this.config); }); } + + defaultVerificationEmail({link, user, appName, }) { + let text = "Hi,\n\n" + + "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + + "" + + "Click here to confirm it:\n" + link; + let to = user.get("email"); + let subject = 'Please verify your e-mail for ' + appName; + return { text, to, subject }; + } + + defaultResetPasswordEmail({link, user, appName, }) { + let text = "Hi,\n\n" + + "You requested to reset your password for " + appName + ".\n\n" + + "" + + "Click here to reset it:\n" + link; + let to = user.get("email"); + let subject = 'Password Reset for ' + appName; + return { text, to, subject }; + } } // Mark this private From 6aa38ea8ca544d87250116bb5c79e9ae7730a2c5 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 28 Feb 2016 00:15:59 -0500 Subject: [PATCH 032/102] Improves validation of email parameters in Configuration --- src/Config.js | 23 +++++++++++++++++++++++ src/Controllers/AdaptableController.js | 5 ++--- src/Controllers/UserController.js | 14 +++++--------- src/index.js | 22 ++++------------------ 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/Config.js b/src/Config.js index cfa53361..ae656011 100644 --- a/src/Config.js +++ b/src/Config.js @@ -39,6 +39,29 @@ export class Config { this.mount = mount; } + static validate(options) { + this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails, + appName: options.appName, + publicServerURL: options.publicServerURL}) + } + + static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) { + if (verifyUserEmails) { + if (typeof appName !== 'string') { + throw 'An app name is required when using email verification.'; + } + if (!process.env.TESTING && typeof publicServerURL !== 'string') { + if (process.env.NODE_ENV === 'production') { + throw 'A public server url is required when using email verification.'; + } else { + console.warn(""); + console.warn("You should set publicServerURL to serve the public pages"); + console.warn(""); + } + } + } + } + get linksServerURL() { return this.publicServerURL || this.serverURL; } diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index bfe0705c..902a6eb3 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -16,8 +16,8 @@ export class AdaptableController { constructor(adapter, appId, options) { this.options = options; - this.adapter = adapter; this.appId = appId; + this.adapter = adapter; } set adapter(adapter) { @@ -62,8 +62,7 @@ export class AdaptableController { }, {}); if (Object.keys(mismatches).length > 0) { - console.error(adapter, mismatches); - throw new Error("Adapter prototype don't match expected prototype"); + throw new Error("Adapter prototype don't match expected prototype", adapter, mismatches); } } } diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index b707e124..35da9a1f 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -162,16 +162,12 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._perishable_token); const username = encodeURIComponent(user.username); let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` - - if (!user.username) { - console.log('No username...'); - } - + let options = { - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }; + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; if (this.adapter.sendPasswordResetEmail) { this.adapter.sendPasswordResetEmail(options); diff --git a/src/index.js b/src/index.js index 1fa39aa7..3eebb483 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ var batch = require('./batch'), Parse = require('parse/node').Parse; import cache from './cache'; +import Config from './Config'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; //import passwordReset from './passwordReset'; @@ -72,17 +73,6 @@ addParseCloud(); // "javascriptKey": optional key from Parse dashboard // "push": optional key from configure push -let validateEmailConfiguration = (verifyUserEmails, appName, emailAdapter) => { - if (verifyUserEmails) { - if (typeof appName !== 'string') { - throw 'An app name is required when using email verification.'; - } - if (!emailAdapter) { - throw 'User email verification was enabled, but no email adapter was provided'; - } - } -} - function ParseServer({ appId = requiredParameter('You must provide an appId!'), masterKey = requiredParameter('You must provide a masterKey!'), @@ -127,13 +117,7 @@ function ParseServer({ if (databaseURI) { DatabaseAdapter.setAppDatabaseURI(appId, databaseURI); } - - if (verifyUserEmails && !publicServerURL && !process.env.TESTING) { - console.warn(""); - console.warn("You should set publicServerURL to serve the public pages"); - console.warn(""); - } - + if (cloud) { addParseCloud(); if (typeof cloud === 'function') { @@ -186,6 +170,8 @@ function ParseServer({ if (process.env.FACEBOOK_APP_ID) { cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } + + Config.validate(cache.apps.get(appId)); // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. From 28d1a8afe4a843baff0b70e75dff0c0182fecd48 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 29 Feb 2016 20:51:13 -0500 Subject: [PATCH 033/102] Sends 404 when parseServerURL is not set on public pages - throws when verifyEmail = true && publicServerURL not set --- spec/PublicAPI.spec.js | 56 ++++++++++++++++++-- spec/ValidationAndPasswordsReset.spec.js | 66 ++++++++++++++++++++++++ spec/helper.js | 1 + spec/index.spec.js | 5 ++ src/Config.js | 28 ++++------ src/Routers/PublicAPIRouter.js | 66 +++++++++++++++++------- src/index.js | 2 +- src/middlewares.js | 3 ++ 8 files changed, 186 insertions(+), 41 deletions(-) diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js index 9979c04d..008d544a 100644 --- a/spec/PublicAPI.spec.js +++ b/spec/PublicAPI.spec.js @@ -1,9 +1,23 @@ var request = require('request'); - describe("public API", () => { - + beforeEach(done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + publicServerURL: 'http://localhost:8378/1' + }); + done(); + }) it("should get invalid_link.html", (done) => { request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse, body) => { expect(httpResponse.statusCode).toBe(200); @@ -31,6 +45,42 @@ describe("public API", () => { done(); }); }); +}); + +describe("public API without publicServerURL", () => { + beforeEach(done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + }); + done(); + }) + it("should get 404 on verify_email", (done) => { + request('http://localhost:8378/1/apps/test/verify_email', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); + it("should get 404 choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); -}) \ No newline at end of file + it("should get 404 on request_password_reset", (done) => { + request('http://localhost:8378/1/apps/test/request_password_reset', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); +}); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 91f7ddce..6ac874cd 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -56,6 +56,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); spyOn(emailAdapter, 'sendVerificationEmail'); var user = new Parse.User(); @@ -97,6 +98,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); spyOn(emailAdapter, 'sendVerificationEmail'); var user = new Parse.User(); @@ -137,6 +139,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); spyOn(emailAdapter, 'sendVerificationEmail'); var user = new Parse.User(); @@ -196,6 +199,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); var user = new Parse.User(); user.setPassword("asdf"); @@ -284,6 +288,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); var user = new Parse.User(); user.setPassword("asdf"); @@ -334,6 +339,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); @@ -342,6 +348,25 @@ describe("Email Verification", () => { }); it('redirects you to invalid link if you try to verify email incorrecly', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); request.get('http://localhost:8378/1/apps/test/verify_email', { followRedirect: false, }, (error, response, body) => { @@ -352,6 +377,25 @@ describe("Email Verification", () => { }); it('redirects you to invalid link if you try to validate a nonexistant users email', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { followRedirect: false, }, (error, response, body) => { @@ -393,6 +437,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); @@ -443,6 +488,7 @@ describe("Password Reset", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); @@ -459,6 +505,25 @@ describe("Password Reset", () => { }); it('redirects you to invalid link if you try to request password for a nonexistant users email', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); request.get('http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', { followRedirect: false, }, (error, response, body) => { @@ -533,6 +598,7 @@ describe("Password Reset", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); diff --git a/spec/helper.js b/spec/helper.js index 92231393..e2daa6ed 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -250,3 +250,4 @@ global.arrayContains = arrayContains; global.jequal = jequal; global.range = range; global.setServerConfiguration = setServerConfiguration; +global.defaultConfiguration = defaultConfiguration; diff --git a/spec/index.spec.js b/spec/index.spec.js index 005b9c76..e3e2cb0b 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -56,6 +56,7 @@ describe('server', () => { apiKey: 'k', domain: 'd', }), + publicServerURL: 'http://localhost:8378/1' }); done(); }); @@ -80,6 +81,7 @@ describe('server', () => { domain: 'd', } }, + publicServerURL: 'http://localhost:8378/1' }); done(); }); @@ -104,6 +106,7 @@ describe('server', () => { domain: 'd', } }, + publicServerURL: 'http://localhost:8378/1' }); done(); }); @@ -122,6 +125,7 @@ describe('server', () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: './Email/SimpleMailgunAdapter', + publicServerURL: 'http://localhost:8378/1' })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); done(); }); @@ -145,6 +149,7 @@ describe('server', () => { domain: 'd', } }, + publicServerURL: 'http://localhost:8378/1' })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); done(); }); diff --git a/src/Config.js b/src/Config.js index ae656011..8042d6db 100644 --- a/src/Config.js +++ b/src/Config.js @@ -50,44 +50,34 @@ export class Config { if (typeof appName !== 'string') { throw 'An app name is required when using email verification.'; } - if (!process.env.TESTING && typeof publicServerURL !== 'string') { - if (process.env.NODE_ENV === 'production') { - throw 'A public server url is required when using email verification.'; - } else { - console.warn(""); - console.warn("You should set publicServerURL to serve the public pages"); - console.warn(""); - } + if (typeof publicServerURL !== 'string') { + throw 'A public server url is required when using email verification.'; } } } - - get linksServerURL() { - return this.publicServerURL || this.serverURL; - } - + get invalidLinkURL() { - return this.customPages.invalidLink || `${this.linksServerURL}/apps/invalid_link.html`; + return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`; } get verifyEmailSuccessURL() { - return this.customPages.verifyEmailSuccess || `${this.linksServerURL}/apps/verify_email_success.html`; + return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`; } get choosePasswordURL() { - return this.customPages.choosePassword || `${this.linksServerURL}/apps/choose_password`; + return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`; } get requestResetPasswordURL() { - return `${this.linksServerURL}/apps/${this.applicationId}/request_password_reset`; + return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { - return this.customPages.passwordResetSuccess || `${this.linksServerURL}/apps/password_reset_success.html`; + return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`; } get verifyEmailURL() { - return `${this.linksServerURL}/apps/${this.applicationId}/verify_email`; + return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`; } }; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 78565311..017caef3 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -11,10 +11,13 @@ let views = path.resolve(__dirname, '../../views'); export class PublicAPIRouter extends PromiseRouter { verifyEmail(req) { - var token = req.query.token; - var username = req.query.username; - var appId = req.params.appId; - var config = new Config(appId); + let { token, username }= req.query; + let appId = req.params.appId; + let config = new Config(appId); + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } if (!token || !username) { return this.invalidLink(req); @@ -33,9 +36,9 @@ export class PublicAPIRouter extends PromiseRouter { changePassword(req) { return new Promise((resolve, reject) => { - var config = new Config(req.query.id); - if (!config.serverURL) { - return Promise.resolve({ + let config = new Config(req.query.id); + if (!config.publicServerURL) { + return resolve({ status: 404, text: 'Not found.' }); @@ -45,7 +48,7 @@ export class PublicAPIRouter extends PromiseRouter { if (err) { return reject(err); } - data = data.replace("PARSE_SERVER_URL", `'${config.serverURL}'`); + data = data.replace("PARSE_SERVER_URL", `'${config.publicServerURL}'`); resolve({ text: data }) @@ -55,13 +58,18 @@ export class PublicAPIRouter extends PromiseRouter { requestResetPassword(req) { - var { username, token } = req.query; + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { username, token } = req.query; if (!username || !token) { return this.invalidLink(req); } - let config = req.config; return config.userController.checkResetTokenValidity(username, token).then( (user) => { return Promise.resolve({ status: 302, @@ -73,7 +81,14 @@ export class PublicAPIRouter extends PromiseRouter { } resetPassword(req) { - var { + + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { username, token, new_password @@ -83,14 +98,12 @@ export class PublicAPIRouter extends PromiseRouter { return this.invalidLink(req); } - let config = req.config; return config.userController.updatePassword(username, token, new_password).then((result) => { return Promise.resolve({ status: 302, location: config.passwordResetSuccessURL }); }, (err) => { - console.error(err); return Promise.resolve({ status: 302, location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&error=${err}&app=${config.appName}` @@ -106,20 +119,37 @@ export class PublicAPIRouter extends PromiseRouter { }); } + missingPublicServerURL() { + return Promise.resolve({ + text: 'Not found.', + status: 404 + }); + } + setConfig(req) { req.config = new Config(req.params.appId); return Promise.resolve(); } mountRoutes() { - this.route('GET','/apps/:appId/verify_email', this.setConfig, req => { return this.verifyEmail(req); }); - this.route('GET','/apps/choose_password', req => { return this.changePassword(req); }); - this.route('POST','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); - this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.requestResetPassword(req); }); + this.route('GET','/apps/:appId/verify_email', + req => { this.setConfig(req) }, + req => { return this.verifyEmail(req); }); + + this.route('GET','/apps/choose_password', + req => { return this.changePassword(req); }); + + this.route('POST','/apps/:appId/request_password_reset', + req => { this.setConfig(req) }, + req => { return this.resetPassword(req); }); + + this.route('GET','/apps/:appId/request_password_reset', + req => { this.setConfig(req) }, + req => { return this.requestResetPassword(req); }); } expressApp() { - var router = express(); + let router = express(); router.use("/apps", express.static(public_html)); router.use("/", super.expressApp()); return router; diff --git a/src/index.js b/src/index.js index 3eebb483..4ee5d140 100644 --- a/src/index.js +++ b/src/index.js @@ -182,7 +182,7 @@ function ParseServer({ maxUploadSize: maxUploadSize })); - api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); + api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { diff --git a/src/middlewares.js b/src/middlewares.js index b9a8d6ec..8489cda0 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -174,6 +174,9 @@ var handleParseErrors = function(err, req, res, next) { res.status(httpStatus); res.json({code: err.code, error: err.message}); + } else if (err.status && err.message) { + res.status(err.status); + res.json({error: err.message}); } else { console.log('Uncaught internal server error.', err, err.stack); res.status(500); From 028ef2a7b2e7e5ba35e5a3eb59d3f94402f34c86 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 29 Feb 2016 17:04:38 -0800 Subject: [PATCH 034/102] Remove dependency on raw mongo from SchemaRouter.delete. --- spec/schemas.spec.js | 9 +- .../Storage/Mongo/MongoStorageAdapter.js | 7 ++ src/Controllers/DatabaseController.js | 4 + src/Routers/SchemasRouter.js | 96 +++++++------------ 4 files changed, 50 insertions(+), 66 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 36ba7637..beb09d08 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -396,7 +396,7 @@ describe('schemas', () => { }) }); - it('refuses to delete non-existant fields', done => { + it('refuses to delete non-existent fields', done => { var obj = hasAllPODobject(); obj.save() .then(() => { @@ -406,13 +406,13 @@ describe('schemas', () => { json: true, body: { fields: { - nonExistantKey: {__op: "Delete"}, + nonExistentKey: {__op: "Delete"}, } } }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(255); - expect(body.error).toEqual('field nonExistantKey does not exist, cannot delete'); + expect(body.error).toEqual('field nonExistentKey does not exist, cannot delete'); done(); }); }); @@ -660,7 +660,8 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(255); - expect(body.error).toEqual('class HasAllPOD not empty, contains 1 objects, cannot drop schema'); + expect(body.error).toMatch(/HasAllPOD/); + expect(body.error).toMatch(/contains 1/); done(); }); }); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 914d9bb0..86f56ea9 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -30,6 +30,13 @@ export class MongoStorageAdapter { }); } + dropCollection(name: string) { + return this.connect.then(() => this.collection(name).then(collection => collection.drop())); + } + + dropCollection(name: string) { + return this.collection(name).then(collection => collection.drop()); + } // Used for testing only right now. collectionsContaining(match: string) { return this.connect().then(() => { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 954b1a6f..96c33931 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -42,6 +42,10 @@ DatabaseController.prototype.rawCollection = function(className) { return this.adapter.collection(this.collectionPrefix + className); }; +DatabaseController.prototype.dropCollection = function(className) { + return this.adapter.dropCollection(this.collectionPrefix + className); +}; + function returnsTrue() { return true; } diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index a748ad14..c496be34 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -184,20 +184,12 @@ function modifySchema(req) { } // A helper function that removes all join tables for a schema. Returns a promise. -var removeJoinTables = (database, prefix, mongoSchema) => { +var removeJoinTables = (database, mongoSchema) => { return Promise.all(Object.keys(mongoSchema) .filter(field => mongoSchema[field].startsWith('relation<')) .map(field => { - var joinCollectionName = prefix + '_Join:' + field + ':' + mongoSchema._id; - return new Promise((resolve, reject) => { - database.dropCollection(joinCollectionName, (err, results) => { - if (err) { - reject(err); - } else { - resolve(); - } - }) - }); + let collectionName = `_Join:${field}:${mongoSchema._id}`; + return database.dropCollection(collectionName); }) ); }; @@ -208,63 +200,43 @@ function deleteSchema(req) { } if (!Schema.classNameIsValid(req.params.className)) { - return Promise.resolve({ - status: 400, - response: { - code: Parse.Error.INVALID_CLASS_NAME, - error: Schema.invalidClassNameMessage(req.params.className), - } - }); + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, Schema.invalidClassNameMessage(req.params.className)); } return req.config.database.collection(req.params.className) - .then(coll => new Promise((resolve, reject) => { - coll.count((err, count) => { - if (err) { - reject(err); - } else if (count > 0) { - resolve({ - status: 400, - response: { - code: 255, - error: 'class ' + req.params.className + ' not empty, contains ' + count + ' objects, cannot drop schema', + .then(collection => { + return collection.count() + .then(count => { + if (count > 0) { + throw new Parse.Error(255, `Class ${req.params.className} is not empty, contains ${count} objects, cannot drop schema.`); } - }); - } else { - coll.drop((err, reply) => { - if (err) { - reject(err); - } else { - // We've dropped the collection now, so delete the item from _SCHEMA - // and clear the _Join collections - req.config.database.collection('_SCHEMA') - .then(coll => new Promise((resolve, reject) => { - coll.findAndRemove({ _id: req.params.className }, [], (err, doc) => { - if (err) { - reject(err); - } else if (doc.value === null) { - //tried to delete non-existant class - resolve({ response: {}}); - } else { - removeJoinTables(req.config.database.adapter.database, req.config.database.collectionPrefix, doc.value) - .then(resolve, reject); - } - }); - })) - .then(resolve.bind(undefined, {response: {}}), reject); - } - }); + return collection.drop(); + }) + .then(() => { + // We've dropped the collection now, so delete the item from _SCHEMA + // and clear the _Join collections + return req.config.database.collection('_SCHEMA') + .then(coll => coll.findAndRemove({_id: req.params.className}, [])) + .then(doc => { + if (doc.value === null) { + //tried to delete non-existent class + return Promise.resolve(); + } + return removeJoinTables(req.config.database, doc.value); + }); + }) + }) + .then(() => { + // Success + return { response: {} }; + }, error => { + if (error.message == 'ns not found') { + // If they try to delete a non-existent class, that's fine, just let them. + return { response: {} }; } + + return Promise.reject(error); }); - })) - .catch( (error) => { - if (error.message == 'ns not found') { - // If they try to delete a non-existant class, thats fine, just let them. - return Promise.resolve({ response: {} }); - } - - return Promise.reject(error); - }); } export class SchemasRouter extends PromiseRouter { From 6893895aea1b5a3946afcd8025af6839e3478bc9 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 29 Feb 2016 17:41:09 -0800 Subject: [PATCH 035/102] Remove direct mongo access from SchemaRouter.modify, Schema.deleteField. --- spec/Schema.spec.js | 14 ++-- spec/schemas.spec.js | 6 +- src/Routers/SchemasRouter.js | 137 ++++++++++++++--------------------- src/Schema.js | 89 +++++++---------------- 4 files changed, 93 insertions(+), 153 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 6a02009f..bb612a4a 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -483,7 +483,7 @@ describe('Schema', () => { .then(schema => schema.deleteField('installationId', '_Installation')) .catch(error => { expect(error.code).toEqual(136); - expect(error.error).toEqual('field installationId cannot be changed'); + expect(error.message).toEqual('field installationId cannot be changed'); done(); }); }); @@ -493,7 +493,7 @@ describe('Schema', () => { .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'); + expect(error.message).toEqual('Class NoClass does not exist.'); done(); }); }); @@ -504,7 +504,7 @@ describe('Schema', () => { .then(schema => schema.deleteField('missingField', 'HasAllPOD')) .fail(error => { expect(error.code).toEqual(255); - expect(error.error).toEqual('field missingField does not exist, cannot delete'); + expect(error.message).toEqual('Field missingField does not exist, cannot delete.'); done(); }); }); @@ -523,11 +523,11 @@ describe('Schema', () => { config.database.adapter.database.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { expect(err).toEqual(null); config.database.loadSchema() - .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database.adapter.database, 'test_')) + .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database)) .then(() => config.database.adapter.database.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { expect(err).not.toEqual(null); done(); - })) + })); }); }) }); @@ -538,7 +538,7 @@ describe('Schema', () => { var obj2 = hasAllPODobject(); var p = Parse.Object.saveAll([obj1, obj2]) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database.adapter.database, 'test_')) + .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database)) .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) .then(obj1Reloaded => { expect(obj1Reloaded.get('aString')).toEqual(undefined); @@ -568,7 +568,7 @@ describe('Schema', () => { expect(obj1.get('aPointer').id).toEqual(obj1.id); }) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aPointer', 'NewClass', config.database.adapter.database, 'test_')) + .then(schema => schema.deleteField('aPointer', 'NewClass', config.database)) .then(() => new Parse.Query('NewClass').get(obj1.id)) .then(obj1 => { expect(obj1.get('aPointer')).toEqual(undefined); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index beb09d08..2e710bcc 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -369,7 +369,7 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('class NoClass does not exist'); + expect(body.error).toEqual('Class NoClass does not exist.'); done(); }); }); @@ -390,7 +390,7 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(255); - expect(body.error).toEqual('field aString exists, cannot update'); + expect(body.error).toEqual('Field aString exists, cannot update.'); done(); }); }) @@ -412,7 +412,7 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(255); - expect(body.error).toEqual('field nonExistentKey does not exist, cannot delete'); + expect(body.error).toEqual('Field nonExistentKey does not exist, cannot delete.'); done(); }); }); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index c496be34..352b1caf 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -1,8 +1,8 @@ // schemas.js var express = require('express'), - Parse = require('parse/node').Parse, - Schema = require('../Schema'); + Parse = require('parse/node').Parse, + Schema = require('../Schema'); import PromiseRouter from '../PromiseRouter'; @@ -49,10 +49,10 @@ function getAllSchemas(req) { return masterKeyRequiredResponse(); } return req.config.database.collection('_SCHEMA') - .then(coll => coll.find({}).toArray()) - .then(schemas => ({response: { - results: schemas.map(mongoSchemaToSchemaAPIResponse) - }})); + .then(coll => coll.find({}).toArray()) + .then(schemas => ({response: { + results: schemas.map(mongoSchemaToSchemaAPIResponse) + }})); } function getOneSchema(req) { @@ -60,15 +60,15 @@ function getOneSchema(req) { return masterKeyRequiredResponse(); } return req.config.database.collection('_SCHEMA') - .then(coll => coll.findOne({'_id': req.params.className})) - .then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)})) - .catch(() => ({ - status: 400, - response: { - code: 103, - error: 'class ' + req.params.className + ' does not exist', - } - })); + .then(coll => coll.findOne({'_id': req.params.className})) + .then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)})) + .catch(() => ({ + status: 400, + response: { + code: 103, + error: 'class ' + req.params.className + ' does not exist', + } + })); } function createSchema(req) { @@ -91,12 +91,12 @@ function createSchema(req) { }); } return req.config.database.loadSchema() - .then(schema => schema.addClassIfNotExists(className, req.body.fields)) - .then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) })) - .catch(error => ({ - status: 400, - response: error, - })); + .then(schema => schema.addClassIfNotExists(className, req.body.fields)) + .then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) })) + .catch(error => ({ + status: 400, + response: error, + })); } function modifySchema(req) { @@ -112,75 +112,48 @@ function modifySchema(req) { var className = req.params.className; return req.config.database.loadSchema() - .then(schema => { - if (!schema.data[className]) { - return Promise.resolve({ - status: 400, - response: { - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class ' + req.params.className + ' does not exist', + .then(schema => { + if (!schema.data[className]) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${req.params.className} does not exist.`); + } + + let existingFields = schema.data[className]; + Object.keys(submittedFields).forEach(name => { + let field = submittedFields[name]; + if (existingFields[name] && field.__op !== 'Delete') { + throw new Parse.Error(255, `Field ${name} exists, cannot update.`); + } + if (!existingFields[name] && field.__op === 'Delete') { + throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); } }); - } - var existingFields = schema.data[className]; - for (var submittedFieldName in submittedFields) { - if (existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op !== 'Delete') { - return Promise.resolve({ - status: 400, - response: { - code: 255, - error: 'field ' + submittedFieldName + ' exists, cannot update', - } - }); + let newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields); + let mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className); + if (!mongoObject.result) { + throw new Parse.Error(mongoObject.code, mongoObject.error); } - if (!existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op === 'Delete') { - return Promise.resolve({ - status: 400, - response: { - code: 255, - error: 'field ' + submittedFieldName + ' does not exist, cannot delete', - } - }); - } - } - - var newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields); - var mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className); - if (!mongoObject.result) { - return Promise.resolve({ - status: 400, - response: mongoObject, + // Finally we have checked to make sure the request is valid and we can start deleting fields. + // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. + let deletionPromises = []; + Object.keys(submittedFields).forEach(submittedFieldName => { + if (submittedFields[submittedFieldName].__op === 'Delete') { + let promise = schema.deleteField(submittedFieldName, className, req.config.database); + deletionPromises.push(promise); + } }); - } - // Finally we have checked to make sure the request is valid and we can start deleting fields. - // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. - var deletionPromises = [] - Object.keys(submittedFields).forEach(submittedFieldName => { - if (submittedFields[submittedFieldName].__op === 'Delete') { - var promise = req.config.database.connect() - .then(() => schema.deleteField( - submittedFieldName, - className, - req.config.database.adapter.database, - req.config.database.collectionPrefix - )); - deletionPromises.push(promise); - } + return Promise.all(deletionPromises) + .then(() => new Promise((resolve, reject) => { + schema.collection.update({_id: className}, mongoObject.result, {w: 1}, (err, docs) => { + if (err) { + reject(err); + } + resolve({ response: mongoSchemaToSchemaAPIResponse(mongoObject.result)}); + }) + })); }); - - return Promise.all(deletionPromises) - .then(() => new Promise((resolve, reject) => { - schema.collection.update({_id: className}, mongoObject.result, {w: 1}, (err, docs) => { - if (err) { - reject(err); - } - resolve({ response: mongoSchemaToSchemaAPIResponse(mongoObject.result)}); - }) - })); - }); } // A helper function that removes all join tables for a schema. Returns a promise. diff --git a/src/Schema.js b/src/Schema.js index 7f7d4701..5c8a94d1 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -500,80 +500,47 @@ Schema.prototype.validateField = function(className, key, type, freeze) { // 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) { +// a database adapter and this function would close over it or access it via member. +Schema.prototype.deleteField = function(fieldName, className, database) { if (!classNameIsValid(className)) { - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: invalidClassNameMessage(className), - }); + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className)); } - if (!fieldNameIsValid(fieldName)) { - return Promise.reject({ - code: Parse.Error.INVALID_KEY_NAME, - error: 'invalid field name: ' + fieldName, - }); + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `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', - }); + throw new Parse.Error(136, `field ${fieldName} cannot be changed`); } 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', - }); - } + .then(schema => { + return schema.hasClass(className) + .then(hasClass => { + if (!hasClass) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + } + if (!schema.data[className][fieldName]) { + throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); + } - if (!schema.data[className][fieldName]) { - return Promise.reject({ - code: 255, - error: 'field ' + fieldName + ' does not exist, cannot delete', - }); - } + if (schema.data[className][fieldName].startsWith('relation<')) { + //For relations, drop the _Join table + return database.dropCollection(`_Join:${fieldName}:${className}`); + } - if (schema.data[className][fieldName].startsWith('relation<')) { - //For relations, drop the _Join table - return database.dropCollection(prefix + '_Join:' + fieldName + ':' + className) - //Save the _SCHEMA object + // for non-relations, remove all the data. + // This is necessary to ensure that the data is still gone if they add the same field. + return database.collection(className) + .then(collection => { + var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? '_p_' + fieldName : fieldName; + return collection.update({}, { "$unset": { [mongoFieldName] : null } }, { multi: true }); + }); + }) + // 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. - return new Promise((resolve, reject) => { - database.collection(prefix + className, (err, coll) => { - if (err) { - reject(err); - } else { - var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? - '_p_' + fieldName : - fieldName; - return coll.update({}, { - "$unset": { [mongoFieldName] : null }, - }, { - multi: true, - }) - //Save the _SCHEMA object - .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})) - .then(resolve) - .catch(reject); - } - }); - }); - } }); - }); -} +}; // Given a schema promise, construct another schema promise that // validates this field once the schema loads. From 2733c0924babc9e749f0d14ec71dcf24cab21291 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 29 Feb 2016 19:41:05 -0800 Subject: [PATCH 036/102] Remove direct mongo access from Schema.spec.js. --- spec/Schema.spec.js | 43 +++++++++++-------- .../Storage/Mongo/MongoStorageAdapter.js | 8 +++- src/Controllers/DatabaseController.js | 4 ++ 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index bb612a4a..d0e50953 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,3 +1,5 @@ +'use strict'; + var Config = require('../src/Config'); var Schema = require('../src/Schema'); var dd = require('deep-diff'); @@ -512,24 +514,31 @@ describe('Schema', () => { 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.adapter.database.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { - expect(err).toEqual(null); - config.database.loadSchema() - .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database)) - .then(() => config.database.adapter.database.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { - expect(err).not.toEqual(null); - done(); - })); + .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.collectionExists('_Join:aRelation:HasPointersAndRelations')) + .then(exists => { + if (!exists) { + fail('Relation collection should exist after save.'); + } + }) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database)) + .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) + .then(exists => { + if (exists) { + fail('Relation collection should not exist after deleting relation field.'); + } + done(); + }, error => { + fail(error); + done(); }); - }) }); it('can delete string fields and resave as number field', done => { diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 86f56ea9..742420c5 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -30,8 +30,12 @@ export class MongoStorageAdapter { }); } - dropCollection(name: string) { - return this.connect.then(() => this.collection(name).then(collection => collection.drop())); + collectionExists(name: string) { + return this.connect().then(() => { + return this.database.listCollections({ name: name }).toArray(); + }).then(collections => { + return collections.length > 0; + }); } dropCollection(name: string) { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 96c33931..99ed5648 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -38,6 +38,10 @@ DatabaseController.prototype.collection = function(className) { return this.rawCollection(className); }; +DatabaseController.prototype.collectionExists = function(className) { + return this.adapter.collectionExists(this.collectionPrefix + className); +}; + DatabaseController.prototype.rawCollection = function(className) { return this.adapter.collection(this.collectionPrefix + className); }; From d1dbc1a035b7bc7332dd34ea81dbd3d82877d8f3 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 29 Feb 2016 19:45:12 -0800 Subject: [PATCH 037/102] Remove direct mongo access from schemas.spec.js. --- spec/schemas.spec.js | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 2e710bcc..145b2134 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1,3 +1,5 @@ +'use strict'; + var Parse = require('parse/node').Parse; var request = require('request'); var dd = require('deep-diff'); @@ -711,28 +713,35 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual({}); - config.database.adapter.database.collection('test__Join:aRelation:MyOtherClass', { strict: true }, (err, coll) => { - //Expect Join table to be gone - expect(err).not.toEqual(null); - config.database.adapter.database.collection('test_MyOtherClass', { strict: true }, (err, coll) => { - // Expect data table to be gone - expect(err).not.toEqual(null); - request.get({ - url: 'http://localhost:8378/1/schemas/MyOtherClass', - headers: masterKeyHeaders, - json: true, - }, (error, response, body) => { - //Expect _SCHEMA entry to be gone. - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('class MyOtherClass does not exist'); - done(); - }); + config.database.collectionExists('_Join:aRelation:MyOtherClass').then(exists => { + if (exists) { + fail('Relation collection should be deleted.'); + done(); + } + return config.database.collectionExists('MyOtherClass'); + }).then(exists => { + if (exists) { + fail('Class collection should be deleted.'); + done(); + } + }).then(() => { + request.get({ + url: 'http://localhost:8378/1/schemas/MyOtherClass', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + //Expect _SCHEMA entry to be gone. + expect(response.statusCode).toEqual(400); + expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(body.error).toEqual('class MyOtherClass does not exist'); + done(); }); }); }); + }).then(() => { }, error => { fail(error); + done(); }); }); }); From 63a534f31d365c537ac0067606a0e580cb38ba86 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Mon, 29 Feb 2016 21:00:04 -0800 Subject: [PATCH 038/102] Make GridStoreAdapter persist it's own connection and don't talk to config.database. --- src/Adapters/Files/GridStoreAdapter.js | 60 +++++++++++++++++--------- src/DatabaseAdapter.js | 11 +++-- src/index.js | 6 ++- 3 files changed, 51 insertions(+), 26 deletions(-) diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index 00fd37bc..b95f5563 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -1,28 +1,47 @@ -// GridStoreAdapter -// -// Stores files in Mongo using GridStore -// Requires the database adapter to be based on mongoclient +/** + GridStoreAdapter + Stores files in Mongo using GridStore + Requires the database adapter to be based on mongoclient -import { GridStore } from 'mongodb'; + @flow weak + */ + +import { MongoClient, GridStore, Db} from 'mongodb'; import { FilesAdapter } from './FilesAdapter'; export class GridStoreAdapter extends FilesAdapter { + _databaseURI: string; + _connectionPromise: Promise; + + constructor(mongoDatabaseURI: string) { + super(); + this._databaseURI = mongoDatabaseURI; + this._connect(); + } + + _connect() { + if (!this._connectionPromise) { + this._connectionPromise = MongoClient.connect(this._databaseURI); + } + return this._connectionPromise; + } + // For a given config object, filename, and data, store a file // Returns a promise - createFile(config, filename, data) { - return config.database.connect().then(() => { - let gridStore = new GridStore(config.database.adapter.database, filename, 'w'); + createFile(config, filename: string, data) { + return this._connect().then(database => { + let gridStore = new GridStore(database, filename, 'w'); return gridStore.open(); - }).then((gridStore) => { + }).then(gridStore => { return gridStore.write(data); - }).then((gridStore) => { + }).then(gridStore => { return gridStore.close(); }); } - deleteFile(config, filename) { - return config.database.connect().then(() => { - let gridStore = new GridStore(config.database.adapter.database, filename, 'w'); + deleteFile(config, filename: string) { + return this._connect().then(database => { + let gridStore = new GridStore(database, filename, 'w'); return gridStore.open(); }).then((gridStore) => { return gridStore.unlink(); @@ -31,13 +50,14 @@ export class GridStoreAdapter extends FilesAdapter { }); } - getFileData(config, filename) { - return config.database.connect().then(() => { - return GridStore.exist(config.database.adapter.database, filename); - }).then(() => { - let gridStore = new GridStore(config.database.adapter.database, filename, 'r'); - return gridStore.open(); - }).then((gridStore) => { + getFileData(config, filename: string) { + return this._connect().then(database => { + return GridStore.exist(database, filename) + .then(() => { + let gridStore = new GridStore(database, filename, 'r'); + return gridStore.open(); + }); + }).then(gridStore => { return gridStore.read(); }); } diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js index 47b4dbca..6663f36b 100644 --- a/src/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -18,10 +18,12 @@ import DatabaseController from './Controllers/DatabaseController'; import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; +const DefaultDatabaseURI = 'mongodb://localhost:27017/parse'; + let adapter = MongoStorageAdapter; -var dbConnections = {}; -var databaseURI = 'mongodb://localhost:27017/parse'; -var appDatabaseURIs = {}; +let dbConnections = {}; +let databaseURI = DefaultDatabaseURI; +let appDatabaseURIs = {}; function setAdapter(databaseAdapter) { adapter = databaseAdapter; @@ -61,5 +63,6 @@ module.exports = { setAdapter: setAdapter, setDatabaseURI: setDatabaseURI, setAppDatabaseURI: setAppDatabaseURI, - clearDatabaseURIs: clearDatabaseURIs + clearDatabaseURIs: clearDatabaseURIs, + defaultDatabaseURI: databaseURI }; diff --git a/src/index.js b/src/index.js index 4ee5d140..13af8463 100644 --- a/src/index.js +++ b/src/index.js @@ -81,7 +81,7 @@ function ParseServer({ filesAdapter, push, loggerAdapter, - databaseURI, + databaseURI = DatabaseAdapter.defaultDatabaseURI, cloud, collectionPrefix = '', clientKey, @@ -129,7 +129,9 @@ function ParseServer({ } } - const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter); + const filesControllerAdapter = loadAdapter(filesAdapter, () => { + return new GridStoreAdapter(databaseURI); + }); const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); const emailControllerAdapter = loadAdapter(emailAdapter); From 3889223b1078715a10fe3733cd85e0d4fa40f4b8 Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Tue, 1 Mar 2016 20:30:37 +0800 Subject: [PATCH 039/102] Avoid Role object without name --- src/Routers/RolesRouter.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Routers/RolesRouter.js b/src/Routers/RolesRouter.js index c9b4f999..f0cb71c6 100644 --- a/src/Routers/RolesRouter.js +++ b/src/Routers/RolesRouter.js @@ -16,6 +16,9 @@ export class RolesRouter extends ClassesRouter { handleCreate(req) { req.params.className = '_Role'; + if(req.body && !req.body.name){ + throw new Parse.Error(135, 'Role names must be specified.'); + } return super.handleCreate(req); } From 9287afc3c22ee5185c14605c022b8e50a30acd0e Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 28 Feb 2016 19:36:16 -0500 Subject: [PATCH 040/102] refactors filesAdapter tests in factories --- spec/FilesController.spec.js | 40 ++++++++-------- spec/FilesControllerTestFactory.js | 73 ++++++++++++++++++++++++++++++ src/Adapters/Files/S3Adapter.js | 68 +++++++++++++++++++--------- 3 files changed, 142 insertions(+), 39 deletions(-) create mode 100644 spec/FilesControllerTestFactory.js diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 67b36de9..13a443d8 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -1,29 +1,33 @@ var FilesController = require('../src/Controllers/FilesController').FilesController; var GridStoreAdapter = require("../src/Adapters/Files/GridStoreAdapter").GridStoreAdapter; +var S3Adapter = require("../src/Adapters/Files/S3Adapter").S3Adapter; var Config = require("../src/Config"); +var FCTestFactory = require("./FilesControllerTestFactory"); + + // Small additional tests to improve overall coverage describe("FilesController",()=>{ - it("should properly expand objects", (done) => { - var config = new Config(Parse.applicationId); - var adapter = new GridStoreAdapter(); - var filesController = new FilesController(adapter); - var result = filesController.expandFilesInObject(config, function(){}); + // Test the grid store adapter + var gridStoreAdapter = new GridStoreAdapter(); + FCTestFactory.testAdapter("GridStoreAdapter", gridStoreAdapter); + + if (process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY) { - expect(result).toBeUndefined(); + // Test the S3 Adapter + var s3Adapter = new S3Adapter(process.env.S3_ACCESS_KEY, process.env.S3_SECRET_KEY, 'parse.server.tests'); - var fullFile = { - type: '__type', - url: "http://an.url" - } + FCTestFactory.testAdapter("S3Adapter",s3Adapter); - var anObject = { - aFile: fullFile - } - filesController.expandFilesInObject(config, anObject); - expect(anObject.aFile.url).toEqual("http://an.url"); + // Test S3 with direct access + var s3DirectAccessAdapter = new S3Adapter(process.env.S3_ACCESS_KEY, process.env.S3_SECRET_KEY, 'parse.server.tests', { + directAccess: true + }); - done(); - }) -}) \ No newline at end of file + FCTestFactory.testAdapter("S3AdapterDirect", s3DirectAccessAdapter); + + } else if (!process.env.TRAVIS) { + console.log("set S3_ACCESS_KEY and S3_SECRET_KEY to test S3Adapter") + } +}); diff --git a/spec/FilesControllerTestFactory.js b/spec/FilesControllerTestFactory.js new file mode 100644 index 00000000..217a383a --- /dev/null +++ b/spec/FilesControllerTestFactory.js @@ -0,0 +1,73 @@ + +var FilesController = require('../src/Controllers/FilesController').FilesController; +var Config = require("../src/Config"); + +var testAdapter = function(name, adapter) { + // Small additional tests to improve overall coverage + + var config = new Config(Parse.applicationId); + var filesController = new FilesController(adapter); + + describe("FilesController with "+name,()=>{ + + it("should properly expand objects", (done) => { + + var result = filesController.expandFilesInObject(config, function(){}); + + expect(result).toBeUndefined(); + + var fullFile = { + type: '__type', + url: "http://an.url" + } + + var anObject = { + aFile: fullFile + } + filesController.expandFilesInObject(config, anObject); + expect(anObject.aFile.url).toEqual("http://an.url"); + + done(); + }) + + it("should properly create, read, delete files", (done) => { + var filename; + filesController.createFile(config, "file.txt", "hello world").then( (result) => { + ok(result.url); + ok(result.name); + filename = result.name; + expect(result.name.match(/file.txt/)).not.toBe(null); + return filesController.getFileData(config, filename); + }, (err) => { + fail("The adapter should create the file"); + console.error(err); + done(); + }).then((result) => { + expect(result instanceof Buffer).toBe(true); + expect(result.toString('utf-8')).toEqual("hello world"); + return filesController.deleteFile(config, filename); + }, (err) => { + fail("The adapter should get the file"); + console.error(err); + done(); + }).then((result) => { + + filesController.getFileData(config, filename).then((res) => { + fail("the file should be deleted"); + done(); + }, (err) => { + done(); + }); + + }, (err) => { + fail("The adapter should delete the file"); + console.error(err); + done(); + }); + }, 5000); // longer tests + }); +} + +module.exports = { + testAdapter: testAdapter +} diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index d63880f4..4acddf6b 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -48,6 +48,22 @@ export class S3Adapter extends FilesAdapter { }; AWS.config._region = this._region; this._s3Client = new AWS.S3(s3Options); + this._hasBucket = false; + } + + createBucket() { + var promise; + if (this._hasBucket) { + promise = Promise.resolve(); + } else { + promise = new Promise((resolve, reject) => { + this._s3Client.createBucket(() => { + this._hasBucket = true; + resolve(); + }); + }); + } + return promise; } // For a given config object, filename, and data, store a file in S3 @@ -60,26 +76,30 @@ export class S3Adapter extends FilesAdapter { 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); + return this.createBucket().then(() => { + return new Promise((resolve, reject) => { + this._s3Client.upload(params, (err, data) => { + if (err !== null) { + return reject(err); + } + resolve(data); + }); }); }); } 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); + return this.createBucket().then(() => { + 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); + }); }); }); } @@ -88,12 +108,18 @@ export class S3Adapter extends FilesAdapter { // 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); + return this.createBucket().then(() => { + return new Promise((resolve, reject) => { + this._s3Client.getObject(params, (err, data) => { + if (err !== null) { + return reject(err); + } + // Something happend here... + if (data && !data.Body) { + return reject(data); + } + resolve(data.Body); + }); }); }); } From 78d380df72caf106d7a5747e6e6e7895ab4b005c Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 09:02:33 -0500 Subject: [PATCH 041/102] Adds content type support in S3 --- spec/FilesController.spec.js | 2 +- src/Adapters/Files/FilesAdapter.js | 2 +- src/Adapters/Files/GridStoreAdapter.js | 2 +- src/Adapters/Files/S3Adapter.js | 5 ++++- src/Controllers/FilesController.js | 4 ++-- src/Routers/FilesRouter.js | 20 +++++++++++++------- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 13a443d8..2306676e 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -10,7 +10,7 @@ var FCTestFactory = require("./FilesControllerTestFactory"); describe("FilesController",()=>{ // Test the grid store adapter - var gridStoreAdapter = new GridStoreAdapter(); + var gridStoreAdapter = new GridStoreAdapter('mongodb://localhost:27017/parse'); FCTestFactory.testAdapter("GridStoreAdapter", gridStoreAdapter); if (process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY) { diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index 2ff9fdb2..1ddfbabb 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -12,7 +12,7 @@ // database adapter. export class FilesAdapter { - createFile(config, filename, data) { } + createFile(config, filename: string, data, contentType: string) { } deleteFile(config, filename) { } diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index b95f5563..ee5cf3ab 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -28,7 +28,7 @@ export class GridStoreAdapter extends FilesAdapter { // For a given config object, filename, and data, store a file // Returns a promise - createFile(config, filename: string, data) { + createFile(config, filename: string, data, contentType: string) { return this._connect().then(database => { let gridStore = new GridStore(database, filename, 'w'); return gridStore.open(); diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index 4acddf6b..e21ef8db 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -68,7 +68,7 @@ export class S3Adapter extends FilesAdapter { // 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) { + createFile(config, filename, data, contentType) { let params = { Key: this._bucketPrefix + filename, Body: data @@ -76,6 +76,9 @@ export class S3Adapter extends FilesAdapter { if (this._directAccess) { params.ACL = "public-read" } + if (contentType) { + params.ContentType = contentType; + } return this.createBucket().then(() => { return new Promise((resolve, reject) => { this._s3Client.upload(params, (err, data) => { diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 9634d807..78086758 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -10,10 +10,10 @@ export class FilesController extends AdaptableController { return this.adapter.getFileData(config, filename); } - createFile(config, filename, data) { + createFile(config, filename, data, contentType) { filename = randomHexString(32) + '_' + filename; var location = this.adapter.getFileLocation(config, filename); - return this.adapter.createFile(config, filename, data).then(() => { + return this.adapter.createFile(config, filename, data, contentType).then(() => { return Promise.resolve({ url: location, name: filename diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 65da555f..5c82d7cc 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -4,6 +4,7 @@ import * as Middlewares from '../middlewares'; import { randomHexString } from '../cryptoUtils'; import mime from 'mime'; import Config from '../Config'; +import path from 'path'; export class FilesRouter { @@ -66,20 +67,25 @@ export class FilesRouter { 'Filename contains invalid characters.')); return; } - let extension = ''; - // Not very safe there. - const hasExtension = req.params.filename.indexOf('.') > 0; - const contentType = req.get('Content-type'); + let filename = req.params.filename; + + // safe way to get the extension + let extname = path.extname(filename); + let contentType = req.get('Content-type'); + + const hasExtension = extname.length > 0; + if (!hasExtension && contentType && mime.extension(contentType)) { - extension = '.' + mime.extension(contentType); + filename = filename + '.' + mime.extension(contentType); + } else if (hasExtension && !contentType) { + contentType = mime.lookup(req.params.filename); } - const filename = req.params.filename + extension; const config = req.config; const filesController = config.filesController; - filesController.createFile(config, filename, req.body).then((result) => { + filesController.createFile(config, filename, req.body, contentType).then((result) => { res.status(201); res.set('Location', result.url); res.json(result); From 7257ee858bf6527b313a085ce8d0773672f47a6c Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 10:14:03 -0500 Subject: [PATCH 042/102] Moves some logic from FilesRouter to FilesController for content-type and filename --- src/Adapters/Files/GridStoreAdapter.js | 2 +- src/Controllers/FilesController.js | 14 ++++++++++++++ src/Routers/FilesRouter.js | 21 ++++----------------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index ee5cf3ab..e6532e21 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -28,7 +28,7 @@ export class GridStoreAdapter extends FilesAdapter { // For a given config object, filename, and data, store a file // Returns a promise - createFile(config, filename: string, data, contentType: string) { + createFile(config, filename: string, data, contentType) { return this._connect().then(database => { let gridStore = new GridStore(database, filename, 'w'); return gridStore.open(); diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 78086758..712e326c 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -3,6 +3,8 @@ import { Parse } from 'parse/node'; import { randomHexString } from '../cryptoUtils'; import AdaptableController from './AdaptableController'; import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import path from 'path'; +import mime from 'mime'; export class FilesController extends AdaptableController { @@ -11,7 +13,19 @@ export class FilesController extends AdaptableController { } createFile(config, filename, data, contentType) { + + let extname = path.extname(filename); + + const hasExtension = extname.length > 0; + + if (!hasExtension && contentType && mime.extension(contentType)) { + filename = filename + '.' + mime.extension(contentType); + } else if (hasExtension && !contentType) { + contentType = mime.lookup(filename); + } + filename = randomHexString(32) + '_' + filename; + var location = this.adapter.getFileLocation(config, filename); return this.adapter.createFile(config, filename, data, contentType).then(() => { return Promise.resolve({ diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 5c82d7cc..a3a3c811 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -2,9 +2,8 @@ import express from 'express'; import BodyParser from 'body-parser'; import * as Middlewares from '../middlewares'; import { randomHexString } from '../cryptoUtils'; -import mime from 'mime'; import Config from '../Config'; -import path from 'path'; +import mime from 'mime'; export class FilesRouter { @@ -42,7 +41,7 @@ export class FilesRouter { var contentType = mime.lookup(filename); res.set('Content-Type', contentType); res.end(data); - }).catch(() => { + }).catch((err) => { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); @@ -68,20 +67,8 @@ export class FilesRouter { return; } - let filename = req.params.filename; - - // safe way to get the extension - let extname = path.extname(filename); - let contentType = req.get('Content-type'); - - const hasExtension = extname.length > 0; - - if (!hasExtension && contentType && mime.extension(contentType)) { - filename = filename + '.' + mime.extension(contentType); - } else if (hasExtension && !contentType) { - contentType = mime.lookup(req.params.filename); - } - + const filename = req.params.filename; + const contentType = req.get('Content-type'); const config = req.config; const filesController = config.filesController; From 66eaf6c6ef95b6b5ecb26a8aaedf71e73e550e3e Mon Sep 17 00:00:00 2001 From: Peter Shin Date: Tue, 1 Mar 2016 07:35:28 -0800 Subject: [PATCH 043/102] Features Endpoint for Dashboard. --- spec/features.spec.js | 26 ++++++ src/Adapters/Push/ParsePushAdapter.js | 4 + src/Controllers/AdaptableController.js | 6 +- src/Controllers/PushController.js | 7 ++ src/Routers/FeaturesRouter.js | 32 ++++++++ src/features.js | 107 +++++++++++++++++++++++++ src/index.js | 4 +- 7 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 spec/features.spec.js create mode 100644 src/Routers/FeaturesRouter.js create mode 100644 src/features.js diff --git a/spec/features.spec.js b/spec/features.spec.js new file mode 100644 index 00000000..b0a27459 --- /dev/null +++ b/spec/features.spec.js @@ -0,0 +1,26 @@ +var features = require('../src/features') + +describe('features', () => { + it('set and get features', (done) => { + features.setFeature('users', { + testOption1: true, + testOption2: false + }); + + var _features = features.getFeatures(); + + var expected = { + testOption1: true, + testOption2: false + }; + + expect(_features.users).toEqual(expected); + done(); + }); + + it('get features that does not exist', (done) => { + var _features = features.getFeatures(); + expect(_features.test).toBeUndefined(); + done(); + }); +}); diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 3f554054..c953d157 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -14,6 +14,10 @@ export class ParsePushAdapter extends PushAdapter { super(pushConfig); this.validPushTypes = ['ios', 'android']; this.senderMap = {}; + // used in PushController for Dashboard Features + this.feature = { + immediatePush: true + }; let pushTypes = Object.keys(pushConfig); for (let pushType of pushTypes) { diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index 902a6eb3..ab7d7156 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -18,8 +18,12 @@ export class AdaptableController { this.options = options; this.appId = appId; this.adapter = adapter; + this.setFeature(); } + // sets features for Dashboard to consume from features router + setFeature() {} + set adapter(adapter) { this.validateAdapter(adapter); this[_adapter] = adapter; @@ -67,4 +71,4 @@ export class AdaptableController { } } -export default AdaptableController; \ No newline at end of file +export default AdaptableController; diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 22d9fe11..2e2134a6 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -3,9 +3,16 @@ import PromiseRouter from '../PromiseRouter'; import rest from '../rest'; import AdaptableController from './AdaptableController'; import { PushAdapter } from '../Adapters/Push/PushAdapter'; +import features from '../features'; + +const FEATURE_NAME = 'push'; export class PushController extends AdaptableController { + setFeature() { + features.setFeature(FEATURE_NAME, this.adapter.feature || {}); + } + /** * Check whether the deviceType parameter in qury condition is valid or not. * @param {Object} where A query condition diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js new file mode 100644 index 00000000..65ca1b71 --- /dev/null +++ b/src/Routers/FeaturesRouter.js @@ -0,0 +1,32 @@ +import PromiseRouter from '../PromiseRouter'; +import {getFeatures} from '../features'; + +let masterKeyRequiredResponse = () => { + return Promise.resolve({ + status: 401, + response: {error: 'master key not specified'}, + }) +} + +export class FeaturesRouter extends PromiseRouter { + + mountRoutes() { + this.route('GET','/features', (req) => { + return this.handleGET(req); + }); + } + + handleGET(req) { + if (!req.auth.isMaster) { + return masterKeyRequiredResponse(); + } + + return Promise.resolve({ + response: { + results: [getFeatures()] + } + }); + } +} + +export default FeaturesRouter; diff --git a/src/features.js b/src/features.js new file mode 100644 index 00000000..1048b91c --- /dev/null +++ b/src/features.js @@ -0,0 +1,107 @@ +/** + * features.js + * Feature config file that holds information on the features that are currently + * available on Parse Server. This is primarily created to work with an UI interface + * like the web dashboard. The list of features will change depending on the your + * app, choice of adapter as well as Parse Server version. This approach will enable + * the dashboard to be built independently and still support these use cases. + * + * + * Default features and feature options are listed in the features object. + * + * featureSwitch is a convenient way to turn on/off features without changing the config + * + * Features that use Adapters should specify the feature options through + * the setFeature method in your controller and feature + * Reference PushController and ParsePushAdapter as an example. + * + * NOTE: When adding new endpoints be sure to update this list both (features, featureSwitch) + * if you are planning to have a UI consume it. + */ + +// default features +let features = { + analytics: { + slowQueries: false, + performanceAnalysis: false, + retentionAnalysis: false, + }, + classes: {}, + files: {}, + functions: {}, + globalConfig: { + create: true, + read: true, + update: true, + delete: true, + }, + hooks: { + create: false, + read: false, + update: false, + delete: false, + }, + iapValidation: {}, + installations: {}, + logs: { + info: true, + error: true, + }, + publicAPI: {}, + push: {}, + roles: {}, + schemas: { + addField: true, + removeField: true, + addClass: true, + removeClass: true, + clearAllDataFromClass: false, + exportClass: false, + }, + sessions: {}, + users: {}, +}; + +// master switch for features +let featuresSwitch = { + analytics: true, + classes: true, + files: true, + functions: true, + globalConfig: true, + hooks: true, + iapValidation: true, + installations: true, + logs: true, + publicAPI: true, + push: true, + roles: true, + schemas: true, + sessions: true, + users: true, +}; + +/** + * set feature config options + */ +function setFeature(key, value) { + features[key] = value; +} + +/** + * get feature config options + */ +function getFeatures() { + let result = {}; + Object.keys(features).forEach((key) => { + if (featuresSwitch[key]) { + result[key] = features[key]; + } + }); + return result; +} + +module.exports = { + getFeatures, + setFeature, +}; diff --git a/src/index.js b/src/index.js index 13af8463..b521a26f 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; import PromiseRouter from './PromiseRouter'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; +import { FeaturesRouter } from './Routers/FeaturesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { FilesController } from './Controllers/FilesController'; import { FilesRouter } from './Routers/FilesRouter'; @@ -207,7 +208,8 @@ function ParseServer({ new SchemasRouter(), new PushRouter(), new LogsRouter(), - new IAPValidationRouter() + new IAPValidationRouter(), + new FeaturesRouter(), ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { From ad2e3c9b0916689a12b412b06b5d1d2b6175c952 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 15:45:11 -0500 Subject: [PATCH 044/102] documents createFile --- src/Adapters/Files/FilesAdapter.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index 1ddfbabb..d0dda004 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -12,6 +12,18 @@ // database adapter. export class FilesAdapter { + /* this method is responsible to store the file in order to be retrived later by it's file name + * + * + * @param config the current config + * @param filename the filename to save + * @param data the buffer of data from the file + * @param contentType the supposed contentType + * @discussion the contentType can be undefined if the controller was not able to determine it + * + * @return a promise that should fail if the storage didn't succeed + * + */ createFile(config, filename: string, data, contentType: string) { } deleteFile(config, filename) { } From 9601ca4fca732a5a169d74b4f0107fb49de19b1e Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 17:11:23 -0500 Subject: [PATCH 045/102] Better promise router --- spec/PromiseRouter.spec.js | 26 ++++++++++++++++++++++++++ src/PromiseRouter.js | 16 +++++----------- 2 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 spec/PromiseRouter.spec.js diff --git a/spec/PromiseRouter.spec.js b/spec/PromiseRouter.spec.js new file mode 100644 index 00000000..999325ac --- /dev/null +++ b/spec/PromiseRouter.spec.js @@ -0,0 +1,26 @@ +var PromiseRouter = require("../src/PromiseRouter").default; + +describe("PromiseRouter", () => { + + it("should properly handle rejects", (done) => { + var router = new PromiseRouter(); + router.route("GET", "/dummy", (req)=> { + return Promise.reject({ + error: "an error", + code: -1 + }) + }, (req) => { + fail("this should not be called"); + }); + + router.routes[0].handler({}).then((result) => { + console.error(result); + fail("this should not be called"); + done(); + }, (error)=> { + expect(error.error).toEqual("an error"); + expect(error.code).toEqual(-1); + done(); + }); + }); +}) \ No newline at end of file diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index 4070f706..308e4ded 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -49,17 +49,11 @@ export default class PromiseRouter { if (handlers.length > 1) { const length = handlers.length; handler = function(req) { - var next = function(i, req, res) { - if (i == length) { - return res; - } - let result = handlers[i](req); - if (!result || typeof result.then !== "function") { - result = Promise.resolve(result); - } - return result.then((res) => (next(i+1, req, res))); - } - return next(0, req); + return handlers.reduce((promise, handler) => { + return promise.then((result) => { + return handler(req); + }); + }, Promise.resolve()); } } From 8ce0bd84fb7723e0e55b442800f472470332cb04 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 15:16:51 -0800 Subject: [PATCH 046/102] Add promise-based master-key only middleware. --- src/middlewares.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/middlewares.js b/src/middlewares.js index 8489cda0..56ebdc1d 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -194,6 +194,16 @@ function enforceMasterKeyAccess(req, res, next) { next(); } +function promiseEnforceMasterKeyAccess(request) { + if (!request.auth.isMaster) { + let error = new Error(); + error.status = 403; + error.message = "unauthorized: master key is required"; + throw error; + } + return Promise.resolve(); +} + function invalidRequest(req, res) { res.status(403); res.end('{"error":"unauthorized"}'); @@ -204,5 +214,6 @@ module.exports = { allowMethodOverride: allowMethodOverride, handleParseErrors: handleParseErrors, handleParseHeaders: handleParseHeaders, - enforceMasterKeyAccess: enforceMasterKeyAccess + enforceMasterKeyAccess: enforceMasterKeyAccess, + promiseEnforceMasterKeyAccess }; From e58c935f22d6413e88fa39303d745442b26f5315 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 15:17:10 -0800 Subject: [PATCH 047/102] Use middleware instead of custom checks inside SchemasRouter. --- spec/schemas.spec.js | 12 ++++++------ src/Routers/SchemasRouter.js | 38 +++++++----------------------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 145b2134..24589a38 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -98,8 +98,8 @@ describe('schemas', () => { json: true, headers: restKeyHeaders, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('master key not specified'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); @@ -110,8 +110,8 @@ describe('schemas', () => { json: true, headers: restKeyHeaders, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('master key not specified'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); @@ -206,8 +206,8 @@ describe('schemas', () => { className: 'MyClass', }, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('master key not specified'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 352b1caf..1ed606b1 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -5,14 +5,7 @@ var express = require('express'), Schema = require('../Schema'); import PromiseRouter from '../PromiseRouter'; - -// TODO: refactor in a SchemaController at one point... -function masterKeyRequiredResponse() { - return Promise.resolve({ - status: 401, - response: {error: 'master key not specified'}, - }) -} +import * as middleware from "../middlewares"; function classNameMismatchResponse(bodyClass, pathClass) { return Promise.resolve({ @@ -45,9 +38,6 @@ function mongoSchemaToSchemaAPIResponse(schema) { } function getAllSchemas(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } return req.config.database.collection('_SCHEMA') .then(coll => coll.find({}).toArray()) .then(schemas => ({response: { @@ -56,9 +46,6 @@ function getAllSchemas(req) { } function getOneSchema(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } return req.config.database.collection('_SCHEMA') .then(coll => coll.findOne({'_id': req.params.className})) .then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)})) @@ -72,9 +59,6 @@ function getOneSchema(req) { } function createSchema(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } if (req.params.className && req.body.className) { if (req.params.className != req.body.className) { return classNameMismatchResponse(req.body.className, req.params.className); @@ -100,10 +84,6 @@ function createSchema(req) { } function modifySchema(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } - if (req.body.className && req.body.className != req.params.className) { return classNameMismatchResponse(req.body.className, req.params.className); } @@ -168,10 +148,6 @@ var removeJoinTables = (database, mongoSchema) => { }; function deleteSchema(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } - if (!Schema.classNameIsValid(req.params.className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, Schema.invalidClassNameMessage(req.params.className)); } @@ -214,11 +190,11 @@ function deleteSchema(req) { export class SchemasRouter extends PromiseRouter { mountRoutes() { - this.route('GET', '/schemas', getAllSchemas); - this.route('GET', '/schemas/:className', getOneSchema); - this.route('POST', '/schemas', createSchema); - this.route('POST', '/schemas/:className', createSchema); - this.route('PUT', '/schemas/:className', modifySchema); - this.route('DELETE', '/schemas/:className', deleteSchema); + this.route('GET', '/schemas', middleware.promiseEnforceMasterKeyAccess, getAllSchemas); + this.route('GET', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, getOneSchema); + this.route('POST', '/schemas', middleware.promiseEnforceMasterKeyAccess, createSchema); + this.route('POST', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, createSchema); + this.route('PUT', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, modifySchema); + this.route('DELETE', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, deleteSchema); } } From fb4a2524b117ef993d50f372276a230494f91b66 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 16:03:37 -0800 Subject: [PATCH 048/102] Cleanup and use masterkey middleware in FeaturesRouter. --- spec/features.spec.js | 20 +++++++++++++++++++- src/Routers/FeaturesRouter.js | 29 +++++------------------------ 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/spec/features.spec.js b/spec/features.spec.js index b0a27459..3ddd7a60 100644 --- a/spec/features.spec.js +++ b/spec/features.spec.js @@ -1,4 +1,7 @@ -var features = require('../src/features') +'use strict'; + +var features = require('../src/features'); +const request = require("request"); describe('features', () => { it('set and get features', (done) => { @@ -23,4 +26,19 @@ describe('features', () => { expect(_features.test).toBeUndefined(); done(); }); + + it('requires the master key to get all schemas', done => { + request.get({ + url: 'http://localhost:8378/1/features', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); + done(); + }); + }); }); diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index 65ca1b71..05ccad5b 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -1,32 +1,13 @@ import PromiseRouter from '../PromiseRouter'; -import {getFeatures} from '../features'; - -let masterKeyRequiredResponse = () => { - return Promise.resolve({ - status: 401, - response: {error: 'master key not specified'}, - }) -} +import * as middleware from "../middlewares"; +import { getFeatures } from '../features'; export class FeaturesRouter extends PromiseRouter { - mountRoutes() { - this.route('GET','/features', (req) => { - return this.handleGET(req); - }); - } - - handleGET(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } - - return Promise.resolve({ - response: { + this.route('GET','/features', middleware.promiseEnforceMasterKeyAccess, () => { + return { response: { results: [getFeatures()] - } + } }; }); } } - -export default FeaturesRouter; From 4054c247ec85afee17b096dc521c124a8839c072 Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Wed, 2 Mar 2016 11:13:24 +0800 Subject: [PATCH 049/102] Add test, JavascriptSDK 1.7.1 rest format not as expected JavascriptSDK 1.7.1 use serverUrl/classes/_Role directly. So move validation from RolesRouter to ClassesRouter. --- spec/ParseRole.spec.js | 21 ++++++++++++++++----- src/Routers/ClassesRouter.js | 3 +++ src/Routers/RolesRouter.js | 3 --- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 83a5a59e..ed483b22 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -59,17 +59,17 @@ describe('Parse Role testing', () => { }); }); - + it("should recursively load roles", (done) => { - + var rolesNames = ["FooRole", "BarRole", "BazRole"]; - + var createRole = function(name, parent, user) { var role = new Parse.Object("_Role") role.set("name", name); if (user) { var users = role.relation('users'); - users.add(user); + users.add(user); } if (parent) { role.relation('roles').add(parent); @@ -78,7 +78,7 @@ describe('Parse Role testing', () => { } var roleIds = {}; createTestUser().then( (user) => { - + return createRole(rolesNames[0], null, null).then( (aRole) => { roleIds[aRole.get("name")] = aRole.id; return createRole(rolesNames[1], aRole, null); @@ -102,5 +102,16 @@ describe('Parse Role testing', () => { }); }); + it("_Role object should not save without name.", (done) => { + var role = new Parse.Role(); + role.save(null,{useMasterKey:true}) + .then((r) => { + fail("_Role object should not save without name."); + }, (error) => { + expect(error.code).toEqual(135); + done(); + }); + }); + }); diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 9742f5f9..72e497e5 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -81,6 +81,9 @@ export class ClassesRouter extends PromiseRouter { } handleCreate(req) { + if(req.params.className === '_Role' && req.body && !req.body.name){ + throw new Parse.Error(135, 'Role names must be specified.'); + } return rest.create(req.config, req.auth, req.params.className, req.body); } diff --git a/src/Routers/RolesRouter.js b/src/Routers/RolesRouter.js index f0cb71c6..c9b4f999 100644 --- a/src/Routers/RolesRouter.js +++ b/src/Routers/RolesRouter.js @@ -16,9 +16,6 @@ export class RolesRouter extends ClassesRouter { handleCreate(req) { req.params.className = '_Role'; - if(req.body && !req.body.name){ - throw new Parse.Error(135, 'Role names must be specified.'); - } return super.handleCreate(req); } From a0150a743f2f55da40f07ae55b22fc55e8e743a9 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 22:33:58 -0500 Subject: [PATCH 050/102] Fix error preventing starting parse-server from CLI with a config file --- src/cli/parse-server.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cli/parse-server.js b/src/cli/parse-server.js index 627964f9..c5945b6c 100755 --- a/src/cli/parse-server.js +++ b/src/cli/parse-server.js @@ -42,14 +42,6 @@ if (program.args.length > 0 ) { console.log(`Configuation loaded from ${jsonPath}`) } -if (!program.appId || !program.masterKey || !program.serverURL) { - program.outputHelp(); - console.error(""); - console.error(colors.red("ERROR: appId, masterKey and serverURL are required")); - console.error(""); - process.exit(1); -} - options = Object.keys(definitions).reduce(function (options, key) { if (program[key]) { options[key] = program[key]; @@ -61,6 +53,14 @@ if (!options.serverURL) { options.serverURL = `http://localhost:${options.port}${options.mountPath}`; } +if (!options.appId || !options.masterKey || !options.serverURL) { + program.outputHelp(); + console.error(""); + console.error(colors.red("ERROR: appId, masterKey and serverURL are required")); + console.error(""); + process.exit(1); +} + const app = express(); const api = new ParseServer(options); app.use(options.mountPath, api); From 5f9b5d54f3df3944f9a9adb88ad4f4d4abee6c2e Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Wed, 2 Mar 2016 12:21:18 +0800 Subject: [PATCH 051/102] move check from classRouter to Schema.js --- spec/ParseRole.spec.js | 2 +- src/Routers/ClassesRouter.js | 3 --- src/Schema.js | 3 ++- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index ed483b22..a217e830 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -108,7 +108,7 @@ describe('Parse Role testing', () => { .then((r) => { fail("_Role object should not save without name."); }, (error) => { - expect(error.code).toEqual(135); + expect(error.code).toEqual(111); done(); }); }); diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 72e497e5..9742f5f9 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -81,9 +81,6 @@ export class ClassesRouter extends PromiseRouter { } handleCreate(req) { - if(req.params.className === '_Role' && req.body && !req.body.name){ - throw new Parse.Error(135, 'Role names must be specified.'); - } return rest.create(req.config, req.auth, req.params.className, req.body); } diff --git a/src/Schema.js b/src/Schema.js index 5c8a94d1..70ea57f7 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -73,7 +73,8 @@ var defaultColumns = { var requiredColumns = { - _Product: ["productIdentifier", "icon", "order", "title", "subtitle"] + _Product: ["productIdentifier", "icon", "order", "title", "subtitle"], + _Role: ["name"] } // Valid classes must: From dacc22de42c0f6c852098787a9d7f32d12549cb9 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 20:29:55 -0800 Subject: [PATCH 052/102] Use shared middleware to enforce master key on hooks API. --- src/Routers/HooksRouter.js | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/Routers/HooksRouter.js b/src/Routers/HooksRouter.js index ed34cdc4..f214e5a6 100644 --- a/src/Routers/HooksRouter.js +++ b/src/Routers/HooksRouter.js @@ -1,15 +1,9 @@ import { Parse } from 'parse/node'; import PromiseRouter from '../PromiseRouter'; import { HooksController } from '../Controllers/HooksController'; - -function enforceMasterKeyAccess(req) { - if (!req.auth.isMaster) { - throw new Parse.Error(403, "unauthorized: master key is required"); - } -} +import * as middleware from "../middlewares"; export class HooksRouter extends PromiseRouter { - createHook(aHook, config) { return config.hooksController.createHook(aHook).then( (hook) => ({response: hook})); }; @@ -93,14 +87,14 @@ export class HooksRouter extends PromiseRouter { } mountRoutes() { - this.route('GET', '/hooks/functions', enforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers', enforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('GET', '/hooks/functions/:functionName', enforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers/:className/:triggerName', enforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('POST', '/hooks/functions', enforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('POST', '/hooks/triggers', enforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('PUT', '/hooks/functions/:functionName', enforceMasterKeyAccess, this.handlePut.bind(this)); - this.route('PUT', '/hooks/triggers/:className/:triggerName', enforceMasterKeyAccess, this.handlePut.bind(this)); + this.route('GET', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); + this.route('GET', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); + this.route('GET', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); + this.route('GET', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); + this.route('POST', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); + this.route('POST', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); + this.route('PUT', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); + this.route('PUT', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); } } From 806800c6fb6acc051a9ed261c98c5cf17e488f72 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 20:30:29 -0800 Subject: [PATCH 053/102] Use shared middleware to enforce master key on global config update API. --- spec/ParseGlobalConfig.spec.js | 4 ++-- src/Routers/GlobalConfigRouter.js | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 8b739a78..399c9ee6 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -53,8 +53,8 @@ describe('a GlobalConfig', () => { 'X-Parse-REST-API-Key': 'rest' }, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('unauthorized'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js index 1fbde2d5..53abdac5 100644 --- a/src/Routers/GlobalConfigRouter.js +++ b/src/Routers/GlobalConfigRouter.js @@ -3,6 +3,7 @@ var Parse = require('parse/node').Parse; import PromiseRouter from '../PromiseRouter'; +import * as middleware from "../middlewares"; export class GlobalConfigRouter extends PromiseRouter { getGlobalConfig(req) { @@ -18,13 +19,6 @@ export class GlobalConfigRouter extends PromiseRouter { })); } updateGlobalConfig(req) { - if (!req.auth.isMaster) { - return Promise.resolve({ - status: 401, - response: {error: 'unauthorized'}, - }); - } - return req.config.database.rawCollection('_GlobalConfig') .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) .then(response => { @@ -41,7 +35,7 @@ export class GlobalConfigRouter extends PromiseRouter { mountRoutes() { this.route('GET', '/config', req => { return this.getGlobalConfig(req) }); - this.route('PUT', '/config', req => { return this.updateGlobalConfig(req) }); + this.route('PUT', '/config', middleware.promiseEnforceMasterKeyAccess, req => { return this.updateGlobalConfig(req) }); } } From 17235b576bf18afb777c3289b54ebe71363c1fbc Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 20:32:39 -0800 Subject: [PATCH 054/102] Use shared middleware to enforce master key on logs API. --- spec/LogsRouter.spec.js | 32 +++++++++++++++----------------- src/Routers/LogsRouter.js | 33 ++++++++------------------------- 2 files changed, 23 insertions(+), 42 deletions(-) diff --git a/spec/LogsRouter.spec.js b/spec/LogsRouter.spec.js index a8ef8b25..6a363a7d 100644 --- a/spec/LogsRouter.spec.js +++ b/spec/LogsRouter.spec.js @@ -1,3 +1,6 @@ +'use strict'; + +const request = require('request'); var LogsRouter = require('../src/Routers/LogsRouter').LogsRouter; var LoggerController = require('../src/Controllers/LoggerController').LoggerController; var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; @@ -45,23 +48,18 @@ describe('LogsRouter', () => { done(); }); - it('can check invalid master key of request', (done) => { - // Make mock request - var request = { - auth: { - isMaster: false - }, - query: {}, - config: { - loggerController: loggerController + it('can check invalid master key of request', done => { + request.get({ + url: 'http://localhost:8378/1/logs', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' } - }; - - var router = new LogsRouter(); - - expect(() => { - router.handleGET(request); - }).toThrow(); - done(); + }, (error, response, body) => { + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); + done(); + }); }); }); diff --git a/src/Routers/LogsRouter.js b/src/Routers/LogsRouter.js index abd57944..27a9bd48 100644 --- a/src/Routers/LogsRouter.js +++ b/src/Routers/LogsRouter.js @@ -1,23 +1,11 @@ import { Parse } from 'parse/node'; import PromiseRouter from '../PromiseRouter'; - -// 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.' - ); - } -} +import * as middleware from "../middlewares"; export class LogsRouter extends PromiseRouter { mountRoutes() { - this.route('GET','/logs', (req) => { - return this.handleGET(req); - }); + this.route('GET','/logs', middleware.promiseEnforceMasterKeyAccess, req => { return this.handleGET(req); }); } // Returns a promise for a {response} object. @@ -29,31 +17,26 @@ export class LogsRouter extends PromiseRouter { // size (optional) Number of rows returned by search. Defaults to 10 handleGET(req) { if (!req.config || !req.config.loggerController) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Logger adapter is not availabe'); + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Logger adapter is not available.'); } - let promise = new Parse.Promise(); let from = req.query.from; let until = req.query.until; let size = req.query.size; let order = req.query.order let level = req.query.level; - enforceSecurity(req.auth); const options = { from, until, size, order, - level, - } + level + }; - return req.config.loggerController.getLogs(options).then((result) => { - return Promise.resolve({ - response: result - }); - }) + return req.config.loggerController + .getLogs(options) + .then(result => ({ response: result })); } } From 49994b6e879aeb80d508c1fdb2cde442b2a0d1f4 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 20:04:15 -0800 Subject: [PATCH 055/102] Add MongoCollection and adaptiveCollection abstraction to MongoAdapter. --- src/Adapters/Storage/Mongo/MongoCollection.js | 49 +++++++++++++++++++ .../Storage/Mongo/MongoStorageAdapter.js | 8 +++ 2 files changed, 57 insertions(+) create mode 100644 src/Adapters/Storage/Mongo/MongoCollection.js diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js new file mode 100644 index 00000000..9b299801 --- /dev/null +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -0,0 +1,49 @@ + +let mongodb = require('mongodb'); +let Collection = mongodb.Collection; + +export default class MongoCollection { + _mongoCollection:Collection; + + constructor(mongoCollection:Collection) { + this._mongoCollection = mongoCollection; + } + + // Does a find with "smart indexing". + // Currently this just means, if it needs a geoindex and there is + // none, then build the geoindex. + // This could be improved a lot but it's not clear if that's a good + // idea. Or even if this behavior is a good idea. + find(query, { skip, limit, sort } = {}) { + return this._rawFind(query, { skip, limit, sort }) + .catch(error => { + // Check for "no geoindex" error + if (error.code != 17007 || + !error.message.match(/unable to find index for .geoNear/)) { + throw error; + } + // Figure out what key needs an index + let key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; + if (!key) { + throw error; + } + + var index = {}; + index[key] = '2d'; + //TODO: condiser moving index creation logic into Schema.js + return this._mongoCollection.createIndex(index) + // Retry, but just once. + .then(() => this._rawFind(query, { skip, limit, sort })); + }); + } + + _rawFind(query, { skip, limit, sort } = {}) { + return this._mongoCollection + .find(query, { skip, limit, sort }) + .toArray(); + } + + count(query, { skip, limit, sort } = {}) { + return this._mongoCollection.count(query, { skip, limit, sort }); + } +} diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 742420c5..201388b2 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -1,4 +1,6 @@ +import MongoCollection from './MongoCollection'; + let mongodb = require('mongodb'); let MongoClient = mongodb.MongoClient; @@ -30,6 +32,12 @@ export class MongoStorageAdapter { }); } + adaptiveCollection(name: string) { + return this.connect() + .then(() => this.database.collection(name)) + .then(rawCollection => new MongoCollection(rawCollection)); + } + collectionExists(name: string) { return this.connect().then(() => { return this.database.listCollections({ name: name }).toArray(); From abde9484ce78a9b9a057f1c222232c528b950af9 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 20:04:51 -0800 Subject: [PATCH 056/102] Use adaptiveCollection for main find/count inside DatabaseController. --- src/Controllers/DatabaseController.js | 71 ++++++++------------------- 1 file changed, 20 insertions(+), 51 deletions(-) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 99ed5648..a7d26245 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -38,6 +38,10 @@ DatabaseController.prototype.collection = function(className) { return this.rawCollection(className); }; +DatabaseController.prototype.adaptiveCollection = function(className) { + return this.adapter.adaptiveCollection(this.collectionPrefix + className); +}; + DatabaseController.prototype.collectionExists = function(className) { return this.adapter.collectionExists(this.collectionPrefix + className); }; @@ -340,9 +344,8 @@ DatabaseController.prototype.create = function(className, object, options) { // to avoid Mongo-format dependencies. // Returns a promise that resolves to a list of items. DatabaseController.prototype.mongoFind = function(className, query, options = {}) { - return this.collection(className).then((coll) => { - return coll.find(query, options).toArray(); - }); + return this.adaptiveCollection(className) + .then(collection => collection.find(query, options)); }; // Deletes everything in the database matching the current collectionPrefix @@ -378,23 +381,17 @@ function keysForQuery(query) { // Returns a promise for a list of related ids given an owning id. // className here is the owning className. DatabaseController.prototype.relatedIds = function(className, key, owningId) { - var joinTable = '_Join:' + key + ':' + className; - return this.collection(joinTable).then((coll) => { - return coll.find({owningId: owningId}).toArray(); - }).then((results) => { - return results.map(r => r.relatedId); - }); + return this.adaptiveCollection(joinTableName(className, key)) + .then(coll => coll.find({owningId : owningId})) + .then(results => results.map(r => r.relatedId)); }; // Returns a promise for a list of owning ids given some related ids. // className here is the owning className. DatabaseController.prototype.owningIds = function(className, key, relatedIds) { - var joinTable = '_Join:' + key + ':' + className; - return this.collection(joinTable).then((coll) => { - return coll.find({relatedId: {'$in': relatedIds}}).toArray(); - }).then((results) => { - return results.map(r => r.owningId); - }); + return this.adaptiveCollection(joinTableName(className, key)) + .then(coll => coll.find({ relatedId: { '$in': relatedIds } })) + .then(results => results.map(r => r.owningId)); }; // Modifies query so that it no longer has $in on relation fields, or @@ -443,38 +440,6 @@ DatabaseController.prototype.reduceRelationKeys = function(className, query) { } }; -// Does a find with "smart indexing". -// Currently this just means, if it needs a geoindex and there is -// none, then build the geoindex. -// This could be improved a lot but it's not clear if that's a good -// idea. Or even if this behavior is a good idea. -DatabaseController.prototype.smartFind = function(coll, where, options) { - return coll.find(where, options).toArray() - .then((result) => { - return result; - }, (error) => { - // Check for "no geoindex" error - if (!error.message.match(/unable to find index for .geoNear/) || - error.code != 17007) { - throw error; - } - - // Figure out what key needs an index - var key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; - if (!key) { - throw error; - } - - var index = {}; - index[key] = '2d'; - //TODO: condiser moving index creation logic into Schema.js - return coll.createIndex(index).then(() => { - // Retry, but just once. - return coll.find(where, options).toArray(); - }); - }); -}; - // Runs a query on the database. // Returns a promise that resolves to a list of items. // Options: @@ -528,8 +493,8 @@ DatabaseController.prototype.find = function(className, query, options = {}) { }).then(() => { return this.reduceInRelation(className, query, schema); }).then(() => { - return this.collection(className); - }).then((coll) => { + return this.adaptiveCollection(className); + }).then(collection => { var mongoWhere = transform.transformWhere(schema, className, query); if (!isMaster) { var orParts = [ @@ -542,9 +507,9 @@ DatabaseController.prototype.find = function(className, query, options = {}) { mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]}; } if (options.count) { - return coll.count(mongoWhere, mongoOptions); + return collection.count(mongoWhere, mongoOptions); } else { - return this.smartFind(coll, mongoWhere, mongoOptions) + return collection.find(mongoWhere, mongoOptions) .then((mongoResults) => { return mongoResults.map((r) => { return this.untransformObject( @@ -555,4 +520,8 @@ DatabaseController.prototype.find = function(className, query, options = {}) { }); }; +function joinTableName(className, key) { + return `_Join:${key}:${className}`; +} + module.exports = DatabaseController; From 9538a7dab5e4a3d5f4465b1ac1eb7133658e348e Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 20:24:36 -0800 Subject: [PATCH 057/102] Make parts of SchemasRouter use adaptiveCollection. --- src/Adapters/Storage/Mongo/MongoCollection.js | 4 ++ src/Routers/SchemasRouter.js | 37 +++++++++---------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 9b299801..cb721db0 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -46,4 +46,8 @@ export default class MongoCollection { count(query, { skip, limit, sort } = {}) { return this._mongoCollection.count(query, { skip, limit, sort }); } + + drop() { + return this._mongoCollection.drop(); + } } diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 1ed606b1..007625f3 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -38,11 +38,10 @@ function mongoSchemaToSchemaAPIResponse(schema) { } function getAllSchemas(req) { - return req.config.database.collection('_SCHEMA') - .then(coll => coll.find({}).toArray()) - .then(schemas => ({response: { - results: schemas.map(mongoSchemaToSchemaAPIResponse) - }})); + return req.config.database.adaptiveCollection('_SCHEMA') + .then(collection => collection.find({})) + .then(schemas => schemas.map(mongoSchemaToSchemaAPIResponse)) + .then(schemas => ({ response: { results: schemas }})); } function getOneSchema(req) { @@ -152,7 +151,7 @@ function deleteSchema(req) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, Schema.invalidClassNameMessage(req.params.className)); } - return req.config.database.collection(req.params.className) + return req.config.database.adaptiveCollection(req.params.className) .then(collection => { return collection.count() .then(count => { @@ -161,19 +160,19 @@ function deleteSchema(req) { } return collection.drop(); }) - .then(() => { - // We've dropped the collection now, so delete the item from _SCHEMA - // and clear the _Join collections - return req.config.database.collection('_SCHEMA') - .then(coll => coll.findAndRemove({_id: req.params.className}, [])) - .then(doc => { - if (doc.value === null) { - //tried to delete non-existent class - return Promise.resolve(); - } - return removeJoinTables(req.config.database, doc.value); - }); - }) + }) + .then(() => { + // We've dropped the collection now, so delete the item from _SCHEMA + // and clear the _Join collections + return req.config.database.collection('_SCHEMA') + .then(coll => coll.findAndRemove({_id: req.params.className}, [])) + .then(doc => { + if (doc.value === null) { + //tried to delete non-existent class + return Promise.resolve(); + } + return removeJoinTables(req.config.database, doc.value); + }); }) .then(() => { // Success From 4bd163b790e67614524505f7ce772c70c24bc571 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 23:04:47 -0800 Subject: [PATCH 058/102] Remove direct mongo collection access from UserController.checkResetTokenValidity. --- src/Controllers/UserController.js | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 35da9a1f..290f2d25 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -69,20 +69,19 @@ export class UserController extends AdaptableController { } checkResetTokenValidity(username, token) { - return new Promise((resolve, reject) => { - return this.config.database.collection('_User').then(coll => { - return coll.findOne({ - username: username, - _perishable_token: token, - }, (err, doc) => { - if (err || !doc) { - reject(err); - } else { - resolve(doc); - } - }); + return this.config.database.adaptiveCollection('_User') + .then(collection => { + return collection.find({ + username: username, + _perishable_token: token + }, { limit: 1 }); + }) + .then(results => { + if (results.length != 1) { + return Promise.reject(); + } + return results[0]; }); - }); } getUserIfNeeded(user) { From a909ab71e3f5845e2a0b807f55915e2a02b6a610 Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Wed, 2 Mar 2016 16:08:39 +0800 Subject: [PATCH 059/102] ACL must be specified in _Role --- spec/ParseRole.spec.js | 13 ++++++++++--- src/Schema.js | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index a217e830..be2de4a2 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -65,8 +65,7 @@ describe('Parse Role testing', () => { var rolesNames = ["FooRole", "BarRole", "BazRole"]; var createRole = function(name, parent, user) { - var role = new Parse.Object("_Role") - role.set("name", name); + var role = new Parse.Role(name, new Parse.ACL()); if (user) { var users = role.relation('users'); users.add(user); @@ -97,6 +96,7 @@ describe('Parse Role testing', () => { }) done(); }, function(err){ + console.log('error?',err); fail("should succeed") done(); }); @@ -109,7 +109,14 @@ describe('Parse Role testing', () => { fail("_Role object should not save without name."); }, (error) => { expect(error.code).toEqual(111); - done(); + role.set('name','testRole'); + role.save(null,{useMasterKey:true}) + .then((r2)=>{ + fail("_Role object should not save without ACL."); + }, (error2) =>{ + expect(error2.code).toEqual(111); + done(); + }); }); }); diff --git a/src/Schema.js b/src/Schema.js index 70ea57f7..b16dfd18 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -74,7 +74,7 @@ var defaultColumns = { var requiredColumns = { _Product: ["productIdentifier", "icon", "order", "title", "subtitle"], - _Role: ["name"] + _Role: ["name", "ACL"] } // Valid classes must: From e39286d88b3a901a284edde1bca91ba29fe6a089 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Tue, 1 Mar 2016 23:39:19 -0800 Subject: [PATCH 060/102] Implement findAndDelete in MongoCollection and move SchemasRouter to it. --- src/Adapters/Storage/Mongo/MongoCollection.js | 11 +++++++++++ src/Routers/SchemasRouter.js | 10 +++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index cb721db0..cabd6038 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -46,6 +46,17 @@ export default class MongoCollection { count(query, { skip, limit, sort } = {}) { return this._mongoCollection.count(query, { skip, limit, sort }); } + + // Atomically find and delete an object based on query. + // The result is the promise with an object that was in the database before deleting. + // Postgres Note: Translates directly to `DELETE * FROM ... RETURNING *`, which will return data after delete is done. + findOneAndDelete(query) { + // arguments: query, sort + return this._mongoCollection.findAndRemove(query, []).then(document => { + // Value is the object where mongo returns multiple fields. + return document.value; + }); + } drop() { return this._mongoCollection.drop(); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 007625f3..70b3157e 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -164,14 +164,14 @@ function deleteSchema(req) { .then(() => { // We've dropped the collection now, so delete the item from _SCHEMA // and clear the _Join collections - return req.config.database.collection('_SCHEMA') - .then(coll => coll.findAndRemove({_id: req.params.className}, [])) - .then(doc => { - if (doc.value === null) { + return req.config.database.adaptiveCollection('_SCHEMA') + .then(coll => coll.findOneAndDelete({_id: req.params.className})) + .then(document => { + if (document === null) { //tried to delete non-existent class return Promise.resolve(); } - return removeJoinTables(req.config.database, doc.value); + return removeJoinTables(req.config.database, document); }); }) .then(() => { From 244009923f8553dfa21e918bfebc81032931fd61 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 00:04:14 -0800 Subject: [PATCH 061/102] Add findOneAndUpdate to MongoCollection. --- src/Adapters/Storage/Mongo/MongoCollection.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index cabd6038..66113c3d 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -46,7 +46,19 @@ export default class MongoCollection { count(query, { skip, limit, sort } = {}) { return this._mongoCollection.count(query, { skip, limit, sort }); } - + + // Atomically finds and updates an object based on query. + // The result is the promise with an object that was in the database !AFTER! changes. + // Postgres Note: Translates directly to `UPDATE * SET * ... RETURNING *`, which will return data after the change is done. + findOneAndUpdate(query, update) { + // arguments: query, sort, update, options(optional) + // Setting `new` option to true makes it return the after document, not the before one. + return this._mongoCollection.findAndModify(query, [], update, { new: true }).then(document => { + // Value is the object where mongo returns multiple fields. + return document.value; + }) + } + // Atomically find and delete an object based on query. // The result is the promise with an object that was in the database before deleting. // Postgres Note: Translates directly to `DELETE * FROM ... RETURNING *`, which will return data after delete is done. From 6d7813be4a1e06e319af07aa4926bd71fdb8697c Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 00:04:29 -0800 Subject: [PATCH 062/102] Move UserController to use adaptiveCollection for findOneAndUpdate. --- src/Controllers/UserController.js | 63 ++++++++++++------------------- 1 file changed, 25 insertions(+), 38 deletions(-) diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 290f2d25..019f71c1 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -40,32 +40,27 @@ export class UserController extends AdaptableController { verifyEmail(username, token) { - - return new Promise((resolve, reject) => { - + if (!this.shouldVerifyEmails) { // Trying to verify email when not enabled - if (!this.shouldVerifyEmails) { - reject(); - return; - } - - var database = this.config.database; - - database.collection('_User').then(coll => { + // TODO: Better error here. + return Promise.reject(); + } + + return this.config.database + .adaptiveCollection('_User') + .then(collection => { // Need direct database access because verification token is not a parse field - return coll.findAndModify({ + return collection.findOneAndUpdate({ username: username, - _email_verify_token: token, - }, null, {$set: {emailVerified: true}}, (err, doc) => { - if (err || !doc.value) { - reject(err); - } else { - resolve(doc.value); - } - }); + _email_verify_token: token + }, {$set: {emailVerified: true}}); + }) + .then(document => { + if (!document) { + return Promise.reject(); + } + return document; }); - - }); } checkResetTokenValidity(username, token) { @@ -129,24 +124,16 @@ export class UserController extends AdaptableController { } setPasswordResetToken(email) { - var database = this.config.database; - var token = randomString(25); - return new Promise((resolve, reject) => { - return database.collection('_User').then(coll => { + let token = randomString(25); + return this.config.database + .adaptiveCollection('_User') + .then(collection => { // Need direct database access because verification token is not a parse field - return coll.findAndModify({ - email: email, - }, null, {$set: {_perishable_token: token}}, (err, doc) => { - if (err || !doc.value) { - console.error(err); - reject(err); - } else { - doc.value._perishable_token = token; - resolve(doc.value); - } - }); + return collection.findOneAndUpdate( + { email: email}, // query + { $set: { _perishable_token: token } } // update + ); }); - }); } sendPasswordResetEmail(email) { From 4049ce41029827c2d4c6838b73fc9c618c1773d1 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 00:21:55 -0800 Subject: [PATCH 063/102] Move DatabaseController to use new findOneAndUpdate. --- src/Controllers/DatabaseController.js | 80 +++++++++++++-------------- 1 file changed, 37 insertions(+), 43 deletions(-) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index a7d26245..91507ef8 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -142,51 +142,45 @@ DatabaseController.prototype.update = function(className, query, update, options var isMaster = !('acl' in options); var aclGroup = options.acl || []; var mongoUpdate, schema; - return this.loadSchema(acceptor).then((s) => { - schema = s; - if (!isMaster) { - return schema.validatePermission(className, aclGroup, 'update'); - } - return Promise.resolve(); - }).then(() => { - - return this.handleRelationUpdates(className, query.objectId, update); - }).then(() => { - return this.collection(className); - }).then((coll) => { - var mongoWhere = transform.transformWhere(schema, className, query); - if (options.acl) { - var writePerms = [ - {_wperm: {'$exists': false}} - ]; - for (var entry of options.acl) { - writePerms.push({_wperm: {'$in': [entry]}}); + return this.loadSchema(acceptor) + .then(s => { + schema = s; + if (!isMaster) { + return schema.validatePermission(className, aclGroup, 'update'); } - mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; - } - - mongoUpdate = transform.transformUpdate(schema, className, update); - - return coll.findAndModify(mongoWhere, {}, mongoUpdate, {}); - }).then((result) => { - if (!result.value) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); - } - if (result.lastErrorObject.n != 1) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); - } - - var response = {}; - var inc = mongoUpdate['$inc']; - if (inc) { - for (var key in inc) { - response[key] = (result.value[key] || 0) + inc[key]; + return Promise.resolve(); + }) + .then(() => this.handleRelationUpdates(className, query.objectId, update)) + .then(() => this.adaptiveCollection(className)) + .then(collection => { + var mongoWhere = transform.transformWhere(schema, className, query); + if (options.acl) { + var writePerms = [ + {_wperm: {'$exists': false}} + ]; + for (var entry of options.acl) { + writePerms.push({_wperm: {'$in': [entry]}}); + } + mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; } - } - return response; - }); + mongoUpdate = transform.transformUpdate(schema, className, update); + return collection.findOneAndUpdate(mongoWhere, mongoUpdate); + }) + .then(result => { + if (!result) { + return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.')); + } + + let response = {}; + let inc = mongoUpdate['$inc']; + if (inc) { + Object.keys(inc).forEach(key => { + response[key] = result[key]; + }); + } + return response; + }); }; // Processes relation-updating operations from a REST-format update. From 36aa5935b197dab8c2c7b3aee71897ecf89e0abb Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Wed, 2 Mar 2016 16:50:00 +0800 Subject: [PATCH 064/102] change error msg --- spec/ParseObject.spec.js | 6 +----- src/Schema.js | 11 +++++------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index ccd8920c..21920ae6 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -350,11 +350,7 @@ describe('Parse.Object testing', () => { var next = function(index) { if (index < tests.length) { tests[index].save().then(fail, error => { - if (types[index] === 'Pointer') { - expect(error.code).toEqual(Parse.Error.INVALID_POINTER); - } else { - expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); - } + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); next(index + 1); }); } else { diff --git a/src/Schema.js b/src/Schema.js index 46bb9151..612e68e2 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -737,34 +737,33 @@ function getObjectType(obj) { if(obj.className) { return '*' + obj.className; } else { - throw new Parse.Error(Parse.Error.INVALID_POINTER, JSON.stringify(obj) + " is not a valid Pointer"); + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } break; case 'File' : if(obj.name) { return 'file'; } else { - let msg = obj.name? JSON.stringify(obj) + " is not a valid File" : "File has no name"; - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, msg); + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } break; case 'Date' : if(obj.iso) { return 'date'; } else { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, JSON.stringify(obj) + " is not a valid Date"); + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } break; case 'GeoPoint' : if(obj.latitude != null && obj.longitude != null) { return 'geopoint'; } else { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, JSON.stringify(obj) + " is not a valid GeoPoint"); + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } break; case 'Bytes' : if(!obj.base64) { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'Bytes type has no base64 field: ' + JSON.stringify(obj)); + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } break; default : From 789dc48f477280838c579993f093517066bc62f5 Mon Sep 17 00:00:00 2001 From: jim1_lin Date: Wed, 2 Mar 2016 21:09:20 +0800 Subject: [PATCH 065/102] remove redundant log --- spec/ParseRole.spec.js | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index be2de4a2..02166ddd 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -96,7 +96,6 @@ describe('Parse Role testing', () => { }) done(); }, function(err){ - console.log('error?',err); fail("should succeed") done(); }); From 8e034dd9b0e868c7efea05786737705c78769b22 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 18:19:37 -0500 Subject: [PATCH 066/102] adds scriptlog endpoint --- src/Routers/LogsRouter.js | 44 +++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/src/Routers/LogsRouter.js b/src/Routers/LogsRouter.js index 27a9bd48..ed622a83 100644 --- a/src/Routers/LogsRouter.js +++ b/src/Routers/LogsRouter.js @@ -5,7 +5,12 @@ import * as middleware from "../middlewares"; export class LogsRouter extends PromiseRouter { mountRoutes() { - this.route('GET','/logs', middleware.promiseEnforceMasterKeyAccess, req => { return this.handleGET(req); }); + this.route('GET','/logs', (req) => { + return this.handleGET(req); + }); + this.route('GET','/scriptlog', (req) => { + return this.handleScriptLog(req); + }); } // Returns a promise for a {response} object. @@ -16,16 +21,11 @@ export class LogsRouter extends PromiseRouter { // 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 (!req.config || !req.config.loggerController) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'Logger adapter is not available.'); - } - - let from = req.query.from; - let until = req.query.until; - let size = req.query.size; - let order = req.query.order - let level = req.query.level; - + const from = req.query.from; + const until = req.query.until; + const size = req.query.size; + const order = req.query.order + const level = req.query.level; const options = { from, until, @@ -37,6 +37,28 @@ export class LogsRouter extends PromiseRouter { return req.config.loggerController .getLogs(options) .then(result => ({ response: result })); + level, + } + return this.getLogs(req, options); + } + + handleScriptLog(req) { + const size = req.query.n; + const level = req.query.level; + return this.getLogs(req, { size, level }); + } + + getLogs(req, options) { + if (!req.config || !req.config.loggerController) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Logger adapter is not availabe'); + } + enforceSecurity(req.auth); + return req.config.loggerController.getLogs(options).then((result) => { + return Promise.resolve({ + response: result + }); + }) } } From 63dc64004d4fa6607dab192db2f33f89e9b1c7a8 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 22:33:14 -0500 Subject: [PATCH 067/102] Removes /logs endpoint --- spec/LogsRouter.spec.js | 6 +++--- src/Routers/LogsRouter.js | 41 ++++++++++++++------------------------- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/spec/LogsRouter.spec.js b/spec/LogsRouter.spec.js index 6a363a7d..e8907a39 100644 --- a/spec/LogsRouter.spec.js +++ b/spec/LogsRouter.spec.js @@ -23,7 +23,7 @@ describe('LogsRouter', () => { var router = new LogsRouter(); expect(() => { - router.handleGET(request); + router.validateRequest(request); }).not.toThrow(); done(); }); @@ -43,14 +43,14 @@ describe('LogsRouter', () => { var router = new LogsRouter(); expect(() => { - router.handleGET(request); + router.validateRequest(request); }).toThrow(); done(); }); it('can check invalid master key of request', done => { request.get({ - url: 'http://localhost:8378/1/logs', + url: 'http://localhost:8378/1/scriptlog', json: true, headers: { 'X-Parse-Application-Id': 'test', diff --git a/src/Routers/LogsRouter.js b/src/Routers/LogsRouter.js index ed622a83..fbc8ec99 100644 --- a/src/Routers/LogsRouter.js +++ b/src/Routers/LogsRouter.js @@ -5,12 +5,16 @@ import * as middleware from "../middlewares"; export class LogsRouter extends PromiseRouter { mountRoutes() { - this.route('GET','/logs', (req) => { + this.route('GET','/scriptlog', middleware.promiseEnforceMasterKeyAccess, this.validateRequest, (req) => { return this.handleGET(req); }); - this.route('GET','/scriptlog', (req) => { - return this.handleScriptLog(req); - }); + } + + validateRequest(req) { + if (!req.config || !req.config.loggerController) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Logger adapter is not availabe'); + } } // Returns a promise for a {response} object. @@ -20,10 +24,15 @@ export class LogsRouter extends PromiseRouter { // 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 + // n same as size, overrides size if set handleGET(req) { const from = req.query.from; const until = req.query.until; - const size = req.query.size; + let size = req.query.size; + if (req.query.n) { + size = req.query.n; + } + const order = req.query.order const level = req.query.level; const options = { @@ -33,27 +42,7 @@ export class LogsRouter extends PromiseRouter { order, level }; - - return req.config.loggerController - .getLogs(options) - .then(result => ({ response: result })); - level, - } - return this.getLogs(req, options); - } - - handleScriptLog(req) { - const size = req.query.n; - const level = req.query.level; - return this.getLogs(req, { size, level }); - } - - getLogs(req, options) { - if (!req.config || !req.config.loggerController) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Logger adapter is not availabe'); - } - enforceSecurity(req.auth); + return req.config.loggerController.getLogs(options).then((result) => { return Promise.resolve({ response: result From 27815b18aa7fd26393097ba0994dcd0d29aad121 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 2 Mar 2016 12:01:49 -0500 Subject: [PATCH 068/102] Adds support for multiple $in --- spec/ParseRelation.spec.js | 52 ++++++++++++++++++++++++++- src/Controllers/DatabaseController.js | 46 ++++++++++++------------ 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index e8e7258c..664a9ba9 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -237,7 +237,57 @@ describe('Parse.Relation testing', () => { success: function(list) { equal(list.length, 1, "There should be only one result"); equal(list[0].id, parent2.id, - "Should have gotten back the right result"); + "Should have gotten back the right result"); + done(); + } + }); + } + }); + } + }); + }); + + it("queries on relation fields with multiple ins", (done) => { + var ChildObject = Parse.Object.extend("ChildObject"); + var childObjects = []; + for (var i = 0; i < 10; i++) { + childObjects.push(new ChildObject({x: i})); + } + + Parse.Object.saveAll(childObjects, { + success: function() { + var ParentObject = Parse.Object.extend("ParentObject"); + var parent = new ParentObject(); + parent.set("x", 4); + var relation = parent.relation("child"); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + var parent2 = new ParentObject(); + parent2.set("x", 3); + var relation2 = parent2.relation("child"); + relation2.add(childObjects[4]); + relation2.add(childObjects[5]); + relation2.add(childObjects[6]); + + var otherChild2 = parent2.relation("otherChild"); + otherChild2.add(childObjects[0]); + otherChild2.add(childObjects[1]); + otherChild2.add(childObjects[2]); + + var parents = []; + parents.push(parent); + parents.push(parent2); + Parse.Object.saveAll(parents, { + success: function() { + var query = new Parse.Query(ParentObject); + var objects = []; + objects.push(childObjects[0]); + query.containedIn("child", objects); + query.containedIn("otherChild", [childObjects[0]]); + query.find({ + success: function(list) { + equal(list.length, 2, "There should be only one result"); done(); } }); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index a7d26245..8901004d 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -397,31 +397,33 @@ DatabaseController.prototype.owningIds = function(className, key, relatedIds) { // Modifies query so that it no longer has $in on relation fields, or // equal-to-pointer constraints on relation fields. // Returns a promise that resolves when query is mutated -// TODO: this only handles one of these at a time - make it handle more DatabaseController.prototype.reduceInRelation = function(className, query, schema) { // Search for an in-relation or equal-to-relation - for (var key in query) { - if (query[key] && - (query[key]['$in'] || query[key].__type == 'Pointer')) { - var t = schema.getExpectedType(className, key); - var match = t ? t.match(/^relation<(.*)>$/) : false; - if (!match) { - continue; + // Make it sequential for now, not sure of paralleization side effects + return Object.keys(query).reduce((promise, key) => { + return promise.then(() => { + if (query[key] && + (query[key]['$in'] || query[key].__type == 'Pointer')) { + let t = schema.getExpectedType(className, key); + let match = t ? t.match(/^relation<(.*)>$/) : false; + if (!match) { + return; + } + let relatedClassName = match[1]; + let relatedIds; + if (query[key]['$in']) { + relatedIds = query[key]['$in'].map(r => r.objectId); + } else { + relatedIds = [query[key].objectId]; + } + return this.owningIds(className, key, relatedIds).then((ids) => { + delete query[key]; + query.objectId = Object.assign({'$in': []}, query.objectId); + query.objectId['$in'] = query.objectId['$in'].concat(ids); + }); } - var relatedClassName = match[1]; - var relatedIds; - if (query[key]['$in']) { - relatedIds = query[key]['$in'].map(r => r.objectId); - } else { - relatedIds = [query[key].objectId]; - } - return this.owningIds(className, key, relatedIds).then((ids) => { - delete query[key]; - query.objectId = {'$in': ids}; - }); - } - } - return Promise.resolve(); + }); + }, Promise.resolve()); }; // Modifies query so that it no longer has $relatedTo From 3629c40036a2be8c84dfe39a1f9a44002fb48608 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 2 Mar 2016 14:33:51 -0500 Subject: [PATCH 069/102] Adds support for or queries on pointer and relations --- spec/ParseRelation.spec.js | 50 ++++++++++++++++++++++++++- src/Controllers/DatabaseController.js | 35 ++++++++++++++----- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index 664a9ba9..550e4b33 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -287,7 +287,55 @@ describe('Parse.Relation testing', () => { query.containedIn("otherChild", [childObjects[0]]); query.find({ success: function(list) { - equal(list.length, 2, "There should be only one result"); + equal(list.length, 2, "There should be 2 results"); + done(); + } + }); + } + }); + } + }); + }); + + it("or queries on pointer and relation fields", (done) => { + var ChildObject = Parse.Object.extend("ChildObject"); + var childObjects = []; + for (var i = 0; i < 10; i++) { + childObjects.push(new ChildObject({x: i})); + } + + Parse.Object.saveAll(childObjects, { + success: function() { + var ParentObject = Parse.Object.extend("ParentObject"); + var parent = new ParentObject(); + parent.set("x", 4); + var relation = parent.relation("toChilds"); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + + var parent2 = new ParentObject(); + parent2.set("x", 3); + parent2.set("toChild", childObjects[2]); + + var parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + Parse.Object.saveAll(parents, { + success: function() { + var query1 = new Parse.Query(ParentObject); + query1.containedIn("toChilds", [childObjects[2]]); + var query2 = new Parse.Query(ParentObject); + query2.equalTo("toChild", childObjects[2]); + var query = Parse.Query.or(query1, query2); + query.find({ + success: function(list) { + list = list.filter(function(item){ + return item.id == parent.id || item.id == parent2.id; + }); + equal(list.length, 2, "There should be 2 results"); done(); } }); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 8901004d..923e6e49 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -366,13 +366,11 @@ DatabaseController.prototype.deleteEverything = function() { function keysForQuery(query) { var sublist = query['$and'] || query['$or']; if (sublist) { - var answer = new Set(); - for (var subquery of sublist) { - for (var key of keysForQuery(subquery)) { - answer.add(key); - } - } - return answer; + let answer = sublist.reduce((memo, subquery) => { + return memo.concat(keysForQuery(subquery)); + }, []); + + return new Set(answer); } return new Set(Object.keys(query)); @@ -400,6 +398,17 @@ DatabaseController.prototype.owningIds = function(className, key, relatedIds) { DatabaseController.prototype.reduceInRelation = function(className, query, schema) { // Search for an in-relation or equal-to-relation // Make it sequential for now, not sure of paralleization side effects + if (query['$or']) { + let ors = query['$or']; + return Promise.all(ors.map((aQuery, index) => { + return this.reduceInRelation(className, aQuery, schema).then((aQuery) => { + if (aQuery) { + query['$or'][index] = aQuery; + } + }) + })); + } + return Object.keys(query).reduce((promise, key) => { return promise.then(() => { if (query[key] && @@ -420,15 +429,25 @@ DatabaseController.prototype.reduceInRelation = function(className, query, schem delete query[key]; query.objectId = Object.assign({'$in': []}, query.objectId); query.objectId['$in'] = query.objectId['$in'].concat(ids); + return Promise.resolve(query); }); } }); - }, Promise.resolve()); + }, Promise.resolve()).then(() => { + return Promise.resolve(query); + }) }; // Modifies query so that it no longer has $relatedTo // Returns a promise that resolves when query is mutated DatabaseController.prototype.reduceRelationKeys = function(className, query) { + + if (query['$or']) { + return Promise.all(query['$or'].map((aQuery) => { + return this.reduceRelationKeys(className, aQuery); + })); + } + var relatedTo = query['$relatedTo']; if (relatedTo) { return this.relatedIds( From 43f014a47d780c5c7dce08c7ae3b9390c393afdb Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 2 Mar 2016 15:16:48 -0500 Subject: [PATCH 070/102] nits --- src/Controllers/DatabaseController.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 923e6e49..ecccbb24 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -396,15 +396,14 @@ DatabaseController.prototype.owningIds = function(className, key, relatedIds) { // equal-to-pointer constraints on relation fields. // Returns a promise that resolves when query is mutated DatabaseController.prototype.reduceInRelation = function(className, query, schema) { + // Search for an in-relation or equal-to-relation // Make it sequential for now, not sure of paralleization side effects if (query['$or']) { let ors = query['$or']; return Promise.all(ors.map((aQuery, index) => { return this.reduceInRelation(className, aQuery, schema).then((aQuery) => { - if (aQuery) { - query['$or'][index] = aQuery; - } + query['$or'][index] = aQuery; }) })); } @@ -416,7 +415,7 @@ DatabaseController.prototype.reduceInRelation = function(className, query, schem let t = schema.getExpectedType(className, key); let match = t ? t.match(/^relation<(.*)>$/) : false; if (!match) { - return; + return Promise.resolve(query); } let relatedClassName = match[1]; let relatedIds; @@ -455,7 +454,10 @@ DatabaseController.prototype.reduceRelationKeys = function(className, query) { relatedTo.key, relatedTo.object.objectId).then((ids) => { delete query['$relatedTo']; - query['objectId'] = {'$in': ids}; + query.objectId = query.objectId || {}; + let queryIn = query.objectId['$in'] || []; + queryIn = queryIn.concat(ids); + query['objectId'] = {'$in': queryIn}; return this.reduceRelationKeys(className, query); }); } From bfafcd4e879acabf5d26293ed27f8bdb74c059f9 Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Wed, 2 Mar 2016 14:14:12 -0800 Subject: [PATCH 071/102] Fix an installation deduplication bug --- spec/ParseAPI.spec.js | 28 ++++++++++++++++++++++++++++ src/RestWrite.js | 6 ++++-- src/Routers/ClassesRouter.js | 5 +---- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 2d0eed80..76fe2a35 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -853,4 +853,32 @@ describe('miscellaneous', function() { }); }); + it('dedupes an installation properly and returns updatedAt', (done) => { + let headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + let data = { + 'installationId': 'lkjsahdfkjhsdfkjhsdfkjhsdf', + 'deviceType': 'embedded' + }; + let requestOptions = { + headers: headers, + url: 'http://localhost:8378/1/installations', + body: JSON.stringify(data) + }; + request.post(requestOptions, (error, response, body) => { + expect(error).toBe(null); + let b = JSON.parse(body); + expect(typeof b.objectId).toEqual('string'); + request.post(requestOptions, (error, response, body) => { + expect(error).toBe(null); + let b = JSON.parse(body); + expect(typeof b.updatedAt).toEqual('string'); + done(); + }); + }); + }); + }); diff --git a/src/RestWrite.js b/src/RestWrite.js index 02815403..bfc6477f 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -782,8 +782,10 @@ RestWrite.prototype.runDatabaseOperation = function() { // Run an update return this.config.database.update( this.className, this.query, this.data, this.runOptions).then((resp) => { - this.response = resp; - this.response.updatedAt = this.updatedAt; + resp.updatedAt = this.updatedAt; + this.response = { + response: resp + }; }); } else { // Set the default ACL for the new _User diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 9742f5f9..57efa95d 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -85,10 +85,7 @@ export class ClassesRouter extends PromiseRouter { } handleUpdate(req) { - return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); + return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body); } handleDelete(req) { From 8eff44410f2358797bcc2099d9ce67c0ed7705a0 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 2 Mar 2016 16:43:44 -0500 Subject: [PATCH 072/102] Graceful fails on httpRequest --- spec/HTTPRequest.spec.js | 15 +++++++++++++++ src/cloud-code/httpRequest.js | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/spec/HTTPRequest.spec.js b/spec/HTTPRequest.spec.js index e599dd5d..ad4e289f 100644 --- a/spec/HTTPRequest.spec.js +++ b/spec/HTTPRequest.spec.js @@ -177,5 +177,20 @@ describe("httpRequest", () => { var result = httpRequest.encodeBody({"foo": "bar", "bar": "baz"}, {'X-Custom-Header': 'my-header'}); expect(result).toEqual({"foo": "bar", "bar": "baz"}); done(); + }); + + it("should fail gracefully", (done) => { + httpRequest({ + url: "http://not a good url", + success: function() { + fail("should not succeed"); + done(); + }, + error: function(error) { + expect(error).not.toBeUndefined(); + expect(error).not.toBeNull(); + done(); + } + }); }) }); diff --git a/src/cloud-code/httpRequest.js b/src/cloud-code/httpRequest.js index 2b5f9bff..4e8ff654 100644 --- a/src/cloud-code/httpRequest.js +++ b/src/cloud-code/httpRequest.js @@ -36,6 +36,12 @@ module.exports = function(options) { options.followRedirect = options.followRedirects == true; request(options, (error, response, body) => { + if (error) { + if (callbacks.error) { + callbacks.error(error); + } + return promise.reject(error); + } var httpResponse = {}; httpResponse.status = response.statusCode; httpResponse.headers = response.headers; @@ -46,7 +52,7 @@ module.exports = function(options) { httpResponse.data = JSON.parse(response.body); } catch (e) {} // Consider <200 && >= 400 as errors - if (error || httpResponse.status <200 || httpResponse.status >=400) { + if (httpResponse.status < 200 || httpResponse.status >= 400) { if (callbacks.error) { callbacks.error(httpResponse); } From 5219e0b1d8e3e3395a2952b071320b6b0974f9f2 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 1 Mar 2016 15:35:48 -0800 Subject: [PATCH 073/102] Touch up features endpoint --- spec/features.spec.js | 6 ++-- src/Routers/FeaturesRouter.js | 6 ++-- src/features.js | 52 ++++++++++++----------------------- 3 files changed, 23 insertions(+), 41 deletions(-) diff --git a/spec/features.spec.js b/spec/features.spec.js index 3ddd7a60..75cbcb22 100644 --- a/spec/features.spec.js +++ b/spec/features.spec.js @@ -5,7 +5,7 @@ const request = require("request"); describe('features', () => { it('set and get features', (done) => { - features.setFeature('users', { + features.setFeature('push', { testOption1: true, testOption2: false }); @@ -14,10 +14,10 @@ describe('features', () => { var expected = { testOption1: true, - testOption2: false + testOption2: false }; - expect(_features.users).toEqual(expected); + expect(_features.push).toEqual(expected); done(); }); diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index 05ccad5b..2205ceff 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -1,13 +1,11 @@ -import PromiseRouter from '../PromiseRouter'; +import PromiseRouter from '../PromiseRouter'; import * as middleware from "../middlewares"; import { getFeatures } from '../features'; export class FeaturesRouter extends PromiseRouter { mountRoutes() { this.route('GET','/features', middleware.promiseEnforceMasterKeyAccess, () => { - return { response: { - results: [getFeatures()] - } }; + return { response: getFeatures() }; }); } } diff --git a/src/features.js b/src/features.js index 1048b91c..6ff00095 100644 --- a/src/features.js +++ b/src/features.js @@ -14,26 +14,18 @@ * Features that use Adapters should specify the feature options through * the setFeature method in your controller and feature * Reference PushController and ParsePushAdapter as an example. - * + * * NOTE: When adding new endpoints be sure to update this list both (features, featureSwitch) - * if you are planning to have a UI consume it. + * if you are planning to have a UI consume it. */ // default features let features = { - analytics: { - slowQueries: false, - performanceAnalysis: false, - retentionAnalysis: false, - }, - classes: {}, - files: {}, - functions: {}, globalConfig: { - create: true, - read: true, - update: true, - delete: true, + create: false, + read: false, + update: false, + delete: false, }, hooks: { create: false, @@ -41,15 +33,19 @@ let features = { update: false, delete: false, }, - iapValidation: {}, - installations: {}, logs: { - info: true, - error: true, + level: false, + size: false, + order: false, + until: false, + from: false, + }, + push: { + immediatePush: false, + scheduledPush: false, + storedPushData: false, + pushAudiences: false, }, - publicAPI: {}, - push: {}, - roles: {}, schemas: { addField: true, removeField: true, @@ -58,27 +54,15 @@ let features = { clearAllDataFromClass: false, exportClass: false, }, - sessions: {}, - users: {}, }; // master switch for features let featuresSwitch = { - analytics: true, - classes: true, - files: true, - functions: true, globalConfig: true, hooks: true, - iapValidation: true, - installations: true, logs: true, - publicAPI: true, push: true, - roles: true, schemas: true, - sessions: true, - users: true, }; /** @@ -94,7 +78,7 @@ function setFeature(key, value) { function getFeatures() { let result = {}; Object.keys(features).forEach((key) => { - if (featuresSwitch[key]) { + if (featuresSwitch[key] && features[key]) { result[key] = features[key]; } }); From 81519852d1460f6dafd6d07f7ff0b8e78733843e Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 2 Mar 2016 11:35:45 -0800 Subject: [PATCH 074/102] Report Server Version so Dashboard can consume it --- spec/index.spec.js | 15 +++++++++++++++ src/features.js | 1 + src/index.js | 38 +++++++++++++++++++------------------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/spec/index.spec.js b/spec/index.spec.js index e3e2cb0b..d33a40eb 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -1,4 +1,5 @@ var request = require('request'); +var parseServerPackage = require('../package.json'); var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); describe('server', () => { @@ -153,4 +154,18 @@ describe('server', () => { })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); done(); }); + + it('can report the server version', done => { + request.get({ + url: 'http://localhost:8378/1/features', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }, (error, response, body) => { + expect(body.serverVersion).toEqual(parseServerPackage.version); + done(); + }) + }); }); diff --git a/src/features.js b/src/features.js index 6ff00095..e7cd5102 100644 --- a/src/features.js +++ b/src/features.js @@ -58,6 +58,7 @@ let features = { // master switch for features let featuresSwitch = { + serverVersion: true, globalConfig: true, hooks: true, logs: true, diff --git a/src/index.js b/src/index.js index b521a26f..076035f8 100644 --- a/src/index.js +++ b/src/index.js @@ -10,12 +10,13 @@ var batch = require('./batch'), multer = require('multer'), Parse = require('parse/node').Parse; +//import passwordReset from './passwordReset'; import cache from './cache'; import Config from './Config'; - +import parseServerPackage from '../package.json'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; -//import passwordReset from './passwordReset'; import PromiseRouter from './PromiseRouter'; +import requiredParameter from './requiredParameter'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FeaturesRouter } from './Routers/FeaturesRouter'; @@ -23,28 +24,27 @@ import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { FilesController } from './Controllers/FilesController'; import { FilesRouter } from './Routers/FilesRouter'; import { FunctionsRouter } from './Routers/FunctionsRouter'; -import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; -import { IAPValidationRouter } from './Routers/IAPValidationRouter'; -import { LogsRouter } from './Routers/LogsRouter'; -import { HooksRouter } from './Routers/HooksRouter'; -import { PublicAPIRouter } from './Routers/PublicAPIRouter'; import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; - +import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { HooksController } from './Controllers/HooksController'; -import { UserController } from './Controllers/UserController'; +import { HooksRouter } from './Routers/HooksRouter'; +import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; import { loadAdapter } from './Adapters/AdapterLoader'; import { LoggerController } from './Controllers/LoggerController'; +import { LogsRouter } from './Routers/LogsRouter'; +import { PublicAPIRouter } from './Routers/PublicAPIRouter'; import { PushController } from './Controllers/PushController'; import { PushRouter } from './Routers/PushRouter'; +import { randomString } from './cryptoUtils'; import { RolesRouter } from './Routers/RolesRouter'; import { S3Adapter } from './Adapters/Files/S3Adapter'; import { SchemasRouter } from './Routers/SchemasRouter'; import { SessionsRouter } from './Routers/SessionsRouter'; +import { setFeature } from './features'; +import { UserController } from './Controllers/UserController'; import { UsersRouter } from './Routers/UsersRouter'; -import requiredParameter from './requiredParameter'; -import { randomString } from './cryptoUtils'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -106,11 +106,11 @@ function ParseServer({ passwordResetSuccess: undefined }, }) { - + setFeature('serverVersion', parseServerPackage.version); // Initialize the node client SDK automatically Parse.initialize(appId, javascriptKey || 'unused', masterKey); Parse.serverURL = serverURL; - + if (databaseAdapter) { DatabaseAdapter.setAdapter(databaseAdapter); } @@ -144,7 +144,7 @@ function ParseServer({ const hooksController = new HooksController(appId, collectionPrefix); const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); - + cache.apps.set(appId, { masterKey: masterKey, serverURL: serverURL, @@ -173,7 +173,7 @@ function ParseServer({ if (process.env.FACEBOOK_APP_ID) { cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } - + Config.validate(cache.apps.get(appId)); // This app serves the Parse API directly. @@ -186,7 +186,7 @@ function ParseServer({ })); api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); - + // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { api.use('/', require('./testing-routes').router); @@ -215,17 +215,17 @@ function ParseServer({ if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { routers.push(new GlobalConfigRouter()); } - + if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { routers.push(new HooksRouter()); } - + let routes = routers.reduce((memo, router) => { return memo.concat(router.routes); }, []); let appRouter = new PromiseRouter(routes); - + batch.mountOnto(appRouter); api.use(appRouter.expressApp()); From 36202badf3850927e252c0266b0fee89fdf99bb5 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 2 Mar 2016 14:16:17 -0800 Subject: [PATCH 075/102] Return parse server version --- src/Routers/FeaturesRouter.js | 8 ++++++-- src/features.js | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js index 2205ceff..f0cdb3ea 100644 --- a/src/Routers/FeaturesRouter.js +++ b/src/Routers/FeaturesRouter.js @@ -1,11 +1,15 @@ +import { version } from '../../package.json'; import PromiseRouter from '../PromiseRouter'; import * as middleware from "../middlewares"; import { getFeatures } from '../features'; export class FeaturesRouter extends PromiseRouter { mountRoutes() { - this.route('GET','/features', middleware.promiseEnforceMasterKeyAccess, () => { - return { response: getFeatures() }; + this.route('GET','/serverInfo', middleware.promiseEnforceMasterKeyAccess, () => { + return { response: { + features: getFeatures(), + parseServerVersion: version, + } }; }); } } diff --git a/src/features.js b/src/features.js index e7cd5102..6ff00095 100644 --- a/src/features.js +++ b/src/features.js @@ -58,7 +58,6 @@ let features = { // master switch for features let featuresSwitch = { - serverVersion: true, globalConfig: true, hooks: true, logs: true, From a56fe0f7e66f2aaf416d8efa77f7b5157cc35a4c Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 2 Mar 2016 14:36:46 -0800 Subject: [PATCH 076/102] Fix tests --- spec/index.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/index.spec.js b/spec/index.spec.js index d33a40eb..56f9bb6b 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -157,14 +157,14 @@ describe('server', () => { it('can report the server version', done => { request.get({ - url: 'http://localhost:8378/1/features', + url: 'http://localhost:8378/1/serverInfo', headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-Master-Key': 'test', }, json: true, }, (error, response, body) => { - expect(body.serverVersion).toEqual(parseServerPackage.version); + expect(body.parseServerVersion).toEqual(parseServerPackage.version); done(); }) }); From ccc2a1a03f624349d6fffff61fbc361275913e77 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 2 Mar 2016 16:34:07 -0800 Subject: [PATCH 077/102] Fix tests again --- spec/features.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features.spec.js b/spec/features.spec.js index 75cbcb22..9d18adf7 100644 --- a/spec/features.spec.js +++ b/spec/features.spec.js @@ -29,7 +29,7 @@ describe('features', () => { it('requires the master key to get all schemas', done => { request.get({ - url: 'http://localhost:8378/1/features', + url: 'http://localhost:8378/1/serverInfo', json: true, headers: { 'X-Parse-Application-Id': 'test', From 6ddc77601c9daf02d24acbae060a7d3ac4600748 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 2 Mar 2016 19:38:42 -0500 Subject: [PATCH 078/102] Fixes mismatching behavior in including keys - When including a key, parse.com would set to undefined all not found pointer, not parse-server --- spec/ParseObject.spec.js | 52 +++++++++++++++++++++++++++++++++++++++- src/RestQuery.js | 2 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index 21920ae6..9f14abfe 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -1,3 +1,4 @@ +"use strict"; // This is a port of the test suite: // hungry/js/test/parse_object_test.js // @@ -1791,6 +1792,55 @@ describe('Parse.Object testing', () => { console.error(err); fail("should not fail"); done(); + }); + }); + + it('should have undefined includes when object is missing', (done) => { + let obj1 = new Parse.Object("AnObject"); + let obj2 = new Parse.Object("AnObject"); + + Parse.Object.saveAll([obj1, obj2]).then(() => { + obj1.set("obj", obj2); + // Save the pointer, delete the pointee + return obj1.save().then(() => { return obj2.destroy() }); + }).then(() => { + let query = new Parse.Query("AnObject"); + query.include("obj"); + return query.find(); + }).then((res) => { + expect(res.length).toBe(1); + expect(res[0].get("obj")).toBe(undefined); + let query = new Parse.Query("AnObject"); + return query.find(); + }).then((res) => { + expect(res.length).toBe(1); + expect(res[0].get("obj")).not.toBe(undefined); + return res[0].get("obj").fetch(); + }).then(() => { + fail("Should not fetch a deleted object"); + }, (err) => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); }) - }) + }); + + it('should have undefined includes when object is missing on deeper path', (done) => { + let obj1 = new Parse.Object("AnObject"); + let obj2 = new Parse.Object("AnObject"); + let obj3 = new Parse.Object("AnObject"); + Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { + obj1.set("obj", obj2); + obj2.set("obj", obj3); + // Save the pointer, delete the pointee + return Parse.Object.saveAll([obj1, obj2]).then(() => { return obj3.destroy() }); + }).then(() => { + let query = new Parse.Query("AnObject"); + query.include("obj.obj"); + return query.get(obj1.id); + }).then((res) => { + expect(res.get("obj")).not.toBe(undefined); + expect(res.get("obj").get("obj")).toBe(undefined); + done(); + }); + }); }); diff --git a/src/RestQuery.js b/src/RestQuery.js index b9385eb6..e68ec16f 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -509,7 +509,7 @@ function replacePointers(object, path, replace) { } if (path.length == 0) { - if (object.__type == 'Pointer' && replace[object.objectId]) { + if (object.__type == 'Pointer') { return replace[object.objectId]; } return object; From 5f9a8d87e536da12884ba7269eb5ca3e675d7d9a Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 2 Mar 2016 20:00:26 -0500 Subject: [PATCH 079/102] Nit for Object type --- src/Schema.js | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/Schema.js b/src/Schema.js index c8009bc9..3681d04e 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -704,38 +704,25 @@ function getObjectType(obj) { case 'Pointer' : if(obj.className) { return '*' + obj.className; - } else { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } - break; case 'File' : if(obj.name) { return 'file'; - } else { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } - break; case 'Date' : if(obj.iso) { return 'date'; - } else { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } - break; case 'GeoPoint' : if(obj.latitude != null && obj.longitude != null) { return 'geopoint'; - } else { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } - break; case 'Bytes' : - if(!obj.base64) { - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); + if(obj.base64) { + return; } - break; - default : - throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'invalid type: ' + obj.__type); + default: + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); } } if (obj['$ne']) { From 747482227dba6ba34494c4ea4415577df3d90a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ramos?= Date: Wed, 2 Mar 2016 17:29:11 -0800 Subject: [PATCH 080/102] Remove Deploy to GCP Button The GCP button links to a step by step guide, and is not really a quick-start way to deploy the app. It's fine to add it back in the `parse-server-example` repo itself, as well as it include it in our community links. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f81b9f1e..59939516 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Parse Server works with the Express web application framework. It can be added t We have provided a basic [Node.js application](https://github.com/ParsePlatform/parse-server-example) that uses the Parse Server module on Express and can be easily deployed using any of the following buttons: - + ### Parse Server + Express From d872f52eff7387ff4b26bb9e4bc1e2b34974263d Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Wed, 2 Mar 2016 20:28:00 -0500 Subject: [PATCH 081/102] backbone style is BAD! --- spec/ParseRelation.spec.js | 106 ++++++++++++-------------- src/Controllers/DatabaseController.js | 48 ++++++------ 2 files changed, 72 insertions(+), 82 deletions(-) diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index 550e4b33..a3fbe82c 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -254,46 +254,40 @@ describe('Parse.Relation testing', () => { childObjects.push(new ChildObject({x: i})); } - Parse.Object.saveAll(childObjects, { - success: function() { - var ParentObject = Parse.Object.extend("ParentObject"); - var parent = new ParentObject(); - parent.set("x", 4); - var relation = parent.relation("child"); - relation.add(childObjects[0]); - relation.add(childObjects[1]); - relation.add(childObjects[2]); - var parent2 = new ParentObject(); - parent2.set("x", 3); - var relation2 = parent2.relation("child"); - relation2.add(childObjects[4]); - relation2.add(childObjects[5]); - relation2.add(childObjects[6]); - - var otherChild2 = parent2.relation("otherChild"); - otherChild2.add(childObjects[0]); - otherChild2.add(childObjects[1]); - otherChild2.add(childObjects[2]); + Parse.Object.saveAll(childObjects).then(() => { + var ParentObject = Parse.Object.extend("ParentObject"); + var parent = new ParentObject(); + parent.set("x", 4); + var relation = parent.relation("child"); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + var parent2 = new ParentObject(); + parent2.set("x", 3); + var relation2 = parent2.relation("child"); + relation2.add(childObjects[4]); + relation2.add(childObjects[5]); + relation2.add(childObjects[6]); + + var otherChild2 = parent2.relation("otherChild"); + otherChild2.add(childObjects[0]); + otherChild2.add(childObjects[1]); + otherChild2.add(childObjects[2]); - var parents = []; - parents.push(parent); - parents.push(parent2); - Parse.Object.saveAll(parents, { - success: function() { - var query = new Parse.Query(ParentObject); - var objects = []; - objects.push(childObjects[0]); - query.containedIn("child", objects); - query.containedIn("otherChild", [childObjects[0]]); - query.find({ - success: function(list) { - equal(list.length, 2, "There should be 2 results"); - done(); - } - }); - } - }); - } + var parents = []; + parents.push(parent); + parents.push(parent2); + return Parse.Object.saveAll(parents); + }).then(() => { + var query = new Parse.Query(ParentObject); + var objects = []; + objects.push(childObjects[0]); + query.containedIn("child", objects); + query.containedIn("otherChild", [childObjects[0]]); + return query.find(); + }).then((list) => { + equal(list.length, 2, "There should be 2 results"); + done(); }); }); @@ -304,8 +298,7 @@ describe('Parse.Relation testing', () => { childObjects.push(new ChildObject({x: i})); } - Parse.Object.saveAll(childObjects, { - success: function() { + Parse.Object.saveAll(childObjects).then(() => { var ParentObject = Parse.Object.extend("ParentObject"); var parent = new ParentObject(); parent.set("x", 4); @@ -323,25 +316,22 @@ describe('Parse.Relation testing', () => { parents.push(parent2); parents.push(new ParentObject()); - Parse.Object.saveAll(parents, { - success: function() { - var query1 = new Parse.Query(ParentObject); - query1.containedIn("toChilds", [childObjects[2]]); - var query2 = new Parse.Query(ParentObject); - query2.equalTo("toChild", childObjects[2]); - var query = Parse.Query.or(query1, query2); - query.find({ - success: function(list) { - list = list.filter(function(item){ - return item.id == parent.id || item.id == parent2.id; - }); - equal(list.length, 2, "There should be 2 results"); - done(); - } + return Parse.Object.saveAll(parents).then(() => { + var query1 = new Parse.Query(ParentObject); + query1.containedIn("toChilds", [childObjects[2]]); + var query2 = new Parse.Query(ParentObject); + query2.equalTo("toChild", childObjects[2]); + var query = Parse.Query.or(query1, query2); + return query.find().then((list) => { + var objectIds = list.map(function(item){ + return item.id; }); - } + expect(objectIds.indexOf(parent.id)).not.toBe(-1); + expect(objectIds.indexOf(parent2.id)).not.toBe(-1); + equal(list.length, 2, "There should be 2 results"); + done(); + }); }); - } }); }); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index ecccbb24..d1c6dde5 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -408,31 +408,31 @@ DatabaseController.prototype.reduceInRelation = function(className, query, schem })); } - return Object.keys(query).reduce((promise, key) => { - return promise.then(() => { - if (query[key] && - (query[key]['$in'] || query[key].__type == 'Pointer')) { - let t = schema.getExpectedType(className, key); - let match = t ? t.match(/^relation<(.*)>$/) : false; - if (!match) { - return Promise.resolve(query); - } - let relatedClassName = match[1]; - let relatedIds; - if (query[key]['$in']) { - relatedIds = query[key]['$in'].map(r => r.objectId); - } else { - relatedIds = [query[key].objectId]; - } - return this.owningIds(className, key, relatedIds).then((ids) => { - delete query[key]; - query.objectId = Object.assign({'$in': []}, query.objectId); - query.objectId['$in'] = query.objectId['$in'].concat(ids); - return Promise.resolve(query); - }); + let promises = Object.keys(query).map((key) => { + if (query[key] && (query[key]['$in'] || query[key].__type == 'Pointer')) { + let t = schema.getExpectedType(className, key); + let match = t ? t.match(/^relation<(.*)>$/) : false; + if (!match) { + return Promise.resolve(query); } - }); - }, Promise.resolve()).then(() => { + let relatedClassName = match[1]; + let relatedIds; + if (query[key]['$in']) { + relatedIds = query[key]['$in'].map(r => r.objectId); + } else { + relatedIds = [query[key].objectId]; + } + return this.owningIds(className, key, relatedIds).then((ids) => { + delete query[key]; + query.objectId = Object.assign({'$in': []}, query.objectId); + query.objectId['$in'] = query.objectId['$in'].concat(ids); + return Promise.resolve(query); + }); + } + return Promise.resolve(query); + }) + + return Promise.all(promises).then(() => { return Promise.resolve(query); }) }; From ee3b37d4a25acc2d6b0d918df0c37ae454953dd1 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 1 Mar 2016 15:10:06 -0500 Subject: [PATCH 082/102] Adds support for badging on iOS --- spec/PushController.spec.js | 118 ++++++++++++++++++++++++++++++ src/Controllers/PushController.js | 52 ++++++++++++- 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 6c86b011..1821d1a3 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -1,5 +1,7 @@ var PushController = require('../src/Controllers/PushController').PushController; +var Config = require('../src/Config'); + describe('PushController', () => { it('can check valid master key of request', (done) => { // Make mock request @@ -127,5 +129,121 @@ describe('PushController', () => { }).toThrow(); done(); }); + + it('properly increment badges', (done) => { + + var payload = { + alert: "Hello World!", + badge: "Increment", + } + var installations = []; + while(installations.length != 10) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("badge", installations.length); + installation.set("originalBadge", installations.length); + installation.set("deviceType", "ios"); + installations.push(installation); + } + + while(installations.length != 15) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("deviceType", "android"); + installations.push(installation); + } + + var pushAdapter = { + send: function(body, installations) { + var badge = body.badge; + installations.forEach((installation) => { + if (installation.deviceType == "ios") { + expect(installation.badge).toEqual(badge); + expect(installation.originalBadge+1).toEqual(installation.badge); + } else { + expect(installation.badge).toBeUndefined(); + } + }) + return Promise.resolve({ + body: body, + installations: installations + }) + }, + getValidPushTypes: function() { + return ["ios", "android"]; + } + } + + var config = new Config(Parse.applicationId); + var auth = { + isMaster: true + } + + var pushController = new PushController(pushAdapter, Parse.applicationId); + Parse.Object.saveAll(installations).then((installations) => { + return pushController.sendPush(payload, {}, config, auth); + }).then((result) => { + done(); + }, (err) => { + console.error(err); + fail("should not fail"); + done(); + }); + + }); + + it('properly set badges to 1', (done) => { + + var payload = { + alert: "Hello World!", + badge: 1, + } + var installations = []; + while(installations.length != 10) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("badge", installations.length); + installation.set("originalBadge", installations.length); + installation.set("deviceType", "ios"); + installations.push(installation); + } + + var pushAdapter = { + send: function(body, installations) { + var badge = body.badge; + installations.forEach((installation) => { + expect(installation.badge).toEqual(badge); + expect(1).toEqual(installation.badge); + }) + return Promise.resolve({ + body: body, + installations: installations + }) + }, + getValidPushTypes: function() { + return ["ios"]; + } + } + + var config = new Config(Parse.applicationId); + var auth = { + isMaster: true + } + + var pushController = new PushController(pushAdapter, Parse.applicationId); + Parse.Object.saveAll(installations).then((installations) => { + return pushController.sendPush(payload, {}, config, auth); + }).then((result) => { + done(); + }, (err) => { + console.error(err); + fail("should not fail"); + done(); + }); + + }) }); diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 2e2134a6..55cb6095 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -3,9 +3,11 @@ import PromiseRouter from '../PromiseRouter'; import rest from '../rest'; import AdaptableController from './AdaptableController'; import { PushAdapter } from '../Adapters/Push/PushAdapter'; +import deepcopy from 'deepcopy'; import features from '../features'; const FEATURE_NAME = 'push'; +const UNSUPPORTED_BADGE_KEY = "unsupported"; export class PushController extends AdaptableController { @@ -58,7 +60,55 @@ export class PushController extends AdaptableController { body['expiration_time'] = PushController.getExpirationTime(body); // 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(config, auth, '_Installation', where).then(function(response) { + let badgeUpdate = Promise.resolve(); + + if (body.badge) { + var op = {}; + if (body.badge == "Increment") { + op = {'$inc': {'badge': 1}} + } else if (Number(body.badge)) { + op = {'$set': {'badge': body.badge } } + } else { + throw "Invalid value for badge, expected number or 'Increment'"; + } + let updateWhere = deepcopy(where); + + // Only on iOS! + updateWhere.deviceType = 'ios'; + + // TODO: @nlutsenko replace with better thing + badgeUpdate = config.database.rawCollection("_Installation").then((coll) => { + return coll.update(updateWhere, op, { multi: true }); + }); + } + + return badgeUpdate.then(() => { + return rest.find(config, auth, '_Installation', where) + }).then((response) => { + if (body.badge && body.badge == "Increment") { + // Collect the badges to reduce the # of calls + let badgeInstallationsMap = response.results.reduce((map, installation) => { + let badge = installation.badge; + if (installation.deviceType != "ios") { + badge = UNSUPPORTED_BADGE_KEY; + } + map[badge] = map[badge] || []; + map[badge].push(installation); + return map; + }, {}); + + // Map the on the badges count and return the send result + let promises = Object.keys(badgeInstallationsMap).map((badge) => { + let payload = deepcopy(body); + if (badge == UNSUPPORTED_BADGE_KEY) { + delete payload.badge; + } else { + payload.badge = parseInt(badge); + } + return pushAdapter.send(payload, badgeInstallationsMap[badge]); + }); + return Promise.all(promises); + } return pushAdapter.send(body, response.results); }); } From b778b314fbf4379bf840c481e648ba800340a04c Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 18:20:02 -0800 Subject: [PATCH 083/102] Flatten custom operations in request.object in afterSave hooks. --- spec/ParseAPI.spec.js | 40 ++++++++++++++++++++++++++++++++++++++++ src/RestWrite.js | 14 ++++++++------ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 76fe2a35..17bfc73b 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -692,6 +692,46 @@ describe('miscellaneous', function() { }); }); + it('afterSave flattens custom operations', done => { + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.afterSave('GameScore', function(req, res) { + let object = req.object; + expect(object instanceof Parse.Object).toBeTruthy(); + let originalObject = req.original; + if (triggerTime == 0) { + // Create + expect(object.get('yolo')).toEqual(1); + } else if (triggerTime == 1) { + // Update + expect(object.get('yolo')).toEqual(2); + // Check the originalObject + expect(originalObject.get('yolo')).toEqual(1); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + var obj = new Parse.Object('GameScore'); + obj.increment('yolo', 1); + obj.save().then(() => { + obj.increment('yolo', 1); + return obj.save(); + }).then(() => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + // Clear mock afterSave + Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); + done(); + }, error => { + console.error(error); + fail(error); + done(); + }); + }); + it('test cloud function error handling', (done) => { // Register a function which will fail Parse.Cloud.define('willFail', (req, res) => { diff --git a/src/RestWrite.js b/src/RestWrite.js index bfc6477f..1914f6c8 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -821,17 +821,19 @@ RestWrite.prototype.runAfterTrigger = function() { extraData.objectId = this.query.objectId; } - // Build the inflated object, different from beforeSave, originalData is not empty - // since developers can change data in the beforeSave. - var inflatedObject = triggers.inflate(extraData, this.originalData); - inflatedObject._finishFetch(this.data); // Build the original object, we only do this for a update write. - var originalObject; + let originalObject; if (this.query && this.query.objectId) { originalObject = triggers.inflate(extraData, this.originalData); } - triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, inflatedObject, originalObject, this.config.applicationId); + // Build the inflated object, different from beforeSave, originalData is not empty + // since developers can change data in the beforeSave. + let updatedObject = triggers.inflate(extraData, this.originalData); + updatedObject.set(Parse._decode(undefined, this.data)); + updatedObject._handleSaveResponse(this.response.response, this.response.status || 200); + + triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config.applicationId); }; // A helper to figure out what location this operation happens at. From f3b713826977f52644912a6a3b091a9ef6c18169 Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Wed, 2 Mar 2016 21:34:17 -0500 Subject: [PATCH 084/102] Fix : remove query count limit Remove the limit on query count. By default the limit is 100. If you try to get the count of a collection and the collection has more than 100 rows, the result is always 100. --- src/Controllers/DatabaseController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 91507ef8..f7166c67 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -501,6 +501,7 @@ DatabaseController.prototype.find = function(className, query, options = {}) { mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]}; } if (options.count) { + delete mongoOptions.limit; return collection.count(mongoWhere, mongoOptions); } else { return collection.find(mongoWhere, mongoOptions) From c4aac335e04d2d109c1d112369e771370cb0fbc8 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 18:23:00 -0800 Subject: [PATCH 085/102] Don't run any afterSave hooks if none are registered. --- src/RestWrite.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/RestWrite.js b/src/RestWrite.js index 1914f6c8..a907a61c 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -816,6 +816,15 @@ RestWrite.prototype.runDatabaseOperation = function() { // Returns nothing - doesn't wait for the trigger. RestWrite.prototype.runAfterTrigger = function() { + if (!this.response || !this.response.response) { + return; + } + + // Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class. + if (!triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId)) { + return Promise.resolve(); + } + var extraData = {className: this.className}; if (this.query && this.query.objectId) { extraData.objectId = this.query.objectId; From 358a7ae7f325324ee82abd6d901ebaaeadb75e6b Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 18:38:24 -0800 Subject: [PATCH 086/102] Fix missing 'let/var' in OneSignalPushAdapter.spec. --- spec/OneSignalPushAdapter.spec.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index a9b853d9..77b958c5 100644 --- a/spec/OneSignalPushAdapter.spec.js +++ b/spec/OneSignalPushAdapter.spec.js @@ -1,3 +1,4 @@ +'use strict'; var OneSignalPushAdapter = require('../src/Adapters/Push/OneSignalPushAdapter'); var classifyInstallations = require('../src/Adapters/Push/PushAdapterUtils').classifyInstallations; @@ -210,7 +211,7 @@ describe('OneSignalPushAdapter', () => { expect(write).toHaveBeenCalled(); // iOS - args = write.calls.first().args; + let args = write.calls.first().args; expect(args[0]).toEqual(JSON.stringify({ 'contents': { 'en':'Example content'}, 'content_available':true, @@ -219,7 +220,7 @@ describe('OneSignalPushAdapter', () => { 'app_id':'APP ID' })); - // Android + // Android args = write.calls.mostRecent().args; expect(args[0]).toEqual(JSON.stringify({ 'contents': { 'en':'Example content'}, From df4022060ece3021684d15e7bbf02b8b494aff54 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 2 Mar 2016 20:32:06 -0800 Subject: [PATCH 087/102] expiresAt should be a Date, not a string. Fixes #776 --- src/transform.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/transform.js b/src/transform.js index 8829f394..8d75b58e 100644 --- a/src/transform.js +++ b/src/transform.js @@ -644,7 +644,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals break; case 'expiresAt': case '_expiresAt': - restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])).iso; + restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])); break; default: // Check other auth data keys From befcd453a4d8dc3fe1767f25a56e935d6a5dbaac Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Wed, 2 Mar 2016 20:51:35 -0800 Subject: [PATCH 088/102] Add test --- spec/ParseUser.spec.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 58c9e8f3..a74644ae 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -54,7 +54,7 @@ describe('Parse.User testing', () => { success: function(user) { Parse.User.logIn("non_existent_user", "asdf3", expectError(Parse.Error.OBJECT_NOT_FOUND, done)); - }, + }, error: function(err) { console.error(err); fail("Shit should not fail"); @@ -1763,5 +1763,22 @@ describe('Parse.User testing', () => { }); }); + it("session expiresAt correct format", (done) => { + Parse.User.signUp("asdf", "zxcv", null, { + success: function(user) { + request.get({ + url: 'http://localhost:8378/1/classes/_Session', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, (error, response, body) => { + expect(body.results[0].expiresAt.__type).toEqual('Date'); + done(); + }) + } + }); + }); }); From c4fa3f0ee0a233e450cd6a1e17f6ab56742af2a5 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 20:59:25 -0800 Subject: [PATCH 089/102] Propagate installationId in all Cloud Code triggers. --- spec/ParseRole.spec.js | 2 +- src/Auth.js | 15 +++++++------ src/Controllers/UserController.js | 37 +++++++++++++++---------------- src/middlewares.js | 12 +++++----- src/triggers.js | 3 +-- 5 files changed, 34 insertions(+), 35 deletions(-) diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 02166ddd..8b4f989f 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -86,7 +86,7 @@ describe('Parse Role testing', () => { return createRole(rolesNames[2], anotherRole, user); }).then( (lastRole) => { roleIds[lastRole.get("name")] = lastRole.id; - var auth = new Auth(new Config("test") , true, user); + var auth = new Auth({ config: new Config("test"), isMaster: true, user: user }); return auth._loadRoles(); }) }).then( (roles) => { diff --git a/src/Auth.js b/src/Auth.js index f64480c8..0b285789 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -7,10 +7,11 @@ import cache from './cache'; // An Auth object tells you who is requesting something and whether // the master key was used. // userObject is a Parse.User and can be null if there's no user. -function Auth(config, isMaster, userObject) { +function Auth({ config, isMaster = false, user, installationId } = {}) { this.config = config; + this.installationId = installationId; this.isMaster = isMaster; - this.user = userObject; + this.user = user; // Assuming a users roles won't change during a single request, we'll // only load them once. @@ -33,19 +34,19 @@ Auth.prototype.couldUpdateUserId = function(userId) { // A helper to get a master-level Auth object function master(config) { - return new Auth(config, true, null); + return new Auth({ config, isMaster: true }); } // A helper to get a nobody-level Auth object function nobody(config) { - return new Auth(config, false, null); + return new Auth({ config, isMaster: false }); } // Returns a promise that resolves to an Auth object -var getAuthForSessionToken = function(config, sessionToken) { +var getAuthForSessionToken = function({ config, sessionToken, installationId } = {}) { var cachedUser = cache.users.get(sessionToken); if (cachedUser) { - return Promise.resolve(new Auth(config, false, cachedUser)); + return Promise.resolve(new Auth({ config, isMaster: false, installationId, user: cachedUser })); } var restOptions = { limit: 1, @@ -67,7 +68,7 @@ var getAuthForSessionToken = function(config, sessionToken) { obj['sessionToken'] = sessionToken; let userObject = Parse.Object.fromJSON(obj); cache.users.set(sessionToken, userObject); - return new Auth(config, false, userObject); + return new Auth({ config, isMaster: false, installationId, user: userObject }); }); }; diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 019f71c1..1581a659 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -22,23 +22,22 @@ export class UserController extends AdaptableController { } super.validateAdapter(adapter); } - + expectedAdapterType() { return MailAdapter; } - + get shouldVerifyEmails() { return this.options.verifyUserEmails; } - + setEmailVerifyToken(user) { if (this.shouldVerifyEmails) { user._email_verify_token = randomString(25); user.emailVerified = false; } } - - + verifyEmail(username, token) { if (!this.shouldVerifyEmails) { // Trying to verify email when not enabled @@ -62,7 +61,7 @@ export class UserController extends AdaptableController { return document; }); } - + checkResetTokenValidity(username, token) { return this.config.database.adaptiveCollection('_User') .then(collection => { @@ -78,7 +77,7 @@ export class UserController extends AdaptableController { return results[0]; }); } - + getUserIfNeeded(user) { if (user.username && user.email) { return Promise.resolve(user); @@ -90,7 +89,7 @@ export class UserController extends AdaptableController { if (user.email) { where.email = user.email; } - + var query = new RestQuery(this.config, Auth.master(this.config), '_User', where); return query.execute().then(function(result){ if (result.results.length != 1) { @@ -99,7 +98,7 @@ export class UserController extends AdaptableController { return result.results[0]; }) } - + sendVerificationEmail(user) { if (!this.shouldVerifyEmails) { @@ -122,7 +121,7 @@ export class UserController extends AdaptableController { } }); } - + setPasswordResetToken(email) { let token = randomString(25); return this.config.database @@ -142,11 +141,11 @@ export class UserController extends AdaptableController { // TODO: No adapter? return; } - + return this.setPasswordResetToken(email).then((user) => { const token = encodeURIComponent(user._perishable_token); - const username = encodeURIComponent(user.username); + const username = encodeURIComponent(user.username); let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` let options = { @@ -154,7 +153,7 @@ export class UserController extends AdaptableController { link: link, user: inflate('_User', user), }; - + if (this.adapter.sendPasswordResetEmail) { this.adapter.sendPasswordResetEmail(options); } else { @@ -164,13 +163,13 @@ export class UserController extends AdaptableController { return Promise.resolve(user); }); } - + updatePassword(username, token, password, config) { return this.checkResetTokenValidity(username, token).then(() => { return updateUserPassword(username, token, password, this.config); }); } - + defaultVerificationEmail({link, user, appName, }) { let text = "Hi,\n\n" + "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + @@ -180,9 +179,9 @@ export class UserController extends AdaptableController { let subject = 'Please verify your e-mail for ' + appName; return { text, to, subject }; } - + defaultResetPasswordEmail({link, user, appName, }) { - let text = "Hi,\n\n" + + let text = "Hi,\n\n" + "You requested to reset your password for " + appName + ".\n\n" + "" + "Click here to reset it:\n" + link; @@ -193,9 +192,9 @@ export class UserController extends AdaptableController { } // Mark this private -function updateUserPassword(username, token, password, config) { +function updateUserPassword(username, token, password, config) { var write = new RestWrite(config, Auth.master(config), '_User', { - username: username, + username: username, _perishable_token: token }, {password: password, _perishable_token: null }, undefined); return write.execute(); diff --git a/src/middlewares.js b/src/middlewares.js index 56ebdc1d..b3c2bf17 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -89,7 +89,7 @@ function handleParseHeaders(req, res, next) { var isMaster = (info.masterKey === req.config.masterKey); if (isMaster) { - req.auth = new auth.Auth(req.config, true); + req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true }); next(); return; } @@ -114,23 +114,23 @@ function handleParseHeaders(req, res, next) { } if (!info.sessionToken) { - req.auth = new auth.Auth(req.config, false); + req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: false }); next(); return; } - return auth.getAuthForSessionToken( - req.config, info.sessionToken).then((auth) => { + return auth.getAuthForSessionToken({ config: req.config, installationId: info.installationId, sessionToken: info.sessionToken }) + .then((auth) => { if (auth) { req.auth = auth; next(); } - }).catch((error) => { + }) + .catch((error) => { // TODO: Determine the correct error scenario. console.log(error); throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); }); - } var allowCrossDomain = function(req, res, next) { diff --git a/src/triggers.js b/src/triggers.js index 5220ce79..8622df87 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -110,12 +110,11 @@ export function getRequestObject(triggerType, auth, parseObject, originalParseOb if (auth.user) { request['user'] = auth.user; } - // TODO: Add installation to Auth? if (auth.installationId) { request['installationId'] = auth.installationId; } return request; -}; +} // Creates the response object, and uses the request object to pass data // The API will call this with REST API formatted objects, this will From edc77206601aa86242ed30566fdc4d40b41cbb16 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 20:59:45 -0800 Subject: [PATCH 090/102] Add tests that verify installationId in Cloud Code triggers. --- spec/ParseAPI.spec.js | 74 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 17bfc73b..42ac3491 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -749,6 +749,80 @@ describe('miscellaneous', function() { }); }); + it('test beforeSave/afterSave get installationId', function(done) { + let triggerTime = 0; + Parse.Cloud.beforeSave('GameScore', function(req, res) { + triggerTime++; + expect(triggerTime).toEqual(1); + expect(req.installationId).toEqual('yolo'); + res.success(); + }); + Parse.Cloud.afterSave('GameScore', function(req) { + triggerTime++; + expect(triggerTime).toEqual(2); + expect(req.installationId).toEqual('yolo'); + }); + + var headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore', + body: JSON.stringify({ a: 'b' }) + }, (error, response, body) => { + expect(error).toBe(null); + expect(triggerTime).toEqual(2); + + Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); + Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); + done(); + }); + }); + + it('test beforeDelete/afterDelete get installationId', function(done) { + let triggerTime = 0; + Parse.Cloud.beforeDelete('GameScore', function(req, res) { + triggerTime++; + expect(triggerTime).toEqual(1); + expect(req.installationId).toEqual('yolo'); + res.success(); + }); + Parse.Cloud.afterDelete('GameScore', function(req) { + triggerTime++; + expect(triggerTime).toEqual(2); + expect(req.installationId).toEqual('yolo'); + }); + + var headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore', + body: JSON.stringify({ a: 'b' }) + }, (error, response, body) => { + expect(error).toBe(null); + request.del({ + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore/' + JSON.parse(body).objectId + }, (error, response, body) => { + expect(error).toBe(null); + expect(triggerTime).toEqual(2); + + Parse.Cloud._removeHook("Triggers", "beforeDelete", "GameScore"); + Parse.Cloud._removeHook("Triggers", "afterDelete", "GameScore"); + done(); + }); + }); + }); + it('test cloud function query parameters', (done) => { Parse.Cloud.define('echoParams', (req, res) => { res.success(req.params); From 2afebf955f630dc7e49dcf3c1cc547cf49dd098d Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 21:33:33 -0800 Subject: [PATCH 091/102] Completely migrate SchemasRouter to new MongoCollection API. --- spec/schemas.spec.js | 4 ++-- src/Routers/SchemasRouter.js | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 24589a38..af5dabc1 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -175,7 +175,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: 103, - error: 'class HASALLPOD does not exist', + error: 'Class HASALLPOD does not exist.', }); done(); }); @@ -733,7 +733,7 @@ describe('schemas', () => { //Expect _SCHEMA entry to be gone. expect(response.statusCode).toEqual(400); expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('class MyOtherClass does not exist'); + expect(body.error).toEqual('Class MyOtherClass does not exist.'); done(); }); }); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 70b3157e..ec6e134b 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -45,16 +45,16 @@ function getAllSchemas(req) { } function getOneSchema(req) { - return req.config.database.collection('_SCHEMA') - .then(coll => coll.findOne({'_id': req.params.className})) - .then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)})) - .catch(() => ({ - status: 400, - response: { - code: 103, - error: 'class ' + req.params.className + ' does not exist', + const className = req.params.className; + return req.config.database.adaptiveCollection('_SCHEMA') + .then(collection => collection.find({ '_id': className }, { limit: 1 })) + .then(results => { + if (results.length != 1) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); } - })); + return results[0]; + }) + .then(schema => ({ response: mongoSchemaToSchemaAPIResponse(schema) })); } function createSchema(req) { @@ -70,7 +70,7 @@ function createSchema(req) { response: { code: 135, error: 'POST ' + req.path + ' needs class name', - }, + } }); } return req.config.database.loadSchema() From 99cb05ea1e7b3f1679511fe358af3263cf8c976b Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Wed, 2 Mar 2016 21:38:06 -0800 Subject: [PATCH 092/102] Use throws syntax for errors in SchemasRouter. --- spec/Schema.spec.js | 9 +++++---- spec/schemas.spec.js | 8 ++++---- src/Routers/SchemasRouter.js | 29 +++++++++-------------------- src/Schema.js | 25 +++++++++---------------- 4 files changed, 27 insertions(+), 44 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index d0e50953..0b95f5a4 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -188,8 +188,8 @@ describe('Schema', () => { foo: {type: 'String'} })) .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME) - expect(error.error).toEqual('class NewClass already exists'); + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual('Class NewClass already exists.'); done(); }); }); @@ -216,7 +216,7 @@ describe('Schema', () => { Promise.all([p1,p2]) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.error).toEqual('class NewClass already exists'); + expect(error.message).toEqual('Class NewClass already exists.'); done(); }); }); @@ -524,7 +524,8 @@ describe('Schema', () => { .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) .then(exists => { if (!exists) { - fail('Relation collection should exist after save.'); + fail('Relation collection ' + + 'should exist after save.'); } }) .then(() => config.database.loadSchema()) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index af5dabc1..f1d52fe5 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -224,7 +224,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, - error: 'class name mismatch between B and A', + error: 'Class name mismatch between B and A.', }); done(); }); @@ -240,7 +240,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: 135, - error: 'POST /schemas needs class name', + error: 'POST /schemas needs a class name.', }); done(); }) @@ -267,7 +267,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, - error: 'class A already exists', + error: 'Class A already exists.' }); done(); }); @@ -353,7 +353,7 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('class name mismatch between WrongClassName and NewClass'); + expect(body.error).toEqual('Class name mismatch between WrongClassName and NewClass.'); done(); }); }); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index ec6e134b..32196c25 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -8,13 +8,10 @@ import PromiseRouter from '../PromiseRouter'; import * as middleware from "../middlewares"; function classNameMismatchResponse(bodyClass, pathClass) { - return Promise.resolve({ - status: 400, - response: { - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class name mismatch between ' + bodyClass + ' and ' + pathClass, - } - }); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class name mismatch between ${bodyClass} and ${pathClass}.` + ); } function mongoSchemaAPIResponseFields(schema) { @@ -63,23 +60,15 @@ function createSchema(req) { return classNameMismatchResponse(req.body.className, req.params.className); } } - var className = req.params.className || req.body.className; + + const className = req.params.className || req.body.className; if (!className) { - return Promise.resolve({ - status: 400, - response: { - code: 135, - error: 'POST ' + req.path + ' needs class name', - } - }); + throw new Parse.Error(135, `POST ${req.path} needs a 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, - })); + .then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) })); } function modifySchema(req) { diff --git a/src/Schema.js b/src/Schema.js index 3681d04e..61ec16ae 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -333,29 +333,22 @@ function buildMergedSchemaObject(mongoObject, putRequest) { // enabled) before calling this function. Schema.prototype.addClassIfNotExists = function(className, fields) { if (this.data[className]) { - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class ' + className + ' already exists', - }); + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); } - var mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); - + let mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); if (!mongoObject.result) { return Promise.reject(mongoObject); } return this.collection.insertOne(mongoObject.result) - .then(result => result.ops[0]) - .catch(error => { - if (error.code === 11000) { //Mongo's duplicate key error - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class ' + className + ' already exists', - }); - } - return Promise.reject(error); - }); + .then(result => result.ops[0]) + .catch(error => { + if (error.code === 11000) { //Mongo's duplicate key error + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); + } + return Promise.reject(error); + }); }; // Returns a promise that resolves successfully to the new schema From 90a1e46905f8601418002d7e20137e2266fa7f98 Mon Sep 17 00:00:00 2001 From: Marco129 Date: Thu, 3 Mar 2016 16:37:30 +0800 Subject: [PATCH 093/102] Fix create system class with relation/pointer --- spec/Schema.spec.js | 37 +++++++++++++++++++++++++++++++++++++ src/Schema.js | 6 +++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index d0e50953..02447080 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -422,6 +422,43 @@ describe('Schema', () => { }); }); + it('creates non-custom classes which include relation field', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('_Role', {})) + .then(mongoObj => { + expect(mongoObj).toEqual({ + _id: '_Role', + createdAt: 'string', + updatedAt: 'string', + objectId: 'string', + name: 'string', + users: 'relation<_User>', + roles: 'relation<_Role>', + }); + done(); + }); + }); + + it('creates non-custom classes which include pointer field', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('_Session', {})) + .then(mongoObj => { + expect(mongoObj).toEqual({ + _id: '_Session', + createdAt: 'string', + updatedAt: 'string', + objectId: 'string', + restricted: 'boolean', + user: '*_User', + installationId: 'string', + sessionToken: 'string', + expiresAt: 'date', + createdWith: 'object' + }); + done(); + }); + }); + it('refuses to create two geopoints', done => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { diff --git a/src/Schema.js b/src/Schema.js index 3681d04e..b98519fd 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -48,13 +48,13 @@ var defaultColumns = { // 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'} + "users": {type:'Relation', targetClass:'_User'}, + "roles": {type:'Relation', targetClass:'_Role'} }, // The additional default columns for the _User collection (in addition to DefaultCols) _Session: { "restricted": {type:'Boolean'}, - "user": {type:'Pointer', className:'_User'}, + "user": {type:'Pointer', targetClass:'_User'}, "installationId": {type:'String'}, "sessionToken": {type:'String'}, "expiresAt": {type:'Date'}, From 6973de7910e22225d1e8d20379fa750d74752c67 Mon Sep 17 00:00:00 2001 From: Carmen Date: Thu, 3 Mar 2016 16:15:36 +0800 Subject: [PATCH 094/102] Fix replace query overwrite the existing query object. --- src/RestQuery.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index e68ec16f..2596344c 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -214,7 +214,11 @@ RestQuery.prototype.replaceInQuery = function() { }); } delete inQueryObject['$inQuery']; - inQueryObject['$in'] = values; + if (Array.isArray(inQueryObject['$in'])) { + inQueryObject['$in'] = inQueryObject['$in'].concat(values); + } else { + inQueryObject['$in'] = values; + } // Recurse to repeat return this.replaceInQuery(); @@ -251,7 +255,11 @@ RestQuery.prototype.replaceNotInQuery = function() { }); } delete notInQueryObject['$notInQuery']; - notInQueryObject['$nin'] = values; + if (Array.isArray(notInQueryObject['$nin'])) { + notInQueryObject['$nin'] = notInQueryObject['$nin'].concat(values); + } else { + notInQueryObject['$nin'] = values; + } // Recurse to repeat return this.replaceNotInQuery(); @@ -290,7 +298,11 @@ RestQuery.prototype.replaceSelect = function() { values.push(result[selectValue.key]); } delete selectObject['$select']; - selectObject['$in'] = values; + if (Array.isArray(selectObject['$in'])) { + selectObject['$in'] = selectObject['$in'].concat(values); + } else { + selectObject['$in'] = values; + } // Keep replacing $select clauses return this.replaceSelect(); @@ -329,7 +341,11 @@ RestQuery.prototype.replaceDontSelect = function() { values.push(result[dontSelectValue.key]); } delete dontSelectObject['$dontSelect']; - dontSelectObject['$nin'] = values; + if (Array.isArray(dontSelectObject['$nin'])) { + dontSelectObject['$nin'] = dontSelectObject['$nin'].concat(values); + } else { + dontSelectObject['$nin'] = values; + } // Keep replacing $dontSelect clauses return this.replaceDontSelect(); From f157538bfdda7f826b2848b7db443d3a47cfcd21 Mon Sep 17 00:00:00 2001 From: Marco129 Date: Thu, 3 Mar 2016 18:44:30 +0800 Subject: [PATCH 095/102] Fix delete schema when actual collection does not exist --- spec/schemas.spec.js | 71 ++++++++++++++++++++++++++++++++++++ src/Routers/SchemasRouter.js | 22 +++++++---- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 24589a38..6bb65023 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -744,4 +744,75 @@ describe('schemas', () => { done(); }); }); + + it('deletes schema when actual collection does not exist', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassForDelete' + } + }, (error, response, body) => { + expect(error).toEqual(null); + expect(response.body.className).toEqual('NewClassForDelete'); + request.del({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual({}); + config.database.loadSchema().then(schema => { + schema.hasClass('NewClassForDelete').then(exist => { + expect(exist).toEqual(false); + done(); + }); + }) + }); + }); + }); + + it('deletes schema when actual collection exists', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassForDelete' + } + }, (error, response, body) => { + expect(error).toEqual(null); + expect(response.body.className).toEqual('NewClassForDelete'); + request.post({ + url: 'http://localhost:8378/1/classes/NewClassForDelete', + headers: restKeyHeaders, + json: true + }, (error, response, body) => { + expect(error).toEqual(null); + expect(typeof response.body.objectId).toEqual('string'); + request.del({ + url: 'http://localhost:8378/1/classes/NewClassForDelete/' + response.body.objectId, + headers: restKeyHeaders, + json: true, + }, (error, response, body) => { + expect(error).toEqual(null); + request.del({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual({}); + config.database.loadSchema().then(schema => { + schema.hasClass('NewClassForDelete').then(exist => { + expect(exist).toEqual(false); + done(); + }); + }); + }); + }); + }); + }); + }); }); diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index 70b3157e..1dfff74b 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -151,14 +151,20 @@ function deleteSchema(req) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, Schema.invalidClassNameMessage(req.params.className)); } - return req.config.database.adaptiveCollection(req.params.className) - .then(collection => { - return collection.count() - .then(count => { - if (count > 0) { - throw new Parse.Error(255, `Class ${req.params.className} is not empty, contains ${count} objects, cannot drop schema.`); - } - return collection.drop(); + return req.config.database.collectionExists(req.params.className) + .then(exist => { + if (!exist) { + return Promise.resolve(); + } + return req.config.database.adaptiveCollection(req.params.className) + .then(collection => { + return collection.count() + .then(count => { + if (count > 0) { + throw new Parse.Error(255, `Class ${req.params.className} is not empty, contains ${count} objects, cannot drop schema.`); + } + return collection.drop(); + }) }) }) .then(() => { From 907a05c57a47b94b9d8ea85c20d4f0b6c55738b9 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Thu, 3 Mar 2016 08:40:30 -0500 Subject: [PATCH 096/102] Sanitize objectId in - if objectId is set in query, move it to $in array - refactors to addInObjectIdsIds --- spec/ParseQuery.spec.js | 56 +++++++++++++++++++++++++++ spec/ParseRelation.spec.js | 39 +++++++++++++++++++ src/Controllers/DatabaseController.js | 21 ++++++---- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index f5b6dc1a..9171d12c 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -2088,4 +2088,60 @@ describe('Parse.Query testing', () => { console.log(error); }); }); + + // #371 + it('should properly interpret a query', (done) => { + var query = new Parse.Query("C1"); + var auxQuery = new Parse.Query("C1"); + query.matchesKeyInQuery("A1", "A2", auxQuery); + query.include("A3"); + query.include("A2"); + query.find().then((result) => { + done(); + }, (err) => { + console.error(err); + fail("should not failt"); + done(); + }) + }); + + it('should properly interpret a query', (done) => { + var user = new Parse.User(); + user.set("username", "foo"); + user.set("password", "bar"); + return user.save().then( (user) => { + var objIdQuery = new Parse.Query("_User").equalTo("objectId", user.id); + var blockedUserQuery = user.relation("blockedUsers").query(); + + var aResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); + aResponseQuery.equalTo("userA", user); + aResponseQuery.equalTo("userAResponse", 1); + + var bResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); + bResponseQuery.equalTo("userB", user); + bResponseQuery.equalTo("userBResponse", 1); + + var matchOr = Parse.Query.or(aResponseQuery, bResponseQuery); + var matchRelationshipA = new Parse.Query("_User"); + matchRelationshipA.matchesKeyInQuery("objectId", "userAObjectId", matchOr); + var matchRelationshipB = new Parse.Query("_User"); + matchRelationshipB.matchesKeyInQuery("objectId", "userBObjectId", matchOr); + + + var orQuery = Parse.Query.or(objIdQuery, blockedUserQuery, matchRelationshipA, matchRelationshipB); + var query = new Parse.Query("_User"); + query.doesNotMatchQuery("objectId", orQuery); + return query.find(); + }).then((res) => { + done(); + done(); + }, (err) => { + console.error(err); + fail("should not fail"); + done(); + }); + + + }); + }); diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index a3fbe82c..e1416ecb 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -291,6 +291,44 @@ describe('Parse.Relation testing', () => { }); }); + it("query on pointer and relation fields with equal", (done) => { + var ChildObject = Parse.Object.extend("ChildObject"); + var childObjects = []; + for (var i = 0; i < 10; i++) { + childObjects.push(new ChildObject({x: i})); + } + + Parse.Object.saveAll(childObjects).then(() => { + var ParentObject = Parse.Object.extend("ParentObject"); + var parent = new ParentObject(); + parent.set("x", 4); + var relation = parent.relation("toChilds"); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + + var parent2 = new ParentObject(); + parent2.set("x", 3); + parent2.set("toChild", childObjects[2]); + + var parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + return Parse.Object.saveAll(parents).then(() => { + var query = new Parse.Query(ParentObject); + query.equalTo("objectId", parent.id); + query.equalTo("toChilds", childObjects[2]); + + return query.find().then((list) => { + equal(list.length, 1, "There should be 1 result"); + done(); + }); + }); + }); + }); + it("or queries on pointer and relation fields", (done) => { var ChildObject = Parse.Object.extend("ChildObject"); var childObjects = []; @@ -335,6 +373,7 @@ describe('Parse.Relation testing', () => { }); }); + it("Get query on relation using un-fetched parent object", (done) => { // Setup data model var Wheel = Parse.Object.extend('Wheel'); diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index ec0fb1f6..683b9be0 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -417,9 +417,8 @@ DatabaseController.prototype.reduceInRelation = function(className, query, schem relatedIds = [query[key].objectId]; } return this.owningIds(className, key, relatedIds).then((ids) => { - delete query[key]; - query.objectId = Object.assign({'$in': []}, query.objectId); - query.objectId['$in'] = query.objectId['$in'].concat(ids); + delete query[key]; + this.addInObjectIdsIds(ids, query); return Promise.resolve(query); }); } @@ -448,15 +447,23 @@ DatabaseController.prototype.reduceRelationKeys = function(className, query) { relatedTo.key, relatedTo.object.objectId).then((ids) => { delete query['$relatedTo']; - query.objectId = query.objectId || {}; - let queryIn = query.objectId['$in'] || []; - queryIn = queryIn.concat(ids); - query['objectId'] = {'$in': queryIn}; + this.addInObjectIdsIds(ids, query); return this.reduceRelationKeys(className, query); }); } }; +DatabaseController.prototype.addInObjectIdsIds = function(ids, query) { + if (typeof query.objectId == 'string') { + query.objectId = {'$in': [query.objectId]}; + } + query.objectId = query.objectId || {}; + let queryIn = [].concat(query.objectId['$in'] || [], ids || []); + // make a set and spread to remove duplicates + query.objectId = {'$in': [...new Set(queryIn)]}; + return query; +} + // Runs a query on the database. // Returns a promise that resolves to a list of items. // Options: From e64b6860c1eb91eb38e43d10cce5d418e9e6203d Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Thu, 3 Mar 2016 11:40:57 -0500 Subject: [PATCH 097/102] Allows to pass no where in $select clause - This is causing a bug for iOS SDK when no query constraints are set --- src/RestQuery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index e68ec16f..5cd2df52 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -271,11 +271,11 @@ RestQuery.prototype.replaceSelect = function() { // The select value must have precisely two keys - query and key var selectValue = selectObject['$select']; + // iOS SDK don't send where if not set, let it pass if (!selectValue.query || !selectValue.key || typeof selectValue.query !== 'object' || !selectValue.query.className || - !selectValue.query.where || Object.keys(selectValue).length !== 2) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $select'); From 0e39b3b0e73b18474cc112468a4b63e6f6e91684 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Thu, 3 Mar 2016 08:38:14 -0500 Subject: [PATCH 098/102] Adds optional COVERAGE renames COVERAGE to CODE_COVERAGE Updates env in .travis.yaml --- .travis.yml | 7 +++++-- package.json | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 27a7b149..53dc9acc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,11 @@ language: node_js node_js: - "4.3" env: - - MONGODB_VERSION=2.6.11 - - MONGODB_VERSION=3.0.8 + global: + - CODE_COVERAGE=1 + matrix: + - MONGODB_VERSION=2.6.11 + - MONGODB_VERSION=3.0.8 cache: directories: - $HOME/.mongodb/versions/downloads diff --git a/package.json b/package.json index 560e8e99..a31014a6 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "dev": "npm run build && bin/dev", "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", - "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 $([[ $CODE_COVERAGE == 1 ]] && echo './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 50735c4cbbbb7ba66d4f18db82597626787b978d Mon Sep 17 00:00:00 2001 From: Marco129 Date: Fri, 4 Mar 2016 03:06:53 +0800 Subject: [PATCH 099/102] Fix update system schema --- spec/Schema.spec.js | 17 +++++++++++++++++ src/Schema.js | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 02447080..2e3e960f 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -655,4 +655,21 @@ describe('Schema', () => { }); done(); }); + + it('ignore default field when merge with system class', done => { + expect(Schema.buildMergedSchemaObject({ + _id: '_User', + username: 'string', + password: 'string', + authData: 'object', + email: 'string', + emailVerified: 'boolean' + },{ + authData: {type: 'string'}, + customField: {type: 'string'}, + })).toEqual({ + customField: {type: 'string'} + }); + done(); + }); }); diff --git a/src/Schema.js b/src/Schema.js index b98519fd..bb6b17c0 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -308,8 +308,12 @@ function mongoFieldTypeToSchemaAPIType(type) { // is done in mongoSchemaFromFieldsAndClassName. function buildMergedSchemaObject(mongoObject, putRequest) { var newSchema = {}; + let sysSchemaField = Object.keys(defaultColumns).indexOf(mongoObject._id) === -1 ? [] : Object.keys(defaultColumns[mongoObject._id]); for (var oldField in mongoObject) { if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') { + if (sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1) { + continue; + } var fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete' if (!fieldIsDeleted) { newSchema[oldField] = mongoFieldTypeToSchemaAPIType(mongoObject[oldField]); @@ -318,6 +322,9 @@ function buildMergedSchemaObject(mongoObject, putRequest) { } for (var newField in putRequest) { if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') { + if (sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1) { + continue; + } newSchema[newField] = putRequest[newField]; } } From ec8529ada7a68e95f38d53fc2c58548c6f93be71 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Thu, 3 Mar 2016 14:32:15 -0500 Subject: [PATCH 100/102] fixes missing coverage with sh script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a31014a6..89a22e85 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "dev": "npm run build && bin/dev", "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", - "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node $([[ $CODE_COVERAGE == 1 ]] && echo './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 $(if [ \"$CODE_COVERAGE\" = \"1\" ]; then echo './node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**'; fi;) ./node_modules/jasmine/bin/jasmine.js", "posttest": "mongodb-runner stop", "start": "./bin/parse-server", "prepublish": "npm run build" From 4d7c87b104ffeb46fb8ab8b84ba7bce1f4ea43e4 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Thu, 3 Mar 2016 10:29:37 -0800 Subject: [PATCH 101/102] Release and Changelog for 2.1.4 --- CHANGELOG.md | 42 +++++++++++++++++++++++++++++++++++++++--- package.json | 2 +- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2c5580..fb02c932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ ## Parse Server Changelog +### 2.1.4 (3/3/2016) + +* New: serverInfo endpoint that returns server version and info about the server's features +* Improvement: Add support for badges on iOS +* Improvement: Improve failure handling in cloud code http requests +* Improvement: Add support for queries on pointers and relations +* Improvement: Add support for multiple $in clauses in a query +* Improvement: Add allowClientClassCreation config option +* Improvement: Allow atomically setting subdocument keys +* Improvement: Allow arbitrarily deeply nested roles +* Improvement: Set proper content-type in S3 File Adapter +* Improvement: S3 adapter auto-creates buckets +* Improvement: Better error messages for many errors +* Performance: Improved algorithm for validating client keys +* Experimental: Parse Hooks and Hooks API +* Experimental: Email verification and password reset emails +* Experimental: Improve compatability of logs feature with Parse.com +* Fix: Fix for attempting to delete missing classes via schemas API +* Fix: Allow creation of system classes via schemas API +* Fix: Allow missing where cause in $select +* Fix: Improve handling of invalid object ids +* Fix: Replace query overwriting existing query +* Fix: Propagate installationId in cloud code triggers +* Fix: Session expiresAt is now a Date instead of a string +* Fix: Fix count queries +* Fix: Disallow _Role objects without names or without ACL +* Fix: Better handling of invalid types submitted +* Fix: beforeSave will not be triggered for attempts to save with invalid authData +* Fix: Fix duplicate device token issues on Android +* Fix: Allow empty authData on signup +* Fix: Allow Master Key Headers (CORS) +* Fix: Fix bugs if JavaScript key was not provided in server configuration +* Fix: Parse Files on objects can now be stored without URLs +* Fix: allow both objectId or installationId when modifying installation +* Fix: Command line works better when not given options + ### 2.1.3 (2/24/2016) * Feature: Add initial support for in-app purchases @@ -8,7 +44,7 @@ * Performance: Faster saves if not using beforeSave triggers * Fix: Send session token in response to current user endpoint * Fix: Remove triggers for _Session collection -* Fix: Improve compatability of Cloud Code beforeSave hook for newly created object +* Fix: Improve compatability of cloud code beforeSave hook for newly created object * Fix: ACL creation for master key only objects * Fix: Allow uploading files without Content-Type * Fix: Add features to http requrest to match Parse.com @@ -41,7 +77,7 @@ * Feature: Support for logs, extensible via Log Adapter * Feature: New Push Adapter for sending push notifications through OneSignal * Feature: Tighter default security for Users -* Feature: Pass parameters to Cloud Code in query string +* Feature: Pass parameters to cloud code in query string * Feature: Disable anonymous users via configuration. * Experimental: Schemas API support for PUT operations * Fix: Prevent installation ID from being added to User @@ -58,7 +94,7 @@ ### 2.0.8 (2/11/2016) * Add: support for Android and iOS push notifications -* Experimental: Cloud Code validation hooks (can mark as non-experimental after we have docs) +* 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 diff --git a/package.json b/package.json index 560e8e99..5d27af3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "2.1.3", + "version": "2.1.4", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { From 8363122c653a66cda35455ee1b09e22853feac10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ramos?= Date: Thu, 3 Mar 2016 13:14:33 -0800 Subject: [PATCH 102/102] Remove duplicated instructions --- README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/README.md b/README.md index f81b9f1e..c1a33dc6 100644 --- a/README.md +++ b/README.md @@ -65,22 +65,6 @@ The default port is 1337, to use a different port set the PORT environment varia The standalone Parse Server can be configured using [environment variables](#configuration). -Please refer to the [configuration section](#configuration) or help; - -To get more help for running the parse-server standalone, you can run: - -`$ npm start -- --help` - -The standalone API server supports loading a configuration file in JSON format: - -`$ npm start -- path/to/your/config.json` - -The default port is 1337, to use a different port set the `--port` option: - -`$ npm start -- --port=8080 path/to/your/config.json` - -Please refer to the [configuration section](#configuration) or help; - You can also install Parse Server globally: `$ npm install -g parse-server`