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/README.md b/README.md index 92b7668f..0a7365dd 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,67 @@ 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)) * 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/bin/parse-server b/bin/parse-server index 7ec17927..66d01041 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/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": { 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/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js new file mode 100644 index 00000000..e7f31768 --- /dev/null +++ b/spec/OneSignalPushAdapter.spec.js @@ -0,0 +1,234 @@ + +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/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 799e21b0..893b1210 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' }); @@ -571,6 +587,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/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/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 368bea22..33da62e8 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(); } }); @@ -816,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, @@ -831,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) { @@ -874,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(); } @@ -895,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(); @@ -910,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(); } @@ -972,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(); }, @@ -1005,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"); @@ -1108,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", { @@ -1144,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", { @@ -1358,6 +1374,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'); @@ -1587,7 +1622,30 @@ describe('Parse.User testing', () => { }).then(function(newUser) { fail('Session should have been invalidated'); done(); - }, function() { + }, function(err) { + 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(); }); }); 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/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/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/spec/schemas.spec.js b/spec/schemas.spec.js index 68ac31c9..fd136df4 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,319 @@ 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 in put', 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'); + done(); + }); + }); + + 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('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', + 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(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({ + 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/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)); } 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'; diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js new file mode 100644 index 00000000..59a660f9 --- /dev/null +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -0,0 +1,230 @@ +"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; +var deepcopy = require('deepcopy'); + +function OneSignalPushAdapter(pushConfig) { + this.https = require('https'); + + this.validPushTypes = ['ios', '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['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) { + console.log("Sending notification to "+installations.length+" devices.") + 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) { + + data= deepcopy(data['data']); + + var 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(wasSuccessful) { + if (!wasSuccessful) { + return promise.reject("OneSignal Error"); + } + + if(offset >= tokenlength) { + promise.resolve() + } else { + this.sendNext(); + } + }.bind(this) + + this.sendNext = function() { + 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) + + this.sendNext() + + return promise; +} + +OneSignalPushAdapter.prototype.sendToGCM = function(data,tokens) { + data= deepcopy(data['data']); + + var 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(wasSuccessful) { + if (!wasSuccessful) { + return promise.reject("OneSIgnal Error"); + } + + if(offset >= tokenlength) { + promise.resolve() + } else { + this.sendNext(); + } + }.bind(this); + + 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) + + + 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) { + 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) { + console.log("Error connecting to OneSignal") + console.log(e); + cb(false); + }); + 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/Config.js b/src/Config.js index 06d7af94..aeb25a61 100644 --- a/src/Config.js +++ b/src/Config.js @@ -20,10 +20,12 @@ 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; + this.oauth = cacheInfo.oauth; this.mount = mount; } diff --git a/src/RestQuery.js b/src/RestQuery.js index 91ebe536..b5bec1fb 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -377,7 +377,11 @@ RestQuery.prototype.handleInclude = function() { this.include = this.include.slice(1); return this.handleInclude(); }); + } else if (this.include.length > 0) { + this.include = this.include.slice(1); + return this.handleInclude(); } + return pathResponse; }; @@ -415,6 +419,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/RestWrite.js b/src/RestWrite.js index 2a2b0ed2..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 (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)) { - 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() { @@ -306,7 +357,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) => { @@ -485,11 +536,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) { @@ -660,6 +706,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(() => { diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 11666b20..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); @@ -51,6 +53,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/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 5b894f75..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); @@ -41,8 +40,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 +49,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 +142,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', () => { 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/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/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 diff --git a/src/index.js b/src/index.js index fef09075..47b639f4 100644 --- a/src/index.js +++ b/src/index.js @@ -104,7 +104,9 @@ function ParseServer(args) { restAPIKey: args.restAPIKey || '', fileKey: args.fileKey || 'invalid-file-key', facebookAppIds: args.facebookAppIds || [], - filesController: filesController + filesController: filesController, + enableAnonymousUsers: args.enableAnonymousUsers || true, + oauth: args.oauth || {}, }; // To maintain compatibility. TODO: Remove in v2.1 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]) { 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/schemas.js b/src/schemas.js index 837224ab..cd8b92ec 100644 --- a/src/schemas.js +++ b/src/schemas.js @@ -7,36 +7,27 @@ var express = require('express'), var router = new PromiseRouter(); -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 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 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'}; @@ -55,10 +46,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 +57,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 +73,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 +99,94 @@ function createSchema(req) { })); } +function modifySchema(req) { + if (!req.auth.isMaster) { + return masterKeyRequiredResponse(); + } + + if (req.body.className && req.body.className != req.params.className) { + return classNameMismatchResponse(req.body.className, req.params.className); + } + + var submittedFields = req.body.fields || {}; + var className = req.params.className; + + return req.config.database.loadSchema() + .then(schema => { + if (!schema.data[className]) { + return Promise.resolve({ + status: 400, + response: { + code: Parse.Error.INVALID_CLASS_NAME, + error: 'class ' + req.params.className + ' does not exist', + } + }); + } + 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)}); + }) + })); + }); +} + 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; diff --git a/src/transform.js b/src/transform.js index 802bf075..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, @@ -97,6 +82,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 +643,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;