From 4045969e7d47191681a0d2cf30a4080b927e379b Mon Sep 17 00:00:00 2001 From: Alexander Mays Date: Tue, 2 Feb 2016 09:12:47 -0500 Subject: [PATCH 1/2] Added linting and moved the source files to a src folder. Signed-off-by: Alexander Mays --- .eslintrc.js | 26 + Auth.js | 170 ---- Config.js | 28 - ExportAdapter.js | 578 -------------- PromiseRouter.js | 148 ---- RestQuery.js | 555 ------------- RestWrite.js | 721 ----------------- S3Adapter.js | 77 -- batch.js | 72 -- cache.js | 37 - facebook.js | 57 -- files.js | 85 -- functions.js | 43 - index.js | 185 ----- middlewares.js | 192 ----- package.json | 6 +- password.js | 35 - rest.js | 129 --- sessions.js | 122 --- spec/ExportAdapter.spec.js | 2 +- spec/ParseAPI.spec.js | 2 +- spec/ParseInstallation.spec.js | 10 +- spec/ParseUser.spec.js | 2 +- spec/RestCreate.spec.js | 10 +- spec/RestQuery.spec.js | 8 +- spec/Schema.spec.js | 4 +- spec/helper.js | 13 +- spec/transform.spec.js | 2 +- src/Auth.js | 170 ++++ src/Config.js | 28 + DatabaseAdapter.js => src/DatabaseAdapter.js | 34 +- src/ExportAdapter.js | 578 ++++++++++++++ FilesAdapter.js => src/FilesAdapter.js | 8 +- .../GridStoreAdapter.js | 40 +- src/PromiseRouter.js | 148 ++++ src/RestQuery.js | 555 +++++++++++++ src/RestWrite.js | 721 +++++++++++++++++ src/S3Adapter.js | 77 ++ Schema.js => src/Schema.js | 386 ++++----- analytics.js => src/analytics.js | 6 +- src/batch.js | 72 ++ src/cache.js | 37 + classes.js => src/classes.js | 78 +- src/facebook.js | 57 ++ src/files.js | 85 ++ src/functions.js | 43 + src/index.js | 185 +++++ installations.js => src/installations.js | 58 +- src/middlewares.js | 192 +++++ src/password.js | 35 + push.js => src/push.js | 2 +- src/rest.js | 129 +++ roles.js => src/roles.js | 22 +- src/sessions.js | 122 +++ testing-routes.js => src/testing-routes.js | 60 +- src/transform.js | 732 ++++++++++++++++++ src/triggers.js | 99 +++ src/users.js | 187 +++++ transform.js | 732 ------------------ triggers.js | 99 --- users.js | 187 ----- 61 files changed, 4656 insertions(+), 4627 deletions(-) create mode 100644 .eslintrc.js delete mode 100644 Auth.js delete mode 100644 Config.js delete mode 100644 ExportAdapter.js delete mode 100644 PromiseRouter.js delete mode 100644 RestQuery.js delete mode 100644 RestWrite.js delete mode 100644 S3Adapter.js delete mode 100644 batch.js delete mode 100644 cache.js delete mode 100644 facebook.js delete mode 100644 files.js delete mode 100644 functions.js delete mode 100644 index.js delete mode 100644 middlewares.js delete mode 100644 password.js delete mode 100644 rest.js delete mode 100644 sessions.js create mode 100644 src/Auth.js create mode 100644 src/Config.js rename DatabaseAdapter.js => src/DatabaseAdapter.js (61%) create mode 100644 src/ExportAdapter.js rename FilesAdapter.js => src/FilesAdapter.js (84%) rename GridStoreAdapter.js => src/GridStoreAdapter.js (52%) create mode 100644 src/PromiseRouter.js create mode 100644 src/RestQuery.js create mode 100644 src/RestWrite.js create mode 100644 src/S3Adapter.js rename Schema.js => src/Schema.js (50%) rename analytics.js => src/analytics.js (87%) create mode 100644 src/batch.js create mode 100644 src/cache.js rename classes.js => src/classes.js (55%) create mode 100644 src/facebook.js create mode 100644 src/files.js create mode 100644 src/functions.js create mode 100644 src/index.js rename installations.js => src/installations.js (59%) create mode 100644 src/middlewares.js create mode 100644 src/password.js rename push.js => src/push.js (85%) create mode 100644 src/rest.js rename roles.js => src/roles.js (65%) create mode 100644 src/sessions.js rename testing-routes.js => src/testing-routes.js (60%) create mode 100644 src/transform.js create mode 100644 src/triggers.js create mode 100644 src/users.js delete mode 100644 transform.js delete mode 100644 triggers.js delete mode 100644 users.js diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..dd4d8c755c --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,26 @@ +module.exports = { + "rules": { + "indent": [ + 2, + 4 + ], + "quotes": [ + 2, + "single" + ], + "linebreak-style": [ + 2, + "unix" + ], + "semi": [ + 2, + "always" + ] + }, + "env": { + "es6": true, + "node": true, + "jasmine": true, + }, + "extends": "eslint:recommended" +}; \ No newline at end of file diff --git a/Auth.js b/Auth.js deleted file mode 100644 index faa1ffd641..0000000000 --- a/Auth.js +++ /dev/null @@ -1,170 +0,0 @@ -var deepcopy = require('deepcopy'); -var Parse = require('parse/node').Parse; -var RestQuery = require('./RestQuery'); - -var cache = require('./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) { - this.config = config; - this.isMaster = isMaster; - this.user = userObject; - - // Assuming a users roles won't change during a single request, we'll - // only load them once. - this.userRoles = []; - this.fetchedRoles = false; - this.rolePromise = null; -} - -// Whether this auth could possibly modify the given user id. -// It still could be forbidden via ACLs even if this returns true. -Auth.prototype.couldUpdateUserId = function(userId) { - if (this.isMaster) { - return true; - } - if (this.user && this.user.id === userId) { - return true; - } - return false; -}; - -// A helper to get a master-level Auth object -function master(config) { - return new Auth(config, true, null); -} - -// A helper to get a nobody-level Auth object -function nobody(config) { - return new Auth(config, false, null); -} - -// Returns a promise that resolves to an Auth object -var getAuthForSessionToken = function(config, sessionToken) { - var cachedUser = cache.getUser(sessionToken); - if (cachedUser) { - return Promise.resolve(new Auth(config, false, cachedUser)); - } - var restOptions = { - limit: 1, - include: 'user' - }; - var restWhere = { - _session_token: sessionToken - }; - var query = new RestQuery(config, master(config), '_Session', - restWhere, restOptions); - return query.execute().then((response) => { - var results = response.results; - if (results.length !== 1 || !results[0]['user']) { - return nobody(config); - } - var obj = results[0]['user']; - delete obj.password; - obj['className'] = '_User'; - var userObject = Parse.Object.fromJSON(obj); - cache.setUser(sessionToken, userObject); - return new Auth(config, false, userObject); - }); -}; - -// Returns a promise that resolves to an array of role names -Auth.prototype.getUserRoles = function() { - if (this.isMaster || !this.user) { - return Promise.resolve([]); - } - if (this.fetchedRoles) { - return Promise.resolve(this.userRoles); - } - if (this.rolePromise) { - return rolePromise; - } - this.rolePromise = this._loadRoles(); - return this.rolePromise; -}; - -// Iterates through the role tree and compiles a users roles -Auth.prototype._loadRoles = function() { - var restWhere = { - 'users': { - __type: 'Pointer', - className: '_User', - objectId: this.user.id - } - }; - // First get the role ids this user is directly a member of - var query = new RestQuery(this.config, master(this.config), '_Role', - restWhere, {}); - return query.execute().then((response) => { - var results = response.results; - if (!results.length) { - this.userRoles = []; - this.fetchedRoles = true; - this.rolePromise = null; - return Promise.resolve(this.userRoles); - } - - var roleIDs = results.map(r => r.objectId); - var promises = [Promise.resolve(roleIDs)]; - for (var role of roleIDs) { - promises.push(this._getAllRoleNamesForId(role)); - } - return Promise.all(promises).then((results) => { - var allIDs = []; - for (var x of results) { - Array.prototype.push.apply(allIDs, x); - } - var restWhere = { - objectId: { - '$in': allIDs - } - }; - var query = new RestQuery(this.config, master(this.config), - '_Role', restWhere, {}); - return query.execute(); - }).then((response) => { - var results = response.results; - this.userRoles = results.map((r) => { - return 'role:' + r.name; - }); - this.fetchedRoles = true; - this.rolePromise = null; - return Promise.resolve(this.userRoles); - }); - }); -}; - -// Given a role object id, get any other roles it is part of -// TODO: Make recursive to support role nesting beyond 1 level deep -Auth.prototype._getAllRoleNamesForId = function(roleID) { - var rolePointer = { - __type: 'Pointer', - className: '_Role', - objectId: roleID - }; - var restWhere = { - '$relatedTo': { - key: 'roles', - object: rolePointer - } - }; - var query = new RestQuery(this.config, master(this.config), '_Role', - restWhere, {}); - return query.execute().then((response) => { - var results = response.results; - if (!results.length) { - return Promise.resolve([]); - } - var roleIDs = results.map(r => r.objectId); - return Promise.resolve(roleIDs); - }); -}; - -module.exports = { - Auth: Auth, - master: master, - nobody: nobody, - getAuthForSessionToken: getAuthForSessionToken -}; diff --git a/Config.js b/Config.js deleted file mode 100644 index df44f8b170..0000000000 --- a/Config.js +++ /dev/null @@ -1,28 +0,0 @@ -// A Config object provides information about how a specific app is -// configured. -// mount is the URL for the root of the API; includes http, domain, etc. -function Config(applicationId, mount) { - var cache = require('./cache'); - var DatabaseAdapter = require('./DatabaseAdapter'); - - var cacheInfo = cache.apps[applicationId]; - this.valid = !!cacheInfo; - if (!this.valid) { - return; - } - - this.applicationId = applicationId; - this.collectionPrefix = cacheInfo.collectionPrefix || ''; - this.database = DatabaseAdapter.getDatabaseConnection(applicationId); - this.masterKey = cacheInfo.masterKey; - this.clientKey = cacheInfo.clientKey; - this.javascriptKey = cacheInfo.javascriptKey; - this.dotNetKey = cacheInfo.dotNetKey; - this.restAPIKey = cacheInfo.restAPIKey; - this.fileKey = cacheInfo.fileKey; - this.facebookAppIds = cacheInfo.facebookAppIds; - this.mount = mount; -} - - -module.exports = Config; diff --git a/ExportAdapter.js b/ExportAdapter.js deleted file mode 100644 index 89cb6c5900..0000000000 --- a/ExportAdapter.js +++ /dev/null @@ -1,578 +0,0 @@ -// A database adapter that works with data exported from the hosted -// Parse database. - -var mongodb = require('mongodb'); -var MongoClient = mongodb.MongoClient; -var Parse = require('parse/node').Parse; - -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; - options = options || {}; - - this.collectionPrefix = options.collectionPrefix; - - // We don't want a mutable this.schema, because then you could have - // one request that uses different schemas for different parts of - // it. Instead, use loadSchema to get a schema. - this.schemaPromise = null; - - this.connect(); -} - -// 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; -}; - -// Returns a promise for a Mongo collection. -// Generally just for internal use. -var joinRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; -var otherRegex = /^[A-Za-z][A-Za-z0-9_]*$/; -ExportAdapter.prototype.collection = function(className) { - if (className !== '_User' && - className !== '_Installation' && - className !== '_Session' && - className !== '_SCHEMA' && - className !== '_Role' && - !joinRegex.test(className) && - !otherRegex.test(className)) { - throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, - 'invalid className: ' + className); - } - return this.connect().then(() => { - return this.db.collection(this.collectionPrefix + className); - }); -}; - -function returnsTrue() { - return true; -} - -// 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) { - acceptor = acceptor || returnsTrue; - - if (!this.schemaPromise) { - this.schemaPromise = this.collection('_SCHEMA').then((coll) => { - delete this.schemaPromise; - return Schema.load(coll); - }); - return this.schemaPromise; - } - - return this.schemaPromise.then((schema) => { - if (acceptor(schema)) { - return schema; - } - this.schemaPromise = this.collection('_SCHEMA').then((coll) => { - delete this.schemaPromise; - return Schema.load(coll); - }); - return this.schemaPromise; - }); -}; - -// 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) { - return this.loadSchema().then((schema) => { - var t = schema.getExpectedType(className, key); - var match = t.match(/^relation<(.*)>$/); - if (match) { - return match[1]; - } else { - return className; - } - }); -}; - -// Uses the schema to validate the object (REST API format). -// 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) { - return this.loadSchema().then((schema) => { - return schema.validateObject(className, object); - }); -}; - -// 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( - schema, isMaster, aclGroup, className, mongoObject) { - var object = transform.untransformObject(schema, className, mongoObject); - - if (className !== '_User') { - return object; - } - - if (isMaster || (aclGroup.indexOf(object.objectId) > -1)) { - return object; - } - - delete object.authData; - delete object.sessionToken; - return object; -}; - -// Runs an update on the database. -// Returns a promise for an object with the new values for field -// modifications that don't know their results ahead of time, like -// 'increment'. -// Options: -// 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) { - 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]}}); - } - 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 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, - objectId, - update) { - var pending = []; - var deleteMe = []; - objectId = update.objectId || objectId; - - var process = (op, key) => { - if (!op) { - return; - } - if (op.__op == 'AddRelation') { - for (var object of op.objects) { - pending.push(this.addRelation(key, className, - objectId, - object.objectId)); - } - deleteMe.push(key); - } - - if (op.__op == 'RemoveRelation') { - for (var object of op.objects) { - pending.push(this.removeRelation(key, className, - objectId, - object.objectId)); - } - deleteMe.push(key); - } - - if (op.__op == 'Batch') { - for (x of op.ops) { - process(x, key); - } - } - }; - - for (var key in update) { - process(update[key], key); - } - for (var key of deleteMe) { - delete update[key]; - } - return Promise.all(pending); -}; - -// Adds a relation. -// Returns a promise that resolves successfully iff the add was successful. -ExportAdapter.prototype.addRelation = function(key, fromClassName, - fromId, toId) { - var doc = { - relatedId: toId, - owningId: fromId - }; - var className = '_Join:' + key + ':' + fromClassName; - return this.collection(className).then((coll) => { - return coll.update(doc, doc, {upsert: true}); - }); -}; - -// Removes a relation. -// Returns a promise that resolves successfully iff the remove was -// successful. -ExportAdapter.prototype.removeRelation = function(key, fromClassName, - fromId, toId) { - var doc = { - relatedId: toId, - owningId: fromId - }; - var className = '_Join:' + key + ':' + fromClassName; - return this.collection(className).then((coll) => { - return coll.remove(doc); - }); -}; - -// Removes objects matches this query from the database. -// Returns a promise that resolves successfully iff the object was -// deleted. -// Options: -// 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) { - options = options || {}; - var isMaster = !('acl' in options); - var aclGroup = options.acl || []; - - var schema; - return this.loadSchema().then((s) => { - schema = s; - if (!isMaster) { - return schema.validatePermission(className, aclGroup, 'delete'); - } - return Promise.resolve(); - }).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]}}); - } - mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; - } - - return coll.remove(mongoWhere); - }).then((resp) => { - if (resp.result.n === 0) { - return Promise.reject( - new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.')); - - } - }, (error) => { - throw error; - }); -}; - -// Inserts an object into the database. -// Returns a promise that resolves successfully iff the object saved. -ExportAdapter.prototype.create = function(className, object, options) { - var schema; - var isMaster = !('acl' in options); - var aclGroup = options.acl || []; - - return this.loadSchema().then((s) => { - schema = s; - if (!isMaster) { - return schema.validatePermission(className, aclGroup, 'create'); - } - return Promise.resolve(); - }).then(() => { - - return this.handleRelationUpdates(className, null, object); - }).then(() => { - return this.collection(className); - }).then((coll) => { - var mongoObject = transform.transformCreate(schema, className, object); - return coll.insert([mongoObject]); - }); -}; - -// Runs a mongo query on the database. -// 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) { - options = options || {}; - return this.collection(className).then((coll) => { - return coll.find(query, options).toArray(); - }); -}; - -// Deletes everything in the database matching the current collectionPrefix -// Won't delete collections in the system namespace -// Returns a promise. -ExportAdapter.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 Promise.all(promises); - }); -}; - -// Finds the keys in a query. Returns a Set. REST format only -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; - } - - return new Set(Object.keys(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); - }); -}; - -// 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); - }); -}; - -// 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) { - // 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; - if (!match) { - continue; - } - var relatedClassName = match[1]; - var 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}; - }); - } - } - return Promise.resolve(); -}; - -// Modifies query so that it no longer has $relatedTo -// Returns a promise that resolves when query is mutated -ExportAdapter.prototype.reduceRelationKeys = function(className, query) { - var relatedTo = query['$relatedTo']; - if (relatedTo) { - return this.relatedIds( - relatedTo.object.className, - relatedTo.key, - relatedTo.object.objectId).then((ids) => { - delete query['$relatedTo']; - query['objectId'] = {'$in': ids}; - 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'; - return coll.createIndex(index).then(() => { - // Retry, but just once. - return coll.find(where, options).toArray(); - }); - }); -}; - -// Runs a query on the database. -// Returns a promise that resolves to a list of items. -// Options: -// skip number of results to skip. -// limit limit to this number of results. -// sort an object where keys are the fields to sort by. -// the value is +1 for ascending, -1 for descending. -// count run a count instead of returning results. -// acl restrict this operation with an ACL for the provided array -// of user objectIds and roles. acl: null means no user. -// when this field is not present, don't do anything regarding ACLs. -// 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) { - options = options || {}; - var mongoOptions = {}; - if (options.skip) { - mongoOptions.skip = options.skip; - } - if (options.limit) { - mongoOptions.limit = options.limit; - } - - var isMaster = !('acl' in options); - var aclGroup = options.acl || []; - var acceptor = function(schema) { - return schema.hasKeys(className, keysForQuery(query)); - }; - var schema; - return this.loadSchema(acceptor).then((s) => { - schema = s; - if (options.sort) { - mongoOptions.sort = {}; - for (var key in options.sort) { - var mongoKey = transform.transformKey(schema, className, key); - mongoOptions.sort[mongoKey] = options.sort[key]; - } - } - - if (!isMaster) { - var op = 'find'; - var k = Object.keys(query); - if (k.length == 1 && typeof query.objectId == 'string') { - op = 'get'; - } - return schema.validatePermission(className, aclGroup, op); - } - return Promise.resolve(); - }).then(() => { - return this.reduceRelationKeys(className, query); - }).then(() => { - return this.reduceInRelation(className, query, schema); - }).then(() => { - return this.collection(className); - }).then((coll) => { - var mongoWhere = transform.transformWhere(schema, className, query); - if (!isMaster) { - var orParts = [ - {"_rperm" : { "$exists": false }}, - {"_rperm" : { "$in" : ["*"]}} - ]; - for (var acl of aclGroup) { - orParts.push({"_rperm" : { "$in" : [acl]}}); - } - mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]}; - } - if (options.count) { - return coll.count(mongoWhere, mongoOptions); - } else { - return this.smartFind(coll, mongoWhere, mongoOptions) - .then((mongoResults) => { - return mongoResults.map((r) => { - return this.untransformObject( - schema, isMaster, aclGroup, className, r); - }); - }); - } - }); -}; - -module.exports = ExportAdapter; diff --git a/PromiseRouter.js b/PromiseRouter.js deleted file mode 100644 index 03514e7818..0000000000 --- a/PromiseRouter.js +++ /dev/null @@ -1,148 +0,0 @@ -// A router that is based on promises rather than req/res/next. -// This is intended to replace the use of express.Router to handle -// subsections of the API surface. -// This will make it easier to have methods like 'batch' that -// themselves use our routing information, without disturbing express -// components that external developers may be modifying. - -function PromiseRouter() { - // Each entry should be an object with: - // path: the path to route, in express format - // method: the HTTP method that this route handles. - // Must be one of: POST, GET, PUT, DELETE - // handler: a function that takes request, and returns a promise. - // Successful handlers should resolve to an object with fields: - // status: optional. the http status code. defaults to 200 - // response: a json object with the content of the response - // location: optional. a location header - this.routes = []; -} - -// Global flag. Set this to true to log every request and response. -PromiseRouter.verbose = process.env.VERBOSE || false; - -// Merge the routes into this one -PromiseRouter.prototype.merge = function(router) { - for (var route of router.routes) { - this.routes.push(route); - } -}; - -PromiseRouter.prototype.route = function(method, path, handler) { - switch(method) { - case 'POST': - case 'GET': - case 'PUT': - case 'DELETE': - break; - default: - throw 'cannot route method: ' + method; - } - - this.routes.push({ - path: path, - method: method, - handler: handler - }); -}; - -// Returns an object with: -// handler: the handler that should deal with this request -// params: any :-params that got parsed from the path -// Returns undefined if there is no match. -PromiseRouter.prototype.match = function(method, path) { - for (var route of this.routes) { - if (route.method != method) { - continue; - } - - // NOTE: we can only route the specific wildcards :className and - // :objectId, and in that order. - // This is pretty hacky but I don't want to rebuild the entire - // express route matcher. Maybe there's a way to reuse its logic. - var pattern = '^' + route.path + '$'; - - pattern = pattern.replace(':className', - '(_?[A-Za-z][A-Za-z_0-9]*)'); - pattern = pattern.replace(':objectId', - '([A-Za-z0-9]+)'); - var re = new RegExp(pattern); - var m = path.match(re); - if (!m) { - continue; - } - var params = {}; - if (m[1]) { - params.className = m[1]; - } - if (m[2]) { - params.objectId = m[2]; - } - - return {params: params, handler: route.handler}; - } -}; - -// A helper function to make an express handler out of a a promise -// handler. -// Express handlers should never throw; if a promise handler throws we -// just treat it like it resolved to an error. -function makeExpressHandler(promiseHandler) { - return function(req, res, next) { - try { - if (PromiseRouter.verbose) { - console.log(req.method, req.originalUrl, req.headers, - JSON.stringify(req.body, null, 2)); - } - promiseHandler(req).then((result) => { - if (!result.response) { - console.log('BUG: the handler did not include a "response" field'); - throw 'control should not get here'; - } - if (PromiseRouter.verbose) { - console.log('response:', JSON.stringify(result.response, null, 2)); - } - var status = result.status || 200; - res.status(status); - if (result.location) { - res.set('Location', result.location); - } - res.json(result.response); - }, (e) => { - if (PromiseRouter.verbose) { - console.log('error:', e); - } - next(e); - }); - } catch (e) { - if (PromiseRouter.verbose) { - console.log('error:', e); - } - next(e); - } - } -} - -// Mount the routes on this router onto an express app (or express router) -PromiseRouter.prototype.mountOnto = function(expressApp) { - 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'; - } - } -}; - -module.exports = PromiseRouter; diff --git a/RestQuery.js b/RestQuery.js deleted file mode 100644 index f136206af4..0000000000 --- a/RestQuery.js +++ /dev/null @@ -1,555 +0,0 @@ -// An object that encapsulates everything we need to run a 'find' -// operation, encoded in the REST API format. - -var Parse = require('parse/node').Parse; - -// restOptions can include: -// skip -// limit -// order -// count -// include -// keys -// redirectClassNameForKey -function RestQuery(config, auth, className, restWhere, restOptions) { - restOptions = restOptions || {}; - - this.config = config; - this.auth = auth; - this.className = className; - this.restWhere = restWhere || {}; - this.response = null; - - this.findOptions = {}; - if (!this.auth.isMaster) { - this.findOptions.acl = this.auth.user ? [this.auth.user.id] : null; - if (this.className == '_Session') { - if (!this.findOptions.acl) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'This session token is invalid.'); - } - this.restWhere = { - '$and': [this.restWhere, { - 'user': { - __type: 'Pointer', - className: '_User', - objectId: this.auth.user.id - } - }] - }; - } - } - - this.doCount = false; - - // The format for this.include is not the same as the format for the - // include option - it's the paths we should include, in order, - // stored as arrays, taking into account that we need to include foo - // before including foo.bar. Also it should dedupe. - // For example, passing an arg of include=foo.bar,foo.baz could lead to - // this.include = [['foo'], ['foo', 'baz'], ['foo', 'bar']] - this.include = []; - - for (var option in restOptions) { - switch(option) { - case 'keys': - this.keys = new Set(restOptions.keys.split(',')); - this.keys.add('objectId'); - this.keys.add('createdAt'); - this.keys.add('updatedAt'); - break; - case 'count': - this.doCount = true; - break; - case 'skip': - case 'limit': - this.findOptions[option] = restOptions[option]; - break; - case 'order': - var fields = restOptions.order.split(','); - var sortMap = {}; - for (var field of fields) { - if (field[0] == '-') { - sortMap[field.slice(1)] = -1; - } else { - sortMap[field] = 1; - } - } - this.findOptions.sort = sortMap; - break; - case 'include': - var paths = restOptions.include.split(','); - var pathSet = {}; - for (var path of paths) { - // Add all prefixes with a .-split to pathSet - var parts = path.split('.'); - for (var len = 1; len <= parts.length; len++) { - pathSet[parts.slice(0, len).join('.')] = true; - } - } - this.include = Object.keys(pathSet).sort((a, b) => { - return a.length - b.length; - }).map((s) => { - return s.split('.'); - }); - break; - case 'redirectClassNameForKey': - this.redirectKey = restOptions.redirectClassNameForKey; - this.redirectClassName = null; - break; - default: - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad option: ' + option); - } - } -} - -// A convenient method to perform all the steps of processing a query -// in order. -// Returns a promise for the response - an object with optional keys -// 'results' and 'count'. -// TODO: consolidate the replaceX functions -RestQuery.prototype.execute = function() { - return Promise.resolve().then(() => { - return this.getUserAndRoleACL(); - }).then(() => { - return this.redirectClassNameForKey(); - }).then(() => { - return this.replaceSelect(); - }).then(() => { - return this.replaceDontSelect(); - }).then(() => { - return this.replaceInQuery(); - }).then(() => { - return this.replaceNotInQuery(); - }).then(() => { - return this.runFind(); - }).then(() => { - return this.runCount(); - }).then(() => { - return this.handleInclude(); - }).then(() => { - return this.response; - }); -}; - -// Uses the Auth object to get the list of roles, adds the user id -RestQuery.prototype.getUserAndRoleACL = function() { - if (this.auth.isMaster || !this.auth.user) { - return Promise.resolve(); - } - return this.auth.getUserRoles().then((roles) => { - roles.push(this.auth.user.id); - this.findOptions.acl = roles; - return Promise.resolve(); - }); -}; - -// Changes the className if redirectClassNameForKey is set. -// Returns a promise. -RestQuery.prototype.redirectClassNameForKey = function() { - if (!this.redirectKey) { - return Promise.resolve(); - } - - // We need to change the class name based on the schema - return this.config.database.redirectClassNameForKey( - this.className, this.redirectKey).then((newClassName) => { - this.className = newClassName; - this.redirectClassName = newClassName; - }); -}; - -// Replaces a $inQuery clause by running the subquery, if there is an -// $inQuery clause. -// The $inQuery clause turns into an $in with values that are just -// pointers to the objects returned in the subquery. -RestQuery.prototype.replaceInQuery = function() { - var inQueryObject = findObjectWithKey(this.restWhere, '$inQuery'); - if (!inQueryObject) { - return; - } - - // The inQuery value must have precisely two keys - where and className - var inQueryValue = inQueryObject['$inQuery']; - if (!inQueryValue.where || !inQueryValue.className) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $inQuery'); - } - - var subquery = new RestQuery( - this.config, this.auth, inQueryValue.className, - inQueryValue.where); - return subquery.execute().then((response) => { - var values = []; - for (var result of response.results) { - values.push({ - __type: 'Pointer', - className: inQueryValue.className, - objectId: result.objectId - }); - } - delete inQueryObject['$inQuery']; - inQueryObject['$in'] = values; - - // Recurse to repeat - return this.replaceInQuery(); - }); -}; - -// Replaces a $notInQuery clause by running the subquery, if there is an -// $notInQuery clause. -// The $notInQuery clause turns into a $nin with values that are just -// pointers to the objects returned in the subquery. -RestQuery.prototype.replaceNotInQuery = function() { - var notInQueryObject = findObjectWithKey(this.restWhere, '$notInQuery'); - if (!notInQueryObject) { - return; - } - - // The notInQuery value must have precisely two keys - where and className - var notInQueryValue = notInQueryObject['$notInQuery']; - if (!notInQueryValue.where || !notInQueryValue.className) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $notInQuery'); - } - - var subquery = new RestQuery( - this.config, this.auth, notInQueryValue.className, - notInQueryValue.where); - return subquery.execute().then((response) => { - var values = []; - for (var result of response.results) { - values.push({ - __type: 'Pointer', - className: notInQueryValue.className, - objectId: result.objectId - }); - } - delete notInQueryObject['$notInQuery']; - notInQueryObject['$nin'] = values; - - // Recurse to repeat - return this.replaceNotInQuery(); - }); -}; - -// Replaces a $select clause by running the subquery, if there is a -// $select clause. -// The $select clause turns into an $in with values selected out of -// the subquery. -// Returns a possible-promise. -RestQuery.prototype.replaceSelect = function() { - var selectObject = findObjectWithKey(this.restWhere, '$select'); - if (!selectObject) { - return; - } - - // The select value must have precisely two keys - query and key - var selectValue = selectObject['$select']; - 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'); - } - - var subquery = new RestQuery( - this.config, this.auth, selectValue.query.className, - selectValue.query.where); - return subquery.execute().then((response) => { - var values = []; - for (var result of response.results) { - values.push(result[selectValue.key]); - } - delete selectObject['$select']; - selectObject['$in'] = values; - - // Keep replacing $select clauses - return this.replaceSelect(); - }) -}; - -// Replaces a $dontSelect clause by running the subquery, if there is a -// $dontSelect clause. -// The $dontSelect clause turns into an $nin with values selected out of -// the subquery. -// Returns a possible-promise. -RestQuery.prototype.replaceDontSelect = function() { - var dontSelectObject = findObjectWithKey(this.restWhere, '$dontSelect'); - if (!dontSelectObject) { - return; - } - - // The dontSelect value must have precisely two keys - query and key - var dontSelectValue = dontSelectObject['$dontSelect']; - if (!dontSelectValue.query || - !dontSelectValue.key || - typeof dontSelectValue.query !== 'object' || - !dontSelectValue.query.className || - !dontSelectValue.query.where || - Object.keys(dontSelectValue).length !== 2) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'improper usage of $dontSelect'); - } - - var subquery = new RestQuery( - this.config, this.auth, dontSelectValue.query.className, - dontSelectValue.query.where); - return subquery.execute().then((response) => { - var values = []; - for (var result of response.results) { - values.push(result[dontSelectValue.key]); - } - delete dontSelectObject['$dontSelect']; - dontSelectObject['$nin'] = values; - - // Keep replacing $dontSelect clauses - return this.replaceDontSelect(); - }) -}; - -// Returns a promise for whether it was successful. -// Populates this.response with an object that only has 'results'. -RestQuery.prototype.runFind = function() { - return this.config.database.find( - this.className, this.restWhere, this.findOptions).then((results) => { - if (this.className == '_User') { - for (var result of results) { - delete result.password; - } - } - - updateParseFiles(this.config, results); - - if (this.keys) { - var keySet = this.keys; - results = results.map((object) => { - var newObject = {}; - for (var key in object) { - if (keySet.has(key)) { - newObject[key] = object[key]; - } - } - return newObject; - }); - } - - if (this.redirectClassName) { - for (var r of results) { - r.className = this.redirectClassName; - } - } - - this.response = {results: results}; - }); -}; - -// Returns a promise for whether it was successful. -// Populates this.response.count with the count -RestQuery.prototype.runCount = function() { - if (!this.doCount) { - return; - } - this.findOptions.count = true; - delete this.findOptions.skip; - return this.config.database.find( - this.className, this.restWhere, this.findOptions).then((c) => { - this.response.count = c; - }); -}; - -// Augments this.response with data at the paths provided in this.include. -RestQuery.prototype.handleInclude = function() { - if (this.include.length == 0) { - return; - } - - var pathResponse = includePath(this.config, this.auth, - this.response, this.include[0]); - if (pathResponse.then) { - return pathResponse.then((newResponse) => { - this.response = newResponse; - this.include = this.include.slice(1); - return this.handleInclude(); - }); - } - return pathResponse; -}; - -// Adds included values to the response. -// Path is a list of field names. -// Returns a promise for an augmented response. -function includePath(config, auth, response, path) { - var pointers = findPointers(response.results, path); - if (pointers.length == 0) { - return response; - } - var className = null; - var objectIds = {}; - for (var pointer of pointers) { - if (className === null) { - className = pointer.className; - } else { - if (className != pointer.className) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'inconsistent type data for include'); - } - } - objectIds[pointer.objectId] = true; - } - if (!className) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad pointers'); - } - - // Get the objects for all these object ids - var where = {'objectId': {'$in': Object.keys(objectIds)}}; - var query = new RestQuery(config, auth, className, where); - return query.execute().then((includeResponse) => { - var replace = {}; - for (var obj of includeResponse.results) { - obj.__type = 'Object'; - obj.className = className; - replace[obj.objectId] = obj; - } - var resp = { - results: replacePointers(response.results, path, replace) - }; - if (response.count) { - resp.count = response.count; - } - return resp; - }); -} - -// Object may be a list of REST-format object to find pointers in, or -// it may be a single object. -// If the path yields things that aren't pointers, this throws an error. -// Path is a list of fields to search into. -// Returns a list of pointers in REST format. -function findPointers(object, path) { - if (object instanceof Array) { - var answer = []; - for (x of object) { - answer = answer.concat(findPointers(x, path)); - } - return answer; - } - - if (typeof object !== 'object') { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'can only include pointer fields'); - } - - if (path.length == 0) { - if (object.__type == 'Pointer') { - return [object]; - } - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'can only include pointer fields'); - } - - var subobject = object[path[0]]; - if (!subobject) { - return []; - } - return findPointers(subobject, path.slice(1)); -} - -// Object may be a list of REST-format objects to replace pointers -// in, or it may be a single object. -// Path is a list of fields to search into. -// replace is a map from object id -> object. -// Returns something analogous to object, but with the appropriate -// pointers inflated. -function replacePointers(object, path, replace) { - if (object instanceof Array) { - return object.map((obj) => replacePointers(obj, path, replace)); - } - - if (typeof object !== 'object') { - return object; - } - - if (path.length == 0) { - if (object.__type == 'Pointer' && replace[object.objectId]) { - return replace[object.objectId]; - } - return object; - } - - var subobject = object[path[0]]; - if (!subobject) { - return object; - } - var newsub = replacePointers(subobject, path.slice(1), replace); - var answer = {}; - for (var key in object) { - if (key == path[0]) { - answer[key] = newsub; - } else { - answer[key] = object[key]; - } - } - return answer; -} - -// Find file references in REST-format object and adds the url key -// with the current mount point and app id -// Object may be a single object or list of REST-format objects -function updateParseFiles(config, object) { - if (object instanceof Array) { - object.map((obj) => updateParseFiles(config, obj)); - return; - } - if (typeof object !== 'object') { - return; - } - for (var key in object) { - if (object[key] && object[key]['__type'] && - object[key]['__type'] == 'File') { - var filename = object[key]['name']; - var encoded = encodeURIComponent(filename); - encoded = encoded.replace('%40', '@'); - if (filename.indexOf('tfss-') === 0) { - object[key]['url'] = 'http://files.parsetfss.com/' + - config.fileKey + '/' + encoded; - } else { - object[key]['url'] = config.mount + '/files/' + - config.applicationId + '/' + - encoded; - } - } - } -} - -// Finds a subobject that has the given key, if there is one. -// Returns undefined otherwise. -function findObjectWithKey(root, key) { - if (typeof root !== 'object') { - return; - } - if (root instanceof Array) { - for (var item of root) { - var answer = findObjectWithKey(item, key); - if (answer) { - return answer; - } - } - } - if (root && root[key]) { - return root; - } - for (var subkey in root) { - var answer = findObjectWithKey(root[subkey], key); - if (answer) { - return answer; - } - } -} - -module.exports = RestQuery; diff --git a/RestWrite.js b/RestWrite.js deleted file mode 100644 index ea7b2225e2..0000000000 --- a/RestWrite.js +++ /dev/null @@ -1,721 +0,0 @@ -// A RestWrite encapsulates everything we need to run an operation -// that writes to the database. -// This could be either a "create" or an "update". - -var crypto = require('crypto'); -var deepcopy = require('deepcopy'); -var rack = require('hat').rack(); - -var Auth = require('./Auth'); -var cache = require('./cache'); -var Config = require('./Config'); -var passwordCrypto = require('./password'); -var facebook = require('./facebook'); -var Parse = require('parse/node'); -var triggers = require('./triggers'); - -// query and data are both provided in REST API format. So data -// types are encoded by plain old objects. -// If query is null, this is a "create" and the data in data should be -// created. -// Otherwise this is an "update" - the object matching the query -// should get updated with data. -// RestWrite will handle objectId, createdAt, and updatedAt for -// everything. It also knows to use triggers and special modifications -// for the _User class. -function RestWrite(config, auth, className, query, data, originalData) { - this.config = config; - this.auth = auth; - this.className = className; - this.storage = {}; - - if (!query && data.objectId) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId ' + - 'is an invalid field name.'); - } - - // When the operation is complete, this.response may have several - // fields. - // response: the actual data to be returned - // status: the http status code. if not present, treated like a 200 - // location: the location header. if not present, no location header - this.response = null; - - // Processing this operation may mutate our data, so we operate on a - // copy - this.query = deepcopy(query); - this.data = deepcopy(data); - // We never change originalData, so we do not need a deep copy - this.originalData = originalData; - - // The timestamp we'll use for this whole operation - this.updatedAt = Parse._encode(new Date()).iso; - - if (this.data) { - // Add default fields - this.data.updatedAt = this.updatedAt; - if (!this.query) { - this.data.createdAt = this.updatedAt; - this.data.objectId = newObjectId(); - } - } -} - -// A convenient method to perform all the steps of processing the -// write, in order. -// Returns a promise for a {response, status, location} object. -// status and location are optional. -RestWrite.prototype.execute = function() { - return Promise.resolve().then(() => { - return this.validateSchema(); - }).then(() => { - return this.handleInstallation(); - }).then(() => { - return this.handleSession(); - }).then(() => { - return this.runBeforeTrigger(); - }).then(() => { - return this.validateAuthData(); - }).then(() => { - return this.transformUser(); - }).then(() => { - return this.runDatabaseOperation(); - }).then(() => { - return this.handleFollowup(); - }).then(() => { - return this.runAfterTrigger(); - }).then(() => { - return this.response; - }); -}; - -// Validates this operation against the schema. -RestWrite.prototype.validateSchema = function() { - return this.config.database.validateObject(this.className, this.data); -}; - -// Runs any beforeSave triggers against this operation. -// Any change leads to our data being mutated. -RestWrite.prototype.runBeforeTrigger = function() { - // Cloud code gets a bit of extra data for its objects - var extraData = {className: this.className}; - if (this.query && this.query.objectId) { - extraData.objectId = this.query.objectId; - } - // Build the inflated object, for a create write, originalData is empty - 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; - if (this.query && this.query.objectId) { - originalObject = triggers.inflate(extraData, this.originalData); - } - - return Promise.resolve().then(() => { - return triggers.maybeRunTrigger( - 'beforeSave', this.auth, inflatedObject, originalObject); - }).then((response) => { - if (response && response.object) { - this.data = response.object; - // We should delete the objectId for an update write - if (this.query && this.query.objectId) { - delete this.data.objectId - } - } - }); -}; - -// Transforms auth data for a user object. -// Does nothing if this isn't a user object. -// Returns a promise for when we're done if it can't finish this tick. -RestWrite.prototype.validateAuthData = function() { - if (this.className !== '_User') { - return; - } - - if (!this.query && !this.data.authData) { - if (typeof this.data.username !== 'string') { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'bad or missing username'); - } - if (typeof this.data.password !== 'string') { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required'); - } - } - - if (!this.data.authData) { - return; - } - - var facebookData = this.data.authData.facebook; - var anonData = this.data.authData.anonymous; - - if (anonData === null || - (anonData && anonData.id)) { - return this.handleAnonymousAuthData(); - } else if (facebookData === null || - (facebookData && facebookData.id && facebookData.access_token)) { - return this.handleFacebookAuthData(); - } else { - throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.'); - } -}; - -RestWrite.prototype.handleAnonymousAuthData = function() { - var anonData = this.data.authData.anonymous; - if (anonData === null && this.query) { - // We are unlinking the user from the anonymous provider - this.data._auth_data_anonymous = null; - return; - } - - // Check if this user already exists - return this.config.database.find( - this.className, - {'authData.anonymous.id': anonData.id}, {}) - .then((results) => { - if (results.length > 0) { - if (!this.query) { - // We're signing up, but this user already exists. Short-circuit - delete results[0].password; - this.response = { - response: results[0], - location: this.location() - }; - return; - } - - // If this is a PUT for the same user, allow the linking - if (results[0].objectId === this.query.objectId) { - // Delete the rest format key before saving - delete this.data.authData; - return; - } - - // We're trying to create a duplicate account. Forbid it - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); - } - - // This anonymous user does not already exist, so transform it - // to a saveable format - this.data._auth_data_anonymous = anonData; - - // Delete the rest format key before saving - delete this.data.authData; - }) - -}; - -RestWrite.prototype.handleFacebookAuthData = function() { - var facebookData = this.data.authData.facebook; - if (facebookData === null && this.query) { - // We are unlinking from Facebook. - this.data._auth_data_facebook = null; - return; - } - - return facebook.validateUserId(facebookData.id, - facebookData.access_token) - .then(() => { - return facebook.validateAppId(this.config.facebookAppIds, - facebookData.access_token); - }).then(() => { - // Check if this user already exists - // TODO: does this handle re-linking correctly? - return this.config.database.find( - this.className, - {'authData.facebook.id': facebookData.id}, {}); - }).then((results) => { - if (results.length > 0) { - if (!this.query) { - // We're signing up, but this user already exists. Short-circuit - delete results[0].password; - this.response = { - response: results[0], - location: this.location() - }; - return; - } - - // If this is a PUT for the same user, allow the linking - if (results[0].objectId === this.query.objectId) { - // Delete the rest format key before saving - delete this.data.authData; - return; - } - // We're trying to create a duplicate FB auth. Forbid it - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, - 'this auth is already used'); - } - - // This FB auth does not already exist, so transform it to a - // saveable format - this.data._auth_data_facebook = facebookData; - - // Delete the rest format key before saving - delete this.data.authData; - }); -}; - -// The non-third-party parts of User transformation -RestWrite.prototype.transformUser = function() { - if (this.response || this.className !== '_User') { - return; - } - - var promise = Promise.resolve(); - - if (!this.query) { - var token = 'r:' + rack(); - this.storage['token'] = token; - promise = promise.then(() => { - // TODO: Proper createdWith options, pass installationId - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: this.objectId() - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false - }; - var create = new RestWrite(this.config, Auth.master(this.config), - '_Session', null, sessionData); - return create.execute(); - }); - } - - return promise.then(() => { - // Transform the password - if (!this.data.password) { - return; - } - if (this.query) { - this.storage['clearSessions'] = true; - } - return passwordCrypto.hash(this.data.password).then((hashedPassword) => { - this.data._hashed_password = hashedPassword; - delete this.data.password; - }); - - }).then(() => { - // Check for username uniqueness - if (!this.data.username) { - if (!this.query) { - // TODO: what's correct behavior here - this.data.username = ''; - } - return; - } - return this.config.database.find( - this.className, { - username: this.data.username, - objectId: {'$ne': this.objectId()} - }, {limit: 1}).then((results) => { - if (results.length > 0) { - throw new Parse.Error(Parse.Error.USERNAME_TAKEN, - 'Account already exists for this username'); - } - return Promise.resolve(); - }); - }).then(() => { - if (!this.data.email) { - return; - } - // Validate basic email address format - if (!this.data.email.match(/^.+@.+$/)) { - throw new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, - 'Email address format is invalid.'); - } - // Check for email uniqueness - return this.config.database.find( - this.className, { - email: this.data.email, - objectId: {'$ne': this.objectId()} - }, {limit: 1}).then((results) => { - if (results.length > 0) { - throw new Parse.Error(Parse.Error.EMAIL_TAKEN, - 'Account already exists for this email ' + - 'address'); - } - return Promise.resolve(); - }); - }); -}; - -// Handles any followup logic -RestWrite.prototype.handleFollowup = function() { - if (this.storage && this.storage['clearSessions']) { - var sessionQuery = { - user: { - __type: 'Pointer', - className: '_User', - objectId: this.objectId() - } - }; - delete this.storage['clearSessions']; - return this.config.database.destroy('_Session', sessionQuery) - .then(this.handleFollowup); - } -}; - -// Handles the _Role class specialness. -// Does nothing if this isn't a role object. -RestWrite.prototype.handleRole = function() { - if (this.response || this.className !== '_Role') { - return; - } - - if (!this.auth.user && !this.auth.isMaster) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); - } - - if (!this.data.name) { - throw new Parse.Error(Parse.Error.INVALID_ROLE_NAME, - 'Invalid role name.'); - } -}; - -// Handles the _Session class specialness. -// Does nothing if this isn't an installation object. -RestWrite.prototype.handleSession = function() { - if (this.response || this.className !== '_Session') { - return; - } - - if (!this.auth.user && !this.auth.isMaster) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); - } - - // TODO: Verify proper error to throw - if (this.data.ACL) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Cannot set ' + - 'ACL on a Session.'); - } - - if (!this.query && !this.auth.isMaster) { - var token = 'r:' + rack(); - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: this.auth.user.id - }, - createdWith: { - 'action': 'create' - }, - restricted: true, - expiresAt: 0 - }; - for (var key in this.data) { - if (key == 'objectId') { - continue; - } - sessionData[key] = this.data[key]; - } - var create = new RestWrite(this.config, Auth.master(this.config), - '_Session', null, sessionData); - return create.execute().then((results) => { - if (!results.response) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, - 'Error creating session.'); - } - sessionData['objectId'] = results.response['objectId']; - this.response = { - status: 201, - location: results.location, - response: sessionData - }; - }); - } -}; - -// Handles the _Installation class specialness. -// Does nothing if this isn't an installation object. -// If an installation is found, this can mutate this.query and turn a create -// into an update. -// Returns a promise for when we're done if it can't finish this tick. -RestWrite.prototype.handleInstallation = function() { - if (this.response || this.className !== '_Installation') { - return; - } - - if (!this.query && !this.data.deviceToken && !this.data.installationId) { - throw new Parse.Error(135, - 'at least one ID field (deviceToken, installationId) ' + - 'must be specified in this operation'); - } - - if (!this.query && !this.data.deviceType) { - throw new Parse.Error(135, - 'deviceType must be specified in this operation'); - } - - // If the device token is 64 characters long, we assume it is for iOS - // and lowercase it. - if (this.data.deviceToken && this.data.deviceToken.length == 64) { - this.data.deviceToken = this.data.deviceToken.toLowerCase(); - } - - // TODO: We may need installationId from headers, plumb through Auth? - // per installation_handler.go - - // We lowercase the installationId if present - if (this.data.installationId) { - this.data.installationId = this.data.installationId.toLowerCase(); - } - - if (this.data.deviceToken && this.data.deviceType == 'android') { - throw new Parse.Error(114, - 'deviceToken may not be set for deviceType android'); - } - - var promise = Promise.resolve(); - - if (this.query && this.query.objectId) { - promise = promise.then(() => { - return this.config.database.find('_Installation', { - objectId: this.query.objectId - }, {}).then((results) => { - if (!results.length) { - 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) { - 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) { - 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) { - throw new Parse.Error(136, - 'deviceType may not be changed in this ' + - 'operation'); - } - return Promise.resolve(); - }); - }); - } - - // 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', { - 'installationId': this.data.installationId - }); - } - return Promise.resolve([]); - }).then((results) => { - if (results && results.length) { - // We only take the first match by installationId - installationMatch = results[0]; - } - if (this.data.deviceToken) { - return this.config.database.find( - '_Installation', - {'deviceToken': this.data.deviceToken}); - } - return Promise.resolve([]); - }).then((results) => { - if (results) { - deviceTokenMatches = results; - } - if (!installationMatch) { - if (!deviceTokenMatches.length) { - return; - } else if (deviceTokenMatches.length == 1 && - (!deviceTokenMatches[0]['installationId'] || !this.data.installationId) - ) { - // Single match on device token but none on installationId, and either - // the passed object or the match is missing an installationId, so we - // can just return the match. - return deviceTokenMatches[0]['objectId']; - } else if (!this.data.installationId) { - throw new Parse.Error(132, - 'Must specify installationId when deviceToken ' + - 'matches multiple Installation objects'); - } else { - // Multiple device token matches and we specified an installation ID, - // or a single match where both the passed and matching objects have - // an installation ID. Try cleaning out old installations that match - // the deviceToken, and return nil to signal that a new object should - // be created. - var delQuery = { - 'deviceToken': this.data.deviceToken, - 'installationId': { - '$ne': this.data.installationId - } - }; - if (this.data.appIdentifier) { - delQuery['appIdentifier'] = this.data.appIdentifier; - } - this.config.database.destroy('_Installation', delQuery); - return; - } - } else { - if (deviceTokenMatches.length == 1 && - !deviceTokenMatches[0]['installationId']) { - // 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}; - return this.config.database.destroy('_Installation', delQuery) - .then(() => { - return deviceTokenMatches[0]['objectId']; - }); - } else { - if (this.data.deviceToken && - installationMatch.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. - var delQuery = { - 'deviceToken': this.data.deviceToken, - 'installationId': { - '$ne': this.data.installationId - } - }; - if (this.data.appIdentifier) { - delQuery['appIdentifier'] = this.data.appIdentifier; - } - this.config.database.destroy('_Installation', delQuery); - } - // In non-merge scenarios, just return the installation match id - return installationMatch.objectId; - } - } - }).then((objId) => { - if (objId) { - this.query = {objectId: objId}; - delete this.data.objectId; - delete this.data.createdAt; - } - // TODO: Validate ops (add/remove on channels, $inc on badge, etc.) - }); - return promise; -}; - -RestWrite.prototype.runDatabaseOperation = function() { - if (this.response) { - return; - } - - if (this.className === '_User' && - this.query && - !this.auth.couldUpdateUserId(this.query.objectId)) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'cannot modify user ' + this.objectId); - } - - // TODO: Add better detection for ACL, ensuring a user can't be locked from - // their own user record. - if (this.data.ACL && this.data.ACL['*unresolved']) { - throw new Parse.Error(Parse.Error.INVALID_ACL, 'Invalid ACL.'); - } - - var options = {}; - if (!this.auth.isMaster) { - options.acl = ['*']; - if (this.auth.user) { - options.acl.push(this.auth.user.id); - } - } - - if (this.query) { - // Run an update - return this.config.database.update( - this.className, this.query, this.data, options).then((resp) => { - this.response = resp; - this.response.updatedAt = this.updatedAt; - }); - } else { - // Run a create - return this.config.database.create(this.className, this.data, options) - .then(() => { - var resp = { - objectId: this.data.objectId, - createdAt: this.data.createdAt - }; - if (this.storage['token']) { - resp.sessionToken = this.storage['token']; - } - this.response = { - status: 201, - response: resp, - location: this.location() - }; - }); - } -}; - -// Returns nothing - doesn't wait for the trigger. -RestWrite.prototype.runAfterTrigger = function() { - 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; - if (this.query && this.query.objectId) { - originalObject = triggers.inflate(extraData, this.originalData); - } - - triggers.maybeRunTrigger('afterSave', this.auth, inflatedObject, originalObject); -}; - -// A helper to figure out what location this operation happens at. -RestWrite.prototype.location = function() { - var middle = (this.className === '_User' ? '/users/' : - '/classes/' + this.className + '/'); - return this.config.mount + middle + this.data.objectId; -}; - -// A helper to get the object id for this operation. -// Because it could be either on the query or on the data -RestWrite.prototype.objectId = function() { - return this.data.objectId || this.query.objectId; -}; - -// Returns a unique string that's usable as an object id. -function newObjectId() { - var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + - 'abcdefghijklmnopqrstuvwxyz' + - '0123456789'); - var objectId = ''; - var bytes = crypto.randomBytes(10); - for (var i = 0; i < bytes.length; ++i) { - // Note: there is a slight modulo bias, because chars length - // of 62 doesn't divide the number of all bytes (256) evenly. - // It is acceptable for our purposes. - objectId += chars[bytes.readUInt8(i) % chars.length]; - } - return objectId; -} - -module.exports = RestWrite; diff --git a/S3Adapter.js b/S3Adapter.js deleted file mode 100644 index 736ebf8bd1..0000000000 --- a/S3Adapter.js +++ /dev/null @@ -1,77 +0,0 @@ -// S3Adapter -// -// Stores Parse files in AWS S3. - -var AWS = require('aws-sdk'); -var path = require('path'); - -var DEFAULT_REGION = "us-east-1"; -var DEFAULT_BUCKET = "parse-files"; - -// Creates an S3 session. -// Providing AWS access and secret keys is mandatory -// Region and bucket will use sane defaults if omitted -function S3Adapter(accessKey, secretKey, options) { - options = options || {}; - - this.region = options.region || DEFAULT_REGION; - this.bucket = options.bucket || DEFAULT_BUCKET; - this.bucketPrefix = options.bucketPrefix || ""; - this.directAccess = options.directAccess || false; - - s3Options = { - accessKeyId: accessKey, - secretAccessKey: secretKey, - params: {Bucket: this.bucket} - }; - AWS.config.region = this.region; - this.s3 = new AWS.S3(s3Options); -} - -// For a given config object, filename, and data, store a file in S3 -// Returns a promise containing the S3 object creation response -S3Adapter.prototype.create = function(config, filename, data) { - var params = { - Key: this.bucketPrefix + filename, - Body: data, - }; - if (this.directAccess) { - params.ACL = "public-read" - } - - return new Promise((resolve, reject) => { - this.s3.upload(params, (err, data) => { - if (err !== null) return reject(err); - resolve(data); - }); - }); -} - -// Search for and return a file if found by filename -// Returns a promise that succeeds with the buffer result from S3 -S3Adapter.prototype.get = function(config, filename) { - var params = {Key: this.bucketPrefix + filename}; - - return new Promise((resolve, reject) => { - this.s3.getObject(params, (err, data) => { - if (err !== null) return reject(err); - resolve(data.Body); - }); - }); -} - -// Generates and returns the location of a file stored in S3 for the given request and -// filename -// The location is the direct S3 link if the option is set, otherwise we serve -// the file through parse-server -S3Adapter.prototype.location = function(config, req, filename) { - if (this.directAccess) { - return ('https://' + this.bucket + '.s3.amazonaws.com' + '/' + - this.bucketPrefix + filename); - } - return (req.protocol + '://' + req.get('host') + - path.dirname(req.originalUrl) + '/' + req.config.applicationId + - '/' + encodeURIComponent(filename)); -} - -module.exports = S3Adapter; diff --git a/batch.js b/batch.js deleted file mode 100644 index 4b710a1721..0000000000 --- a/batch.js +++ /dev/null @@ -1,72 +0,0 @@ -var Parse = require('parse/node').Parse; - -// These methods handle batch requests. -var batchPath = '/batch'; - -// Mounts a batch-handler onto a PromiseRouter. -function mountOnto(router) { - router.route('POST', batchPath, (req) => { - return handleBatch(router, req); - }); -} - -// Returns a promise for a {response} object. -// TODO: pass along auth correctly -function handleBatch(router, req) { - if (!req.body.requests instanceof Array) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'requests must be an array'); - } - - // The batch paths are all from the root of our domain. - // That means they include the API prefix, that the API is mounted - // to. However, our promise router does not route the api prefix. So - // we need to figure out the API prefix, so that we can strip it - // from all the subrequests. - if (!req.originalUrl.endsWith(batchPath)) { - throw 'internal routing problem - expected url to end with batch'; - } - var apiPrefixLength = req.originalUrl.length - batchPath.length; - var apiPrefix = req.originalUrl.slice(0, apiPrefixLength); - - var promises = []; - for (var restRequest of req.body.requests) { - // The routablePath is the path minus the api prefix - if (restRequest.path.slice(0, apiPrefixLength) != apiPrefix) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'cannot route batch path ' + restRequest.path); - } - var routablePath = restRequest.path.slice(apiPrefixLength); - - // Use the router to figure out what handler to use - var match = router.match(restRequest.method, routablePath); - if (!match) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'cannot route ' + restRequest.method + ' ' + routablePath); - } - - // Construct a request that we can send to a handler - var request = { - body: restRequest.body, - params: match.params, - config: req.config, - auth: req.auth - }; - - promises.push(match.handler(request).then((response) => { - return {success: response.response}; - }, (error) => { - return {error: {code: error.code, error: error.message}}; - })); - } - - return Promise.all(promises).then((results) => { - return {response: results}; - }); -} - -module.exports = { - mountOnto: mountOnto -}; diff --git a/cache.js b/cache.js deleted file mode 100644 index aba6ce16ff..0000000000 --- a/cache.js +++ /dev/null @@ -1,37 +0,0 @@ -var apps = {}; -var stats = {}; -var isLoaded = false; -var users = {}; - -function getApp(app, callback) { - if (apps[app]) return callback(true, apps[app]); - return callback(false); -} - -function updateStat(key, value) { - stats[key] = value; -} - -function getUser(sessionToken) { - if (users[sessionToken]) return users[sessionToken]; - return undefined; -} - -function setUser(sessionToken, userObject) { - users[sessionToken] = userObject; -} - -function clearUser(sessionToken) { - delete users[sessionToken]; -} - -module.exports = { - apps: apps, - stats: stats, - isLoaded: isLoaded, - getApp: getApp, - updateStat: updateStat, - clearUser: clearUser, - getUser: getUser, - setUser: setUser -}; diff --git a/facebook.js b/facebook.js deleted file mode 100644 index 5f9bbee85e..0000000000 --- a/facebook.js +++ /dev/null @@ -1,57 +0,0 @@ -// Helper functions for accessing the Facebook Graph API. -var https = require('https'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateUserId(userId, access_token) { - return graphRequest('me?fields=id&access_token=' + access_token) - .then((data) => { - if (data && data.id == userId) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is invalid for this user.'); - }); -} - -// Returns a promise that fulfills iff this app id is valid. -function validateAppId(appIds, access_token) { - if (!appIds.length) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is not configured.'); - } - return graphRequest('app?access_token=' + access_token) - .then((data) => { - if (data && appIds.indexOf(data.id) != -1) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Facebook auth is invalid for this user.'); - }); -} - -// A promisey wrapper for FB graph requests. -function graphRequest(path) { - return new Promise(function(resolve, reject) { - https.get('https://graph.facebook.com/v2.5/' + path, function(res) { - var data = ''; - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - data = JSON.parse(data); - resolve(data); - }); - }).on('error', function(e) { - reject('Failed to validate this access token with Facebook.'); - }); - }); -} - -module.exports = { - validateAppId: validateAppId, - validateUserId: validateUserId -}; diff --git a/files.js b/files.js deleted file mode 100644 index e2575a5d7e..0000000000 --- a/files.js +++ /dev/null @@ -1,85 +0,0 @@ -// files.js - -var bodyParser = require('body-parser'), - Config = require('./Config'), - express = require('express'), - FilesAdapter = require('./FilesAdapter'), - middlewares = require('./middlewares.js'), - mime = require('mime'), - Parse = require('parse/node').Parse, - rack = require('hat').rack(); - -var router = express.Router(); - -var processCreate = function(req, res, next) { - if (!req.body || !req.body.length) { - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, - 'Invalid file upload.')); - return; - } - - if (req.params.filename.length > 128) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename too long.')); - return; - } - - if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename contains invalid characters.')); - return; - } - - // If a content-type is included, we'll add an extension so we can - // return the same content-type. - var extension = ''; - var hasExtension = req.params.filename.indexOf('.') > 0; - var contentType = req.get('Content-type'); - if (!hasExtension && contentType && mime.extension(contentType)) { - extension = '.' + mime.extension(contentType); - } - - var filename = rack() + '_' + req.params.filename + extension; - FilesAdapter.getAdapter().create(req.config, filename, req.body) - .then(() => { - res.status(201); - var location = FilesAdapter.getAdapter().location(req.config, req, filename); - res.set('Location', location); - res.json({ url: location, name: filename }); - }).catch((error) => { - next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, - 'Could not store file.')); - }); -}; - -var processGet = function(req, res) { - var config = new Config(req.params.appId); - FilesAdapter.getAdapter().get(config, req.params.filename) - .then((data) => { - res.status(200); - var contentType = mime.lookup(req.params.filename); - res.set('Content-type', contentType); - res.end(data); - }).catch((error) => { - res.status(404); - res.set('Content-type', 'text/plain'); - res.end('File not found.'); - }); -}; - -router.get('/files/:appId/:filename', processGet); - -router.post('/files', function(req, res, next) { - next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, - 'Filename not provided.')); -}); - -// TODO: do we need to allow crossdomain and method override? -router.post('/files/:filename', - bodyParser.raw({type: '*/*', limit: '20mb'}), - middlewares.handleParseHeaders, - processCreate); - -module.exports = { - router: router -}; diff --git a/functions.js b/functions.js deleted file mode 100644 index cf4aeb28bf..0000000000 --- a/functions.js +++ /dev/null @@ -1,43 +0,0 @@ -// functions.js - -var express = require('express'), - Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); - -var router = new PromiseRouter(); - -function handleCloudFunction(req) { - // TODO: set user from req.auth - if (Parse.Cloud.Functions[req.params.functionName]) { - return new Promise(function (resolve, reject) { - var response = createResponseObject(resolve, reject); - var request = { - params: req.body || {} - }; - Parse.Cloud.Functions[req.params.functionName](request, response); - }); - } else { - throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid function.'); - } -} - -function createResponseObject(resolve, reject) { - return { - success: function(result) { - resolve({ - response: { - result: result - } - }); - }, - error: function(error) { - reject(new Parse.Error(Parse.Error.SCRIPT_FAILED, error)); - } - } -} - -router.route('POST', '/functions/:functionName', handleCloudFunction); - - -module.exports = router; diff --git a/index.js b/index.js deleted file mode 100644 index 6c0a6e13f2..0000000000 --- a/index.js +++ /dev/null @@ -1,185 +0,0 @@ -// ParseServer - open-source compatible API Server for Parse apps - -var batch = require('./batch'), - bodyParser = require('body-parser'), - cache = require('./cache'), - DatabaseAdapter = require('./DatabaseAdapter'), - express = require('express'), - FilesAdapter = require('./FilesAdapter'), - S3Adapter = require('./S3Adapter'), - middlewares = require('./middlewares'), - multer = require('multer'), - Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - request = require('request'); - -// 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, -// update, and delete -// "filesAdapter": a class like GridStoreAdapter providing create, get, -// and delete -// "databaseURI": a uri like mongodb://localhost:27017/dbname to tell us -// what database this Parse API connects to. -// "cloud": relative location to cloud code to require -// "appId": the application id to host -// "masterKey": the master key for requests to this app -// "facebookAppIds": an array of valid Facebook Application IDs, required -// if using Facebook login -// "collectionPrefix": optional prefix for database collection names -// "fileKey": optional key from Parse dashboard for supporting older files -// hosted by Parse -// "clientKey": optional key from Parse dashboard -// "dotNetKey": optional key from Parse dashboard -// "restAPIKey": optional key from Parse dashboard -// "javascriptKey": optional key from Parse dashboard -function ParseServer(args) { - if (!args.appId || !args.masterKey) { - throw 'You must provide an appId and masterKey!'; - } - - if (args.databaseAdapter) { - DatabaseAdapter.setAdapter(args.databaseAdapter); - } - if (args.filesAdapter) { - FilesAdapter.setAdapter(args.filesAdapter); - } - if (args.databaseURI) { - DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); - } - if (args.cloud) { - addParseCloud(); - require(args.cloud); - } - - cache.apps[args.appId] = { - masterKey: args.masterKey, - collectionPrefix: args.collectionPrefix || '', - clientKey: args.clientKey || '', - javascriptKey: args.javascriptKey || '', - dotNetKey: args.dotNetKey || '', - restAPIKey: args.restAPIKey || '', - fileKey: args.fileKey || 'invalid-file-key', - facebookAppIds: args.facebookAppIds || [] - }; - - // To maintain compatibility. TODO: Remove in v2.1 - if (process.env.FACEBOOK_APP_ID) { - cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); - } - - // Initialize the node client SDK automatically - Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); - - // 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(); - - // File handling needs to be before default middlewares are applied - api.use('/', require('./files').router); - - // TODO: separate this from the regular ParseServer object - if (process.env.TESTING == 1) { - console.log('enabling integration testing-routes'); - api.use('/', require('./testing-routes').router); - } - - api.use(bodyParser.json({ 'type': '*/*' })); - api.use(middlewares.allowCrossDomain); - api.use(middlewares.allowMethodOverride); - api.use(middlewares.handleParseHeaders); - - var router = new PromiseRouter(); - - router.merge(require('./classes')); - router.merge(require('./users')); - router.merge(require('./sessions')); - router.merge(require('./roles')); - router.merge(require('./analytics')); - router.merge(require('./push')); - router.merge(require('./installations')); - router.merge(require('./functions')); - - batch.mountOnto(router); - - router.mountOnto(api); - - api.use(middlewares.handleParseErrors); - - return api; -} - -function addParseCloud() { - Parse.Cloud.Functions = {}; - Parse.Cloud.Triggers = { - beforeSave: {}, - beforeDelete: {}, - afterSave: {}, - afterDelete: {} - }; - Parse.Cloud.define = function(functionName, handler) { - Parse.Cloud.Functions[functionName] = handler; - }; - Parse.Cloud.beforeSave = function(parseClass, handler) { - var className = getClassName(parseClass); - Parse.Cloud.Triggers.beforeSave[className] = handler; - }; - Parse.Cloud.beforeDelete = function(parseClass, handler) { - var className = getClassName(parseClass); - Parse.Cloud.Triggers.beforeDelete[className] = handler; - }; - Parse.Cloud.afterSave = function(parseClass, handler) { - var className = getClassName(parseClass); - Parse.Cloud.Triggers.afterSave[className] = handler; - }; - Parse.Cloud.afterDelete = function(parseClass, handler) { - var className = getClassName(parseClass); - Parse.Cloud.Triggers.afterDelete[className] = handler; - }; - Parse.Cloud.httpRequest = function(options) { - var promise = new Parse.Promise(); - var callbacks = { - success: options.success, - error: options.error - }; - delete options.success; - delete options.error; - if (options.uri && !options.url) { - options.uri = options.url; - delete options.url; - } - if (typeof options.body === 'object') { - options.body = JSON.stringify(options.body); - } - request(options, (error, response, body) => { - if (error) { - if (callbacks.error) { - return callbacks.error(error); - } - return promise.reject(error); - } else { - if (callbacks.success) { - return callbacks.success(body); - } - return promise.resolve(body); - } - }); - return promise; - }; - global.Parse = Parse; -} - -function getClassName(parseClass) { - if (parseClass && parseClass.className) { - return parseClass.className; - } - return parseClass; -} - -module.exports = { - ParseServer: ParseServer, - S3Adapter: S3Adapter -}; diff --git a/middlewares.js b/middlewares.js deleted file mode 100644 index bb2512391a..0000000000 --- a/middlewares.js +++ /dev/null @@ -1,192 +0,0 @@ -var Parse = require('parse/node').Parse; - -var auth = require('./Auth'); -var cache = require('./cache'); -var Config = require('./Config'); - -// Checks that the request is authorized for this app and checks user -// auth too. -// The bodyparser should run before this middleware. -// Adds info to the request: -// req.config - the Config for this app -// req.auth - the Auth for this request -function handleParseHeaders(req, res, next) { - var mountPathLength = req.originalUrl.length - req.url.length; - var mountPath = req.originalUrl.slice(0, mountPathLength); - var mount = req.protocol + '://' + req.get('host') + mountPath; - - var info = { - appId: req.get('X-Parse-Application-Id'), - sessionToken: req.get('X-Parse-Session-Token'), - masterKey: req.get('X-Parse-Master-Key'), - installationId: req.get('X-Parse-Installation-Id'), - clientKey: req.get('X-Parse-Client-Key'), - javascriptKey: req.get('X-Parse-Javascript-Key'), - dotNetKey: req.get('X-Parse-Windows-Key'), - restAPIKey: req.get('X-Parse-REST-API-Key') - }; - - var fileViaJSON = false; - - if (!info.appId || !cache.apps[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 - // upload that actually is a JSON body. So try to parse it. - req.body = JSON.parse(req.body); - fileViaJSON = true; - } - - if (req.body && req.body._ApplicationId - && cache.apps[req.body._ApplicationId] - && ( - !info.masterKey - || - cache.apps[req.body._ApplicationId]['masterKey'] === info.masterKey) - ) { - info.appId = req.body._ApplicationId; - info.javascriptKey = req.body._JavaScriptKey || ''; - delete req.body._ApplicationId; - delete req.body._JavaScriptKey; - // TODO: test that the REST API formats generated by the other - // SDKs are handled ok - if (req.body._ClientVersion) { - info.clientVersion = req.body._ClientVersion; - delete req.body._ClientVersion; - } - if (req.body._InstallationId) { - info.installationId = req.body._InstallationId; - delete req.body._InstallationId; - } - if (req.body._SessionToken) { - info.sessionToken = req.body._SessionToken; - delete req.body._SessionToken; - } - if (req.body._MasterKey) { - info.masterKey = req.body._MasterKey; - delete req.body._MasterKey; - } - } else { - return invalidRequest(req, res); - } - } - - if (fileViaJSON) { - // We need to repopulate req.body with a buffer - var base64 = req.body.base64; - req.body = new Buffer(base64, 'base64'); - } - - info.app = cache.apps[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); - next(); - return; - } - - // Client keys are not required in parse-server, but if any have been configured in the server, validate them - // to preserve original behavior. - var keyRequired = (req.config.clientKey - || req.config.javascriptKey - || req.config.dotNetKey - || req.config.restAPIKey); - var keyHandled = false; - if (keyRequired - && ((info.clientKey && req.config.clientKey && info.clientKey === req.config.clientKey) - || (info.javascriptKey && req.config.javascriptKey && info.javascriptKey === req.config.javascriptKey) - || (info.dotNetKey && req.config.dotNetKey && info.dotNetKey === req.config.dotNetKey) - || (info.restAPIKey && req.config.restAPIKey && info.restAPIKey === req.config.restAPIKey) - )) { - keyHandled = true; - } - if (keyRequired && !keyHandled) { - return invalidRequest(req, res); - } - - if (!info.sessionToken) { - req.auth = new auth.Auth(req.config, false); - next(); - return; - } - - return auth.getAuthForSessionToken( - req.config, info.sessionToken).then((auth) => { - if (auth) { - req.auth = auth; - next(); - } - }).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) { - res.header('Access-Control-Allow-Origin', '*'); - res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); - res.header('Access-Control-Allow-Headers', '*'); - - // intercept OPTIONS method - if ('OPTIONS' == req.method) { - res.send(200); - } - else { - next(); - } -}; - -var allowMethodOverride = function(req, res, next) { - if (req.method === 'POST' && req.body._method) { - req.originalMethod = req.method; - req.method = req.body._method; - delete req.body._method; - } - next(); -}; - -var handleParseErrors = function(err, req, res, next) { - if (err instanceof Parse.Error) { - var httpStatus; - - // TODO: fill out this mapping - switch (err.code) { - case Parse.Error.INTERNAL_SERVER_ERROR: - httpStatus = 500; - break; - case Parse.Error.OBJECT_NOT_FOUND: - httpStatus = 404; - break; - default: - httpStatus = 400; - } - - res.status(httpStatus); - res.json({code: err.code, error: err.message}); - } else { - console.log('Uncaught internal server error.', err, err.stack); - res.status(500); - res.json({code: Parse.Error.INTERNAL_SERVER_ERROR, - message: 'Internal server error.'}); - } -}; - -function invalidRequest(req, res) { - res.status(403); - res.end('{"error":"unauthorized"}'); -} - - -module.exports = { - allowCrossDomain: allowCrossDomain, - allowMethodOverride: allowMethodOverride, - handleParseErrors: handleParseErrors, - handleParseHeaders: handleParseHeaders -}; diff --git a/package.json b/package.json index abdcbca898..b32315dc63 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "parse-server", "version": "2.0.4", "description": "An express module providing a Parse-compatible API server", - "main": "index.js", + "main": "src/index.js", "repository": { "type": "git", "url": "https://github.com/ParsePlatform/parse-server" @@ -22,10 +22,12 @@ "request": "^2.65.0" }, "devDependencies": { + "eslint": "^1.10.3", "jasmine": "^2.3.2" }, "scripts": { - "test": "TESTING=1 jasmine" + "test": "TESTING=1 ./node_modules/.bin/jasmine", + "lint": "./node_modules/.bin/eslint ." }, "engines": { "node": ">=4.1" diff --git a/password.js b/password.js deleted file mode 100644 index f1154c96e6..0000000000 --- a/password.js +++ /dev/null @@ -1,35 +0,0 @@ -// Tools for encrypting and decrypting passwords. -// Basically promise-friendly wrappers for bcrypt. -var bcrypt = require('bcrypt-nodejs'); - -// Returns a promise for a hashed password string. -function hash(password) { - return new Promise(function(fulfill, reject) { - bcrypt.hash(password, null, null, function(err, hashedPassword) { - if (err) { - reject(err); - } else { - fulfill(hashedPassword); - } - }); - }); -} - -// Returns a promise for whether this password compares to equal this -// hashed password. -function compare(password, hashedPassword) { - return new Promise(function(fulfill, reject) { - bcrypt.compare(password, hashedPassword, function(err, success) { - if (err) { - reject(err); - } else { - fulfill(success); - } - }); - }); -} - -module.exports = { - hash: hash, - compare: compare -}; diff --git a/rest.js b/rest.js deleted file mode 100644 index 552fa6be8c..0000000000 --- a/rest.js +++ /dev/null @@ -1,129 +0,0 @@ -// This file contains helpers for running operations in REST format. -// The goal is that handlers that explicitly handle an express route -// should just be shallow wrappers around things in this file, but -// these functions should not explicitly depend on the request -// object. -// This means that one of these handlers can support multiple -// routes. That's useful for the routes that do really similar -// things. - -var Parse = require('parse/node').Parse; - -var cache = require('./cache'); -var RestQuery = require('./RestQuery'); -var RestWrite = require('./RestWrite'); -var triggers = require('./triggers'); - -// Returns a promise for an object with optional keys 'results' and 'count'. -function find(config, auth, className, restWhere, restOptions) { - enforceRoleSecurity('find', className, auth); - var query = new RestQuery(config, auth, className, - restWhere, restOptions); - return query.execute(); -} - -// Returns a promise that doesn't resolve to any useful value. -function del(config, auth, className, objectId) { - if (typeof objectId !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad objectId'); - } - - if (className === '_User' && !auth.couldUpdateUserId(objectId)) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'insufficient auth to delete user'); - } - - enforceRoleSecurity('delete', className, auth); - - var inflatedObject; - - return Promise.resolve().then(() => { - if (triggers.getTrigger(className, 'beforeDelete') || - triggers.getTrigger(className, 'afterDelete') || - className == '_Session') { - return find(config, auth, className, {objectId: objectId}) - .then((response) => { - if (response && response.results && response.results.length) { - response.results[0].className = className; - cache.clearUser(response.results[0].sessionToken); - inflatedObject = Parse.Object.fromJSON(response.results[0]); - return triggers.maybeRunTrigger('beforeDelete', - auth, inflatedObject); - } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found for delete.'); - }); - } - return Promise.resolve({}); - }).then(() => { - var options = {}; - if (!auth.isMaster) { - options.acl = ['*']; - if (auth.user) { - options.acl.push(auth.user.id); - } - } - - return config.database.destroy(className, { - objectId: objectId - }, options); - }).then(() => { - triggers.maybeRunTrigger('afterDelete', auth, inflatedObject); - return Promise.resolve(); - }); -} - -// Returns a promise for a {response, status, location} object. -function create(config, auth, className, restObject) { - enforceRoleSecurity('create', className, auth); - - var write = new RestWrite(config, auth, className, null, restObject); - return write.execute(); -} - -// Returns a promise that contains the fields of the update that the -// REST API is supposed to return. -// Usually, this is just updatedAt. -function update(config, auth, className, objectId, restObject) { - enforceRoleSecurity('update', className, auth); - - return Promise.resolve().then(() => { - if (triggers.getTrigger(className, 'beforeSave') || - triggers.getTrigger(className, 'afterSave')) { - return find(config, auth, className, {objectId: objectId}); - } - return Promise.resolve({}); - }).then((response) => { - var originalRestObject; - if (response && response.results && response.results.length) { - originalRestObject = response.results[0]; - } - - var write = new RestWrite(config, auth, className, - {objectId: objectId}, restObject, originalRestObject); - return write.execute(); - }); -} - -// Disallowing access to the _Role collection except by master key -function enforceRoleSecurity(method, className, auth) { - if (className === '_Role' && !auth.isMaster) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - 'Clients aren\'t allowed to perform the ' + - method + ' operation on the role collection.'); - } - if (method === 'delete' && className === '_Installation' && !auth.isMaster) { - throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - 'Clients aren\'t allowed to perform the ' + - 'delete operation on the installation collection.'); - - } -} - -module.exports = { - create: create, - del: del, - find: find, - update: update -}; diff --git a/sessions.js b/sessions.js deleted file mode 100644 index 30290a9d52..0000000000 --- a/sessions.js +++ /dev/null @@ -1,122 +0,0 @@ -// sessions.js - -var Auth = require('./Auth'), - Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); - -var router = new PromiseRouter(); - -function handleCreate(req) { - return rest.create(req.config, req.auth, - '_Session', req.body); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_Session', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - '_Session', req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleGet(req) { - return rest.find(req.config, req.auth, '_Session', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -function handleLogout(req) { - // TODO: Verify correct behavior for logout without token - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.SESSION_MISSING, - 'Session token required for logout.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - { _session_token: req.info.sessionToken}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token not found.'); - } - return rest.del(req.config, Auth.master(req.config), '_Session', - response.results[0].objectId); - }).then(() => { - return { - status: 200, - response: {} - }; - }); -} - -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - - return rest.find(req.config, req.auth, - '_Session', req.body.where, options) - .then((response) => { - return {response: response}; - }); -} - -function handleMe(req) { - // TODO: Verify correct behavior - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - { _session_token: req.info.sessionToken}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token not found.'); - } - return { - response: response.results[0] - }; - }); -} - -router.route('POST', '/logout', handleLogout); -router.route('POST','/sessions', handleCreate); -router.route('GET','/sessions/me', handleMe); -router.route('GET','/sessions/:objectId', handleGet); -router.route('PUT','/sessions/:objectId', handleUpdate); -router.route('GET','/sessions', handleFind); -router.route('DELETE','/sessions/:objectId', handleDelete); - -module.exports = router; \ No newline at end of file diff --git a/spec/ExportAdapter.spec.js b/spec/ExportAdapter.spec.js index 95fbdd2190..a4f3f9b6ec 100644 --- a/spec/ExportAdapter.spec.js +++ b/spec/ExportAdapter.spec.js @@ -1,4 +1,4 @@ -var ExportAdapter = require('../ExportAdapter'); +var ExportAdapter = require('../src/ExportAdapter'); describe('ExportAdapter', () => { it('can be constructed', (done) => { diff --git a/spec/ParseAPI.spec.js b/spec/ParseAPI.spec.js index 810ae46cc9..158e6f4c46 100644 --- a/spec/ParseAPI.spec.js +++ b/spec/ParseAPI.spec.js @@ -1,7 +1,7 @@ // A bunch of different tests are in here - it isn't very thematic. // It would probably be better to refactor them into different files. -var DatabaseAdapter = require('../DatabaseAdapter'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var request = require('request'); describe('miscellaneous', function() { diff --git a/spec/ParseInstallation.spec.js b/spec/ParseInstallation.spec.js index 6d8e61625f..91bb9a23b4 100644 --- a/spec/ParseInstallation.spec.js +++ b/spec/ParseInstallation.spec.js @@ -1,12 +1,12 @@ // These tests check the Installations functionality of the REST API. // Ported from installation_collection_test.go -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var Parse = require('parse/node').Parse; -var rest = require('../rest'); +var rest = require('../src/rest'); var config = new Config('test'); var database = DatabaseAdapter.getDatabaseConnection('test'); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 458b43eef4..1c3ffb7380 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -6,7 +6,7 @@ // Tests that involve sending password reset emails. var request = require('request'); -var passwordCrypto = require('../password'); +var passwordCrypto = require('../src/password'); describe('Parse.User testing', () => { it("user sign up class method", (done) => { diff --git a/spec/RestCreate.spec.js b/spec/RestCreate.spec.js index 59de11ead9..244555075a 100644 --- a/spec/RestCreate.spec.js +++ b/spec/RestCreate.spec.js @@ -1,10 +1,10 @@ // These tests check the "create" functionality of the REST API. -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var Parse = require('parse/node').Parse; -var rest = require('../rest'); +var rest = require('../src/rest'); var request = require('request'); var config = new Config('test'); diff --git a/spec/RestQuery.spec.js b/spec/RestQuery.spec.js index 08d0176654..b93a07d588 100644 --- a/spec/RestQuery.spec.js +++ b/spec/RestQuery.spec.js @@ -1,8 +1,8 @@ // These tests check the "find" functionality of the REST API. -var auth = require('../Auth'); -var cache = require('../cache'); -var Config = require('../Config'); -var rest = require('../rest'); +var auth = require('../src/Auth'); +var cache = require('../src/cache'); +var Config = require('../src/Config'); +var rest = require('../src/rest'); var config = new Config('test'); var nobody = auth.nobody(config); diff --git a/spec/Schema.spec.js b/spec/Schema.spec.js index abf178ab03..364b7402c4 100644 --- a/spec/Schema.spec.js +++ b/spec/Schema.spec.js @@ -1,6 +1,6 @@ // These tests check that the Schema operates correctly. -var Config = require('../Config'); -var Schema = require('../Schema'); +var Config = require('../src/Config'); +var Schema = require('../src/Schema'); var config = new Config('test'); diff --git a/spec/helper.js b/spec/helper.js index 255d61f810..9424919d3a 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -1,15 +1,16 @@ // Sets up a Parse API server for testing. -jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000; +var path = require('path'); -var cache = require('../cache'); -var DatabaseAdapter = require('../DatabaseAdapter'); +var cache = require('../src/cache'); +var DatabaseAdapter = require('../src/DatabaseAdapter'); var express = require('express'); -var facebook = require('../facebook'); -var ParseServer = require('../index').ParseServer; +var facebook = require('../src/facebook'); +var ParseServer = require('../src/index').ParseServer; var databaseURI = process.env.DATABASE_URI; -var cloudMain = process.env.CLOUD_CODE_MAIN || './cloud/main.js'; +var cloudMain = process.env.CLOUD_CODE_MAIN || path.resolve('cloud/main.js'); // Set up an API server for testing var api = new ParseServer({ diff --git a/spec/transform.spec.js b/spec/transform.spec.js index c581c5d6c3..2e623e7a76 100644 --- a/spec/transform.spec.js +++ b/spec/transform.spec.js @@ -1,6 +1,6 @@ // These tests are unit tests designed to only test transform.js. -var transform = require('../transform'); +var transform = require('../src/transform'); var dummySchema = { data: {}, diff --git a/src/Auth.js b/src/Auth.js new file mode 100644 index 0000000000..c81a1885d2 --- /dev/null +++ b/src/Auth.js @@ -0,0 +1,170 @@ +var deepcopy = require('deepcopy'); +var Parse = require('parse/node').Parse; +var RestQuery = require('./RestQuery'); + +var cache = require('./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) { + this.config = config; + this.isMaster = isMaster; + this.user = userObject; + + // Assuming a users roles won't change during a single request, we'll + // only load them once. + this.userRoles = []; + this.fetchedRoles = false; + this.rolePromise = null; +} + +// Whether this auth could possibly modify the given user id. +// It still could be forbidden via ACLs even if this returns true. +Auth.prototype.couldUpdateUserId = function(userId) { + if (this.isMaster) { + return true; + } + if (this.user && this.user.id === userId) { + return true; + } + return false; +}; + +// A helper to get a master-level Auth object +function master(config) { + return new Auth(config, true, null); +} + +// A helper to get a nobody-level Auth object +function nobody(config) { + return new Auth(config, false, null); +} + +// Returns a promise that resolves to an Auth object +var getAuthForSessionToken = function(config, sessionToken) { + var cachedUser = cache.getUser(sessionToken); + if (cachedUser) { + return Promise.resolve(new Auth(config, false, cachedUser)); + } + var restOptions = { + limit: 1, + include: 'user' + }; + var restWhere = { + _session_token: sessionToken + }; + var query = new RestQuery(config, master(config), '_Session', + restWhere, restOptions); + return query.execute().then((response) => { + var results = response.results; + if (results.length !== 1 || !results[0]['user']) { + return nobody(config); + } + var obj = results[0]['user']; + delete obj.password; + obj['className'] = '_User'; + var userObject = Parse.Object.fromJSON(obj); + cache.setUser(sessionToken, userObject); + return new Auth(config, false, userObject); + }); +}; + +// Returns a promise that resolves to an array of role names +Auth.prototype.getUserRoles = function() { + if (this.isMaster || !this.user) { + return Promise.resolve([]); + } + if (this.fetchedRoles) { + return Promise.resolve(this.userRoles); + } + if (this.rolePromise) { + return rolePromise; + } + this.rolePromise = this._loadRoles(); + return this.rolePromise; +}; + +// Iterates through the role tree and compiles a users roles +Auth.prototype._loadRoles = function() { + var restWhere = { + 'users': { + __type: 'Pointer', + className: '_User', + objectId: this.user.id + } + }; + // First get the role ids this user is directly a member of + var query = new RestQuery(this.config, master(this.config), '_Role', + restWhere, {}); + return query.execute().then((response) => { + var results = response.results; + if (!results.length) { + this.userRoles = []; + this.fetchedRoles = true; + this.rolePromise = null; + return Promise.resolve(this.userRoles); + } + + var roleIDs = results.map(r => r.objectId); + var promises = [Promise.resolve(roleIDs)]; + for (var role of roleIDs) { + promises.push(this._getAllRoleNamesForId(role)); + } + return Promise.all(promises).then((results) => { + var allIDs = []; + for (var x of results) { + Array.prototype.push.apply(allIDs, x); + } + var restWhere = { + objectId: { + '$in': allIDs + } + }; + var query = new RestQuery(this.config, master(this.config), + '_Role', restWhere, {}); + return query.execute(); + }).then((response) => { + var results = response.results; + this.userRoles = results.map((r) => { + return 'role:' + r.name; + }); + this.fetchedRoles = true; + this.rolePromise = null; + return Promise.resolve(this.userRoles); + }); + }); +}; + +// Given a role object id, get any other roles it is part of +// TODO: Make recursive to support role nesting beyond 1 level deep +Auth.prototype._getAllRoleNamesForId = function(roleID) { + var rolePointer = { + __type: 'Pointer', + className: '_Role', + objectId: roleID + }; + var restWhere = { + '$relatedTo': { + key: 'roles', + object: rolePointer + } + }; + var query = new RestQuery(this.config, master(this.config), '_Role', + restWhere, {}); + return query.execute().then((response) => { + var results = response.results; + if (!results.length) { + return Promise.resolve([]); + } + var roleIDs = results.map(r => r.objectId); + return Promise.resolve(roleIDs); + }); +}; + +module.exports = { + Auth: Auth, + master: master, + nobody: nobody, + getAuthForSessionToken: getAuthForSessionToken +}; diff --git a/src/Config.js b/src/Config.js new file mode 100644 index 0000000000..03d03500e0 --- /dev/null +++ b/src/Config.js @@ -0,0 +1,28 @@ +// A Config object provides information about how a specific app is +// configured. +// mount is the URL for the root of the API; includes http, domain, etc. +function Config(applicationId, mount) { + var cache = require('./cache'); + var DatabaseAdapter = require('./DatabaseAdapter'); + + var cacheInfo = cache.apps[applicationId]; + this.valid = !!cacheInfo; + if (!this.valid) { + return; + } + + this.applicationId = applicationId; + this.collectionPrefix = cacheInfo.collectionPrefix || ''; + this.database = DatabaseAdapter.getDatabaseConnection(applicationId); + this.masterKey = cacheInfo.masterKey; + this.clientKey = cacheInfo.clientKey; + this.javascriptKey = cacheInfo.javascriptKey; + this.dotNetKey = cacheInfo.dotNetKey; + this.restAPIKey = cacheInfo.restAPIKey; + this.fileKey = cacheInfo.fileKey; + this.facebookAppIds = cacheInfo.facebookAppIds; + this.mount = mount; +} + + +module.exports = Config; diff --git a/DatabaseAdapter.js b/src/DatabaseAdapter.js similarity index 61% rename from DatabaseAdapter.js rename to src/DatabaseAdapter.js index 4967d5665d..9861c3fce6 100644 --- a/DatabaseAdapter.js +++ b/src/DatabaseAdapter.js @@ -23,34 +23,34 @@ var databaseURI = 'mongodb://localhost:27017/parse'; var appDatabaseURIs = {}; function setAdapter(databaseAdapter) { - adapter = databaseAdapter; + adapter = databaseAdapter; } function setDatabaseURI(uri) { - databaseURI = uri; + databaseURI = uri; } function setAppDatabaseURI(appId, uri) { - appDatabaseURIs[appId] = uri; + appDatabaseURIs[appId] = uri; } function getDatabaseConnection(appId) { - if (dbConnections[appId]) { + if (dbConnections[appId]) { + return dbConnections[appId]; + } + + var dbURI = (appDatabaseURIs[appId] ? appDatabaseURIs[appId] : databaseURI); + dbConnections[appId] = new adapter(dbURI, { + collectionPrefix: cache.apps[appId]['collectionPrefix'] + }); + dbConnections[appId].connect(); return dbConnections[appId]; - } - - var dbURI = (appDatabaseURIs[appId] ? appDatabaseURIs[appId] : databaseURI); - dbConnections[appId] = new adapter(dbURI, { - collectionPrefix: cache.apps[appId]['collectionPrefix'] - }); - dbConnections[appId].connect(); - return dbConnections[appId]; } module.exports = { - dbConnections: dbConnections, - getDatabaseConnection: getDatabaseConnection, - setAdapter: setAdapter, - setDatabaseURI: setDatabaseURI, - setAppDatabaseURI: setAppDatabaseURI + dbConnections: dbConnections, + getDatabaseConnection: getDatabaseConnection, + setAdapter: setAdapter, + setDatabaseURI: setDatabaseURI, + setAppDatabaseURI: setAppDatabaseURI }; diff --git a/src/ExportAdapter.js b/src/ExportAdapter.js new file mode 100644 index 0000000000..d6a199c613 --- /dev/null +++ b/src/ExportAdapter.js @@ -0,0 +1,578 @@ +// A database adapter that works with data exported from the hosted +// Parse database. + +var mongodb = require('mongodb'); +var MongoClient = mongodb.MongoClient; +var Parse = require('parse/node').Parse; + +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; + options = options || {}; + + this.collectionPrefix = options.collectionPrefix; + + // We don't want a mutable this.schema, because then you could have + // one request that uses different schemas for different parts of + // it. Instead, use loadSchema to get a schema. + this.schemaPromise = null; + + this.connect(); +} + +// 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; +}; + +// Returns a promise for a Mongo collection. +// Generally just for internal use. +var joinRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/; +var otherRegex = /^[A-Za-z][A-Za-z0-9_]*$/; +ExportAdapter.prototype.collection = function(className) { + if (className !== '_User' && + className !== '_Installation' && + className !== '_Session' && + className !== '_SCHEMA' && + className !== '_Role' && + !joinRegex.test(className) && + !otherRegex.test(className)) { + throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, + 'invalid className: ' + className); + } + return this.connect().then(() => { + return this.db.collection(this.collectionPrefix + className); + }); +}; + +function returnsTrue() { + return true; +} + +// 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) { + acceptor = acceptor || returnsTrue; + + if (!this.schemaPromise) { + this.schemaPromise = this.collection('_SCHEMA').then((coll) => { + delete this.schemaPromise; + return Schema.load(coll); + }); + return this.schemaPromise; + } + + return this.schemaPromise.then((schema) => { + if (acceptor(schema)) { + return schema; + } + this.schemaPromise = this.collection('_SCHEMA').then((coll) => { + delete this.schemaPromise; + return Schema.load(coll); + }); + return this.schemaPromise; + }); +}; + +// 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) { + return this.loadSchema().then((schema) => { + var t = schema.getExpectedType(className, key); + var match = t.match(/^relation<(.*)>$/); + if (match) { + return match[1]; + } else { + return className; + } + }); +}; + +// Uses the schema to validate the object (REST API format). +// 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) { + return this.loadSchema().then((schema) => { + return schema.validateObject(className, object); + }); +}; + +// 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( + schema, isMaster, aclGroup, className, mongoObject) { + var object = transform.untransformObject(schema, className, mongoObject); + + if (className !== '_User') { + return object; + } + + if (isMaster || (aclGroup.indexOf(object.objectId) > -1)) { + return object; + } + + delete object.authData; + delete object.sessionToken; + return object; +}; + +// Runs an update on the database. +// Returns a promise for an object with the new values for field +// modifications that don't know their results ahead of time, like +// 'increment'. +// Options: +// 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) { + 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]}}); + } + 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 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, + objectId, + update) { + var pending = []; + var deleteMe = []; + objectId = update.objectId || objectId; + + var process = (op, key) => { + if (!op) { + return; + } + if (op.__op == 'AddRelation') { + for (var object of op.objects) { + pending.push(this.addRelation(key, className, + objectId, + object.objectId)); + } + deleteMe.push(key); + } + + if (op.__op == 'RemoveRelation') { + for (var object of op.objects) { + pending.push(this.removeRelation(key, className, + objectId, + object.objectId)); + } + deleteMe.push(key); + } + + if (op.__op == 'Batch') { + for (x of op.ops) { + process(x, key); + } + } + }; + + for (var key in update) { + process(update[key], key); + } + for (var key of deleteMe) { + delete update[key]; + } + return Promise.all(pending); +}; + +// Adds a relation. +// Returns a promise that resolves successfully iff the add was successful. +ExportAdapter.prototype.addRelation = function(key, fromClassName, + fromId, toId) { + var doc = { + relatedId: toId, + owningId: fromId + }; + var className = '_Join:' + key + ':' + fromClassName; + return this.collection(className).then((coll) => { + return coll.update(doc, doc, {upsert: true}); + }); +}; + +// Removes a relation. +// Returns a promise that resolves successfully iff the remove was +// successful. +ExportAdapter.prototype.removeRelation = function(key, fromClassName, + fromId, toId) { + var doc = { + relatedId: toId, + owningId: fromId + }; + var className = '_Join:' + key + ':' + fromClassName; + return this.collection(className).then((coll) => { + return coll.remove(doc); + }); +}; + +// Removes objects matches this query from the database. +// Returns a promise that resolves successfully iff the object was +// deleted. +// Options: +// 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) { + options = options || {}; + var isMaster = !('acl' in options); + var aclGroup = options.acl || []; + + var schema; + return this.loadSchema().then((s) => { + schema = s; + if (!isMaster) { + return schema.validatePermission(className, aclGroup, 'delete'); + } + return Promise.resolve(); + }).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]}}); + } + mongoWhere = {'$and': [mongoWhere, {'$or': writePerms}]}; + } + + return coll.remove(mongoWhere); + }).then((resp) => { + if (resp.result.n === 0) { + return Promise.reject( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.')); + + } + }, (error) => { + throw error; + }); +}; + +// Inserts an object into the database. +// Returns a promise that resolves successfully iff the object saved. +ExportAdapter.prototype.create = function(className, object, options) { + var schema; + var isMaster = !('acl' in options); + var aclGroup = options.acl || []; + + return this.loadSchema().then((s) => { + schema = s; + if (!isMaster) { + return schema.validatePermission(className, aclGroup, 'create'); + } + return Promise.resolve(); + }).then(() => { + + return this.handleRelationUpdates(className, null, object); + }).then(() => { + return this.collection(className); + }).then((coll) => { + var mongoObject = transform.transformCreate(schema, className, object); + return coll.insert([mongoObject]); + }); +}; + +// Runs a mongo query on the database. +// 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) { + options = options || {}; + return this.collection(className).then((coll) => { + return coll.find(query, options).toArray(); + }); +}; + +// Deletes everything in the database matching the current collectionPrefix +// Won't delete collections in the system namespace +// Returns a promise. +ExportAdapter.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 Promise.all(promises); + }); +}; + +// Finds the keys in a query. Returns a Set. REST format only +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; + } + + return new Set(Object.keys(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); + }); +}; + +// 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); + }); +}; + +// 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) { + // 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; + if (!match) { + continue; + } + var relatedClassName = match[1]; + var 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}; + }); + } + } + return Promise.resolve(); +}; + +// Modifies query so that it no longer has $relatedTo +// Returns a promise that resolves when query is mutated +ExportAdapter.prototype.reduceRelationKeys = function(className, query) { + var relatedTo = query['$relatedTo']; + if (relatedTo) { + return this.relatedIds( + relatedTo.object.className, + relatedTo.key, + relatedTo.object.objectId).then((ids) => { + delete query['$relatedTo']; + query['objectId'] = {'$in': ids}; + 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'; + return coll.createIndex(index).then(() => { + // Retry, but just once. + return coll.find(where, options).toArray(); + }); + }); +}; + +// Runs a query on the database. +// Returns a promise that resolves to a list of items. +// Options: +// skip number of results to skip. +// limit limit to this number of results. +// sort an object where keys are the fields to sort by. +// the value is +1 for ascending, -1 for descending. +// count run a count instead of returning results. +// acl restrict this operation with an ACL for the provided array +// of user objectIds and roles. acl: null means no user. +// when this field is not present, don't do anything regarding ACLs. +// 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) { + options = options || {}; + var mongoOptions = {}; + if (options.skip) { + mongoOptions.skip = options.skip; + } + if (options.limit) { + mongoOptions.limit = options.limit; + } + + var isMaster = !('acl' in options); + var aclGroup = options.acl || []; + var acceptor = function(schema) { + return schema.hasKeys(className, keysForQuery(query)); + }; + var schema; + return this.loadSchema(acceptor).then((s) => { + schema = s; + if (options.sort) { + mongoOptions.sort = {}; + for (var key in options.sort) { + var mongoKey = transform.transformKey(schema, className, key); + mongoOptions.sort[mongoKey] = options.sort[key]; + } + } + + if (!isMaster) { + var op = 'find'; + var k = Object.keys(query); + if (k.length == 1 && typeof query.objectId == 'string') { + op = 'get'; + } + return schema.validatePermission(className, aclGroup, op); + } + return Promise.resolve(); + }).then(() => { + return this.reduceRelationKeys(className, query); + }).then(() => { + return this.reduceInRelation(className, query, schema); + }).then(() => { + return this.collection(className); + }).then((coll) => { + var mongoWhere = transform.transformWhere(schema, className, query); + if (!isMaster) { + var orParts = [ + {'_rperm' : { '$exists': false }}, + {'_rperm' : { '$in' : ['*']}} + ]; + for (var acl of aclGroup) { + orParts.push({'_rperm' : { '$in' : [acl]}}); + } + mongoWhere = {'$and': [mongoWhere, {'$or': orParts}]}; + } + if (options.count) { + return coll.count(mongoWhere, mongoOptions); + } else { + return this.smartFind(coll, mongoWhere, mongoOptions) + .then((mongoResults) => { + return mongoResults.map((r) => { + return this.untransformObject( + schema, isMaster, aclGroup, className, r); + }); + }); + } + }); +}; + +module.exports = ExportAdapter; diff --git a/FilesAdapter.js b/src/FilesAdapter.js similarity index 84% rename from FilesAdapter.js rename to src/FilesAdapter.js index 427e20d9bb..0e50fe783f 100644 --- a/FilesAdapter.js +++ b/src/FilesAdapter.js @@ -16,14 +16,14 @@ var GridStoreAdapter = require('./GridStoreAdapter'); var adapter = GridStoreAdapter; function setAdapter(filesAdapter) { - adapter = filesAdapter; + adapter = filesAdapter; } function getAdapter() { - return adapter; + return adapter; } module.exports = { - getAdapter: getAdapter, - setAdapter: setAdapter + getAdapter: getAdapter, + setAdapter: setAdapter }; diff --git a/GridStoreAdapter.js b/src/GridStoreAdapter.js similarity index 52% rename from GridStoreAdapter.js rename to src/GridStoreAdapter.js index 0d1e896578..d0c79b1de5 100644 --- a/GridStoreAdapter.js +++ b/src/GridStoreAdapter.js @@ -9,40 +9,40 @@ var path = require('path'); // For a given config object, filename, and data, store a file // Returns a promise function create(config, filename, data) { - return config.database.connect().then(() => { - var gridStore = new GridStore(config.database.db, filename, 'w'); - return gridStore.open(); - }).then((gridStore) => { - return gridStore.write(data); - }).then((gridStore) => { - return gridStore.close(); - }); + return config.database.connect().then(() => { + var gridStore = new GridStore(config.database.db, filename, 'w'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.write(data); + }).then((gridStore) => { + return gridStore.close(); + }); } // Search for and return a file if found by filename // Resolves a promise that succeeds with the buffer result // from GridStore function get(config, filename) { - return config.database.connect().then(() => { - return GridStore.exist(config.database.db, filename); - }).then(() => { - var gridStore = new GridStore(config.database.db, filename, 'r'); - return gridStore.open(); - }).then((gridStore) => { - return gridStore.read(); - }); + return config.database.connect().then(() => { + return GridStore.exist(config.database.db, filename); + }).then(() => { + var gridStore = new GridStore(config.database.db, filename, 'r'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.read(); + }); } // Generates and returns the location of a file stored in GridStore for the // given request and filename function location(config, req, filename) { - return (req.protocol + '://' + req.get('host') + + return (req.protocol + '://' + req.get('host') + path.dirname(req.originalUrl) + '/' + req.config.applicationId + '/' + encodeURIComponent(filename)); } module.exports = { - create: create, - get: get, - location: location + create: create, + get: get, + location: location }; diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js new file mode 100644 index 0000000000..f26f5419c6 --- /dev/null +++ b/src/PromiseRouter.js @@ -0,0 +1,148 @@ +// A router that is based on promises rather than req/res/next. +// This is intended to replace the use of express.Router to handle +// subsections of the API surface. +// This will make it easier to have methods like 'batch' that +// themselves use our routing information, without disturbing express +// components that external developers may be modifying. + +function PromiseRouter() { + // Each entry should be an object with: + // path: the path to route, in express format + // method: the HTTP method that this route handles. + // Must be one of: POST, GET, PUT, DELETE + // handler: a function that takes request, and returns a promise. + // Successful handlers should resolve to an object with fields: + // status: optional. the http status code. defaults to 200 + // response: a json object with the content of the response + // location: optional. a location header + this.routes = []; +} + +// Global flag. Set this to true to log every request and response. +PromiseRouter.verbose = process.env.VERBOSE || false; + +// Merge the routes into this one +PromiseRouter.prototype.merge = function(router) { + for (var route of router.routes) { + this.routes.push(route); + } +}; + +PromiseRouter.prototype.route = function(method, path, handler) { + switch(method) { + case 'POST': + case 'GET': + case 'PUT': + case 'DELETE': + break; + default: + throw 'cannot route method: ' + method; + } + + this.routes.push({ + path: path, + method: method, + handler: handler + }); +}; + +// Returns an object with: +// handler: the handler that should deal with this request +// params: any :-params that got parsed from the path +// Returns undefined if there is no match. +PromiseRouter.prototype.match = function(method, path) { + for (var route of this.routes) { + if (route.method != method) { + continue; + } + + // NOTE: we can only route the specific wildcards :className and + // :objectId, and in that order. + // This is pretty hacky but I don't want to rebuild the entire + // express route matcher. Maybe there's a way to reuse its logic. + var pattern = '^' + route.path + '$'; + + pattern = pattern.replace(':className', + '(_?[A-Za-z][A-Za-z_0-9]*)'); + pattern = pattern.replace(':objectId', + '([A-Za-z0-9]+)'); + var re = new RegExp(pattern); + var m = path.match(re); + if (!m) { + continue; + } + var params = {}; + if (m[1]) { + params.className = m[1]; + } + if (m[2]) { + params.objectId = m[2]; + } + + return {params: params, handler: route.handler}; + } +}; + +// A helper function to make an express handler out of a a promise +// handler. +// Express handlers should never throw; if a promise handler throws we +// just treat it like it resolved to an error. +function makeExpressHandler(promiseHandler) { + return function(req, res, next) { + try { + if (PromiseRouter.verbose) { + console.log(req.method, req.originalUrl, req.headers, + JSON.stringify(req.body, null, 2)); + } + promiseHandler(req).then((result) => { + if (!result.response) { + console.log('BUG: the handler did not include a "response" field'); + throw 'control should not get here'; + } + if (PromiseRouter.verbose) { + console.log('response:', JSON.stringify(result.response, null, 2)); + } + var status = result.status || 200; + res.status(status); + if (result.location) { + res.set('Location', result.location); + } + res.json(result.response); + }, (e) => { + if (PromiseRouter.verbose) { + console.log('error:', e); + } + next(e); + }); + } catch (e) { + if (PromiseRouter.verbose) { + console.log('error:', e); + } + next(e); + } + }; +} + +// Mount the routes on this router onto an express app (or express router) +PromiseRouter.prototype.mountOnto = function(expressApp) { + 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'; + } + } +}; + +module.exports = PromiseRouter; diff --git a/src/RestQuery.js b/src/RestQuery.js new file mode 100644 index 0000000000..9c534dc3ae --- /dev/null +++ b/src/RestQuery.js @@ -0,0 +1,555 @@ +// An object that encapsulates everything we need to run a 'find' +// operation, encoded in the REST API format. + +var Parse = require('parse/node').Parse; + +// restOptions can include: +// skip +// limit +// order +// count +// include +// keys +// redirectClassNameForKey +function RestQuery(config, auth, className, restWhere, restOptions) { + restOptions = restOptions || {}; + + this.config = config; + this.auth = auth; + this.className = className; + this.restWhere = restWhere || {}; + this.response = null; + + this.findOptions = {}; + if (!this.auth.isMaster) { + this.findOptions.acl = this.auth.user ? [this.auth.user.id] : null; + if (this.className == '_Session') { + if (!this.findOptions.acl) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'This session token is invalid.'); + } + this.restWhere = { + '$and': [this.restWhere, { + 'user': { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id + } + }] + }; + } + } + + this.doCount = false; + + // The format for this.include is not the same as the format for the + // include option - it's the paths we should include, in order, + // stored as arrays, taking into account that we need to include foo + // before including foo.bar. Also it should dedupe. + // For example, passing an arg of include=foo.bar,foo.baz could lead to + // this.include = [['foo'], ['foo', 'baz'], ['foo', 'bar']] + this.include = []; + + for (var option in restOptions) { + switch(option) { + case 'keys': + this.keys = new Set(restOptions.keys.split(',')); + this.keys.add('objectId'); + this.keys.add('createdAt'); + this.keys.add('updatedAt'); + break; + case 'count': + this.doCount = true; + break; + case 'skip': + case 'limit': + this.findOptions[option] = restOptions[option]; + break; + case 'order': + var fields = restOptions.order.split(','); + var sortMap = {}; + for (var field of fields) { + if (field[0] == '-') { + sortMap[field.slice(1)] = -1; + } else { + sortMap[field] = 1; + } + } + this.findOptions.sort = sortMap; + break; + case 'include': + var paths = restOptions.include.split(','); + var pathSet = {}; + for (var path of paths) { + // Add all prefixes with a .-split to pathSet + var parts = path.split('.'); + for (var len = 1; len <= parts.length; len++) { + pathSet[parts.slice(0, len).join('.')] = true; + } + } + this.include = Object.keys(pathSet).sort((a, b) => { + return a.length - b.length; + }).map((s) => { + return s.split('.'); + }); + break; + case 'redirectClassNameForKey': + this.redirectKey = restOptions.redirectClassNameForKey; + this.redirectClassName = null; + break; + default: + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad option: ' + option); + } + } +} + +// A convenient method to perform all the steps of processing a query +// in order. +// Returns a promise for the response - an object with optional keys +// 'results' and 'count'. +// TODO: consolidate the replaceX functions +RestQuery.prototype.execute = function() { + return Promise.resolve().then(() => { + return this.getUserAndRoleACL(); + }).then(() => { + return this.redirectClassNameForKey(); + }).then(() => { + return this.replaceSelect(); + }).then(() => { + return this.replaceDontSelect(); + }).then(() => { + return this.replaceInQuery(); + }).then(() => { + return this.replaceNotInQuery(); + }).then(() => { + return this.runFind(); + }).then(() => { + return this.runCount(); + }).then(() => { + return this.handleInclude(); + }).then(() => { + return this.response; + }); +}; + +// Uses the Auth object to get the list of roles, adds the user id +RestQuery.prototype.getUserAndRoleACL = function() { + if (this.auth.isMaster || !this.auth.user) { + return Promise.resolve(); + } + return this.auth.getUserRoles().then((roles) => { + roles.push(this.auth.user.id); + this.findOptions.acl = roles; + return Promise.resolve(); + }); +}; + +// Changes the className if redirectClassNameForKey is set. +// Returns a promise. +RestQuery.prototype.redirectClassNameForKey = function() { + if (!this.redirectKey) { + return Promise.resolve(); + } + + // We need to change the class name based on the schema + return this.config.database.redirectClassNameForKey( + this.className, this.redirectKey).then((newClassName) => { + this.className = newClassName; + this.redirectClassName = newClassName; + }); +}; + +// Replaces a $inQuery clause by running the subquery, if there is an +// $inQuery clause. +// The $inQuery clause turns into an $in with values that are just +// pointers to the objects returned in the subquery. +RestQuery.prototype.replaceInQuery = function() { + var inQueryObject = findObjectWithKey(this.restWhere, '$inQuery'); + if (!inQueryObject) { + return; + } + + // The inQuery value must have precisely two keys - where and className + var inQueryValue = inQueryObject['$inQuery']; + if (!inQueryValue.where || !inQueryValue.className) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'improper usage of $inQuery'); + } + + var subquery = new RestQuery( + this.config, this.auth, inQueryValue.className, + inQueryValue.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push({ + __type: 'Pointer', + className: inQueryValue.className, + objectId: result.objectId + }); + } + delete inQueryObject['$inQuery']; + inQueryObject['$in'] = values; + + // Recurse to repeat + return this.replaceInQuery(); + }); +}; + +// Replaces a $notInQuery clause by running the subquery, if there is an +// $notInQuery clause. +// The $notInQuery clause turns into a $nin with values that are just +// pointers to the objects returned in the subquery. +RestQuery.prototype.replaceNotInQuery = function() { + var notInQueryObject = findObjectWithKey(this.restWhere, '$notInQuery'); + if (!notInQueryObject) { + return; + } + + // The notInQuery value must have precisely two keys - where and className + var notInQueryValue = notInQueryObject['$notInQuery']; + if (!notInQueryValue.where || !notInQueryValue.className) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'improper usage of $notInQuery'); + } + + var subquery = new RestQuery( + this.config, this.auth, notInQueryValue.className, + notInQueryValue.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push({ + __type: 'Pointer', + className: notInQueryValue.className, + objectId: result.objectId + }); + } + delete notInQueryObject['$notInQuery']; + notInQueryObject['$nin'] = values; + + // Recurse to repeat + return this.replaceNotInQuery(); + }); +}; + +// Replaces a $select clause by running the subquery, if there is a +// $select clause. +// The $select clause turns into an $in with values selected out of +// the subquery. +// Returns a possible-promise. +RestQuery.prototype.replaceSelect = function() { + var selectObject = findObjectWithKey(this.restWhere, '$select'); + if (!selectObject) { + return; + } + + // The select value must have precisely two keys - query and key + var selectValue = selectObject['$select']; + 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'); + } + + var subquery = new RestQuery( + this.config, this.auth, selectValue.query.className, + selectValue.query.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push(result[selectValue.key]); + } + delete selectObject['$select']; + selectObject['$in'] = values; + + // Keep replacing $select clauses + return this.replaceSelect(); + }); +}; + +// Replaces a $dontSelect clause by running the subquery, if there is a +// $dontSelect clause. +// The $dontSelect clause turns into an $nin with values selected out of +// the subquery. +// Returns a possible-promise. +RestQuery.prototype.replaceDontSelect = function() { + var dontSelectObject = findObjectWithKey(this.restWhere, '$dontSelect'); + if (!dontSelectObject) { + return; + } + + // The dontSelect value must have precisely two keys - query and key + var dontSelectValue = dontSelectObject['$dontSelect']; + if (!dontSelectValue.query || + !dontSelectValue.key || + typeof dontSelectValue.query !== 'object' || + !dontSelectValue.query.className || + !dontSelectValue.query.where || + Object.keys(dontSelectValue).length !== 2) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'improper usage of $dontSelect'); + } + + var subquery = new RestQuery( + this.config, this.auth, dontSelectValue.query.className, + dontSelectValue.query.where); + return subquery.execute().then((response) => { + var values = []; + for (var result of response.results) { + values.push(result[dontSelectValue.key]); + } + delete dontSelectObject['$dontSelect']; + dontSelectObject['$nin'] = values; + + // Keep replacing $dontSelect clauses + return this.replaceDontSelect(); + }); +}; + +// Returns a promise for whether it was successful. +// Populates this.response with an object that only has 'results'. +RestQuery.prototype.runFind = function() { + return this.config.database.find( + this.className, this.restWhere, this.findOptions).then((results) => { + if (this.className == '_User') { + for (var result of results) { + delete result.password; + } + } + + updateParseFiles(this.config, results); + + if (this.keys) { + var keySet = this.keys; + results = results.map((object) => { + var newObject = {}; + for (var key in object) { + if (keySet.has(key)) { + newObject[key] = object[key]; + } + } + return newObject; + }); + } + + if (this.redirectClassName) { + for (var r of results) { + r.className = this.redirectClassName; + } + } + + this.response = {results: results}; + }); +}; + +// Returns a promise for whether it was successful. +// Populates this.response.count with the count +RestQuery.prototype.runCount = function() { + if (!this.doCount) { + return; + } + this.findOptions.count = true; + delete this.findOptions.skip; + return this.config.database.find( + this.className, this.restWhere, this.findOptions).then((c) => { + this.response.count = c; + }); +}; + +// Augments this.response with data at the paths provided in this.include. +RestQuery.prototype.handleInclude = function() { + if (this.include.length == 0) { + return; + } + + var pathResponse = includePath(this.config, this.auth, + this.response, this.include[0]); + if (pathResponse.then) { + return pathResponse.then((newResponse) => { + this.response = newResponse; + this.include = this.include.slice(1); + return this.handleInclude(); + }); + } + return pathResponse; +}; + +// Adds included values to the response. +// Path is a list of field names. +// Returns a promise for an augmented response. +function includePath(config, auth, response, path) { + var pointers = findPointers(response.results, path); + if (pointers.length == 0) { + return response; + } + var className = null; + var objectIds = {}; + for (var pointer of pointers) { + if (className === null) { + className = pointer.className; + } else { + if (className != pointer.className) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'inconsistent type data for include'); + } + } + objectIds[pointer.objectId] = true; + } + if (!className) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad pointers'); + } + + // Get the objects for all these object ids + var where = {'objectId': {'$in': Object.keys(objectIds)}}; + var query = new RestQuery(config, auth, className, where); + return query.execute().then((includeResponse) => { + var replace = {}; + for (var obj of includeResponse.results) { + obj.__type = 'Object'; + obj.className = className; + replace[obj.objectId] = obj; + } + var resp = { + results: replacePointers(response.results, path, replace) + }; + if (response.count) { + resp.count = response.count; + } + return resp; + }); +} + +// Object may be a list of REST-format object to find pointers in, or +// it may be a single object. +// If the path yields things that aren't pointers, this throws an error. +// Path is a list of fields to search into. +// Returns a list of pointers in REST format. +function findPointers(object, path) { + if (object instanceof Array) { + var answer = []; + for (x of object) { + answer = answer.concat(findPointers(x, path)); + } + return answer; + } + + if (typeof object !== 'object') { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'can only include pointer fields'); + } + + if (path.length == 0) { + if (object.__type == 'Pointer') { + return [object]; + } + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'can only include pointer fields'); + } + + var subobject = object[path[0]]; + if (!subobject) { + return []; + } + return findPointers(subobject, path.slice(1)); +} + +// Object may be a list of REST-format objects to replace pointers +// in, or it may be a single object. +// Path is a list of fields to search into. +// replace is a map from object id -> object. +// Returns something analogous to object, but with the appropriate +// pointers inflated. +function replacePointers(object, path, replace) { + if (object instanceof Array) { + return object.map((obj) => replacePointers(obj, path, replace)); + } + + if (typeof object !== 'object') { + return object; + } + + if (path.length == 0) { + if (object.__type == 'Pointer' && replace[object.objectId]) { + return replace[object.objectId]; + } + return object; + } + + var subobject = object[path[0]]; + if (!subobject) { + return object; + } + var newsub = replacePointers(subobject, path.slice(1), replace); + var answer = {}; + for (var key in object) { + if (key == path[0]) { + answer[key] = newsub; + } else { + answer[key] = object[key]; + } + } + return answer; +} + +// Find file references in REST-format object and adds the url key +// with the current mount point and app id +// Object may be a single object or list of REST-format objects +function updateParseFiles(config, object) { + if (object instanceof Array) { + object.map((obj) => updateParseFiles(config, obj)); + return; + } + if (typeof object !== 'object') { + return; + } + for (var key in object) { + if (object[key] && object[key]['__type'] && + object[key]['__type'] == 'File') { + var filename = object[key]['name']; + var encoded = encodeURIComponent(filename); + encoded = encoded.replace('%40', '@'); + if (filename.indexOf('tfss-') === 0) { + object[key]['url'] = 'http://files.parsetfss.com/' + + config.fileKey + '/' + encoded; + } else { + object[key]['url'] = config.mount + '/files/' + + config.applicationId + '/' + + encoded; + } + } + } +} + +// Finds a subobject that has the given key, if there is one. +// Returns undefined otherwise. +function findObjectWithKey(root, key) { + if (typeof root !== 'object') { + return; + } + if (root instanceof Array) { + for (var item of root) { + var answer = findObjectWithKey(item, key); + if (answer) { + return answer; + } + } + } + if (root && root[key]) { + return root; + } + for (var subkey in root) { + var answer = findObjectWithKey(root[subkey], key); + if (answer) { + return answer; + } + } +} + +module.exports = RestQuery; diff --git a/src/RestWrite.js b/src/RestWrite.js new file mode 100644 index 0000000000..4eb593cbcc --- /dev/null +++ b/src/RestWrite.js @@ -0,0 +1,721 @@ +// A RestWrite encapsulates everything we need to run an operation +// that writes to the database. +// This could be either a "create" or an "update". + +var crypto = require('crypto'); +var deepcopy = require('deepcopy'); +var rack = require('hat').rack(); + +var Auth = require('./Auth'); +var cache = require('./cache'); +var Config = require('./Config'); +var passwordCrypto = require('./password'); +var facebook = require('./facebook'); +var Parse = require('parse/node'); +var triggers = require('./triggers'); + +// query and data are both provided in REST API format. So data +// types are encoded by plain old objects. +// If query is null, this is a "create" and the data in data should be +// created. +// Otherwise this is an "update" - the object matching the query +// should get updated with data. +// RestWrite will handle objectId, createdAt, and updatedAt for +// everything. It also knows to use triggers and special modifications +// for the _User class. +function RestWrite(config, auth, className, query, data, originalData) { + this.config = config; + this.auth = auth; + this.className = className; + this.storage = {}; + + if (!query && data.objectId) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId ' + + 'is an invalid field name.'); + } + + // When the operation is complete, this.response may have several + // fields. + // response: the actual data to be returned + // status: the http status code. if not present, treated like a 200 + // location: the location header. if not present, no location header + this.response = null; + + // Processing this operation may mutate our data, so we operate on a + // copy + this.query = deepcopy(query); + this.data = deepcopy(data); + // We never change originalData, so we do not need a deep copy + this.originalData = originalData; + + // The timestamp we'll use for this whole operation + this.updatedAt = Parse._encode(new Date()).iso; + + if (this.data) { + // Add default fields + this.data.updatedAt = this.updatedAt; + if (!this.query) { + this.data.createdAt = this.updatedAt; + this.data.objectId = newObjectId(); + } + } +} + +// A convenient method to perform all the steps of processing the +// write, in order. +// Returns a promise for a {response, status, location} object. +// status and location are optional. +RestWrite.prototype.execute = function() { + return Promise.resolve().then(() => { + return this.validateSchema(); + }).then(() => { + return this.handleInstallation(); + }).then(() => { + return this.handleSession(); + }).then(() => { + return this.runBeforeTrigger(); + }).then(() => { + return this.validateAuthData(); + }).then(() => { + return this.transformUser(); + }).then(() => { + return this.runDatabaseOperation(); + }).then(() => { + return this.handleFollowup(); + }).then(() => { + return this.runAfterTrigger(); + }).then(() => { + return this.response; + }); +}; + +// Validates this operation against the schema. +RestWrite.prototype.validateSchema = function() { + return this.config.database.validateObject(this.className, this.data); +}; + +// Runs any beforeSave triggers against this operation. +// Any change leads to our data being mutated. +RestWrite.prototype.runBeforeTrigger = function() { + // Cloud code gets a bit of extra data for its objects + var extraData = {className: this.className}; + if (this.query && this.query.objectId) { + extraData.objectId = this.query.objectId; + } + // Build the inflated object, for a create write, originalData is empty + 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; + if (this.query && this.query.objectId) { + originalObject = triggers.inflate(extraData, this.originalData); + } + + return Promise.resolve().then(() => { + return triggers.maybeRunTrigger( + 'beforeSave', this.auth, inflatedObject, originalObject); + }).then((response) => { + if (response && response.object) { + this.data = response.object; + // We should delete the objectId for an update write + if (this.query && this.query.objectId) { + delete this.data.objectId; + } + } + }); +}; + +// Transforms auth data for a user object. +// Does nothing if this isn't a user object. +// Returns a promise for when we're done if it can't finish this tick. +RestWrite.prototype.validateAuthData = function() { + if (this.className !== '_User') { + return; + } + + if (!this.query && !this.data.authData) { + if (typeof this.data.username !== 'string') { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, + 'bad or missing username'); + } + if (typeof this.data.password !== 'string') { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, + 'password is required'); + } + } + + if (!this.data.authData) { + return; + } + + var facebookData = this.data.authData.facebook; + var anonData = this.data.authData.anonymous; + + if (anonData === null || + (anonData && anonData.id)) { + return this.handleAnonymousAuthData(); + } else if (facebookData === null || + (facebookData && facebookData.id && facebookData.access_token)) { + return this.handleFacebookAuthData(); + } else { + throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.'); + } +}; + +RestWrite.prototype.handleAnonymousAuthData = function() { + var anonData = this.data.authData.anonymous; + if (anonData === null && this.query) { + // We are unlinking the user from the anonymous provider + this.data._auth_data_anonymous = null; + return; + } + + // Check if this user already exists + return this.config.database.find( + this.className, + {'authData.anonymous.id': anonData.id}, {}) + .then((results) => { + if (results.length > 0) { + if (!this.query) { + // We're signing up, but this user already exists. Short-circuit + delete results[0].password; + this.response = { + response: results[0], + location: this.location() + }; + return; + } + + // If this is a PUT for the same user, allow the linking + if (results[0].objectId === this.query.objectId) { + // Delete the rest format key before saving + delete this.data.authData; + return; + } + + // We're trying to create a duplicate account. Forbid it + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used'); + } + + // This anonymous user does not already exist, so transform it + // to a saveable format + this.data._auth_data_anonymous = anonData; + + // Delete the rest format key before saving + delete this.data.authData; + }); + +}; + +RestWrite.prototype.handleFacebookAuthData = function() { + var facebookData = this.data.authData.facebook; + if (facebookData === null && this.query) { + // We are unlinking from Facebook. + this.data._auth_data_facebook = null; + return; + } + + return facebook.validateUserId(facebookData.id, + facebookData.access_token) + .then(() => { + return facebook.validateAppId(this.config.facebookAppIds, + facebookData.access_token); + }).then(() => { + // Check if this user already exists + // TODO: does this handle re-linking correctly? + return this.config.database.find( + this.className, + {'authData.facebook.id': facebookData.id}, {}); + }).then((results) => { + if (results.length > 0) { + if (!this.query) { + // We're signing up, but this user already exists. Short-circuit + delete results[0].password; + this.response = { + response: results[0], + location: this.location() + }; + return; + } + + // If this is a PUT for the same user, allow the linking + if (results[0].objectId === this.query.objectId) { + // Delete the rest format key before saving + delete this.data.authData; + return; + } + // We're trying to create a duplicate FB auth. Forbid it + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, + 'this auth is already used'); + } + + // This FB auth does not already exist, so transform it to a + // saveable format + this.data._auth_data_facebook = facebookData; + + // Delete the rest format key before saving + delete this.data.authData; + }); +}; + +// The non-third-party parts of User transformation +RestWrite.prototype.transformUser = function() { + if (this.response || this.className !== '_User') { + return; + } + + var promise = Promise.resolve(); + + if (!this.query) { + var token = 'r:' + rack(); + this.storage['token'] = token; + promise = promise.then(() => { + // TODO: Proper createdWith options, pass installationId + var sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: this.objectId() + }, + createdWith: { + 'action': 'login', + 'authProvider': 'password' + }, + restricted: false + }; + var create = new RestWrite(this.config, Auth.master(this.config), + '_Session', null, sessionData); + return create.execute(); + }); + } + + return promise.then(() => { + // Transform the password + if (!this.data.password) { + return; + } + if (this.query) { + this.storage['clearSessions'] = true; + } + return passwordCrypto.hash(this.data.password).then((hashedPassword) => { + this.data._hashed_password = hashedPassword; + delete this.data.password; + }); + + }).then(() => { + // Check for username uniqueness + if (!this.data.username) { + if (!this.query) { + // TODO: what's correct behavior here + this.data.username = ''; + } + return; + } + return this.config.database.find( + this.className, { + username: this.data.username, + objectId: {'$ne': this.objectId()} + }, {limit: 1}).then((results) => { + if (results.length > 0) { + throw new Parse.Error(Parse.Error.USERNAME_TAKEN, + 'Account already exists for this username'); + } + return Promise.resolve(); + }); + }).then(() => { + if (!this.data.email) { + return; + } + // Validate basic email address format + if (!this.data.email.match(/^.+@.+$/)) { + throw new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, + 'Email address format is invalid.'); + } + // Check for email uniqueness + return this.config.database.find( + this.className, { + email: this.data.email, + objectId: {'$ne': this.objectId()} + }, {limit: 1}).then((results) => { + if (results.length > 0) { + throw new Parse.Error(Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email ' + + 'address'); + } + return Promise.resolve(); + }); + }); +}; + +// Handles any followup logic +RestWrite.prototype.handleFollowup = function() { + if (this.storage && this.storage['clearSessions']) { + var sessionQuery = { + user: { + __type: 'Pointer', + className: '_User', + objectId: this.objectId() + } + }; + delete this.storage['clearSessions']; + return this.config.database.destroy('_Session', sessionQuery) + .then(this.handleFollowup); + } +}; + +// Handles the _Role class specialness. +// Does nothing if this isn't a role object. +RestWrite.prototype.handleRole = function() { + if (this.response || this.className !== '_Role') { + return; + } + + if (!this.auth.user && !this.auth.isMaster) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.'); + } + + if (!this.data.name) { + throw new Parse.Error(Parse.Error.INVALID_ROLE_NAME, + 'Invalid role name.'); + } +}; + +// Handles the _Session class specialness. +// Does nothing if this isn't an installation object. +RestWrite.prototype.handleSession = function() { + if (this.response || this.className !== '_Session') { + return; + } + + if (!this.auth.user && !this.auth.isMaster) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.'); + } + + // TODO: Verify proper error to throw + if (this.data.ACL) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Cannot set ' + + 'ACL on a Session.'); + } + + if (!this.query && !this.auth.isMaster) { + var token = 'r:' + rack(); + var sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: this.auth.user.id + }, + createdWith: { + 'action': 'create' + }, + restricted: true, + expiresAt: 0 + }; + for (var key in this.data) { + if (key == 'objectId') { + continue; + } + sessionData[key] = this.data[key]; + } + var create = new RestWrite(this.config, Auth.master(this.config), + '_Session', null, sessionData); + return create.execute().then((results) => { + if (!results.response) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, + 'Error creating session.'); + } + sessionData['objectId'] = results.response['objectId']; + this.response = { + status: 201, + location: results.location, + response: sessionData + }; + }); + } +}; + +// Handles the _Installation class specialness. +// Does nothing if this isn't an installation object. +// If an installation is found, this can mutate this.query and turn a create +// into an update. +// Returns a promise for when we're done if it can't finish this tick. +RestWrite.prototype.handleInstallation = function() { + if (this.response || this.className !== '_Installation') { + return; + } + + if (!this.query && !this.data.deviceToken && !this.data.installationId) { + throw new Parse.Error(135, + 'at least one ID field (deviceToken, installationId) ' + + 'must be specified in this operation'); + } + + if (!this.query && !this.data.deviceType) { + throw new Parse.Error(135, + 'deviceType must be specified in this operation'); + } + + // If the device token is 64 characters long, we assume it is for iOS + // and lowercase it. + if (this.data.deviceToken && this.data.deviceToken.length == 64) { + this.data.deviceToken = this.data.deviceToken.toLowerCase(); + } + + // TODO: We may need installationId from headers, plumb through Auth? + // per installation_handler.go + + // We lowercase the installationId if present + if (this.data.installationId) { + this.data.installationId = this.data.installationId.toLowerCase(); + } + + if (this.data.deviceToken && this.data.deviceType == 'android') { + throw new Parse.Error(114, + 'deviceToken may not be set for deviceType android'); + } + + var promise = Promise.resolve(); + + if (this.query && this.query.objectId) { + promise = promise.then(() => { + return this.config.database.find('_Installation', { + objectId: this.query.objectId + }, {}).then((results) => { + if (!results.length) { + 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) { + 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) { + 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) { + throw new Parse.Error(136, + 'deviceType may not be changed in this ' + + 'operation'); + } + return Promise.resolve(); + }); + }); + } + + // 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', { + 'installationId': this.data.installationId + }); + } + return Promise.resolve([]); + }).then((results) => { + if (results && results.length) { + // We only take the first match by installationId + installationMatch = results[0]; + } + if (this.data.deviceToken) { + return this.config.database.find( + '_Installation', + {'deviceToken': this.data.deviceToken}); + } + return Promise.resolve([]); + }).then((results) => { + if (results) { + deviceTokenMatches = results; + } + if (!installationMatch) { + if (!deviceTokenMatches.length) { + return; + } else if (deviceTokenMatches.length == 1 && + (!deviceTokenMatches[0]['installationId'] || !this.data.installationId) + ) { + // Single match on device token but none on installationId, and either + // the passed object or the match is missing an installationId, so we + // can just return the match. + return deviceTokenMatches[0]['objectId']; + } else if (!this.data.installationId) { + throw new Parse.Error(132, + 'Must specify installationId when deviceToken ' + + 'matches multiple Installation objects'); + } else { + // Multiple device token matches and we specified an installation ID, + // or a single match where both the passed and matching objects have + // an installation ID. Try cleaning out old installations that match + // the deviceToken, and return nil to signal that a new object should + // be created. + var delQuery = { + 'deviceToken': this.data.deviceToken, + 'installationId': { + '$ne': this.data.installationId + } + }; + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; + } + this.config.database.destroy('_Installation', delQuery); + return; + } + } else { + if (deviceTokenMatches.length == 1 && + !deviceTokenMatches[0]['installationId']) { + // 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}; + return this.config.database.destroy('_Installation', delQuery) + .then(() => { + return deviceTokenMatches[0]['objectId']; + }); + } else { + if (this.data.deviceToken && + installationMatch.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. + var delQuery = { + 'deviceToken': this.data.deviceToken, + 'installationId': { + '$ne': this.data.installationId + } + }; + if (this.data.appIdentifier) { + delQuery['appIdentifier'] = this.data.appIdentifier; + } + this.config.database.destroy('_Installation', delQuery); + } + // In non-merge scenarios, just return the installation match id + return installationMatch.objectId; + } + } + }).then((objId) => { + if (objId) { + this.query = {objectId: objId}; + delete this.data.objectId; + delete this.data.createdAt; + } + // TODO: Validate ops (add/remove on channels, $inc on badge, etc.) + }); + return promise; +}; + +RestWrite.prototype.runDatabaseOperation = function() { + if (this.response) { + return; + } + + if (this.className === '_User' && + this.query && + !this.auth.couldUpdateUserId(this.query.objectId)) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, + 'cannot modify user ' + this.objectId); + } + + // TODO: Add better detection for ACL, ensuring a user can't be locked from + // their own user record. + if (this.data.ACL && this.data.ACL['*unresolved']) { + throw new Parse.Error(Parse.Error.INVALID_ACL, 'Invalid ACL.'); + } + + var options = {}; + if (!this.auth.isMaster) { + options.acl = ['*']; + if (this.auth.user) { + options.acl.push(this.auth.user.id); + } + } + + if (this.query) { + // Run an update + return this.config.database.update( + this.className, this.query, this.data, options).then((resp) => { + this.response = resp; + this.response.updatedAt = this.updatedAt; + }); + } else { + // Run a create + return this.config.database.create(this.className, this.data, options) + .then(() => { + var resp = { + objectId: this.data.objectId, + createdAt: this.data.createdAt + }; + if (this.storage['token']) { + resp.sessionToken = this.storage['token']; + } + this.response = { + status: 201, + response: resp, + location: this.location() + }; + }); + } +}; + +// Returns nothing - doesn't wait for the trigger. +RestWrite.prototype.runAfterTrigger = function() { + 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; + if (this.query && this.query.objectId) { + originalObject = triggers.inflate(extraData, this.originalData); + } + + triggers.maybeRunTrigger('afterSave', this.auth, inflatedObject, originalObject); +}; + +// A helper to figure out what location this operation happens at. +RestWrite.prototype.location = function() { + var middle = (this.className === '_User' ? '/users/' : + '/classes/' + this.className + '/'); + return this.config.mount + middle + this.data.objectId; +}; + +// A helper to get the object id for this operation. +// Because it could be either on the query or on the data +RestWrite.prototype.objectId = function() { + return this.data.objectId || this.query.objectId; +}; + +// Returns a unique string that's usable as an object id. +function newObjectId() { + var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + 'abcdefghijklmnopqrstuvwxyz' + + '0123456789'); + var objectId = ''; + var bytes = crypto.randomBytes(10); + for (var i = 0; i < bytes.length; ++i) { + // Note: there is a slight modulo bias, because chars length + // of 62 doesn't divide the number of all bytes (256) evenly. + // It is acceptable for our purposes. + objectId += chars[bytes.readUInt8(i) % chars.length]; + } + return objectId; +} + +module.exports = RestWrite; diff --git a/src/S3Adapter.js b/src/S3Adapter.js new file mode 100644 index 0000000000..50a56d54ff --- /dev/null +++ b/src/S3Adapter.js @@ -0,0 +1,77 @@ +// S3Adapter +// +// Stores Parse files in AWS S3. + +var AWS = require('aws-sdk'); +var path = require('path'); + +var DEFAULT_REGION = 'us-east-1'; +var DEFAULT_BUCKET = 'parse-files'; + +// Creates an S3 session. +// Providing AWS access and secret keys is mandatory +// Region and bucket will use sane defaults if omitted +function S3Adapter(accessKey, secretKey, options) { + options = options || {}; + + this.region = options.region || DEFAULT_REGION; + this.bucket = options.bucket || DEFAULT_BUCKET; + this.bucketPrefix = options.bucketPrefix || ''; + this.directAccess = options.directAccess || false; + + s3Options = { + accessKeyId: accessKey, + secretAccessKey: secretKey, + params: {Bucket: this.bucket} + }; + AWS.config.region = this.region; + this.s3 = new AWS.S3(s3Options); +} + +// For a given config object, filename, and data, store a file in S3 +// Returns a promise containing the S3 object creation response +S3Adapter.prototype.create = function(config, filename, data) { + var params = { + Key: this.bucketPrefix + filename, + Body: data, + }; + if (this.directAccess) { + params.ACL = 'public-read'; + } + + return new Promise((resolve, reject) => { + this.s3.upload(params, (err, data) => { + if (err !== null) return reject(err); + resolve(data); + }); + }); +}; + +// Search for and return a file if found by filename +// Returns a promise that succeeds with the buffer result from S3 +S3Adapter.prototype.get = function(config, filename) { + var params = {Key: this.bucketPrefix + filename}; + + return new Promise((resolve, reject) => { + this.s3.getObject(params, (err, data) => { + if (err !== null) return reject(err); + resolve(data.Body); + }); + }); +}; + +// Generates and returns the location of a file stored in S3 for the given request and +// filename +// The location is the direct S3 link if the option is set, otherwise we serve +// the file through parse-server +S3Adapter.prototype.location = function(config, req, filename) { + if (this.directAccess) { + return ('https://' + this.bucket + '.s3.amazonaws.com' + '/' + + this.bucketPrefix + filename); + } + return (req.protocol + '://' + req.get('host') + + path.dirname(req.originalUrl) + '/' + req.config.applicationId + + '/' + encodeURIComponent(filename)); +}; + +module.exports = S3Adapter; diff --git a/Schema.js b/src/Schema.js similarity index 50% rename from Schema.js rename to src/Schema.js index c95444045e..76941671d8 100644 --- a/Schema.js +++ b/src/Schema.js @@ -24,108 +24,108 @@ var transform = require('./transform'); // '_metadata' is ignored for now // Everything else is expected to be a userspace field. function Schema(collection, mongoSchema) { - this.collection = collection; + this.collection = collection; // this.data[className][fieldName] tells you the type of that field - this.data = {}; + this.data = {}; // this.perms[className][operation] tells you the acl-style permissions - this.perms = {}; + this.perms = {}; - for (var obj of mongoSchema) { - var className = null; - var classData = {}; - var permsData = null; - for (var key in obj) { - var value = obj[key]; - switch(key) { - case '_id': - className = value; - break; - case '_metadata': - if (value && value['class_permissions']) { - permsData = value['class_permissions']; + for (var obj of mongoSchema) { + var className = null; + var classData = {}; + var permsData = null; + for (var key in obj) { + var value = obj[key]; + switch(key) { + case '_id': + className = value; + break; + case '_metadata': + if (value && value['class_permissions']) { + permsData = value['class_permissions']; + } + break; + default: + classData[key] = value; + } + } + if (className) { + this.data[className] = classData; + if (permsData) { + this.perms[className] = permsData; + } } - break; - default: - classData[key] = value; - } - } - if (className) { - this.data[className] = classData; - if (permsData) { - this.perms[className] = permsData; - } } - } } // Returns a promise for a new Schema. function load(collection) { - return collection.find({}, {}).toArray().then((mongoSchema) => { - return new Schema(collection, mongoSchema); - }); + return collection.find({}, {}).toArray().then((mongoSchema) => { + return new Schema(collection, mongoSchema); + }); } // Returns a new, reloaded schema. Schema.prototype.reload = function() { - return load(this.collection); + return load(this.collection); }; // Returns a promise that resolves successfully to the new schema // object. // If 'freeze' is true, refuse to update the schema. Schema.prototype.validateClassName = function(className, freeze) { - if (this.data[className]) { - return Promise.resolve(this); - } - if (freeze) { - throw new Parse.Error(Parse.Error.INVALID_JSON, + if (this.data[className]) { + return Promise.resolve(this); + } + if (freeze) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema is frozen, cannot add: ' + className); - } + } // We don't have this class. Update the schema - return this.collection.insert([{_id: className}]).then(() => { + return this.collection.insert([{_id: className}]).then(() => { // The schema update succeeded. Reload the schema - return this.reload(); - }, () => { + return this.reload(); + }, () => { // The schema update failed. This can be okay - it might // have failed because there's a race condition and a different // client is making the exact same schema update that we want. // So just reload the schema. - return this.reload(); - }).then((schema) => { + return this.reload(); + }).then((schema) => { // Ensure that the schema now validates - return schema.validateClassName(className, true); - }, (error) => { + return schema.validateClassName(className, true); + }, (error) => { // The schema still doesn't validate. Give up - throw new Parse.Error(Parse.Error.INVALID_JSON, + throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema class name does not revalidate'); - }); + }); }; // Returns whether the schema knows the type of all these keys. Schema.prototype.hasKeys = function(className, keys) { - for (var key of keys) { - if (!this.data[className] || !this.data[className][key]) { - return false; + for (var key of keys) { + if (!this.data[className] || !this.data[className][key]) { + return false; + } } - } - return true; + return true; }; // Sets the Class-level permissions for a given className, which must // exist. Schema.prototype.setPermissions = function(className, perms) { - var query = {_id: className}; - var update = { - _metadata: { - class_permissions: perms - } - }; - update = {'$set': update}; - return this.collection.findAndModify(query, {}, update, {}).then(() => { + var query = {_id: className}; + var update = { + _metadata: { + class_permissions: perms + } + }; + update = {'$set': update}; + return this.collection.findAndModify(query, {}, update, {}).then(() => { // The update succeeded. Reload the schema - return this.reload(); - }); + return this.reload(); + }); }; // Returns a promise that resolves successfully to the new schema @@ -134,141 +134,141 @@ Schema.prototype.setPermissions = function(className, perms) { // If 'freeze' is true, refuse to update the schema for this field. Schema.prototype.validateField = function(className, key, type, freeze) { // Just to check that the key is valid - transform.transformKey(this, className, key); + transform.transformKey(this, className, key); - var expected = this.data[className][key]; - if (expected) { - expected = (expected === 'map' ? 'object' : expected); - if (expected === type) { - return Promise.resolve(this); - } else { - throw new Parse.Error( + var expected = this.data[className][key]; + if (expected) { + expected = (expected === 'map' ? 'object' : expected); + if (expected === type) { + return Promise.resolve(this); + } else { + throw new Parse.Error( Parse.Error.INCORRECT_TYPE, 'schema mismatch for ' + className + '.' + key + '; expected ' + expected + ' but got ' + type); + } } - } - if (freeze) { - throw new Parse.Error(Parse.Error.INVALID_JSON, + if (freeze) { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema is frozen, cannot add ' + key + ' field'); - } + } // We don't have this field, but if the value is null or undefined, // we won't update the schema until we get a value with a type. - if (!type) { - return Promise.resolve(this); - } + if (!type) { + return Promise.resolve(this); + } - if (type === 'geopoint') { + if (type === 'geopoint') { // Make sure there are not other geopoint fields - for (var otherKey in this.data[className]) { - if (this.data[className][otherKey] === 'geopoint') { - throw new Parse.Error( + for (var otherKey in this.data[className]) { + if (this.data[className][otherKey] === 'geopoint') { + throw new Parse.Error( Parse.Error.INCORRECT_TYPE, 'there can only be one geopoint field in a class'); - } + } + } } - } // We don't have this field. Update the schema. // Note that we use the $exists guard and $set to avoid race // conditions in the database. This is important! - var query = {_id: className}; - query[key] = {'$exists': false}; - var update = {}; - update[key] = type; - update = {'$set': update}; - return this.collection.findAndModify(query, {}, update, {}).then(() => { + var query = {_id: className}; + query[key] = {'$exists': false}; + var update = {}; + update[key] = type; + update = {'$set': update}; + return this.collection.findAndModify(query, {}, update, {}).then(() => { // The update succeeded. Reload the schema - return this.reload(); - }, () => { + return this.reload(); + }, () => { // The update failed. This can be okay - it might have been a race // condition where another client updated the schema in the same // way that we wanted to. So, just reload the schema - return this.reload(); - }).then((schema) => { + return this.reload(); + }).then((schema) => { // Ensure that the schema now validates - return schema.validateField(className, key, type, true); - }, (error) => { + return schema.validateField(className, key, type, true); + }, (error) => { // The schema still doesn't validate. Give up - throw new Parse.Error(Parse.Error.INVALID_JSON, + throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema key will not revalidate'); - }); + }); }; // Given a schema promise, construct another schema promise that // validates this field once the schema loads. function thenValidateField(schemaPromise, className, key, type) { - return schemaPromise.then((schema) => { - return schema.validateField(className, key, type); - }); + return schemaPromise.then((schema) => { + return schema.validateField(className, key, type); + }); } // Validates an object provided in REST format. // Returns a promise that resolves to the new schema if this object is // valid. Schema.prototype.validateObject = function(className, object) { - var geocount = 0; - var promise = this.validateClassName(className); - for (var key in object) { - var expected = getType(object[key]); - if (expected === 'geopoint') { - geocount++; - } - if (geocount > 1) { - throw new Parse.Error( + var geocount = 0; + var promise = this.validateClassName(className); + for (var key in object) { + var expected = getType(object[key]); + if (expected === 'geopoint') { + geocount++; + } + if (geocount > 1) { + throw new Parse.Error( Parse.Error.INCORRECT_TYPE, 'there can only be one geopoint field in a class'); + } + if (!expected) { + continue; + } + promise = thenValidateField(promise, className, key, expected); } - if (!expected) { - continue; - } - promise = thenValidateField(promise, className, key, expected); - } - return promise; + return promise; }; // Validates an operation passes class-level-permissions set in the schema Schema.prototype.validatePermission = function(className, aclGroup, operation) { - if (!this.perms[className] || !this.perms[className][operation]) { - return Promise.resolve(); - } - var perms = this.perms[className][operation]; + if (!this.perms[className] || !this.perms[className][operation]) { + return Promise.resolve(); + } + var perms = this.perms[className][operation]; // Handle the public scenario quickly - if (perms['*']) { - return Promise.resolve(); - } + if (perms['*']) { + return Promise.resolve(); + } // Check permissions against the aclGroup provided (array of userId/roles) - var found = false; - for (var i = 0; i < aclGroup.length && !found; i++) { - if (perms[aclGroup[i]]) { - found = true; + var found = false; + for (var i = 0; i < aclGroup.length && !found; i++) { + if (perms[aclGroup[i]]) { + found = true; + } } - } - if (!found) { + if (!found) { // TODO: Verify correct error code - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Permission denied for this action.'); - } + } }; // Returns the expected type for a className+key combination // or undefined if the schema is not set Schema.prototype.getExpectedType = function(className, key) { - if (this.data && this.data[className]) { - return this.data[className][key]; - } - return undefined; + if (this.data && this.data[className]) { + return this.data[className][key]; + } + return undefined; }; // Helper function to check if a field is a pointer, returns true or false. Schema.prototype.isPointer = function(className, key) { - var expected = this.getExpectedType(className, key); - if (expected && expected.charAt(0) == '*') { - return true; - } - return false; + var expected = this.getExpectedType(className, key); + if (expected && expected.charAt(0) == '*') { + return true; + } + return false; }; // Gets the type from a REST API formatted object, where 'type' is @@ -277,73 +277,73 @@ Schema.prototype.isPointer = function(className, key) { // The output should be a valid schema value. // TODO: ensure that this is compatible with the format used in Open DB function getType(obj) { - var type = typeof obj; - switch(type) { - case 'boolean': - case 'string': - case 'number': - return type; - case 'map': - case 'object': - if (!obj) { - return undefined; + var type = typeof obj; + switch(type) { + case 'boolean': + case 'string': + case 'number': + return type; + case 'map': + case 'object': + if (!obj) { + return undefined; + } + return getObjectType(obj); + case 'function': + case 'symbol': + case 'undefined': + default: + throw 'bad obj: ' + obj; } - return getObjectType(obj); - case 'function': - case 'symbol': - case 'undefined': - default: - throw 'bad obj: ' + obj; - } } // This gets the type for non-JSON types like pointers and files, but // also gets the appropriate type for $ operators. // Returns null if the type is unknown. function getObjectType(obj) { - if (obj instanceof Array) { - return 'array'; - } - if (obj.__type === 'Pointer' && obj.className) { - return '*' + obj.className; - } - if (obj.__type === 'File' && obj.url && obj.name) { - return 'file'; - } - if (obj.__type === 'Date' && obj.iso) { - return 'date'; - } - if (obj.__type == 'GeoPoint' && + if (obj instanceof Array) { + return 'array'; + } + if (obj.__type === 'Pointer' && obj.className) { + return '*' + obj.className; + } + if (obj.__type === 'File' && obj.url && 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['$ne']) { - return getObjectType(obj['$ne']); - } - if (obj.__op) { - switch(obj.__op) { - case 'Increment': - return 'number'; - case 'Delete': - return null; - case 'Add': - case 'AddUnique': - case 'Remove': - return 'array'; - case 'AddRelation': - case 'RemoveRelation': - return 'relation<' + obj.objects[0].className + '>'; - case 'Batch': - return getObjectType(obj.ops[0]); - default: - throw 'unexpected op: ' + obj.__op; + return 'geopoint'; + } + if (obj['$ne']) { + return getObjectType(obj['$ne']); + } + if (obj.__op) { + switch(obj.__op) { + case 'Increment': + return 'number'; + case 'Delete': + return null; + case 'Add': + case 'AddUnique': + case 'Remove': + return 'array'; + case 'AddRelation': + case 'RemoveRelation': + return 'relation<' + obj.objects[0].className + '>'; + case 'Batch': + return getObjectType(obj.ops[0]); + default: + throw 'unexpected op: ' + obj.__op; + } } - } - return 'object'; + return 'object'; } module.exports = { - load: load + load: load }; diff --git a/analytics.js b/src/analytics.js similarity index 87% rename from analytics.js rename to src/analytics.js index 7294837f6a..d58f2564ec 100644 --- a/analytics.js +++ b/src/analytics.js @@ -9,9 +9,9 @@ var router = new PromiseRouter(); // Returns a promise that resolves to an empty object response function ignoreAndSucceed(req) { - return Promise.resolve({ - response: {} - }); + return Promise.resolve({ + response: {} + }); } router.route('POST','/events/AppOpened', ignoreAndSucceed); diff --git a/src/batch.js b/src/batch.js new file mode 100644 index 0000000000..8a2accef71 --- /dev/null +++ b/src/batch.js @@ -0,0 +1,72 @@ +var Parse = require('parse/node').Parse; + +// These methods handle batch requests. +var batchPath = '/batch'; + +// Mounts a batch-handler onto a PromiseRouter. +function mountOnto(router) { + router.route('POST', batchPath, (req) => { + return handleBatch(router, req); + }); +} + +// Returns a promise for a {response} object. +// TODO: pass along auth correctly +function handleBatch(router, req) { + if (!req.body.requests instanceof Array) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'requests must be an array'); + } + + // The batch paths are all from the root of our domain. + // That means they include the API prefix, that the API is mounted + // to. However, our promise router does not route the api prefix. So + // we need to figure out the API prefix, so that we can strip it + // from all the subrequests. + if (!req.originalUrl.endsWith(batchPath)) { + throw 'internal routing problem - expected url to end with batch'; + } + var apiPrefixLength = req.originalUrl.length - batchPath.length; + var apiPrefix = req.originalUrl.slice(0, apiPrefixLength); + + var promises = []; + for (var restRequest of req.body.requests) { + // The routablePath is the path minus the api prefix + if (restRequest.path.slice(0, apiPrefixLength) != apiPrefix) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'cannot route batch path ' + restRequest.path); + } + var routablePath = restRequest.path.slice(apiPrefixLength); + + // Use the router to figure out what handler to use + var match = router.match(restRequest.method, routablePath); + if (!match) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'cannot route ' + restRequest.method + ' ' + routablePath); + } + + // Construct a request that we can send to a handler + var request = { + body: restRequest.body, + params: match.params, + config: req.config, + auth: req.auth + }; + + promises.push(match.handler(request).then((response) => { + return {success: response.response}; + }, (error) => { + return {error: {code: error.code, error: error.message}}; + })); + } + + return Promise.all(promises).then((results) => { + return {response: results}; + }); +} + +module.exports = { + mountOnto: mountOnto +}; diff --git a/src/cache.js b/src/cache.js new file mode 100644 index 0000000000..86115ddad2 --- /dev/null +++ b/src/cache.js @@ -0,0 +1,37 @@ +var apps = {}; +var stats = {}; +var isLoaded = false; +var users = {}; + +function getApp(app, callback) { + if (apps[app]) return callback(true, apps[app]); + return callback(false); +} + +function updateStat(key, value) { + stats[key] = value; +} + +function getUser(sessionToken) { + if (users[sessionToken]) return users[sessionToken]; + return undefined; +} + +function setUser(sessionToken, userObject) { + users[sessionToken] = userObject; +} + +function clearUser(sessionToken) { + delete users[sessionToken]; +} + +module.exports = { + apps: apps, + stats: stats, + isLoaded: isLoaded, + getApp: getApp, + updateStat: updateStat, + clearUser: clearUser, + getUser: getUser, + setUser: setUser +}; diff --git a/classes.js b/src/classes.js similarity index 55% rename from classes.js rename to src/classes.js index dc33eab09f..7c28c198a3 100644 --- a/classes.js +++ b/src/classes.js @@ -10,76 +10,76 @@ var router = new PromiseRouter(); // Returns a promise that resolves to a {response} object. function handleFind(req) { - var body = Object.assign(req.body, req.query); - var options = {}; - if (body.skip) { - options.skip = Number(body.skip); - } - if (body.limit) { - options.limit = Number(body.limit); - } - if (body.order) { - options.order = String(body.order); - } - if (body.count) { - options.count = true; - } - if (typeof body.keys == 'string') { - options.keys = body.keys; - } - if (body.include) { - options.include = String(body.include); - } - if (body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(body.redirectClassNameForKey); - } + var body = Object.assign(req.body, req.query); + var options = {}; + if (body.skip) { + options.skip = Number(body.skip); + } + if (body.limit) { + options.limit = Number(body.limit); + } + if (body.order) { + options.order = String(body.order); + } + if (body.count) { + options.count = true; + } + if (typeof body.keys == 'string') { + options.keys = body.keys; + } + if (body.include) { + options.include = String(body.include); + } + if (body.redirectClassNameForKey) { + options.redirectClassNameForKey = String(body.redirectClassNameForKey); + } - if(typeof body.where === 'string') { - body.where = JSON.parse(body.where); - } + if(typeof body.where === 'string') { + body.where = JSON.parse(body.where); + } - return rest.find(req.config, req.auth, + return rest.find(req.config, req.auth, req.params.className, body.where, options) .then((response) => { - return {response: response}; + return {response: response}; }); } // Returns a promise for a {status, response, location} object. function handleCreate(req) { - return rest.create(req.config, req.auth, + return rest.create(req.config, req.auth, req.params.className, req.body); } // Returns a promise for a {response} object. function handleGet(req) { - return rest.find(req.config, req.auth, + return rest.find(req.config, req.auth, req.params.className, {objectId: req.params.objectId}) .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } else { - return {response: response.results[0]}; - } + } else { + return {response: response.results[0]}; + } }); } // Returns a promise for a {response} object. function handleDelete(req) { - return rest.del(req.config, req.auth, + return rest.del(req.config, req.auth, req.params.className, req.params.objectId) .then(() => { - return {response: {}}; + return {response: {}}; }); } // Returns a promise for a {response} object. function handleUpdate(req) { - return rest.update(req.config, req.auth, + return rest.update(req.config, req.auth, req.params.className, req.params.objectId, req.body) .then((response) => { - return {response: response}; + return {response: response}; }); } diff --git a/src/facebook.js b/src/facebook.js new file mode 100644 index 0000000000..27d24d5368 --- /dev/null +++ b/src/facebook.js @@ -0,0 +1,57 @@ +// Helper functions for accessing the Facebook Graph API. +var https = require('https'); +var Parse = require('parse/node').Parse; + +// Returns a promise that fulfills iff this user id is valid. +function validateUserId(userId, access_token) { + return graphRequest('me?fields=id&access_token=' + access_token) + .then((data) => { + if (data && data.id == userId) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId(appIds, access_token) { + if (!appIds.length) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is not configured.'); + } + return graphRequest('app?access_token=' + access_token) + .then((data) => { + if (data && appIds.indexOf(data.id) != -1) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Facebook auth is invalid for this user.'); + }); +} + +// A promisey wrapper for FB graph requests. +function graphRequest(path) { + return new Promise(function(resolve, reject) { + https.get('https://graph.facebook.com/v2.5/' + path, function(res) { + var data = ''; + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + data = JSON.parse(data); + resolve(data); + }); + }).on('error', function(e) { + reject('Failed to validate this access token with Facebook.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateUserId: validateUserId +}; diff --git a/src/files.js b/src/files.js new file mode 100644 index 0000000000..f47874e4f9 --- /dev/null +++ b/src/files.js @@ -0,0 +1,85 @@ +// files.js + +var bodyParser = require('body-parser'), + Config = require('./Config'), + express = require('express'), + FilesAdapter = require('./FilesAdapter'), + middlewares = require('./middlewares.js'), + mime = require('mime'), + Parse = require('parse/node').Parse, + rack = require('hat').rack(); + +var router = express.Router(); + +var processCreate = function(req, res, next) { + if (!req.body || !req.body.length) { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, + 'Invalid file upload.')); + return; + } + + if (req.params.filename.length > 128) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename too long.')); + return; + } + + if (!req.params.filename.match(/^[_a-zA-Z0-9][a-zA-Z0-9@\.\ ~_-]*$/)) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename contains invalid characters.')); + return; + } + + // If a content-type is included, we'll add an extension so we can + // return the same content-type. + var extension = ''; + var hasExtension = req.params.filename.indexOf('.') > 0; + var contentType = req.get('Content-type'); + if (!hasExtension && contentType && mime.extension(contentType)) { + extension = '.' + mime.extension(contentType); + } + + var filename = rack() + '_' + req.params.filename + extension; + FilesAdapter.getAdapter().create(req.config, filename, req.body) + .then(() => { + res.status(201); + var location = FilesAdapter.getAdapter().location(req.config, req, filename); + res.set('Location', location); + res.json({ url: location, name: filename }); + }).catch((error) => { + next(new Parse.Error(Parse.Error.FILE_SAVE_ERROR, + 'Could not store file.')); + }); +}; + +var processGet = function(req, res) { + var config = new Config(req.params.appId); + FilesAdapter.getAdapter().get(config, req.params.filename) + .then((data) => { + res.status(200); + var contentType = mime.lookup(req.params.filename); + res.set('Content-type', contentType); + res.end(data); + }).catch((error) => { + res.status(404); + res.set('Content-type', 'text/plain'); + res.end('File not found.'); + }); +}; + +router.get('/files/:appId/:filename', processGet); + +router.post('/files', function(req, res, next) { + next(new Parse.Error(Parse.Error.INVALID_FILE_NAME, + 'Filename not provided.')); +}); + +// TODO: do we need to allow crossdomain and method override? +router.post('/files/:filename', + bodyParser.raw({type: '*/*', limit: '20mb'}), + middlewares.handleParseHeaders, + processCreate); + +module.exports = { + router: router +}; diff --git a/src/functions.js b/src/functions.js new file mode 100644 index 0000000000..b3eda65784 --- /dev/null +++ b/src/functions.js @@ -0,0 +1,43 @@ +// functions.js + +var express = require('express'), + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + +function handleCloudFunction(req) { + // TODO: set user from req.auth + if (Parse.Cloud.Functions[req.params.functionName]) { + return new Promise(function (resolve, reject) { + var response = createResponseObject(resolve, reject); + var request = { + params: req.body || {} + }; + Parse.Cloud.Functions[req.params.functionName](request, response); + }); + } else { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid function.'); + } +} + +function createResponseObject(resolve, reject) { + return { + success: function(result) { + resolve({ + response: { + result: result + } + }); + }, + error: function(error) { + reject(new Parse.Error(Parse.Error.SCRIPT_FAILED, error)); + } + }; +} + +router.route('POST', '/functions/:functionName', handleCloudFunction); + + +module.exports = router; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000000..192cabd6a5 --- /dev/null +++ b/src/index.js @@ -0,0 +1,185 @@ +// ParseServer - open-source compatible API Server for Parse apps + +var batch = require('./batch'), + bodyParser = require('body-parser'), + cache = require('./cache'), + DatabaseAdapter = require('./DatabaseAdapter'), + express = require('express'), + FilesAdapter = require('./FilesAdapter'), + S3Adapter = require('./S3Adapter'), + middlewares = require('./middlewares'), + multer = require('multer'), + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + request = require('request'); + +// 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, +// update, and delete +// "filesAdapter": a class like GridStoreAdapter providing create, get, +// and delete +// "databaseURI": a uri like mongodb://localhost:27017/dbname to tell us +// what database this Parse API connects to. +// "cloud": relative location to cloud code to require +// "appId": the application id to host +// "masterKey": the master key for requests to this app +// "facebookAppIds": an array of valid Facebook Application IDs, required +// if using Facebook login +// "collectionPrefix": optional prefix for database collection names +// "fileKey": optional key from Parse dashboard for supporting older files +// hosted by Parse +// "clientKey": optional key from Parse dashboard +// "dotNetKey": optional key from Parse dashboard +// "restAPIKey": optional key from Parse dashboard +// "javascriptKey": optional key from Parse dashboard +function ParseServer(args) { + if (!args.appId || !args.masterKey) { + throw 'You must provide an appId and masterKey!'; + } + + if (args.databaseAdapter) { + DatabaseAdapter.setAdapter(args.databaseAdapter); + } + if (args.filesAdapter) { + FilesAdapter.setAdapter(args.filesAdapter); + } + if (args.databaseURI) { + DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); + } + if (args.cloud) { + addParseCloud(); + require(args.cloud); + } + + cache.apps[args.appId] = { + masterKey: args.masterKey, + collectionPrefix: args.collectionPrefix || '', + clientKey: args.clientKey || '', + javascriptKey: args.javascriptKey || '', + dotNetKey: args.dotNetKey || '', + restAPIKey: args.restAPIKey || '', + fileKey: args.fileKey || 'invalid-file-key', + facebookAppIds: args.facebookAppIds || [] + }; + + // To maintain compatibility. TODO: Remove in v2.1 + if (process.env.FACEBOOK_APP_ID) { + cache.apps[args.appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); + } + + // Initialize the node client SDK automatically + Parse.initialize(args.appId, args.javascriptKey || '', args.masterKey); + + // 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(); + + // File handling needs to be before default middlewares are applied + api.use('/', require('./files').router); + + // TODO: separate this from the regular ParseServer object + if (process.env.TESTING == 1) { + console.log('enabling integration testing-routes'); + api.use('/', require('./testing-routes').router); + } + + api.use(bodyParser.json({ 'type': '*/*' })); + api.use(middlewares.allowCrossDomain); + api.use(middlewares.allowMethodOverride); + api.use(middlewares.handleParseHeaders); + + var router = new PromiseRouter(); + + router.merge(require('./classes')); + router.merge(require('./users')); + router.merge(require('./sessions')); + router.merge(require('./roles')); + router.merge(require('./analytics')); + router.merge(require('./push')); + router.merge(require('./installations')); + router.merge(require('./functions')); + + batch.mountOnto(router); + + router.mountOnto(api); + + api.use(middlewares.handleParseErrors); + + return api; +} + +function addParseCloud() { + Parse.Cloud.Functions = {}; + Parse.Cloud.Triggers = { + beforeSave: {}, + beforeDelete: {}, + afterSave: {}, + afterDelete: {} + }; + Parse.Cloud.define = function(functionName, handler) { + Parse.Cloud.Functions[functionName] = handler; + }; + Parse.Cloud.beforeSave = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.beforeSave[className] = handler; + }; + Parse.Cloud.beforeDelete = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.beforeDelete[className] = handler; + }; + Parse.Cloud.afterSave = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.afterSave[className] = handler; + }; + Parse.Cloud.afterDelete = function(parseClass, handler) { + var className = getClassName(parseClass); + Parse.Cloud.Triggers.afterDelete[className] = handler; + }; + Parse.Cloud.httpRequest = function(options) { + var promise = new Parse.Promise(); + var callbacks = { + success: options.success, + error: options.error + }; + delete options.success; + delete options.error; + if (options.uri && !options.url) { + options.uri = options.url; + delete options.url; + } + if (typeof options.body === 'object') { + options.body = JSON.stringify(options.body); + } + request(options, (error, response, body) => { + if (error) { + if (callbacks.error) { + return callbacks.error(error); + } + return promise.reject(error); + } else { + if (callbacks.success) { + return callbacks.success(body); + } + return promise.resolve(body); + } + }); + return promise; + }; + global.Parse = Parse; +} + +function getClassName(parseClass) { + if (parseClass && parseClass.className) { + return parseClass.className; + } + return parseClass; +} + +module.exports = { + ParseServer: ParseServer, + S3Adapter: S3Adapter +}; diff --git a/installations.js b/src/installations.js similarity index 59% rename from installations.js rename to src/installations.js index 517c3b812e..d79fb4052d 100644 --- a/installations.js +++ b/src/installations.js @@ -9,65 +9,65 @@ var router = new PromiseRouter(); // Returns a promise for a {status, response, location} object. function handleCreate(req) { - return rest.create(req.config, + return rest.create(req.config, req.auth, '_Installation', req.body); } // Returns a promise that resolves to a {response} object. function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (req.body.include) { - options.include = String(req.body.include); - } + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (req.body.include) { + options.include = String(req.body.include); + } - return rest.find(req.config, req.auth, + return rest.find(req.config, req.auth, '_Installation', req.body.where, options) .then((response) => { - return {response: response}; + return {response: response}; }); } // Returns a promise for a {response} object. function handleGet(req) { - return rest.find(req.config, req.auth, '_Installation', + return rest.find(req.config, req.auth, '_Installation', {objectId: req.params.objectId}) .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } else { - return {response: response.results[0]}; - } + } else { + return {response: response.results[0]}; + } }); } // Returns a promise for a {response} object. function handleUpdate(req) { - return rest.update(req.config, req.auth, + return rest.update(req.config, req.auth, '_Installation', req.params.objectId, req.body) .then((response) => { - return {response: response}; + return {response: response}; }); } // Returns a promise for a {response} object. function handleDelete(req) { - return rest.del(req.config, req.auth, + return rest.del(req.config, req.auth, '_Installation', req.params.objectId) .then(() => { - return {response: {}}; + return {response: {}}; }); } diff --git a/src/middlewares.js b/src/middlewares.js new file mode 100644 index 0000000000..caa5130676 --- /dev/null +++ b/src/middlewares.js @@ -0,0 +1,192 @@ +var Parse = require('parse/node').Parse; + +var auth = require('./Auth'); +var cache = require('./cache'); +var Config = require('./Config'); + +// Checks that the request is authorized for this app and checks user +// auth too. +// The bodyparser should run before this middleware. +// Adds info to the request: +// req.config - the Config for this app +// req.auth - the Auth for this request +function handleParseHeaders(req, res, next) { + var mountPathLength = req.originalUrl.length - req.url.length; + var mountPath = req.originalUrl.slice(0, mountPathLength); + var mount = req.protocol + '://' + req.get('host') + mountPath; + + var info = { + appId: req.get('X-Parse-Application-Id'), + sessionToken: req.get('X-Parse-Session-Token'), + masterKey: req.get('X-Parse-Master-Key'), + installationId: req.get('X-Parse-Installation-Id'), + clientKey: req.get('X-Parse-Client-Key'), + javascriptKey: req.get('X-Parse-Javascript-Key'), + dotNetKey: req.get('X-Parse-Windows-Key'), + restAPIKey: req.get('X-Parse-REST-API-Key') + }; + + var fileViaJSON = false; + + if (!info.appId || !cache.apps[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 + // upload that actually is a JSON body. So try to parse it. + req.body = JSON.parse(req.body); + fileViaJSON = true; + } + + if (req.body && req.body._ApplicationId + && cache.apps[req.body._ApplicationId] + && ( + !info.masterKey + || + cache.apps[req.body._ApplicationId]['masterKey'] === info.masterKey) + ) { + info.appId = req.body._ApplicationId; + info.javascriptKey = req.body._JavaScriptKey || ''; + delete req.body._ApplicationId; + delete req.body._JavaScriptKey; + // TODO: test that the REST API formats generated by the other + // SDKs are handled ok + if (req.body._ClientVersion) { + info.clientVersion = req.body._ClientVersion; + delete req.body._ClientVersion; + } + if (req.body._InstallationId) { + info.installationId = req.body._InstallationId; + delete req.body._InstallationId; + } + if (req.body._SessionToken) { + info.sessionToken = req.body._SessionToken; + delete req.body._SessionToken; + } + if (req.body._MasterKey) { + info.masterKey = req.body._MasterKey; + delete req.body._MasterKey; + } + } else { + return invalidRequest(req, res); + } + } + + if (fileViaJSON) { + // We need to repopulate req.body with a buffer + var base64 = req.body.base64; + req.body = new Buffer(base64, 'base64'); + } + + info.app = cache.apps[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); + next(); + return; + } + + // Client keys are not required in parse-server, but if any have been configured in the server, validate them + // to preserve original behavior. + var keyRequired = (req.config.clientKey + || req.config.javascriptKey + || req.config.dotNetKey + || req.config.restAPIKey); + var keyHandled = false; + if (keyRequired + && ((info.clientKey && req.config.clientKey && info.clientKey === req.config.clientKey) + || (info.javascriptKey && req.config.javascriptKey && info.javascriptKey === req.config.javascriptKey) + || (info.dotNetKey && req.config.dotNetKey && info.dotNetKey === req.config.dotNetKey) + || (info.restAPIKey && req.config.restAPIKey && info.restAPIKey === req.config.restAPIKey) + )) { + keyHandled = true; + } + if (keyRequired && !keyHandled) { + return invalidRequest(req, res); + } + + if (!info.sessionToken) { + req.auth = new auth.Auth(req.config, false); + next(); + return; + } + + return auth.getAuthForSessionToken( + req.config, info.sessionToken).then((auth) => { + if (auth) { + req.auth = auth; + next(); + } + }).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) { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); + res.header('Access-Control-Allow-Headers', '*'); + + // intercept OPTIONS method + if ('OPTIONS' == req.method) { + res.send(200); + } + else { + next(); + } +}; + +var allowMethodOverride = function(req, res, next) { + if (req.method === 'POST' && req.body._method) { + req.originalMethod = req.method; + req.method = req.body._method; + delete req.body._method; + } + next(); +}; + +var handleParseErrors = function(err, req, res, next) { + if (err instanceof Parse.Error) { + var httpStatus; + + // TODO: fill out this mapping + switch (err.code) { + case Parse.Error.INTERNAL_SERVER_ERROR: + httpStatus = 500; + break; + case Parse.Error.OBJECT_NOT_FOUND: + httpStatus = 404; + break; + default: + httpStatus = 400; + } + + res.status(httpStatus); + res.json({code: err.code, error: err.message}); + } else { + console.log('Uncaught internal server error.', err, err.stack); + res.status(500); + res.json({code: Parse.Error.INTERNAL_SERVER_ERROR, + message: 'Internal server error.'}); + } +}; + +function invalidRequest(req, res) { + res.status(403); + res.end('{"error":"unauthorized"}'); +} + + +module.exports = { + allowCrossDomain: allowCrossDomain, + allowMethodOverride: allowMethodOverride, + handleParseErrors: handleParseErrors, + handleParseHeaders: handleParseHeaders +}; diff --git a/src/password.js b/src/password.js new file mode 100644 index 0000000000..849068ac29 --- /dev/null +++ b/src/password.js @@ -0,0 +1,35 @@ +// Tools for encrypting and decrypting passwords. +// Basically promise-friendly wrappers for bcrypt. +var bcrypt = require('bcrypt-nodejs'); + +// Returns a promise for a hashed password string. +function hash(password) { + return new Promise(function(fulfill, reject) { + bcrypt.hash(password, null, null, function(err, hashedPassword) { + if (err) { + reject(err); + } else { + fulfill(hashedPassword); + } + }); + }); +} + +// Returns a promise for whether this password compares to equal this +// hashed password. +function compare(password, hashedPassword) { + return new Promise(function(fulfill, reject) { + bcrypt.compare(password, hashedPassword, function(err, success) { + if (err) { + reject(err); + } else { + fulfill(success); + } + }); + }); +} + +module.exports = { + hash: hash, + compare: compare +}; diff --git a/push.js b/src/push.js similarity index 85% rename from push.js rename to src/push.js index 08a192c474..e05841e7ec 100644 --- a/push.js +++ b/src/push.js @@ -9,7 +9,7 @@ var router = new PromiseRouter(); function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); } diff --git a/src/rest.js b/src/rest.js new file mode 100644 index 0000000000..47833e826e --- /dev/null +++ b/src/rest.js @@ -0,0 +1,129 @@ +// This file contains helpers for running operations in REST format. +// The goal is that handlers that explicitly handle an express route +// should just be shallow wrappers around things in this file, but +// these functions should not explicitly depend on the request +// object. +// This means that one of these handlers can support multiple +// routes. That's useful for the routes that do really similar +// things. + +var Parse = require('parse/node').Parse; + +var cache = require('./cache'); +var RestQuery = require('./RestQuery'); +var RestWrite = require('./RestWrite'); +var triggers = require('./triggers'); + +// Returns a promise for an object with optional keys 'results' and 'count'. +function find(config, auth, className, restWhere, restOptions) { + enforceRoleSecurity('find', className, auth); + var query = new RestQuery(config, auth, className, + restWhere, restOptions); + return query.execute(); +} + +// Returns a promise that doesn't resolve to any useful value. +function del(config, auth, className, objectId) { + if (typeof objectId !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad objectId'); + } + + if (className === '_User' && !auth.couldUpdateUserId(objectId)) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, + 'insufficient auth to delete user'); + } + + enforceRoleSecurity('delete', className, auth); + + var inflatedObject; + + return Promise.resolve().then(() => { + if (triggers.getTrigger(className, 'beforeDelete') || + triggers.getTrigger(className, 'afterDelete') || + className == '_Session') { + return find(config, auth, className, {objectId: objectId}) + .then((response) => { + if (response && response.results && response.results.length) { + response.results[0].className = className; + cache.clearUser(response.results[0].sessionToken); + inflatedObject = Parse.Object.fromJSON(response.results[0]); + return triggers.maybeRunTrigger('beforeDelete', + auth, inflatedObject); + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found for delete.'); + }); + } + return Promise.resolve({}); + }).then(() => { + var options = {}; + if (!auth.isMaster) { + options.acl = ['*']; + if (auth.user) { + options.acl.push(auth.user.id); + } + } + + return config.database.destroy(className, { + objectId: objectId + }, options); + }).then(() => { + triggers.maybeRunTrigger('afterDelete', auth, inflatedObject); + return Promise.resolve(); + }); +} + +// Returns a promise for a {response, status, location} object. +function create(config, auth, className, restObject) { + enforceRoleSecurity('create', className, auth); + + var write = new RestWrite(config, auth, className, null, restObject); + return write.execute(); +} + +// Returns a promise that contains the fields of the update that the +// REST API is supposed to return. +// Usually, this is just updatedAt. +function update(config, auth, className, objectId, restObject) { + enforceRoleSecurity('update', className, auth); + + return Promise.resolve().then(() => { + if (triggers.getTrigger(className, 'beforeSave') || + triggers.getTrigger(className, 'afterSave')) { + return find(config, auth, className, {objectId: objectId}); + } + return Promise.resolve({}); + }).then((response) => { + var originalRestObject; + if (response && response.results && response.results.length) { + originalRestObject = response.results[0]; + } + + var write = new RestWrite(config, auth, className, + {objectId: objectId}, restObject, originalRestObject); + return write.execute(); + }); +} + +// Disallowing access to the _Role collection except by master key +function enforceRoleSecurity(method, className, auth) { + if (className === '_Role' && !auth.isMaster) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + 'Clients aren\'t allowed to perform the ' + + method + ' operation on the role collection.'); + } + if (method === 'delete' && className === '_Installation' && !auth.isMaster) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + 'Clients aren\'t allowed to perform the ' + + 'delete operation on the installation collection.'); + + } +} + +module.exports = { + create: create, + del: del, + find: find, + update: update +}; diff --git a/roles.js b/src/roles.js similarity index 65% rename from roles.js rename to src/roles.js index 6aaf806526..4bb977fb4f 100644 --- a/roles.js +++ b/src/roles.js @@ -7,36 +7,36 @@ var Parse = require('parse/node').Parse, var router = new PromiseRouter(); function handleCreate(req) { - return rest.create(req.config, req.auth, + return rest.create(req.config, req.auth, '_Role', req.body); } function handleUpdate(req) { - return rest.update(req.config, req.auth, '_Role', + return rest.update(req.config, req.auth, '_Role', req.params.objectId, req.body) .then((response) => { - return {response: response}; + return {response: response}; }); } function handleDelete(req) { - return rest.del(req.config, req.auth, + return rest.del(req.config, req.auth, '_Role', req.params.objectId) .then(() => { - return {response: {}}; + return {response: {}}; }); } function handleGet(req) { - return rest.find(req.config, req.auth, '_Role', + return rest.find(req.config, req.auth, '_Role', {objectId: req.params.objectId}) .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); - } else { - return {response: response.results[0]}; - } + } else { + return {response: response.results[0]}; + } }); } diff --git a/src/sessions.js b/src/sessions.js new file mode 100644 index 0000000000..092c793956 --- /dev/null +++ b/src/sessions.js @@ -0,0 +1,122 @@ +// sessions.js + +var Auth = require('./Auth'), + Parse = require('parse/node').Parse, + PromiseRouter = require('./PromiseRouter'), + rest = require('./rest'); + +var router = new PromiseRouter(); + +function handleCreate(req) { + return rest.create(req.config, req.auth, + '_Session', req.body); +} + +function handleUpdate(req) { + return rest.update(req.config, req.auth, '_Session', + req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); +} + +function handleDelete(req) { + return rest.del(req.config, req.auth, + '_Session', req.params.objectId) + .then(() => { + return {response: {}}; + }); +} + +function handleGet(req) { + return rest.find(req.config, req.auth, '_Session', + {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + return {response: response.results[0]}; + } + }); +} + +function handleLogout(req) { + // TODO: Verify correct behavior for logout without token + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.SESSION_MISSING, + 'Session token required for logout.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token not found.'); + } + return rest.del(req.config, Auth.master(req.config), '_Session', + response.results[0].objectId); + }).then(() => { + return { + status: 200, + response: {} + }; + }); +} + +function handleFind(req) { + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (typeof req.body.keys == 'string') { + options.keys = req.body.keys; + } + if (req.body.include) { + options.include = String(req.body.include); + } + + return rest.find(req.config, req.auth, + '_Session', req.body.where, options) + .then((response) => { + return {response: response}; + }); +} + +function handleMe(req) { + // TODO: Verify correct behavior + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token not found.'); + } + return { + response: response.results[0] + }; + }); +} + +router.route('POST', '/logout', handleLogout); +router.route('POST','/sessions', handleCreate); +router.route('GET','/sessions/me', handleMe); +router.route('GET','/sessions/:objectId', handleGet); +router.route('PUT','/sessions/:objectId', handleUpdate); +router.route('GET','/sessions', handleFind); +router.route('DELETE','/sessions/:objectId', handleDelete); + +module.exports = router; \ No newline at end of file diff --git a/testing-routes.js b/src/testing-routes.js similarity index 60% rename from testing-routes.js rename to src/testing-routes.js index 85db148516..c0ee96fbd1 100644 --- a/testing-routes.js +++ b/src/testing-routes.js @@ -9,47 +9,47 @@ var router = express.Router(); // creates a unique app in the cache, with a collection prefix function createApp(req, res) { - var appId = rack(); - cache.apps[appId] = { - 'collectionPrefix': appId + '_', - 'masterKey': 'master' - }; - var keys = { - 'application_id': appId, - 'client_key': 'unused', - 'windows_key': 'unused', - 'javascript_key': 'unused', - 'webhook_key': 'unused', - 'rest_api_key': 'unused', - 'master_key': 'master' - }; - res.status(200).send(keys); + var appId = rack(); + cache.apps[appId] = { + 'collectionPrefix': appId + '_', + 'masterKey': 'master' + }; + var keys = { + 'application_id': appId, + 'client_key': 'unused', + 'windows_key': 'unused', + 'javascript_key': 'unused', + 'webhook_key': 'unused', + 'rest_api_key': 'unused', + 'master_key': 'master' + }; + res.status(200).send(keys); } // deletes all collections with the collectionPrefix of the app function clearApp(req, res) { - if (!req.auth.isMaster) { - return res.status(401).send({"error": "unauthorized"}); - } - req.database.deleteEverything().then(() => { - res.status(200).send({}); - }); + if (!req.auth.isMaster) { + return res.status(401).send({'error': 'unauthorized'}); + } + req.database.deleteEverything().then(() => { + res.status(200).send({}); + }); } // deletes all collections and drops the app from cache 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]; - res.status(200).send({}); - }); + if (!req.auth.isMaster) { + return res.status(401).send({'error': 'unauthorized'}); + } + req.database.deleteEverything().then(() => { + delete cache.apps[req.config.applicationId]; + res.status(200).send({}); + }); } // Lets just return a success response and see what happens. function notImplementedYet(req, res) { - res.status(200).send({}); + res.status(200).send({}); } router.post('/rest_clear_app', @@ -69,5 +69,5 @@ router.post('/rest_configure_app', middlewares.handleParseHeaders, notImplementedYet); module.exports = { - router: router + router: router }; \ No newline at end of file diff --git a/src/transform.js b/src/transform.js new file mode 100644 index 0000000000..b0ecd50345 --- /dev/null +++ b/src/transform.js @@ -0,0 +1,732 @@ +var mongodb = require('mongodb'); +var Parse = require('parse/node').Parse; + +// TODO: Turn this into a helper library for the database adapter. + +// Transforms a key-value pair from REST API form to Mongo form. +// This is the main entry point for converting anything from REST form +// to Mongo form; no conversion should happen that doesn't pass +// through this function. +// Schema should already be loaded. +// +// There are several options that can help transform: +// +// query: true indicates that query constraints like $lt are allowed in +// the value. +// +// update: true indicates that __op operators like Add and Delete +// in the value are converted to a mongo update form. Otherwise they are +// converted to static data. +// +// validate: true indicates that key names are to be validated. +// +// Returns an object with {key: key, value: value}. +function transformKeyValue(schema, className, restKey, restValue, options) { + options = options || {}; + + // Check if the schema is known since it's a built-in field. + var key = restKey; + var timeField = false; + switch(key) { + case 'objectId': + case '_id': + key = '_id'; + break; + case 'createdAt': + case '_created_at': + key = '_created_at'; + timeField = true; + break; + case 'updatedAt': + case '_updated_at': + key = '_updated_at'; + timeField = true; + break; + case 'sessionToken': + case '_session_token': + key = '_session_token'; + break; + case 'expiresAt': + case '_expiresAt': + key = '_expiresAt'; + timeField = true; + break; + case '_rperm': + case '_wperm': + return {key: key, value: restValue}; + break; + case 'authData.anonymous.id': + if (options.query) { + return {key: '_auth_data_anonymous.id', value: restValue}; + } + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'can only query on ' + key); + break; + case 'authData.facebook.id': + if (options.query) { + // Special-case auth data. + return {key: '_auth_data_facebook.id', value: restValue}; + } + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'can only query on ' + key); + break; + case '$or': + if (!options.query) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'you can only use $or in queries'); + } + if (!(restValue instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'bad $or format - use an array value'); + } + var mongoSubqueries = restValue.map((s) => { + return transformWhere(schema, className, s); + }); + return {key: '$or', value: mongoSubqueries}; + case '$and': + if (!options.query) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'you can only use $and in queries'); + } + if (!(restValue instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'bad $and format - use an array value'); + } + var mongoSubqueries = restValue.map((s) => { + return transformWhere(schema, className, s); + }); + return {key: '$and', value: mongoSubqueries}; + default: + if (options.validate && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { + throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, + 'invalid key name: ' + key); + } + } + + // Handle special schema key changes + // TODO: it seems like this is likely to have edge cases where + // pointer types are missed + var expected = undefined; + if (schema && schema.getExpectedType) { + expected = schema.getExpectedType(className, key); + } + if ((expected && expected[0] == '*') || + (!expected && restValue && restValue.__type == 'Pointer')) { + key = '_p_' + key; + } + var inArray = (expected === 'array'); + + // Handle query constraints + if (options.query) { + value = transformConstraint(restValue, inArray); + if (value !== CannotTransform) { + return {key: key, value: value}; + } + } + + if (inArray && options.query && !(restValue instanceof Array)) { + return { + key: key, value: [restValue] + }; + } + + // Handle atomic values + var value = transformAtom(restValue, false, options); + if (value !== CannotTransform) { + if (timeField && (typeof value === 'string')) { + value = new Date(value); + } + return {key: key, value: value}; + } + + // ACLs are handled before this method is called + // If an ACL key still exists here, something is wrong. + if (key === 'ACL') { + throw 'There was a problem transforming an ACL.'; + } + + + + // Handle arrays + if (restValue instanceof Array) { + if (options.query) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'cannot use array as query param'); + } + value = restValue.map((restObj) => { + var out = transformKeyValue(schema, className, restKey, restObj, { inArray: true }); + return out.value; + }); + return {key: key, value: value}; + } + + // Handle update operators + value = transformUpdateOperator(restValue, !options.update); + if (value !== CannotTransform) { + return {key: key, value: value}; + } + + // Handle normal objects by recursing + value = {}; + for (var subRestKey in restValue) { + var subRestValue = restValue[subRestKey]; + var out = transformKeyValue(schema, className, subRestKey, subRestValue, { inObject: true }); + // For recursed objects, keep the keys in rest format + value[subRestKey] = out.value; + } + return {key: key, value: value}; +} + + +// Main exposed method to help run queries. +// restWhere is the "where" clause in REST API form. +// Returns the mongo form of the query. +// Throws a Parse.Error if the input query is invalid. +function transformWhere(schema, className, restWhere) { + var mongoWhere = {}; + if (restWhere['ACL']) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'Cannot query on ACL.'); + } + for (var restKey in restWhere) { + var out = transformKeyValue(schema, className, restKey, restWhere[restKey], + {query: true, validate: true}); + mongoWhere[out.key] = out.value; + } + return mongoWhere; +} + +// Main exposed method to create new objects. +// restCreate is the "create" clause in REST API form. +// Returns the mongo form of the object. +function transformCreate(schema, className, restCreate) { + var mongoCreate = transformACL(restCreate); + for (var restKey in restCreate) { + var out = transformKeyValue(schema, className, restKey, restCreate[restKey]); + if (out.value !== undefined) { + mongoCreate[out.key] = out.value; + } + } + return mongoCreate; +} + +// Main exposed method to help update old objects. +function transformUpdate(schema, className, restUpdate) { + if (!restUpdate) { + throw 'got empty restUpdate'; + } + var mongoUpdate = {}; + var acl = transformACL(restUpdate); + if (acl._rperm || acl._wperm) { + mongoUpdate['$set'] = {}; + if (acl._rperm) { + mongoUpdate['$set']['_rperm'] = acl._rperm; + } + if (acl._wperm) { + mongoUpdate['$set']['_wperm'] = acl._wperm; + } + } + + for (var restKey in restUpdate) { + var out = transformKeyValue(schema, className, restKey, restUpdate[restKey], + {update: true}); + + // If the output value is an object with any $ keys, it's an + // operator that needs to be lifted onto the top level update + // object. + if (typeof out.value === 'object' && out.value !== null && + out.value.__op) { + mongoUpdate[out.value.__op] = mongoUpdate[out.value.__op] || {}; + mongoUpdate[out.value.__op][out.key] = out.value.arg; + } else { + mongoUpdate['$set'] = mongoUpdate['$set'] || {}; + mongoUpdate['$set'][out.key] = out.value; + } + } + + return mongoUpdate; +} + +// Transforms a REST API formatted ACL object to our two-field mongo format. +// This mutates the restObject passed in to remove the ACL key. +function transformACL(restObject) { + var output = {}; + if (!restObject['ACL']) { + return output; + } + var acl = restObject['ACL']; + var rperm = []; + var wperm = []; + for (var entry in acl) { + if (acl[entry].read) { + rperm.push(entry); + } + if (acl[entry].write) { + wperm.push(entry); + } + } + if (rperm.length) { + output._rperm = rperm; + } + if (wperm.length) { + output._wperm = wperm; + } + delete restObject.ACL; + return output; +} + +// Transforms a mongo format ACL to a REST API format ACL key +// This mutates the mongoObject passed in to remove the _rperm/_wperm keys +function untransformACL(mongoObject) { + var output = {}; + if (!mongoObject['_rperm'] && !mongoObject['_wperm']) { + return output; + } + var acl = {}; + var rperm = mongoObject['_rperm'] || []; + var wperm = mongoObject['_wperm'] || []; + rperm.map((entry) => { + if (!acl[entry]) { + acl[entry] = {read: true}; + } else { + acl[entry]['read'] = true; + } + }); + wperm.map((entry) => { + if (!acl[entry]) { + acl[entry] = {write: true}; + } else { + acl[entry]['write'] = true; + } + }); + output['ACL'] = acl; + delete mongoObject._rperm; + delete mongoObject._wperm; + return output; +} + +// Transforms a key used in the REST API format to its mongo format. +function transformKey(schema, className, key) { + return transformKeyValue(schema, className, key, null, {validate: true}).key; +} + +// A sentinel value that helper transformations return when they +// cannot perform a transformation +function CannotTransform() {} + +// Helper function to transform an atom from REST format to Mongo format. +// An atom is anything that can't contain other expressions. So it +// includes things where objects are used to represent other +// datatypes, like pointers and dates, but it does not include objects +// or arrays with generic stuff inside. +// If options.inArray is true, we'll leave it in REST format. +// If options.inObject is true, we'll leave files in REST format. +// Raises an error if this cannot possibly be valid REST format. +// Returns CannotTransform if it's just not an atom, or if force is +// true, throws an error. +function transformAtom(atom, force, options) { + options = options || {}; + var inArray = options.inArray; + var inObject = options.inObject; + switch(typeof atom) { + case 'string': + case 'number': + case 'boolean': + return atom; + + case 'undefined': + case 'symbol': + case 'function': + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'cannot transform value: ' + atom); + + case 'object': + if (atom instanceof Date) { + // Technically dates are not rest format, but, it seems pretty + // clear what they should be transformed to, so let's just do it. + return atom; + } + + if (atom === null) { + return atom; + } + + // TODO: check validity harder for the __type-defined types + if (atom.__type == 'Pointer') { + if (!inArray && !inObject) { + return atom.className + '$' + atom.objectId; + } + return { + __type: 'Pointer', + className: atom.className, + objectId: atom.objectId + }; + } + if (atom.__type == 'Date') { + return new Date(atom.iso); + } + if (atom.__type == 'GeoPoint') { + return [atom.longitude, atom.latitude]; + } + if (atom.__type == 'Bytes') { + return new mongodb.Binary(new Buffer(atom.base64, 'base64')); + } + if (atom.__type == 'File') { + if (!inArray && !inObject) { + return atom.name; + } + return atom; + } + + if (force) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad atom: ' + atom); + } + return CannotTransform; + + default: + // I don't think typeof can ever let us get here + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, + 'really did not expect value: ' + atom); + } +} + +// Transforms a query constraint from REST API format to Mongo format. +// A constraint is something with fields like $lt. +// If it is not a valid constraint but it could be a valid something +// else, return CannotTransform. +// inArray is whether this is an array field. +function transformConstraint(constraint, inArray) { + if (typeof constraint !== 'object' || !constraint) { + return CannotTransform; + } + + // keys is the constraints in reverse alphabetical order. + // This is a hack so that: + // $regex is handled before $options + // $nearSphere is handled before $maxDistance + var keys = Object.keys(constraint).sort().reverse(); + var answer = {}; + for (var key of keys) { + switch(key) { + case '$lt': + case '$lte': + case '$gt': + case '$gte': + case '$exists': + case '$ne': + answer[key] = transformAtom(constraint[key], true, + {inArray: inArray}); + break; + + case '$in': + case '$nin': + var arr = constraint[key]; + if (!(arr instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad ' + key + ' value'); + } + answer[key] = arr.map((v) => { + return transformAtom(v, true); + }); + break; + + case '$all': + var arr = constraint[key]; + if (!(arr instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'bad ' + key + ' value'); + } + answer[key] = arr.map((v) => { + return transformAtom(v, true, { inArray: true }); + }); + break; + + case '$regex': + var s = constraint[key]; + if (typeof s !== 'string') { + throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad regex: ' + s); + } + answer[key] = s; + break; + + case '$options': + var options = constraint[key]; + if (!answer['$regex'] || (typeof options !== 'string') + || !options.match(/^[imxs]+$/)) { + throw new Parse.Error(Parse.Error.INVALID_QUERY, + 'got a bad $options'); + } + answer[key] = options; + break; + + case '$nearSphere': + var point = constraint[key]; + answer[key] = [point.longitude, point.latitude]; + break; + + case '$maxDistance': + answer[key] = constraint[key]; + break; + + // The SDKs don't seem to use these but they are documented in the + // REST API docs. + case '$maxDistanceInRadians': + answer['$maxDistance'] = constraint[key]; + break; + case '$maxDistanceInMiles': + answer['$maxDistance'] = constraint[key] / 3959; + break; + case '$maxDistanceInKilometers': + answer['$maxDistance'] = constraint[key] / 6371; + break; + + case '$select': + case '$dontSelect': + throw new Parse.Error( + Parse.Error.COMMAND_UNAVAILABLE, + 'the ' + key + ' constraint is not supported yet'); + + case '$within': + var box = constraint[key]['$box']; + if (!box || box.length != 2) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'malformatted $within arg'); + } + answer[key] = { + '$box': [ + [box[0].longitude, box[0].latitude], + [box[1].longitude, box[1].latitude] + ] + }; + break; + + default: + if (key.match(/^\$+/)) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + 'bad constraint: ' + key); + } + return CannotTransform; + } + } + return answer; +} + +// Transforms an update operator from REST format to mongo format. +// To be transformed, the input should have an __op field. +// If flatten is true, this will flatten operators to their static +// data format. For example, an increment of 2 would simply become a +// 2. +// The output for a non-flattened operator is a hash with __op being +// the mongo op, and arg being the argument. +// The output for a flattened operator is just a value. +// Returns CannotTransform if this cannot transform it. +// Returns undefined if this should be a no-op. +function transformUpdateOperator(operator, flatten) { + if (typeof operator !== 'object' || !operator.__op) { + return CannotTransform; + } + + switch(operator.__op) { + case 'Delete': + if (flatten) { + return undefined; + } else { + return {__op: '$unset', arg: ''}; + } + + case 'Increment': + if (typeof operator.amount !== 'number') { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'incrementing must provide a number'); + } + if (flatten) { + return operator.amount; + } else { + return {__op: '$inc', arg: operator.amount}; + } + + case 'Add': + case 'AddUnique': + if (!(operator.objects instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'objects to add must be an array'); + } + var toAdd = operator.objects.map((obj) => { + return transformAtom(obj, true, { inArray: true }); + }); + if (flatten) { + return toAdd; + } else { + var mongoOp = { + Add: '$push', + AddUnique: '$addToSet' + }[operator.__op]; + return {__op: mongoOp, arg: {'$each': toAdd}}; + } + + case 'Remove': + if (!(operator.objects instanceof Array)) { + throw new Parse.Error(Parse.Error.INVALID_JSON, + 'objects to remove must be an array'); + } + var toRemove = operator.objects.map((obj) => { + return transformAtom(obj, true, { inArray: true }); + }); + if (flatten) { + return []; + } else { + return {__op: '$pullAll', arg: toRemove}; + } + + default: + throw new Parse.Error( + Parse.Error.COMMAND_UNAVAILABLE, + 'the ' + operator.__op + ' op is not supported yet'); + } +} + + +// Converts from a mongo-format object to a REST-format object. +// Does not strip out anything based on a lack of authentication. +function untransformObject(schema, className, mongoObject) { + switch(typeof mongoObject) { + case 'string': + case 'number': + case 'boolean': + return mongoObject; + case 'undefined': + case 'symbol': + case 'function': + throw 'bad value in untransformObject'; + case 'object': + if (mongoObject === null) { + return null; + } + + if (mongoObject instanceof Array) { + return mongoObject.map((o) => { + return untransformObject(schema, className, o); + }); + } + + if (mongoObject instanceof Date) { + return Parse._encode(mongoObject); + } + + if (mongoObject instanceof mongodb.Binary) { + return { + __type: 'Bytes', + base64: mongoObject.buffer.toString('base64') + }; + } + + var restObject = untransformACL(mongoObject); + for (var key in mongoObject) { + switch(key) { + case '_id': + restObject['objectId'] = '' + mongoObject[key]; + break; + case '_hashed_password': + restObject['password'] = mongoObject[key]; + break; + case '_acl': + case '_email_verify_token': + case '_perishable_token': + break; + case '_session_token': + restObject['sessionToken'] = mongoObject[key]; + break; + case 'updatedAt': + case '_updated_at': + restObject['updatedAt'] = Parse._encode(new Date(mongoObject[key])).iso; + break; + case 'createdAt': + case '_created_at': + restObject['createdAt'] = Parse._encode(new Date(mongoObject[key])).iso; + break; + case 'expiresAt': + case '_expiresAt': + restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])).iso; + break; + case '_auth_data_anonymous': + restObject['authData'] = restObject['authData'] || {}; + restObject['authData']['anonymous'] = mongoObject[key]; + break; + case '_auth_data_facebook': + restObject['authData'] = restObject['authData'] || {}; + restObject['authData']['facebook'] = mongoObject[key]; + break; + default: + if (key.indexOf('_p_') == 0) { + var newKey = key.substring(3); + var expected; + if (schema && schema.getExpectedType) { + expected = schema.getExpectedType(className, newKey); + } + if (!expected) { + console.log( + 'Found a pointer column not in the schema, dropping it.', + className, newKey); + break; + } + if (expected && expected[0] != '*') { + console.log('Found a pointer in a non-pointer column, dropping it.', className, key); + break; + } + if (mongoObject[key] === null) { + break; + } + var objData = mongoObject[key].split('$'); + var newClass = (expected ? expected.substring(1) : objData[0]); + if (objData[0] !== newClass) { + throw 'pointer to incorrect className'; + } + restObject[newKey] = { + __type: 'Pointer', + className: objData[0], + objectId: objData[1] + }; + break; + } else if (key[0] == '_' && key != '__type') { + throw ('bad key in untransform: ' + key); + //} else if (mongoObject[key] === null) { + //break; + } else { + var expected = schema.getExpectedType(className, key); + if (expected == 'file' && mongoObject[key]) { + restObject[key] = { + __type: 'File', + name: mongoObject[key] + }; + break; + } + if (expected == 'geopoint') { + restObject[key] = { + __type: 'GeoPoint', + latitude: mongoObject[key][1], + longitude: mongoObject[key][0] + }; + break; + } + } + restObject[key] = untransformObject(schema, className, + mongoObject[key]); + } + } + return restObject; + default: + throw 'unknown js type'; + } +} + +module.exports = { + transformKey: transformKey, + transformCreate: transformCreate, + transformUpdate: transformUpdate, + transformWhere: transformWhere, + untransformObject: untransformObject +}; + diff --git a/src/triggers.js b/src/triggers.js new file mode 100644 index 0000000000..841d3851eb --- /dev/null +++ b/src/triggers.js @@ -0,0 +1,99 @@ +// triggers.js + +var Parse = require('parse/node').Parse; + +var Types = { + beforeSave: 'beforeSave', + afterSave: 'afterSave', + beforeDelete: 'beforeDelete', + afterDelete: 'afterDelete' +}; + +var getTrigger = function(className, triggerType) { + if (Parse.Cloud.Triggers + && Parse.Cloud.Triggers[triggerType] + && Parse.Cloud.Triggers[triggerType][className]) { + return Parse.Cloud.Triggers[triggerType][className]; + } + return undefined; +}; + +var getRequestObject = function(triggerType, auth, parseObject, originalParseObject) { + var request = { + triggerName: triggerType, + object: parseObject, + master: false + }; + if (originalParseObject) { + request.original = originalParseObject; + } + if (!auth) { + return request; + } + if (auth.isMaster) { + request['master'] = true; + } + 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 +// transform them to Parse.Object instances expected by Cloud Code. +// Any changes made to the object in a beforeSave will be included. +var getResponseObject = function(request, resolve, reject) { + return { + success: function() { + var response = {}; + if (request.triggerName === Types.beforeSave) { + response['object'] = request.object.toJSON(); + } + return resolve(response); + }, + error: function(error) { + throw new Parse.Error(Parse.Error.SCRIPT_FAILED, error); + } + }; +}; + +// To be used as part of the promise chain when saving/deleting an object +// Will resolve successfully if no trigger is configured +// Resolves to an object, empty or containing an object key. A beforeSave +// trigger will set the object key to the rest format object to save. +// originalParseObject is optional, we only need that for befote/afterSave functions +var maybeRunTrigger = function(triggerType, auth, parseObject, originalParseObject) { + if (!parseObject) { + return Promise.resolve({}); + } + return new Promise(function (resolve, reject) { + var trigger = getTrigger(parseObject.className, triggerType); + if (!trigger) return resolve({}); + var request = getRequestObject(triggerType, auth, parseObject, originalParseObject); + var response = getResponseObject(request, resolve, reject); + trigger(request, response); + }); +}; + +// Converts a REST-format object to a Parse.Object +// data is either className or an object +function inflate(data, restObject) { + var copy = typeof data == 'object' ? data : {className: data}; + for (var key in restObject) { + copy[key] = restObject[key]; + } + return Parse.Object.fromJSON(copy); +} + +module.exports = { + getTrigger: getTrigger, + getRequestObject: getRequestObject, + inflate: inflate, + maybeRunTrigger: maybeRunTrigger, + Types: Types +}; diff --git a/src/users.js b/src/users.js new file mode 100644 index 0000000000..1e685c277b --- /dev/null +++ b/src/users.js @@ -0,0 +1,187 @@ +// These methods handle the User-related routes. + +var mongodb = require('mongodb'); +var Parse = require('parse/node').Parse; +var rack = require('hat').rack(); + +var Auth = require('./Auth'); +var passwordCrypto = require('./password'); +var facebook = require('./facebook'); +var PromiseRouter = require('./PromiseRouter'); +var rest = require('./rest'); +var RestWrite = require('./RestWrite'); + +var router = new PromiseRouter(); + +// Returns a promise for a {status, response, location} object. +function handleCreate(req) { + return rest.create(req.config, req.auth, + '_User', req.body); +} + +// Returns a promise for a {response} object. +function handleLogIn(req) { + + // Use query parameters instead if provided in url + if (!req.body.username && req.query.username) { + req.body = req.query; + } + + // TODO: use the right error codes / descriptions. + if (!req.body.username) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, + 'username is required.'); + } + if (!req.body.password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, + 'password is required.'); + } + + var user; + return req.database.find('_User', {username: req.body.username}) + .then((results) => { + if (!results.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.'); + } + user = results[0]; + return passwordCrypto.compare(req.body.password, user.password); + }).then((correct) => { + if (!correct) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username/password.'); + } + var token = 'r:' + rack(); + user.sessionToken = token; + delete user.password; + + var expiresAt = new Date(); + expiresAt.setFullYear(expiresAt.getFullYear() + 1); + + var sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: user.objectId + }, + createdWith: { + 'action': 'login', + 'authProvider': 'password' + }, + restricted: false, + expiresAt: Parse._encode(expiresAt) + }; + + if (req.info.installationId) { + sessionData.installationId = req.info.installationId; + } + + var create = new RestWrite(req.config, Auth.master(req.config), + '_Session', null, sessionData); + return create.execute(); + }).then(() => { + return {response: user}; + }); +} + +// Returns a promise that resolves to a {response} object. +// TODO: share code with classes.js +function handleFind(req) { + var options = {}; + if (req.body.skip) { + options.skip = Number(req.body.skip); + } + if (req.body.limit) { + options.limit = Number(req.body.limit); + } + if (req.body.order) { + options.order = String(req.body.order); + } + if (req.body.count) { + options.count = true; + } + if (typeof req.body.keys == 'string') { + options.keys = req.body.keys; + } + if (req.body.include) { + options.include = String(req.body.include); + } + if (req.body.redirectClassNameForKey) { + options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); + } + + return rest.find(req.config, req.auth, + '_User', req.body.where, options) + .then((response) => { + return {response: response}; + }); + +} + +// Returns a promise for a {response} object. +function handleGet(req) { + return rest.find(req.config, req.auth, '_User', + {objectId: req.params.objectId}) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + return {response: response.results[0]}; + } + }); +} + +function handleMe(req) { + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + {_session_token: req.info.sessionToken}, + {include: 'user'}) + .then((response) => { + if (!response.results || response.results.length == 0 || + !response.results[0].user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + var user = response.results[0].user; + return {response: user}; + } + }); +} + +function handleDelete(req) { + return rest.del(req.config, req.auth, + req.params.className, req.params.objectId) + .then(() => { + return {response: {}}; + }); +} + +function handleUpdate(req) { + return rest.update(req.config, req.auth, '_User', + req.params.objectId, req.body) + .then((response) => { + return {response: response}; + }); +} + +function notImplementedYet(req) { + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, + 'This path is not implemented yet.'); +} + +router.route('POST', '/users', handleCreate); +router.route('GET', '/login', handleLogIn); +router.route('GET', '/users/me', handleMe); +router.route('GET', '/users/:objectId', handleGet); +router.route('PUT', '/users/:objectId', handleUpdate); +router.route('GET', '/users', handleFind); +router.route('DELETE', '/users/:objectId', handleDelete); + +router.route('POST', '/requestPasswordReset', notImplementedYet); + +module.exports = router; diff --git a/transform.js b/transform.js deleted file mode 100644 index 0285e837c7..0000000000 --- a/transform.js +++ /dev/null @@ -1,732 +0,0 @@ -var mongodb = require('mongodb'); -var Parse = require('parse/node').Parse; - -// TODO: Turn this into a helper library for the database adapter. - -// Transforms a key-value pair from REST API form to Mongo form. -// This is the main entry point for converting anything from REST form -// to Mongo form; no conversion should happen that doesn't pass -// through this function. -// Schema should already be loaded. -// -// There are several options that can help transform: -// -// query: true indicates that query constraints like $lt are allowed in -// the value. -// -// update: true indicates that __op operators like Add and Delete -// in the value are converted to a mongo update form. Otherwise they are -// converted to static data. -// -// validate: true indicates that key names are to be validated. -// -// Returns an object with {key: key, value: value}. -function transformKeyValue(schema, className, restKey, restValue, options) { - options = options || {}; - - // Check if the schema is known since it's a built-in field. - var key = restKey; - var timeField = false; - switch(key) { - case 'objectId': - case '_id': - key = '_id'; - break; - case 'createdAt': - case '_created_at': - key = '_created_at'; - timeField = true; - break; - case 'updatedAt': - case '_updated_at': - key = '_updated_at'; - timeField = true; - break; - case 'sessionToken': - case '_session_token': - key = '_session_token'; - break; - case 'expiresAt': - case '_expiresAt': - key = '_expiresAt'; - timeField = true; - break; - case '_rperm': - case '_wperm': - return {key: key, value: restValue}; - break; - case 'authData.anonymous.id': - if (options.query) { - return {key: '_auth_data_anonymous.id', value: restValue}; - } - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'can only query on ' + key); - break; - case 'authData.facebook.id': - if (options.query) { - // Special-case auth data. - return {key: '_auth_data_facebook.id', value: restValue}; - } - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'can only query on ' + key); - break; - case '$or': - if (!options.query) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'you can only use $or in queries'); - } - if (!(restValue instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'bad $or format - use an array value'); - } - var mongoSubqueries = restValue.map((s) => { - return transformWhere(schema, className, s); - }); - return {key: '$or', value: mongoSubqueries}; - case '$and': - if (!options.query) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'you can only use $and in queries'); - } - if (!(restValue instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'bad $and format - use an array value'); - } - var mongoSubqueries = restValue.map((s) => { - return transformWhere(schema, className, s); - }); - return {key: '$and', value: mongoSubqueries}; - default: - if (options.validate && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) { - throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, - 'invalid key name: ' + key); - } - } - - // Handle special schema key changes - // TODO: it seems like this is likely to have edge cases where - // pointer types are missed - var expected = undefined; - if (schema && schema.getExpectedType) { - expected = schema.getExpectedType(className, key); - } - if ((expected && expected[0] == '*') || - (!expected && restValue && restValue.__type == 'Pointer')) { - key = '_p_' + key; - } - var inArray = (expected === 'array'); - - // Handle query constraints - if (options.query) { - value = transformConstraint(restValue, inArray); - if (value !== CannotTransform) { - return {key: key, value: value}; - } - } - - if (inArray && options.query && !(restValue instanceof Array)) { - return { - key: key, value: [restValue] - }; - } - - // Handle atomic values - var value = transformAtom(restValue, false, options); - if (value !== CannotTransform) { - if (timeField && (typeof value === 'string')) { - value = new Date(value); - } - return {key: key, value: value}; - } - - // ACLs are handled before this method is called - // If an ACL key still exists here, something is wrong. - if (key === 'ACL') { - throw 'There was a problem transforming an ACL.'; - } - - - - // Handle arrays - if (restValue instanceof Array) { - if (options.query) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'cannot use array as query param'); - } - value = restValue.map((restObj) => { - var out = transformKeyValue(schema, className, restKey, restObj, { inArray: true }); - return out.value; - }); - return {key: key, value: value}; - } - - // Handle update operators - value = transformUpdateOperator(restValue, !options.update); - if (value !== CannotTransform) { - return {key: key, value: value}; - } - - // Handle normal objects by recursing - value = {}; - for (var subRestKey in restValue) { - var subRestValue = restValue[subRestKey]; - var out = transformKeyValue(schema, className, subRestKey, subRestValue, { inObject: true }); - // For recursed objects, keep the keys in rest format - value[subRestKey] = out.value; - } - return {key: key, value: value}; -} - - -// Main exposed method to help run queries. -// restWhere is the "where" clause in REST API form. -// Returns the mongo form of the query. -// Throws a Parse.Error if the input query is invalid. -function transformWhere(schema, className, restWhere) { - var mongoWhere = {}; - if (restWhere['ACL']) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'Cannot query on ACL.'); - } - for (var restKey in restWhere) { - var out = transformKeyValue(schema, className, restKey, restWhere[restKey], - {query: true, validate: true}); - mongoWhere[out.key] = out.value; - } - return mongoWhere; -} - -// Main exposed method to create new objects. -// restCreate is the "create" clause in REST API form. -// Returns the mongo form of the object. -function transformCreate(schema, className, restCreate) { - var mongoCreate = transformACL(restCreate); - for (var restKey in restCreate) { - var out = transformKeyValue(schema, className, restKey, restCreate[restKey]); - if (out.value !== undefined) { - mongoCreate[out.key] = out.value; - } - } - return mongoCreate; -} - -// Main exposed method to help update old objects. -function transformUpdate(schema, className, restUpdate) { - if (!restUpdate) { - throw 'got empty restUpdate'; - } - var mongoUpdate = {}; - var acl = transformACL(restUpdate); - if (acl._rperm || acl._wperm) { - mongoUpdate['$set'] = {}; - if (acl._rperm) { - mongoUpdate['$set']['_rperm'] = acl._rperm; - } - if (acl._wperm) { - mongoUpdate['$set']['_wperm'] = acl._wperm; - } - } - - for (var restKey in restUpdate) { - var out = transformKeyValue(schema, className, restKey, restUpdate[restKey], - {update: true}); - - // If the output value is an object with any $ keys, it's an - // operator that needs to be lifted onto the top level update - // object. - if (typeof out.value === 'object' && out.value !== null && - out.value.__op) { - mongoUpdate[out.value.__op] = mongoUpdate[out.value.__op] || {}; - mongoUpdate[out.value.__op][out.key] = out.value.arg; - } else { - mongoUpdate['$set'] = mongoUpdate['$set'] || {}; - mongoUpdate['$set'][out.key] = out.value; - } - } - - return mongoUpdate; -} - -// Transforms a REST API formatted ACL object to our two-field mongo format. -// This mutates the restObject passed in to remove the ACL key. -function transformACL(restObject) { - var output = {}; - if (!restObject['ACL']) { - return output; - } - var acl = restObject['ACL']; - var rperm = []; - var wperm = []; - for (var entry in acl) { - if (acl[entry].read) { - rperm.push(entry); - } - if (acl[entry].write) { - wperm.push(entry); - } - } - if (rperm.length) { - output._rperm = rperm; - } - if (wperm.length) { - output._wperm = wperm; - } - delete restObject.ACL; - return output; -} - -// Transforms a mongo format ACL to a REST API format ACL key -// This mutates the mongoObject passed in to remove the _rperm/_wperm keys -function untransformACL(mongoObject) { - var output = {}; - if (!mongoObject['_rperm'] && !mongoObject['_wperm']) { - return output; - } - var acl = {}; - var rperm = mongoObject['_rperm'] || []; - var wperm = mongoObject['_wperm'] || []; - rperm.map((entry) => { - if (!acl[entry]) { - acl[entry] = {read: true}; - } else { - acl[entry]['read'] = true; - } - }); - wperm.map((entry) => { - if (!acl[entry]) { - acl[entry] = {write: true}; - } else { - acl[entry]['write'] = true; - } - }); - output['ACL'] = acl; - delete mongoObject._rperm; - delete mongoObject._wperm; - return output; -} - -// Transforms a key used in the REST API format to its mongo format. -function transformKey(schema, className, key) { - return transformKeyValue(schema, className, key, null, {validate: true}).key; -} - -// A sentinel value that helper transformations return when they -// cannot perform a transformation -function CannotTransform() {} - -// Helper function to transform an atom from REST format to Mongo format. -// An atom is anything that can't contain other expressions. So it -// includes things where objects are used to represent other -// datatypes, like pointers and dates, but it does not include objects -// or arrays with generic stuff inside. -// If options.inArray is true, we'll leave it in REST format. -// If options.inObject is true, we'll leave files in REST format. -// Raises an error if this cannot possibly be valid REST format. -// Returns CannotTransform if it's just not an atom, or if force is -// true, throws an error. -function transformAtom(atom, force, options) { - options = options || {}; - var inArray = options.inArray; - var inObject = options.inObject; - switch(typeof atom) { - case 'string': - case 'number': - case 'boolean': - return atom; - - case 'undefined': - case 'symbol': - case 'function': - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'cannot transform value: ' + atom); - - case 'object': - if (atom instanceof Date) { - // Technically dates are not rest format, but, it seems pretty - // clear what they should be transformed to, so let's just do it. - return atom; - } - - if (atom === null) { - return atom; - } - - // TODO: check validity harder for the __type-defined types - if (atom.__type == 'Pointer') { - if (!inArray && !inObject) { - return atom.className + '$' + atom.objectId; - } - return { - __type: 'Pointer', - className: atom.className, - objectId: atom.objectId - }; - } - if (atom.__type == 'Date') { - return new Date(atom.iso); - } - if (atom.__type == 'GeoPoint') { - return [atom.longitude, atom.latitude]; - } - if (atom.__type == 'Bytes') { - return new mongodb.Binary(new Buffer(atom.base64, 'base64')); - } - if (atom.__type == 'File') { - if (!inArray && !inObject) { - return atom.name; - } - return atom; - } - - if (force) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad atom: ' + atom); - } - return CannotTransform; - - default: - // I don't think typeof can ever let us get here - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, - 'really did not expect value: ' + atom); - } -} - -// Transforms a query constraint from REST API format to Mongo format. -// A constraint is something with fields like $lt. -// If it is not a valid constraint but it could be a valid something -// else, return CannotTransform. -// inArray is whether this is an array field. -function transformConstraint(constraint, inArray) { - if (typeof constraint !== 'object' || !constraint) { - return CannotTransform; - } - - // keys is the constraints in reverse alphabetical order. - // This is a hack so that: - // $regex is handled before $options - // $nearSphere is handled before $maxDistance - var keys = Object.keys(constraint).sort().reverse(); - var answer = {}; - for (var key of keys) { - switch(key) { - case '$lt': - case '$lte': - case '$gt': - case '$gte': - case '$exists': - case '$ne': - answer[key] = transformAtom(constraint[key], true, - {inArray: inArray}); - break; - - case '$in': - case '$nin': - var arr = constraint[key]; - if (!(arr instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad ' + key + ' value'); - } - answer[key] = arr.map((v) => { - return transformAtom(v, true); - }); - break; - - case '$all': - var arr = constraint[key]; - if (!(arr instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'bad ' + key + ' value'); - } - answer[key] = arr.map((v) => { - return transformAtom(v, true, { inArray: true }); - }); - break; - - case '$regex': - var s = constraint[key]; - if (typeof s !== 'string') { - throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad regex: ' + s); - } - answer[key] = s; - break; - - case '$options': - var options = constraint[key]; - if (!answer['$regex'] || (typeof options !== 'string') - || !options.match(/^[imxs]+$/)) { - throw new Parse.Error(Parse.Error.INVALID_QUERY, - 'got a bad $options'); - } - answer[key] = options; - break; - - case '$nearSphere': - var point = constraint[key]; - answer[key] = [point.longitude, point.latitude]; - break; - - case '$maxDistance': - answer[key] = constraint[key]; - break; - - // The SDKs don't seem to use these but they are documented in the - // REST API docs. - case '$maxDistanceInRadians': - answer['$maxDistance'] = constraint[key]; - break; - case '$maxDistanceInMiles': - answer['$maxDistance'] = constraint[key] / 3959; - break; - case '$maxDistanceInKilometers': - answer['$maxDistance'] = constraint[key] / 6371; - break; - - case '$select': - case '$dontSelect': - throw new Parse.Error( - Parse.Error.COMMAND_UNAVAILABLE, - 'the ' + key + ' constraint is not supported yet'); - - case '$within': - var box = constraint[key]['$box']; - if (!box || box.length != 2) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'malformatted $within arg'); - } - answer[key] = { - '$box': [ - [box[0].longitude, box[0].latitude], - [box[1].longitude, box[1].latitude] - ] - }; - break; - - default: - if (key.match(/^\$+/)) { - throw new Parse.Error( - Parse.Error.INVALID_JSON, - 'bad constraint: ' + key); - } - return CannotTransform; - } - } - return answer; -} - -// Transforms an update operator from REST format to mongo format. -// To be transformed, the input should have an __op field. -// If flatten is true, this will flatten operators to their static -// data format. For example, an increment of 2 would simply become a -// 2. -// The output for a non-flattened operator is a hash with __op being -// the mongo op, and arg being the argument. -// The output for a flattened operator is just a value. -// Returns CannotTransform if this cannot transform it. -// Returns undefined if this should be a no-op. -function transformUpdateOperator(operator, flatten) { - if (typeof operator !== 'object' || !operator.__op) { - return CannotTransform; - } - - switch(operator.__op) { - case 'Delete': - if (flatten) { - return undefined; - } else { - return {__op: '$unset', arg: ''}; - } - - case 'Increment': - if (typeof operator.amount !== 'number') { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'incrementing must provide a number'); - } - if (flatten) { - return operator.amount; - } else { - return {__op: '$inc', arg: operator.amount}; - } - - case 'Add': - case 'AddUnique': - if (!(operator.objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'objects to add must be an array'); - } - var toAdd = operator.objects.map((obj) => { - return transformAtom(obj, true, { inArray: true }); - }); - if (flatten) { - return toAdd; - } else { - var mongoOp = { - Add: '$push', - AddUnique: '$addToSet' - }[operator.__op]; - return {__op: mongoOp, arg: {'$each': toAdd}}; - } - - case 'Remove': - if (!(operator.objects instanceof Array)) { - throw new Parse.Error(Parse.Error.INVALID_JSON, - 'objects to remove must be an array'); - } - var toRemove = operator.objects.map((obj) => { - return transformAtom(obj, true, { inArray: true }); - }); - if (flatten) { - return []; - } else { - return {__op: '$pullAll', arg: toRemove}; - } - - default: - throw new Parse.Error( - Parse.Error.COMMAND_UNAVAILABLE, - 'the ' + operator.__op + ' op is not supported yet'); - } -} - - -// Converts from a mongo-format object to a REST-format object. -// Does not strip out anything based on a lack of authentication. -function untransformObject(schema, className, mongoObject) { - switch(typeof mongoObject) { - case 'string': - case 'number': - case 'boolean': - return mongoObject; - case 'undefined': - case 'symbol': - case 'function': - throw 'bad value in untransformObject'; - case 'object': - if (mongoObject === null) { - return null; - } - - if (mongoObject instanceof Array) { - return mongoObject.map((o) => { - return untransformObject(schema, className, o); - }); - } - - if (mongoObject instanceof Date) { - return Parse._encode(mongoObject); - } - - if (mongoObject instanceof mongodb.Binary) { - return { - __type: 'Bytes', - base64: mongoObject.buffer.toString('base64') - }; - } - - var restObject = untransformACL(mongoObject); - for (var key in mongoObject) { - switch(key) { - case '_id': - restObject['objectId'] = '' + mongoObject[key]; - break; - case '_hashed_password': - restObject['password'] = mongoObject[key]; - break; - case '_acl': - case '_email_verify_token': - case '_perishable_token': - break; - case '_session_token': - restObject['sessionToken'] = mongoObject[key]; - break; - case 'updatedAt': - case '_updated_at': - restObject['updatedAt'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case 'createdAt': - case '_created_at': - restObject['createdAt'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case 'expiresAt': - case '_expiresAt': - restObject['expiresAt'] = Parse._encode(new Date(mongoObject[key])).iso; - break; - case '_auth_data_anonymous': - restObject['authData'] = restObject['authData'] || {}; - restObject['authData']['anonymous'] = mongoObject[key]; - break; - case '_auth_data_facebook': - restObject['authData'] = restObject['authData'] || {}; - restObject['authData']['facebook'] = mongoObject[key]; - break; - default: - if (key.indexOf('_p_') == 0) { - var newKey = key.substring(3); - var expected; - if (schema && schema.getExpectedType) { - expected = schema.getExpectedType(className, newKey); - } - if (!expected) { - console.log( - 'Found a pointer column not in the schema, dropping it.', - className, newKey); - break; - } - if (expected && expected[0] != '*') { - console.log('Found a pointer in a non-pointer column, dropping it.', className, key); - break; - } - if (mongoObject[key] === null) { - break; - } - var objData = mongoObject[key].split('$'); - var newClass = (expected ? expected.substring(1) : objData[0]); - if (objData[0] !== newClass) { - throw 'pointer to incorrect className'; - } - restObject[newKey] = { - __type: 'Pointer', - className: objData[0], - objectId: objData[1] - }; - break; - } else if (key[0] == '_' && key != '__type') { - throw ('bad key in untransform: ' + key); - //} else if (mongoObject[key] === null) { - //break; - } else { - var expected = schema.getExpectedType(className, key); - if (expected == 'file' && mongoObject[key]) { - restObject[key] = { - __type: 'File', - name: mongoObject[key] - }; - break; - } - if (expected == 'geopoint') { - restObject[key] = { - __type: 'GeoPoint', - latitude: mongoObject[key][1], - longitude: mongoObject[key][0] - }; - break; - } - } - restObject[key] = untransformObject(schema, className, - mongoObject[key]); - } - } - return restObject; - default: - throw 'unknown js type'; - } -} - -module.exports = { - transformKey: transformKey, - transformCreate: transformCreate, - transformUpdate: transformUpdate, - transformWhere: transformWhere, - untransformObject: untransformObject -}; - diff --git a/triggers.js b/triggers.js deleted file mode 100644 index 9756051a87..0000000000 --- a/triggers.js +++ /dev/null @@ -1,99 +0,0 @@ -// triggers.js - -var Parse = require('parse/node').Parse; - -var Types = { - beforeSave: 'beforeSave', - afterSave: 'afterSave', - beforeDelete: 'beforeDelete', - afterDelete: 'afterDelete' -}; - -var getTrigger = function(className, triggerType) { - if (Parse.Cloud.Triggers - && Parse.Cloud.Triggers[triggerType] - && Parse.Cloud.Triggers[triggerType][className]) { - return Parse.Cloud.Triggers[triggerType][className]; - } - return undefined; -}; - -var getRequestObject = function(triggerType, auth, parseObject, originalParseObject) { - var request = { - triggerName: triggerType, - object: parseObject, - master: false - }; - if (originalParseObject) { - request.original = originalParseObject; - } - if (!auth) { - return request; - } - if (auth.isMaster) { - request['master'] = true; - } - 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 -// transform them to Parse.Object instances expected by Cloud Code. -// Any changes made to the object in a beforeSave will be included. -var getResponseObject = function(request, resolve, reject) { - return { - success: function() { - var response = {}; - if (request.triggerName === Types.beforeSave) { - response['object'] = request.object.toJSON(); - } - return resolve(response); - }, - error: function(error) { - throw new Parse.Error(Parse.Error.SCRIPT_FAILED, error); - } - } -}; - -// To be used as part of the promise chain when saving/deleting an object -// Will resolve successfully if no trigger is configured -// Resolves to an object, empty or containing an object key. A beforeSave -// trigger will set the object key to the rest format object to save. -// originalParseObject is optional, we only need that for befote/afterSave functions -var maybeRunTrigger = function(triggerType, auth, parseObject, originalParseObject) { - if (!parseObject) { - return Promise.resolve({}); - } - return new Promise(function (resolve, reject) { - var trigger = getTrigger(parseObject.className, triggerType); - if (!trigger) return resolve({}); - var request = getRequestObject(triggerType, auth, parseObject, originalParseObject); - var response = getResponseObject(request, resolve, reject); - trigger(request, response); - }); -}; - -// Converts a REST-format object to a Parse.Object -// data is either className or an object -function inflate(data, restObject) { - var copy = typeof data == 'object' ? data : {className: data}; - for (var key in restObject) { - copy[key] = restObject[key]; - } - return Parse.Object.fromJSON(copy); -} - -module.exports = { - getTrigger: getTrigger, - getRequestObject: getRequestObject, - inflate: inflate, - maybeRunTrigger: maybeRunTrigger, - Types: Types -}; diff --git a/users.js b/users.js deleted file mode 100644 index 007808543e..0000000000 --- a/users.js +++ /dev/null @@ -1,187 +0,0 @@ -// These methods handle the User-related routes. - -var mongodb = require('mongodb'); -var Parse = require('parse/node').Parse; -var rack = require('hat').rack(); - -var Auth = require('./Auth'); -var passwordCrypto = require('./password'); -var facebook = require('./facebook'); -var PromiseRouter = require('./PromiseRouter'); -var rest = require('./rest'); -var RestWrite = require('./RestWrite'); - -var router = new PromiseRouter(); - -// Returns a promise for a {status, response, location} object. -function handleCreate(req) { - return rest.create(req.config, req.auth, - '_User', req.body); -} - -// Returns a promise for a {response} object. -function handleLogIn(req) { - - // Use query parameters instead if provided in url - if (!req.body.username && req.query.username) { - req.body = req.query; - } - - // TODO: use the right error codes / descriptions. - if (!req.body.username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'username is required.'); - } - if (!req.body.password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required.'); - } - - var user; - return req.database.find('_User', {username: req.body.username}) - .then((results) => { - if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - user = results[0]; - return passwordCrypto.compare(req.body.password, user.password); - }).then((correct) => { - if (!correct) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - var token = 'r:' + rack(); - user.sessionToken = token; - delete user.password; - - var expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: user.objectId - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false, - expiresAt: Parse._encode(expiresAt) - }; - - if (req.info.installationId) { - sessionData.installationId = req.info.installationId - } - - var create = new RestWrite(req.config, Auth.master(req.config), - '_Session', null, sessionData); - return create.execute(); - }).then(() => { - return {response: user}; - }); -} - -// Returns a promise that resolves to a {response} object. -// TODO: share code with classes.js -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - if (req.body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); - } - - return rest.find(req.config, req.auth, - '_User', req.body.where, options) - .then((response) => { - return {response: response}; - }); - -} - -// Returns a promise for a {response} object. -function handleGet(req) { - return rest.find(req.config, req.auth, '_User', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -function handleMe(req) { - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken}, - {include: 'user'}) - .then((response) => { - if (!response.results || response.results.length == 0 || - !response.results[0].user) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - var user = response.results[0].user; - return {response: user}; - } - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - req.params.className, req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_User', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); -} - -router.route('POST', '/users', handleCreate); -router.route('GET', '/login', handleLogIn); -router.route('GET', '/users/me', handleMe); -router.route('GET', '/users/:objectId', handleGet); -router.route('PUT', '/users/:objectId', handleUpdate); -router.route('GET', '/users', handleFind); -router.route('DELETE', '/users/:objectId', handleDelete); - -router.route('POST', '/requestPasswordReset', notImplementedYet); - -module.exports = router; From 9096f38d7daffc82778939c3363688f9b3291685 Mon Sep 17 00:00:00 2001 From: Alexander Mays Date: Tue, 2 Feb 2016 09:15:37 -0500 Subject: [PATCH 2/2] Updated the npm script for linting to point to the right directory Signed-off-by: Alexander Mays --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b32315dc63..476f5744dd 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "scripts": { "test": "TESTING=1 ./node_modules/.bin/jasmine", - "lint": "./node_modules/.bin/eslint ." + "lint": "./node_modules/.bin/eslint src" }, "engines": { "node": ">=4.1"