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..476f5744dd 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 src" }, "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;