829 lines
27 KiB
JavaScript
829 lines
27 KiB
JavaScript
// A RestWrite encapsulates everything we need to run an operation
|
||
// that writes to the database.
|
||
// This could be either a "create" or an "update".
|
||
|
||
import cache from './cache';
|
||
var Schema = require('./Schema');
|
||
var deepcopy = require('deepcopy');
|
||
|
||
var Auth = require('./Auth');
|
||
var Config = require('./Config');
|
||
var cryptoUtils = require('./cryptoUtils');
|
||
var passwordCrypto = require('./password');
|
||
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 = {};
|
||
this.runOptions = {};
|
||
|
||
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;
|
||
}
|
||
|
||
// 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.getUserAndRoleACL();
|
||
}).then(() => {
|
||
return this.validateClientClassCreation();
|
||
}).then(() => {
|
||
return this.validateSchema();
|
||
}).then(() => {
|
||
return this.handleInstallation();
|
||
}).then(() => {
|
||
return this.handleSession();
|
||
}).then(() => {
|
||
return this.validateAuthData();
|
||
}).then(() => {
|
||
return this.runBeforeTrigger();
|
||
}).then(() => {
|
||
return this.setRequiredFieldsIfNeeded();
|
||
}).then(() => {
|
||
return this.transformUser();
|
||
}).then(() => {
|
||
return this.expandFilesForExistingObjects();
|
||
}).then(() => {
|
||
return this.runDatabaseOperation();
|
||
}).then(() => {
|
||
return this.handleFollowup();
|
||
}).then(() => {
|
||
return this.runAfterTrigger();
|
||
}).then(() => {
|
||
return this.response;
|
||
});
|
||
};
|
||
|
||
// Uses the Auth object to get the list of roles, adds the user id
|
||
RestWrite.prototype.getUserAndRoleACL = function() {
|
||
if (this.auth.isMaster) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
this.runOptions.acl = ['*'];
|
||
|
||
if (this.auth.user) {
|
||
return this.auth.getUserRoles().then((roles) => {
|
||
roles.push(this.auth.user.id);
|
||
this.runOptions.acl = this.runOptions.acl.concat(roles);
|
||
return Promise.resolve();
|
||
});
|
||
}else{
|
||
return Promise.resolve();
|
||
}
|
||
};
|
||
|
||
// Validates this operation against the allowClientClassCreation config.
|
||
RestWrite.prototype.validateClientClassCreation = function() {
|
||
let sysClass = Schema.systemClasses;
|
||
if (this.config.allowClientClassCreation === false && !this.auth.isMaster
|
||
&& sysClass.indexOf(this.className) === -1) {
|
||
return this.config.database.collectionExists(this.className).then((hasClass) => {
|
||
if (hasClass === true) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN,
|
||
'This user is not allowed to access ' +
|
||
'non-existent class: ' + this.className);
|
||
});
|
||
} else {
|
||
return Promise.resolve();
|
||
}
|
||
};
|
||
|
||
// Validates this operation against the schema.
|
||
RestWrite.prototype.validateSchema = function() {
|
||
return this.config.database.validateObject(this.className, this.data, this.query, this.runOptions);
|
||
};
|
||
|
||
// Runs any beforeSave triggers against this operation.
|
||
// Any change leads to our data being mutated.
|
||
RestWrite.prototype.runBeforeTrigger = function() {
|
||
if (this.response) {
|
||
return;
|
||
}
|
||
|
||
// Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class.
|
||
if (!triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId)) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
let originalObject = null;
|
||
let updatedObject = triggers.inflate(extraData, this.originalData);
|
||
if (this.query && this.query.objectId) {
|
||
// This is an update for existing object.
|
||
originalObject = triggers.inflate(extraData, this.originalData);
|
||
}
|
||
updatedObject.set(this.sanitizedData());
|
||
|
||
return Promise.resolve().then(() => {
|
||
return triggers.maybeRunTrigger(triggers.Types.beforeSave, this.auth, updatedObject, originalObject, this.config.applicationId);
|
||
}).then((response) => {
|
||
if (response && response.object) {
|
||
this.data = response.object;
|
||
this.storage['changedByTrigger'] = true;
|
||
// We should delete the objectId for an update write
|
||
if (this.query && this.query.objectId) {
|
||
delete this.data.objectId
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
RestWrite.prototype.setRequiredFieldsIfNeeded = function() {
|
||
if (this.data) {
|
||
// Add default fields
|
||
this.data.updatedAt = this.updatedAt;
|
||
if (!this.query) {
|
||
this.data.createdAt = this.updatedAt;
|
||
|
||
// Only assign new objectId if we are creating new object
|
||
if (!this.data.objectId) {
|
||
this.data.objectId = cryptoUtils.newObjectId();
|
||
}
|
||
}
|
||
}
|
||
return Promise.resolve();
|
||
};
|
||
|
||
// 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 || !Object.keys(this.data.authData).length) {
|
||
return;
|
||
}
|
||
|
||
var authData = this.data.authData;
|
||
var providers = Object.keys(authData);
|
||
if (providers.length > 0) {
|
||
let canHandleAuthData = providers.reduce((canHandle, provider) => {
|
||
var providerAuthData = authData[provider];
|
||
var hasToken = (providerAuthData && providerAuthData.id);
|
||
return canHandle && (hasToken || providerAuthData == null);
|
||
}, true);
|
||
if (canHandleAuthData) {
|
||
return this.handleAuthData(authData);
|
||
}
|
||
}
|
||
throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE,
|
||
'This authentication method is unsupported.');
|
||
};
|
||
|
||
RestWrite.prototype.handleAuthDataValidation = function(authData) {
|
||
let validations = Object.keys(authData).map((provider) => {
|
||
if (authData[provider] === null) {
|
||
return Promise.resolve();
|
||
}
|
||
let validateAuthData = this.config.authDataManager.getValidatorForProvider(provider);
|
||
if (!validateAuthData) {
|
||
throw new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE,
|
||
'This authentication method is unsupported.');
|
||
};
|
||
return validateAuthData(authData[provider]);
|
||
});
|
||
return Promise.all(validations);
|
||
}
|
||
|
||
RestWrite.prototype.findUsersWithAuthData = function(authData) {
|
||
let providers = Object.keys(authData);
|
||
let query = providers.reduce((memo, provider) => {
|
||
if (!authData[provider]) {
|
||
return memo;
|
||
}
|
||
let queryKey = `authData.${provider}.id`;
|
||
let query = {};
|
||
query[queryKey] = authData[provider].id;
|
||
memo.push(query);
|
||
return memo;
|
||
}, []).filter((q) => {
|
||
return typeof q !== undefined;
|
||
});
|
||
|
||
let findPromise = Promise.resolve([]);
|
||
if (query.length > 0) {
|
||
findPromise = this.config.database.find(
|
||
this.className,
|
||
{'$or': query}, {})
|
||
}
|
||
|
||
return findPromise;
|
||
}
|
||
|
||
|
||
RestWrite.prototype.handleAuthData = function(authData) {
|
||
let results;
|
||
return this.handleAuthDataValidation(authData).then(() => {
|
||
return this.findUsersWithAuthData(authData);
|
||
}).then((r) => {
|
||
results = r;
|
||
if (results.length > 1) {
|
||
// More than 1 user with the passed id's
|
||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED,
|
||
'this auth is already used');
|
||
}
|
||
|
||
this.storage['authProvider'] = Object.keys(authData).join(',');
|
||
|
||
if (results.length == 0) {
|
||
this.data.username = cryptoUtils.newToken();
|
||
} else if (!this.query) {
|
||
// Login with auth data
|
||
// Short circuit
|
||
delete results[0].password;
|
||
// need to set the objectId first otherwise location has trailing undefined
|
||
this.data.objectId = results[0].objectId;
|
||
this.response = {
|
||
response: results[0],
|
||
location: this.location()
|
||
};
|
||
} else if (this.query && this.query.objectId) {
|
||
// Trying to update auth data but users
|
||
// are different
|
||
if (results[0].objectId !== this.query.objectId) {
|
||
throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED,
|
||
'this auth is already used');
|
||
}
|
||
}
|
||
return Promise.resolve();
|
||
});
|
||
}
|
||
|
||
// 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:' + cryptoUtils.newToken();
|
||
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();
|
||
});
|
||
}
|
||
|
||
// If we're updating a _User object, clear the user cache for the session
|
||
if (this.query && this.auth.user && this.auth.user.getSessionToken()) {
|
||
cache.users.remove(this.auth.user.getSessionToken());
|
||
}
|
||
|
||
return promise.then(() => {
|
||
// Transform the password
|
||
if (!this.data.password) {
|
||
return;
|
||
}
|
||
if (this.query && !this.auth.isMaster ) {
|
||
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) {
|
||
this.data.username = cryptoUtils.randomString(25);
|
||
}
|
||
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();
|
||
}).then(() => {
|
||
// We updated the email, send a new validation
|
||
this.storage['sendVerificationEmail'] = true;
|
||
this.config.userController.setEmailVerifyToken(this.data);
|
||
return Promise.resolve();
|
||
})
|
||
});
|
||
};
|
||
|
||
// Handles any followup logic
|
||
RestWrite.prototype.handleFollowup = function() {
|
||
|
||
if (this.storage && this.storage['clearSessions']) {
|
||
var sessionQuery = {
|
||
user: {
|
||
__type: 'Pointer',
|
||
className: '_User',
|
||
objectId: this.objectId()
|
||
}
|
||
};
|
||
delete this.storage['clearSessions'];
|
||
this.config.database.destroy('_Session', sessionQuery)
|
||
.then(this.handleFollowup.bind(this));
|
||
}
|
||
|
||
if (this.storage && this.storage['sendVerificationEmail']) {
|
||
delete this.storage['sendVerificationEmail'];
|
||
// Fire and forget!
|
||
this.config.userController.sendVerificationEmail(this.data);
|
||
this.handleFollowup.bind(this);
|
||
}
|
||
};
|
||
|
||
// Handles the _Role class specialness.
|
||
// 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:' + cryptoUtils.newToken();
|
||
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();
|
||
}
|
||
|
||
var promise = Promise.resolve();
|
||
|
||
var idMatch; // Will be a match on either objectId or installationId
|
||
var deviceTokenMatches = [];
|
||
|
||
if (this.query && this.query.objectId) {
|
||
promise = promise.then(() => {
|
||
return this.config.database.find('_Installation', {
|
||
objectId: this.query.objectId
|
||
}, {}).then((results) => {
|
||
if (!results.length) {
|
||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND,
|
||
'Object not found for update.');
|
||
}
|
||
idMatch = results[0];
|
||
if (this.data.installationId && idMatch.installationId &&
|
||
this.data.installationId !== idMatch.installationId) {
|
||
throw new Parse.Error(136,
|
||
'installationId may not be changed in this ' +
|
||
'operation');
|
||
}
|
||
if (this.data.deviceToken && idMatch.deviceToken &&
|
||
this.data.deviceToken !== idMatch.deviceToken &&
|
||
!this.data.installationId && !idMatch.installationId) {
|
||
throw new Parse.Error(136,
|
||
'deviceToken may not be changed in this ' +
|
||
'operation');
|
||
}
|
||
if (this.data.deviceType && this.data.deviceType &&
|
||
this.data.deviceType !== idMatch.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
|
||
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
|
||
idMatch = 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 (!idMatch) {
|
||
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: idMatch.objectId};
|
||
return this.config.database.destroy('_Installation', delQuery)
|
||
.then(() => {
|
||
return deviceTokenMatches[0]['objectId'];
|
||
});
|
||
} else {
|
||
if (this.data.deviceToken &&
|
||
idMatch.deviceToken != this.data.deviceToken) {
|
||
// We're setting the device token on an existing installation, so
|
||
// we should try cleaning out old installations that match this
|
||
// device token.
|
||
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 idMatch.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;
|
||
};
|
||
|
||
// If we short-circuted the object response - then we need to make sure we expand all the files,
|
||
// since this might not have a query, meaning it won't return the full result back.
|
||
// TODO: (nlutsenko) This should die when we move to per-class based controllers on _Session/_User
|
||
RestWrite.prototype.expandFilesForExistingObjects = function() {
|
||
// Check whether we have a short-circuited response - only then run expansion.
|
||
if (this.response && this.response.response) {
|
||
this.config.filesController.expandFilesInObject(this.config, this.response.response);
|
||
}
|
||
};
|
||
|
||
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.query.objectId);
|
||
}
|
||
|
||
if (this.className === '_Product' && this.data.download) {
|
||
this.data.downloadName = this.data.download.name;
|
||
}
|
||
|
||
// 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.');
|
||
}
|
||
|
||
if (this.query) {
|
||
// Run an update
|
||
return this.config.database.update(
|
||
this.className, this.query, this.data, this.runOptions).then((resp) => {
|
||
resp.updatedAt = this.updatedAt;
|
||
if (this.storage['changedByTrigger']) {
|
||
resp = Object.keys(this.data).reduce((memo, key) => {
|
||
memo[key] = resp[key] || this.data[key];
|
||
return memo;
|
||
}, resp);
|
||
}
|
||
this.response = {
|
||
response: resp
|
||
};
|
||
});
|
||
} else {
|
||
// Set the default ACL for the new _User
|
||
if (!this.data.ACL && this.className === '_User') {
|
||
var ACL = {};
|
||
ACL[this.data.objectId] = { read: true, write: true };
|
||
ACL['*'] = { read: true, write: false };
|
||
this.data.ACL = ACL;
|
||
}
|
||
// Run a create
|
||
return this.config.database.create(this.className, this.data, this.runOptions)
|
||
.then((resp) => {
|
||
Object.assign(resp, {
|
||
objectId: this.data.objectId,
|
||
createdAt: this.data.createdAt
|
||
});
|
||
if (this.storage['changedByTrigger']) {
|
||
resp = Object.keys(this.data).reduce((memo, key) => {
|
||
memo[key] = resp[key] || this.data[key];
|
||
return memo;
|
||
}, resp);
|
||
}
|
||
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() {
|
||
if (!this.response || !this.response.response) {
|
||
return;
|
||
}
|
||
|
||
// Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class.
|
||
let hasAfterSaveHook = triggers.triggerExists(this.className, triggers.Types.afterSave, this.config.applicationId);
|
||
let hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className);
|
||
if (!hasAfterSaveHook && !hasLiveQuery) {
|
||
return Promise.resolve();
|
||
}
|
||
|
||
var extraData = {className: this.className};
|
||
if (this.query && this.query.objectId) {
|
||
extraData.objectId = this.query.objectId;
|
||
}
|
||
|
||
// Build the original object, we only do this for a update write.
|
||
let originalObject;
|
||
if (this.query && this.query.objectId) {
|
||
originalObject = triggers.inflate(extraData, this.originalData);
|
||
}
|
||
|
||
// Build the inflated object, different from beforeSave, originalData is not empty
|
||
// since developers can change data in the beforeSave.
|
||
let updatedObject = triggers.inflate(extraData, this.originalData);
|
||
updatedObject.set(this.sanitizedData());
|
||
updatedObject._handleSaveResponse(this.response.response, this.response.status || 200);
|
||
|
||
// Notifiy LiveQueryServer if possible
|
||
this.config.liveQueryController.onAfterSave(updatedObject.className, updatedObject, originalObject);
|
||
|
||
// Run afterSave trigger
|
||
triggers.maybeRunTrigger(triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config.applicationId);
|
||
};
|
||
|
||
// 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 copy of the data and delete bad keys (_auth_data, _hashed_password...)
|
||
RestWrite.prototype.sanitizedData = function() {
|
||
let data = Object.keys(this.data).reduce((data, key) => {
|
||
// Regexp comes from Parse.Object.prototype.validate
|
||
if (!(/^[A-Za-z][0-9A-Za-z_]*$/).test(key)) {
|
||
delete data[key];
|
||
}
|
||
return data;
|
||
}, deepcopy(this.data));
|
||
return Parse._decode(undefined, data);
|
||
}
|
||
|
||
export default RestWrite;
|
||
module.exports = RestWrite;
|