Merged with master
This commit is contained in:
95
src/APNS.js
Normal file
95
src/APNS.js
Normal 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
171
src/Auth.js
Normal 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
28
src/Config.js
Normal 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
56
src/DatabaseAdapter.js
Normal 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
577
src/ExportAdapter.js
Normal 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
29
src/FilesAdapter.js
Normal 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
82
src/GCM.js
Normal 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
48
src/GridStoreAdapter.js
Normal 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
148
src/PromiseRouter.js
Normal 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
555
src/RestQuery.js
Normal 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
733
src/RestWrite.js
Normal 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
77
src/S3Adapter.js
Normal 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
565
src/Schema.js
Normal 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
20
src/analytics.js
Normal 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
72
src/batch.js
Normal 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
37
src/cache.js
Normal 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
101
src/classes.js
Normal 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
104
src/cloud/main.js
Normal 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
57
src/facebook.js
Normal 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
85
src/files.js
Normal 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
51
src/functions.js
Normal 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
43
src/httpRequest.js
Normal 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
173
src/index.js
Normal 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
80
src/installations.js
Normal 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
192
src/middlewares.js
Normal 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
35
src/password.js
Normal 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
124
src/push.js
Normal 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
129
src/rest.js
Normal 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
48
src/roles.js
Normal 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
131
src/schemas.js
Normal 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
122
src/sessions.js
Normal 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
73
src/testing-routes.js
Normal 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
809
src/transform.js
Normal 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
100
src/triggers.js
Normal 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
207
src/users.js
Normal 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;
|
||||
Reference in New Issue
Block a user