From cef5a5fabfeed1dc3356e736fd1869c174600b0d Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 9 Feb 2016 11:26:46 -0800 Subject: [PATCH 01/29] First part of schemas PUT --- spec/schemas.spec.js | 99 +++++++++++++++++++++++++++++++++++++++++++- src/schemas.js | 69 +++++++++++++++++++++--------- 2 files changed, 148 insertions(+), 20 deletions(-) diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 68ac31c9..907c5f65 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -94,7 +94,7 @@ describe('schemas', () => { headers: restKeyHeaders, }, (error, response, body) => { expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('unauthorized'); + expect(body.error).toEqual('master key not specified'); done(); }); }); @@ -318,4 +318,101 @@ describe('schemas', () => { done(); }); }); + + it('requires the master key to modify schemas', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, (error, response, body) => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: noAuthHeaders, + json: true, + body: {}, + }, (error, response, body) => { + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized'); + done(); + }); + }); + }); + + it('rejects class name mis-matches', done => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {className: 'WrongClassName'} + }, (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'); + }); + }); + + it('refuses to add fields to non-existent classes', done => { + request.put({ + url: 'http://localhost:8378/1/schemas/NoClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newField: {type: 'String'} + } + } + }, (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'); + done(); + }); + }); + + it('put with no modifications returns all fields', done => { + var obj = hasAllPODobject(); + obj.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/HasAllPOD' + headers: masterKeyHeaders, + json: true, + body: {}, + }, (error, response, body) => { + expect(body).toEqual(plainOldDataSchema); + done(); + }); + }); + }); + + it('lets you add fields', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: {}, + }, (error, response, body) => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newField: {type: 'String'} + } + } + }, (error, response, body) => { + expect(body).toEqual('blah'); + request.get({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(body).toEqual('blah'); + done(); + }); + }); + }) + }); }); diff --git a/src/schemas.js b/src/schemas.js index 837224ab..c17e9407 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -7,6 +7,23 @@ var express = require('express'), var router = new PromiseRouter(); +function masterKeyRequiredResponse() { + return Promise.resolve({ + status: 401, + response: {error: 'master key not specified'}, + }) +} + +function classNameMismatchResponse(bodyClass, pathClass) { + return Promise.resolve({ + status: 400, + response: { + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class name mismatch between ' + bodyClass + ' and ' + pathClass, + } + }); +} + function mongoFieldTypeToSchemaAPIType(type) { if (type[0] === '*') { return { @@ -55,10 +72,7 @@ function mongoSchemaToSchemaAPIResponse(schema) { function getAllSchemas(req) { if (!req.auth.isMaster) { - return Promise.resolve({ - status: 401, - response: {error: 'master key not specified'}, - }); + return masterKeyRequiredResponse(); } return req.config.database.collection('_SCHEMA') .then(coll => coll.find({}).toArray()) @@ -69,10 +83,7 @@ function getAllSchemas(req) { function getOneSchema(req) { if (!req.auth.isMaster) { - return Promise.resolve({ - status: 401, - response: {error: 'unauthorized'}, - }); + return masterKeyRequiredResponse(); } return req.config.database.collection('_SCHEMA') .then(coll => coll.findOne({'_id': req.params.className})) @@ -88,20 +99,11 @@ function getOneSchema(req) { function createSchema(req) { if (!req.auth.isMaster) { - return Promise.resolve({ - status: 401, - response: {error: 'master key not specified'}, - }); + return masterKeyRequiredResponse(); } if (req.params.className && req.body.className) { if (req.params.className != req.body.className) { - return Promise.resolve({ - status: 400, - response: { - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class name mismatch between ' + req.body.className + ' and ' + req.params.className, - }, - }); + return classNameMismatchResponse(req.body.className, req.params.className); } } var className = req.params.className || req.body.className; @@ -123,9 +125,38 @@ 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.path.className); + } + + if (!req.body.fields) { + req.body.fields = {}; + } + + return req.config.database.loadSchema() + .then(schema => schema.hasClass(req.params.className)) + .then(hasClass => { + if (!hasClass) { + return Promise.resolve({ + status: 400, + response: { + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class ' + req.params.className + ' does not exist', + } + }); + } + }); +} + router.route('GET', '/schemas', getAllSchemas); router.route('GET', '/schemas/:className', getOneSchema); router.route('POST', '/schemas', createSchema); router.route('POST', '/schemas/:className', createSchema); +router.route('PUT', '/schemas/:className', modifySchema); module.exports = router; From 90a4ac70acf780c5e328a9e6335c4d76e526111d Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Thu, 11 Feb 2016 20:32:31 -0500 Subject: [PATCH 02/29] Fix session token issue In _User collection a field _session_token is present and if you fetch the user data form server, this field override the sessionToken saved in your browser. If you don't fetch the user, all request to server contain the right sessionToken and if you fetch the user data from the server, all next requests will contain the wrong sessionToken come form the _session_token in user data fetched. --- src/RestQuery.js | 5 +++++ src/Routers/ClassesRouter.js | 5 +++++ src/users.js | 3 +++ 3 files changed, 13 insertions(+) diff --git a/src/RestQuery.js b/src/RestQuery.js index 91ebe536..7cf8074f 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -415,6 +415,11 @@ function includePath(config, auth, response, path) { for (var obj of includeResponse.results) { obj.__type = 'Object'; obj.className = className; + + if(className == "_User"){ + delete obj.sessionToken; + } + replace[obj.objectId] = obj; } var resp = { diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 11666b20..a49d6d4a 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -51,6 +51,11 @@ export class ClassesRouter { if (!response.results || response.results.length == 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } + + if(req.params.className === "_User"){ + delete response.results[0].sessionToken; + } + return { response: response.results[0] }; }); } diff --git a/src/users.js b/src/users.js index 4205c666..9484ee64 100644 --- a/src/users.js +++ b/src/users.js @@ -133,6 +133,9 @@ function handleGet(req) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } else { + if(req.params.className === "_User"){ + delete response.results[0].sessionToken; + } return {response: response.results[0]}; } }); From 56552537a1b6af6d6cb1680510aa75e287604446 Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Fri, 12 Feb 2016 08:15:58 -0500 Subject: [PATCH 03/29] Add test to the session token hasn't changed --- spec/ParseUser.spec.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 368bea22..787a8ecb 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1358,6 +1358,25 @@ describe('Parse.User testing', () => { }); }); + it('retrieve user data from fetch, make sure the session token hasn\'t changed', (done) => { + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + var currentSessionToken = ""; + Parse.Promise.as().then(function() { + return user.signUp(); + }).then(function(){ + currentSessionToken = user.getSessionToken(); + return user.fetch(); + }).then(function(u){ + expect(currentSessionToken).toEqual(u.getSessionToken()); + done(); + }, function(error) { + ok(false, error); + done(); + }) + }); + it('user save should fail with invalid email', (done) => { var user = new Parse.User(); user.set('username', 'teste'); From 66efd0d0305cac1484b456e78878443c6e368741 Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Fri, 12 Feb 2016 15:57:37 -0500 Subject: [PATCH 04/29] Rebase --- src/users.js | 215 --------------------------------------------------- 1 file changed, 215 deletions(-) delete mode 100644 src/users.js diff --git a/src/users.js b/src/users.js deleted file mode 100644 index 9484ee64..00000000 --- a/src/users.js +++ /dev/null @@ -1,215 +0,0 @@ -// These methods handle the User-related routes. - -var mongodb = require('mongodb'); -var Parse = require('parse/node').Parse; -var rack = require('hat').rack(); - -var Auth = require('./Auth'); -var passwordCrypto = require('./password'); -var facebook = require('./facebook'); -var PromiseRouter = require('./PromiseRouter'); -var rest = require('./rest'); -var RestWrite = require('./RestWrite'); -var deepcopy = require('deepcopy'); - -var router = new PromiseRouter(); - -// Returns a promise for a {status, response, location} object. -function handleCreate(req) { - var data = deepcopy(req.body); - data.installationId = req.info.installationId; - return rest.create(req.config, req.auth, - '_User', data); -} - -// Returns a promise for a {response} object. -function handleLogIn(req) { - - // Use query parameters instead if provided in url - if (!req.body.username && req.query.username) { - req.body = req.query; - } - - // TODO: use the right error codes / descriptions. - if (!req.body.username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'username is required.'); - } - if (!req.body.password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required.'); - } - - var user; - return req.database.find('_User', {username: req.body.username}) - .then((results) => { - if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - user = results[0]; - return passwordCrypto.compare(req.body.password, user.password); - }).then((correct) => { - if (!correct) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - var token = 'r:' + rack(); - user.sessionToken = token; - delete user.password; - - req.config.filesController.expandFilesInObject(req.config, user); - - var expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: user.objectId - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false, - expiresAt: Parse._encode(expiresAt) - }; - - if (req.info.installationId) { - sessionData.installationId = req.info.installationId - } - - var create = new RestWrite(req.config, Auth.master(req.config), - '_Session', null, sessionData); - return create.execute(); - }).then(() => { - return {response: user}; - }); -} - -// Returns a promise that resolves to a {response} object. -// TODO: share code with ClassesRouter.js -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - if (req.body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); - } - - return rest.find(req.config, req.auth, - '_User', req.body.where, options) - .then((response) => { - return {response: response}; - }); - -} - -// Returns a promise for a {response} object. -function handleGet(req) { - return rest.find(req.config, req.auth, '_User', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - if(req.params.className === "_User"){ - delete response.results[0].sessionToken; - } - return {response: response.results[0]}; - } - }); -} - -function handleMe(req) { - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken}, - {include: 'user'}) - .then((response) => { - if (!response.results || response.results.length == 0 || - !response.results[0].user) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - var user = response.results[0].user; - return {response: user}; - } - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - req.params.className, req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleLogOut(req) { - var success = {response: {}}; - if (req.info && req.info.sessionToken) { - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken} - ).then((records) => { - if (records.results && records.results.length) { - return rest.del(req.config, Auth.master(req.config), '_Session', - records.results[0].objectId - ).then(() => { - return Promise.resolve(success); - }); - } - return Promise.resolve(success); - }); - } - return Promise.resolve(success); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_User', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); -} - -router.route('POST', '/users', handleCreate); -router.route('GET', '/login', handleLogIn); -router.route('POST', '/logout', handleLogOut); -router.route('GET', '/users/me', handleMe); -router.route('GET', '/users/:objectId', handleGet); -router.route('PUT', '/users/:objectId', handleUpdate); -router.route('GET', '/users', handleFind); -router.route('DELETE', '/users/:objectId', handleDelete); - -router.route('POST', '/requestPasswordReset', notImplementedYet); - -module.exports = router; From 9248f9af6d006243617e34d0a184072e804bb42d Mon Sep 17 00:00:00 2001 From: "Peter J. Shin" Date: Fri, 12 Feb 2016 17:05:57 -0800 Subject: [PATCH 05/29] Fixing comment. --- src/Adapters/Logger/FileLoggerAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js index 4edc4122..9e308242 100644 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -3,7 +3,7 @@ // Wrapper around Winston logging library with custom query // // expected log entry to be in the shape of: -// {"level":"info","message":"{ '0': 'Your Message' }","timestamp":"2016-02-04T05:59:27.412Z"} +// {"level":"info","message":"Your Message","timestamp":"2016-02-04T05:59:27.412Z"} // import { LoggerAdapter } from './LoggerAdapter'; import winston from 'winston'; From a1b24da3e7e73341d004cddb371c67cf7250c36b Mon Sep 17 00:00:00 2001 From: George Deglin Date: Fri, 12 Feb 2016 18:32:39 -0800 Subject: [PATCH 06/29] WIP Add OneSignal Adapter --- spec/OneSignalPushAdapter.spec.js | 1 + spec/ParseInstallation.spec.js | 20 --- src/Adapters/Push/OneSignalPushAdapter.js | 209 ++++++++++++++++++++++ src/RestWrite.js | 5 - 4 files changed, 210 insertions(+), 25 deletions(-) create mode 100644 spec/OneSignalPushAdapter.spec.js create mode 100644 src/Adapters/Push/OneSignalPushAdapter.js diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js new file mode 100644 index 00000000..8371f3a2 --- /dev/null +++ b/spec/OneSignalPushAdapter.spec.js @@ -0,0 +1 @@ +//todo \ No newline at end of file diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 91bb9a23..cef6871e 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -133,26 +133,6 @@ describe('Installations', () => { }); }); - it('fails for android with device token', (done) => { - var installId = '12345678-abcd-abcd-abcd-123456789abc'; - var t = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306'; - var device = 'android'; - var input = { - 'installationId': installId, - 'deviceType': device, - 'deviceToken': t, - 'channels': ['foo', 'bar'] - }; - rest.create(config, auth.nobody(config), '_Installation', input) - .then(() => { - fail('Should not have been able to create an Installation.'); - done(); - }).catch((error) => { - expect(error.code).toEqual(114); - done(); - }); - }); - it('fails for android with missing type', (done) => { var installId = '12345678-abcd-abcd-abcd-123456789abc'; var input = { diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js new file mode 100644 index 00000000..73f61ce8 --- /dev/null +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -0,0 +1,209 @@ +"use strict"; +// ParsePushAdapter is the default implementation of +// PushAdapter, it uses GCM for android push and APNS +// for ios push. + +const Parse = require('parse/node').Parse; + +function OneSignalPushAdapter(pushConfig) { + this.https = require('https'); + + this.validPushTypes = ['ios', 'gcm','android']; + this.senderMap = {}; + + pushConfig = pushConfig || {}; + this.OneSignalConfig = {}; + this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; + this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; + + this.senderMap['ios'] = this.sendToAPNS.bind(this); + this.senderMap['gcm'] = this.sendToGCM.bind(this); + this.senderMap['android'] = this.sendToGCM.bind(this); +} + +/** + * Get an array of valid push types. + * @returns {Array} An array of valid push types + */ +OneSignalPushAdapter.prototype.getValidPushTypes = function() { + return this.validPushTypes; +} + +OneSignalPushAdapter.prototype.send = function(data, installations) { + let deviceMap = classifyInstallation(installations, this.validPushTypes); + + let sendPromises = []; + for (let pushType in deviceMap) { + let sender = this.senderMap[pushType]; + if (!sender) { + console.log('Can not find sender for push type %s, %j', pushType, data); + continue; + } + let devices = deviceMap[pushType]; + + if(devices.length > 0) { + sendPromises.push(sender(data, devices)); + } + } + return Parse.Promise.when(sendPromises); +} + +OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { + + let post = {}; + if(data['badge']) { + if(data['badge'] == "Increment") { + post['ios_badgeType'] = 'Increase'; + post['ios_badgeCount'] = 1; + } else { + post['ios_badgeType'] = 'SetTo'; + post['ios_badgeCount'] = data['badge']; + } + delete data['badge']; + } + if(data['alert']) { + post['contents'] = {en: data['alert']}; + delete data['alert']; + } + if(data['sound']) { + post['ios_sound'] = data['sound']; + delete data['sound']; + } + if(data['content-available'] == 1) { + post['content_available'] = true; + delete data['content-available']; + } + post['data'] = data; + + let promise = new Parse.Promise(); + + var chunk = 2000 // OneSignal can process 2000 devices at a time + var tokenlength=tokens.length; + var offset = 0 + // handle onesignal response. Start next batch if there's not an error. + let handleResponse = function(err) { + if (err) { + return promise.reject(err, tokens.slice(i, tokens.length())); + } + + if(offset => tokenlength) { + promise.resolve() + } else { + this.sendNext(); + } + }.bind(this) + + this.sendNext = function() { + post['include_android_reg_ids'] = tokens.slice(offset,offset+chunk); + offset+=chunk; + this.sendToOneSignal(post, handleResponse); + }.bind(this) + + this.sendNext() + + return promise; +} + +OneSignalPushAdapter.prototype.sendToGCM = function(data,tokens) { + let post = {}; + + if(data['alert']) { + post['contents'] = {en: data['alert']}; + delete data['alert']; + } + if(data['title']) { + post['title'] = {en: data['title']}; + delete data['title']; + } + if(data['uri']) { + post['url'] = data['uri']; + } + + post['data'] = data; + + let promise = new Parse.Promise(); + + var chunk = 2000 // OneSignal can process 2000 devices at a time + var tokenlength=tokens.length; + var offset = 0 + // handle onesignal response. Start next batch if there's not an error. + let handleResponse = function(err) { + if (err) { + return promise.reject(err, tokens.slice(i, tokens.length())); + } + + if(offset => tokenlength) { + promise.resolve() + } else { + this.sendNext(); + } + }.bind(this); + + this.sendNext = function() { + post['include_android_reg_ids'] = tokens.slice(offset,offset+chunk);; + offset+=chunk; + this.sendToOneSignal(post, handleResponse); + }.bind(this); + + this.sendNext(); + return promise; +} + + +OneSignalPushAdapter.prototype.sendToOneSignal = function(data, cb) { + let headers = { + "Content-Type": "application/json", + "Authorization": "Basic "+this.OneSignalConfig['apiKey'] + }; + let options = { + host: "onesignal.com", + port: 443, + path: "/api/v1/notifications", + method: "POST", + headers: headers + }; + data['app_id'] = this.OneSignalConfig['appId']; + + let request = this.https.request(options, function(res) { + cb(null); + }); + request.on('error', function(e) { + cb(e); + }); + console.log(data); + request.write(JSON.stringify(data)) + request.end(); +} +/**g + * Classify the device token of installations based on its device type. + * @param {Object} installations An array of installations + * @param {Array} validPushTypes An array of valid push types(string) + * @returns {Object} A map whose key is device type and value is an array of device + */ +function classifyInstallation(installations, validPushTypes) { + // Init deviceTokenMap, create a empty array for each valid pushType + let deviceMap = {}; + for (let validPushType of validPushTypes) { + deviceMap[validPushType] = []; + } + for (let installation of installations) { + // No deviceToken, ignore + if (!installation.deviceToken) { + continue; + } + let pushType = installation.deviceType; + if (deviceMap[pushType]) { + deviceMap[pushType].push({ + deviceToken: installation.deviceToken + }); + } else { + console.log('Unknown push type from installation %j', installation); + } + } + return deviceMap; +} + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + OneSignalPushAdapter.classifyInstallation = classifyInstallation; +} +module.exports = OneSignalPushAdapter; diff --git a/src/RestWrite.js b/src/RestWrite.js index 2a2b0ed2..b6c8f126 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -485,11 +485,6 @@ RestWrite.prototype.handleInstallation = function() { this.data.installationId = this.data.installationId.toLowerCase(); } - if (this.data.deviceToken && this.data.deviceType == 'android') { - throw new Parse.Error(114, - 'deviceToken may not be set for deviceType android'); - } - var promise = Promise.resolve(); if (this.query && this.query.objectId) { From ce4f13f3bf95a52af807fcde832f78f9006356d9 Mon Sep 17 00:00:00 2001 From: Lewuathe Date: Sat, 13 Feb 2016 17:44:43 +0900 Subject: [PATCH 07/29] Limit 100 records as default --- spec/ParseAPI.spec.js | 16 ++++++++++++++++ src/Routers/ClassesRouter.js | 2 ++ 2 files changed, 18 insertions(+) diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 8670bdd2..fa11a307 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -129,6 +129,22 @@ describe('miscellaneous', function() { }); }); + it('query without limit get default 100 records', function(done) { + var objects = []; + for (var i = 0; i < 150; i++) { + objects.push(new TestObject({name: 'name' + i})); + } + Parse.Object.saveAll(objects).then(() => { + return new Parse.Query(TestObject).find(); + }).then((results) => { + expect(results.length).toEqual(100); + done(); + }, (error) => { + fail(error); + done(); + }); + }); + it('basic saveAll', function(done) { var alpha = new TestObject({ letter: 'alpha' }); var beta = new TestObject({ letter: 'beta' }); diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index a49d6d4a..c9fe9c48 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -12,6 +12,8 @@ export class ClassesRouter { } if (body.limit) { options.limit = Number(body.limit); + } else { + options.limit = Number(100); } if (body.order) { options.order = String(body.order); From a5deb60c79492e4b198c307719b51adb4bd789da Mon Sep 17 00:00:00 2001 From: merowinger92 Date: Sat, 13 Feb 2016 15:38:58 +0100 Subject: [PATCH 08/29] Update parse-server --- bin/parse-server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/parse-server b/bin/parse-server index 7ec17927..8d0104e4 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1,6 +1,6 @@ #!/usr/bin/env node var express = require('express'); -var ParseServer = require("../lib/index").ParseServer; +var ParseServer = require("../src/index").ParseServer; var app = express(); From 5a628516a688d668d2d54b7f459e332c746d9c84 Mon Sep 17 00:00:00 2001 From: George Deglin Date: Sat, 13 Feb 2016 18:26:17 -0800 Subject: [PATCH 09/29] OneSignalPushAdapter now correctly sends APNS and GCM notifications and handles errors --- src/Adapters/Push/OneSignalPushAdapter.js | 55 ++++++++++++++++------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index 73f61ce8..aac8bdb0 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -30,6 +30,7 @@ OneSignalPushAdapter.prototype.getValidPushTypes = function() { } OneSignalPushAdapter.prototype.send = function(data, installations) { + console.log("Sending notification to "+installations.length+" devices.") let deviceMap = classifyInstallation(installations, this.validPushTypes); let sendPromises = []; @@ -50,7 +51,9 @@ OneSignalPushAdapter.prototype.send = function(data, installations) { OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { - let post = {}; + data= data['data'] + + var post = {}; if(data['badge']) { if(data['badge'] == "Increment") { post['ios_badgeType'] = 'Increase'; @@ -81,12 +84,12 @@ OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { var tokenlength=tokens.length; var offset = 0 // handle onesignal response. Start next batch if there's not an error. - let handleResponse = function(err) { - if (err) { - return promise.reject(err, tokens.slice(i, tokens.length())); + let handleResponse = function(wasSuccessful) { + if (!wasSuccessful) { + return promise.reject("OneSignal Error"); } - if(offset => tokenlength) { + if(offset >= tokenlength) { promise.resolve() } else { this.sendNext(); @@ -94,7 +97,10 @@ OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { }.bind(this) this.sendNext = function() { - post['include_android_reg_ids'] = tokens.slice(offset,offset+chunk); + post['include_ios_tokens'] = []; + tokens.slice(offset,offset+chunk).forEach(function(i) { + post['include_ios_tokens'].push(i['deviceToken']) + }) offset+=chunk; this.sendToOneSignal(post, handleResponse); }.bind(this) @@ -105,7 +111,9 @@ OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { } OneSignalPushAdapter.prototype.sendToGCM = function(data,tokens) { - let post = {}; + data= data['data'] + + var post = {}; if(data['alert']) { post['contents'] = {en: data['alert']}; @@ -127,23 +135,27 @@ OneSignalPushAdapter.prototype.sendToGCM = function(data,tokens) { var tokenlength=tokens.length; var offset = 0 // handle onesignal response. Start next batch if there's not an error. - let handleResponse = function(err) { - if (err) { - return promise.reject(err, tokens.slice(i, tokens.length())); + let handleResponse = function(wasSuccessful) { + if (!wasSuccessful) { + return promise.reject("OneSIgnal Error"); } - if(offset => tokenlength) { + if(offset >= tokenlength) { promise.resolve() } else { this.sendNext(); } }.bind(this); - this.sendNext = function() { - post['include_android_reg_ids'] = tokens.slice(offset,offset+chunk);; + this.sendNext = function() { + post['include_android_reg_ids'] = []; + tokens.slice(offset,offset+chunk).forEach(function(i) { + post['include_android_reg_ids'].push(i['deviceToken']) + }) offset+=chunk; this.sendToOneSignal(post, handleResponse); - }.bind(this); + }.bind(this) + this.sendNext(); return promise; @@ -165,12 +177,21 @@ OneSignalPushAdapter.prototype.sendToOneSignal = function(data, cb) { data['app_id'] = this.OneSignalConfig['appId']; let request = this.https.request(options, function(res) { - cb(null); + if(res.statusCode < 299) { + cb(true); + } else { + console.log('OneSignal Error'); + res.on('data', function(chunk) { + console.log(chunk.toString()) + }); + cb(false) + } }); request.on('error', function(e) { - cb(e); + console.log("Error connecting to OneSignal") + console.log(e); + cb(false); }); - console.log(data); request.write(JSON.stringify(data)) request.end(); } From 2ff6eff63a35e7c1e863ff4b7028648b6ff14792 Mon Sep 17 00:00:00 2001 From: George Deglin Date: Sat, 13 Feb 2016 23:38:39 -0800 Subject: [PATCH 10/29] Added OneSignalPushAdapter spec and fix a bug in OneSignalPushAdapter. --- spec/OneSignalPushAdapter.spec.js | 235 +++++++++++++++++++++- src/Adapters/Push/OneSignalPushAdapter.js | 8 +- 2 files changed, 238 insertions(+), 5 deletions(-) diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index 8371f3a2..e7f31768 100644 --- a/spec/OneSignalPushAdapter.spec.js +++ b/spec/OneSignalPushAdapter.spec.js @@ -1 +1,234 @@ -//todo \ No newline at end of file + +var OneSignalPushAdapter = require('../src/Adapters/Push/OneSignalPushAdapter'); + +describe('OneSignalPushAdapter', () => { + it('can be initialized', (done) => { + // Make mock config + var pushConfig = { + oneSignalAppId:"APP ID", + oneSignalApiKey:"API KEY" + }; + + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); + + var senderMap = oneSignalPushAdapter.senderMap; + + expect(senderMap.ios instanceof Function).toBe(true); + expect(senderMap.android instanceof Function).toBe(true); + done(); + }); + + it('can get valid push types', (done) => { + var oneSignalPushAdapter = new OneSignalPushAdapter(); + + expect(oneSignalPushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); + done(); + }); + + it('can classify installation', (done) => { + // Mock installations + var validPushTypes = ['ios', 'android']; + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + + var deviceMap = OneSignalPushAdapter.classifyInstallation(installations, validPushTypes); + expect(deviceMap['android']).toEqual([makeDevice('androidToken')]); + expect(deviceMap['ios']).toEqual([makeDevice('iosToken')]); + expect(deviceMap['win']).toBe(undefined); + done(); + }); + + + it('can send push notifications', (done) => { + var oneSignalPushAdapter = new OneSignalPushAdapter(); + + // Mock android ios senders + var androidSender = jasmine.createSpy('send') + var iosSender = jasmine.createSpy('send') + + var senderMap = { + ios: iosSender, + android: androidSender + }; + oneSignalPushAdapter.senderMap = senderMap; + + // Mock installations + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + var data = {}; + + oneSignalPushAdapter.send(data, installations); + // Check android sender + expect(androidSender).toHaveBeenCalled(); + var args = androidSender.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('androidToken') + ]); + // Check ios sender + expect(iosSender).toHaveBeenCalled(); + args = iosSender.calls.first().args; + expect(args[0]).toEqual(data); + expect(args[1]).toEqual([ + makeDevice('iosToken') + ]); + done(); + }); + + it("can send iOS notifications", (done) => { + var oneSignalPushAdapter = new OneSignalPushAdapter(); + var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); + oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; + + oneSignalPushAdapter.sendToAPNS({'data':{ + 'badge': 1, + 'alert': "Example content", + 'sound': "Example sound", + 'content-available': 1, + 'misc-data': 'Example Data' + }},[{'deviceToken':'iosToken1'},{'deviceToken':'iosToken2'}]) + + expect(sendToOneSignal).toHaveBeenCalled(); + var args = sendToOneSignal.calls.first().args; + expect(args[0]).toEqual({ + 'ios_badgeType':'SetTo', + 'ios_badgeCount':1, + 'contents': { 'en':'Example content'}, + 'ios_sound': 'Example sound', + 'content_available':true, + 'data':{'misc-data':'Example Data'}, + 'include_ios_tokens':['iosToken1','iosToken2'] + }) + done(); + }); + + it("can send Android notifications", (done) => { + var oneSignalPushAdapter = new OneSignalPushAdapter(); + var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); + oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; + + oneSignalPushAdapter.sendToGCM({'data':{ + 'title': 'Example title', + 'alert': 'Example content', + 'misc-data': 'Example Data' + }},[{'deviceToken':'androidToken1'},{'deviceToken':'androidToken2'}]) + + expect(sendToOneSignal).toHaveBeenCalled(); + var args = sendToOneSignal.calls.first().args; + expect(args[0]).toEqual({ + 'contents': { 'en':'Example content'}, + 'title': {'en':'Example title'}, + 'data':{'misc-data':'Example Data'}, + 'include_android_reg_ids': ['androidToken1','androidToken2'] + }) + done(); + }); + + 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'); + oneSignalPushAdapter.https = { + 'request': function(a,b) { + return { + 'end':function(){}, + 'on':function(a,b){}, + 'write':write + } + } + }; + + var installations = [ + { + deviceType: 'android', + deviceToken: 'androidToken' + }, + { + deviceType: 'ios', + deviceToken: 'iosToken' + }, + { + deviceType: 'win', + deviceToken: 'winToken' + }, + { + deviceType: 'android', + deviceToken: undefined + } + ]; + + oneSignalPushAdapter.send({'data':{ + 'title': 'Example title', + 'alert': 'Example content', + 'content-available':1, + 'misc-data': 'Example Data' + }}, installations); + + expect(write).toHaveBeenCalled(); + + // iOS + args = write.calls.first().args; + expect(args[0]).toEqual(JSON.stringify({ + 'contents': { 'en':'Example content'}, + 'content_available':true, + 'data':{'title':'Example title','misc-data':'Example Data'}, + 'include_ios_tokens':['iosToken'], + 'app_id':'APP ID' + })); + + // Android + args = write.calls.mostRecent().args; + expect(args[0]).toEqual(JSON.stringify({ + 'contents': { 'en':'Example content'}, + 'title': {'en':'Example title'}, + 'data':{"content-available":1,'misc-data':'Example Data'}, + 'include_android_reg_ids':['androidToken'], + 'app_id':'APP ID' + })); + + done(); + }); + + function makeDevice(deviceToken, appIdentifier) { + return { + deviceToken: deviceToken + }; + } + +}); diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index aac8bdb0..59a660f9 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -4,11 +4,12 @@ // for ios push. const Parse = require('parse/node').Parse; +var deepcopy = require('deepcopy'); function OneSignalPushAdapter(pushConfig) { this.https = require('https'); - this.validPushTypes = ['ios', 'gcm','android']; + this.validPushTypes = ['ios', 'android']; this.senderMap = {}; pushConfig = pushConfig || {}; @@ -17,7 +18,6 @@ function OneSignalPushAdapter(pushConfig) { this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; this.senderMap['ios'] = this.sendToAPNS.bind(this); - this.senderMap['gcm'] = this.sendToGCM.bind(this); this.senderMap['android'] = this.sendToGCM.bind(this); } @@ -51,7 +51,7 @@ OneSignalPushAdapter.prototype.send = function(data, installations) { OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { - data= data['data'] + data= deepcopy(data['data']); var post = {}; if(data['badge']) { @@ -111,7 +111,7 @@ OneSignalPushAdapter.prototype.sendToAPNS = function(data,tokens) { } OneSignalPushAdapter.prototype.sendToGCM = function(data,tokens) { - data= data['data'] + data= deepcopy(data['data']); var post = {}; From 25708992729c858ad6243e6816609ad267c2e505 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Fri, 12 Feb 2016 20:07:42 -0800 Subject: [PATCH 11/29] Fix invalid s3 files url. --- src/Adapters/Files/S3Adapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index b33b66f1..dd6c8d0d 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -88,7 +88,7 @@ export class S3Adapter extends FilesAdapter { // The location is the direct S3 link if the option is set, otherwise we serve the file through parse-server getFileLocation(config, filename) { if (this._directAccess) { - return ('https://' + this.bucket + '._s3Client.amazonaws.com' + '/' + this._bucketPrefix + filename); + return `https://${this._bucket}.s3.amazonaws.com/${this._bucketPrefix + filename}`; } return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); } From a7093d33bb8900e37bcd4dc5908c7067b5003d5b Mon Sep 17 00:00:00 2001 From: Alex Kwan Date: Mon, 15 Feb 2016 20:14:50 +0800 Subject: [PATCH 12/29] fix multiple include --- .DS_Store | Bin 0 -> 6148 bytes src/RestQuery.js | 4 ++++ 2 files changed, 4 insertions(+) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..942763772fff983b6f5e286a29ac4d1597536a1d GIT binary patch literal 6148 zcmeHK%Wl&^6g@*MHMB+TlBJidBJl?%jawo?i<+<}k2Y#+;;JnOcK!@LiVxs#%DHz2 zG2^%_s|wvK%^Z7t&fK{ti6;ZV4Zkd31N#8_Y=YS~yN;0O(l+8*7!k44InGew28(f3 zFE-dVU=ElAPt5`U0vKS98C&%Gomcgoi>Z>%9R`DaMqQ`nKux_!WhM2XB z1H8o>9ODQj-r z5#FbHr7G8ts@mcj$IM+a*92vY&FT{NE}fn+&lUR>Km7-?NOg&B3m@poRI^MmNwYXs zldzxDgOdCa-V;0O*DAh6Rb;IGn(NV>B>jfDnAamO>^FZ|y4nub@^(?p>$>7rWg!j( zBlo7sS8I8 z=j9yF1Fs0!c=U3(aQJW`vkNB_r`b7wu5`G0){ + this.include = this.include.slice(1); + return this.handleInclude(); } + return pathResponse; }; From ea07eb506d8f08d6e2a467414872d9d3d3f975ee Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 15 Feb 2016 10:12:53 -0500 Subject: [PATCH 13/29] Clears session on password change - Fixes error type when passing an invalid session token --- spec/ParseUser.spec.js | 4 +++- src/RestWrite.js | 2 +- src/Routers/UsersRouter.js | 8 +++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 787a8ecb..e173f9f8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1606,7 +1606,9 @@ describe('Parse.User testing', () => { }).then(function(newUser) { fail('Session should have been invalidated'); done(); - }, function() { + }, function(err) { + expect(err.code).toBe(209); + expect(err.message).toBe('invalid session token'); done(); }); }); diff --git a/src/RestWrite.js b/src/RestWrite.js index 2a2b0ed2..34e7ae94 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -306,7 +306,7 @@ RestWrite.prototype.transformUser = function() { if (!this.data.password) { return; } - if (this.query) { + if (this.query && !this.auth.isMaster ) { this.storage['clearSessions'] = true; } return passwordCrypto.hash(this.data.password).then((hashedPassword) => { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 5b894f75..c6ba1c1b 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -41,8 +41,7 @@ export class UsersRouter extends ClassesRouter { handleMe(req) { if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); } return rest.find(req.config, Auth.master(req.config), '_Session', { _session_token: req.info.sessionToken }, @@ -51,8 +50,7 @@ export class UsersRouter extends ClassesRouter { if (!response.results || response.results.length == 0 || !response.results[0].user) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token'); } else { let user = response.results[0].user; return { response: user }; @@ -145,10 +143,10 @@ export class UsersRouter extends ClassesRouter { let router = new PromiseRouter(); router.route('GET', '/users', req => { return this.handleFind(req); }); router.route('POST', '/users', req => { return this.handleCreate(req); }); + router.route('GET', '/users/me', req => { return this.handleMe(req); }); router.route('GET', '/users/:objectId', req => { return this.handleGet(req); }); router.route('PUT', '/users/:objectId', req => { return this.handleUpdate(req); }); router.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); - router.route('GET', '/users/me', req => { return this.handleMe(req); }); router.route('GET', '/login', req => { return this.handleLogIn(req); }); router.route('POST', '/logout', req => { return this.handleLogOut(req); }); router.route('POST', '/requestPasswordReset', () => { From be92b4af6710182ec0829cdb90a4f1c33ae0c333 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 15 Feb 2016 10:21:01 -0500 Subject: [PATCH 14/29] Adds test to make sure Parse.User.become is functional --- spec/ParseUser.spec.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index e173f9f8..9d62f832 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1607,11 +1607,32 @@ describe('Parse.User testing', () => { fail('Session should have been invalidated'); done(); }, function(err) { - expect(err.code).toBe(209); + expect(err.code).toBe(Parse.Error.INVALID_SESSION_TOKEN); expect(err.message).toBe('invalid session token'); done(); }); }); + + it('test parse user become', (done) => { + var sessionToken = null; + Parse.Promise.as().then(function() { + return Parse.User.signUp("flessard", "folo",{'foo':1}); + }).then(function(newUser) { + equal(Parse.User.current(), newUser); + sessionToken = newUser.getSessionToken(); + ok(sessionToken); + newUser.set('foo',2); + return newUser.save(); + }).then(function() { + return Parse.User.become(sessionToken); + }).then(function(newUser) { + equal(newUser.get('foo'), 2); + done(); + }, function(e) { + fail('The session should still be valid'); + done(); + }); + }); it('ensure logout works', (done) => { var user = null; From 20eca71a6f18168e2faea0417580dabb2942d986 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 15 Feb 2016 19:40:22 -0500 Subject: [PATCH 15/29] Adds locked down ACL on _User --- spec/ParseUser.spec.js | 17 ++++++++++++++++- src/RestWrite.js | 7 +++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 787a8ecb..e8d5b15b 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -8,6 +8,20 @@ var request = require('request'); var passwordCrypto = require('../src/password'); +function verifyACL(user) { + const ACL = user.getACL(); + expect(ACL.getReadAccess(user)).toBe(true); + expect(ACL.getWriteAccess(user)).toBe(true); + expect(ACL.getPublicReadAccess()).toBe(true); + expect(ACL.getPublicWriteAccess()).toBe(false); + const perms = ACL.permissionsById; + expect(Object.keys(perms).length).toBe(2); + expect(perms[user.id].read).toBe(true); + expect(perms[user.id].write).toBe(true); + expect(perms['*'].read).toBe(true); + expect(perms['*'].write).not.toBe(true); +} + describe('Parse.User testing', () => { it("user sign up class method", (done) => { Parse.User.signUp("asdf", "zxcv", null, { @@ -57,6 +71,7 @@ describe('Parse.User testing', () => { Parse.User.logIn("asdf", "zxcv", { success: function(user) { equal(user.get("username"), "asdf"); + verifyACL(user); done(); } }); @@ -1352,7 +1367,7 @@ describe('Parse.User testing', () => { var b = JSON.parse(body); expect(b.results.length).toEqual(1); var user = b.results[0]; - expect(Object.keys(user).length).toEqual(6); + expect(Object.keys(user).length).toEqual(7); done(); }); }); diff --git a/src/RestWrite.js b/src/RestWrite.js index 2a2b0ed2..7ccdf783 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -660,6 +660,13 @@ RestWrite.prototype.runDatabaseOperation = function() { this.response.updatedAt = this.updatedAt; }); } else { + // Set the default ACL for the new _User + if (!this.data.ACL && this.className === '_User') { + var ACL = {}; + ACL[this.data.objectId] = { read: true, write: true }; + ACL['*'] = { read: true, write: false }; + this.data.ACL = ACL; + } // Run a create return this.config.database.create(this.className, this.data, options) .then(() => { From 8296d77f2889d1384d1437d27838beefc6022e8e Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 15 Feb 2016 22:18:19 -0500 Subject: [PATCH 16/29] Adds ability to pass qs params to cloud code functions --- spec/ParseAPI.spec.js | 29 +++++++++++++++++++++++++++++ src/functions.js | 7 +++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 8670bdd2..10f6b5f6 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -571,6 +571,35 @@ describe('miscellaneous', function() { done(); }); }); + + it('test cloud function query parameters', (done) => { + Parse.Cloud.define('echoParams', (req, res) => { + res.success(req.params); + }); + var headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-Javascript-Key': 'test' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/functions/echoParams', //?option=1&other=2 + qs: { + option: 1, + other: 2 + }, + body: '{"foo":"bar", "other": 1}' + }, (error, response, body) => { + expect(error).toBe(null); + var res = JSON.parse(body).result; + expect(res.option).toEqual('1'); + // Make sure query string params override body params + expect(res.other).toEqual('2'); + expect(res.foo).toEqual("bar"); + delete Parse.Cloud.Functions['echoParams']; + done(); + }); + }); it('test cloud function parameter validation success', (done) => { // Register a function with validation diff --git a/src/functions.js b/src/functions.js index f8b8fbc9..c787a814 100644 --- a/src/functions.js +++ b/src/functions.js @@ -9,8 +9,11 @@ var router = new PromiseRouter(); function handleCloudFunction(req) { if (Parse.Cloud.Functions[req.params.functionName]) { + + const params = Object.assign({}, req.body, req.query); + if (Parse.Cloud.Validators[req.params.functionName]) { - var result = Parse.Cloud.Validators[req.params.functionName](req.body || {}); + var result = Parse.Cloud.Validators[req.params.functionName](params); if (!result) { throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Validation failed.'); } @@ -19,7 +22,7 @@ function handleCloudFunction(req) { return new Promise(function (resolve, reject) { var response = createResponseObject(resolve, reject); var request = { - params: req.body || {}, + params: params, master: req.auth && req.auth.isMaster, user: req.auth && req.auth.user, installationId: req.info.installationId From c0bd5d203645bb9a200b359fd9df7b93859e9ded Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 15 Feb 2016 22:44:41 -0500 Subject: [PATCH 17/29] adds ability to disable anonymous users --- README.md | 2 +- spec/RestCreate.spec.js | 19 +++++++++++++++++++ src/Config.js | 1 + src/RestWrite.js | 4 ++-- src/index.js | 3 ++- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 92b7668f..10b44d54 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The client keys used with Parse are no longer necessary with parse-server. If y * filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see [`FilesAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Files/FilesAdapter.js)) * databaseAdapter (unfinished) - The backing store can be changed by creating an adapter class (see `DatabaseAdapter.js`) * loggerAdapter - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js)) - +* enableAnonymousUsers - Defaults to true. Set to false to disable anonymous users. --- ### Usage diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index b769a3b5..f4055974 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -100,6 +100,25 @@ describe('rest create', () => { done(); }); }); + + it('handles no anonymous users config', (done) => { + var NoAnnonConfig = Object.assign({}, config, {enableAnonymousUsers: false}); + var data1 = { + authData: { + anonymous: { + id: '00000000-0000-0000-0000-000000000001' + } + } + }; + rest.create(NoAnnonConfig, auth.nobody(NoAnnonConfig), '_User', data1).then(() => { + fail("Should throw an error"); + done(); + }, (err) => { + expect(err.code).toEqual(Parse.Error.UNSUPPORTED_SERVICE); + expect(err.message).toEqual('This authentication method is unsupported.'); + done(); + }) + }); it('test facebook signup and login', (done) => { var data = { diff --git a/src/Config.js b/src/Config.js index 06d7af94..cb047b67 100644 --- a/src/Config.js +++ b/src/Config.js @@ -20,6 +20,7 @@ function Config(applicationId, mount) { this.restAPIKey = cacheInfo.restAPIKey; this.fileKey = cacheInfo.fileKey; this.facebookAppIds = cacheInfo.facebookAppIds; + this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.database = DatabaseAdapter.getDatabaseConnection(applicationId); this.filesController = cacheInfo.filesController; diff --git a/src/RestWrite.js b/src/RestWrite.js index 2a2b0ed2..2b90fe3c 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -150,8 +150,8 @@ RestWrite.prototype.validateAuthData = function() { var facebookData = this.data.authData.facebook; var anonData = this.data.authData.anonymous; - if (anonData === null || - (anonData && anonData.id)) { + if (this.config.enableAnonymousUsers === true && (anonData === null || + (anonData && anonData.id))) { return this.handleAnonymousAuthData(); } else if (facebookData === null || (facebookData && facebookData.id && facebookData.access_token)) { diff --git a/src/index.js b/src/index.js index fef09075..a5355f6c 100644 --- a/src/index.js +++ b/src/index.js @@ -104,7 +104,8 @@ function ParseServer(args) { restAPIKey: args.restAPIKey || '', fileKey: args.fileKey || 'invalid-file-key', facebookAppIds: args.facebookAppIds || [], - filesController: filesController + filesController: filesController, + enableAnonymousUsers: args.enableAnonymousUsers || true }; // To maintain compatibility. TODO: Remove in v2.1 From 6a88a8101756c63aeb671f9fc37a49735720a8b2 Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Tue, 16 Feb 2016 08:35:55 -0800 Subject: [PATCH 18/29] Remove _noBody from Unity SDK request. --- src/middlewares.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/middlewares.js b/src/middlewares.js index a07b2a1b..7dcf8889 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -26,6 +26,12 @@ function handleParseHeaders(req, res, next) { restAPIKey: req.get('X-Parse-REST-API-Key') }; + if (req.body && req.body._noBody) { + // Unity SDK sends a _noBody key which needs to be removed. + // Unclear at this point if action needs to be taken. + delete req.body._noBody; + } + var fileViaJSON = false; if (!info.appId || !cache.apps[info.appId]) { From 86c502c702a0e76be8d2e23953a934d558edab6f Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 16 Feb 2016 12:38:45 -0500 Subject: [PATCH 19/29] Removes installationId from _User --- spec/ParseUser.spec.js | 2 +- src/Routers/UsersRouter.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 5f0b28b7..1d5049a8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1367,7 +1367,7 @@ describe('Parse.User testing', () => { var b = JSON.parse(body); expect(b.results.length).toEqual(1); var user = b.results[0]; - expect(Object.keys(user).length).toEqual(7); + expect(Object.keys(user).length).toEqual(6); done(); }); }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index c6ba1c1b..946dfe54 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -23,7 +23,6 @@ export class UsersRouter extends ClassesRouter { handleCreate(req) { let data = deepcopy(req.body); - data.installationId = req.info.installationId; req.body = data; req.params.className = '_User'; return super.handleCreate(req); From a455e1b23fb219ae5410390ef5c82c0b65fe4bc3 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 16 Feb 2016 12:30:30 -0800 Subject: [PATCH 20/29] Finish implementation of PUT /schemas/:className --- spec/Schema.spec.js | 31 ++++++ spec/schemas.spec.js | 228 ++++++++++++++++++++++++++++++++++++++++++- src/Schema.js | 169 ++++++++++++++++++++++---------- src/schemas.js | 98 ++++++++++++------- 4 files changed, 436 insertions(+), 90 deletions(-) diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index 7f59fec2..a5c28c5b 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -162,6 +162,9 @@ describe('Schema', () => { foo: 'string', }) done(); + }) + .catch(error => { + fail('Error creating class: ' + JSON.stringify(error)); }); }); @@ -570,4 +573,32 @@ describe('Schema', () => { Parse.Object.enableSingleInstance(); }); }); + + it('can merge schemas', done => { + expect(Schema.buildMergedSchemaObject({ + _id: 'SomeClass', + someType: 'number' + }, { + newType: {type: 'Number'} + })).toEqual({ + someType: {type: 'Number'}, + newType: {type: 'Number'}, + }); + done(); + }); + + it('can merge deletions', done => { + expect(Schema.buildMergedSchemaObject({ + _id: 'SomeClass', + someType: 'number', + outDatedType: 'string', + },{ + newType: {type: 'GeoPoint'}, + outDatedType: {__op: 'Delete'}, + })).toEqual({ + someType: {type: 'Number'}, + newType: {type: 'GeoPoint'}, + }); + done(); + }); }); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 907c5f65..fd136df4 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -339,7 +339,7 @@ describe('schemas', () => { }); }); - it('rejects class name mis-matches', done => { + it('rejects class name mis-matches in put', done => { request.put({ url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, @@ -349,6 +349,7 @@ describe('schemas', () => { 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'); + done(); }); }); @@ -370,12 +371,133 @@ describe('schemas', () => { }); }); + it('refuses to put to existing fields, even if it would not be a change', done => { + var obj = hasAllPODobject(); + obj.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {type: 'String'} + } + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body.code).toEqual(255); + expect(body.error).toEqual('field aString exists, cannot update'); + done(); + }); + }) + }); + + it('refuses to delete non-existant fields', done => { + var obj = hasAllPODobject(); + obj.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + nonExistantKey: {__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'); + done(); + }); + }); + }); + + it('refuses to add a geopoint to a class that already has one', done => { + var obj = hasAllPODobject(); + obj.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newGeo: {type: 'GeoPoint'} + } + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo when aGeoPoint already exists.'); + done(); + }); + }); + }); + + it('refuses to add two geopoints', done => { + var obj = new Parse.Object('NewClass'); + obj.set('aString', 'aString'); + obj.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + newGeo1: {type: 'GeoPoint'}, + newGeo2: {type: 'GeoPoint'}, + } + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(400); + expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(body.error).toEqual('currently, only one GeoPoint field may exist in an object. Adding newGeo2 when newGeo1 already exists.'); + done(); + }); + }); + }); + + it('allows you to delete and add a geopoint in the same request', done => { + var obj = new Parse.Object('NewClass'); + obj.set('geo1', new Parse.GeoPoint({latitude: 0, longitude: 0})); + obj.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/NewClass', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + geo2: {type: 'GeoPoint'}, + geo1: {__op: 'Delete'} + } + } + }, (error, response, body) => { + expect(dd(body, { + "className": "NewClass", + "fields": { + "ACL": {"type": "ACL"}, + "createdAt": {"type": "Date"}, + "objectId": {"type": "String"}, + "updatedAt": {"type": "Date"}, + "geo2": {"type": "GeoPoint"}, + } + })).toEqual(undefined); + done(); + }); + }) + }); + it('put with no modifications returns all fields', done => { var obj = hasAllPODobject(); obj.save() .then(() => { request.put({ - url: 'http://localhost:8378/1/schemas/HasAllPOD' + url: 'http://localhost:8378/1/schemas/HasAllPOD', headers: masterKeyHeaders, json: true, body: {}, @@ -383,7 +505,7 @@ describe('schemas', () => { expect(body).toEqual(plainOldDataSchema); done(); }); - }); + }) }); it('lets you add fields', done => { @@ -403,16 +525,112 @@ describe('schemas', () => { } } }, (error, response, body) => { - expect(body).toEqual('blah'); + expect(dd(body, { + className: 'NewClass', + fields: { + "ACL": {"type": "ACL"}, + "createdAt": {"type": "Date"}, + "objectId": {"type": "String"}, + "updatedAt": {"type": "Date"}, + "newField": {"type": "String"}, + }, + })).toEqual(undefined); request.get({ url: 'http://localhost:8378/1/schemas/NewClass', headers: masterKeyHeaders, json: true, }, (error, response, body) => { - expect(body).toEqual('blah'); + expect(body).toEqual({ + className: 'NewClass', + fields: { + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + newField: {type: 'String'}, + } + }); done(); }); }); }) }); + + it('lets you delete multiple fields and add fields', done => { + var obj1 = hasAllPODobject(); + obj1.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + aString: {__op: 'Delete'}, + aNumber: {__op: 'Delete'}, + aNewString: {type: 'String'}, + aNewNumber: {type: 'Number'}, + aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'}, + aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'}, + } + } + }, (error, response, body) => { + expect(body).toEqual({ + className: 'HasAllPOD', + fields: { + //Default fields + ACL: {type: 'ACL'}, + createdAt: {type: 'Date'}, + updatedAt: {type: 'Date'}, + objectId: {type: 'String'}, + //Custom fields + aBool: {type: 'Boolean'}, + aDate: {type: 'Date'}, + aObject: {type: 'Object'}, + aArray: {type: 'Array'}, + aGeoPoint: {type: 'GeoPoint'}, + aFile: {type: 'File'}, + aNewNumber: {type: 'Number'}, + aNewString: {type: 'String'}, + aNewPointer: {type: 'Pointer', targetClass: 'HasAllPOD'}, + aNewRelation: {type: 'Relation', targetClass: 'HasAllPOD'}, + } + }); + var obj2 = new Parse.Object('HasAllPOD'); + obj2.set('aNewPointer', obj1); + var relation = obj2.relation('aNewRelation'); + relation.add(obj1); + obj2.save().then(done); //Just need to make sure saving works on the new object. + }); + }); + }); + + it('will not delete any fields if the additions are invalid', done => { + var obj = hasAllPODobject(); + obj.save() + .then(() => { + request.put({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + headers: masterKeyHeaders, + json: true, + body: { + fields: { + fakeNewField: {type: 'fake type'}, + aString: {__op: 'Delete'} + } + } + }, (error, response, body) => { + expect(body.code).toEqual(Parse.Error.INCORRECT_TYPE); + expect(body.error).toEqual('invalid field type: fake type'); + request.get({ + url: 'http://localhost:8378/1/schemas/HasAllPOD', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(response.body).toEqual(plainOldDataSchema); + done(); + }); + }); + }); + }); }); diff --git a/src/Schema.js b/src/Schema.js index d8f38499..a07018bf 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -116,7 +116,7 @@ function schemaAPITypeToMongoFieldType(type) { return invalidJsonError; } else if (!classNameIsValid(type.targetClass)) { return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME }; - } else { + } else { return { result: '*' + type.targetClass }; } } @@ -200,6 +200,114 @@ Schema.prototype.reload = function() { return load(this.collection); }; +// Returns { code, error } if invalid, or { result }, an object +// suitable for inserting into _SCHEMA collection, otherwise +function mongoSchemaFromFieldsAndClassName(fields, className) { + if (!classNameIsValid(className)) { + return { + code: Parse.Error.INVALID_CLASS_NAME, + error: invalidClassNameMessage(className), + }; + } + + for (var fieldName in fields) { + if (!fieldNameIsValid(fieldName)) { + return { + code: Parse.Error.INVALID_KEY_NAME, + error: 'invalid field name: ' + fieldName, + }; + } + if (!fieldNameIsValidForClass(fieldName, className)) { + return { + code: 136, + error: 'field ' + fieldName + ' cannot be added', + }; + } + } + + var mongoObject = { + _id: className, + objectId: 'string', + updatedAt: 'string', + createdAt: 'string' + }; + + for (var fieldName in defaultColumns[className]) { + var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]); + if (!validatedField.result) { + return validatedField; + } + mongoObject[fieldName] = validatedField.result; + } + + for (var fieldName in fields) { + var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]); + if (!validatedField.result) { + return validatedField; + } + mongoObject[fieldName] = validatedField.result; + } + + var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint'); + if (geoPoints.length > 1) { + return { + code: Parse.Error.INCORRECT_TYPE, + error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', + }; + } + + return { result: mongoObject }; +} + +function mongoFieldTypeToSchemaAPIType(type) { + if (type[0] === '*') { + return { + type: 'Pointer', + targetClass: type.slice(1), + }; + } + if (type.startsWith('relation<')) { + return { + type: 'Relation', + targetClass: type.slice('relation<'.length, type.length - 1), + }; + } + switch (type) { + case 'number': return {type: 'Number'}; + case 'string': return {type: 'String'}; + case 'boolean': return {type: 'Boolean'}; + case 'date': return {type: 'Date'}; + case 'map': + case 'object': return {type: 'Object'}; + case 'array': return {type: 'Array'}; + case 'geopoint': return {type: 'GeoPoint'}; + case 'file': return {type: 'File'}; + } +} + +// Builds a new schema (in schema API response format) out of an +// existing mongo schema + a schemas API put request. This response +// does not include the default fields, as it is intended to be passed +// to mongoSchemaFromFieldsAndClassName. No validation is done here, it +// is done in mongoSchemaFromFieldsAndClassName. +function buildMergedSchemaObject(mongoObject, putRequest) { + var newSchema = {}; + for (var oldField in mongoObject) { + if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') { + var fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete' + if (!fieldIsDeleted) { + newSchema[oldField] = mongoFieldTypeToSchemaAPIType(mongoObject[oldField]); + } + } + } + for (var newField in putRequest) { + if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') { + newSchema[newField] = putRequest[newField]; + } + } + return newSchema; +} + // Create a new class that includes the three default fields. // ACL is an implicit column that does not get an entry in the // _SCHEMAS database. Returns a promise that resolves with the @@ -215,58 +323,13 @@ Schema.prototype.addClassIfNotExists = function(className, fields) { }); } - if (!classNameIsValid(className)) { - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: invalidClassNameMessage(className), - }); - } - for (var fieldName in fields) { - if (!fieldNameIsValid(fieldName)) { - return Promise.reject({ - code: Parse.Error.INVALID_KEY_NAME, - error: 'invalid field name: ' + fieldName, - }); - } - if (!fieldNameIsValidForClass(fieldName, className)) { - return Promise.reject({ - code: 136, - error: 'field ' + fieldName + ' cannot be added', - }); - } + var mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); + + if (!mongoObject.result) { + return Promise.reject(mongoObject); } - var mongoObject = { - _id: className, - objectId: 'string', - updatedAt: 'string', - createdAt: 'string' - }; - for (var fieldName in defaultColumns[className]) { - var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]); - if (validatedField.code) { - return Promise.reject(validatedField); - } - mongoObject[fieldName] = validatedField.result; - } - - for (var fieldName in fields) { - var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]); - if (validatedField.code) { - return Promise.reject(validatedField); - } - mongoObject[fieldName] = validatedField.result; - } - - var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint'); - if (geoPoints.length > 1) { - return Promise.reject({ - code: Parse.Error.INCORRECT_TYPE, - error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.', - }); - } - - return this.collection.insertOne(mongoObject) + return this.collection.insertOne(mongoObject.result) .then(result => result.ops[0]) .catch(error => { if (error.code === 11000) { //Mongo's duplicate key error @@ -651,4 +714,8 @@ function getObjectType(obj) { module.exports = { load: load, classNameIsValid: classNameIsValid, + mongoSchemaFromFieldsAndClassName: mongoSchemaFromFieldsAndClassName, + schemaAPITypeToMongoFieldType: schemaAPITypeToMongoFieldType, + buildMergedSchemaObject: buildMergedSchemaObject, + mongoFieldTypeToSchemaAPIType: mongoFieldTypeToSchemaAPIType, }; diff --git a/src/schemas.js b/src/schemas.js index c17e9407..cd8b92ec 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -24,36 +24,10 @@ function classNameMismatchResponse(bodyClass, pathClass) { }); } -function mongoFieldTypeToSchemaAPIType(type) { - if (type[0] === '*') { - return { - type: 'Pointer', - targetClass: type.slice(1), - }; - } - if (type.startsWith('relation<')) { - return { - type: 'Relation', - targetClass: type.slice('relation<'.length, type.length - 1), - }; - } - switch (type) { - case 'number': return {type: 'Number'}; - case 'string': return {type: 'String'}; - case 'boolean': return {type: 'Boolean'}; - case 'date': return {type: 'Date'}; - case 'map': - case 'object': return {type: 'Object'}; - case 'array': return {type: 'Array'}; - case 'geopoint': return {type: 'GeoPoint'}; - case 'file': return {type: 'File'}; - } -} - function mongoSchemaAPIResponseFields(schema) { var fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata'); var response = fieldNames.reduce((obj, fieldName) => { - obj[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName]) + obj[fieldName] = Schema.mongoFieldTypeToSchemaAPIType(schema[fieldName]) return obj; }, {}); response.ACL = {type: 'ACL'}; @@ -131,17 +105,15 @@ function modifySchema(req) { } if (req.body.className && req.body.className != req.params.className) { - return classNameMismatchResponse(req.body.className, req.path.className); + return classNameMismatchResponse(req.body.className, req.params.className); } - if (!req.body.fields) { - req.body.fields = {}; - } + var submittedFields = req.body.fields || {}; + var className = req.params.className; return req.config.database.loadSchema() - .then(schema => schema.hasClass(req.params.className)) - .then(hasClass => { - if (!hasClass) { + .then(schema => { + if (!schema.data[className]) { return Promise.resolve({ status: 400, response: { @@ -150,6 +122,64 @@ function modifySchema(req) { } }); } + 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', + } + }); + } + + 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. + 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.db, + 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)}); + }) + })); }); } From c5efcac8e0ee24127bcfd5e7893ace615e5f5aee Mon Sep 17 00:00:00 2001 From: Alex Kwan Date: Wed, 17 Feb 2016 12:53:17 +0800 Subject: [PATCH 21/29] fix spacing --- src/RestQuery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index c61b9d69..3dfad0f0 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -377,7 +377,7 @@ RestQuery.prototype.handleInclude = function() { this.include = this.include.slice(1); return this.handleInclude(); }); - }else if(this.include.length >0){ + }else if (this.include.length >0){ this.include = this.include.slice(1); return this.handleInclude(); } From 317a2fe56fd8acfbe208bbe27f56dac8b5b1d44c Mon Sep 17 00:00:00 2001 From: Alex Kwan Date: Wed, 17 Feb 2016 13:13:20 +0800 Subject: [PATCH 22/29] fix spacing again --- src/RestQuery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index 3dfad0f0..710c25d8 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -377,7 +377,7 @@ RestQuery.prototype.handleInclude = function() { this.include = this.include.slice(1); return this.handleInclude(); }); - }else if (this.include.length >0){ + }else if (this.include.length > 0){ this.include = this.include.slice(1); return this.handleInclude(); } From 7b891bd69c14ca936f2edc9a5119ef96a54171d8 Mon Sep 17 00:00:00 2001 From: Alex Kwan Date: Wed, 17 Feb 2016 13:14:20 +0800 Subject: [PATCH 23/29] fix spacing againn --- src/RestQuery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index 710c25d8..b5bec1fb 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -377,7 +377,7 @@ RestQuery.prototype.handleInclude = function() { this.include = this.include.slice(1); return this.handleInclude(); }); - }else if (this.include.length > 0){ + } else if (this.include.length > 0) { this.include = this.include.slice(1); return this.handleInclude(); } From 32bb1bddafae61c0195815a69ce669d582f46a51 Mon Sep 17 00:00:00 2001 From: Alex Kwan Date: Wed, 17 Feb 2016 16:14:41 +0800 Subject: [PATCH 24/29] removed .DS_Store --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 942763772fff983b6f5e286a29ac4d1597536a1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%Wl&^6g@*MHMB+TlBJidBJl?%jawo?i<+<}k2Y#+;;JnOcK!@LiVxs#%DHz2 zG2^%_s|wvK%^Z7t&fK{ti6;ZV4Zkd31N#8_Y=YS~yN;0O(l+8*7!k44InGew28(f3 zFE-dVU=ElAPt5`U0vKS98C&%Gomcgoi>Z>%9R`DaMqQ`nKux_!WhM2XB z1H8o>9ODQj-r z5#FbHr7G8ts@mcj$IM+a*92vY&FT{NE}fn+&lUR>Km7-?NOg&B3m@poRI^MmNwYXs zldzxDgOdCa-V;0O*DAh6Rb;IGn(NV>B>jfDnAamO>^FZ|y4nub@^(?p>$>7rWg!j( zBlo7sS8I8 z=j9yF1Fs0!c=U3(aQJW`vkNB_r`b7wu5`G Date: Thu, 4 Feb 2016 14:03:39 -0500 Subject: [PATCH 25/29] Generic OAuth provider support Refactors facebook login into oauth generic login Adds additional oauth2 providers adds ability to pass an oAuth validator in the config Adds Twitter validation support + OAuth 1 client Support auth_token instead of access_token for twitter Improves code coverage of OAuth Adds validation of oauth provider structures Better coverage of the OAuth spec 100% coverage of OAuth1.js Adds passing auth_token_secret for Twitter auth. Refactors auth validation methods to include authData parameter - Adds ability to extens oauth validator through configuration - Adds ability to extend oauth validator through external module (file or package) - Adds more tests - Adds tests to login with custom auth provider Adds more tests for REST API fixes twitter auth_token f --- bin/parse-server | 5 + spec/OAuth.spec.js | 307 ++++++++++++++++++++++++++++++++++++++ spec/ParseUser.spec.js | 57 +++---- spec/helper.js | 21 ++- spec/myoauth.js | 17 +++ src/Config.js | 1 + src/RestWrite.js | 97 +++++++++--- src/facebook.js | 11 +- src/index.js | 3 +- src/oauth/OAuth1Client.js | 226 ++++++++++++++++++++++++++++ src/oauth/facebook.js | 57 +++++++ src/oauth/github.js | 51 +++++++ src/oauth/google.js | 44 ++++++ src/oauth/index.js | 17 +++ src/oauth/instagram.js | 44 ++++++ src/oauth/linkedin.js | 51 +++++++ src/oauth/meetup.js | 50 +++++++ src/oauth/twitter.js | 30 ++++ src/transform.js | 59 +++++--- 19 files changed, 1061 insertions(+), 87 deletions(-) create mode 100644 spec/OAuth.spec.js create mode 100644 spec/myoauth.js create mode 100644 src/oauth/OAuth1Client.js create mode 100644 src/oauth/facebook.js create mode 100644 src/oauth/github.js create mode 100644 src/oauth/google.js create mode 100644 src/oauth/index.js create mode 100644 src/oauth/instagram.js create mode 100644 src/oauth/linkedin.js create mode 100644 src/oauth/meetup.js create mode 100644 src/oauth/twitter.js diff --git a/bin/parse-server b/bin/parse-server index 8d0104e4..0785b7f0 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -30,6 +30,11 @@ if (process.env.PARSE_SERVER_OPTIONS) { facebookAppIds = facebookAppIds.split(","); options.facebookAppIds = facebookAppIds; } + + var oauth = process.env.PARSE_SERVER_OAUTH_PROVIDERS; + if (oauth) { + options.oauth = JSON.parse(oauth); + }; } var mountPath = process.env.PARSE_SERVER_MOUNT_PATH || "/"; diff --git a/spec/OAuth.spec.js b/spec/OAuth.spec.js new file mode 100644 index 00000000..47e4349d --- /dev/null +++ b/spec/OAuth.spec.js @@ -0,0 +1,307 @@ +var OAuth = require("../src/oauth/OAuth1Client"); +var request = require('request'); + +describe('OAuth', function() { + + it("Nonce should have right length", (done) => { + jequal(OAuth.nonce().length, 30); + done(); + }); + + it("Should properly build parameter string", (done) => { + var string = OAuth.buildParameterString({c:1, a:2, b:3}) + jequal(string, "a=2&b=3&c=1"); + done(); + }); + + it("Should properly build empty parameter string", (done) => { + var string = OAuth.buildParameterString() + jequal(string, ""); + done(); + }); + + it("Should properly build signature string", (done) => { + var string = OAuth.buildSignatureString("get", "http://dummy.com", ""); + jequal(string, "GET&http%3A%2F%2Fdummy.com&"); + done(); + }); + + it("Should properly generate request signature", (done) => { + var request = { + host: "dummy.com", + path: "path" + }; + + var oauth_params = { + oauth_timestamp: 123450000, + oauth_nonce: "AAAAAAAAAAAAAAAAA", + oauth_consumer_key: "hello", + oauth_token: "token" + }; + + var consumer_secret = "world"; + var auth_token_secret = "secret"; + request = OAuth.signRequest(request, oauth_params, consumer_secret, auth_token_secret); + jequal(request.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="8K95bpQcDi9Nd2GkhumTVcw4%2BXw%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"'); + done(); + }); + + it("Should properly build request", (done) => { + var options = { + host: "dummy.com", + consumer_key: "hello", + consumer_secret: "world", + auth_token: "token", + auth_token_secret: "secret", + // Custom oauth params for tests + oauth_params: { + oauth_timestamp: 123450000, + oauth_nonce: "AAAAAAAAAAAAAAAAA" + } + }; + var path = "path"; + var method = "get"; + + var oauthClient = new OAuth(options); + var req = oauthClient.buildRequest(method, path, {"query": "param"}); + + jequal(req.host, options.host); + jequal(req.path, "/"+path+"?query=param"); + jequal(req.method, "GET"); + jequal(req.headers['Content-Type'], 'application/x-www-form-urlencoded'); + jequal(req.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="wNkyEkDE%2F0JZ2idmqyrgHdvC0rs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"') + done(); + }); + + + function validateCannotAuthenticateError(data, done) { + jequal(typeof data, "object"); + jequal(typeof data.errors, "object"); + var errors = data.errors; + jequal(typeof errors[0], "object"); + // Cannot authenticate error + jequal(errors[0].code, 32); + done(); + } + + it("Should fail a GET request", (done) => { + var options = { + host: "api.twitter.com", + consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", + consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }; + var path = "/1.1/help/configuration.json"; + var params = {"lang": "en"}; + var oauthClient = new OAuth(options); + oauthClient.get(path, params).then(function(data){ + validateCannotAuthenticateError(data, done); + }) + }); + + it("Should fail a POST request", (done) => { + var options = { + host: "api.twitter.com", + consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", + consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }; + var body = { + lang: "en" + }; + var path = "/1.1/account/settings.json"; + + var oauthClient = new OAuth(options); + oauthClient.post(path, null, body).then(function(data){ + validateCannotAuthenticateError(data, done); + }) + }); + + it("Should fail a request", (done) => { + var options = { + host: "localhost", + consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX", + consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + }; + var body = { + lang: "en" + }; + var path = "/"; + + var oauthClient = new OAuth(options); + oauthClient.post(path, null, body).then(function(data){ + jequal(false, true); + done(); + }).catch(function(){ + jequal(true, true); + done(); + }) + }); + + ["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter"].map(function(providerName){ + it("Should validate structure of "+providerName, (done) => { + var provider = require("../src/oauth/"+providerName); + jequal(typeof provider.validateAuthData, "function"); + jequal(typeof provider.validateAppId, "function"); + jequal(provider.validateAuthData({}, {}).constructor, Promise.prototype.constructor); + jequal(provider.validateAppId("app", "key", {}).constructor, Promise.prototype.constructor); + done(); + }); + }); + + var getMockMyOauthProvider = function() { + return { + authData: { + id: "12345", + access_token: "12345", + expiration_date: new Date().toJSON(), + }, + shouldError: false, + loggedOut: false, + synchronizedUserId: null, + synchronizedAuthToken: null, + synchronizedExpiration: null, + + authenticate: function(options) { + if (this.shouldError) { + options.error(this, "An error occurred"); + } else if (this.shouldCancel) { + options.error(this, null); + } else { + options.success(this, this.authData); + } + }, + restoreAuthentication: function(authData) { + if (!authData) { + this.synchronizedUserId = null; + this.synchronizedAuthToken = null; + this.synchronizedExpiration = null; + return true; + } + this.synchronizedUserId = authData.id; + this.synchronizedAuthToken = authData.access_token; + this.synchronizedExpiration = authData.expiration_date; + return true; + }, + getAuthType: function() { + return "myoauth"; + }, + deauthenticate: function() { + this.loggedOut = true; + this.restoreAuthentication(null); + } + }; + }; + + var ExtendedUser = Parse.User.extend({ + extended: function() { + return true; + } + }); + + var createOAuthUser = function(callback) { + var jsonBody = { + authData: { + myoauth: getMockMyOauthProvider().authData + } + }; + var headers = {'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json' } + + var options = { + headers: {'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json' }, + url: 'http://localhost:8378/1/users', + body: JSON.stringify(jsonBody) + }; + + return request.post(options, callback); + } + + it("should create user with REST API", (done) => { + + createOAuthUser((error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.objectId).not.toBeNull(); + expect(b.objectId).not.toBeUndefined(); + done(); + }); + + }); + + it("should only create a single user with REST API", (done) => { + var objectId; + createOAuthUser((error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.objectId).not.toBeNull(); + expect(b.objectId).not.toBeUndefined(); + objectId = b.objectId; + + createOAuthUser((error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.objectId).not.toBeNull(); + expect(b.objectId).not.toBeUndefined(); + expect(b.objectId).toBe(objectId); + done(); + }); + }); + + }); + + it("unlink and link with custom provider", (done) => { + var provider = getMockMyOauthProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("myoauth", { + success: function(model) { + ok(model instanceof Parse.User, "Model should be a Parse.User"); + strictEqual(Parse.User.current(), model); + ok(model.extended(), "Should have used the subclass."); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); + ok(model._isLinked("myoauth"), "User should be linked to myoauth"); + + model._unlinkFrom("myoauth", { + success: function(model) { + ok(!model._isLinked("myoauth"), + "User should not be linked to myoauth"); + ok(!provider.synchronizedUserId, "User id should be cleared"); + ok(!provider.synchronizedAuthToken, "Auth token should be cleared"); + ok(!provider.synchronizedExpiration, + "Expiration should be cleared"); + + model._linkWith("myoauth", { + success: function(model) { + ok(provider.synchronizedUserId, "User id should have a value"); + ok(provider.synchronizedAuthToken, + "Auth token should have a value"); + ok(provider.synchronizedExpiration, + "Expiration should have a value"); + ok(model._isLinked("myoauth"), + "User should be linked to myoauth"); + done(); + }, + error: function(model, error) { + ok(false, "linking again should succeed"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "unlinking should succeed"); + done(); + } + }); + }, + error: function(model, error) { + ok(false, "linking should have worked"); + done(); + } + }); + }); + + +}) \ No newline at end of file diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 1d5049a8..33da62e8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -831,9 +831,11 @@ describe('Parse.User testing', () => { // server-side. var getMockFacebookProvider = function() { return { - userId: "8675309", - authToken: "jenny", - expiration: new Date().toJSON(), + authData: { + id: "8675309", + access_token: "jenny", + expiration_date: new Date().toJSON(), + }, shouldError: false, loggedOut: false, synchronizedUserId: null, @@ -846,11 +848,7 @@ describe('Parse.User testing', () => { } else if (this.shouldCancel) { options.error(this, null); } else { - options.success(this, { - id: this.userId, - access_token: this.authToken, - expiration_date: this.expiration - }); + options.success(this, this.authData); } }, restoreAuthentication: function(authData) { @@ -889,13 +887,14 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook"); done(); }, error: function(model, error) { + console.error(model, error); ok(false, "linking should have worked"); done(); } @@ -910,9 +909,9 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook"); Parse.User.logOut(); @@ -925,20 +924,22 @@ describe('Parse.User testing', () => { "Model should be a Parse.User"); ok(innerModel === Parse.User.current(), "Returned model should be the current user"); - ok(provider.userId === provider.synchronizedUserId); - ok(provider.authToken === provider.synchronizedAuthToken); + ok(provider.authData.id === provider.synchronizedUserId); + ok(provider.authData.access_token === provider.synchronizedAuthToken); ok(innerModel._isLinked("facebook"), "User should be linked to facebook"); ok(innerModel.existed(), "User should not be newly-created"); done(); }, error: function(model, error) { + fail(error); ok(false, "LogIn should have worked"); done(); } }); }, error: function(model, error) { + console.error(model, error); ok(false, "LogIn should have worked"); done(); } @@ -987,9 +988,9 @@ describe('Parse.User testing', () => { success: function(model) { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked"); done(); }, @@ -1020,9 +1021,9 @@ describe('Parse.User testing', () => { success: function(model) { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked."); var user2 = new Parse.User(); user2.set("username", "testLinkWithProviderToAlreadyLinkedUser2"); @@ -1123,9 +1124,9 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User."); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook."); model._unlinkFrom("facebook", { @@ -1159,9 +1160,9 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook"); model._unlinkFrom("facebook", { diff --git a/spec/helper.js b/spec/helper.js index 3e6c6d98..8b587f7d 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -5,7 +5,7 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; var cache = require('../src/cache'); var DatabaseAdapter = require('../src/DatabaseAdapter'); var express = require('express'); -var facebook = require('../src/facebook'); +var facebook = require('../src/oauth/facebook'); var ParseServer = require('../src/index').ParseServer; var databaseURI = process.env.DATABASE_URI; @@ -22,7 +22,13 @@ var api = new ParseServer({ restAPIKey: 'rest', masterKey: 'test', collectionPrefix: 'test_', - fileKey: 'test' + fileKey: 'test', + oauth: { // Override the facebook provider + facebook: mockFacebook(), + myoauth: { + module: "../spec/myoauth" // relative path as it's run from src + } + } }); var app = express(); @@ -40,7 +46,6 @@ Parse.Promise.disableAPlusCompliant(); beforeEach(function(done) { Parse.initialize('test', 'test', 'test'); - mockFacebook(); Parse.User.enableUnsafeCurrentUser(); done(); }); @@ -175,18 +180,20 @@ function range(n) { } function mockFacebook() { - facebook.validateUserId = function(userId, accessToken) { - if (userId === '8675309' && accessToken === 'jenny') { + var facebook = {}; + facebook.validateAuthData = function(authData) { + if (authData.id === '8675309' && authData.access_token === 'jenny') { return Promise.resolve(); } return Promise.reject(); }; - facebook.validateAppId = function(appId, accessToken) { - if (accessToken === 'jenny') { + facebook.validateAppId = function(appId, authData) { + if (authData.access_token === 'jenny') { return Promise.resolve(); } return Promise.reject(); }; + return facebook; } function clearData() { diff --git a/spec/myoauth.js b/spec/myoauth.js new file mode 100644 index 00000000..d28f9e81 --- /dev/null +++ b/spec/myoauth.js @@ -0,0 +1,17 @@ +// Custom oauth provider by module + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData) { + if (authData.id == "12345" && authData.access_token == "12345") { + return Promise.resolve(); + } + return Promise.reject(); +} +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/Config.js b/src/Config.js index cb047b67..aeb25a61 100644 --- a/src/Config.js +++ b/src/Config.js @@ -25,6 +25,7 @@ function Config(applicationId, mount) { this.database = DatabaseAdapter.getDatabaseConnection(applicationId); this.filesController = cacheInfo.filesController; + this.oauth = cacheInfo.oauth; this.mount = mount; } diff --git a/src/RestWrite.js b/src/RestWrite.js index 781bc655..54f5cfc9 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -9,7 +9,7 @@ var cache = require('./cache'); var Config = require('./Config'); var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); -var facebook = require('./facebook'); +var oauth = require("./oauth"); var Parse = require('parse/node'); var triggers = require('./triggers'); @@ -147,19 +147,26 @@ RestWrite.prototype.validateAuthData = function() { return; } - var facebookData = this.data.authData.facebook; + var authData = this.data.authData; var anonData = this.data.authData.anonymous; - + if (this.config.enableAnonymousUsers === true && (anonData === null || (anonData && anonData.id))) { return this.handleAnonymousAuthData(); - } else if (facebookData === null || - (facebookData && facebookData.id && facebookData.access_token)) { - return this.handleFacebookAuthData(); - } else { - throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.'); + } + + // Not anon, try other providers + var providers = Object.keys(authData); + if (!anonData && providers.length == 1) { + var provider = providers[0]; + var providerAuthData = authData[provider]; + var hasToken = (providerAuthData && providerAuthData.id); + if (providerAuthData === null || hasToken) { + return this.handleOAuthAuthData(provider); + } } + throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.'); }; RestWrite.prototype.handleAnonymousAuthData = function() { @@ -208,27 +215,71 @@ RestWrite.prototype.handleAnonymousAuthData = function() { }; -RestWrite.prototype.handleFacebookAuthData = function() { - var facebookData = this.data.authData.facebook; - if (facebookData === null && this.query) { - // We are unlinking from Facebook. - this.data._auth_data_facebook = null; +RestWrite.prototype.handleOAuthAuthData = function(provider) { + var authData = this.data.authData[provider]; + + if (authData === null && this.query) { + // We are unlinking from the provider. + this.data["_auth_data_" + provider ] = null; return; } - return facebook.validateUserId(facebookData.id, - facebookData.access_token) + var appIds; + var oauthOptions = this.config.oauth[provider]; + if (oauthOptions) { + appIds = oauthOptions.appIds; + } else if (provider == "facebook") { + appIds = this.config.facebookAppIds; + } + + var validateAuthData; + var validateAppId; + + + if (oauth[provider]) { + validateAuthData = oauth[provider].validateAuthData; + validateAppId = oauth[provider].validateAppId; + } + + // Try the configuration methods + if (oauthOptions) { + if (oauthOptions.module) { + validateAuthData = require(oauthOptions.module).validateAuthData; + validateAppId = require(oauthOptions.module).validateAppId; + }; + + if (oauthOptions.validateAuthData) { + validateAuthData = oauthOptions.validateAuthData; + } + if (oauthOptions.validateAppId) { + validateAppId = oauthOptions.validateAppId; + } + } + // try the custom provider first, fallback on the oauth implementation + + if (!validateAuthData || !validateAppId) { + return false; + }; + + return validateAuthData(authData, oauthOptions) .then(() => { - return facebook.validateAppId(this.config.facebookAppIds, - facebookData.access_token); + if (appIds && typeof validateAppId === "function") { + return validateAppId(appIds, authData, oauthOptions); + } + + // No validation required by the developer + return Promise.resolve(); + }).then(() => { // Check if this user already exists // TODO: does this handle re-linking correctly? + var query = {}; + query['authData.' + provider + '.id'] = authData.id; return this.config.database.find( this.className, - {'authData.facebook.id': facebookData.id}, {}); + query, {}); }).then((results) => { - this.storage['authProvider'] = "facebook"; + this.storage['authProvider'] = provider; if (results.length > 0) { if (!this.query) { // We're signing up, but this user already exists. Short-circuit @@ -247,7 +298,7 @@ RestWrite.prototype.handleFacebookAuthData = function() { delete this.data.authData; return; } - // We're trying to create a duplicate FB auth. Forbid it + // We're trying to create a duplicate oauth auth. Forbid it throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } else { @@ -256,12 +307,12 @@ RestWrite.prototype.handleFacebookAuthData = function() { // This FB auth does not already exist, so transform it to a // saveable format - this.data._auth_data_facebook = facebookData; + this.data["_auth_data_" + provider ] = authData; // Delete the rest format key before saving delete this.data.authData; }); -}; +} // The non-third-party parts of User transformation RestWrite.prototype.transformUser = function() { diff --git a/src/facebook.js b/src/facebook.js index 5f9bbee8..77e0e213 100644 --- a/src/facebook.js +++ b/src/facebook.js @@ -3,10 +3,10 @@ var https = require('https'); var Parse = require('parse/node').Parse; // Returns a promise that fulfills iff this user id is valid. -function validateUserId(userId, access_token) { - return graphRequest('me?fields=id&access_token=' + access_token) +function validateAuthData(authData) { + return graphRequest('me?fields=id&access_token=' + authData.access_token) .then((data) => { - if (data && data.id == userId) { + if (data && data.id == authData.id) { return; } throw new Parse.Error( @@ -16,7 +16,8 @@ function validateUserId(userId, access_token) { } // Returns a promise that fulfills iff this app id is valid. -function validateAppId(appIds, access_token) { +function validateAppId(appIds, authData) { + var access_token = authData.access_token; if (!appIds.length) { throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, @@ -53,5 +54,5 @@ function graphRequest(path) { module.exports = { validateAppId: validateAppId, - validateUserId: validateUserId + validateAuthData: validateAuthData }; diff --git a/src/index.js b/src/index.js index a5355f6c..47b639f4 100644 --- a/src/index.js +++ b/src/index.js @@ -105,7 +105,8 @@ function ParseServer(args) { fileKey: args.fileKey || 'invalid-file-key', facebookAppIds: args.facebookAppIds || [], filesController: filesController, - enableAnonymousUsers: args.enableAnonymousUsers || true + enableAnonymousUsers: args.enableAnonymousUsers || true, + oauth: args.oauth || {}, }; // To maintain compatibility. TODO: Remove in v2.1 diff --git a/src/oauth/OAuth1Client.js b/src/oauth/OAuth1Client.js new file mode 100644 index 00000000..2c70be0c --- /dev/null +++ b/src/oauth/OAuth1Client.js @@ -0,0 +1,226 @@ +var https = require('https'), + crypto = require('crypto'); + +var OAuth = function(options) { + this.consumer_key = options.consumer_key; + this.consumer_secret = options.consumer_secret; + this.auth_token = options.auth_token; + this.auth_token_secret = options.auth_token_secret; + this.host = options.host; + this.oauth_params = options.oauth_params || {}; +}; + +OAuth.prototype.send = function(method, path, params, body){ + + var request = this.buildRequest(method, path, params, body); + // Encode the body properly, the current Parse Implementation don't do it properly + return new Promise(function(resolve, reject) { + var httpRequest = https.request(request, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to make an OAuth request'); + }); + if (request.body) { + httpRequest.write(request.body); + } + httpRequest.end(); + }); +}; + +OAuth.prototype.buildRequest = function(method, path, params, body) { + if (path.indexOf("/") != 0) { + path = "/"+path; + } + if (params && Object.keys(params).length > 0) { + path += "?" + OAuth.buildParameterString(params); + } + + var request = { + host: this.host, + path: path, + method: method.toUpperCase() + }; + + var oauth_params = this.oauth_params || {}; + oauth_params.oauth_consumer_key = this.consumer_key; + if(this.auth_token){ + oauth_params["oauth_token"] = this.auth_token; + } + + request = OAuth.signRequest(request, oauth_params, this.consumer_secret, this.auth_token_secret); + + if (body && Object.keys(body).length > 0) { + request.body = OAuth.buildParameterString(body); + } + return request; +} + +OAuth.prototype.get = function(path, params) { + return this.send("GET", path, params); +} + +OAuth.prototype.post = function(path, params, body) { + return this.send("POST", path, params, body); +} + +/* + Proper string %escape encoding +*/ +OAuth.encode = function(str) { + // discuss at: http://phpjs.org/functions/rawurlencode/ + // original by: Brett Zamir (http://brett-zamir.me) + // input by: travc + // input by: Brett Zamir (http://brett-zamir.me) + // input by: Michael Grier + // input by: Ratheous + // bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // bugfixed by: Brett Zamir (http://brett-zamir.me) + // bugfixed by: Joris + // reimplemented by: Brett Zamir (http://brett-zamir.me) + // reimplemented by: Brett Zamir (http://brett-zamir.me) + // note: This reflects PHP 5.3/6.0+ behavior + // note: Please be aware that this function expects to encode into UTF-8 encoded strings, as found on + // note: pages served as UTF-8 + // example 1: rawurlencode('Kevin van Zonneveld!'); + // returns 1: 'Kevin%20van%20Zonneveld%21' + // example 2: rawurlencode('http://kevin.vanzonneveld.net/'); + // returns 2: 'http%3A%2F%2Fkevin.vanzonneveld.net%2F' + // example 3: rawurlencode('http://www.google.nl/search?q=php.js&ie=utf-8&oe=utf-8&aq=t&rls=com.ubuntu:en-US:unofficial&client=firefox-a'); + // returns 3: 'http%3A%2F%2Fwww.google.nl%2Fsearch%3Fq%3Dphp.js%26ie%3Dutf-8%26oe%3Dutf-8%26aq%3Dt%26rls%3Dcom.ubuntu%3Aen-US%3Aunofficial%26client%3Dfirefox-a' + + str = (str + '') + .toString(); + + // Tilde should be allowed unescaped in future versions of PHP (as reflected below), but if you want to reflect current + // PHP behavior, you would need to add ".replace(/~/g, '%7E');" to the following. + return encodeURIComponent(str) + .replace(/!/g, '%21') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A'); +} + +OAuth.signatureMethod = "HMAC-SHA1"; +OAuth.version = "1.0"; + +/* + Generate a nonce +*/ +OAuth.nonce = function(){ + var text = ""; + var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for( var i=0; i < 30; i++ ) + text += possible.charAt(Math.floor(Math.random() * possible.length)); + + return text; +} + +OAuth.buildParameterString = function(obj){ + var result = {}; + + // Sort keys and encode values + if (obj) { + var keys = Object.keys(obj).sort(); + + // Map key=value, join them by & + return keys.map(function(key){ + return key + "=" + OAuth.encode(obj[key]); + }).join("&"); + } + + return ""; +} + +/* + Build the signature string from the object +*/ + +OAuth.buildSignatureString = function(method, url, parameters){ + return [method.toUpperCase(), OAuth.encode(url), OAuth.encode(parameters)].join("&"); +} + +/* + Retuns encoded HMAC-SHA1 from key and text +*/ +OAuth.signature = function(text, key){ + crypto = require("crypto"); + return OAuth.encode(crypto.createHmac('sha1', key).update(text).digest('base64')); +} + +OAuth.signRequest = function(request, oauth_parameters, consumer_secret, auth_token_secret){ + oauth_parameters = oauth_parameters || {}; + + // Set default values + if (!oauth_parameters.oauth_nonce) { + oauth_parameters.oauth_nonce = OAuth.nonce(); + } + if (!oauth_parameters.oauth_timestamp) { + oauth_parameters.oauth_timestamp = Math.floor(new Date().getTime()/1000); + } + if (!oauth_parameters.oauth_signature_method) { + oauth_parameters.oauth_signature_method = OAuth.signatureMethod; + } + if (!oauth_parameters.oauth_version) { + oauth_parameters.oauth_version = OAuth.version; + } + + if(!auth_token_secret){ + auth_token_secret=""; + } + // Force GET method if unset + if (!request.method) { + request.method = "GET" + } + + // Collect all the parameters in one signatureParameters object + var signatureParams = {}; + var parametersToMerge = [request.params, request.body, oauth_parameters]; + for(var i in parametersToMerge) { + var parameters = parametersToMerge[i]; + for(var k in parameters) { + signatureParams[k] = parameters[k]; + } + } + + // Create a string based on the parameters + var parameterString = OAuth.buildParameterString(signatureParams); + + // Build the signature string + var url = "https://"+request.host+""+request.path; + + var signatureString = OAuth.buildSignatureString(request.method, url, parameterString); + // Hash the signature string + var signatureKey = [OAuth.encode(consumer_secret), OAuth.encode(auth_token_secret)].join("&"); + + var signature = OAuth.signature(signatureString, signatureKey); + + // Set the signature in the params + oauth_parameters.oauth_signature = signature; + if(!request.headers){ + request.headers = {}; + } + + // Set the authorization header + var signature = Object.keys(oauth_parameters).sort().map(function(key){ + var value = oauth_parameters[key]; + return key+'="'+value+'"'; + }).join(", ") + + request.headers.Authorization = 'OAuth ' + signature; + + // Set the content type header + request.headers["Content-Type"] = "application/x-www-form-urlencoded"; + return request; + +} + +module.exports = OAuth; \ No newline at end of file diff --git a/src/oauth/facebook.js b/src/oauth/facebook.js new file mode 100644 index 00000000..822df711 --- /dev/null +++ b/src/oauth/facebook.js @@ -0,0 +1,57 @@ +// Helper functions for accessing the Facebook Graph API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData) { + return graphRequest('me?fields=id&access_token=' + authData.access_token) + .then((data) => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId(appIds, access_token) { + if (!appIds.length) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is not configured.'); + } + return graphRequest('app?access_token=' + access_token) + .then((data) => { + if (data && appIds.indexOf(data.id) != -1) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is invalid for this user.'); + }); +} + +// A promisey wrapper for FB graph requests. +function graphRequest(path) { + return new Promise(function(resolve, reject) { + https.get('https://graph.facebook.com/v2.5/' + path, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Facebook.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/oauth/github.js b/src/oauth/github.js new file mode 100644 index 00000000..ab6715b1 --- /dev/null +++ b/src/oauth/github.js @@ -0,0 +1,51 @@ +// Helper functions for accessing the github API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData) { + return request('user', authData.access_token) + .then((data) => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Github auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + return new Promise(function(resolve, reject) { + https.get({ + host: 'api.github.com', + path: '/' + path, + headers: { + 'Authorization': 'bearer '+access_token, + 'User-Agent': 'parse-server' + } + }, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Github.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/oauth/google.js b/src/oauth/google.js new file mode 100644 index 00000000..c339eae9 --- /dev/null +++ b/src/oauth/google.js @@ -0,0 +1,44 @@ +// Helper functions for accessing the google API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData) { + return request("tokeninfo?access_token="+authData.access_token) + .then((response) => { + if (response && response.user_id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Google auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path) { + return new Promise(function(resolve, reject) { + https.get("https://www.googleapis.com/oauth2/v1/" + path, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Google.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/oauth/index.js b/src/oauth/index.js new file mode 100644 index 00000000..f39aea07 --- /dev/null +++ b/src/oauth/index.js @@ -0,0 +1,17 @@ +var facebook = require('./facebook'); +var instagram = require("./instagram"); +var linkedin = require("./linkedin"); +var meetup = require("./meetup"); +var google = require("./google"); +var github = require("./github"); +var twitter = require("./twitter"); + +module.exports = { + facebook: facebook, + github: github, + google: google, + instagram: instagram, + linkedin: linkedin, + meetup: meetup, + twitter: twitter +} \ No newline at end of file diff --git a/src/oauth/instagram.js b/src/oauth/instagram.js new file mode 100644 index 00000000..03971695 --- /dev/null +++ b/src/oauth/instagram.js @@ -0,0 +1,44 @@ +// Helper functions for accessing the instagram API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData) { + return request("users/self/?access_token="+authData.access_token) + .then((response) => { + if (response && response.data && response.data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Instagram auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path) { + return new Promise(function(resolve, reject) { + https.get("https://api.instagram.com/v1/" + path, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Instagram.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/oauth/linkedin.js b/src/oauth/linkedin.js new file mode 100644 index 00000000..efcd13cd --- /dev/null +++ b/src/oauth/linkedin.js @@ -0,0 +1,51 @@ +// Helper functions for accessing the linkedin API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData) { + return request('people/~:(id)', authData.access_token) + .then((data) => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Meetup auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + return new Promise(function(resolve, reject) { + https.get({ + host: 'api.linkedin.com', + path: '/v1/' + path, + headers: { + 'Authorization': 'Bearer '+access_token, + 'x-li-format': 'json' + } + }, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Linkedin.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/oauth/meetup.js b/src/oauth/meetup.js new file mode 100644 index 00000000..04d16c5a --- /dev/null +++ b/src/oauth/meetup.js @@ -0,0 +1,50 @@ +// Helper functions for accessing the meetup API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData) { + return request('member/self', authData.access_token) + .then((data) => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Meetup auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for api requests +function request(path, access_token) { + return new Promise(function(resolve, reject) { + https.get({ + host: 'api.meetup.com', + path: '/2/' + path, + headers: { + 'Authorization': 'bearer '+access_token + } + }, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Meetup.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/oauth/twitter.js b/src/oauth/twitter.js new file mode 100644 index 00000000..b53ce333 --- /dev/null +++ b/src/oauth/twitter.js @@ -0,0 +1,30 @@ +// Helper functions for accessing the meetup API. +var OAuth = require('./OAuth1Client'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData, options) { + var client = new OAuth(options); + client.host = "api.twitter.com"; + client.auth_token = authData.auth_token; + client.auth_token_secret = authData.auth_token_secret; + + return client.get("/1.1/account/verify_credentials.json").then((data) => { + if (data && data.id == authData.id) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/transform.js b/src/transform.js index 802bf075..83a8017d 100644 --- a/src/transform.js +++ b/src/transform.js @@ -55,21 +55,21 @@ export function transformKeyValue(schema, className, restKey, restValue, options case '_wperm': return {key: key, value: restValue}; break; - case 'authData.anonymous.id': - if (options.query) { - return {key: '_auth_data_anonymous.id', value: restValue}; - } - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'can only query on ' + key); - break; - case 'authData.facebook.id': - if (options.query) { - // Special-case auth data. - return {key: '_auth_data_facebook.id', value: restValue}; - } - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'can only query on ' + key); - break; + // case 'authData.anonymous.id': + // if (options.query) { + // return {key: '_auth_data_anonymous.id', value: restValue}; + // } + // throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + // 'can only query on ' + key); + // break; + // case 'authData.facebook.id': + // if (options.query) { + // // Special-case auth data. + // return {key: '_auth_data_facebook.id', value: restValue}; + // } + // throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + // 'can only query on ' + key); + // break; case '$or': if (!options.query) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, @@ -97,6 +97,18 @@ export function transformKeyValue(schema, className, restKey, restValue, options }); return {key: '$and', value: mongoSubqueries}; default: + // Other auth data + var authDataMatch = key.match(/^authData\.([a-zA-Z0-9_]+)\.id$/); + if (authDataMatch) { + if (options.query) { + var provider = authDataMatch[1]; + // Special-case auth data. + return {key: '_auth_data_'+provider+'.id', value: restValue}; + } + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'can only query on ' + key); + break; + }; if (options.validate && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'invalid key name: ' + key); @@ -646,15 +658,16 @@ function untransformObject(schema, className, mongoObject) { case '_expiresAt': restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])).iso; break; - case '_auth_data_anonymous': - restObject['authData'] = restObject['authData'] || {}; - restObject['authData']['anonymous'] = mongoObject[key]; - break; - case '_auth_data_facebook': - restObject['authData'] = restObject['authData'] || {}; - restObject['authData']['facebook'] = mongoObject[key]; - break; default: + // Check other auth data keys + var authDataMatch = key.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + if (authDataMatch) { + var provider = authDataMatch[1]; + restObject['authData'] = restObject['authData'] || {}; + restObject['authData'][provider] = mongoObject[key]; + break; + } + if (key.indexOf('_p_') == 0) { var newKey = key.substring(3); var expected; From 45bf8ffadb60929bee64d5d58372e2b226238246 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 16 Feb 2016 08:00:00 -0500 Subject: [PATCH 26/29] removes commented out code --- src/transform.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/transform.js b/src/transform.js index 83a8017d..0e99b488 100644 --- a/src/transform.js +++ b/src/transform.js @@ -55,21 +55,6 @@ export function transformKeyValue(schema, className, restKey, restValue, options case '_wperm': return {key: key, value: restValue}; break; - // case 'authData.anonymous.id': - // if (options.query) { - // return {key: '_auth_data_anonymous.id', value: restValue}; - // } - // throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - // 'can only query on ' + key); - // break; - // case 'authData.facebook.id': - // if (options.query) { - // // Special-case auth data. - // return {key: '_auth_data_facebook.id', value: restValue}; - // } - // throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - // 'can only query on ' + key); - // break; case '$or': if (!options.query) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, From 077b977efad63f741f9ebc1b442753b5f049d9b1 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 16 Feb 2016 08:28:14 -0500 Subject: [PATCH 27/29] Updates README --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index 10b44d54..0a7365dd 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,61 @@ The client keys used with Parse are no longer necessary with parse-server. If y * restAPIKey * dotNetKey +#### OAuth Support + +parse-server supports 3rd party authentication with + +* Twitter +* Meetup +* Linkedin +* Google +* Instagram +* Facebook + + +Configuration options for these 3rd-party modules is done with the oauth option passed to ParseServer: + +``` +{ + oauth: { + twitter: { + consumer_key: "", // REQUIRED + consumer_secret: "" // REQUIRED + }, + facebook: { + appIds: "FACEBOOK APP ID" + } + } + +} +``` + +#### Custom Authentication + +It is possible to leverage the OAuth support with any 3rd party authentication that you bring in. + +``` +{ + + oauth: { + my_custom_auth: { + module: "PATH_TO_MODULE" // OR object, + option1: "", + option2: "", + } + } +} +``` + +On this module, you need to implement and export those two functions `validateAuthData(authData, options) {} ` and `validateAppId(appIds, authData) {}`. + +For more informations about custom auth please see the examples: + +- [facebook OAuth](https://github.com/ParsePlatform/parse-server/blob/master/src/oauth/facebook.js) +- [twitter OAuth](https://github.com/ParsePlatform/parse-server/blob/master/src/oauth/twitter.js) +- [instagram OAuth](https://github.com/ParsePlatform/parse-server/blob/master/src/oauth/instagram.js) + + #### Advanced options: * filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see [`FilesAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Files/FilesAdapter.js)) From 35fd06be7b6f83d4cd676777e51b1631020aff62 Mon Sep 17 00:00:00 2001 From: "Peter J. Shin" Date: Wed, 17 Feb 2016 10:26:40 -0800 Subject: [PATCH 28/29] Fixing babel issue. Addresses https://github.com/ParsePlatform/parse-server/issues/462 --- bin/parse-server | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/parse-server b/bin/parse-server index 0785b7f0..66d01041 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1,6 +1,6 @@ #!/usr/bin/env node var express = require('express'); -var ParseServer = require("../src/index").ParseServer; +var ParseServer = require("../lib/index").ParseServer; var app = express(); From 52c4c4b9e944bed3d5da9c51ae8391f1748b21ab Mon Sep 17 00:00:00 2001 From: Fosco Marotto Date: Wed, 17 Feb 2016 11:50:37 -0800 Subject: [PATCH 29/29] Updating to 2.1.0 --- CHANGELOG.md | 23 +++++++++++++++++++++++ package.json | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index beb534d9..46a6feba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ ## Parse Server Changelog +### 2.1.0 (2/17/2016) + +* Feature: Support for additional OAuth providers +* Feature: Ability to implement custom OAuth providers +* Feature: Support for deleting Parse Files +* Feature: Allow querying roles +* 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: Disable anonymous users via configuration. +* Experimental: Schemas API support for PUT operations +* Fix: Prevent installation ID from being added to User +* Fix: Becoming a user works properly with sessions +* Fix: Including multiple object when some object are unavailable will get all the objects that are available +* Fix: Invalid URL for Parse Files +* Fix: Making a query without a limit now returns 100 results +* Fix: Expose installation id in cloud code +* Fix: Correct username for Anonymous users +* Fix: Session token issue after fetching user +* Fix: Issues during install process +* Fix: Issue with Unity SDK sending _noBody + ### 2.0.8 (2/11/2016) * Add: support for Android and iOS push notifications diff --git a/package.json b/package.json index ae2b5331..a39b5ca1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "2.0.8", + "version": "2.1.0", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": {