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
+ We apologize, but resetting your password requires javascript
+
+
+
+
+