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/CONTRIBUTING.md b/CONTRIBUTING.md index 6a1923cf..ca0afdcb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,4 @@ We really want Parse to be yours, to see it grow and thrive in the open source c ##### Code of Conduct -This project adheres to the [Open Code of Conduct][code-of-conduct]. By participating, you are expected to honor this code. -[code-of-conduct]: http://todogroup.org/opencodeofconduct/#Parse Server/fjm@fb.com - - +This project adheres to the [Open Code of Conduct](http://todogroup.org/opencodeofconduct/#Parse Server/fjm@fb.com). By participating, you are expected to honor this code. diff --git a/README.md b/README.md index 92b7668f..19bf9e62 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,36 @@ -## parse-server +Parse logo + +## Parse Server [![Build Status](https://img.shields.io/travis/ParsePlatform/parse-server/master.svg?style=flat)](https://travis-ci.org/ParsePlatform/parse-server) [![Coverage Status](https://img.shields.io/codecov/c/github/ParsePlatform/parse-server/master.svg)](https://codecov.io/github/ParsePlatform/parse-server?branch=master) [![npm version](https://img.shields.io/npm/v/parse-server.svg?style=flat)](https://www.npmjs.com/package/parse-server) -A Parse.com API compatible router package for Express +Parse Server is an open source version of the Parse backend that can be deployed to any infrastructure that can run Node.js. + +Parse Server works with the Express web application framework. It can be added to existing web applications, or run by itself. Read the announcement blog post here: http://blog.parse.com/announcements/introducing-parse-server-and-the-database-migration-tool/ -Read the migration guide here: https://parse.com/docs/server/guide#migrating +## Documentation + +Documentation for Parse Server is available in the [wiki](https://github.com/ParsePlatform/parse-server/wiki) for this repository. The [Parse Server guide](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide) is a good place to get started. + +If you're interested in developing for Parse Server, the [Development guide](https://github.com/ParsePlatform/parse-server/wiki/Development-Guide) will help you get set up. + +### Example Project + +Check out the [parse-server-example project](https://github.com/ParsePlatform/parse-server-example) repository for an example of a Node.js application that uses the parse-server module on Express. + +### Migration Guide + +Migrate your existing Parse apps to your own Parse Server. The hosted version of Parse will be fully retired on January 28th, 2017. If you are planning to migrate an app, you need to begin work as soon as possible. Learn more in the [Migration guide](https://github.com/ParsePlatform/parse-server/wiki/Migrating-an-Existing-Parse-App). -There is a development wiki here on GitHub: https://github.com/ParsePlatform/parse-server/wiki -We also have an [example project](https://github.com/ParsePlatform/parse-server-example) using the parse-server module on Express. --- + #### Basic options: * databaseURI (required) - The connection string for your database, i.e. `mongodb://user:pass@host.com/dbname` @@ -36,12 +51,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 @@ -86,12 +156,12 @@ app.listen(port, function() { You can configure the Parse Server with environment variables: -```js +```js PARSE_SERVER_DATABASE_URI PARSE_SERVER_CLOUD_CODE_MAIN PARSE_SERVER_COLLECTION_PREFIX PARSE_SERVER_APPLICATION_ID // required -PARSE_SERVER_CLIENT_KEY +PARSE_SERVER_CLIENT_KEY PARSE_SERVER_REST_API_KEY PARSE_SERVER_DOTNET_KEY PARSE_SERVER_JAVASCRIPT_KEY @@ -137,3 +207,7 @@ You can also set up an app on Parse, providing the connection string for your mo ### Not supported * `Parse.User.current()` or `Parse.Cloud.useMasterKey()` in cloud code. Instead of `Parse.User.current()` use `request.user` and instead of `Parse.Cloud.useMasterKey()` pass `useMasterKey: true` to each query. To make queries and writes as a specific user within Cloud Code, you need the user's session token, which is available in `request.user.getSessionToken()`. + +## Contributing + +We really want Parse to be yours, to see it grow and thrive in the open source community. Please see the [Contributing to Parse Server guide](CONTRIBUTING.md). diff --git a/bin/parse-server b/bin/parse-server index 8d0104e4..66d01041 100755 --- a/bin/parse-server +++ b/bin/parse-server @@ -1,6 +1,6 @@ #!/usr/bin/env node var express = require('express'); -var ParseServer = require("../src/index").ParseServer; +var ParseServer = require("../lib/index").ParseServer; var app = express(); @@ -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/ParseUser.spec.js b/spec/ParseUser.spec.js index 5f0b28b7..33da62e8 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -831,9 +831,11 @@ describe('Parse.User testing', () => { // server-side. var getMockFacebookProvider = function() { return { - userId: "8675309", - authToken: "jenny", - expiration: new Date().toJSON(), + authData: { + id: "8675309", + access_token: "jenny", + expiration_date: new Date().toJSON(), + }, shouldError: false, loggedOut: false, synchronizedUserId: null, @@ -846,11 +848,7 @@ describe('Parse.User testing', () => { } else if (this.shouldCancel) { options.error(this, null); } else { - options.success(this, { - id: this.userId, - access_token: this.authToken, - expiration_date: this.expiration - }); + options.success(this, this.authData); } }, restoreAuthentication: function(authData) { @@ -889,13 +887,14 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook"); done(); }, error: function(model, error) { + console.error(model, error); ok(false, "linking should have worked"); done(); } @@ -910,9 +909,9 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook"); Parse.User.logOut(); @@ -925,20 +924,22 @@ describe('Parse.User testing', () => { "Model should be a Parse.User"); ok(innerModel === Parse.User.current(), "Returned model should be the current user"); - ok(provider.userId === provider.synchronizedUserId); - ok(provider.authToken === provider.synchronizedAuthToken); + ok(provider.authData.id === provider.synchronizedUserId); + ok(provider.authData.access_token === provider.synchronizedAuthToken); ok(innerModel._isLinked("facebook"), "User should be linked to facebook"); ok(innerModel.existed(), "User should not be newly-created"); done(); }, error: function(model, error) { + fail(error); ok(false, "LogIn should have worked"); done(); } }); }, error: function(model, error) { + console.error(model, error); ok(false, "LogIn should have worked"); done(); } @@ -987,9 +988,9 @@ describe('Parse.User testing', () => { success: function(model) { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked"); done(); }, @@ -1020,9 +1021,9 @@ describe('Parse.User testing', () => { success: function(model) { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked."); var user2 = new Parse.User(); user2.set("username", "testLinkWithProviderToAlreadyLinkedUser2"); @@ -1123,9 +1124,9 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User."); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook."); model._unlinkFrom("facebook", { @@ -1159,9 +1160,9 @@ describe('Parse.User testing', () => { ok(model instanceof Parse.User, "Model should be a Parse.User"); strictEqual(Parse.User.current(), model); ok(model.extended(), "Should have used the subclass."); - strictEqual(provider.userId, provider.synchronizedUserId); - strictEqual(provider.authToken, provider.synchronizedAuthToken); - strictEqual(provider.expiration, provider.synchronizedExpiration); + strictEqual(provider.authData.id, provider.synchronizedUserId); + strictEqual(provider.authData.access_token, provider.synchronizedAuthToken); + strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration); ok(model._isLinked("facebook"), "User should be linked to facebook"); model._unlinkFrom("facebook", { @@ -1367,7 +1368,7 @@ describe('Parse.User testing', () => { var b = JSON.parse(body); expect(b.results.length).toEqual(1); var user = b.results[0]; - expect(Object.keys(user).length).toEqual(7); + expect(Object.keys(user).length).toEqual(6); done(); }); }); diff --git a/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/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 7cf8074f..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; }; diff --git a/src/RestWrite.js b/src/RestWrite.js index 798e2bb3..88296b4e 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'); @@ -171,19 +171,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() { @@ -232,27 +239,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 @@ -271,7 +322,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 { @@ -280,12 +331,12 @@ RestWrite.prototype.handleFacebookAuthData = function() { // This FB auth does not already exist, so transform it to a // saveable format - this.data._auth_data_facebook = facebookData; + this.data["_auth_data_" + provider ] = authData; // Delete the rest format key before saving delete this.data.authData; }); -}; +} // The non-third-party parts of User transformation RestWrite.prototype.transformUser = function() { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index c6ba1c1b..946dfe54 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -23,7 +23,6 @@ export class UsersRouter extends ClassesRouter { handleCreate(req) { let data = deepcopy(req.body); - data.installationId = req.info.installationId; req.body = data; req.params.className = '_User'; return super.handleCreate(req); 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/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;