diff --git a/.travis.yml b/.travis.yml index 27a7b149..53dc9acc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,11 @@ language: node_js node_js: - "4.3" env: - - MONGODB_VERSION=2.6.11 - - MONGODB_VERSION=3.0.8 + global: + - CODE_COVERAGE=1 + matrix: + - MONGODB_VERSION=2.6.11 + - MONGODB_VERSION=3.0.8 cache: directories: - $HOME/.mongodb/versions/downloads diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2c5580..fb02c932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ ## Parse Server Changelog +### 2.1.4 (3/3/2016) + +* New: serverInfo endpoint that returns server version and info about the server's features +* Improvement: Add support for badges on iOS +* Improvement: Improve failure handling in cloud code http requests +* Improvement: Add support for queries on pointers and relations +* Improvement: Add support for multiple $in clauses in a query +* Improvement: Add allowClientClassCreation config option +* Improvement: Allow atomically setting subdocument keys +* Improvement: Allow arbitrarily deeply nested roles +* Improvement: Set proper content-type in S3 File Adapter +* Improvement: S3 adapter auto-creates buckets +* Improvement: Better error messages for many errors +* Performance: Improved algorithm for validating client keys +* Experimental: Parse Hooks and Hooks API +* Experimental: Email verification and password reset emails +* Experimental: Improve compatability of logs feature with Parse.com +* Fix: Fix for attempting to delete missing classes via schemas API +* Fix: Allow creation of system classes via schemas API +* Fix: Allow missing where cause in $select +* Fix: Improve handling of invalid object ids +* Fix: Replace query overwriting existing query +* Fix: Propagate installationId in cloud code triggers +* Fix: Session expiresAt is now a Date instead of a string +* Fix: Fix count queries +* Fix: Disallow _Role objects without names or without ACL +* Fix: Better handling of invalid types submitted +* Fix: beforeSave will not be triggered for attempts to save with invalid authData +* Fix: Fix duplicate device token issues on Android +* Fix: Allow empty authData on signup +* Fix: Allow Master Key Headers (CORS) +* Fix: Fix bugs if JavaScript key was not provided in server configuration +* Fix: Parse Files on objects can now be stored without URLs +* Fix: allow both objectId or installationId when modifying installation +* Fix: Command line works better when not given options + ### 2.1.3 (2/24/2016) * Feature: Add initial support for in-app purchases @@ -8,7 +44,7 @@ * Performance: Faster saves if not using beforeSave triggers * Fix: Send session token in response to current user endpoint * Fix: Remove triggers for _Session collection -* Fix: Improve compatability of Cloud Code beforeSave hook for newly created object +* Fix: Improve compatability of cloud code beforeSave hook for newly created object * Fix: ACL creation for master key only objects * Fix: Allow uploading files without Content-Type * Fix: Add features to http requrest to match Parse.com @@ -41,7 +77,7 @@ * Feature: Support for logs, extensible via Log Adapter * Feature: New Push Adapter for sending push notifications through OneSignal * Feature: Tighter default security for Users -* Feature: Pass parameters to Cloud Code in query string +* Feature: Pass parameters to cloud code in query string * Feature: Disable anonymous users via configuration. * Experimental: Schemas API support for PUT operations * Fix: Prevent installation ID from being added to User @@ -58,7 +94,7 @@ ### 2.0.8 (2/11/2016) * Add: support for Android and iOS push notifications -* Experimental: Cloud Code validation hooks (can mark as non-experimental after we have docs) +* Experimental: cloud code validation hooks (can mark as non-experimental after we have docs) * Experimental: support for schemas API (GET and POST only) * Experimental: support for Parse Config (GET and POST only) * Fix: Querying objects with equality constraint on array column diff --git a/README.md b/README.md index 640b637e..84538c4f 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ app.listen(1337, function() { ### Standalone Parse Server -Parse Server can also run as a standalone API server. +Parse Server can also run as a standalone API server. You can configure Parse Server with a configuration file, arguments and environment variables. -To start the server: +To start the server: `npm start -- --appId MYAPP --masterKey MASTER_KEY --serverURL http://localhost:1337/parse`. @@ -65,22 +65,6 @@ The default port is 1337, to use a different port set the PORT environment varia The standalone Parse Server can be configured using [environment variables](#configuration). -Please refer to the [configuration section](#configuration) or help; - -To get more help for running the parse-server standalone, you can run: - -`$ npm start -- --help` - -The standalone API server supports loading a configuration file in JSON format: - -`$ npm start -- path/to/your/config.json` - -The default port is 1337, to use a different port set the `--port` option: - -`$ npm start -- --port=8080 path/to/your/config.json` - -Please refer to the [configuration section](#configuration) or help; - You can also install Parse Server globally: `$ npm install -g parse-server` @@ -126,6 +110,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo * `databaseAdapter` (unfinished) - The backing store can be changed by creating an adapter class (see `DatabaseAdapter.js`) * `loggerAdapter` - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js)) * `enableAnonymousUsers` - Defaults to true. Set to false to disable anonymous users. +* `allowClientClassCreation` - Defaults to true. Set to false to disable client class creation. * `oauth` - Used to configure support for [3rd party authentication](https://github.com/ParsePlatform/parse-server/wiki/Parse-Server-Guide#oauth). * `maxUploadSize` - Defaults to 20mb. Max file size for uploads diff --git a/package.json b/package.json index 22b11e1c..b20b1432 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "2.1.3", + "version": "2.1.4", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { @@ -27,6 +27,7 @@ "deepcopy": "^0.6.1", "express": "^4.13.4", "gcloud": "^0.28.0", + "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", @@ -56,7 +57,7 @@ "dev": "npm run build && bin/dev", "build": "./node_modules/.bin/babel src/ -d lib/", "pretest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=3.0.8} ./node_modules/.bin/mongodb-runner start", - "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node ./node_modules/babel-istanbul/lib/cli.js cover -x **/spec/** ./node_modules/jasmine/bin/jasmine.js", + "test": "cross-env NODE_ENV=test TESTING=1 ./node_modules/.bin/babel-node $(if [ \"$CODE_COVERAGE\" = \"1\" ]; then echo './node_modules/babel-istanbul/lib/cli.js cover -x **/spec/**'; fi;) ./node_modules/jasmine/bin/jasmine.js", "posttest": "mongodb-runner stop", "start": "./bin/parse-server", "prepublish": "npm run build" diff --git a/public_html/invalid_link.html b/public_html/invalid_link.html new file mode 100644 index 00000000..66bdc788 --- /dev/null +++ b/public_html/invalid_link.html @@ -0,0 +1,43 @@ + + + + + Invalid Link + + +
+

Invalid Link

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

Successfully updated your password!

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

Successfully verified your email!

+ + diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index 80f30d6f..f32867e0 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -2,15 +2,17 @@ var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; -describe("AdaptableController", ()=>{ +describe("AdapterLoader", ()=>{ it("should instantiate an adapter from string in object", (done) => { var adapterPath = require('path').resolve("./spec/MockAdapter"); var adapter = loadAdapter({ adapter: adapterPath, - key: "value", - foo: "bar" + options: { + key: "value", + foo: "bar" + } }); expect(adapter instanceof Object).toBe(true); @@ -24,7 +26,6 @@ describe("AdaptableController", ()=>{ var adapter = loadAdapter(adapterPath); expect(adapter instanceof Object).toBe(true); - expect(adapter.options).toBe(adapterPath); done(); }); @@ -65,4 +66,22 @@ describe("AdaptableController", ()=>{ expect(adapter).toBe(originalAdapter); done(); }); + + it("should fail loading an improperly configured adapter", (done) => { + var Adapter = function(options) { + if (!options.foo) { + throw "foo is required for that adapter"; + } + } + var adapterOptions = { + param: "key", + doSomething: function() {} + }; + + expect(() => { + var adapter = loadAdapter(adapterOptions, Adapter); + expect(adapter).toEqual(adapterOptions); + }).not.toThrow("foo is required for that adapter"); + done(); + }); }); diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js new file mode 100644 index 00000000..3c55e1dd --- /dev/null +++ b/spec/DatabaseController.spec.js @@ -0,0 +1,17 @@ +'use strict'; + +let DatabaseController = require('../src/Controllers/DatabaseController'); +let MongoStorageAdapter = require('../src/Adapters/Storage/Mongo/MongoStorageAdapter'); + +describe('DatabaseController', () => { + it('can be constructed', done => { + let adapter = new MongoStorageAdapter('mongodb://localhost:27017/test'); + let databaseController = new DatabaseController(adapter, { + collectionPrefix: 'test_' + }); + databaseController.connect().then(done, error => { + console.log('error', error.stack); + fail(); + }); + }); +}); diff --git a/spec/ExportAdapter.spec.js b/spec/ExportAdapter.spec.js deleted file mode 100644 index a4f3f9b6..00000000 --- a/spec/ExportAdapter.spec.js +++ /dev/null @@ -1,15 +0,0 @@ -var ExportAdapter = require('../src/ExportAdapter'); - -describe('ExportAdapter', () => { - it('can be constructed', (done) => { - var database = new ExportAdapter('mongodb://localhost:27017/test', - { - collectionPrefix: 'test_' - }); - database.connect().then(done, (error) => { - console.log('error', error.stack); - fail(); - }); - }); - -}); diff --git a/spec/FilesController.spec.js b/spec/FilesController.spec.js index 67b36de9..183dcb27 100644 --- a/spec/FilesController.spec.js +++ b/spec/FilesController.spec.js @@ -1,29 +1,52 @@ var FilesController = require('../src/Controllers/FilesController').FilesController; var GridStoreAdapter = require("../src/Adapters/Files/GridStoreAdapter").GridStoreAdapter; +var S3Adapter = require("../src/Adapters/Files/S3Adapter").S3Adapter; +var GCSAdapter = require("../src/Adapters/Files/GCSAdapter").GCSAdapter; var Config = require("../src/Config"); +var FCTestFactory = require("./FilesControllerTestFactory"); + + // Small additional tests to improve overall coverage describe("FilesController",()=>{ - - it("should properly expand objects", (done) => { - var config = new Config(Parse.applicationId); - var adapter = new GridStoreAdapter(); - var filesController = new FilesController(adapter); - var result = filesController.expandFilesInObject(config, function(){}); - - expect(result).toBeUndefined(); - - var fullFile = { - type: '__type', - url: "http://an.url" - } - - var anObject = { - aFile: fullFile - } - filesController.expandFilesInObject(config, anObject); - expect(anObject.aFile.url).toEqual("http://an.url"); - - done(); - }) -}) \ No newline at end of file + + // Test the grid store adapter + var gridStoreAdapter = new GridStoreAdapter('mongodb://localhost:27017/parse'); + FCTestFactory.testAdapter("GridStoreAdapter", gridStoreAdapter); + + if (process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY) { + + // Test the S3 Adapter + var s3Adapter = new S3Adapter(process.env.S3_ACCESS_KEY, process.env.S3_SECRET_KEY, 'parse.server.tests'); + + FCTestFactory.testAdapter("S3Adapter",s3Adapter); + + // Test S3 with direct access + var s3DirectAccessAdapter = new S3Adapter(process.env.S3_ACCESS_KEY, process.env.S3_SECRET_KEY, 'parse.server.tests', { + directAccess: true + }); + + FCTestFactory.testAdapter("S3AdapterDirect", s3DirectAccessAdapter); + + } else if (!process.env.TRAVIS) { + console.log("set S3_ACCESS_KEY and S3_SECRET_KEY to test S3Adapter") + } + + if (process.env.GCP_PROJECT_ID && process.env.GCP_KEYFILE_PATH && process.env.GCS_BUCKET_NAME) { + + // Test the GCS Adapter + var gcsAdapter = new GCSAdapter(process.env.GCP_PROJECT_ID, process.env.GCP_KEYFILE_PATH, process.env.GCS_BUCKET_NAME); + + FCTestFactory.testAdapter("GCSAdapter", gcsAdapter); + + // Test GCS with direct access + var gcsDirectAccessAdapter = new GCSAdapter(process.env.GCP_PROJECT_ID, process.env.GCP_KEYFILE_PATH, process.env.GCS_BUCKET_NAME, { + directAccess: true + }); + + FCTestFactory.testAdapter("GCSAdapterDirect", gcsDirectAccessAdapter); + + } else if (!process.env.TRAVIS) { + console.log("set GCP_PROJECT_ID, GCP_KEYFILE_PATH, and GCS_BUCKET_NAME to test GCSAdapter") + } +}); diff --git a/spec/FilesControllerTestFactory.js b/spec/FilesControllerTestFactory.js new file mode 100644 index 00000000..217a383a --- /dev/null +++ b/spec/FilesControllerTestFactory.js @@ -0,0 +1,73 @@ + +var FilesController = require('../src/Controllers/FilesController').FilesController; +var Config = require("../src/Config"); + +var testAdapter = function(name, adapter) { + // Small additional tests to improve overall coverage + + var config = new Config(Parse.applicationId); + var filesController = new FilesController(adapter); + + describe("FilesController with "+name,()=>{ + + it("should properly expand objects", (done) => { + + var result = filesController.expandFilesInObject(config, function(){}); + + expect(result).toBeUndefined(); + + var fullFile = { + type: '__type', + url: "http://an.url" + } + + var anObject = { + aFile: fullFile + } + filesController.expandFilesInObject(config, anObject); + expect(anObject.aFile.url).toEqual("http://an.url"); + + done(); + }) + + it("should properly create, read, delete files", (done) => { + var filename; + filesController.createFile(config, "file.txt", "hello world").then( (result) => { + ok(result.url); + ok(result.name); + filename = result.name; + expect(result.name.match(/file.txt/)).not.toBe(null); + return filesController.getFileData(config, filename); + }, (err) => { + fail("The adapter should create the file"); + console.error(err); + done(); + }).then((result) => { + expect(result instanceof Buffer).toBe(true); + expect(result.toString('utf-8')).toEqual("hello world"); + return filesController.deleteFile(config, filename); + }, (err) => { + fail("The adapter should get the file"); + console.error(err); + done(); + }).then((result) => { + + filesController.getFileData(config, filename).then((res) => { + fail("the file should be deleted"); + done(); + }, (err) => { + done(); + }); + + }, (err) => { + fail("The adapter should delete the file"); + console.error(err); + done(); + }); + }, 5000); // longer tests + }); +} + +module.exports = { + testAdapter: testAdapter +} diff --git a/spec/HTTPRequest.spec.js b/spec/HTTPRequest.spec.js index e599dd5d..ad4e289f 100644 --- a/spec/HTTPRequest.spec.js +++ b/spec/HTTPRequest.spec.js @@ -177,5 +177,20 @@ describe("httpRequest", () => { var result = httpRequest.encodeBody({"foo": "bar", "bar": "baz"}, {'X-Custom-Header': 'my-header'}); expect(result).toEqual({"foo": "bar", "bar": "baz"}); done(); + }); + + it("should fail gracefully", (done) => { + httpRequest({ + url: "http://not a good url", + success: function() { + fail("should not succeed"); + done(); + }, + error: function(error) { + expect(error).not.toBeUndefined(); + expect(error).not.toBeNull(); + done(); + } + }); }) }); diff --git a/spec/LogsRouter.spec.js b/spec/LogsRouter.spec.js index a8ef8b25..e8907a39 100644 --- a/spec/LogsRouter.spec.js +++ b/spec/LogsRouter.spec.js @@ -1,3 +1,6 @@ +'use strict'; + +const request = require('request'); var LogsRouter = require('../src/Routers/LogsRouter').LogsRouter; var LoggerController = require('../src/Controllers/LoggerController').LoggerController; var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; @@ -20,7 +23,7 @@ describe('LogsRouter', () => { var router = new LogsRouter(); expect(() => { - router.handleGET(request); + router.validateRequest(request); }).not.toThrow(); done(); }); @@ -40,28 +43,23 @@ describe('LogsRouter', () => { var router = new LogsRouter(); expect(() => { - router.handleGET(request); + router.validateRequest(request); }).toThrow(); done(); }); - it('can check invalid master key of request', (done) => { - // Make mock request - var request = { - auth: { - isMaster: false - }, - query: {}, - config: { - loggerController: loggerController + it('can check invalid master key of request', done => { + request.get({ + url: 'http://localhost:8378/1/scriptlog', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' } - }; - - var router = new LogsRouter(); - - expect(() => { - router.handleGET(request); - }).toThrow(); - done(); + }, (error, response, body) => { + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); + done(); + }); }); }); diff --git a/spec/MockAdapter.js b/spec/MockAdapter.js index 60d8ef86..c3f55784 100644 --- a/spec/MockAdapter.js +++ b/spec/MockAdapter.js @@ -1,3 +1,5 @@ module.exports = function(options) { - this.options = options; -} + return { + options: options + }; +}; diff --git a/spec/MockEmailAdapter.js b/spec/MockEmailAdapter.js new file mode 100644 index 00000000..b143e37e --- /dev/null +++ b/spec/MockEmailAdapter.js @@ -0,0 +1,5 @@ +module.exports = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() +} diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js new file mode 100644 index 00000000..8a3095e2 --- /dev/null +++ b/spec/MockEmailAdapterWithOptions.js @@ -0,0 +1,10 @@ +module.exports = options => { + if (!options) { + throw "Options were not provided" + } + return { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } +} diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index 2c165c45..77b958c5 100644 --- a/spec/OneSignalPushAdapter.spec.js +++ b/spec/OneSignalPushAdapter.spec.js @@ -1,13 +1,16 @@ +'use strict'; var OneSignalPushAdapter = require('../src/Adapters/Push/OneSignalPushAdapter'); var classifyInstallations = require('../src/Adapters/Push/PushAdapterUtils').classifyInstallations; + +// Make mock config +var pushConfig = { + oneSignalAppId:"APP ID", + oneSignalApiKey:"API KEY" +}; + describe('OneSignalPushAdapter', () => { it('can be initialized', (done) => { - // Make mock config - var pushConfig = { - oneSignalAppId:"APP ID", - oneSignalApiKey:"API KEY" - }; var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); @@ -17,9 +20,17 @@ describe('OneSignalPushAdapter', () => { expect(senderMap.android instanceof Function).toBe(true); done(); }); + + it('cannot be initialized if options are missing', (done) => { + + expect(() => { + new OneSignalPushAdapter(); + }).toThrow("Trying to initialize OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"); + done(); + }); it('can get valid push types', (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); expect(oneSignalPushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); done(); @@ -56,7 +67,7 @@ describe('OneSignalPushAdapter', () => { it('can send push notifications', (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); // Mock android ios senders var androidSender = jasmine.createSpy('send') @@ -108,7 +119,7 @@ describe('OneSignalPushAdapter', () => { }); it("can send iOS notifications", (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; @@ -135,7 +146,7 @@ describe('OneSignalPushAdapter', () => { }); it("can send Android notifications", (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; @@ -157,10 +168,7 @@ describe('OneSignalPushAdapter', () => { }); it("can post the correct data", (done) => { - var pushConfig = { - oneSignalAppId:"APP ID", - oneSignalApiKey:"API KEY" - }; + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var write = jasmine.createSpy('write'); @@ -203,7 +211,7 @@ describe('OneSignalPushAdapter', () => { expect(write).toHaveBeenCalled(); // iOS - args = write.calls.first().args; + let args = write.calls.first().args; expect(args[0]).toEqual(JSON.stringify({ 'contents': { 'en':'Example content'}, 'content_available':true, @@ -212,7 +220,7 @@ describe('OneSignalPushAdapter', () => { 'app_id':'APP ID' })); - // Android + // Android args = write.calls.mostRecent().args; expect(args[0]).toEqual(JSON.stringify({ 'contents': { 'en':'Example content'}, diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 7bc7aa0a..42ac3491 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -372,6 +372,15 @@ describe('miscellaneous', function() { done(); }); }); + + it('test cloud function shoud echo keys', function(done) { + Parse.Cloud.run('echoKeys').then((result) => { + expect(result.applicationId).toEqual(Parse.applicationId); + expect(result.masterKey).toEqual(Parse.masterKey); + expect(result.javascriptKey).toEqual(Parse.javascriptKey); + done(); + }); + }); it('test rest_create_app', function(done) { var appId; @@ -683,6 +692,46 @@ describe('miscellaneous', function() { }); }); + it('afterSave flattens custom operations', done => { + var triggerTime = 0; + // Register a mock beforeSave hook + Parse.Cloud.afterSave('GameScore', function(req, res) { + let object = req.object; + expect(object instanceof Parse.Object).toBeTruthy(); + let originalObject = req.original; + if (triggerTime == 0) { + // Create + expect(object.get('yolo')).toEqual(1); + } else if (triggerTime == 1) { + // Update + expect(object.get('yolo')).toEqual(2); + // Check the originalObject + expect(originalObject.get('yolo')).toEqual(1); + } else { + res.error(); + } + triggerTime++; + res.success(); + }); + + var obj = new Parse.Object('GameScore'); + obj.increment('yolo', 1); + obj.save().then(() => { + obj.increment('yolo', 1); + return obj.save(); + }).then(() => { + // Make sure the checking has been triggered + expect(triggerTime).toBe(2); + // Clear mock afterSave + Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); + done(); + }, error => { + console.error(error); + fail(error); + done(); + }); + }); + it('test cloud function error handling', (done) => { // Register a function which will fail Parse.Cloud.define('willFail', (req, res) => { @@ -700,6 +749,80 @@ describe('miscellaneous', function() { }); }); + it('test beforeSave/afterSave get installationId', function(done) { + let triggerTime = 0; + Parse.Cloud.beforeSave('GameScore', function(req, res) { + triggerTime++; + expect(triggerTime).toEqual(1); + expect(req.installationId).toEqual('yolo'); + res.success(); + }); + Parse.Cloud.afterSave('GameScore', function(req) { + triggerTime++; + expect(triggerTime).toEqual(2); + expect(req.installationId).toEqual('yolo'); + }); + + var headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore', + body: JSON.stringify({ a: 'b' }) + }, (error, response, body) => { + expect(error).toBe(null); + expect(triggerTime).toEqual(2); + + Parse.Cloud._removeHook("Triggers", "beforeSave", "GameScore"); + Parse.Cloud._removeHook("Triggers", "afterSave", "GameScore"); + done(); + }); + }); + + it('test beforeDelete/afterDelete get installationId', function(done) { + let triggerTime = 0; + Parse.Cloud.beforeDelete('GameScore', function(req, res) { + triggerTime++; + expect(triggerTime).toEqual(1); + expect(req.installationId).toEqual('yolo'); + res.success(); + }); + Parse.Cloud.afterDelete('GameScore', function(req) { + triggerTime++; + expect(triggerTime).toEqual(2); + expect(req.installationId).toEqual('yolo'); + }); + + var headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Installation-Id': 'yolo' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore', + body: JSON.stringify({ a: 'b' }) + }, (error, response, body) => { + expect(error).toBe(null); + request.del({ + headers: headers, + url: 'http://localhost:8378/1/classes/GameScore/' + JSON.parse(body).objectId + }, (error, response, body) => { + expect(error).toBe(null); + expect(triggerTime).toEqual(2); + + Parse.Cloud._removeHook("Triggers", "beforeDelete", "GameScore"); + Parse.Cloud._removeHook("Triggers", "afterDelete", "GameScore"); + done(); + }); + }); + }); + it('test cloud function query parameters', (done) => { Parse.Cloud.define('echoParams', (req, res) => { res.success(req.params); @@ -844,4 +967,32 @@ describe('miscellaneous', function() { }); }); + it('dedupes an installation properly and returns updatedAt', (done) => { + let headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + let data = { + 'installationId': 'lkjsahdfkjhsdfkjhsdfkjhsdf', + 'deviceType': 'embedded' + }; + let requestOptions = { + headers: headers, + url: 'http://localhost:8378/1/installations', + body: JSON.stringify(data) + }; + request.post(requestOptions, (error, response, body) => { + expect(error).toBe(null); + let b = JSON.parse(body); + expect(typeof b.objectId).toEqual('string'); + request.post(requestOptions, (error, response, body) => { + expect(error).toBe(null); + let b = JSON.parse(body); + expect(typeof b.updatedAt).toEqual('string'); + done(); + }); + }); + }); + }); diff --git a/spec/ParseFile+GCS.spec.js b/spec/ParseFile+GCS.spec.js deleted file mode 100644 index ae6543e2..00000000 --- a/spec/ParseFile+GCS.spec.js +++ /dev/null @@ -1,400 +0,0 @@ -// This is a port of the test suite: -// hungry/js/test/parse_file_test.js - -"use strict"; - -var request = require('request'); -var GCSAdapter = require('../src/index').GCSAdapter; - -var str = "Hello World!"; -var data = []; -for (var i = 0; i < str.length; i++) { - data.push(str.charCodeAt(i)); -} - -// Make sure that you fill these in, otherwise the tests won't run!!! -var GCP_PROJECT_ID = ""; -var GCP_KEYFILE_PATH = ""; -var GCS_BUCKET_NAME = ""; - -// Note the 'xdescribe', make sure to delete the 'x' once the above vars -// are filled in to run the test suite -xdescribe('Parse.File GCS testing', () => { - describe('GCS directAccess: false', () => { - beforeEach(function(done){ - var port = 8378; - var GCSConfiguration = { - databaseURI: process.env.DATABASE_URI, - serverURL: 'http://localhost:' + port + '/1', - appId: 'test', - javascriptKey: 'test', - restAPIKey: 'rest', - masterKey: 'test', - fileKey: 'test', - filesAdapter: new GCSAdapter( - GCP_PROJECT_ID, - GCP_KEYFILE_PATH, - GCS_BUCKET_NAME, - { - bucketPrefix: 'private/', - directAccess: false - } - ) - }; - setServerConfiguration(GCSConfiguration); - done(); - }); - - it('works with Content-Type', done => { - var headers = { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file.txt', - body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); - done(); - }); - }); - }); - - it('works without Content-Type', done => { - var headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file.txt', - body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*file.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); - done(); - }); - }); - }); - - it('supports REST end-to-end file create, read, delete, read', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/testfile.txt', - body: 'check one two', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_testfile.txt$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('check one two'); - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'test' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toEqual(200); - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }, - url: b.url - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toEqual(404); - done(); - }); - }); - }); - }); - }); - - it('blocks file deletions with missing or incorrect master-key header', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/thefile.jpg', - body: 'the file body' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); - // missing X-Parse-Master-Key header - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b = JSON.parse(body); - expect(response.statusCode).toEqual(403); - expect(del_b.error).toMatch(/unauthorized/); - // incorrect X-Parse-Master-Key header - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'tryagain' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b2 = JSON.parse(body); - expect(response.statusCode).toEqual(403); - expect(del_b2.error).toMatch(/unauthorized/); - done(); - }); - }); - }); - }); - - it('handles other filetypes', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file.jpg', - body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.jpg$/); - expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/.*file.jpg$/); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); - done(); - }); - }); - }); - }); - - describe('GCS directAccess: true', () => { - beforeEach(function(done){ - var port = 8378; - var GCSConfiguration = { - databaseURI: process.env.DATABASE_URI, - serverURL: 'http://localhost:' + port + '/1', - appId: 'test', - javascriptKey: 'test', - restAPIKey: 'rest', - masterKey: 'test', - fileKey: 'test', - filesAdapter: new GCSAdapter( - GCP_PROJECT_ID, - GCP_KEYFILE_PATH, - GCS_BUCKET_NAME, - { - bucketPrefix: 'public/', - directAccess: true - } - ) - }; - setServerConfiguration(GCSConfiguration); - done(); - }); - - it('works with Content-Type', done => { - var headers = { - 'Content-Type': 'application/octet-stream', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file.txt', - body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.txt$/); - var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*file.txt") - expect(b.url).toMatch(gcsRegex); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); - done(); - }); - }); - }); - - it('works without Content-Type', done => { - var headers = { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file.txt', - body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.txt$/); - var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*file.txt") - expect(b.url).toMatch(gcsRegex); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); - done(); - }); - }); - }); - - it('supports REST end-to-end file create, read, delete, read', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - // Create the file - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/testfile.txt', - body: 'check one two', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_testfile.txt$/); - var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*testfile.txt") - expect(b.url).toMatch(gcsRegex); - // Read the file the first time - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('check one two'); - // Delete the file - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'test' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toEqual(200); - // Read the file the second time--expect it to be gone - // Note that we're reading from the public cloud storage URL - // This is different from the above test since it's assumed - // users are reading from the public URL - request.get({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }, - url: "https://" + GCS_BUCKET_NAME + ".storage.googleapis.com/public/.*testfile.txt" - }, (error, response, body) => { - expect(error).toBe(null); - expect(response.statusCode).toEqual(404); - done(); - }); - }); - }); - }); - }); - - it('blocks file deletions with missing or incorrect master-key header', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/thefile.jpg', - body: 'the file body' - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*thefile.jpg") - expect(b.url).toMatch(gcsRegex); - // missing X-Parse-Master-Key header - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b = JSON.parse(body); - expect(response.statusCode).toEqual(403); - expect(del_b.error).toMatch(/unauthorized/); - // incorrect X-Parse-Master-Key header - request.del({ - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest', - 'X-Parse-Master-Key': 'tryagain' - }, - url: 'http://localhost:8378/1/files/' + b.name - }, (error, response, body) => { - expect(error).toBe(null); - var del_b2 = JSON.parse(body); - expect(response.statusCode).toEqual(403); - expect(del_b2.error).toMatch(/unauthorized/); - done(); - }); - }); - }); - }); - - it('handles other filetypes', done => { - var headers = { - 'Content-Type': 'image/jpeg', - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'rest' - }; - request.post({ - headers: headers, - url: 'http://localhost:8378/1/files/file.jpg', - body: 'argle bargle', - }, (error, response, body) => { - expect(error).toBe(null); - var b = JSON.parse(body); - expect(b.name).toMatch(/_file.jpg$/); - var gcsRegex = new RegExp("https:\/\/" + GCS_BUCKET_NAME + ".storage.googleapis.com\/public\/.*file.jpg") - expect(b.url).toMatch(gcsRegex); - request.get(b.url, (error, response, body) => { - expect(error).toBe(null); - expect(body).toEqual('argle bargle'); - done(); - }); - }); - }); - }); -}); diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 8c29ee48..399c9ee6 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -2,13 +2,12 @@ var request = require('request'); var Parse = require('parse/node').Parse; -var DatabaseAdapter = require('../src/DatabaseAdapter'); - -let database = DatabaseAdapter.getDatabaseConnection('test', 'test_'); +let Config = require('../src/Config'); describe('a GlobalConfig', () => { beforeEach(function(done) { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.updateOne({ '_id': 1}, { $set: { params: { companies: ['US', 'DK'] } } }, { upsert: true })) .then(done()); }); @@ -54,14 +53,15 @@ describe('a GlobalConfig', () => { 'X-Parse-REST-API-Key': 'rest' }, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('unauthorized'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); it('failed getting config when it is missing', (done) => { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.deleteOne({ '_id': 1}, {}, {})) .then(_ => { request.get({ diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 1fb1950f..ef35a944 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -446,6 +446,52 @@ describe('Installations', () => { }); }); + it('update android device token with duplicate device token', (done) => { + var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; + var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; + var t = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + var input = { + 'installationId': installId1, + 'deviceToken': t, + 'deviceType': 'android' + }; + var firstObject; + var secondObject; + rest.create(config, auth.nobody(config), '_Installation', input) + .then(() => { + input = { + 'installationId': installId2, + 'deviceType': 'android' + }; + return rest.create(config, auth.nobody(config), '_Installation', input); + }).then(() => { + return database.mongoFind('_Installation', + {installationId: installId1}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + firstObject = results[0]; + return database.mongoFind('_Installation', + {installationId: installId2}, {}); + }).then((results) => { + expect(results.length).toEqual(1); + secondObject = results[0]; + // Update second installation to conflict with first installation + input = { + 'objectId': secondObject._id, + 'deviceToken': t + }; + return rest.update(config, auth.nobody(config), '_Installation', + secondObject._id, input); + }).then(() => { + // The first object should have been deleted + return database.mongoFind('_Installation', {_id: firstObject._id}, {}); + }).then((results) => { + expect(results.length).toEqual(0); + done(); + }).catch((error) => { console.log(error); }); + }); + + it('update ios device token with duplicate device token', (done) => { var installId1 = '11111111-abcd-abcd-abcd-123456789abc'; var installId2 = '22222222-abcd-abcd-abcd-123456789abc'; diff --git a/spec/ParseObject.spec.js b/spec/ParseObject.spec.js index 5d899655..9f14abfe 100644 --- a/spec/ParseObject.spec.js +++ b/spec/ParseObject.spec.js @@ -1,3 +1,4 @@ +"use strict"; // This is a port of the test suite: // hungry/js/test/parse_object_test.js // @@ -336,6 +337,34 @@ describe('Parse.Object testing', () => { item.save({ "foo^bar": "baz" }).then(fail, done); }); + it("invalid __type", function(done) { + var item = new Parse.Object("Item"); + var types = ['Pointer', 'File', 'Date', 'GeoPoint', 'Bytes']; + var Error = Parse.Error; + var tests = types.map(type => { + var test = new Parse.Object("Item"); + test.set('foo', { + __type: type + }); + return test; + }); + var next = function(index) { + if (index < tests.length) { + tests[index].save().then(fail, error => { + expect(error.code).toEqual(Parse.Error.INCORRECT_TYPE); + next(index + 1); + }); + } else { + done(); + } + } + item.save({ + "foo": { + __type: "IvalidName" + } + }).then(fail, err => next(0)); + }); + it("simple field deletion", function(done) { var simple = new Parse.Object("SimpleObject"); simple.save({ @@ -1763,6 +1792,55 @@ describe('Parse.Object testing', () => { console.error(err); fail("should not fail"); done(); + }); + }); + + it('should have undefined includes when object is missing', (done) => { + let obj1 = new Parse.Object("AnObject"); + let obj2 = new Parse.Object("AnObject"); + + Parse.Object.saveAll([obj1, obj2]).then(() => { + obj1.set("obj", obj2); + // Save the pointer, delete the pointee + return obj1.save().then(() => { return obj2.destroy() }); + }).then(() => { + let query = new Parse.Query("AnObject"); + query.include("obj"); + return query.find(); + }).then((res) => { + expect(res.length).toBe(1); + expect(res[0].get("obj")).toBe(undefined); + let query = new Parse.Query("AnObject"); + return query.find(); + }).then((res) => { + expect(res.length).toBe(1); + expect(res[0].get("obj")).not.toBe(undefined); + return res[0].get("obj").fetch(); + }).then(() => { + fail("Should not fetch a deleted object"); + }, (err) => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); }) - }) + }); + + it('should have undefined includes when object is missing on deeper path', (done) => { + let obj1 = new Parse.Object("AnObject"); + let obj2 = new Parse.Object("AnObject"); + let obj3 = new Parse.Object("AnObject"); + Parse.Object.saveAll([obj1, obj2, obj3]).then(() => { + obj1.set("obj", obj2); + obj2.set("obj", obj3); + // Save the pointer, delete the pointee + return Parse.Object.saveAll([obj1, obj2]).then(() => { return obj3.destroy() }); + }).then(() => { + let query = new Parse.Query("AnObject"); + query.include("obj.obj"); + return query.get(obj1.id); + }).then((res) => { + expect(res.get("obj")).not.toBe(undefined); + expect(res.get("obj").get("obj")).toBe(undefined); + done(); + }); + }); }); diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index f5b6dc1a..9171d12c 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -2088,4 +2088,60 @@ describe('Parse.Query testing', () => { console.log(error); }); }); + + // #371 + it('should properly interpret a query', (done) => { + var query = new Parse.Query("C1"); + var auxQuery = new Parse.Query("C1"); + query.matchesKeyInQuery("A1", "A2", auxQuery); + query.include("A3"); + query.include("A2"); + query.find().then((result) => { + done(); + }, (err) => { + console.error(err); + fail("should not failt"); + done(); + }) + }); + + it('should properly interpret a query', (done) => { + var user = new Parse.User(); + user.set("username", "foo"); + user.set("password", "bar"); + return user.save().then( (user) => { + var objIdQuery = new Parse.Query("_User").equalTo("objectId", user.id); + var blockedUserQuery = user.relation("blockedUsers").query(); + + var aResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); + aResponseQuery.equalTo("userA", user); + aResponseQuery.equalTo("userAResponse", 1); + + var bResponseQuery = new Parse.Query("MatchRelationshipActivityResponse"); + bResponseQuery.equalTo("userB", user); + bResponseQuery.equalTo("userBResponse", 1); + + var matchOr = Parse.Query.or(aResponseQuery, bResponseQuery); + var matchRelationshipA = new Parse.Query("_User"); + matchRelationshipA.matchesKeyInQuery("objectId", "userAObjectId", matchOr); + var matchRelationshipB = new Parse.Query("_User"); + matchRelationshipB.matchesKeyInQuery("objectId", "userBObjectId", matchOr); + + + var orQuery = Parse.Query.or(objIdQuery, blockedUserQuery, matchRelationshipA, matchRelationshipB); + var query = new Parse.Query("_User"); + query.doesNotMatchQuery("objectId", orQuery); + return query.find(); + }).then((res) => { + done(); + done(); + }, (err) => { + console.error(err); + fail("should not fail"); + done(); + }); + + + }); + }); diff --git a/spec/ParseRelation.spec.js b/spec/ParseRelation.spec.js index e8e7258c..e1416ecb 100644 --- a/spec/ParseRelation.spec.js +++ b/spec/ParseRelation.spec.js @@ -237,7 +237,7 @@ describe('Parse.Relation testing', () => { success: function(list) { equal(list.length, 1, "There should be only one result"); equal(list[0].id, parent2.id, - "Should have gotten back the right result"); + "Should have gotten back the right result"); done(); } }); @@ -246,6 +246,133 @@ describe('Parse.Relation testing', () => { } }); }); + + it("queries on relation fields with multiple ins", (done) => { + var ChildObject = Parse.Object.extend("ChildObject"); + var childObjects = []; + for (var i = 0; i < 10; i++) { + childObjects.push(new ChildObject({x: i})); + } + + Parse.Object.saveAll(childObjects).then(() => { + var ParentObject = Parse.Object.extend("ParentObject"); + var parent = new ParentObject(); + parent.set("x", 4); + var relation = parent.relation("child"); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + var parent2 = new ParentObject(); + parent2.set("x", 3); + var relation2 = parent2.relation("child"); + relation2.add(childObjects[4]); + relation2.add(childObjects[5]); + relation2.add(childObjects[6]); + + var otherChild2 = parent2.relation("otherChild"); + otherChild2.add(childObjects[0]); + otherChild2.add(childObjects[1]); + otherChild2.add(childObjects[2]); + + var parents = []; + parents.push(parent); + parents.push(parent2); + return Parse.Object.saveAll(parents); + }).then(() => { + var query = new Parse.Query(ParentObject); + var objects = []; + objects.push(childObjects[0]); + query.containedIn("child", objects); + query.containedIn("otherChild", [childObjects[0]]); + return query.find(); + }).then((list) => { + equal(list.length, 2, "There should be 2 results"); + done(); + }); + }); + + it("query on pointer and relation fields with equal", (done) => { + var ChildObject = Parse.Object.extend("ChildObject"); + var childObjects = []; + for (var i = 0; i < 10; i++) { + childObjects.push(new ChildObject({x: i})); + } + + Parse.Object.saveAll(childObjects).then(() => { + var ParentObject = Parse.Object.extend("ParentObject"); + var parent = new ParentObject(); + parent.set("x", 4); + var relation = parent.relation("toChilds"); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + + var parent2 = new ParentObject(); + parent2.set("x", 3); + parent2.set("toChild", childObjects[2]); + + var parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + return Parse.Object.saveAll(parents).then(() => { + var query = new Parse.Query(ParentObject); + query.equalTo("objectId", parent.id); + query.equalTo("toChilds", childObjects[2]); + + return query.find().then((list) => { + equal(list.length, 1, "There should be 1 result"); + done(); + }); + }); + }); + }); + + it("or queries on pointer and relation fields", (done) => { + var ChildObject = Parse.Object.extend("ChildObject"); + var childObjects = []; + for (var i = 0; i < 10; i++) { + childObjects.push(new ChildObject({x: i})); + } + + Parse.Object.saveAll(childObjects).then(() => { + var ParentObject = Parse.Object.extend("ParentObject"); + var parent = new ParentObject(); + parent.set("x", 4); + var relation = parent.relation("toChilds"); + relation.add(childObjects[0]); + relation.add(childObjects[1]); + relation.add(childObjects[2]); + + var parent2 = new ParentObject(); + parent2.set("x", 3); + parent2.set("toChild", childObjects[2]); + + var parents = []; + parents.push(parent); + parents.push(parent2); + parents.push(new ParentObject()); + + return Parse.Object.saveAll(parents).then(() => { + var query1 = new Parse.Query(ParentObject); + query1.containedIn("toChilds", [childObjects[2]]); + var query2 = new Parse.Query(ParentObject); + query2.equalTo("toChild", childObjects[2]); + var query = Parse.Query.or(query1, query2); + return query.find().then((list) => { + var objectIds = list.map(function(item){ + return item.id; + }); + expect(objectIds.indexOf(parent.id)).not.toBe(-1); + expect(objectIds.indexOf(parent2.id)).not.toBe(-1); + equal(list.length, 2, "There should be 2 results"); + done(); + }); + }); + }); + }); + it("Get query on relation using un-fetched parent object", (done) => { // Setup data model diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 919e0b55..8b4f989f 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -2,6 +2,8 @@ // Roles are not accessible without the master key, so they are not intended // for use by clients. We can manually test them using the master key. +var Auth = require("../src/Auth").Auth; +var Config = require("../src/Config"); describe('Parse Role testing', () => { @@ -58,5 +60,64 @@ describe('Parse Role testing', () => { }); + it("should recursively load roles", (done) => { + + var rolesNames = ["FooRole", "BarRole", "BazRole"]; + + var createRole = function(name, parent, user) { + var role = new Parse.Role(name, new Parse.ACL()); + if (user) { + var users = role.relation('users'); + users.add(user); + } + if (parent) { + role.relation('roles').add(parent); + } + return role.save({}, { useMasterKey: true }); + } + var roleIds = {}; + createTestUser().then( (user) => { + + return createRole(rolesNames[0], null, null).then( (aRole) => { + roleIds[aRole.get("name")] = aRole.id; + return createRole(rolesNames[1], aRole, null); + }).then( (anotherRole) => { + roleIds[anotherRole.get("name")] = anotherRole.id; + return createRole(rolesNames[2], anotherRole, user); + }).then( (lastRole) => { + roleIds[lastRole.get("name")] = lastRole.id; + var auth = new Auth({ config: new Config("test"), isMaster: true, user: user }); + return auth._loadRoles(); + }) + }).then( (roles) => { + expect(roles.length).toEqual(3); + rolesNames.forEach( (name) => { + expect(roles.indexOf('role:'+name)).not.toBe(-1); + }) + done(); + }, function(err){ + fail("should succeed") + done(); + }); + }); + + it("_Role object should not save without name.", (done) => { + var role = new Parse.Role(); + role.save(null,{useMasterKey:true}) + .then((r) => { + fail("_Role object should not save without name."); + }, (error) => { + expect(error.code).toEqual(111); + role.set('name','testRole'); + role.save(null,{useMasterKey:true}) + .then((r2)=>{ + fail("_Role object should not save without ACL."); + }, (error2) =>{ + expect(error2.code).toEqual(111); + done(); + }); + }); + }); + }); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 424e4207..a74644ae 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -54,6 +54,11 @@ describe('Parse.User testing', () => { success: function(user) { Parse.User.logIn("non_existent_user", "asdf3", expectError(Parse.Error.OBJECT_NOT_FOUND, done)); + }, + error: function(err) { + console.error(err); + fail("Shit should not fail"); + done(); } }); }); @@ -1026,6 +1031,32 @@ describe('Parse.User testing', () => { }); }); + it("login with provider should not call beforeSave trigger", (done) => { + var provider = getMockFacebookProvider(); + Parse.User._registerAuthenticationProvider(provider); + Parse.User._logInWith("facebook", { + success: function(model) { + Parse.User.logOut(); + + Parse.Cloud.beforeSave(Parse.User, function(req, res) { + res.error("Before save shouldn't be called on login"); + }); + + Parse.User._logInWith("facebook", { + success: function(innerModel) { + Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); + done(); + }, + error: function(model, error) { + ok(undefined, error); + Parse.Cloud._removeHook('Triggers', 'beforeSave', Parse.User.className); + done(); + } + }); + } + }); + }); + it("link with provider", (done) => { var provider = getMockFacebookProvider(); Parse.User._registerAuthenticationProvider(provider); @@ -1678,7 +1709,7 @@ describe('Parse.User testing', () => { done(); }); }); - + it('test parse user become', (done) => { var sessionToken = null; Parse.Promise.as().then(function() { @@ -1732,5 +1763,22 @@ describe('Parse.User testing', () => { }); }); + it("session expiresAt correct format", (done) => { + Parse.User.signUp("asdf", "zxcv", null, { + success: function(user) { + request.get({ + url: 'http://localhost:8378/1/classes/_Session', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + }, (error, response, body) => { + expect(body.results[0].expiresAt.__type).toEqual('Date'); + done(); + }) + } + }); + }); }); diff --git a/spec/PromiseRouter.spec.js b/spec/PromiseRouter.spec.js new file mode 100644 index 00000000..999325ac --- /dev/null +++ b/spec/PromiseRouter.spec.js @@ -0,0 +1,26 @@ +var PromiseRouter = require("../src/PromiseRouter").default; + +describe("PromiseRouter", () => { + + it("should properly handle rejects", (done) => { + var router = new PromiseRouter(); + router.route("GET", "/dummy", (req)=> { + return Promise.reject({ + error: "an error", + code: -1 + }) + }, (req) => { + fail("this should not be called"); + }); + + router.routes[0].handler({}).then((result) => { + console.error(result); + fail("this should not be called"); + done(); + }, (error)=> { + expect(error.error).toEqual("an error"); + expect(error.code).toEqual(-1); + done(); + }); + }); +}) \ No newline at end of file diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js new file mode 100644 index 00000000..008d544a --- /dev/null +++ b/spec/PublicAPI.spec.js @@ -0,0 +1,86 @@ + +var request = require('request'); + +describe("public API", () => { + beforeEach(done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + publicServerURL: 'http://localhost:8378/1' + }); + done(); + }) + it("should get invalid_link.html", (done) => { + request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get verify_email_success.html", (done) => { + request('http://localhost:8378/1/apps/verify_email_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get password_reset_success.html", (done) => { + request('http://localhost:8378/1/apps/password_reset_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); +}); + +describe("public API without publicServerURL", () => { + beforeEach(done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + }); + done(); + }) + it("should get 404 on verify_email", (done) => { + request('http://localhost:8378/1/apps/test/verify_email', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); + + it("should get 404 choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); + + it("should get 404 on request_password_reset", (done) => { + request('http://localhost:8378/1/apps/test/request_password_reset', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); +}); diff --git a/spec/PushController.spec.js b/spec/PushController.spec.js index 6c86b011..1821d1a3 100644 --- a/spec/PushController.spec.js +++ b/spec/PushController.spec.js @@ -1,5 +1,7 @@ var PushController = require('../src/Controllers/PushController').PushController; +var Config = require('../src/Config'); + describe('PushController', () => { it('can check valid master key of request', (done) => { // Make mock request @@ -127,5 +129,121 @@ describe('PushController', () => { }).toThrow(); done(); }); + + it('properly increment badges', (done) => { + + var payload = { + alert: "Hello World!", + badge: "Increment", + } + var installations = []; + while(installations.length != 10) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("badge", installations.length); + installation.set("originalBadge", installations.length); + installation.set("deviceType", "ios"); + installations.push(installation); + } + + while(installations.length != 15) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("deviceType", "android"); + installations.push(installation); + } + + var pushAdapter = { + send: function(body, installations) { + var badge = body.badge; + installations.forEach((installation) => { + if (installation.deviceType == "ios") { + expect(installation.badge).toEqual(badge); + expect(installation.originalBadge+1).toEqual(installation.badge); + } else { + expect(installation.badge).toBeUndefined(); + } + }) + return Promise.resolve({ + body: body, + installations: installations + }) + }, + getValidPushTypes: function() { + return ["ios", "android"]; + } + } + + var config = new Config(Parse.applicationId); + var auth = { + isMaster: true + } + + var pushController = new PushController(pushAdapter, Parse.applicationId); + Parse.Object.saveAll(installations).then((installations) => { + return pushController.sendPush(payload, {}, config, auth); + }).then((result) => { + done(); + }, (err) => { + console.error(err); + fail("should not fail"); + done(); + }); + + }); + + it('properly set badges to 1', (done) => { + + var payload = { + alert: "Hello World!", + badge: 1, + } + var installations = []; + while(installations.length != 10) { + var installation = new Parse.Object("_Installation"); + installation.set("installationId", "installation_"+installations.length); + installation.set("deviceToken","device_token_"+installations.length) + installation.set("badge", installations.length); + installation.set("originalBadge", installations.length); + installation.set("deviceType", "ios"); + installations.push(installation); + } + + var pushAdapter = { + send: function(body, installations) { + var badge = body.badge; + installations.forEach((installation) => { + expect(installation.badge).toEqual(badge); + expect(1).toEqual(installation.badge); + }) + return Promise.resolve({ + body: body, + installations: installations + }) + }, + getValidPushTypes: function() { + return ["ios"]; + } + } + + var config = new Config(Parse.applicationId); + var auth = { + isMaster: true + } + + var pushController = new PushController(pushAdapter, Parse.applicationId); + Parse.Object.saveAll(installations).then((installations) => { + return pushController.sendPush(payload, {}, config, auth); + }).then((result) => { + done(); + }, (err) => { + console.error(err); + fail("should not fail"); + done(); + }); + + }) }); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index ca427792..b5613c1d 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,3 +1,5 @@ +'use strict'; + var Config = require('../src/Config'); var Schema = require('../src/Schema'); var dd = require('deep-diff'); @@ -186,8 +188,8 @@ describe('Schema', () => { foo: {type: 'String'} })) .catch(error => { - expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME) - expect(error.error).toEqual('class NewClass already exists'); + expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(error.message).toEqual('Class NewClass already exists.'); done(); }); }); @@ -214,7 +216,7 @@ describe('Schema', () => { Promise.all([p1,p2]) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.error).toEqual('class NewClass already exists'); + expect(error.message).toEqual('Class NewClass already exists.'); done(); }); }); @@ -420,6 +422,43 @@ describe('Schema', () => { }); }); + it('creates non-custom classes which include relation field', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('_Role', {})) + .then(mongoObj => { + expect(mongoObj).toEqual({ + _id: '_Role', + createdAt: 'string', + updatedAt: 'string', + objectId: 'string', + name: 'string', + users: 'relation<_User>', + roles: 'relation<_Role>', + }); + done(); + }); + }); + + it('creates non-custom classes which include pointer field', done => { + config.database.loadSchema() + .then(schema => schema.addClassIfNotExists('_Session', {})) + .then(mongoObj => { + expect(mongoObj).toEqual({ + _id: '_Session', + createdAt: 'string', + updatedAt: 'string', + objectId: 'string', + restricted: 'boolean', + user: '*_User', + installationId: 'string', + sessionToken: 'string', + expiresAt: 'date', + createdWith: 'object' + }); + done(); + }); + }); + it('refuses to create two geopoints', done => { config.database.loadSchema() .then(schema => schema.addClassIfNotExists('NewClass', { @@ -483,7 +522,7 @@ describe('Schema', () => { .then(schema => schema.deleteField('installationId', '_Installation')) .catch(error => { expect(error.code).toEqual(136); - expect(error.error).toEqual('field installationId cannot be changed'); + expect(error.message).toEqual('field installationId cannot be changed'); done(); }); }); @@ -493,7 +532,7 @@ describe('Schema', () => { .then(schema => schema.deleteField('field', 'NoClass')) .catch(error => { expect(error.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(error.error).toEqual('class NoClass does not exist'); + expect(error.message).toEqual('Class NoClass does not exist.'); done(); }); }); @@ -504,7 +543,7 @@ describe('Schema', () => { .then(schema => schema.deleteField('missingField', 'HasAllPOD')) .fail(error => { expect(error.code).toEqual(255); - expect(error.error).toEqual('field missingField does not exist, cannot delete'); + expect(error.message).toEqual('Field missingField does not exist, cannot delete.'); done(); }); }); @@ -512,24 +551,32 @@ describe('Schema', () => { it('drops related collection when deleting relation field', done => { var obj1 = hasAllPODobject(); obj1.save() - .then(savedObj1 => { - var obj2 = new Parse.Object('HasPointersAndRelations'); - obj2.set('aPointer', savedObj1); - var relation = obj2.relation('aRelation'); - relation.add(obj1); - return obj2.save(); - }) - .then(() => { - config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { - expect(err).toEqual(null); - config.database.loadSchema() - .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database.db, 'test_')) - .then(() => config.database.db.collection('test__Join:aRelation:HasPointersAndRelations', { strict: true }, (err, coll) => { - expect(err).not.toEqual(null); - done(); - })) + .then(savedObj1 => { + var obj2 = new Parse.Object('HasPointersAndRelations'); + obj2.set('aPointer', savedObj1); + var relation = obj2.relation('aRelation'); + relation.add(obj1); + return obj2.save(); + }) + .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) + .then(exists => { + if (!exists) { + fail('Relation collection ' + + 'should exist after save.'); + } + }) + .then(() => config.database.loadSchema()) + .then(schema => schema.deleteField('aRelation', 'HasPointersAndRelations', config.database)) + .then(() => config.database.collectionExists('_Join:aRelation:HasPointersAndRelations')) + .then(exists => { + if (exists) { + fail('Relation collection should not exist after deleting relation field.'); + } + done(); + }, error => { + fail(error); + done(); }); - }) }); it('can delete string fields and resave as number field', done => { @@ -538,7 +585,7 @@ describe('Schema', () => { var obj2 = hasAllPODobject(); var p = Parse.Object.saveAll([obj1, obj2]) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database.db, 'test_')) + .then(schema => schema.deleteField('aString', 'HasAllPOD', config.database)) .then(() => new Parse.Query('HasAllPOD').get(obj1.id)) .then(obj1Reloaded => { expect(obj1Reloaded.get('aString')).toEqual(undefined); @@ -568,7 +615,7 @@ describe('Schema', () => { expect(obj1.get('aPointer').id).toEqual(obj1.id); }) .then(() => config.database.loadSchema()) - .then(schema => schema.deleteField('aPointer', 'NewClass', config.database.db, 'test_')) + .then(schema => schema.deleteField('aPointer', 'NewClass', config.database)) .then(() => new Parse.Query('NewClass').get(obj1.id)) .then(obj1 => { expect(obj1.get('aPointer')).toEqual(undefined); @@ -609,4 +656,21 @@ describe('Schema', () => { }); done(); }); + + it('ignore default field when merge with system class', done => { + expect(Schema.buildMergedSchemaObject({ + _id: '_User', + username: 'string', + password: 'string', + authData: 'object', + email: 'string', + emailVerified: 'boolean' + },{ + authData: {type: 'string'}, + customField: {type: 'string'}, + })).toEqual({ + customField: {type: 'string'} + }); + done(); + }); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js new file mode 100644 index 00000000..6ac874cd --- /dev/null +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -0,0 +1,618 @@ +"use strict"; + +var request = require('request'); +var Config = require("../src/Config"); +describe("Custom Pages Configuration", () => { + it("should set the custom pages", (done) => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + customPages: { + invalidLink: "myInvalidLink", + verifyEmailSuccess: "myVerifyEmailSuccess", + choosePassword: "myChoosePassword", + passwordResetSuccess: "myPasswordResetSuccess" + }, + publicServerURL: "https://my.public.server.com/1" + }); + + var config = new Config("test"); + + expect(config.invalidLinkURL).toEqual("myInvalidLink"); + expect(config.verifyEmailSuccessURL).toEqual("myVerifyEmailSuccess"); + expect(config.choosePasswordURL).toEqual("myChoosePassword"); + expect(config.passwordResetSuccessURL).toEqual("myPasswordResetSuccess"); + expect(config.verifyEmailURL).toEqual("https://my.public.server.com/1/apps/test/verify_email"); + expect(config.requestResetPasswordURL).toEqual("https://my.public.server.com/1/apps/test/request_password_reset"); + done(); + }); +}); + +describe("Email Verification", () => { + it('sends verification email if email verification is enabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.setEmail('cool_guy@parse.com'); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does not send verification email when verification is enabled and email is not set', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does send a validation email when updating the email', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).not.toHaveBeenCalled(); + user.fetch() + .then((user) => { + user.set("email", "cool_guy@parse.com"); + return user.save(); + }).then((user) => { + return user.fetch(); + }).then(() => { + expect(user.get('emailVerified')).toEqual(false); + // Wait as on update emai, we need to fetch the username + setTimeout(function(){ + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + done(); + }, 200); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does send with a simple adapter', done => { + var calls = 0; + var emailAdapter = { + sendMail: function(options){ + expect(options.to).toBe('cool_guy@parse.com'); + if (calls == 0) { + expect(options.subject).toEqual('Please verify your e-mail for My Cool App'); + expect(options.text.match(/verify_email/)).not.toBe(null); + } else if (calls == 1) { + expect(options.subject).toEqual('Password Reset for My Cool App'); + expect(options.text.match(/request_password_reset/)).not.toBe(null); + } + calls++; + return Promise.resolve(); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'My Cool App', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set("email", "cool_guy@parse.com"); + user.signUp(null, { + success: function(user) { + expect(calls).toBe(1); + user.fetch() + .then((user) => { + return user.save(); + }).then((user) => { + return Parse.User.requestPasswordReset("cool_guy@parse.com"); + }).then(() => { + expect(calls).toBe(2); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does not send verification email if email verification is disabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: false, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + user.fetch() + .then(() => { + expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('receives the app name and user in the adapter', done => { + var emailAdapter = { + sendVerificationEmail: options => { + expect(options.appName).toEqual('emailing app'); + expect(options.user.get('email')).toEqual('user@parse.com'); + done(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }) + + it('when you click the link in the email it sets emailVerified to true and redirects you', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=zxcv'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(true); + done(); + }, (err) => { + console.error(err); + fail("this should not fail"); + done(); + }); + }); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(); + }); + + it('redirects you to invalid link if you try to verify email incorrecly', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); + request.get('http://localhost:8378/1/apps/test/verify_email', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + done() + }); + }); + + it('redirects you to invalid link if you try to validate a nonexistant users email', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); + request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + done(); + }); + }); + + it('does not update email verified if you use an invalid token', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get('http://localhost:8378/1/apps/test/verify_email?token=invalid&username=zxcv', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); +}); + +describe("Password Reset", () => { + + it('should send a password reset link', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to get the reset link"); + return; + } + expect(response.statusCode).toEqual(302); + var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=zxcv/; + expect(response.body.match(re)).not.toBe(null); + done(); + }); + }, + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: (err) => { + console.error(err); + fail("Should not fail"); + done(); + } + }); + }); + }); + + it('redirects you to invalid link if you try to request password for a nonexistant users email', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); + request.get('http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); + done(); + }); + }); + + it('should programatically reset password', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to get the reset link"); + return; + } + expect(response.statusCode).toEqual(302); + var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; + var match = response.body.match(re); + if (!match) { + fail("should have a token"); + done(); + return; + } + var token = match[1]; + + request.post({ + url: "http://localhost:8378/1/apps/test/request_password_reset" , + body: `new_password=hello&token=${token}&username=zxcv`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to POST request password reset"); + return; + } + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'); + + Parse.User.logIn("zxcv", "hello").then(function(user){ + done(); + }, (err) => { + console.error(err); + fail("should login with new password"); + done(); + }); + + }); + }); + }, + sendMail: () => {} + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: (err) => { + console.error(err); + fail("Should not fail"); + done(); + } + }); + }); + }); + +}) + diff --git a/spec/cloud/main.js b/spec/cloud/main.js index 9e53e637..396fa862 100644 --- a/spec/cloud/main.js +++ b/spec/cloud/main.js @@ -100,3 +100,11 @@ Parse.Cloud.define('requiredParameterCheck', function(req, res) { }, function(params) { return params.name; }); + +Parse.Cloud.define('echoKeys', function(req, res){ + return res.success({ + applicationId: Parse.applicationId, + masterKey: Parse.masterKey, + javascriptKey: Parse.javascriptKey + }) +}); diff --git a/spec/features.spec.js b/spec/features.spec.js new file mode 100644 index 00000000..9d18adf7 --- /dev/null +++ b/spec/features.spec.js @@ -0,0 +1,44 @@ +'use strict'; + +var features = require('../src/features'); +const request = require("request"); + +describe('features', () => { + it('set and get features', (done) => { + features.setFeature('push', { + testOption1: true, + testOption2: false + }); + + var _features = features.getFeatures(); + + var expected = { + testOption1: true, + testOption2: false + }; + + expect(_features.push).toEqual(expected); + done(); + }); + + it('get features that does not exist', (done) => { + var _features = features.getFeatures(); + expect(_features.test).toBeUndefined(); + done(); + }); + + it('requires the master key to get all schemas', done => { + request.get({ + url: 'http://localhost:8378/1/serverInfo', + json: true, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + } + }, (error, response, body) => { + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); + done(); + }); + }); +}); diff --git a/spec/helper.js b/spec/helper.js index c7474afe..e2daa6ed 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -52,13 +52,13 @@ delete defaultConfiguration.cloud; // Allows testing specific configurations of Parse Server var setServerConfiguration = configuration => { - api = new ParseServer(configuration); - app = express(); - app.use('/1', api); - cache.clearCache(); server.close(); + cache.clearCache(); + app = express(); + api = new ParseServer(configuration); + app.use('/1', api); server = app.listen(port); -} +}; var restoreServerConfiguration = () => setServerConfiguration(defaultConfiguration); @@ -250,3 +250,4 @@ global.arrayContains = arrayContains; global.jequal = jequal; global.range = range; global.setServerConfiguration = setServerConfiguration; +global.defaultConfiguration = defaultConfiguration; diff --git a/spec/index.spec.js b/spec/index.spec.js index 8b558089..56f9bb6b 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -1,4 +1,6 @@ var request = require('request'); +var parseServerPackage = require('../package.json'); +var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); describe('server', () => { it('requires a master key and app id', done => { @@ -37,4 +39,133 @@ describe('server', () => { done(); }); }); + + it('can load email adapter via object', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: MockEmailAdapterWithOptions({ + apiKey: 'k', + domain: 'd', + }), + publicServerURL: 'http://localhost:8378/1' + }); + done(); + }); + + it('can load email adapter via class', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + class: MockEmailAdapterWithOptions, + options: { + apiKey: 'k', + domain: 'd', + } + }, + publicServerURL: 'http://localhost:8378/1' + }); + done(); + }); + + it('can load email adapter via module name', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + module: './Email/SimpleMailgunAdapter', + options: { + apiKey: 'k', + domain: 'd', + } + }, + publicServerURL: 'http://localhost:8378/1' + }); + done(); + }); + + it('can load email adapter via only module name', done => { + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: './Email/SimpleMailgunAdapter', + publicServerURL: 'http://localhost:8378/1' + })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); + done(); + }); + + it('throws if you initialize email adapter incorrecly', done => { + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + module: './Email/SimpleMailgunAdapter', + options: { + domain: 'd', + } + }, + publicServerURL: 'http://localhost:8378/1' + })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); + done(); + }); + + it('can report the server version', done => { + request.get({ + url: 'http://localhost:8378/1/serverInfo', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Master-Key': 'test', + }, + json: true, + }, (error, response, body) => { + expect(body.parseServerVersion).toEqual(parseServerPackage.version); + done(); + }) + }); }); diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 1a6a3069..63843e57 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -1,3 +1,5 @@ +'use strict'; + var Parse = require('parse/node').Parse; var request = require('request'); var dd = require('deep-diff'); @@ -96,8 +98,8 @@ describe('schemas', () => { json: true, headers: restKeyHeaders, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('master key not specified'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); @@ -108,8 +110,8 @@ describe('schemas', () => { json: true, headers: restKeyHeaders, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('master key not specified'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); @@ -173,7 +175,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: 103, - error: 'class HASALLPOD does not exist', + error: 'Class HASALLPOD does not exist.', }); done(); }); @@ -204,8 +206,8 @@ describe('schemas', () => { className: 'MyClass', }, }, (error, response, body) => { - expect(response.statusCode).toEqual(401); - expect(body.error).toEqual('master key not specified'); + expect(response.statusCode).toEqual(403); + expect(body.error).toEqual('unauthorized: master key is required'); done(); }); }); @@ -222,7 +224,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, - error: 'class name mismatch between B and A', + error: 'Class name mismatch between B and A.', }); done(); }); @@ -238,7 +240,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: 135, - error: 'POST /schemas needs class name', + error: 'POST /schemas needs a class name.', }); done(); }) @@ -265,7 +267,7 @@ describe('schemas', () => { expect(response.statusCode).toEqual(400); expect(body).toEqual({ code: Parse.Error.INVALID_CLASS_NAME, - error: 'class A already exists', + error: 'Class A already exists.' }); done(); }); @@ -351,7 +353,7 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('class name mismatch between WrongClassName and NewClass'); + expect(body.error).toEqual('Class name mismatch between WrongClassName and NewClass.'); done(); }); }); @@ -369,7 +371,7 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('class NoClass does not exist'); + expect(body.error).toEqual('Class NoClass does not exist.'); done(); }); }); @@ -390,13 +392,13 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(255); - expect(body.error).toEqual('field aString exists, cannot update'); + expect(body.error).toEqual('Field aString exists, cannot update.'); done(); }); }) }); - it('refuses to delete non-existant fields', done => { + it('refuses to delete non-existent fields', done => { var obj = hasAllPODobject(); obj.save() .then(() => { @@ -406,13 +408,13 @@ describe('schemas', () => { json: true, body: { fields: { - nonExistantKey: {__op: "Delete"}, + nonExistentKey: {__op: "Delete"}, } } }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(255); - expect(body.error).toEqual('field nonExistantKey does not exist, cannot delete'); + expect(body.error).toEqual('Field nonExistentKey does not exist, cannot delete.'); done(); }); }); @@ -660,7 +662,8 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(400); expect(body.code).toEqual(255); - expect(body.error).toEqual('class HasAllPOD not empty, contains 1 objects, cannot drop schema'); + expect(body.error).toMatch(/HasAllPOD/); + expect(body.error).toMatch(/contains 1/); done(); }); }); @@ -710,28 +713,106 @@ describe('schemas', () => { }, (error, response, body) => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual({}); - config.database.db.collection('test__Join:aRelation:MyOtherClass', { strict: true }, (err, coll) => { - //Expect Join table to be gone - expect(err).not.toEqual(null); - config.database.db.collection('test_MyOtherClass', { strict: true }, (err, coll) => { - // Expect data table to be gone - expect(err).not.toEqual(null); - request.get({ - url: 'http://localhost:8378/1/schemas/MyOtherClass', - headers: masterKeyHeaders, - json: true, - }, (error, response, body) => { - //Expect _SCHEMA entry to be gone. - expect(response.statusCode).toEqual(400); - expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); - expect(body.error).toEqual('class MyOtherClass does not exist'); - done(); + config.database.collectionExists('_Join:aRelation:MyOtherClass').then(exists => { + if (exists) { + fail('Relation collection should be deleted.'); + done(); + } + return config.database.collectionExists('MyOtherClass'); + }).then(exists => { + if (exists) { + fail('Class collection should be deleted.'); + done(); + } + }).then(() => { + request.get({ + url: 'http://localhost:8378/1/schemas/MyOtherClass', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + //Expect _SCHEMA entry to be gone. + expect(response.statusCode).toEqual(400); + expect(body.code).toEqual(Parse.Error.INVALID_CLASS_NAME); + expect(body.error).toEqual('Class MyOtherClass does not exist.'); + done(); + }); + }); + }); + }).then(() => { + }, error => { + fail(error); + done(); + }); + }); + + it('deletes schema when actual collection does not exist', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassForDelete' + } + }, (error, response, body) => { + expect(error).toEqual(null); + expect(response.body.className).toEqual('NewClassForDelete'); + request.del({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual({}); + config.database.loadSchema().then(schema => { + schema.hasClass('NewClassForDelete').then(exist => { + expect(exist).toEqual(false); + done(); + }); + }) + }); + }); + }); + + it('deletes schema when actual collection exists', done => { + request.post({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + body: { + className: 'NewClassForDelete' + } + }, (error, response, body) => { + expect(error).toEqual(null); + expect(response.body.className).toEqual('NewClassForDelete'); + request.post({ + url: 'http://localhost:8378/1/classes/NewClassForDelete', + headers: restKeyHeaders, + json: true + }, (error, response, body) => { + expect(error).toEqual(null); + expect(typeof response.body.objectId).toEqual('string'); + request.del({ + url: 'http://localhost:8378/1/classes/NewClassForDelete/' + response.body.objectId, + headers: restKeyHeaders, + json: true, + }, (error, response, body) => { + expect(error).toEqual(null); + request.del({ + url: 'http://localhost:8378/1/schemas/NewClassForDelete', + headers: masterKeyHeaders, + json: true, + }, (error, response, body) => { + expect(response.statusCode).toEqual(200); + expect(response.body).toEqual({}); + config.database.loadSchema().then(schema => { + schema.hasClass('NewClassForDelete').then(exist => { + expect(exist).toEqual(false); + done(); + }); }); }); }); }); - }, error => { - fail(error); }); }); }); diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index cfe51ffd..5b46f22d 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,35 +1,43 @@ +export function loadAdapter(adapter, defaultAdapter, options) { -export function loadAdapter(options, defaultAdapter) { - let adapter; - - // We have options and options have adapter key - if (options) { - // Pass an adapter as a module name, a function or an instance - if (typeof options == "string" || typeof options == "function" || options.constructor != Object) { - adapter = options; + if (!adapter) + { + if (!defaultAdapter) { + return options; } - if (options.adapter) { - adapter = options.adapter; + // Load from the default adapter when no adapter is set + return loadAdapter(defaultAdapter, undefined, options); + } else if (typeof adapter === "function") { + try { + return adapter(options); + } catch(e) { + var Adapter = adapter; + return new Adapter(options); } - } - - if (!adapter) { - adapter = defaultAdapter; - } - - // This is a string, require the module - if (typeof adapter === "string") { + } else if (typeof adapter === "string") { adapter = require(adapter); // If it's define as a module, get the default if (adapter.default) { adapter = adapter.default; } + + return loadAdapter(adapter, undefined, options); + } else if (adapter.module) { + return loadAdapter(adapter.module, undefined, adapter.options); + } else if (adapter.class) { + return loadAdapter(adapter.class, undefined, adapter.options); + } else if (adapter.adapter) { + return loadAdapter(adapter.adapter, undefined, adapter.options); + } else { + // Try to load the defaultAdapter with the options + // The default adapter should throw if the options are + // incompatible + try { + return loadAdapter(defaultAdapter, undefined, adapter); + } catch (e) {}; } - // From there it's either a function or an object - // if it's an function, instanciate and pass the options - if (typeof adapter === "function") { - var Adapter = adapter; - adapter = new Adapter(options); - } - return adapter; + // return the adapter as is as it's unusable otherwise + return adapter; } + +export default loadAdapter; diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js new file mode 100644 index 00000000..82ea8b34 --- /dev/null +++ b/src/Adapters/Email/MailAdapter.js @@ -0,0 +1,23 @@ + +/* + Mail Adapter prototype + A MailAdapter should implement at least sendMail() + */ +export class MailAdapter { + /* + * A method for sending mail + * @param options would have the parameters + * - to: the recipient + * - text: the raw text of the message + * - subject: the subject of the email + */ + sendMail(options) {} + + /* You can implement those methods if you want + * to provide HTML templates etc... + */ + // sendVerificationEmail({ link, appName, user }) {} + // sendPasswordResetEmail({ link, appName, user }) {} +} + +export default MailAdapter; diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js new file mode 100644 index 00000000..a90a43d7 --- /dev/null +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -0,0 +1,32 @@ +import Mailgun from 'mailgun-js'; + +let SimpleMailgunAdapter = mailgunOptions => { + if (!mailgunOptions || !mailgunOptions.apiKey || !mailgunOptions.domain) { + throw 'SimpleMailgunAdapter requires an API Key and domain.'; + } + let mailgun = Mailgun(mailgunOptions); + + let sendMail = ({to, subject, text}) => { + let data = { + from: mailgunOptions.fromAddress, + to: to, + subject: subject, + text: text, + } + + return new Promise((resolve, reject) => { + mailgun.messages().send(data, (err, body) => { + if (typeof err !== 'undefined') { + reject(err); + } + resolve(body); + }); + }); + } + + return Object.freeze({ + sendMail: sendMail + }); +} + +module.exports = SimpleMailgunAdapter diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index a1d5955f..d0dda004 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -8,11 +8,23 @@ // * getFileLocation(config, request, filename) // // Default is GridStoreAdapter, which requires mongo -// and for the API server to be using the ExportAdapter +// and for the API server to be using the DatabaseController with Mongo // database adapter. export class FilesAdapter { - createFile(config, filename, data) { } + /* this method is responsible to store the file in order to be retrived later by it's file name + * + * + * @param config the current config + * @param filename the filename to save + * @param data the buffer of data from the file + * @param contentType the supposed contentType + * @discussion the contentType can be undefined if the controller was not able to determine it + * + * @return a promise that should fail if the storage didn't succeed + * + */ + createFile(config, filename: string, data, contentType: string) { } deleteFile(config, filename) { } diff --git a/src/Adapters/Files/GCSAdapter.js b/src/Adapters/Files/GCSAdapter.js index b0f841e0..e749502d 100644 --- a/src/Adapters/Files/GCSAdapter.js +++ b/src/Adapters/Files/GCSAdapter.js @@ -1,16 +1,18 @@ // GCSAdapter // Store Parse Files in Google Cloud Storage: https://cloud.google.com/storage -import * as gcloud from 'gcloud'; +import { storage } from 'gcloud'; import { FilesAdapter } from './FilesAdapter'; +import requiredParameter from '../../requiredParameter'; export class GCSAdapter extends FilesAdapter { // GCS Project ID and the name of a corresponding Keyfile are required. + // Unlike the S3 adapter, you must create a new Cloud Storage bucket, as this is not created automatically. // See https://googlecloudplatform.github.io/gcloud-node/#/docs/master/guides/authentication // for more details. constructor( - projectId, - keyFilename, - bucket, + projectId = requiredParameter('GCSAdapter requires a GCP Project ID'), + keyFilename = requiredParameter('GCSAdapter requires a GCP keyfile'), + bucket = requiredParameter('GCSAdapter requires a GCS bucket name'), { bucketPrefix = '', directAccess = false } = {} ) { @@ -20,21 +22,25 @@ export class GCSAdapter extends FilesAdapter { this._bucketPrefix = bucketPrefix; this._directAccess = directAccess; - let gcsOptions = { + let options = { projectId: projectId, keyFilename: keyFilename }; - this._gcsClient = new gcloud.storage(gcsOptions); + this._gcsClient = new storage(options); } // For a given config object, filename, and data, store a file in GCS. // Resolves the promise or fails with an error. - createFile(config, filename, data) { + createFile(config, filename, data, contentType) { + let params = { + contentType: contentType || 'application/octet-stream' + }; + return new Promise((resolve, reject) => { let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); // gcloud supports upload(file) not upload(bytes), so we need to stream. - var uploadStream = file.createWriteStream(); + var uploadStream = file.createWriteStream(params); uploadStream.on('error', (err) => { return reject(err); }).on('finish', () => { @@ -61,6 +67,7 @@ export class GCSAdapter extends FilesAdapter { return new Promise((resolve, reject) => { let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); file.delete((err, res) => { + console.log("delete: ", filename, err, res); if(err !== null) { return reject(err); } @@ -74,11 +81,19 @@ export class GCSAdapter extends FilesAdapter { getFileData(config, filename) { return new Promise((resolve, reject) => { let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); - file.download((err, data) => { - if (err !== null) { - return reject(err); + // Check for existence, since gcloud-node seemed to be caching the result + file.exists((err, exists) => { + if (exists) { + file.download((err, data) => { + console.log("get: ", filename, err, data); + if (err !== null) { + return reject(err); + } + return resolve(data); + }); + } else { + reject(err); } - resolve(data); }); }); } diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index 21934c9a..e6532e21 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -1,28 +1,47 @@ -// GridStoreAdapter -// -// Stores files in Mongo using GridStore -// Requires the database adapter to be based on mongoclient +/** + GridStoreAdapter + Stores files in Mongo using GridStore + Requires the database adapter to be based on mongoclient -import { GridStore } from 'mongodb'; + @flow weak + */ + +import { MongoClient, GridStore, Db} from 'mongodb'; import { FilesAdapter } from './FilesAdapter'; export class GridStoreAdapter extends FilesAdapter { + _databaseURI: string; + _connectionPromise: Promise; + + constructor(mongoDatabaseURI: string) { + super(); + this._databaseURI = mongoDatabaseURI; + this._connect(); + } + + _connect() { + if (!this._connectionPromise) { + this._connectionPromise = MongoClient.connect(this._databaseURI); + } + return this._connectionPromise; + } + // For a given config object, filename, and data, store a file // Returns a promise - createFile(config, filename, data) { - return config.database.connect().then(() => { - let gridStore = new GridStore(config.database.db, filename, 'w'); + createFile(config, filename: string, data, contentType) { + return this._connect().then(database => { + let gridStore = new GridStore(database, filename, 'w'); return gridStore.open(); - }).then((gridStore) => { + }).then(gridStore => { return gridStore.write(data); - }).then((gridStore) => { + }).then(gridStore => { return gridStore.close(); }); } - deleteFile(config, filename) { - return config.database.connect().then(() => { - let gridStore = new GridStore(config.database.db, filename, 'w'); + deleteFile(config, filename: string) { + return this._connect().then(database => { + let gridStore = new GridStore(database, filename, 'w'); return gridStore.open(); }).then((gridStore) => { return gridStore.unlink(); @@ -31,13 +50,14 @@ export class GridStoreAdapter extends FilesAdapter { }); } - getFileData(config, filename) { - return config.database.connect().then(() => { - return GridStore.exist(config.database.db, filename); - }).then(() => { - let gridStore = new GridStore(config.database.db, filename, 'r'); - return gridStore.open(); - }).then((gridStore) => { + getFileData(config, filename: string) { + return this._connect().then(database => { + return GridStore.exist(database, filename) + .then(() => { + let gridStore = new GridStore(database, filename, 'r'); + return gridStore.open(); + }); + }).then(gridStore => { return gridStore.read(); }); } diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index 0732fbfe..e21ef8db 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -4,23 +4,38 @@ import * as AWS from 'aws-sdk'; import { FilesAdapter } from './FilesAdapter'; +import requiredParameter from '../../requiredParameter'; const DEFAULT_S3_REGION = "us-east-1"; +function parseS3AdapterOptions(...options) { + if (options.length === 1 && typeof options[0] == "object") { + return options; + } + + const additionalOptions = options[3] || {}; + + return { + accessKey: options[0], + secretKey: options[1], + bucket: options[2], + region: additionalOptions.region + } +} + export class S3Adapter extends FilesAdapter { // Creates an S3 session. // Providing AWS access and secret keys is mandatory // Region and bucket will use sane defaults if omitted constructor( - accessKey, - secretKey, - bucket, - { region = DEFAULT_S3_REGION, - bucketPrefix = '', - directAccess = false } = {} - ) { + accessKey = requiredParameter('S3Adapter requires an accessKey'), + secretKey = requiredParameter('S3Adapter requires a secretKey'), + bucket, + { region = DEFAULT_S3_REGION, + bucketPrefix = '', + directAccess = false } = {}) { super(); - + this._region = region; this._bucket = bucket; this._bucketPrefix = bucketPrefix; @@ -33,11 +48,27 @@ export class S3Adapter extends FilesAdapter { }; AWS.config._region = this._region; this._s3Client = new AWS.S3(s3Options); + this._hasBucket = false; + } + + createBucket() { + var promise; + if (this._hasBucket) { + promise = Promise.resolve(); + } else { + promise = new Promise((resolve, reject) => { + this._s3Client.createBucket(() => { + this._hasBucket = true; + resolve(); + }); + }); + } + return promise; } // For a given config object, filename, and data, store a file in S3 // Returns a promise containing the S3 object creation response - createFile(config, filename, data) { + createFile(config, filename, data, contentType) { let params = { Key: this._bucketPrefix + filename, Body: data @@ -45,26 +76,33 @@ export class S3Adapter extends FilesAdapter { if (this._directAccess) { params.ACL = "public-read" } - return new Promise((resolve, reject) => { - this._s3Client.upload(params, (err, data) => { - if (err !== null) { - return reject(err); - } - resolve(data); + if (contentType) { + params.ContentType = contentType; + } + return this.createBucket().then(() => { + return new Promise((resolve, reject) => { + this._s3Client.upload(params, (err, data) => { + if (err !== null) { + return reject(err); + } + resolve(data); + }); }); }); } deleteFile(config, filename) { - return new Promise((resolve, reject) => { - let params = { - Key: this._bucketPrefix + filename - }; - this._s3Client.deleteObject(params, (err, data) =>{ - if(err !== null) { - return reject(err); - } - resolve(data); + return this.createBucket().then(() => { + return new Promise((resolve, reject) => { + let params = { + Key: this._bucketPrefix + filename + }; + this._s3Client.deleteObject(params, (err, data) =>{ + if(err !== null) { + return reject(err); + } + resolve(data); + }); }); }); } @@ -73,12 +111,18 @@ export class S3Adapter extends FilesAdapter { // Returns a promise that succeeds with the buffer result from S3 getFileData(config, filename) { let params = {Key: this._bucketPrefix + filename}; - return new Promise((resolve, reject) => { - this._s3Client.getObject(params, (err, data) => { - if (err !== null) { - return reject(err); - } - resolve(data.Body); + return this.createBucket().then(() => { + return new Promise((resolve, reject) => { + this._s3Client.getObject(params, (err, data) => { + if (err !== null) { + return reject(err); + } + // Something happend here... + if (data && !data.Body) { + return reject(data); + } + resolve(data.Body); + }); }); }); } diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js index 9e308242..3d3c192f 100644 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -101,7 +101,6 @@ let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => { export class FileLoggerAdapter extends LoggerAdapter { constructor(options = {}) { super(); - this._logsFolder = options.logsFolder || LOGS_FOLDER; // check logs folder exists diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index fe2fcc0b..b92d00c5 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -18,6 +18,10 @@ export class OneSignalPushAdapter extends PushAdapter { this.validPushTypes = ['ios', 'android']; this.senderMap = {}; this.OneSignalConfig = {}; + const { oneSignalAppId, oneSignalApiKey } = pushConfig; + if (!oneSignalAppId || !oneSignalApiKey) { + throw "Trying to initialize OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"; + } this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; diff --git a/src/Adapters/Push/ParsePushAdapter.js b/src/Adapters/Push/ParsePushAdapter.js index 3f554054..c953d157 100644 --- a/src/Adapters/Push/ParsePushAdapter.js +++ b/src/Adapters/Push/ParsePushAdapter.js @@ -14,6 +14,10 @@ export class ParsePushAdapter extends PushAdapter { super(pushConfig); this.validPushTypes = ['ios', 'android']; this.senderMap = {}; + // used in PushController for Dashboard Features + this.feature = { + immediatePush: true + }; let pushTypes = Object.keys(pushConfig); for (let pushType of pushTypes) { diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js new file mode 100644 index 00000000..66113c3d --- /dev/null +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -0,0 +1,76 @@ + +let mongodb = require('mongodb'); +let Collection = mongodb.Collection; + +export default class MongoCollection { + _mongoCollection:Collection; + + constructor(mongoCollection:Collection) { + this._mongoCollection = mongoCollection; + } + + // Does a find with "smart indexing". + // Currently this just means, if it needs a geoindex and there is + // none, then build the geoindex. + // This could be improved a lot but it's not clear if that's a good + // idea. Or even if this behavior is a good idea. + find(query, { skip, limit, sort } = {}) { + return this._rawFind(query, { skip, limit, sort }) + .catch(error => { + // Check for "no geoindex" error + if (error.code != 17007 || + !error.message.match(/unable to find index for .geoNear/)) { + throw error; + } + // Figure out what key needs an index + let key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; + if (!key) { + throw error; + } + + var index = {}; + index[key] = '2d'; + //TODO: condiser moving index creation logic into Schema.js + return this._mongoCollection.createIndex(index) + // Retry, but just once. + .then(() => this._rawFind(query, { skip, limit, sort })); + }); + } + + _rawFind(query, { skip, limit, sort } = {}) { + return this._mongoCollection + .find(query, { skip, limit, sort }) + .toArray(); + } + + count(query, { skip, limit, sort } = {}) { + return this._mongoCollection.count(query, { skip, limit, sort }); + } + + // Atomically finds and updates an object based on query. + // The result is the promise with an object that was in the database !AFTER! changes. + // Postgres Note: Translates directly to `UPDATE * SET * ... RETURNING *`, which will return data after the change is done. + findOneAndUpdate(query, update) { + // arguments: query, sort, update, options(optional) + // Setting `new` option to true makes it return the after document, not the before one. + return this._mongoCollection.findAndModify(query, [], update, { new: true }).then(document => { + // Value is the object where mongo returns multiple fields. + return document.value; + }) + } + + // Atomically find and delete an object based on query. + // The result is the promise with an object that was in the database before deleting. + // Postgres Note: Translates directly to `DELETE * FROM ... RETURNING *`, which will return data after delete is done. + findOneAndDelete(query) { + // arguments: query, sort + return this._mongoCollection.findAndRemove(query, []).then(document => { + // Value is the object where mongo returns multiple fields. + return document.value; + }); + } + + drop() { + return this._mongoCollection.drop(); + } +} diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js new file mode 100644 index 00000000..201388b2 --- /dev/null +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -0,0 +1,68 @@ + +import MongoCollection from './MongoCollection'; + +let mongodb = require('mongodb'); +let MongoClient = mongodb.MongoClient; + +export class MongoStorageAdapter { + // Private + _uri: string; + // Public + connectionPromise; + database; + + constructor(uri: string) { + this._uri = uri; + } + + connect() { + if (this.connectionPromise) { + return this.connectionPromise; + } + + this.connectionPromise = MongoClient.connect(this._uri).then(database => { + this.database = database; + }); + return this.connectionPromise; + } + + collection(name: string) { + return this.connect().then(() => { + return this.database.collection(name); + }); + } + + adaptiveCollection(name: string) { + return this.connect() + .then(() => this.database.collection(name)) + .then(rawCollection => new MongoCollection(rawCollection)); + } + + collectionExists(name: string) { + return this.connect().then(() => { + return this.database.listCollections({ name: name }).toArray(); + }).then(collections => { + return collections.length > 0; + }); + } + + dropCollection(name: string) { + return this.collection(name).then(collection => collection.drop()); + } + // Used for testing only right now. + collectionsContaining(match: string) { + return this.connect().then(() => { + return this.database.collections(); + }).then(collections => { + return collections.filter(collection => { + if (collection.namespace.match(/\.system\./)) { + return false; + } + return (collection.collectionName.indexOf(match) == 0); + }); + }); + } +} + +export default MongoStorageAdapter; +module.exports = MongoStorageAdapter; // Required for tests diff --git a/src/Auth.js b/src/Auth.js index 642f34ab..0b285789 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -7,10 +7,11 @@ import cache from './cache'; // An Auth object tells you who is requesting something and whether // the master key was used. // userObject is a Parse.User and can be null if there's no user. -function Auth(config, isMaster, userObject) { +function Auth({ config, isMaster = false, user, installationId } = {}) { this.config = config; + this.installationId = installationId; this.isMaster = isMaster; - this.user = userObject; + this.user = user; // Assuming a users roles won't change during a single request, we'll // only load them once. @@ -33,19 +34,19 @@ Auth.prototype.couldUpdateUserId = function(userId) { // A helper to get a master-level Auth object function master(config) { - return new Auth(config, true, null); + return new Auth({ config, isMaster: true }); } // A helper to get a nobody-level Auth object function nobody(config) { - return new Auth(config, false, null); + return new Auth({ config, isMaster: false }); } // Returns a promise that resolves to an Auth object -var getAuthForSessionToken = function(config, sessionToken) { - var cachedUser = cache.getUser(sessionToken); +var getAuthForSessionToken = function({ config, sessionToken, installationId } = {}) { + var cachedUser = cache.users.get(sessionToken); if (cachedUser) { - return Promise.resolve(new Auth(config, false, cachedUser)); + return Promise.resolve(new Auth({ config, isMaster: false, installationId, user: cachedUser })); } var restOptions = { limit: 1, @@ -65,9 +66,9 @@ var getAuthForSessionToken = function(config, sessionToken) { delete obj.password; obj['className'] = '_User'; obj['sessionToken'] = sessionToken; - var userObject = Parse.Object.fromJSON(obj); - cache.setUser(sessionToken, userObject); - return new Auth(config, false, userObject); + let userObject = Parse.Object.fromJSON(obj); + cache.users.set(sessionToken, userObject); + return new Auth({ config, isMaster: false, installationId, user: userObject }); }); }; @@ -159,6 +160,22 @@ Auth.prototype._getAllRoleNamesForId = function(roleID) { return Promise.resolve([]); } var roleIDs = results.map(r => r.objectId); + + var parentRolesPromises = roleIDs.map( (roleId) => { + return this._getAllRoleNamesForId(roleId); + }); + parentRolesPromises.push(Promise.resolve(roleIDs)); + return Promise.all(parentRolesPromises); + }).then(function(results){ + // Flatten + let roleIDs = results.reduce( (memo, result) => { + if (typeof result == "object") { + memo = memo.concat(result); + } else { + memo.push(result); + } + return memo; + }, []); return Promise.resolve(roleIDs); }); }; diff --git a/src/Config.js b/src/Config.js index 8ceeb0e1..8042d6db 100644 --- a/src/Config.js +++ b/src/Config.js @@ -7,15 +7,12 @@ import cache from './cache'; export class Config { constructor(applicationId: string, mount: string) { let DatabaseAdapter = require('./DatabaseAdapter'); - - let cacheInfo = cache.apps[applicationId]; - this.valid = !!cacheInfo; - if (!this.valid) { + let cacheInfo = cache.apps.get(applicationId); + if (!cacheInfo) { return; } this.applicationId = applicationId; - this.collectionPrefix = cacheInfo.collectionPrefix || ''; this.masterKey = cacheInfo.masterKey; this.clientKey = cacheInfo.clientKey; this.javascriptKey = cacheInfo.javascriptKey; @@ -25,16 +22,64 @@ export class Config { this.facebookAppIds = cacheInfo.facebookAppIds; this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; - this.database = DatabaseAdapter.getDatabaseConnection(applicationId, this.collectionPrefix); + this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); + + this.serverURL = cacheInfo.serverURL; + this.publicServerURL = cacheInfo.publicServerURL; + this.verifyUserEmails = cacheInfo.verifyUserEmails; + this.appName = cacheInfo.appName; + this.hooksController = cacheInfo.hooksController; this.filesController = cacheInfo.filesController; - this.pushController = cacheInfo.pushController; + this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; + this.userController = cacheInfo.userController; this.oauth = cacheInfo.oauth; - + this.customPages = cacheInfo.customPages || {}; this.mount = mount; } -} + + static validate(options) { + this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails, + appName: options.appName, + publicServerURL: options.publicServerURL}) + } + + static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) { + if (verifyUserEmails) { + if (typeof appName !== 'string') { + throw 'An app name is required when using email verification.'; + } + if (typeof publicServerURL !== 'string') { + throw 'A public server url is required when using email verification.'; + } + } + } + + get invalidLinkURL() { + return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`; + } + + get verifyEmailSuccessURL() { + return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`; + } + + get choosePasswordURL() { + return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`; + } + + get requestResetPasswordURL() { + return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`; + } + + get passwordResetSuccessURL() { + return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`; + } + + get verifyEmailURL() { + return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`; + } +}; export default Config; module.exports = Config; diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index ef45b022..ab7d7156 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,13 +10,20 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); +import Config from '../Config'; export class AdaptableController { - constructor(adapter) { + constructor(adapter, appId, options) { + this.options = options; + this.appId = appId; this.adapter = adapter; + this.setFeature(); } + // sets features for Dashboard to consume from features router + setFeature() {} + set adapter(adapter) { this.validateAdapter(adapter); this[_adapter] = adapter; @@ -26,12 +33,15 @@ export class AdaptableController { return this[_adapter]; } + get config() { + return new Config(this.appId); + } + expectedAdapterType() { throw new Error("Subclasses should implement expectedAdapterType()"); } validateAdapter(adapter) { - if (!adapter) { throw new Error(this.constructor.name+" requires an adapter"); } @@ -56,10 +66,9 @@ export class AdaptableController { }, {}); if (Object.keys(mismatches).length > 0) { - console.error(adapter, mismatches); - throw new Error("Adapter prototype don't match expected prototype"); + throw new Error("Adapter prototype don't match expected prototype", adapter, mismatches); } } } -export default AdaptableController; \ No newline at end of file +export default AdaptableController; diff --git a/src/ExportAdapter.js b/src/Controllers/DatabaseController.js similarity index 65% rename from src/ExportAdapter.js rename to src/Controllers/DatabaseController.js index 821c69cd..683b9be0 100644 --- a/src/ExportAdapter.js +++ b/src/Controllers/DatabaseController.js @@ -2,18 +2,17 @@ // Parse database. var mongodb = require('mongodb'); -var MongoClient = mongodb.MongoClient; var Parse = require('parse/node').Parse; -var Schema = require('./Schema'); -var transform = require('./transform'); +var Schema = require('./../Schema'); +var transform = require('./../transform'); // options can contain: // collectionPrefix: the string to put in front of every collection name. -function ExportAdapter(mongoURI, options = {}) { - this.mongoURI = mongoURI; +function DatabaseController(adapter, { collectionPrefix } = {}) { + this.adapter = adapter; - this.collectionPrefix = options.collectionPrefix; + this.collectionPrefix = collectionPrefix; // We don't want a mutable this.schema, because then you could have // one request that uses different schemas for different parts of @@ -25,25 +24,13 @@ function ExportAdapter(mongoURI, options = {}) { // Connects to the database. Returns a promise that resolves when the // connection is successful. -// this.db will be populated with a Mongo "Db" object when the -// promise resolves successfully. -ExportAdapter.prototype.connect = function() { - if (this.connectionPromise) { - // There's already a connection in progress. - return this.connectionPromise; - } - - this.connectionPromise = Promise.resolve().then(() => { - return MongoClient.connect(this.mongoURI); - }).then((db) => { - this.db = db; - }); - return this.connectionPromise; +DatabaseController.prototype.connect = function() { + return this.adapter.connect(); }; // Returns a promise for a Mongo collection. // Generally just for internal use. -ExportAdapter.prototype.collection = function(className) { +DatabaseController.prototype.collection = function(className) { if (!Schema.classNameIsValid(className)) { throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, 'invalid className: ' + className); @@ -51,10 +38,20 @@ ExportAdapter.prototype.collection = function(className) { return this.rawCollection(className); }; -ExportAdapter.prototype.rawCollection = function(className) { - return this.connect().then(() => { - return this.db.collection(this.collectionPrefix + className); - }); +DatabaseController.prototype.adaptiveCollection = function(className) { + return this.adapter.adaptiveCollection(this.collectionPrefix + className); +}; + +DatabaseController.prototype.collectionExists = function(className) { + return this.adapter.collectionExists(this.collectionPrefix + className); +}; + +DatabaseController.prototype.rawCollection = function(className) { + return this.adapter.collection(this.collectionPrefix + className); +}; + +DatabaseController.prototype.dropCollection = function(className) { + return this.adapter.dropCollection(this.collectionPrefix + className); }; function returnsTrue() { @@ -64,7 +61,7 @@ function returnsTrue() { // Returns a promise for a schema object. // If we are provided a acceptor, then we run it on the schema. // If the schema isn't accepted, we reload it at most once. -ExportAdapter.prototype.loadSchema = function(acceptor = returnsTrue) { +DatabaseController.prototype.loadSchema = function(acceptor = returnsTrue) { if (!this.schemaPromise) { this.schemaPromise = this.collection('_SCHEMA').then((coll) => { @@ -88,8 +85,8 @@ ExportAdapter.prototype.loadSchema = function(acceptor = returnsTrue) { // Returns a promise for the classname that is related to the given // classname through the key. -// TODO: make this not in the ExportAdapter interface -ExportAdapter.prototype.redirectClassNameForKey = function(className, key) { +// TODO: make this not in the DatabaseController interface +DatabaseController.prototype.redirectClassNameForKey = function(className, key) { return this.loadSchema().then((schema) => { var t = schema.getExpectedType(className, key); var match = t.match(/^relation<(.*)>$/); @@ -105,7 +102,7 @@ ExportAdapter.prototype.redirectClassNameForKey = function(className, key) { // Returns a promise that resolves to the new schema. // This does not update this.schema, because in a situation like a // batch request, that could confuse other users of the schema. -ExportAdapter.prototype.validateObject = function(className, object, query) { +DatabaseController.prototype.validateObject = function(className, object, query) { return this.loadSchema().then((schema) => { return schema.validateObject(className, object, query); }); @@ -113,7 +110,7 @@ ExportAdapter.prototype.validateObject = function(className, object, query) { // Like transform.untransformObject but you need to provide a className. // Filters out any data that shouldn't be on this REST-formatted object. -ExportAdapter.prototype.untransformObject = function( +DatabaseController.prototype.untransformObject = function( schema, isMaster, aclGroup, className, mongoObject) { var object = transform.untransformObject(schema, className, mongoObject); @@ -138,65 +135,59 @@ ExportAdapter.prototype.untransformObject = function( // acl: a list of strings. If the object to be updated has an ACL, // one of the provided strings must provide the caller with // write permissions. -ExportAdapter.prototype.update = function(className, query, update, options) { +DatabaseController.prototype.update = function(className, query, update, options) { var acceptor = function(schema) { return schema.hasKeys(className, Object.keys(query)); }; var isMaster = !('acl' in options); var aclGroup = options.acl || []; var mongoUpdate, schema; - return this.loadSchema(acceptor).then((s) => { - schema = s; - if (!isMaster) { - return schema.validatePermission(className, aclGroup, 'update'); - } - return Promise.resolve(); - }).then(() => { - - return this.handleRelationUpdates(className, query.objectId, update); - }).then(() => { - return this.collection(className); - }).then((coll) => { - var mongoWhere = transform.transformWhere(schema, className, query); - if (options.acl) { - var writePerms = [ - {_wperm: {'$exists': false}} - ]; - for (var entry of options.acl) { - writePerms.push({_wperm: {'$in': [entry]}}); + return this.loadSchema(acceptor) + .then(s => { + schema = s; + if (!isMaster) { + return schema.validatePermission(className, aclGroup, 'update'); } - mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; - } - - mongoUpdate = transform.transformUpdate(schema, className, update); - - return coll.findAndModify(mongoWhere, {}, mongoUpdate, {}); - }).then((result) => { - if (!result.value) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); - } - if (result.lastErrorObject.n != 1) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); - } - - var response = {}; - var inc = mongoUpdate['$inc']; - if (inc) { - for (var key in inc) { - response[key] = (result.value[key] || 0) + inc[key]; + return Promise.resolve(); + }) + .then(() => this.handleRelationUpdates(className, query.objectId, update)) + .then(() => this.adaptiveCollection(className)) + .then(collection => { + var mongoWhere = transform.transformWhere(schema, className, query); + if (options.acl) { + var writePerms = [ + {_wperm: {'$exists': false}} + ]; + for (var entry of options.acl) { + writePerms.push({_wperm: {'$in': [entry]}}); + } + mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; } - } - return response; - }); + mongoUpdate = transform.transformUpdate(schema, className, update); + return collection.findOneAndUpdate(mongoWhere, mongoUpdate); + }) + .then(result => { + if (!result) { + return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.')); + } + + let response = {}; + let inc = mongoUpdate['$inc']; + if (inc) { + Object.keys(inc).forEach(key => { + response[key] = result[key]; + }); + } + return response; + }); }; // Processes relation-updating operations from a REST-format update. // Returns a promise that resolves successfully when these are // processed. // This mutates update. -ExportAdapter.prototype.handleRelationUpdates = function(className, +DatabaseController.prototype.handleRelationUpdates = function(className, objectId, update) { var pending = []; @@ -243,7 +234,7 @@ ExportAdapter.prototype.handleRelationUpdates = function(className, // Adds a relation. // Returns a promise that resolves successfully iff the add was successful. -ExportAdapter.prototype.addRelation = function(key, fromClassName, +DatabaseController.prototype.addRelation = function(key, fromClassName, fromId, toId) { var doc = { relatedId: toId, @@ -258,7 +249,7 @@ ExportAdapter.prototype.addRelation = function(key, fromClassName, // Removes a relation. // Returns a promise that resolves successfully iff the remove was // successful. -ExportAdapter.prototype.removeRelation = function(key, fromClassName, +DatabaseController.prototype.removeRelation = function(key, fromClassName, fromId, toId) { var doc = { relatedId: toId, @@ -277,7 +268,7 @@ ExportAdapter.prototype.removeRelation = function(key, fromClassName, // acl: a list of strings. If the object to be updated has an ACL, // one of the provided strings must provide the caller with // write permissions. -ExportAdapter.prototype.destroy = function(className, query, options = {}) { +DatabaseController.prototype.destroy = function(className, query, options = {}) { var isMaster = !('acl' in options); var aclGroup = options.acl || []; @@ -320,7 +311,7 @@ ExportAdapter.prototype.destroy = function(className, query, options = {}) { // Inserts an object into the database. // Returns a promise that resolves successfully iff the object saved. -ExportAdapter.prototype.create = function(className, object, options) { +DatabaseController.prototype.create = function(className, object, options) { var schema; var isMaster = !('acl' in options); var aclGroup = options.acl || []; @@ -346,28 +337,21 @@ ExportAdapter.prototype.create = function(className, object, options) { // This should only be used for testing - use 'find' for normal code // to avoid Mongo-format dependencies. // Returns a promise that resolves to a list of items. -ExportAdapter.prototype.mongoFind = function(className, query, options = {}) { - return this.collection(className).then((coll) => { - return coll.find(query, options).toArray(); - }); +DatabaseController.prototype.mongoFind = function(className, query, options = {}) { + return this.adaptiveCollection(className) + .then(collection => collection.find(query, options)); }; // Deletes everything in the database matching the current collectionPrefix // Won't delete collections in the system namespace // Returns a promise. -ExportAdapter.prototype.deleteEverything = function() { +DatabaseController.prototype.deleteEverything = function() { this.schemaPromise = null; - return this.connect().then(() => { - return this.db.collections(); - }).then((colls) => { - var promises = []; - for (var coll of colls) { - if (!coll.namespace.match(/\.system\./) && - coll.collectionName.indexOf(this.collectionPrefix) === 0) { - promises.push(coll.drop()); - } - } + return this.adapter.collectionsContaining(this.collectionPrefix).then(collections => { + let promises = collections.map(collection => { + return collection.drop(); + }); return Promise.all(promises); }); }; @@ -376,13 +360,11 @@ ExportAdapter.prototype.deleteEverything = function() { function keysForQuery(query) { var sublist = query['$and'] || query['$or']; if (sublist) { - var answer = new Set(); - for (var subquery of sublist) { - for (var key of keysForQuery(subquery)) { - answer.add(key); - } - } - return answer; + let answer = sublist.reduce((memo, subquery) => { + return memo.concat(keysForQuery(subquery)); + }, []); + + return new Set(answer); } return new Set(Object.keys(query)); @@ -390,59 +372,74 @@ function keysForQuery(query) { // Returns a promise for a list of related ids given an owning id. // className here is the owning className. -ExportAdapter.prototype.relatedIds = function(className, key, owningId) { - var joinTable = '_Join:' + key + ':' + className; - return this.collection(joinTable).then((coll) => { - return coll.find({owningId: owningId}).toArray(); - }).then((results) => { - return results.map(r => r.relatedId); - }); +DatabaseController.prototype.relatedIds = function(className, key, owningId) { + return this.adaptiveCollection(joinTableName(className, key)) + .then(coll => coll.find({owningId : owningId})) + .then(results => results.map(r => r.relatedId)); }; // Returns a promise for a list of owning ids given some related ids. // className here is the owning className. -ExportAdapter.prototype.owningIds = function(className, key, relatedIds) { - var joinTable = '_Join:' + key + ':' + className; - return this.collection(joinTable).then((coll) => { - return coll.find({relatedId: {'$in': relatedIds}}).toArray(); - }).then((results) => { - return results.map(r => r.owningId); - }); +DatabaseController.prototype.owningIds = function(className, key, relatedIds) { + return this.adaptiveCollection(joinTableName(className, key)) + .then(coll => coll.find({ relatedId: { '$in': relatedIds } })) + .then(results => results.map(r => r.owningId)); }; // Modifies query so that it no longer has $in on relation fields, or // equal-to-pointer constraints on relation fields. // Returns a promise that resolves when query is mutated -// TODO: this only handles one of these at a time - make it handle more -ExportAdapter.prototype.reduceInRelation = function(className, query, schema) { +DatabaseController.prototype.reduceInRelation = function(className, query, schema) { + // Search for an in-relation or equal-to-relation - for (var key in query) { - if (query[key] && - (query[key]['$in'] || query[key].__type == 'Pointer')) { - var t = schema.getExpectedType(className, key); - var match = t ? t.match(/^relation<(.*)>$/) : false; + // Make it sequential for now, not sure of paralleization side effects + if (query['$or']) { + let ors = query['$or']; + return Promise.all(ors.map((aQuery, index) => { + return this.reduceInRelation(className, aQuery, schema).then((aQuery) => { + query['$or'][index] = aQuery; + }) + })); + } + + let promises = Object.keys(query).map((key) => { + if (query[key] && (query[key]['$in'] || query[key].__type == 'Pointer')) { + let t = schema.getExpectedType(className, key); + let match = t ? t.match(/^relation<(.*)>$/) : false; if (!match) { - continue; + return Promise.resolve(query); } - var relatedClassName = match[1]; - var relatedIds; + let relatedClassName = match[1]; + let relatedIds; if (query[key]['$in']) { relatedIds = query[key]['$in'].map(r => r.objectId); } else { relatedIds = [query[key].objectId]; } return this.owningIds(className, key, relatedIds).then((ids) => { - delete query[key]; - query.objectId = {'$in': ids}; + delete query[key]; + this.addInObjectIdsIds(ids, query); + return Promise.resolve(query); }); } - } - return Promise.resolve(); + return Promise.resolve(query); + }) + + return Promise.all(promises).then(() => { + return Promise.resolve(query); + }) }; // Modifies query so that it no longer has $relatedTo // Returns a promise that resolves when query is mutated -ExportAdapter.prototype.reduceRelationKeys = function(className, query) { +DatabaseController.prototype.reduceRelationKeys = function(className, query) { + + if (query['$or']) { + return Promise.all(query['$or'].map((aQuery) => { + return this.reduceRelationKeys(className, aQuery); + })); + } + var relatedTo = query['$relatedTo']; if (relatedTo) { return this.relatedIds( @@ -450,43 +447,22 @@ ExportAdapter.prototype.reduceRelationKeys = function(className, query) { relatedTo.key, relatedTo.object.objectId).then((ids) => { delete query['$relatedTo']; - query['objectId'] = {'$in': ids}; + this.addInObjectIdsIds(ids, query); return this.reduceRelationKeys(className, query); }); } }; -// Does a find with "smart indexing". -// Currently this just means, if it needs a geoindex and there is -// none, then build the geoindex. -// This could be improved a lot but it's not clear if that's a good -// idea. Or even if this behavior is a good idea. -ExportAdapter.prototype.smartFind = function(coll, where, options) { - return coll.find(where, options).toArray() - .then((result) => { - return result; - }, (error) => { - // Check for "no geoindex" error - if (!error.message.match(/unable to find index for .geoNear/) || - error.code != 17007) { - throw error; - } - - // Figure out what key needs an index - var key = error.message.match(/field=([A-Za-z_0-9]+) /)[1]; - if (!key) { - throw error; - } - - var index = {}; - index[key] = '2d'; - //TODO: condiser moving index creation logic into Schema.js - return coll.createIndex(index).then(() => { - // Retry, but just once. - return coll.find(where, options).toArray(); - }); - }); -}; +DatabaseController.prototype.addInObjectIdsIds = function(ids, query) { + if (typeof query.objectId == 'string') { + query.objectId = {'$in': [query.objectId]}; + } + query.objectId = query.objectId || {}; + let queryIn = [].concat(query.objectId['$in'] || [], ids || []); + // make a set and spread to remove duplicates + query.objectId = {'$in': [...new Set(queryIn)]}; + return query; +} // Runs a query on the database. // Returns a promise that resolves to a list of items. @@ -502,7 +478,7 @@ ExportAdapter.prototype.smartFind = function(coll, where, options) { // TODO: make userIds not needed here. The db adapter shouldn't know // anything about users, ideally. Then, improve the format of the ACL // arg to work like the others. -ExportAdapter.prototype.find = function(className, query, options = {}) { +DatabaseController.prototype.find = function(className, query, options = {}) { var mongoOptions = {}; if (options.skip) { mongoOptions.skip = options.skip; @@ -541,8 +517,8 @@ ExportAdapter.prototype.find = function(className, query, options = {}) { }).then(() => { return this.reduceInRelation(className, query, schema); }).then(() => { - return this.collection(className); - }).then((coll) => { + return this.adaptiveCollection(className); + }).then(collection => { var mongoWhere = transform.transformWhere(schema, className, query); if (!isMaster) { var orParts = [ @@ -555,9 +531,10 @@ ExportAdapter.prototype.find = function(className, query, options = {}) { mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]}; } if (options.count) { - return coll.count(mongoWhere, mongoOptions); + delete mongoOptions.limit; + return collection.count(mongoWhere, mongoOptions); } else { - return this.smartFind(coll, mongoWhere, mongoOptions) + return collection.find(mongoWhere, mongoOptions) .then((mongoResults) => { return mongoResults.map((r) => { return this.untransformObject( @@ -568,4 +545,8 @@ ExportAdapter.prototype.find = function(className, query, options = {}) { }); }; -module.exports = ExportAdapter; +function joinTableName(className, key) { + return `_Join:${key}:${className}`; +} + +module.exports = DatabaseController; diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 9634d807..712e326c 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -3,6 +3,8 @@ import { Parse } from 'parse/node'; import { randomHexString } from '../cryptoUtils'; import AdaptableController from './AdaptableController'; import { FilesAdapter } from '../Adapters/Files/FilesAdapter'; +import path from 'path'; +import mime from 'mime'; export class FilesController extends AdaptableController { @@ -10,10 +12,22 @@ export class FilesController extends AdaptableController { return this.adapter.getFileData(config, filename); } - createFile(config, filename, data) { + createFile(config, filename, data, contentType) { + + let extname = path.extname(filename); + + const hasExtension = extname.length > 0; + + if (!hasExtension && contentType && mime.extension(contentType)) { + filename = filename + '.' + mime.extension(contentType); + } else if (hasExtension && !contentType) { + contentType = mime.lookup(filename); + } + filename = randomHexString(32) + '_' + filename; + var location = this.adapter.getFileLocation(config, filename); - return this.adapter.createFile(config, filename, data).then(() => { + return this.adapter.createFile(config, filename, data, contentType).then(() => { return Promise.resolve({ url: location, name: filename diff --git a/src/Controllers/PushController.js b/src/Controllers/PushController.js index 22d9fe11..55cb6095 100644 --- a/src/Controllers/PushController.js +++ b/src/Controllers/PushController.js @@ -3,9 +3,18 @@ import PromiseRouter from '../PromiseRouter'; import rest from '../rest'; import AdaptableController from './AdaptableController'; import { PushAdapter } from '../Adapters/Push/PushAdapter'; +import deepcopy from 'deepcopy'; +import features from '../features'; + +const FEATURE_NAME = 'push'; +const UNSUPPORTED_BADGE_KEY = "unsupported"; export class PushController extends AdaptableController { + setFeature() { + features.setFeature(FEATURE_NAME, this.adapter.feature || {}); + } + /** * Check whether the deviceType parameter in qury condition is valid or not. * @param {Object} where A query condition @@ -51,7 +60,55 @@ export class PushController extends AdaptableController { body['expiration_time'] = PushController.getExpirationTime(body); // TODO: If the req can pass the checking, we return immediately instead of waiting // pushes to be sent. We probably change this behaviour in the future. - rest.find(config, auth, '_Installation', where).then(function(response) { + let badgeUpdate = Promise.resolve(); + + if (body.badge) { + var op = {}; + if (body.badge == "Increment") { + op = {'$inc': {'badge': 1}} + } else if (Number(body.badge)) { + op = {'$set': {'badge': body.badge } } + } else { + throw "Invalid value for badge, expected number or 'Increment'"; + } + let updateWhere = deepcopy(where); + + // Only on iOS! + updateWhere.deviceType = 'ios'; + + // TODO: @nlutsenko replace with better thing + badgeUpdate = config.database.rawCollection("_Installation").then((coll) => { + return coll.update(updateWhere, op, { multi: true }); + }); + } + + return badgeUpdate.then(() => { + return rest.find(config, auth, '_Installation', where) + }).then((response) => { + if (body.badge && body.badge == "Increment") { + // Collect the badges to reduce the # of calls + let badgeInstallationsMap = response.results.reduce((map, installation) => { + let badge = installation.badge; + if (installation.deviceType != "ios") { + badge = UNSUPPORTED_BADGE_KEY; + } + map[badge] = map[badge] || []; + map[badge].push(installation); + return map; + }, {}); + + // Map the on the badges count and return the send result + let promises = Object.keys(badgeInstallationsMap).map((badge) => { + let payload = deepcopy(body); + if (badge == UNSUPPORTED_BADGE_KEY) { + delete payload.badge; + } else { + payload.badge = parseInt(badge); + } + return pushAdapter.send(payload, badgeInstallationsMap[badge]); + }); + return Promise.all(promises); + } return pushAdapter.send(body, response.results); }); } diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js new file mode 100644 index 00000000..1581a659 --- /dev/null +++ b/src/Controllers/UserController.js @@ -0,0 +1,203 @@ +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; +import AdaptableController from './AdaptableController'; +import MailAdapter from '../Adapters/Email/MailAdapter'; + +var DatabaseAdapter = require('../DatabaseAdapter'); +var RestWrite = require('../RestWrite'); +var RestQuery = require('../RestQuery'); +var hash = require('../password').hash; +var Auth = require('../Auth'); + +export class UserController extends AdaptableController { + + constructor(adapter, appId, options = {}) { + super(adapter, appId, options); + } + + validateAdapter(adapter) { + // Allow no adapter + if (!adapter && !this.shouldVerifyEmails) { + return; + } + super.validateAdapter(adapter); + } + + expectedAdapterType() { + return MailAdapter; + } + + get shouldVerifyEmails() { + return this.options.verifyUserEmails; + } + + setEmailVerifyToken(user) { + if (this.shouldVerifyEmails) { + user._email_verify_token = randomString(25); + user.emailVerified = false; + } + } + + verifyEmail(username, token) { + if (!this.shouldVerifyEmails) { + // Trying to verify email when not enabled + // TODO: Better error here. + return Promise.reject(); + } + + return this.config.database + .adaptiveCollection('_User') + .then(collection => { + // Need direct database access because verification token is not a parse field + return collection.findOneAndUpdate({ + username: username, + _email_verify_token: token + }, {$set: {emailVerified: true}}); + }) + .then(document => { + if (!document) { + return Promise.reject(); + } + return document; + }); + } + + checkResetTokenValidity(username, token) { + return this.config.database.adaptiveCollection('_User') + .then(collection => { + return collection.find({ + username: username, + _perishable_token: token + }, { limit: 1 }); + }) + .then(results => { + if (results.length != 1) { + return Promise.reject(); + } + return results[0]; + }); + } + + getUserIfNeeded(user) { + if (user.username && user.email) { + return Promise.resolve(user); + } + var where = {}; + if (user.username) { + where.username = user.username; + } + if (user.email) { + where.email = user.email; + } + + var query = new RestQuery(this.config, Auth.master(this.config), '_User', where); + return query.execute().then(function(result){ + if (result.results.length != 1) { + return Promise.reject(); + } + return result.results[0]; + }) + } + + + sendVerificationEmail(user) { + if (!this.shouldVerifyEmails) { + return; + } + // We may need to fetch the user in case of update email + this.getUserIfNeeded(user).then((user) => { + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; + let options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; + if (this.adapter.sendVerificationEmail) { + this.adapter.sendVerificationEmail(options); + } else { + this.adapter.sendMail(this.defaultVerificationEmail(options)); + } + }); + } + + setPasswordResetToken(email) { + let token = randomString(25); + return this.config.database + .adaptiveCollection('_User') + .then(collection => { + // Need direct database access because verification token is not a parse field + return collection.findOneAndUpdate( + { email: email}, // query + { $set: { _perishable_token: token } } // update + ); + }); + } + + sendPasswordResetEmail(email) { + if (!this.adapter) { + throw "Trying to send a reset password but no adapter is set"; + // TODO: No adapter? + return; + } + + return this.setPasswordResetToken(email).then((user) => { + + const token = encodeURIComponent(user._perishable_token); + const username = encodeURIComponent(user.username); + let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` + + let options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; + + if (this.adapter.sendPasswordResetEmail) { + this.adapter.sendPasswordResetEmail(options); + } else { + this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + } + + return Promise.resolve(user); + }); + } + + updatePassword(username, token, password, config) { + return this.checkResetTokenValidity(username, token).then(() => { + return updateUserPassword(username, token, password, this.config); + }); + } + + defaultVerificationEmail({link, user, appName, }) { + let text = "Hi,\n\n" + + "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + + "" + + "Click here to confirm it:\n" + link; + let to = user.get("email"); + let subject = 'Please verify your e-mail for ' + appName; + return { text, to, subject }; + } + + defaultResetPasswordEmail({link, user, appName, }) { + let text = "Hi,\n\n" + + "You requested to reset your password for " + appName + ".\n\n" + + "" + + "Click here to reset it:\n" + link; + let to = user.get("email"); + let subject = 'Password Reset for ' + appName; + return { text, to, subject }; + } +} + +// Mark this private +function updateUserPassword(username, token, password, config) { + var write = new RestWrite(config, Auth.master(config), '_User', { + username: username, + _perishable_token: token + }, {password: password, _perishable_token: null }, undefined); + return write.execute(); + } + +export default UserController; diff --git a/src/DatabaseAdapter.js b/src/DatabaseAdapter.js index 5c957418..6663f36b 100644 --- a/src/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -13,14 +13,17 @@ // * destroy(className, query, options) // * This list is incomplete and the database process is not fully modularized. // -// Default is ExportAdapter, which uses mongo. +// Default is MongoStorageAdapter. -var ExportAdapter = require('./ExportAdapter'); +import DatabaseController from './Controllers/DatabaseController'; +import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; -var adapter = ExportAdapter; -var dbConnections = {}; -var databaseURI = 'mongodb://localhost:27017/parse'; -var appDatabaseURIs = {}; +const DefaultDatabaseURI = 'mongodb://localhost:27017/parse'; + +let adapter = MongoStorageAdapter; +let dbConnections = {}; +let databaseURI = DefaultDatabaseURI; +let appDatabaseURIs = {}; function setAdapter(databaseAdapter) { adapter = databaseAdapter; @@ -46,10 +49,11 @@ function getDatabaseConnection(appId: string, collectionPrefix: string) { } var dbURI = (appDatabaseURIs[appId] ? appDatabaseURIs[appId] : databaseURI); - dbConnections[appId] = new adapter(dbURI, { + + let storageAdapter = new adapter(dbURI); + dbConnections[appId] = new DatabaseController(storageAdapter, { collectionPrefix: collectionPrefix }); - dbConnections[appId].connect(); return dbConnections[appId]; } @@ -59,5 +63,6 @@ module.exports = { setAdapter: setAdapter, setDatabaseURI: setDatabaseURI, setAppDatabaseURI: setAppDatabaseURI, - clearDatabaseURIs: clearDatabaseURIs + clearDatabaseURIs: clearDatabaseURIs, + defaultDatabaseURI: databaseURI }; diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index 8155c796..308e4ded 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -5,6 +5,8 @@ // themselves use our routing information, without disturbing express // components that external developers may be modifying. +import express from 'express'; + export default class PromiseRouter { // Each entry should be an object with: // path: the path to route, in express format @@ -15,8 +17,8 @@ export default class PromiseRouter { // status: optional. the http status code. defaults to 200 // response: a json object with the content of the response // location: optional. a location header - constructor() { - this.routes = []; + constructor(routes = []) { + this.routes = routes; this.mountRoutes(); } @@ -47,17 +49,11 @@ export default class PromiseRouter { if (handlers.length > 1) { const length = handlers.length; handler = function(req) { - var next = function(i, req, res) { - if (i == length) { - return res; - } - let result = handlers[i](req); - if (!result || typeof result.then !== "function") { - result = Promise.resolve(result); - } - return result.then((res) => (next(i+1, req, res))); - } - return next(0, req); + return handlers.reduce((promise, handler) => { + return promise.then((result) => { + return handler(req); + }); + }, Promise.resolve()); } } @@ -125,6 +121,29 @@ export default class PromiseRouter { } } }; + + expressApp() { + var expressApp = express(); + for (var route of this.routes) { + switch(route.method) { + case 'POST': + expressApp.post(route.path, makeExpressHandler(route.handler)); + break; + case 'GET': + expressApp.get(route.path, makeExpressHandler(route.handler)); + break; + case 'PUT': + expressApp.put(route.path, makeExpressHandler(route.handler)); + break; + case 'DELETE': + expressApp.delete(route.path, makeExpressHandler(route.handler)); + break; + default: + throw 'unexpected code branch'; + } + } + return expressApp; + } } // Global flag. Set this to true to log every request and response. @@ -142,15 +161,24 @@ function makeExpressHandler(promiseHandler) { JSON.stringify(req.body, null, 2)); } promiseHandler(req).then((result) => { - if (!result.response) { - console.log('BUG: the handler did not include a "response" field'); + if (!result.response && !result.location && !result.text) { + console.log('BUG: the handler did not include a "response" or a "location" field'); throw 'control should not get here'; } if (PromiseRouter.verbose) { - console.log('response:', JSON.stringify(result.response, null, 2)); + console.log('response:', JSON.stringify(result, null, 2)); } + var status = result.status || 200; res.status(status); + + if (result.text) { + return res.send(result.text); + } + + if (result.location && !result.response) { + return res.redirect(result.location); + } if (result.location) { res.set('Location', result.location); } diff --git a/src/RestQuery.js b/src/RestQuery.js index 86562e9d..9a4764a9 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -165,7 +165,9 @@ RestQuery.prototype.redirectClassNameForKey = function() { // Validates this operation against the allowClientClassCreation config. RestQuery.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster) { + let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product']; + if (this.config.allowClientClassCreation === false && !this.auth.isMaster + && sysClass.indexOf(this.className) === -1) { return this.config.database.loadSchema().then((schema) => { return schema.hasClass(this.className) }).then((hasClass) => { @@ -212,7 +214,11 @@ RestQuery.prototype.replaceInQuery = function() { }); } delete inQueryObject['$inQuery']; - inQueryObject['$in'] = values; + if (Array.isArray(inQueryObject['$in'])) { + inQueryObject['$in'] = inQueryObject['$in'].concat(values); + } else { + inQueryObject['$in'] = values; + } // Recurse to repeat return this.replaceInQuery(); @@ -249,7 +255,11 @@ RestQuery.prototype.replaceNotInQuery = function() { }); } delete notInQueryObject['$notInQuery']; - notInQueryObject['$nin'] = values; + if (Array.isArray(notInQueryObject['$nin'])) { + notInQueryObject['$nin'] = notInQueryObject['$nin'].concat(values); + } else { + notInQueryObject['$nin'] = values; + } // Recurse to repeat return this.replaceNotInQuery(); @@ -269,11 +279,11 @@ RestQuery.prototype.replaceSelect = function() { // The select value must have precisely two keys - query and key var selectValue = selectObject['$select']; + // iOS SDK don't send where if not set, let it pass if (!selectValue.query || !selectValue.key || typeof selectValue.query !== 'object' || !selectValue.query.className || - !selectValue.query.where || Object.keys(selectValue).length !== 2) { throw new Parse.Error(Parse.Error.INVALID_QUERY, 'improper usage of $select'); @@ -288,7 +298,11 @@ RestQuery.prototype.replaceSelect = function() { values.push(result[selectValue.key]); } delete selectObject['$select']; - selectObject['$in'] = values; + if (Array.isArray(selectObject['$in'])) { + selectObject['$in'] = selectObject['$in'].concat(values); + } else { + selectObject['$in'] = values; + } // Keep replacing $select clauses return this.replaceSelect(); @@ -327,7 +341,11 @@ RestQuery.prototype.replaceDontSelect = function() { values.push(result[dontSelectValue.key]); } delete dontSelectObject['$dontSelect']; - dontSelectObject['$nin'] = values; + if (Array.isArray(dontSelectObject['$nin'])) { + dontSelectObject['$nin'] = dontSelectObject['$nin'].concat(values); + } else { + dontSelectObject['$nin'] = values; + } // Keep replacing $dontSelect clauses return this.replaceDontSelect(); @@ -507,7 +525,7 @@ function replacePointers(object, path, replace) { } if (path.length == 0) { - if (object.__type == 'Pointer' && replace[object.objectId]) { + if (object.__type == 'Pointer') { return replace[object.objectId]; } return object; diff --git a/src/RestWrite.js b/src/RestWrite.js index 4922f71d..a907a61c 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -67,12 +67,12 @@ RestWrite.prototype.execute = function() { return this.handleInstallation(); }).then(() => { return this.handleSession(); + }).then(() => { + return this.validateAuthData(); }).then(() => { return this.runBeforeTrigger(); }).then(() => { return this.setRequiredFieldsIfNeeded(); - }).then(() => { - return this.validateAuthData(); }).then(() => { return this.transformUser(); }).then(() => { @@ -109,7 +109,9 @@ RestWrite.prototype.getUserAndRoleACL = function() { // Validates this operation against the allowClientClassCreation config. RestWrite.prototype.validateClientClassCreation = function() { - if (this.config.allowClientClassCreation === false && !this.auth.isMaster) { + let sysClass = ['_User', '_Installation', '_Role', '_Session', '_Product']; + if (this.config.allowClientClassCreation === false && !this.auth.isMaster + && sysClass.indexOf(this.className) === -1) { return this.config.database.loadSchema().then((schema) => { return schema.hasClass(this.className) }).then((hasClass) => { @@ -134,6 +136,10 @@ RestWrite.prototype.validateSchema = function() { // Runs any beforeSave triggers against this operation. // Any change leads to our data being mutated. RestWrite.prototype.runBeforeTrigger = function() { + if (this.response) { + return; + } + // Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class. if (!triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId)) { return Promise.resolve(); @@ -459,12 +465,18 @@ RestWrite.prototype.transformUser = function() { 'address'); } return Promise.resolve(); - }); + }).then(() => { + // We updated the email, send a new validation + this.storage['sendVerificationEmail'] = true; + this.config.userController.setEmailVerifyToken(this.data); + return Promise.resolve(); + }) }); }; // Handles any followup logic RestWrite.prototype.handleFollowup = function() { + if (this.storage && this.storage['clearSessions']) { var sessionQuery = { user: { @@ -474,9 +486,16 @@ RestWrite.prototype.handleFollowup = function() { } }; delete this.storage['clearSessions']; - return this.config.database.destroy('_Session', sessionQuery) + this.config.database.destroy('_Session', sessionQuery) .then(this.handleFollowup.bind(this)); } + + if (this.storage && this.storage['sendVerificationEmail']) { + delete this.storage['sendVerificationEmail']; + // Fire and forget! + this.config.userController.sendVerificationEmail(this.data); + this.handleFollowup.bind(this); + } }; // Handles the _Role class specialness. @@ -592,6 +611,9 @@ RestWrite.prototype.handleInstallation = function() { var promise = Promise.resolve(); + var idMatch; // Will be a match on either objectId or installationId + var deviceTokenMatches = []; + if (this.query && this.query.objectId) { promise = promise.then(() => { return this.config.database.find('_Installation', { @@ -601,22 +623,22 @@ RestWrite.prototype.handleInstallation = function() { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for update.'); } - var existing = results[0]; - if (this.data.installationId && existing.installationId && - this.data.installationId !== existing.installationId) { + idMatch = results[0]; + if (this.data.installationId && idMatch.installationId && + this.data.installationId !== idMatch.installationId) { throw new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); } - if (this.data.deviceToken && existing.deviceToken && - this.data.deviceToken !== existing.deviceToken && - !this.data.installationId && !existing.installationId) { + if (this.data.deviceToken && idMatch.deviceToken && + this.data.deviceToken !== idMatch.deviceToken && + !this.data.installationId && !idMatch.installationId) { throw new Parse.Error(136, 'deviceToken may not be changed in this ' + 'operation'); } if (this.data.deviceType && this.data.deviceType && - this.data.deviceType !== existing.deviceType) { + this.data.deviceType !== idMatch.deviceType) { throw new Parse.Error(136, 'deviceType may not be changed in this ' + 'operation'); @@ -627,8 +649,6 @@ RestWrite.prototype.handleInstallation = function() { } // Check if we already have installations for the installationId/deviceToken - var installationMatch; - var deviceTokenMatches = []; promise = promise.then(() => { if (this.data.installationId) { return this.config.database.find('_Installation', { @@ -639,7 +659,7 @@ RestWrite.prototype.handleInstallation = function() { }).then((results) => { if (results && results.length) { // We only take the first match by installationId - installationMatch = results[0]; + idMatch = results[0]; } if (this.data.deviceToken) { return this.config.database.find( @@ -651,7 +671,7 @@ RestWrite.prototype.handleInstallation = function() { if (results) { deviceTokenMatches = results; } - if (!installationMatch) { + if (!idMatch) { if (!deviceTokenMatches.length) { return; } else if (deviceTokenMatches.length == 1 && @@ -689,14 +709,14 @@ RestWrite.prototype.handleInstallation = function() { // Exactly one device token match and it doesn't have an installation // ID. This is the one case where we want to merge with the existing // object. - var delQuery = {objectId: installationMatch.objectId}; + var delQuery = {objectId: idMatch.objectId}; return this.config.database.destroy('_Installation', delQuery) .then(() => { return deviceTokenMatches[0]['objectId']; }); } else { if (this.data.deviceToken && - installationMatch.deviceToken != this.data.deviceToken) { + idMatch.deviceToken != this.data.deviceToken) { // We're setting the device token on an existing installation, so // we should try cleaning out old installations that match this // device token. @@ -712,7 +732,7 @@ RestWrite.prototype.handleInstallation = function() { this.config.database.destroy('_Installation', delQuery); } // In non-merge scenarios, just return the installation match id - return installationMatch.objectId; + return idMatch.objectId; } } }).then((objId) => { @@ -762,8 +782,10 @@ RestWrite.prototype.runDatabaseOperation = function() { // Run an update return this.config.database.update( this.className, this.query, this.data, this.runOptions).then((resp) => { - this.response = resp; - this.response.updatedAt = this.updatedAt; + resp.updatedAt = this.updatedAt; + this.response = { + response: resp + }; }); } else { // Set the default ACL for the new _User @@ -794,22 +816,33 @@ RestWrite.prototype.runDatabaseOperation = function() { // Returns nothing - doesn't wait for the trigger. RestWrite.prototype.runAfterTrigger = function() { + if (!this.response || !this.response.response) { + return; + } + + // Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class. + if (!triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId)) { + return Promise.resolve(); + } + var extraData = {className: this.className}; if (this.query && this.query.objectId) { extraData.objectId = this.query.objectId; } - // Build the inflated object, different from beforeSave, originalData is not empty - // since developers can change data in the beforeSave. - var inflatedObject = triggers.inflate(extraData, this.originalData); - inflatedObject._finishFetch(this.data); // Build the original object, we only do this for a update write. - var originalObject; + let originalObject; if (this.query && this.query.objectId) { originalObject = triggers.inflate(extraData, this.originalData); } - triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, inflatedObject, originalObject, this.config.applicationId); + // Build the inflated object, different from beforeSave, originalData is not empty + // since developers can change data in the beforeSave. + let updatedObject = triggers.inflate(extraData, this.originalData); + updatedObject.set(Parse._decode(undefined, this.data)); + updatedObject._handleSaveResponse(this.response.response, this.response.status || 200); + + triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config.applicationId); }; // A helper to figure out what location this operation happens at. @@ -825,4 +858,5 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; +export default RestWrite; module.exports = RestWrite; diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 9742f5f9..57efa95d 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -85,10 +85,7 @@ export class ClassesRouter extends PromiseRouter { } handleUpdate(req) { - return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); + return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body); } handleDelete(req) { diff --git a/src/Routers/FeaturesRouter.js b/src/Routers/FeaturesRouter.js new file mode 100644 index 00000000..f0cdb3ea --- /dev/null +++ b/src/Routers/FeaturesRouter.js @@ -0,0 +1,15 @@ +import { version } from '../../package.json'; +import PromiseRouter from '../PromiseRouter'; +import * as middleware from "../middlewares"; +import { getFeatures } from '../features'; + +export class FeaturesRouter extends PromiseRouter { + mountRoutes() { + this.route('GET','/serverInfo', middleware.promiseEnforceMasterKeyAccess, () => { + return { response: { + features: getFeatures(), + parseServerVersion: version, + } }; + }); + } +} diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 65da555f..a3a3c811 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -2,8 +2,8 @@ import express from 'express'; import BodyParser from 'body-parser'; import * as Middlewares from '../middlewares'; import { randomHexString } from '../cryptoUtils'; -import mime from 'mime'; import Config from '../Config'; +import mime from 'mime'; export class FilesRouter { @@ -41,7 +41,7 @@ export class FilesRouter { var contentType = mime.lookup(filename); res.set('Content-Type', contentType); res.end(data); - }).catch(() => { + }).catch((err) => { res.status(404); res.set('Content-Type', 'text/plain'); res.end('File not found.'); @@ -66,20 +66,13 @@ export class FilesRouter { 'Filename contains invalid characters.')); return; } - let extension = ''; - // Not very safe there. - const hasExtension = req.params.filename.indexOf('.') > 0; + const filename = req.params.filename; const contentType = req.get('Content-type'); - if (!hasExtension && contentType && mime.extension(contentType)) { - extension = '.' + mime.extension(contentType); - } - - const filename = req.params.filename + extension; const config = req.config; const filesController = config.filesController; - filesController.createFile(config, filename, req.body).then((result) => { + filesController.createFile(config, filename, req.body, contentType).then((result) => { res.status(201); res.set('Location', result.url); res.json(result); diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js new file mode 100644 index 00000000..53abdac5 --- /dev/null +++ b/src/Routers/GlobalConfigRouter.js @@ -0,0 +1,42 @@ +// global_config.js + +var Parse = require('parse/node').Parse; + +import PromiseRouter from '../PromiseRouter'; +import * as middleware from "../middlewares"; + +export class GlobalConfigRouter extends PromiseRouter { + getGlobalConfig(req) { + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOne({'_id': 1})) + .then(globalConfig => ({response: { params: globalConfig.params }})) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config does not exist', + } + })); + } + updateGlobalConfig(req) { + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) + .then(response => { + return { response: { result: true } } + }) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config cannot be updated', + } + })); + } + + mountRoutes() { + this.route('GET', '/config', req => { return this.getGlobalConfig(req) }); + this.route('PUT', '/config', middleware.promiseEnforceMasterKeyAccess, req => { return this.updateGlobalConfig(req) }); + } +} + +export default GlobalConfigRouter; diff --git a/src/Routers/HooksRouter.js b/src/Routers/HooksRouter.js index ed34cdc4..f214e5a6 100644 --- a/src/Routers/HooksRouter.js +++ b/src/Routers/HooksRouter.js @@ -1,15 +1,9 @@ import { Parse } from 'parse/node'; import PromiseRouter from '../PromiseRouter'; import { HooksController } from '../Controllers/HooksController'; - -function enforceMasterKeyAccess(req) { - if (!req.auth.isMaster) { - throw new Parse.Error(403, "unauthorized: master key is required"); - } -} +import * as middleware from "../middlewares"; export class HooksRouter extends PromiseRouter { - createHook(aHook, config) { return config.hooksController.createHook(aHook).then( (hook) => ({response: hook})); }; @@ -93,14 +87,14 @@ export class HooksRouter extends PromiseRouter { } mountRoutes() { - this.route('GET', '/hooks/functions', enforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers', enforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('GET', '/hooks/functions/:functionName', enforceMasterKeyAccess, this.handleGetFunctions.bind(this)); - this.route('GET', '/hooks/triggers/:className/:triggerName', enforceMasterKeyAccess, this.handleGetTriggers.bind(this)); - this.route('POST', '/hooks/functions', enforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('POST', '/hooks/triggers', enforceMasterKeyAccess, this.handlePost.bind(this)); - this.route('PUT', '/hooks/functions/:functionName', enforceMasterKeyAccess, this.handlePut.bind(this)); - this.route('PUT', '/hooks/triggers/:className/:triggerName', enforceMasterKeyAccess, this.handlePut.bind(this)); + this.route('GET', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); + this.route('GET', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); + this.route('GET', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handleGetFunctions.bind(this)); + this.route('GET', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handleGetTriggers.bind(this)); + this.route('POST', '/hooks/functions', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); + this.route('POST', '/hooks/triggers', middleware.promiseEnforceMasterKeyAccess, this.handlePost.bind(this)); + this.route('PUT', '/hooks/functions/:functionName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); + this.route('PUT', '/hooks/triggers/:className/:triggerName', middleware.promiseEnforceMasterKeyAccess, this.handlePut.bind(this)); } } diff --git a/src/Routers/LogsRouter.js b/src/Routers/LogsRouter.js index abd57944..fbc8ec99 100644 --- a/src/Routers/LogsRouter.js +++ b/src/Routers/LogsRouter.js @@ -1,25 +1,22 @@ import { Parse } from 'parse/node'; import PromiseRouter from '../PromiseRouter'; - -// only allow request with master key -let enforceSecurity = (auth) => { - if (!auth || !auth.isMaster) { - throw new Parse.Error( - Parse.Error.OPERATION_FORBIDDEN, - 'Clients aren\'t allowed to perform the ' + - 'get' + ' operation on logs.' - ); - } -} +import * as middleware from "../middlewares"; export class LogsRouter extends PromiseRouter { mountRoutes() { - this.route('GET','/logs', (req) => { + this.route('GET','/scriptlog', middleware.promiseEnforceMasterKeyAccess, this.validateRequest, (req) => { return this.handleGET(req); }); } + validateRequest(req) { + if (!req.config || !req.config.loggerController) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Logger adapter is not availabe'); + } + } + // Returns a promise for a {response} object. // query params: // level (optional) Level of logging you want to query for (info || error) @@ -27,28 +24,25 @@ export class LogsRouter extends PromiseRouter { // until (optional) End time for the search. Defaults to current time. // order (optional) Direction of results returned, either “asc” or “desc”. Defaults to “desc”. // size (optional) Number of rows returned by search. Defaults to 10 + // n same as size, overrides size if set handleGET(req) { - if (!req.config || !req.config.loggerController) { - throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, - 'Logger adapter is not availabe'); - } - - let promise = new Parse.Promise(); - let from = req.query.from; - let until = req.query.until; + const from = req.query.from; + const until = req.query.until; let size = req.query.size; - let order = req.query.order - let level = req.query.level; - enforceSecurity(req.auth); + if (req.query.n) { + size = req.query.n; + } + const order = req.query.order + const level = req.query.level; const options = { from, until, size, order, - level, - } - + level + }; + return req.config.loggerController.getLogs(options).then((result) => { return Promise.resolve({ response: result diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js new file mode 100644 index 00000000..017caef3 --- /dev/null +++ b/src/Routers/PublicAPIRouter.js @@ -0,0 +1,159 @@ +import PromiseRouter from '../PromiseRouter'; +import UserController from '../Controllers/UserController'; +import Config from '../Config'; +import express from 'express'; +import path from 'path'; +import fs from 'fs'; + +let public_html = path.resolve(__dirname, "../../public_html"); +let views = path.resolve(__dirname, '../../views'); + +export class PublicAPIRouter extends PromiseRouter { + + verifyEmail(req) { + let { token, username }= req.query; + let appId = req.params.appId; + let config = new Config(appId); + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + if (!token || !username) { + return this.invalidLink(req); + } + + let userController = config.userController; + return userController.verifyEmail(username, token).then( () => { + return Promise.resolve({ + status: 302, + location: `${config.verifyEmailSuccessURL}?username=${username}` + }); + }, ()=> { + return this.invalidLink(req); + }) + } + + changePassword(req) { + return new Promise((resolve, reject) => { + let config = new Config(req.query.id); + if (!config.publicServerURL) { + return resolve({ + status: 404, + text: 'Not found.' + }); + } + // Should we keep the file in memory or leave like that? + fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { + if (err) { + return reject(err); + } + data = data.replace("PARSE_SERVER_URL", `'${config.publicServerURL}'`); + resolve({ + text: data + }) + }); + }); + } + + requestResetPassword(req) { + + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { username, token } = req.query; + + if (!username || !token) { + return this.invalidLink(req); + } + + return config.userController.checkResetTokenValidity(username, token).then( (user) => { + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&app=${config.appName}` + }) + }, () => { + return this.invalidLink(req); + }) + } + + resetPassword(req) { + + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { + username, + token, + new_password + } = req.body; + + if (!username || !token || !new_password) { + return this.invalidLink(req); + } + + return config.userController.updatePassword(username, token, new_password).then((result) => { + return Promise.resolve({ + status: 302, + location: config.passwordResetSuccessURL + }); + }, (err) => { + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&error=${err}&app=${config.appName}` + }); + }); + + } + + invalidLink(req) { + return Promise.resolve({ + status: 302, + location: req.config.invalidLinkURL + }); + } + + missingPublicServerURL() { + return Promise.resolve({ + text: 'Not found.', + status: 404 + }); + } + + setConfig(req) { + req.config = new Config(req.params.appId); + return Promise.resolve(); + } + + mountRoutes() { + this.route('GET','/apps/:appId/verify_email', + req => { this.setConfig(req) }, + req => { return this.verifyEmail(req); }); + + this.route('GET','/apps/choose_password', + req => { return this.changePassword(req); }); + + this.route('POST','/apps/:appId/request_password_reset', + req => { this.setConfig(req) }, + req => { return this.resetPassword(req); }); + + this.route('GET','/apps/:appId/request_password_reset', + req => { this.setConfig(req) }, + req => { return this.requestResetPassword(req); }); + } + + expressApp() { + let router = express(); + router.use("/apps", express.static(public_html)); + router.use("/", super.expressApp()); + return router; + } +} + +export default PublicAPIRouter; diff --git a/src/Routers/SchemasRouter.js b/src/Routers/SchemasRouter.js index d7388158..e352bd5e 100644 --- a/src/Routers/SchemasRouter.js +++ b/src/Routers/SchemasRouter.js @@ -1,27 +1,17 @@ // schemas.js var express = require('express'), - Parse = require('parse/node').Parse, - Schema = require('../Schema'); + Parse = require('parse/node').Parse, + Schema = require('../Schema'); import PromiseRouter from '../PromiseRouter'; - -// TODO: refactor in a SchemaController at one point... -function masterKeyRequiredResponse() { - return Promise.resolve({ - status: 401, - response: {error: 'master key not specified'}, - }) -} +import * as middleware from "../middlewares"; function classNameMismatchResponse(bodyClass, pathClass) { - return Promise.resolve({ - status: 400, - response: { - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class name mismatch between ' + bodyClass + ' and ' + pathClass, - } - }); + throw new Parse.Error( + Parse.Error.INVALID_CLASS_NAME, + `Class name mismatch between ${bodyClass} and ${pathClass}.` + ); } function mongoSchemaAPIResponseFields(schema) { @@ -45,65 +35,43 @@ function mongoSchemaToSchemaAPIResponse(schema) { } function getAllSchemas(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } - return req.config.database.collection('_SCHEMA') - .then(coll => coll.find({}).toArray()) - .then(schemas => ({response: { - results: schemas.map(mongoSchemaToSchemaAPIResponse) - }})); + return req.config.database.adaptiveCollection('_SCHEMA') + .then(collection => collection.find({})) + .then(schemas => schemas.map(mongoSchemaToSchemaAPIResponse)) + .then(schemas => ({ response: { results: schemas }})); } function getOneSchema(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } - return req.config.database.collection('_SCHEMA') - .then(coll => coll.findOne({'_id': req.params.className})) - .then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)})) - .catch(() => ({ - status: 400, - response: { - code: 103, - error: 'class ' + req.params.className + ' does not exist', - } - })); + const className = req.params.className; + return req.config.database.adaptiveCollection('_SCHEMA') + .then(collection => collection.find({ '_id': className }, { limit: 1 })) + .then(results => { + if (results.length != 1) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + } + return results[0]; + }) + .then(schema => ({ response: mongoSchemaToSchemaAPIResponse(schema) })); } function createSchema(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } if (req.params.className && req.body.className) { if (req.params.className != req.body.className) { return classNameMismatchResponse(req.body.className, req.params.className); } } - var className = req.params.className || req.body.className; + + const className = req.params.className || req.body.className; if (!className) { - return Promise.resolve({ - status: 400, - response: { - code: 135, - error: 'POST ' + req.path + ' needs class name', - }, - }); + throw new Parse.Error(135, `POST ${req.path} needs a class name.`); } + return req.config.database.loadSchema() - .then(schema => schema.addClassIfNotExists(className, req.body.fields)) - .then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) })) - .catch(error => ({ - status: 400, - response: error, - })); + .then(schema => schema.addClassIfNotExists(className, req.body.fields)) + .then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) })); } 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); } @@ -112,168 +80,115 @@ function modifySchema(req) { var className = req.params.className; return req.config.database.loadSchema() - .then(schema => { - if (!schema.data[className]) { - return Promise.resolve({ - status: 400, - response: { - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class ' + req.params.className + ' does not exist', + .then(schema => { + if (!schema.data[className]) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${req.params.className} does not exist.`); + } + + let existingFields = schema.data[className]; + Object.keys(submittedFields).forEach(name => { + let field = submittedFields[name]; + if (existingFields[name] && field.__op !== 'Delete') { + throw new Parse.Error(255, `Field ${name} exists, cannot update.`); + } + if (!existingFields[name] && field.__op === 'Delete') { + throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`); } }); - } - var existingFields = schema.data[className]; - for (var submittedFieldName in submittedFields) { - if (existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op !== 'Delete') { - return Promise.resolve({ - status: 400, - response: { - code: 255, - error: 'field ' + submittedFieldName + ' exists, cannot update', - } - }); + let newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields); + let mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className); + if (!mongoObject.result) { + throw new Parse.Error(mongoObject.code, mongoObject.error); } - if (!existingFields[submittedFieldName] && submittedFields[submittedFieldName].__op === 'Delete') { - return Promise.resolve({ - status: 400, - response: { - code: 255, - error: 'field ' + submittedFieldName + ' does not exist, cannot delete', - } - }); - } - } - - var newSchema = Schema.buildMergedSchemaObject(existingFields, submittedFields); - var mongoObject = Schema.mongoSchemaFromFieldsAndClassName(newSchema, className); - if (!mongoObject.result) { - return Promise.resolve({ - status: 400, - response: mongoObject, + // Finally we have checked to make sure the request is valid and we can start deleting fields. + // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. + let deletionPromises = []; + Object.keys(submittedFields).forEach(submittedFieldName => { + if (submittedFields[submittedFieldName].__op === 'Delete') { + let promise = schema.deleteField(submittedFieldName, className, req.config.database); + deletionPromises.push(promise); + } }); - } - // Finally we have checked to make sure the request is valid and we can start deleting fields. - // Do all deletions first, then a single save to _SCHEMA collection to handle all additions. - var deletionPromises = [] - Object.keys(submittedFields).forEach(submittedFieldName => { - if (submittedFields[submittedFieldName].__op === 'Delete') { - var promise = req.config.database.connect() - .then(() => schema.deleteField( - submittedFieldName, - className, - req.config.database.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)}); + }) + })); }); - - return Promise.all(deletionPromises) - .then(() => new Promise((resolve, reject) => { - schema.collection.update({_id: className}, mongoObject.result, {w: 1}, (err, docs) => { - if (err) { - reject(err); - } - resolve({ response: mongoSchemaToSchemaAPIResponse(mongoObject.result)}); - }) - })); - }); } // A helper function that removes all join tables for a schema. Returns a promise. -var removeJoinTables = (database, prefix, mongoSchema) => { +var removeJoinTables = (database, mongoSchema) => { return Promise.all(Object.keys(mongoSchema) .filter(field => mongoSchema[field].startsWith('relation<')) .map(field => { - var joinCollectionName = prefix + '_Join:' + field + ':' + mongoSchema._id; - return new Promise((resolve, reject) => { - database.dropCollection(joinCollectionName, (err, results) => { - if (err) { - reject(err); - } else { - resolve(); - } - }) - }); + let collectionName = `_Join:${field}:${mongoSchema._id}`; + return database.dropCollection(collectionName); }) ); }; function deleteSchema(req) { - if (!req.auth.isMaster) { - return masterKeyRequiredResponse(); - } - if (!Schema.classNameIsValid(req.params.className)) { - return Promise.resolve({ - status: 400, - response: { - code: Parse.Error.INVALID_CLASS_NAME, - error: Schema.invalidClassNameMessage(req.params.className), - } - }); + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, Schema.invalidClassNameMessage(req.params.className)); } - return req.config.database.collection(req.params.className) - .then(coll => new Promise((resolve, reject) => { - coll.count((err, count) => { - if (err) { - reject(err); - } else if (count > 0) { - resolve({ - status: 400, - response: { - code: 255, - error: 'class ' + req.params.className + ' not empty, contains ' + count + ' objects, cannot drop schema', - } - }); - } else { - coll.drop((err, reply) => { - if (err) { - reject(err); - } else { - // We've dropped the collection now, so delete the item from _SCHEMA - // and clear the _Join collections - req.config.database.collection('_SCHEMA') - .then(coll => new Promise((resolve, reject) => { - coll.findAndRemove({ _id: req.params.className }, [], (err, doc) => { - if (err) { - reject(err); - } else if (doc.value === null) { - //tried to delete non-existant class - resolve({ response: {}}); - } else { - removeJoinTables(req.config.database.db, req.config.database.collectionPrefix, doc.value) - .then(resolve, reject); - } - }); - })) - .then(resolve.bind(undefined, {response: {}}), reject); - } - }); + return req.config.database.collectionExists(req.params.className) + .then(exist => { + if (!exist) { + return Promise.resolve(); } + return req.config.database.adaptiveCollection(req.params.className) + .then(collection => { + return collection.count() + .then(count => { + if (count > 0) { + throw new Parse.Error(255, `Class ${req.params.className} is not empty, contains ${count} objects, cannot drop schema.`); + } + return collection.drop(); + }) + }) + }) + .then(() => { + // We've dropped the collection now, so delete the item from _SCHEMA + // and clear the _Join collections + return req.config.database.adaptiveCollection('_SCHEMA') + .then(coll => coll.findOneAndDelete({_id: req.params.className})) + .then(document => { + if (document === null) { + //tried to delete non-existent class + return Promise.resolve(); + } + return removeJoinTables(req.config.database, document); + }); + }) + .then(() => { + // Success + return { response: {} }; + }, error => { + if (error.message == 'ns not found') { + // If they try to delete a non-existent class, that's fine, just let them. + return { response: {} }; + } + + return Promise.reject(error); }); - })) - .catch( (error) => { - if (error.message == 'ns not found') { - // If they try to delete a non-existant class, thats fine, just let them. - return Promise.resolve({ response: {} }); - } - - return Promise.reject(error); - }); } export class SchemasRouter extends PromiseRouter { mountRoutes() { - this.route('GET', '/schemas', getAllSchemas); - this.route('GET', '/schemas/:className', getOneSchema); - this.route('POST', '/schemas', createSchema); - this.route('POST', '/schemas/:className', createSchema); - this.route('PUT', '/schemas/:className', modifySchema); - this.route('DELETE', '/schemas/:className', deleteSchema); + this.route('GET', '/schemas', middleware.promiseEnforceMasterKeyAccess, getAllSchemas); + this.route('GET', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, getOneSchema); + this.route('POST', '/schemas', middleware.promiseEnforceMasterKeyAccess, createSchema); + this.route('POST', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, createSchema); + this.route('PUT', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, modifySchema); + this.route('DELETE', '/schemas/:className', middleware.promiseEnforceMasterKeyAccess, deleteSchema); } } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 592bdc0b..21dc80ba 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,14 +1,15 @@ // These methods handle the User-related routes. -import deepcopy from 'deepcopy'; +import deepcopy from 'deepcopy'; -import ClassesRouter from './ClassesRouter'; -import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import Auth from '../Auth'; +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; +import Auth from '../Auth'; import passwordCrypto from '../password'; -import RestWrite from '../RestWrite'; -import { newToken } from '../cryptoUtils'; +import RestWrite from '../RestWrite'; +let cryptoUtils = require('../cryptoUtils'); +let triggers = require('../triggers'); export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -25,7 +26,18 @@ export class UsersRouter extends ClassesRouter { let data = deepcopy(req.body); req.body = data; req.params.className = '_User'; + + //req.config.userController.setEmailVerifyToken(req.body); + return super.handleCreate(req); + + // if (req.config.verifyUserEmails) { + // // Send email as fire-and-forget once the user makes it into the DB. + // p.then(() => { + // req.config.userController.sendVerificationEmail(req.body); + // }); + // } + // return p; } handleUpdate(req) { @@ -75,7 +87,7 @@ export class UsersRouter extends ClassesRouter { } let user; - return req.database.find('_User', { username: req.body.username }) + return req.config.database.find('_User', { username: req.body.username }) .then((results) => { if (!results.length) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); @@ -87,7 +99,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - let token = 'r:' + newToken(); + let token = 'r:' + cryptoUtils.newToken(); user.sessionToken = token; delete user.password; @@ -140,6 +152,23 @@ export class UsersRouter extends ClassesRouter { } return Promise.resolve(success); } + + handleResetRequest(req) { + let { email } = req.body; + if (!email) { + throw new Parse.Error(Parse.Error.EMAIL_MISSING, "you must provide an email"); + } + let userController = req.config.userController; + + return userController.sendPasswordResetEmail(email).then((token) => { + return Promise.resolve({ + response: {} + }); + }, (err) => { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `no user found with email ${email}`); + }); + } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -150,9 +179,7 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', () => { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); - }); + this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }) } } diff --git a/src/Schema.js b/src/Schema.js index 63dc6f37..755249bf 100644 --- a/src/Schema.js +++ b/src/Schema.js @@ -10,7 +10,7 @@ // keeping it this way for now. // // In API-handling code, you should only use the Schema class via the -// ExportAdapter. This will let us replace the schema logic for +// DatabaseController. This will let us replace the schema logic for // different databases. // TODO: hide all schema logic inside the database adapter. @@ -48,13 +48,13 @@ var defaultColumns = { // The additional default columns for the _User collection (in addition to DefaultCols) _Role: { "name": {type:'String'}, - "users": {type:'Relation',className:'_User'}, - "roles": {type:'Relation',className:'_Role'} + "users": {type:'Relation', targetClass:'_User'}, + "roles": {type:'Relation', targetClass:'_Role'} }, // The additional default columns for the _User collection (in addition to DefaultCols) _Session: { "restricted": {type:'Boolean'}, - "user": {type:'Pointer', className:'_User'}, + "user": {type:'Pointer', targetClass:'_User'}, "installationId": {type:'String'}, "sessionToken": {type:'String'}, "expiresAt": {type:'Date'}, @@ -73,7 +73,8 @@ var defaultColumns = { var requiredColumns = { - _Product: ["productIdentifier", "icon", "order", "title", "subtitle"] + _Product: ["productIdentifier", "icon", "order", "title", "subtitle"], + _Role: ["name", "ACL"] } // Valid classes must: @@ -307,8 +308,12 @@ function mongoFieldTypeToSchemaAPIType(type) { // is done in mongoSchemaFromFieldsAndClassName. function buildMergedSchemaObject(mongoObject, putRequest) { var newSchema = {}; + let sysSchemaField = Object.keys(defaultColumns).indexOf(mongoObject._id) === -1 ? [] : Object.keys(defaultColumns[mongoObject._id]); for (var oldField in mongoObject) { if (oldField !== '_id' && oldField !== 'ACL' && oldField !== 'updatedAt' && oldField !== 'createdAt' && oldField !== 'objectId') { + if (sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1) { + continue; + } var fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete' if (!fieldIsDeleted) { newSchema[oldField] = mongoFieldTypeToSchemaAPIType(mongoObject[oldField]); @@ -317,6 +322,9 @@ function buildMergedSchemaObject(mongoObject, putRequest) { } for (var newField in putRequest) { if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') { + if (sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1) { + continue; + } newSchema[newField] = putRequest[newField]; } } @@ -332,29 +340,22 @@ function buildMergedSchemaObject(mongoObject, putRequest) { // enabled) before calling this function. Schema.prototype.addClassIfNotExists = function(className, fields) { if (this.data[className]) { - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class ' + className + ' already exists', - }); + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); } - var mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); - + let mongoObject = mongoSchemaFromFieldsAndClassName(fields, className); if (!mongoObject.result) { return Promise.reject(mongoObject); } return this.collection.insertOne(mongoObject.result) - .then(result => result.ops[0]) - .catch(error => { - if (error.code === 11000) { //Mongo's duplicate key error - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class ' + className + ' already exists', - }); - } - return Promise.reject(error); - }); + .then(result => result.ops[0]) + .catch(error => { + if (error.code === 11000) { //Mongo's duplicate key error + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`); + } + return Promise.reject(error); + }); }; // Returns a promise that resolves successfully to the new schema @@ -500,80 +501,47 @@ Schema.prototype.validateField = function(className, key, type, freeze) { // Passing the database and prefix is necessary in order to drop relation collections // and remove fields from objects. Ideally the database would belong to -// a database adapter and this fuction would close over it or access it via member. -Schema.prototype.deleteField = function(fieldName, className, database, prefix) { +// a database adapter and this function would close over it or access it via member. +Schema.prototype.deleteField = function(fieldName, className, database) { if (!classNameIsValid(className)) { - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: invalidClassNameMessage(className), - }); + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className)); } - if (!fieldNameIsValid(fieldName)) { - return Promise.reject({ - code: Parse.Error.INVALID_KEY_NAME, - error: 'invalid field name: ' + fieldName, - }); + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`); } - //Don't allow deleting the default fields. if (!fieldNameIsValidForClass(fieldName, className)) { - return Promise.reject({ - code: 136, - error: 'field ' + fieldName + ' cannot be changed', - }); + throw new Parse.Error(136, `field ${fieldName} cannot be changed`); } return this.reload() - .then(schema => { - return schema.hasClass(className) - .then(hasClass => { - if (!hasClass) { - return Promise.reject({ - code: Parse.Error.INVALID_CLASS_NAME, - error: 'class ' + className + ' does not exist', - }); - } + .then(schema => { + return schema.hasClass(className) + .then(hasClass => { + if (!hasClass) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} does not exist.`); + } + if (!schema.data[className][fieldName]) { + throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`); + } - if (!schema.data[className][fieldName]) { - return Promise.reject({ - code: 255, - error: 'field ' + fieldName + ' does not exist, cannot delete', - }); - } + if (schema.data[className][fieldName].startsWith('relation<')) { + //For relations, drop the _Join table + return database.dropCollection(`_Join:${fieldName}:${className}`); + } - if (schema.data[className][fieldName].startsWith('relation<')) { - //For relations, drop the _Join table - return database.dropCollection(prefix + '_Join:' + fieldName + ':' + className) - //Save the _SCHEMA object + // for non-relations, remove all the data. + // This is necessary to ensure that the data is still gone if they add the same field. + return database.collection(className) + .then(collection => { + var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? '_p_' + fieldName : fieldName; + return collection.update({}, { "$unset": { [mongoFieldName] : null } }, { multi: true }); + }); + }) + // Save the _SCHEMA object .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})); - } else { - //for non-relations, remove all the data. This is necessary to ensure that the data is still gone - //if they add the same field. - return new Promise((resolve, reject) => { - database.collection(prefix + className, (err, coll) => { - if (err) { - reject(err); - } else { - var mongoFieldName = schema.data[className][fieldName].startsWith('*') ? - '_p_' + fieldName : - fieldName; - return coll.update({}, { - "$unset": { [mongoFieldName] : null }, - }, { - multi: true, - }) - //Save the _SCHEMA object - .then(() => this.collection.update({ _id: className }, { $unset: {[fieldName]: null }})) - .then(resolve) - .catch(reject); - } - }); - }); - } }); - }); -} +}; // Given a schema promise, construct another schema promise that // validates this field once the schema loads. @@ -626,7 +594,7 @@ Schema.prototype.validateRequiredColumns = function(className, object, query) { if (!columns || columns.length == 0) { return Promise.resolve(this); } - + var missingColumns = columns.filter(function(column){ if (query && query.objectId) { if (object[column] && typeof object[column] === "object") { @@ -636,15 +604,15 @@ Schema.prototype.validateRequiredColumns = function(className, object, query) { // Not trying to do anything there return false; } - return !object[column] + return !object[column] }); - + if (missingColumns.length > 0) { throw new Parse.Error( Parse.Error.INCORRECT_TYPE, missingColumns[0]+' is required.'); } - + return Promise.resolve(this); } @@ -731,19 +699,31 @@ function getObjectType(obj) { if (obj instanceof Array) { return 'array'; } - if (obj.__type === 'Pointer' && obj.className) { - return '*' + obj.className; - } - if (obj.__type === 'File' && obj.name) { - return 'file'; - } - if (obj.__type === 'Date' && obj.iso) { - return 'date'; - } - if (obj.__type == 'GeoPoint' && - obj.latitude != null && - obj.longitude != null) { - return 'geopoint'; + if (obj.__type){ + switch(obj.__type) { + case 'Pointer' : + if(obj.className) { + return '*' + obj.className; + } + case 'File' : + if(obj.name) { + return 'file'; + } + case 'Date' : + if(obj.iso) { + return 'date'; + } + case 'GeoPoint' : + if(obj.latitude != null && obj.longitude != null) { + return 'geopoint'; + } + case 'Bytes' : + if(obj.base64) { + return; + } + default: + throw new Parse.Error(Parse.Error.INCORRECT_TYPE, "This is not a valid "+obj.__type); + } } if (obj['$ne']) { return getObjectType(obj['$ne']); diff --git a/src/cache.js b/src/cache.js index a737a91d..8893f29b 100644 --- a/src/cache.js +++ b/src/cache.js @@ -1,45 +1,35 @@ -export var apps = {}; -export var stats = {}; -export var isLoaded = false; -export var users = {}; +/** @flow weak */ -export function getApp(app, callback) { - if (apps[app]) return callback(true, apps[app]); - return callback(false); +export function CacheStore() { + let dataStore: {[id:KeyType]:ValueType} = {}; + return { + get: (key: KeyType): ValueType => { + return dataStore[key]; + }, + set(key: KeyType, value: ValueType): void { + dataStore[key] = value; + }, + remove(key: KeyType): void { + delete dataStore[key]; + }, + clear(): void { + dataStore = {}; + } + }; } -export function updateStat(key, value) { - stats[key] = value; -} - -export function getUser(sessionToken) { - if (users[sessionToken]) return users[sessionToken]; - return undefined; -} - -export function setUser(sessionToken, userObject) { - users[sessionToken] = userObject; -} - -export function clearUser(sessionToken) { - delete users[sessionToken]; -} +const apps = CacheStore(); +const users = CacheStore(); //So far used only in tests -export function clearCache() { - apps = {}; - stats = {}; - users = {}; +export function clearCache(): void { + apps.clear(); + users.clear(); } export default { apps, - stats, - isLoaded, - getApp, - updateStat, - clearUser, - getUser, - setUser, + users, clearCache, + CacheStore }; diff --git a/src/cli/parse-server.js b/src/cli/parse-server.js index 627964f9..c5945b6c 100755 --- a/src/cli/parse-server.js +++ b/src/cli/parse-server.js @@ -42,14 +42,6 @@ if (program.args.length > 0 ) { console.log(`Configuation loaded from ${jsonPath}`) } -if (!program.appId || !program.masterKey || !program.serverURL) { - program.outputHelp(); - console.error(""); - console.error(colors.red("ERROR: appId, masterKey and serverURL are required")); - console.error(""); - process.exit(1); -} - options = Object.keys(definitions).reduce(function (options, key) { if (program[key]) { options[key] = program[key]; @@ -61,6 +53,14 @@ if (!options.serverURL) { options.serverURL = `http://localhost:${options.port}${options.mountPath}`; } +if (!options.appId || !options.masterKey || !options.serverURL) { + program.outputHelp(); + console.error(""); + console.error(colors.red("ERROR: appId, masterKey and serverURL are required")); + console.error(""); + process.exit(1); +} + const app = express(); const api = new ParseServer(options); app.use(options.mountPath, api); diff --git a/src/cloud-code/httpRequest.js b/src/cloud-code/httpRequest.js index 2b5f9bff..4e8ff654 100644 --- a/src/cloud-code/httpRequest.js +++ b/src/cloud-code/httpRequest.js @@ -36,6 +36,12 @@ module.exports = function(options) { options.followRedirect = options.followRedirects == true; request(options, (error, response, body) => { + if (error) { + if (callbacks.error) { + callbacks.error(error); + } + return promise.reject(error); + } var httpResponse = {}; httpResponse.status = response.statusCode; httpResponse.headers = response.headers; @@ -46,7 +52,7 @@ module.exports = function(options) { httpResponse.data = JSON.parse(response.body); } catch (e) {} // Consider <200 && >= 400 as errors - if (error || httpResponse.status <200 || httpResponse.status >=400) { + if (httpResponse.status < 200 || httpResponse.status >= 400) { if (callbacks.error) { callbacks.error(httpResponse); } diff --git a/src/features.js b/src/features.js new file mode 100644 index 00000000..6ff00095 --- /dev/null +++ b/src/features.js @@ -0,0 +1,91 @@ +/** + * features.js + * Feature config file that holds information on the features that are currently + * available on Parse Server. This is primarily created to work with an UI interface + * like the web dashboard. The list of features will change depending on the your + * app, choice of adapter as well as Parse Server version. This approach will enable + * the dashboard to be built independently and still support these use cases. + * + * + * Default features and feature options are listed in the features object. + * + * featureSwitch is a convenient way to turn on/off features without changing the config + * + * Features that use Adapters should specify the feature options through + * the setFeature method in your controller and feature + * Reference PushController and ParsePushAdapter as an example. + * + * NOTE: When adding new endpoints be sure to update this list both (features, featureSwitch) + * if you are planning to have a UI consume it. + */ + +// default features +let features = { + globalConfig: { + create: false, + read: false, + update: false, + delete: false, + }, + hooks: { + create: false, + read: false, + update: false, + delete: false, + }, + logs: { + level: false, + size: false, + order: false, + until: false, + from: false, + }, + push: { + immediatePush: false, + scheduledPush: false, + storedPushData: false, + pushAudiences: false, + }, + schemas: { + addField: true, + removeField: true, + addClass: true, + removeClass: true, + clearAllDataFromClass: false, + exportClass: false, + }, +}; + +// master switch for features +let featuresSwitch = { + globalConfig: true, + hooks: true, + logs: true, + push: true, + schemas: true, +}; + +/** + * set feature config options + */ +function setFeature(key, value) { + features[key] = value; +} + +/** + * get feature config options + */ +function getFeatures() { + let result = {}; + Object.keys(features).forEach((key) => { + if (featuresSwitch[key] && features[key]) { + result[key] = features[key]; + } + }); + return result; +} + +module.exports = { + getFeatures, + setFeature, +}; diff --git a/src/global_config.js b/src/global_config.js deleted file mode 100644 index 0c005e4d..00000000 --- a/src/global_config.js +++ /dev/null @@ -1,46 +0,0 @@ -// global_config.js - -var Parse = require('parse/node').Parse; - -import PromiseRouter from './PromiseRouter'; -var router = new PromiseRouter(); - -function getGlobalConfig(req) { - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOne({'_id': 1})) - .then(globalConfig => ({response: { params: globalConfig.params }})) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config does not exist', - } - })); -} - -function updateGlobalConfig(req) { - if (!req.auth.isMaster) { - return Promise.resolve({ - status: 401, - response: {error: 'unauthorized'}, - }); - } - - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) - .then(response => { - return { response: { result: true } } - }) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config cannot be updated', - } - })); -} - -router.route('GET', '/config', getGlobalConfig); -router.route('PUT', '/config', updateGlobalConfig); - -module.exports = router; diff --git a/src/index.js b/src/index.js index 6112a3ea..a13d921d 100644 --- a/src/index.js +++ b/src/index.js @@ -10,43 +10,48 @@ var batch = require('./batch'), multer = require('multer'), Parse = require('parse/node').Parse; +//import passwordReset from './passwordReset'; import cache from './cache'; -import PromiseRouter from './PromiseRouter'; -import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; -import { S3Adapter } from './Adapters/Files/S3Adapter'; -import { GCSAdapter } from './Adapters/Files/GCSAdapter'; -import { FilesController } from './Controllers/FilesController'; - +import Config from './Config'; +import parseServerPackage from '../package.json'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; -import { PushController } from './Controllers/PushController'; - -import { ClassesRouter } from './Routers/ClassesRouter'; -import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { UsersRouter } from './Routers/UsersRouter'; -import { SessionsRouter } from './Routers/SessionsRouter'; -import { RolesRouter } from './Routers/RolesRouter'; -import { AnalyticsRouter } from './Routers/AnalyticsRouter'; -import { FunctionsRouter } from './Routers/FunctionsRouter'; -import { SchemasRouter } from './Routers/SchemasRouter'; -import { IAPValidationRouter } from './Routers/IAPValidationRouter'; -import { PushRouter } from './Routers/PushRouter'; -import { FilesRouter } from './Routers/FilesRouter'; -import { LogsRouter } from './Routers/LogsRouter'; -import { HooksRouter } from './Routers/HooksRouter'; - -import { loadAdapter } from './Adapters/AdapterLoader'; -import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; -import { LoggerController } from './Controllers/LoggerController'; -import { HooksController } from './Controllers/HooksController'; - +import PromiseRouter from './PromiseRouter'; import requiredParameter from './requiredParameter'; +import { AnalyticsRouter } from './Routers/AnalyticsRouter'; +import { ClassesRouter } from './Routers/ClassesRouter'; +import { FeaturesRouter } from './Routers/FeaturesRouter'; +import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; +import { FilesController } from './Controllers/FilesController'; +import { FilesRouter } from './Routers/FilesRouter'; +import { FunctionsRouter } from './Routers/FunctionsRouter'; +import { GCSAdapter } from './Adapters/Files/GCSAdapter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; +import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; +import { HooksController } from './Controllers/HooksController'; +import { HooksRouter } from './Routers/HooksRouter'; +import { IAPValidationRouter } from './Routers/IAPValidationRouter'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { loadAdapter } from './Adapters/AdapterLoader'; +import { LoggerController } from './Controllers/LoggerController'; +import { LogsRouter } from './Routers/LogsRouter'; +import { PublicAPIRouter } from './Routers/PublicAPIRouter'; +import { PushController } from './Controllers/PushController'; +import { PushRouter } from './Routers/PushRouter'; import { randomString } from './cryptoUtils'; +import { RolesRouter } from './Routers/RolesRouter'; +import { S3Adapter } from './Adapters/Files/S3Adapter'; +import { SchemasRouter } from './Routers/SchemasRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; +import { setFeature } from './features'; +import { UserController } from './Controllers/UserController'; +import { UsersRouter } from './Routers/UsersRouter'; + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); // ParseServer works like a constructor of an express app. // The args that we understand are: -// "databaseAdapter": a class like ExportAdapter providing create, find, +// "databaseAdapter": a class like DatabaseController providing create, find, // update, and delete // "filesAdapter": a class like GridStoreAdapter providing create, get, // and delete @@ -73,11 +78,12 @@ addParseCloud(); function ParseServer({ appId = requiredParameter('You must provide an appId!'), masterKey = requiredParameter('You must provide a masterKey!'), + appName, databaseAdapter, filesAdapter, push, loggerAdapter, - databaseURI, + databaseURI = DatabaseAdapter.defaultDatabaseURI, cloud, collectionPrefix = '', clientKey, @@ -90,9 +96,18 @@ function ParseServer({ allowClientClassCreation = true, oauth = {}, serverURL = requiredParameter('You must provide a serverURL!'), - maxUploadSize = '20mb' + maxUploadSize = '20mb', + verifyUserEmails = false, + emailAdapter, + publicServerURL, + customPages = { + invalidLink: undefined, + verifyEmailSuccess: undefined, + choosePassword: undefined, + passwordResetSuccess: undefined + }, }) { - + setFeature('serverVersion', parseServerPackage.version); // Initialize the node client SDK automatically Parse.initialize(appId, javascriptKey || 'unused', masterKey); Parse.serverURL = serverURL; @@ -116,19 +131,24 @@ function ParseServer({ } } - const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter); + const filesControllerAdapter = loadAdapter(filesAdapter, () => { + return new GridStoreAdapter(databaseURI); + }); const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); - + const emailControllerAdapter = loadAdapter(emailAdapter); // We pass the options and the base class for the adatper, // Note that passing an instance would work too - const filesController = new FilesController(filesControllerAdapter); - const pushController = new PushController(pushControllerAdapter); - const loggerController = new LoggerController(loggerControllerAdapter); + const filesController = new FilesController(filesControllerAdapter, appId); + const pushController = new PushController(pushControllerAdapter, appId); + const loggerController = new LoggerController(loggerControllerAdapter, appId); const hooksController = new HooksController(appId, collectionPrefix); + const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); - cache.apps[appId] = { + + cache.apps.set(appId, { masterKey: masterKey, + serverURL: serverURL, collectionPrefix: collectionPrefix, clientKey: clientKey, javascriptKey: javascriptKey, @@ -140,25 +160,34 @@ function ParseServer({ pushController: pushController, loggerController: loggerController, hooksController: hooksController, + userController: userController, + verifyUserEmails: verifyUserEmails, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, - oauth: oauth - }; + oauth: oauth, + appName: appName, + publicServerURL: publicServerURL, + customPages: customPages, + }); - // To maintain compatibility. TODO: Remove in v2.1 + // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { - cache.apps[appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); + cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } + Config.validate(cache.apps.get(appId)); + // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); - + //api.use("/apps", express.static(__dirname + "/public")); // File handling needs to be before default middlewares are applied api.use('/', new FilesRouter().getExpressRouter({ maxUploadSize: maxUploadSize })); + api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); + // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { api.use('/', require('./testing-routes').router); @@ -180,24 +209,27 @@ function ParseServer({ new SchemasRouter(), new PushRouter(), new LogsRouter(), - new IAPValidationRouter() + new IAPValidationRouter(), + new FeaturesRouter(), ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { - routers.push(require('./global_config')); + routers.push(new GlobalConfigRouter()); } if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { routers.push(new HooksRouter()); } - let appRouter = new PromiseRouter(); - routers.forEach((router) => { - appRouter.merge(router); - }); + let routes = routers.reduce((memo, router) => { + return memo.concat(router.routes); + }, []); + + let appRouter = new PromiseRouter(routes); + batch.mountOnto(appRouter); - appRouter.mountOnto(api); + api.use(appRouter.expressApp()); api.use(middlewares.handleParseErrors); @@ -221,13 +253,6 @@ function addParseCloud() { global.Parse = Parse; } -function getClassName(parseClass) { - if (parseClass && parseClass.className) { - return parseClass.className; - } - return parseClass; -} - module.exports = { ParseServer: ParseServer, S3Adapter: S3Adapter, diff --git a/src/middlewares.js b/src/middlewares.js index 8acece2d..b3c2bf17 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -35,7 +35,7 @@ function handleParseHeaders(req, res, next) { var fileViaJSON = false; - if (!info.appId || !cache.apps[info.appId]) { + if (!info.appId || !cache.apps.get(info.appId)) { // See if we can find the app id on the body. if (req.body instanceof Buffer) { // The only chance to find the app id is if this is a file @@ -44,12 +44,10 @@ function handleParseHeaders(req, res, next) { fileViaJSON = true; } - if (req.body && req.body._ApplicationId - && cache.apps[req.body._ApplicationId] - && ( - !info.masterKey - || - cache.apps[req.body._ApplicationId]['masterKey'] === info.masterKey) + if (req.body && + req.body._ApplicationId && + cache.apps.get(req.body._ApplicationId) && + (!info.masterKey || cache.apps.get(req.body._ApplicationId).masterKey === info.masterKey) ) { info.appId = req.body._ApplicationId; info.javascriptKey = req.body._JavaScriptKey || ''; @@ -84,15 +82,14 @@ function handleParseHeaders(req, res, next) { req.body = new Buffer(base64, 'base64'); } - info.app = cache.apps[info.appId]; + info.app = cache.apps.get(info.appId); req.config = new Config(info.appId, mount); - req.database = req.config.database; req.info = info; var isMaster = (info.masterKey === req.config.masterKey); if (isMaster) { - req.auth = new auth.Auth(req.config, true); + req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: true }); next(); return; } @@ -117,23 +114,23 @@ function handleParseHeaders(req, res, next) { } if (!info.sessionToken) { - req.auth = new auth.Auth(req.config, false); + req.auth = new auth.Auth({ config: req.config, installationId: info.installationId, isMaster: false }); next(); return; } - return auth.getAuthForSessionToken( - req.config, info.sessionToken).then((auth) => { + return auth.getAuthForSessionToken({ config: req.config, installationId: info.installationId, sessionToken: info.sessionToken }) + .then((auth) => { if (auth) { req.auth = auth; next(); } - }).catch((error) => { + }) + .catch((error) => { // TODO: Determine the correct error scenario. console.log(error); throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); }); - } var allowCrossDomain = function(req, res, next) { @@ -177,6 +174,9 @@ var handleParseErrors = function(err, req, res, next) { res.status(httpStatus); res.json({code: err.code, error: err.message}); + } else if (err.status && err.message) { + res.status(err.status); + res.json({error: err.message}); } else { console.log('Uncaught internal server error.', err, err.stack); res.status(500); @@ -194,6 +194,16 @@ function enforceMasterKeyAccess(req, res, next) { next(); } +function promiseEnforceMasterKeyAccess(request) { + if (!request.auth.isMaster) { + let error = new Error(); + error.status = 403; + error.message = "unauthorized: master key is required"; + throw error; + } + return Promise.resolve(); +} + function invalidRequest(req, res) { res.status(403); res.end('{"error":"unauthorized"}'); @@ -204,5 +214,6 @@ module.exports = { allowMethodOverride: allowMethodOverride, handleParseErrors: handleParseErrors, handleParseHeaders: handleParseHeaders, - enforceMasterKeyAccess: enforceMasterKeyAccess + enforceMasterKeyAccess: enforceMasterKeyAccess, + promiseEnforceMasterKeyAccess }; diff --git a/src/requiredParameter.js b/src/requiredParameter.js index 0355285c..f6d5dd42 100644 --- a/src/requiredParameter.js +++ b/src/requiredParameter.js @@ -1,2 +1,2 @@ -/* @flow */ -export default (errorMessage: string) => {throw errorMessage} +/** @flow */ +export default (errorMessage: string): any => { throw errorMessage } diff --git a/src/rest.js b/src/rest.js index dbf52616..d624f068 100644 --- a/src/rest.js +++ b/src/rest.js @@ -46,7 +46,7 @@ function del(config, auth, className, objectId) { .then((response) => { if (response && response.results && response.results.length) { response.results[0].className = className; - cache.clearUser(response.results[0].sessionToken); + cache.users.remove(response.results[0].sessionToken); inflatedObject = Parse.Object.fromJSON(response.results[0]); return triggers.maybeRunTrigger(triggers.Types.beforeDelete, auth, inflatedObject, null, config.applicationId); } diff --git a/src/testing-routes.js b/src/testing-routes.js index 3823946f..f91c14a1 100644 --- a/src/testing-routes.js +++ b/src/testing-routes.js @@ -10,10 +10,11 @@ var router = express.Router(); // creates a unique app in the cache, with a collection prefix function createApp(req, res) { var appId = cryptoUtils.randomHexString(32); - cache.apps[appId] = { + // TODO: (nlutsenko) This doesn't work and should die, since there are no controllers on this configuration. + cache.apps.set(appId, { 'collectionPrefix': appId + '_', 'masterKey': 'master' - }; + }); var keys = { 'application_id': appId, 'client_key': 'unused', @@ -31,7 +32,7 @@ function clearApp(req, res) { if (!req.auth.isMaster) { return res.status(401).send({"error": "unauthorized"}); } - req.database.deleteEverything().then(() => { + return req.config.database.deleteEverything().then(() => { res.status(200).send({}); }); } @@ -41,8 +42,8 @@ function dropApp(req, res) { if (!req.auth.isMaster) { return res.status(401).send({"error": "unauthorized"}); } - req.database.deleteEverything().then(() => { - delete cache.apps[req.config.applicationId]; + return req.config.database.deleteEverything().then(() => { + cache.apps.remove(req.config.applicationId); res.status(200).send({}); }); } diff --git a/src/transform.js b/src/transform.js index f254f0d4..8d75b58e 100644 --- a/src/transform.js +++ b/src/transform.js @@ -42,6 +42,12 @@ export function transformKeyValue(schema, className, restKey, restValue, options key = '_updated_at'; timeField = true; break; + case '_email_verify_token': + key = "_email_verify_token"; + break; + case '_perishable_token': + key = "_perishable_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; @@ -638,7 +644,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals break; case 'expiresAt': case '_expiresAt': - restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])).iso; + restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])); break; default: // Check other auth data keys @@ -649,7 +655,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals restObject['authData'][provider] = mongoObject[key]; break; } - + if (key.indexOf('_p_') == 0) { var newKey = key.substring(3); var expected; diff --git a/src/triggers.js b/src/triggers.js index 23e8f4a7..8622df87 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -110,12 +110,11 @@ export function getRequestObject(triggerType, auth, parseObject, originalParseOb if (auth.user) { request['user'] = auth.user; } - // TODO: Add installation to Auth? if (auth.installationId) { request['installationId'] = auth.installationId; } return request; -}; +} // Creates the response object, and uses the request object to pass data // The API will call this with REST API formatted objects, this will @@ -157,8 +156,8 @@ export function maybeRunTrigger(triggerType, auth, parseObject, originalParseObj var response = getResponseObject(request, resolve, reject); // Force the current Parse app before the trigger Parse.applicationId = applicationId; - Parse.javascriptKey = cache.apps[applicationId].javascriptKey || ''; - Parse.masterKey = cache.apps[applicationId].masterKey; + Parse.javascriptKey = cache.apps.get(applicationId).javascriptKey || ''; + Parse.masterKey = cache.apps.get(applicationId).masterKey; trigger(request, response); }); }; diff --git a/views/choose_password b/views/choose_password new file mode 100644 index 00000000..097cbd20 --- /dev/null +++ b/views/choose_password @@ -0,0 +1,176 @@ + + + + + Password Reset + + + +

Reset Your Password

+ +
+
+ + + + + + +
+ + +