Merged with master

This commit is contained in:
Peter Theill
2016-02-09 10:23:55 +01:00
parent 307a5d8157
commit 7fa4b3bc07
57 changed files with 325 additions and 109 deletions

95
src/APNS.js Normal file
View File

@@ -0,0 +1,95 @@
var Parse = require('parse/node').Parse;
// TODO: apn does not support the new HTTP/2 protocal. It is fine to use it in V1,
// but probably we will replace it in the future.
var apn = require('apn');
/**
* Create a new connection to the APN service.
* @constructor
* @param {Object} args Arguments to config APNS connection
* @param {String} args.cert The filename of the connection certificate to load from disk, default is cert.pem
* @param {String} args.key The filename of the connection key to load from disk, default is key.pem
* @param {String} args.passphrase The passphrase for the connection key, if required
* @param {Boolean} args.production Specifies which environment to connect to: Production (if true) or Sandbox
*/
function APNS(args) {
this.sender = new apn.connection(args);
this.sender.on('connected', function() {
console.log('APNS Connected');
});
this.sender.on('transmissionError', function(errCode, notification, device) {
console.error('APNS Notification caused error: ' + errCode + ' for device ', device, notification);
// TODO: For error caseud by invalid deviceToken, we should mark those installations.
});
this.sender.on("timeout", function () {
console.log("APNS Connection Timeout");
});
this.sender.on("disconnected", function() {
console.log("APNS Disconnected");
});
this.sender.on("socketError", console.error);
}
/**
* Send apns request.
* @param {Object} data The data we need to send, the format is the same with api request body
* @param {Array} deviceTokens A array of device tokens
* @returns {Object} A promise which is resolved immediately
*/
APNS.prototype.send = function(data, deviceTokens) {
var coreData = data.data;
var expirationTime = data['expiration_time'];
var notification = generateNotification(coreData, expirationTime);
this.sender.pushNotification(notification, deviceTokens);
// TODO: pushNotification will push the notification to apn's queue.
// We do not handle error in V1, we just relies apn to auto retry and send the
// notifications.
return Parse.Promise.as();
}
/**
* Generate the apns notification from the data we get from api request.
* @param {Object} coreData The data field under api request body
* @returns {Object} A apns notification
*/
var generateNotification = function(coreData, expirationTime) {
var notification = new apn.notification();
var payload = {};
for (var key in coreData) {
switch (key) {
case 'alert':
notification.setAlertText(coreData.alert);
break;
case 'badge':
notification.badge = coreData.badge;
break;
case 'sound':
notification.sound = coreData.sound;
break;
case 'content-available':
notification.setNewsstandAvailable(true);
var isAvailable = coreData['content-available'] === 1;
notification.setContentAvailable(isAvailable);
break;
case 'category':
notification.category = coreData.category;
break;
default:
payload[key] = coreData[key];
break;
}
}
notification.payload = payload;
notification.expiry = expirationTime;
return notification;
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
APNS.generateNotification = generateNotification;
}
module.exports = APNS;

171
src/Auth.js Normal file
View File

@@ -0,0 +1,171 @@
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';
obj['sessionToken'] = sessionToken;
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
};

28
src/Config.js Normal file
View File

@@ -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;

56
src/DatabaseAdapter.js Normal file
View File

@@ -0,0 +1,56 @@
// Database Adapter
//
// Allows you to change the underlying database.
//
// Adapter classes must implement the following methods:
// * a constructor with signature (connectionString, optionsObject)
// * connect()
// * loadSchema()
// * create(className, object)
// * find(className, query, options)
// * update(className, query, update, options)
// * destroy(className, query, options)
// * This list is incomplete and the database process is not fully modularized.
//
// Default is ExportAdapter, which uses mongo.
var ExportAdapter = require('./ExportAdapter');
var adapter = ExportAdapter;
var cache = require('./cache');
var dbConnections = {};
var databaseURI = 'mongodb://localhost:27017/parse';
var appDatabaseURIs = {};
function setAdapter(databaseAdapter) {
adapter = databaseAdapter;
}
function setDatabaseURI(uri) {
databaseURI = uri;
}
function setAppDatabaseURI(appId, uri) {
appDatabaseURIs[appId] = uri;
}
function getDatabaseConnection(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];
}
module.exports = {
dbConnections: dbConnections,
getDatabaseConnection: getDatabaseConnection,
setAdapter: setAdapter,
setDatabaseURI: setDatabaseURI,
setAppDatabaseURI: setAppDatabaseURI
};

577
src/ExportAdapter.js Normal file
View File

@@ -0,0 +1,577 @@
// 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 (!Schema.classNameIsValid(className)) {
throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME,
'invalid className: ' + className);
}
return this.rawCollection(className);
};
ExportAdapter.prototype.rawCollection = function(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 (var 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';
//TODO: condiser moving index creation logic into Schema.js
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;

29
src/FilesAdapter.js Normal file
View File

@@ -0,0 +1,29 @@
// Files Adapter
//
// Allows you to change the file storage mechanism.
//
// Adapter classes must implement the following functions:
// * create(config, filename, data)
// * get(config, filename)
// * location(config, req, filename)
//
// Default is GridStoreAdapter, which requires mongo
// and for the API server to be using the ExportAdapter
// database adapter.
var GridStoreAdapter = require('./GridStoreAdapter');
var adapter = GridStoreAdapter;
function setAdapter(filesAdapter) {
adapter = filesAdapter;
}
function getAdapter() {
return adapter;
}
module.exports = {
getAdapter: getAdapter,
setAdapter: setAdapter
};

82
src/GCM.js Normal file
View File

@@ -0,0 +1,82 @@
var Parse = require('parse/node').Parse;
var gcm = require('node-gcm');
var randomstring = require('randomstring');
var GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks
var GCMRegistrationTokensMax = 1000;
function GCM(apiKey) {
this.sender = new gcm.Sender(apiKey);
}
/**
* Send gcm request.
* @param {Object} data The data we need to send, the format is the same with api request body
* @param {Array} registrationTokens A array of registration tokens
* @returns {Object} A promise which is resolved after we get results from gcm
*/
GCM.prototype.send = function (data, registrationTokens) {
if (registrationTokens.length >= GCMRegistrationTokensMax) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Too many registration tokens for a GCM request.');
}
var pushId = randomstring.generate({
length: 10,
charset: 'alphanumeric'
});
var timeStamp = Date.now();
var expirationTime;
// We handle the expiration_time convertion in push.js, so expiration_time is a valid date
// in Unix epoch time in milliseconds here
if (data['expiration_time']) {
expirationTime = data['expiration_time'];
}
// Generate gcm payload
var gcmPayload = generateGCMPayload(data.data, pushId, timeStamp, expirationTime);
// Make and send gcm request
var message = new gcm.Message(gcmPayload);
var promise = new Parse.Promise();
this.sender.send(message, { registrationTokens: registrationTokens }, 5, function (error, response) {
// TODO: Use the response from gcm to generate and save push report
// TODO: If gcm returns some deviceTokens are invalid, set tombstone for the installation
promise.resolve();
});
return promise;
}
/**
* Generate the gcm payload from the data we get from api request.
* @param {Object} coreData The data field under api request body
* @param {String} pushId A random string
* @param {Number} timeStamp A number whose format is the Unix Epoch
* @param {Number|undefined} expirationTime A number whose format is the Unix Epoch or undefined
* @returns {Object} A promise which is resolved after we get results from gcm
*/
var generateGCMPayload = function(coreData, pushId, timeStamp, expirationTime) {
var payloadData = {
'time': new Date(timeStamp).toISOString(),
'push_id': pushId,
'data': JSON.stringify(coreData)
}
var payload = {
priority: 'normal',
data: payloadData
};
if (expirationTime) {
// The timeStamp and expiration is in milliseconds but gcm requires second
var timeToLive = Math.floor((expirationTime - timeStamp) / 1000);
if (timeToLive < 0) {
timeToLive = 0;
}
if (timeToLive >= GCMTimeToLiveMax) {
timeToLive = GCMTimeToLiveMax;
}
payload.timeToLive = timeToLive;
}
return payload;
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
GCM.generateGCMPayload = generateGCMPayload;
}
module.exports = GCM;

48
src/GridStoreAdapter.js Normal file
View File

@@ -0,0 +1,48 @@
// GridStoreAdapter
//
// Stores files in Mongo using GridStore
// Requires the database adapter to be based on mongoclient
var GridStore = require('mongodb').GridStore;
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();
});
}
// 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();
});
}
// 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') +
path.dirname(req.originalUrl) + '/' + req.config.applicationId +
'/' + encodeURIComponent(filename));
}
module.exports = {
create: create,
get: get,
location: location
};

148
src/PromiseRouter.js Normal file
View File

@@ -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;

555
src/RestQuery.js Normal file
View File

@@ -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 (var 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;

733
src/RestWrite.js Normal file
View File

@@ -0,0 +1,733 @@
// 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) => {
this.storage['authProvider'] = "facebook";
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()
};
this.data.objectId = results[0].objectId;
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');
} else {
this.data.username = rack();
}
// 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.className !== '_User') {
return;
}
var promise = Promise.resolve();
if (!this.query) {
var token = 'r:' + rack();
this.storage['token'] = token;
promise = promise.then(() => {
var expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
var sessionData = {
sessionToken: token,
user: {
__type: 'Pointer',
className: '_User',
objectId: this.objectId()
},
createdWith: {
'action': 'login',
'authProvider': this.storage['authProvider'] || 'password'
},
restricted: false,
installationId: this.data.installationId,
expiresAt: Parse._encode(expiresAt)
};
if (this.response && this.response.response) {
this.response.response.sessionToken = token;
}
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.bind(this));
}
};
// 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 expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);
var sessionData = {
sessionToken: token,
user: {
__type: 'Pointer',
className: '_User',
objectId: this.auth.user.id
},
createdWith: {
'action': 'create'
},
restricted: true,
expiresAt: Parse._encode(expiresAt)
};
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;

77
src/S3Adapter.js Normal file
View File

@@ -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;

565
src/Schema.js Normal file
View File

@@ -0,0 +1,565 @@
// This class handles schema validation, persistence, and modification.
//
// Each individual Schema object should be immutable. The helpers to
// do things with the Schema just return a new schema when the schema
// is changed.
//
// The canonical place to store this Schema is in the database itself,
// in a _SCHEMA collection. This is not the right way to do it for an
// open source framework, but it's backward compatible, so we're
// keeping it this way for now.
//
// In API-handling code, you should only use the Schema class via the
// ExportAdapter. This will let us replace the schema logic for
// different databases.
// TODO: hide all schema logic inside the database adapter.
var Parse = require('parse/node').Parse;
var transform = require('./transform');
var defaultColumns = {
// Contain the default columns for every parse object type (except _Join collection)
_Default: {
"objectId": {type:'String'},
"createdAt": {type:'Date'},
"updatedAt": {type:'Date'},
"ACL": {type:'ACL'},
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_User: {
"username": {type:'String'},
"password": {type:'String'},
"authData": {type:'Object'},
"email": {type:'String'},
"emailVerified": {type:'Boolean'},
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_Installation: {
"installationId": {type:'String'},
"deviceToken": {type:'String'},
"channels": {type:'Array'},
"deviceType": {type:'String'},
"pushType": {type:'String'},
"GCMSenderId": {type:'String'},
"timeZone": {type:'String'},
"localeIdentifier": {type:'String'},
"badge": {type:'Number'}
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_Role: {
"name": {type:'String'},
"users": {type:'Relation',className:'_User'},
"roles": {type:'Relation',className:'_Role'}
},
// The additional default columns for the _User collection (in addition to DefaultCols)
_Session: {
"restricted": {type:'Boolean'},
"user": {type:'Pointer', className:'_User'},
"installationId": {type:'String'},
"sessionToken": {type:'String'},
"expiresAt": {type:'Date'},
"createdWith": {type:'Object'}
}
};
// Valid classes must:
// Be one of _User, _Installation, _Role, _Session OR
// Be a join table OR
// Include only alpha-numeric and underscores, and not start with an underscore or number
var joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/;
var classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/;
function classNameIsValid(className) {
return (
className === '_User' ||
className === '_Installation' ||
className === '_Session' ||
className === '_SCHEMA' || //TODO: remove this, as _SCHEMA is not a valid class name for storing Parse Objects.
className === '_Role' ||
joinClassRegex.test(className) ||
//Class names have the same constraints as field names, but also allow the previous additional names.
fieldNameIsValid(className)
);
}
// Valid fields must be alpha-numeric, and not start with an underscore or number
function fieldNameIsValid(fieldName) {
return classAndFieldRegex.test(fieldName);
}
// Checks that it's not trying to clobber one of the default fields of the class.
function fieldNameIsValidForClass(fieldName, className) {
if (!fieldNameIsValid(fieldName)) {
return false;
}
if (defaultColumns._Default[fieldName]) {
return false;
}
if (defaultColumns[className] && defaultColumns[className][fieldName]) {
return false;
}
return true;
}
function invalidClassNameMessage(className) {
return 'Invalid classname: ' + className + ', classnames can only have alphanumeric characters and _, and must start with an alpha character ';
}
// Returns { error: "message", code: ### } if the type could not be
// converted, otherwise returns a returns { result: "mongotype" }
// where mongotype is suitable for inserting into mongo _SCHEMA collection
function schemaAPITypeToMongoFieldType(type) {
var invalidJsonError = { error: "invalid JSON", code: Parse.Error.INVALID_JSON };
if (type.type == 'Pointer') {
if (!type.targetClass) {
return { error: 'type Pointer needs a class name', code: 135 };
} else if (typeof type.targetClass !== 'string') {
return invalidJsonError;
} else if (!classNameIsValid(type.targetClass)) {
return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME };
} else {
return { result: '*' + type.targetClass };
}
}
if (type.type == 'Relation') {
if (!type.targetClass) {
return { error: 'type Relation needs a class name', code: 135 };
} else if (typeof type.targetClass !== 'string') {
return invalidJsonError;
} else if (!classNameIsValid(type.targetClass)) {
return { error: invalidClassNameMessage(type.targetClass), code: Parse.Error.INVALID_CLASS_NAME };
} else {
return { result: 'relation<' + type.targetClass + '>' };
}
}
if (typeof type.type !== 'string') {
return { error: "invalid JSON", code: Parse.Error.INVALID_JSON };
}
switch (type.type) {
default: return { error: 'invalid field type: ' + type.type, code: Parse.Error.INCORRECT_TYPE };
case 'Number': return { result: 'number' };
case 'String': return { result: 'string' };
case 'Boolean': return { result: 'boolean' };
case 'Date': return { result: 'date' };
case 'Object': return { result: 'object' };
case 'Array': return { result: 'array' };
case 'GeoPoint': return { result: 'geopoint' };
case 'File': return { result: 'file' };
}
}
// Create a schema from a Mongo collection and the exported schema format.
// mongoSchema should be a list of objects, each with:
// '_id' indicates the className
// '_metadata' is ignored for now
// Everything else is expected to be a userspace field.
function Schema(collection, mongoSchema) {
this.collection = collection;
// this.data[className][fieldName] tells you the type of that field
this.data = {};
// this.perms[className][operation] tells you the acl-style permissions
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'];
}
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);
});
}
// Returns a new, reloaded schema.
Schema.prototype.reload = function() {
return load(this.collection);
};
// Create a new class that includes the three default fields.
// ACL is an implicit column that does not get an entry in the
// _SCHEMAS database. Returns a promise that resolves with the
// created schema, in mongo format.
// on success, and rejects with an error on fail. Ensure you
// have authorization (master key, or client class creation
// enabled) before calling this function.
Schema.prototype.addClassIfNotExists = function(className, fields) {
if (this.data[className]) {
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + className + ' already exists',
});
}
if (!classNameIsValid(className)) {
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: invalidClassNameMessage(className),
});
}
for (var fieldName in fields) {
if (!fieldNameIsValid(fieldName)) {
return Promise.reject({
code: Parse.Error.INVALID_KEY_NAME,
error: 'invalid field name: ' + fieldName,
});
}
if (!fieldNameIsValidForClass(fieldName, className)) {
return Promise.reject({
code: 136,
error: 'field ' + fieldName + ' cannot be added',
});
}
}
var mongoObject = {
_id: className,
objectId: 'string',
updatedAt: 'string',
createdAt: 'string'
};
for (var fieldName in defaultColumns[className]) {
var validatedField = schemaAPITypeToMongoFieldType(defaultColumns[className][fieldName]);
if (validatedField.code) {
return Promise.reject(validatedField);
}
mongoObject[fieldName] = validatedField.result;
}
for (var fieldName in fields) {
var validatedField = schemaAPITypeToMongoFieldType(fields[fieldName]);
if (validatedField.code) {
return Promise.reject(validatedField);
}
mongoObject[fieldName] = validatedField.result;
}
var geoPoints = Object.keys(mongoObject).filter(key => mongoObject[key] === 'geopoint');
if (geoPoints.length > 1) {
return Promise.reject({
code: Parse.Error.INCORRECT_TYPE,
error: 'currently, only one GeoPoint field may exist in an object. Adding ' + geoPoints[1] + ' when ' + geoPoints[0] + ' already exists.',
});
}
return this.collection.insertOne(mongoObject)
.then(result => result.ops[0])
.catch(error => {
if (error.code === 11000) { //Mongo's duplicate key error
return Promise.reject({
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class ' + className + ' already exists',
});
}
return Promise.reject(error);
});
};
// Returns a promise that resolves successfully to the new schema
// object or fails with a reason.
// If 'freeze' is true, refuse to update the schema.
// WARNING: this function has side-effects, and doesn't actually
// do any validation of the format of the className. You probably
// should use classNameIsValid or addClassIfNotExists or something
// like that instead. TODO: rename or remove this function.
Schema.prototype.validateClassName = function(className, freeze) {
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(() => {
// The schema update succeeded. Reload the schema
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) => {
// Ensure that the schema now validates
return schema.validateClassName(className, true);
}, (error) => {
// The schema still doesn't validate. Give up
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;
}
}
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(() => {
// The update succeeded. Reload the schema
return this.reload();
});
};
// Returns a promise that resolves successfully to the new schema
// object if the provided className-key-type tuple is valid.
// The className must already be validated.
// 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);
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,
'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 === '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(
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(() => {
// The update succeeded. Reload the schema
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) => {
// Ensure that the schema now validates
return schema.validateField(className, key, type, true);
}, (error) => {
// The schema still doesn't validate. Give up
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);
});
}
// 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) {
if (object[key] === undefined) {
continue;
}
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);
}
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];
// Handle the public scenario quickly
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;
}
}
if (!found) {
// TODO: Verify correct error code
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;
};
// 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;
};
// Gets the type from a REST API formatted object, where 'type' is
// extended past javascript types to include the rest of the Parse
// type system.
// 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;
}
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' &&
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 'object';
}
module.exports = {
load: load,
classNameIsValid: classNameIsValid,
};

20
src/analytics.js Normal file
View File

@@ -0,0 +1,20 @@
// analytics.js
var Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
var router = new PromiseRouter();
// Returns a promise that resolves to an empty object response
function ignoreAndSucceed(req) {
return Promise.resolve({
response: {}
});
}
router.route('POST','/events/AppOpened', ignoreAndSucceed);
router.route('POST','/events/:eventName', ignoreAndSucceed);
module.exports = router;

72
src/batch.js Normal file
View File

@@ -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
};

37
src/cache.js Normal file
View File

@@ -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
};

101
src/classes.js Normal file
View File

@@ -0,0 +1,101 @@
// These methods handle the 'classes' routes.
// Methods of the form 'handleX' return promises and are intended to
// be used with the PromiseRouter.
var Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
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);
}
if(typeof body.where === 'string') {
body.where = JSON.parse(body.where);
}
return rest.find(req.config, req.auth,
req.params.className, body.where, options)
.then((response) => {
if (response && response.results) {
for (var result of response.results) {
if (result.sessionToken) {
result.sessionToken = req.info.sessionToken || result.sessionToken;
}
}
response.results.sessionToken
}
return {response: response};
});
}
// Returns a promise for a {status, response, location} object.
function handleCreate(req) {
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,
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,
'Object not found.');
} else {
return {response: response.results[0]};
}
});
}
// Returns a promise for a {response} object.
function handleDelete(req) {
return rest.del(req.config, req.auth,
req.params.className, req.params.objectId)
.then(() => {
return {response: {}};
});
}
// Returns a promise for a {response} object.
function handleUpdate(req) {
return rest.update(req.config, req.auth,
req.params.className, req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
router.route('GET', '/classes/:className', handleFind);
router.route('POST', '/classes/:className', handleCreate);
router.route('GET', '/classes/:className/:objectId', handleGet);
router.route('DELETE', '/classes/:className/:objectId', handleDelete);
router.route('PUT', '/classes/:className/:objectId', handleUpdate);
module.exports = router;

104
src/cloud/main.js Normal file
View File

@@ -0,0 +1,104 @@
var Parse = require('parse/node').Parse;
Parse.Cloud.define('hello', function(req, res) {
res.success('Hello world!');
});
Parse.Cloud.beforeSave('BeforeSaveFail', function(req, res) {
res.error('You shall not pass!');
});
Parse.Cloud.beforeSave('BeforeSaveFailWithPromise', function (req, res) {
var query = new Parse.Query('Yolo');
query.find().then(() => {
res.error('Nope');
}, () => {
res.success();
});
});
Parse.Cloud.beforeSave('BeforeSaveUnchanged', function(req, res) {
res.success();
});
Parse.Cloud.beforeSave('BeforeSaveChanged', function(req, res) {
req.object.set('foo', 'baz');
res.success();
});
Parse.Cloud.afterSave('AfterSaveTest', function(req) {
var obj = new Parse.Object('AfterSaveProof');
obj.set('proof', req.object.id);
obj.save();
});
Parse.Cloud.beforeDelete('BeforeDeleteFail', function(req, res) {
res.error('Nope');
});
Parse.Cloud.beforeSave('BeforeDeleteFailWithPromise', function (req, res) {
var query = new Parse.Query('Yolo');
query.find().then(() => {
res.error('Nope');
}, () => {
res.success();
});
});
Parse.Cloud.beforeDelete('BeforeDeleteTest', function(req, res) {
res.success();
});
Parse.Cloud.afterDelete('AfterDeleteTest', function(req) {
var obj = new Parse.Object('AfterDeleteProof');
obj.set('proof', req.object.id);
obj.save();
});
Parse.Cloud.beforeSave('SaveTriggerUser', function(req, res) {
if (req.user && req.user.id) {
res.success();
} else {
res.error('No user present on request object for beforeSave.');
}
});
Parse.Cloud.afterSave('SaveTriggerUser', function(req) {
if (!req.user || !req.user.id) {
console.log('No user present on request object for afterSave.');
}
});
Parse.Cloud.define('foo', function(req, res) {
res.success({
object: {
__type: 'Object',
className: 'Foo',
objectId: '123',
x: 2,
relation: {
__type: 'Object',
className: 'Bar',
objectId: '234',
x: 3
}
},
array: [{
__type: 'Object',
className: 'Bar',
objectId: '345',
x: 2
}],
a: 2
});
});
Parse.Cloud.define('bar', function(req, res) {
res.error('baz');
});
Parse.Cloud.define('requiredParameterCheck', function(req, res) {
res.success();
}, function(params) {
return params.name;
});

57
src/facebook.js Normal file
View File

@@ -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
};

85
src/files.js Normal file
View File

@@ -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.'));
});
router.post('/files/:filename',
middlewares.allowCrossDomain,
bodyParser.raw({type: '*/*', limit: '20mb'}),
middlewares.handleParseHeaders,
processCreate);
module.exports = {
router: router
};

51
src/functions.js Normal file
View File

@@ -0,0 +1,51 @@
// functions.js
var express = require('express'),
Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
var router = new PromiseRouter();
function handleCloudFunction(req) {
if (Parse.Cloud.Functions[req.params.functionName]) {
if (Parse.Cloud.Validators[req.params.functionName]) {
var result = Parse.Cloud.Validators[req.params.functionName](req.body || {});
if (!result) {
throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Validation failed.');
}
}
return new Promise(function (resolve, reject) {
var response = createResponseObject(resolve, reject);
var request = {
params: req.body || {},
master: req.auth && req.auth.isMaster,
user: req.auth && req.auth.user,
};
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: Parse._encode(result)
}
});
},
error: function(error) {
reject(new Parse.Error(Parse.Error.SCRIPT_FAILED, error));
}
}
}
router.route('POST', '/functions/:functionName', handleCloudFunction);
module.exports = router;

43
src/httpRequest.js Normal file
View File

@@ -0,0 +1,43 @@
var request = require("request"),
Parse = require('parse/node').Parse;
module.exports = 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) => {
var httpResponse = {};
httpResponse.status = response.statusCode;
httpResponse.headers = response.headers;
httpResponse.buffer = new Buffer(response.body);
httpResponse.cookies = response.headers["set-cookie"];
httpResponse.text = response.body;
try {
httpResponse.data = JSON.parse(response.body);
} catch (e) {}
// Consider <200 && >= 400 as errors
if (error || httpResponse.status <200 || httpResponse.status >=400) {
if (callbacks.error) {
return callbacks.error(httpResponse);
}
return promise.reject(httpResponse);
} else {
if (callbacks.success) {
return callbacks.success(httpResponse);
}
return promise.resolve(httpResponse);
}
});
return promise;
};

173
src/index.js Normal file
View File

@@ -0,0 +1,173 @@
// 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'),
httpRequest = require('./httpRequest');
// 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, or a function
// that is given an instance of Parse as a parameter. Use this instance of Parse
// to register your cloud code hooks and functions.
// "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();
if (typeof args.cloud === 'function') {
args.cloud(Parse)
} else if (typeof args.cloud === 'string') {
require(args.cloud);
} else {
throw "argument 'cloud' must either be a string or a function";
}
}
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);
if(args.serverURL) {
Parse.serverURL = args.serverURL;
}
// 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);
router.merge(require('./installations'));
router.merge(require('./functions'));
router.merge(require('./schemas'));
router.merge(require('./global_config'));
batch.mountOnto(router);
router.mountOnto(api);
api.use(middlewares.handleParseErrors);
return api;
}
function addParseCloud() {
Parse.Cloud.Functions = {};
Parse.Cloud.Validators = {};
Parse.Cloud.Triggers = {
beforeSave: {},
beforeDelete: {},
afterSave: {},
afterDelete: {}
};
Parse.Cloud.define = function(functionName, handler, validationHandler) {
Parse.Cloud.Functions[functionName] = handler;
Parse.Cloud.Validators[functionName] = validationHandler;
};
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 = httpRequest;
global.Parse = Parse;
}
function getClassName(parseClass) {
if (parseClass && parseClass.className) {
return parseClass.className;
}
return parseClass;
}
module.exports = {
ParseServer: ParseServer,
S3Adapter: S3Adapter
};

80
src/installations.js Normal file
View File

@@ -0,0 +1,80 @@
// installations.js
var Parse = require('parse/node').Parse;
var PromiseRouter = require('./PromiseRouter');
var rest = require('./rest');
var router = new PromiseRouter();
// Returns a promise for a {status, response, location} object.
function handleCreate(req) {
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);
}
return rest.find(req.config, req.auth,
'_Installation', 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, '_Installation',
{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]};
}
});
}
// Returns a promise for a {response} object.
function handleUpdate(req) {
return rest.update(req.config, req.auth,
'_Installation', req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
// Returns a promise for a {response} object.
function handleDelete(req) {
return rest.del(req.config, req.auth,
'_Installation', req.params.objectId)
.then(() => {
return {response: {}};
});
}
router.route('POST','/installations', handleCreate);
router.route('GET','/installations', handleFind);
router.route('GET','/installations/:objectId', handleGet);
router.route('PUT','/installations/:objectId', handleUpdate);
router.route('DELETE','/installations/:objectId', handleDelete);
module.exports = router;

192
src/middlewares.js Normal file
View File

@@ -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
};

35
src/password.js Normal file
View File

@@ -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
};

124
src/push.js Normal file
View File

@@ -0,0 +1,124 @@
// push.js
var Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
rest = require('./rest');
var validPushTypes = ['ios', 'android'];
function handlePushWithoutQueue(req) {
validateMasterKey(req);
var where = getQueryCondition(req);
validateDeviceType(where);
// Replace the expiration_time with a valid Unix epoch milliseconds time
req.body['expiration_time'] = getExpirationTime(req);
return rest.find(req.config, req.auth, '_Installation', where).then(function(response) {
throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE,
'This path is not implemented yet.');
});
}
/**
* Check whether the deviceType parameter in qury condition is valid or not.
* @param {Object} where A query condition
*/
function validateDeviceType(where) {
var where = where || {};
var deviceTypeField = where.deviceType || {};
var deviceTypes = [];
if (typeof deviceTypeField === 'string') {
deviceTypes.push(deviceTypeField);
} else if (typeof deviceTypeField['$in'] === 'array') {
deviceTypes.concat(deviceTypeField['$in']);
}
for (var i = 0; i < deviceTypes.length; i++) {
var deviceType = deviceTypes[i];
if (validPushTypes.indexOf(deviceType) < 0) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
deviceType + ' is not supported push type.');
}
}
}
/**
* Get expiration time from the request body.
* @param {Object} request A request object
* @returns {Number|undefined} The expiration time if it exists in the request
*/
function getExpirationTime(req) {
var body = req.body || {};
var hasExpirationTime = !!body['expiration_time'];
if (!hasExpirationTime) {
return;
}
var expirationTimeParam = body['expiration_time'];
var expirationTime;
if (typeof expirationTimeParam === 'number') {
expirationTime = new Date(expirationTimeParam * 1000);
} else if (typeof expirationTimeParam === 'string') {
expirationTime = new Date(expirationTimeParam);
} else {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
body['expiration_time'] + ' is not valid time.');
}
// Check expirationTime is valid or not, if it is not valid, expirationTime is NaN
if (!isFinite(expirationTime)) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
body['expiration_time'] + ' is not valid time.');
}
return expirationTime.valueOf();
}
/**
* Get query condition from the request body.
* @param {Object} request A request object
* @returns {Object} The query condition, the where field in a query api call
*/
function getQueryCondition(req) {
var body = req.body || {};
var hasWhere = typeof body.where !== 'undefined';
var hasChannels = typeof body.channels !== 'undefined';
var where;
if (hasWhere && hasChannels) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Channels and query can not be set at the same time.');
} else if (hasWhere) {
where = body.where;
} else if (hasChannels) {
where = {
"channels": {
"$in": body.channels
}
}
} else {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Channels and query should be set at least one.');
}
return where;
}
/**
* Check whether the api call has master key or not.
* @param {Object} request A request object
*/
function validateMasterKey(req) {
if (req.info.masterKey !== req.config.masterKey) {
throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED,
'Master key is invalid, you should only use master key to send push');
}
}
var router = new PromiseRouter();
router.route('POST','/push', handlePushWithoutQueue);
module.exports = {
router: router
}
if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
module.exports.getQueryCondition = getQueryCondition;
module.exports.validateMasterKey = validateMasterKey;
module.exports.getExpirationTime = getExpirationTime;
module.exports.validateDeviceType = validateDeviceType;
}

129
src/rest.js Normal file
View File

@@ -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
};

48
src/roles.js Normal file
View File

@@ -0,0 +1,48 @@
// roles.js
var 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,
'_Role', req.body);
}
function handleUpdate(req) {
return rest.update(req.config, req.auth, '_Role',
req.params.objectId, req.body)
.then((response) => {
return {response: response};
});
}
function handleDelete(req) {
return rest.del(req.config, req.auth,
'_Role', req.params.objectId)
.then(() => {
return {response: {}};
});
}
function handleGet(req) {
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,
'Object not found.');
} else {
return {response: response.results[0]};
}
});
}
router.route('POST','/roles', handleCreate);
router.route('GET','/roles/:objectId', handleGet);
router.route('PUT','/roles/:objectId', handleUpdate);
router.route('DELETE','/roles/:objectId', handleDelete);
module.exports = router;

131
src/schemas.js Normal file
View File

@@ -0,0 +1,131 @@
// schemas.js
var express = require('express'),
Parse = require('parse/node').Parse,
PromiseRouter = require('./PromiseRouter'),
Schema = require('./Schema');
var router = new PromiseRouter();
function mongoFieldTypeToSchemaAPIType(type) {
if (type[0] === '*') {
return {
type: 'Pointer',
targetClass: type.slice(1),
};
}
if (type.startsWith('relation<')) {
return {
type: 'Relation',
targetClass: type.slice('relation<'.length, type.length - 1),
};
}
switch (type) {
case 'number': return {type: 'Number'};
case 'string': return {type: 'String'};
case 'boolean': return {type: 'Boolean'};
case 'date': return {type: 'Date'};
case 'map':
case 'object': return {type: 'Object'};
case 'array': return {type: 'Array'};
case 'geopoint': return {type: 'GeoPoint'};
case 'file': return {type: 'File'};
}
}
function mongoSchemaAPIResponseFields(schema) {
var fieldNames = Object.keys(schema).filter(key => key !== '_id' && key !== '_metadata');
var response = fieldNames.reduce((obj, fieldName) => {
obj[fieldName] = mongoFieldTypeToSchemaAPIType(schema[fieldName])
return obj;
}, {});
response.ACL = {type: 'ACL'};
response.createdAt = {type: 'Date'};
response.updatedAt = {type: 'Date'};
response.objectId = {type: 'String'};
return response;
}
function mongoSchemaToSchemaAPIResponse(schema) {
return {
className: schema._id,
fields: mongoSchemaAPIResponseFields(schema),
};
}
function getAllSchemas(req) {
if (!req.auth.isMaster) {
return Promise.resolve({
status: 401,
response: {error: 'master key not specified'},
});
}
return req.config.database.collection('_SCHEMA')
.then(coll => coll.find({}).toArray())
.then(schemas => ({response: {
results: schemas.map(mongoSchemaToSchemaAPIResponse)
}}));
}
function getOneSchema(req) {
if (!req.auth.isMaster) {
return Promise.resolve({
status: 401,
response: {error: 'unauthorized'},
});
}
return req.config.database.collection('_SCHEMA')
.then(coll => coll.findOne({'_id': req.params.className}))
.then(schema => ({response: mongoSchemaToSchemaAPIResponse(schema)}))
.catch(() => ({
status: 400,
response: {
code: 103,
error: 'class ' + req.params.className + ' does not exist',
}
}));
}
function createSchema(req) {
if (!req.auth.isMaster) {
return Promise.resolve({
status: 401,
response: {error: 'master key not specified'},
});
}
if (req.params.className && req.body.className) {
if (req.params.className != req.body.className) {
return Promise.resolve({
status: 400,
response: {
code: Parse.Error.INVALID_CLASS_NAME,
error: 'class name mismatch between ' + req.body.className + ' and ' + req.params.className,
},
});
}
}
var className = req.params.className || req.body.className;
if (!className) {
return Promise.resolve({
status: 400,
response: {
code: 135,
error: 'POST ' + req.path + ' needs class name',
},
});
}
return req.config.database.loadSchema()
.then(schema => schema.addClassIfNotExists(className, req.body.fields))
.then(result => ({ response: mongoSchemaToSchemaAPIResponse(result) }))
.catch(error => ({
status: 400,
response: error,
}));
}
router.route('GET', '/schemas', getAllSchemas);
router.route('GET', '/schemas/:className', getOneSchema);
router.route('POST', '/schemas', createSchema);
router.route('POST', '/schemas/:className', createSchema);
module.exports = router;

122
src/sessions.js Normal file
View File

@@ -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;

73
src/testing-routes.js Normal file
View File

@@ -0,0 +1,73 @@
// testing-routes.js
var express = require('express'),
cache = require('./cache'),
middlewares = require('./middlewares'),
rack = require('hat').rack();
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);
}
// 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({});
});
}
// 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({});
});
}
// Lets just return a success response and see what happens.
function notImplementedYet(req, res) {
res.status(200).send({});
}
router.post('/rest_clear_app',
middlewares.handleParseHeaders, clearApp);
router.post('/rest_block',
middlewares.handleParseHeaders, notImplementedYet);
router.post('/rest_mock_v8_client',
middlewares.handleParseHeaders, notImplementedYet);
router.post('/rest_unmock_v8_client',
middlewares.handleParseHeaders, notImplementedYet);
router.post('/rest_verify_analytics',
middlewares.handleParseHeaders, notImplementedYet);
router.post('/rest_create_app', createApp);
router.post('/rest_drop_app',
middlewares.handleParseHeaders, dropApp);
router.post('/rest_configure_app',
middlewares.handleParseHeaders, notImplementedYet);
module.exports = {
router: router
};

809
src/transform.js Normal file
View File

@@ -0,0 +1,809 @@
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}.
export 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: { '$all' : [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':
return atom;
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 (DateCoder.isValidJSON(atom)) {
return DateCoder.JSONToDatabase(atom);
}
if (BytesCoder.isValidJSON(atom)) {
return BytesCoder.JSONToDatabase(atom);
}
if (GeoPointCoder.isValidJSON(atom)) {
return (inArray || inObject ? atom : GeoPointCoder.JSONToDatabase(atom));
}
if (FileCoder.isValidJSON(atom)) {
return (inArray || inObject ? atom : FileCoder.JSONToDatabase(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 (BytesCoder.isValidDatabaseObject(mongoObject)) {
return BytesCoder.databaseToJSON(mongoObject);
}
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 expectedType = schema.getExpectedType(className, key);
var value = mongoObject[key];
if (expectedType === 'file' && FileCoder.isValidDatabaseObject(value)) {
restObject[key] = FileCoder.databaseToJSON(value);
break;
}
if (expectedType === 'geopoint' && GeoPointCoder.isValidDatabaseObject(value)) {
restObject[key] = GeoPointCoder.databaseToJSON(value);
break;
}
}
restObject[key] = untransformObject(schema, className,
mongoObject[key]);
}
}
return restObject;
default:
throw 'unknown js type';
}
}
var DateCoder = {
JSONToDatabase(json) {
return new Date(json.iso);
},
isValidJSON(value) {
return (typeof value === 'object' &&
value !== null &&
value.__type === 'Date'
);
}
};
var BytesCoder = {
databaseToJSON(object) {
return {
__type: 'Bytes',
base64: object.buffer.toString('base64')
};
},
isValidDatabaseObject(object) {
return (object instanceof mongodb.Binary);
},
JSONToDatabase(json) {
return new mongodb.Binary(new Buffer(json.base64, 'base64'));
},
isValidJSON(value) {
return (typeof value === 'object' &&
value !== null &&
value.__type === 'Bytes'
);
}
};
var GeoPointCoder = {
databaseToJSON(object) {
return {
__type: 'GeoPoint',
latitude: object[1],
longitude: object[0]
}
},
isValidDatabaseObject(object) {
return (object instanceof Array &&
object.length == 2
);
},
JSONToDatabase(json) {
return [ json.longitude, json.latitude ];
},
isValidJSON(value) {
return (typeof value === 'object' &&
value !== null &&
value.__type === 'GeoPoint'
);
}
};
var FileCoder = {
databaseToJSON(object) {
return {
__type: 'File',
name: object
}
},
isValidDatabaseObject(object) {
return (typeof object === 'string');
},
JSONToDatabase(json) {
return json.name;
},
isValidJSON(value) {
return (typeof value === 'object' &&
value !== null &&
value.__type === 'File'
);
}
};
module.exports = {
transformKey: transformKey,
transformCreate: transformCreate,
transformUpdate: transformUpdate,
transformWhere: transformWhere,
untransformObject: untransformObject
};

100
src/triggers.js Normal file
View File

@@ -0,0 +1,100 @@
// 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) {
var scriptError = new Parse.Error(Parse.Error.SCRIPT_FAILED, error);
return reject(scriptError);
}
}
};
// 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
};

207
src/users.js Normal file
View File

@@ -0,0 +1,207 @@
// 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 deepcopy = require('deepcopy');
var router = new PromiseRouter();
// Returns a promise for a {status, response, location} object.
function handleCreate(req) {
var data = deepcopy(req.body);
data.installationId = req.info.installationId;
return rest.create(req.config, req.auth,
'_User', data);
}
// 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 handleLogOut(req) {
var success = {response: {}};
if (req.info && req.info.sessionToken) {
rest.find(req.config, Auth.master(req.config), '_Session',
{_session_token: req.info.sessionToken}
).then((records) => {
if (records.results && records.results.length) {
rest.del(req.config, Auth.master(req.config), '_Session',
records.results[0].id
);
}
});
}
return Promise.resolve(success);
}
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('POST', '/logout', handleLogOut);
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;