mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-27 22:49:56 -05:00
d6aaf2dbc7
no issue - if you upload a huge import file, parallel operations can throw errors e.g. lock wait exceeds - this can happen if multiple transactions run in parallel - there is no need to run: 1. the removal of active tokens on import, because imported users have no active session 2. rescheduling logic on timezone, because importing scheduled posts works out of the box via the model layer (if a published date is detected and it's in the future, the post get's scheduled)
802 lines
29 KiB
JavaScript
802 lines
29 KiB
JavaScript
var _ = require('lodash'),
|
|
Promise = require('bluebird'),
|
|
bcrypt = require('bcryptjs'),
|
|
validator = require('validator'),
|
|
ObjectId = require('bson-objectid'),
|
|
ghostBookshelf = require('./base'),
|
|
baseUtils = require('./base/utils'),
|
|
errors = require('../errors'),
|
|
logging = require('../logging'),
|
|
utils = require('../utils'),
|
|
gravatar = require('../utils/gravatar'),
|
|
validation = require('../data/validation'),
|
|
events = require('../events'),
|
|
i18n = require('../i18n'),
|
|
pipeline = require('../utils/pipeline'),
|
|
|
|
bcryptGenSalt = Promise.promisify(bcrypt.genSalt),
|
|
bcryptHash = Promise.promisify(bcrypt.hash),
|
|
bcryptCompare = Promise.promisify(bcrypt.compare),
|
|
activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4'],
|
|
/**
|
|
* inactive: owner user before blog setup, suspended users
|
|
* locked user: imported users, they get a random passport
|
|
*/
|
|
inactiveStates = ['inactive', 'locked'],
|
|
allStates = activeStates.concat(inactiveStates),
|
|
User,
|
|
Users;
|
|
|
|
function validatePasswordLength(password) {
|
|
return validator.isLength(password, 8);
|
|
}
|
|
|
|
/**
|
|
* generate a random salt and then hash the password with that salt
|
|
*/
|
|
function generatePasswordHash(password) {
|
|
return bcryptGenSalt().then(function (salt) {
|
|
return bcryptHash(password, salt);
|
|
});
|
|
}
|
|
|
|
User = ghostBookshelf.Model.extend({
|
|
|
|
tableName: 'users',
|
|
|
|
defaults: function defaults() {
|
|
var baseDefaults = ghostBookshelf.Model.prototype.defaults.call(this);
|
|
|
|
return _.merge({
|
|
password: utils.uid(50)
|
|
}, baseDefaults);
|
|
},
|
|
|
|
emitChange: function emitChange(event, options) {
|
|
events.emit('user' + '.' + event, this, options);
|
|
},
|
|
|
|
onDestroyed: function onDestroyed(model, response, options) {
|
|
if (_.includes(activeStates, model.previous('status'))) {
|
|
model.emitChange('deactivated', options);
|
|
}
|
|
|
|
model.emitChange('deleted');
|
|
},
|
|
|
|
onCreated: function onCreated(model) {
|
|
model.emitChange('added');
|
|
|
|
// active is the default state, so if status isn't provided, this will be an active user
|
|
if (!model.get('status') || _.includes(activeStates, model.get('status'))) {
|
|
model.emitChange('activated');
|
|
}
|
|
},
|
|
|
|
onUpdated: function onUpdated(model, response, options) {
|
|
model.statusChanging = model.get('status') !== model.updated('status');
|
|
model.isActive = _.includes(activeStates, model.get('status'));
|
|
|
|
if (model.statusChanging) {
|
|
model.emitChange(model.isActive ? 'activated' : 'deactivated', options);
|
|
} else {
|
|
if (model.isActive) {
|
|
model.emitChange('activated.edited');
|
|
}
|
|
}
|
|
|
|
model.emitChange('edited');
|
|
},
|
|
|
|
isActive: function isActive() {
|
|
return activeStates.indexOf(this.get('status')) !== -1;
|
|
},
|
|
|
|
isLocked: function isLocked() {
|
|
return this.get('status') === 'locked';
|
|
},
|
|
|
|
isInactive: function isInactive() {
|
|
return this.get('status') === 'inactive';
|
|
},
|
|
|
|
/**
|
|
* Lookup Gravatar if email changes to update image url
|
|
* Generating a slug requires a db call to look for conflicting slugs
|
|
*/
|
|
onSaving: function onSaving(newPage, attr, options) {
|
|
var self = this,
|
|
tasks = [];
|
|
|
|
ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
|
|
|
|
if (self.hasChanged('email') && self.get('email')) {
|
|
tasks.gravatar = (function lookUpGravatar() {
|
|
return gravatar.lookup({
|
|
email: self.get('email')
|
|
}).then(function (response) {
|
|
if (response && response.image) {
|
|
self.set('profile_image', response.image);
|
|
}
|
|
});
|
|
})();
|
|
}
|
|
|
|
if (this.hasChanged('slug') || !this.get('slug')) {
|
|
tasks.slug = (function generateSlug() {
|
|
return ghostBookshelf.Model.generateSlug(
|
|
User,
|
|
self.get('slug') || self.get('name'),
|
|
{
|
|
status: 'all',
|
|
transacting: options.transacting,
|
|
shortSlug: !self.get('slug')
|
|
})
|
|
.then(function then(slug) {
|
|
self.set({slug: slug});
|
|
});
|
|
})();
|
|
}
|
|
|
|
/**
|
|
* CASE: add model, hash password
|
|
* CASE: update model, hash password
|
|
*
|
|
* Important:
|
|
* - Password hashing happens when we import a database
|
|
* - we do some pre-validation checks, because onValidate is called AFTER onSaving
|
|
*/
|
|
if (self.isNew() || self.hasChanged('password')) {
|
|
this.set('password', String(this.get('password')));
|
|
|
|
if (!validatePasswordLength(this.get('password'))) {
|
|
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.models.user.passwordDoesNotComplyLength')}));
|
|
}
|
|
|
|
tasks.hashPassword = (function hashPassword() {
|
|
return generatePasswordHash(self.get('password'))
|
|
.then(function (hash) {
|
|
self.set('password', hash);
|
|
});
|
|
})();
|
|
}
|
|
|
|
return Promise.props(tasks);
|
|
},
|
|
|
|
// For the user model ONLY it is possible to disable validations.
|
|
// This is used to bypass validation during the credential check, and must never be done with user-provided data
|
|
// Should be removed when #3691 is done
|
|
onValidate: function validate() {
|
|
var opts = arguments[1],
|
|
userData;
|
|
|
|
if (opts && _.has(opts, 'validate') && opts.validate === false) {
|
|
return;
|
|
}
|
|
|
|
// use the base toJSON since this model's overridden toJSON
|
|
// removes fields and we want everything to run through the validator.
|
|
userData = ghostBookshelf.Model.prototype.toJSON.call(this);
|
|
|
|
return validation.validateSchema(this.tableName, userData);
|
|
},
|
|
|
|
toJSON: function toJSON(options) {
|
|
options = options || {};
|
|
|
|
var attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
|
|
// remove password hash for security reasons
|
|
delete attrs.password;
|
|
delete attrs.ghost_auth_access_token;
|
|
|
|
if (!options || !options.context || (!options.context.user && !options.context.internal)) {
|
|
delete attrs.email;
|
|
}
|
|
|
|
return attrs;
|
|
},
|
|
|
|
format: function format(options) {
|
|
if (!_.isEmpty(options.website) &&
|
|
!validator.isURL(options.website, {
|
|
require_protocol: true,
|
|
protocols: ['http', 'https']})) {
|
|
options.website = 'http://' + options.website;
|
|
}
|
|
return ghostBookshelf.Model.prototype.format.call(this, options);
|
|
},
|
|
|
|
posts: function posts() {
|
|
return this.hasMany('Posts', 'created_by');
|
|
},
|
|
|
|
roles: function roles() {
|
|
return this.belongsToMany('Role');
|
|
},
|
|
|
|
permissions: function permissions() {
|
|
return this.belongsToMany('Permission');
|
|
},
|
|
|
|
hasRole: function hasRole(roleName) {
|
|
var roles = this.related('roles');
|
|
|
|
return roles.some(function getRole(role) {
|
|
return role.get('name') === roleName;
|
|
});
|
|
},
|
|
|
|
enforcedFilters: function enforcedFilters() {
|
|
if (this.isInternalContext()) {
|
|
return null;
|
|
}
|
|
|
|
return this.isPublicContext() ? 'status:[' + allStates.join(',') + ']' : null;
|
|
},
|
|
|
|
defaultFilters: function defaultFilters() {
|
|
if (this.isInternalContext()) {
|
|
return null;
|
|
}
|
|
|
|
return this.isPublicContext() ? null : 'status:[' + allStates.join(',') + ']';
|
|
}
|
|
}, {
|
|
orderDefaultOptions: function orderDefaultOptions() {
|
|
return {
|
|
last_seen: 'DESC',
|
|
name: 'ASC',
|
|
created_at: 'DESC'
|
|
};
|
|
},
|
|
|
|
/**
|
|
* @deprecated in favour of filter
|
|
*/
|
|
processOptions: function processOptions(options) {
|
|
if (!options.status) {
|
|
return options;
|
|
}
|
|
|
|
// This is the only place that 'options.where' is set now
|
|
options.where = {statements: []};
|
|
|
|
var value;
|
|
|
|
// Filter on the status. A status of 'all' translates to no filter since we want all statuses
|
|
if (options.status !== 'all') {
|
|
// make sure that status is valid
|
|
options.status = allStates.indexOf(options.status) > -1 ? options.status : 'active';
|
|
}
|
|
|
|
if (options.status === 'active') {
|
|
value = activeStates;
|
|
} else if (options.status === 'all') {
|
|
value = allStates;
|
|
} else {
|
|
value = options.status;
|
|
}
|
|
|
|
options.where.statements.push({prop: 'status', op: 'IN', value: value});
|
|
delete options.status;
|
|
|
|
return options;
|
|
},
|
|
|
|
/**
|
|
* Returns an array of keys permitted in a method's `options` hash, depending on the current method.
|
|
* @param {String} methodName The name of the method to check valid options for.
|
|
* @return {Array} Keys allowed in the `options` hash of the model's method.
|
|
*/
|
|
permittedOptions: function permittedOptions(methodName) {
|
|
var options = ghostBookshelf.Model.permittedOptions(),
|
|
|
|
// whitelists for the `options` hash argument on methods, by method name.
|
|
// these are the only options that can be passed to Bookshelf / Knex.
|
|
validOptions = {
|
|
findOne: ['withRelated', 'status'],
|
|
setup: ['id'],
|
|
edit: ['withRelated', 'id'],
|
|
findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status'],
|
|
findAll: ['filter']
|
|
};
|
|
|
|
if (validOptions[methodName]) {
|
|
options = options.concat(validOptions[methodName]);
|
|
}
|
|
|
|
return options;
|
|
},
|
|
|
|
/**
|
|
* ### Find One
|
|
*
|
|
* We have to clone the data, because we remove values from this object.
|
|
* This is not expected from outside!
|
|
*
|
|
* @extends ghostBookshelf.Model.findOne to include roles
|
|
* **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One)
|
|
*/
|
|
findOne: function findOne(dataToClone, options) {
|
|
var query,
|
|
status,
|
|
optInc,
|
|
data = _.cloneDeep(dataToClone),
|
|
lookupRole = data.role;
|
|
|
|
delete data.role;
|
|
data = _.defaults(data || {}, {
|
|
status: 'all'
|
|
});
|
|
|
|
status = data.status;
|
|
delete data.status;
|
|
|
|
options = options || {};
|
|
optInc = options.include;
|
|
options.withRelated = _.union(options.withRelated, options.include);
|
|
data = this.filterData(data);
|
|
|
|
// Support finding by role
|
|
if (lookupRole) {
|
|
options.withRelated = _.union(options.withRelated, ['roles']);
|
|
options.include = _.union(options.include, ['roles']);
|
|
|
|
query = this.forge(data, {include: options.include});
|
|
|
|
query.query('join', 'roles_users', 'users.id', '=', 'roles_users.user_id');
|
|
query.query('join', 'roles', 'roles_users.role_id', '=', 'roles.id');
|
|
query.query('where', 'roles.name', '=', lookupRole);
|
|
} else {
|
|
// We pass include to forge so that toJSON has access
|
|
query = this.forge(data, {include: options.include});
|
|
}
|
|
|
|
if (status === 'active') {
|
|
query.query('whereIn', 'status', activeStates);
|
|
} else if (status !== 'all') {
|
|
query.query('where', {status: status});
|
|
}
|
|
|
|
options = this.filterOptions(options, 'findOne');
|
|
delete options.include;
|
|
options.include = optInc;
|
|
|
|
return query.fetch(options);
|
|
},
|
|
|
|
/**
|
|
* ### Edit
|
|
*
|
|
* Note: In case of login the last_seen attribute gets updated.
|
|
*
|
|
* @extends ghostBookshelf.Model.edit to handle returning the full object
|
|
* **See:** [ghostBookshelf.Model.edit](base.js.html#edit)
|
|
*/
|
|
edit: function edit(data, options) {
|
|
var self = this,
|
|
ops = [];
|
|
|
|
if (data.roles && data.roles.length > 1) {
|
|
return Promise.reject(
|
|
new errors.ValidationError({message: i18n.t('errors.models.user.onlyOneRolePerUserSupported')})
|
|
);
|
|
}
|
|
|
|
options = options || {};
|
|
options.withRelated = _.union(options.withRelated, options.include);
|
|
|
|
if (data.email) {
|
|
ops.push(function checkForDuplicateEmail() {
|
|
return self.getByEmail(data.email, options).then(function then(user) {
|
|
if (user && user.id !== options.id) {
|
|
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.models.user.userUpdateError.emailIsAlreadyInUse')}));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
ops.push(function update() {
|
|
return ghostBookshelf.Model.edit.call(self, data, options).then(function then(user) {
|
|
var roleId;
|
|
|
|
if (!data.roles) {
|
|
return user;
|
|
}
|
|
|
|
roleId = data.roles[0].id || data.roles[0];
|
|
|
|
return user.roles().fetch().then(function then(roles) {
|
|
// return if the role is already assigned
|
|
if (roles.models[0].id === roleId) {
|
|
return;
|
|
}
|
|
return ghostBookshelf.model('Role').findOne({id: roleId});
|
|
}).then(function then(roleToAssign) {
|
|
if (roleToAssign && roleToAssign.get('name') === 'Owner') {
|
|
return Promise.reject(
|
|
new errors.ValidationError({message: i18n.t('errors.models.user.methodDoesNotSupportOwnerRole')})
|
|
);
|
|
} else {
|
|
// assign all other roles
|
|
return user.roles().updatePivot({role_id: roleId});
|
|
}
|
|
}).then(function then() {
|
|
options.status = 'all';
|
|
return self.findOne({id: user.id}, options);
|
|
});
|
|
});
|
|
});
|
|
|
|
return pipeline(ops);
|
|
},
|
|
|
|
/**
|
|
* ## Add
|
|
* Naive user add
|
|
* Hashes the password provided before saving to the database.
|
|
*
|
|
* We have to clone the data, because we remove values from this object.
|
|
* This is not expected from outside!
|
|
*
|
|
* @param {object} dataToClone
|
|
* @param {object} options
|
|
* @extends ghostBookshelf.Model.add to manage all aspects of user signup
|
|
* **See:** [ghostBookshelf.Model.add](base.js.html#Add)
|
|
*/
|
|
add: function add(dataToClone, options) {
|
|
var self = this,
|
|
data = _.cloneDeep(dataToClone),
|
|
userData = this.filterData(data),
|
|
roles;
|
|
|
|
options = this.filterOptions(options, 'add');
|
|
options.withRelated = _.union(options.withRelated, options.include);
|
|
|
|
// check for too many roles
|
|
if (data.roles && data.roles.length > 1) {
|
|
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.models.user.onlyOneRolePerUserSupported')}));
|
|
}
|
|
|
|
function getAuthorRole() {
|
|
return ghostBookshelf.model('Role').findOne({name: 'Author'}, _.pick(options, 'transacting'))
|
|
.then(function then(authorRole) {
|
|
return [authorRole.get('id')];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* We need this default author role because of the following Ghost feature:
|
|
* You setup your blog and you can invite people instantly, but without choosing a role.
|
|
* roles: [] -> no default role (used for owner creation, see fixtures.json)
|
|
* roles: undefined -> default role
|
|
*/
|
|
roles = data.roles;
|
|
delete data.roles;
|
|
|
|
return ghostBookshelf.Model.add.call(self, userData, options)
|
|
.then(function then(addedUser) {
|
|
// Assign the userData to our created user so we can pass it back
|
|
userData = addedUser;
|
|
})
|
|
.then(function () {
|
|
if (!roles) {
|
|
return getAuthorRole();
|
|
}
|
|
|
|
return Promise.resolve(roles);
|
|
})
|
|
.then(function (_roles) {
|
|
roles = _roles;
|
|
|
|
// CASE: it is possible to add roles by name, by id or by object
|
|
if (_.isString(roles[0]) && !ObjectId.isValid(roles[0])) {
|
|
return Promise.map(roles, function (roleName) {
|
|
return ghostBookshelf.model('Role').findOne({
|
|
name: roleName
|
|
}, options);
|
|
}).then(function (roleModels) {
|
|
roles = [];
|
|
|
|
_.each(roleModels, function (roleModel) {
|
|
roles.push(roleModel.id);
|
|
});
|
|
});
|
|
}
|
|
|
|
return Promise.resolve();
|
|
})
|
|
.then(function () {
|
|
return baseUtils.attach(User, userData.id, 'roles', roles, options);
|
|
})
|
|
.then(function then() {
|
|
// find and return the added user
|
|
return self.findOne({id: userData.id, status: 'all'}, options);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* We override the owner!
|
|
* Owner already has a slug -> force setting a new one by setting slug to null
|
|
* @TODO: kill setup function?
|
|
*/
|
|
setup: function setup(data, options) {
|
|
var self = this,
|
|
userData = this.filterData(data);
|
|
|
|
if (!validatePasswordLength(userData.password)) {
|
|
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.models.user.passwordDoesNotComplyLength')}));
|
|
}
|
|
|
|
options = this.filterOptions(options, 'setup');
|
|
options.withRelated = _.union(options.withRelated, options.include);
|
|
|
|
userData.slug = null;
|
|
return self.edit.call(self, userData, options);
|
|
},
|
|
|
|
/**
|
|
* Right now the setup of the blog depends on the user status.
|
|
* @TODO: see https://github.com/TryGhost/Ghost/issues/8003
|
|
*/
|
|
isSetup: function isSetup() {
|
|
return this
|
|
.where('status', 'in', activeStates)
|
|
.where('id', this.ownerUser)
|
|
.count('id')
|
|
.then(function (count) {
|
|
return !!count;
|
|
});
|
|
},
|
|
|
|
permissible: function permissible(userModelOrId, action, context, loadedPermissions, hasUserPermission, hasAppPermission) {
|
|
var self = this,
|
|
userModel = userModelOrId,
|
|
origArgs;
|
|
|
|
// If we passed in a model without its related roles, we need to fetch it again
|
|
if (_.isObject(userModelOrId) && !_.isObject(userModelOrId.related('roles'))) {
|
|
userModelOrId = userModelOrId.id;
|
|
}
|
|
// If we passed in an id instead of a model get the model first
|
|
if (_.isNumber(userModelOrId) || _.isString(userModelOrId)) {
|
|
// Grab the original args without the first one
|
|
origArgs = _.toArray(arguments).slice(1);
|
|
|
|
// Get the actual user model
|
|
return this.findOne({id: userModelOrId, status: 'all'}, {include: ['roles']}).then(function then(foundUserModel) {
|
|
if (!foundUserModel) {
|
|
throw new errors.NotFoundError({
|
|
message: i18n.t('errors.models.user.userNotFound')
|
|
});
|
|
}
|
|
|
|
// Build up the original args but substitute with actual model
|
|
var newArgs = [foundUserModel].concat(origArgs);
|
|
|
|
return self.permissible.apply(self, newArgs);
|
|
});
|
|
}
|
|
|
|
if (action === 'edit') {
|
|
// Owner can only be edited by owner
|
|
if (loadedPermissions.user && userModel.hasRole('Owner')) {
|
|
hasUserPermission = _.some(loadedPermissions.user.roles, {name: 'Owner'});
|
|
}
|
|
// Users with the role 'Editor' and 'Author' have complex permissions when the action === 'edit'
|
|
// We now have all the info we need to construct the permissions
|
|
if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Author'})) {
|
|
// If this is the same user that requests the operation allow it.
|
|
hasUserPermission = hasUserPermission || context.user === userModel.get('id');
|
|
}
|
|
|
|
if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
|
|
// If this is the same user that requests the operation allow it.
|
|
hasUserPermission = context.user === userModel.get('id');
|
|
|
|
// Alternatively, if the user we are trying to edit is an Author, allow it
|
|
hasUserPermission = hasUserPermission || userModel.hasRole('Author');
|
|
}
|
|
}
|
|
|
|
if (action === 'destroy') {
|
|
// Owner cannot be deleted EVER
|
|
if (loadedPermissions.user && userModel.hasRole('Owner')) {
|
|
return Promise.reject(new errors.NoPermissionError({message: i18n.t('errors.models.user.notEnoughPermission')}));
|
|
}
|
|
|
|
// Users with the role 'Editor' have complex permissions when the action === 'destroy'
|
|
if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
|
|
// If this is the same user that requests the operation allow it.
|
|
hasUserPermission = context.user === userModel.get('id');
|
|
|
|
// Alternatively, if the user we are trying to edit is an Author, allow it
|
|
hasUserPermission = hasUserPermission || userModel.hasRole('Author');
|
|
}
|
|
}
|
|
|
|
if (hasUserPermission && hasAppPermission) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return Promise.reject(new errors.NoPermissionError({message: i18n.t('errors.models.user.notEnoughPermission')}));
|
|
},
|
|
|
|
// Finds the user by email, and checks the password
|
|
// @TODO: shorten this function and rename...
|
|
check: function check(object) {
|
|
var self = this;
|
|
|
|
return this.getByEmail(object.email).then(function then(user) {
|
|
if (!user) {
|
|
return Promise.reject(new errors.NotFoundError({
|
|
message: i18n.t('errors.models.user.noUserWithEnteredEmailAddr')
|
|
}));
|
|
}
|
|
|
|
if (user.isLocked()) {
|
|
return Promise.reject(new errors.NoPermissionError({
|
|
message: i18n.t('errors.models.user.accountLocked')
|
|
}));
|
|
}
|
|
|
|
if (user.isInactive()) {
|
|
return Promise.reject(new errors.NoPermissionError({
|
|
message: i18n.t('errors.models.user.accountSuspended')
|
|
}));
|
|
}
|
|
|
|
return self.isPasswordCorrect({plainPassword: object.password, hashedPassword: user.get('password')})
|
|
.then(function then() {
|
|
return Promise.resolve(user.set({status: 'active', last_seen: new Date()}).save({validate: false}))
|
|
.catch(function handleError(err) {
|
|
// If we get a validation or other error during this save, catch it and log it, but don't
|
|
// cause a login error because of it. The user validation is not important here.
|
|
logging.error(new errors.GhostError({
|
|
err: err,
|
|
context: i18n.t('errors.models.user.userUpdateError.context'),
|
|
help: i18n.t('errors.models.user.userUpdateError.help')
|
|
}));
|
|
|
|
return user;
|
|
});
|
|
})
|
|
.catch(function onError(err) {
|
|
return Promise.reject(err);
|
|
});
|
|
}, function handleError(error) {
|
|
if (error.message === 'NotFound' || error.message === 'EmptyResponse') {
|
|
return Promise.reject(new errors.NotFoundError({message: i18n.t('errors.models.user.noUserWithEnteredEmailAddr')}));
|
|
}
|
|
|
|
return Promise.reject(error);
|
|
});
|
|
},
|
|
|
|
isPasswordCorrect: function isPasswordCorrect(object) {
|
|
var plainPassword = object.plainPassword,
|
|
hashedPassword = object.hashedPassword;
|
|
|
|
if (!plainPassword || !hashedPassword) {
|
|
return Promise.reject(new errors.ValidationError({
|
|
message: i18n.t('errors.models.user.passwordRequiredForOperation')
|
|
}));
|
|
}
|
|
|
|
return bcryptCompare(plainPassword, hashedPassword)
|
|
.then(function (matched) {
|
|
if (matched) {
|
|
return;
|
|
}
|
|
|
|
return Promise.reject(new errors.ValidationError({
|
|
context: i18n.t('errors.models.user.incorrectPassword'),
|
|
message: i18n.t('errors.models.user.incorrectPassword'),
|
|
help: i18n.t('errors.models.user.userUpdateError.help'),
|
|
code: 'PASSWORD_INCORRECT'
|
|
}));
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Naive change password method
|
|
* @param {Object} object
|
|
* @param {Object} options
|
|
*/
|
|
changePassword: function changePassword(object, options) {
|
|
var self = this,
|
|
newPassword = object.newPassword,
|
|
userId = object.user_id,
|
|
oldPassword = object.oldPassword,
|
|
isLoggedInUser = userId === options.context.user,
|
|
user;
|
|
|
|
return self.forge({id: userId}).fetch({require: true})
|
|
.then(function then(_user) {
|
|
user = _user;
|
|
|
|
if (isLoggedInUser) {
|
|
return self.isPasswordCorrect({
|
|
plainPassword: oldPassword,
|
|
hashedPassword: user.get('password')
|
|
});
|
|
}
|
|
})
|
|
.then(function then() {
|
|
return user.save({password: newPassword});
|
|
});
|
|
},
|
|
|
|
transferOwnership: function transferOwnership(object, options) {
|
|
var ownerRole,
|
|
contextUser;
|
|
|
|
return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Owner'}),
|
|
User.findOne({id: options.context.user}, {include: ['roles']}))
|
|
.then(function then(results) {
|
|
ownerRole = results[0];
|
|
contextUser = results[1];
|
|
|
|
// check if user has the owner role
|
|
var currentRoles = contextUser.toJSON(options).roles;
|
|
if (!_.some(currentRoles, {id: ownerRole.id})) {
|
|
return Promise.reject(new errors.NoPermissionError({message: i18n.t('errors.models.user.onlyOwnerCanTransferOwnerRole')}));
|
|
}
|
|
|
|
return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Administrator'}),
|
|
User.findOne({id: object.id}, {include: ['roles']}));
|
|
}).then(function then(results) {
|
|
var adminRole = results[0],
|
|
user = results[1],
|
|
currentRoles = user.toJSON(options).roles;
|
|
|
|
if (!_.some(currentRoles, {id: adminRole.id})) {
|
|
return Promise.reject(new errors.ValidationError({message: i18n.t('errors.models.user.onlyAdmCanBeAssignedOwnerRole')}));
|
|
}
|
|
|
|
// convert owner to admin
|
|
return Promise.join(contextUser.roles().updatePivot({role_id: adminRole.id}),
|
|
user.roles().updatePivot({role_id: ownerRole.id}),
|
|
user.id);
|
|
}).then(function then(results) {
|
|
return Users.forge()
|
|
.query('whereIn', 'id', [contextUser.id, results[2]])
|
|
.fetch({withRelated: ['roles']});
|
|
}).then(function then(users) {
|
|
options.include = ['roles'];
|
|
return users.toJSON(options);
|
|
});
|
|
},
|
|
|
|
// Get the user by email address, enforces case insensitivity rejects if the user is not found
|
|
// When multi-user support is added, email addresses must be deduplicated with case insensitivity, so that
|
|
// joe@bloggs.com and JOE@BLOGGS.COM cannot be created as two separate users.
|
|
getByEmail: function getByEmail(email, options) {
|
|
options = options || {};
|
|
// We fetch all users and process them in JS as there is no easy way to make this query across all DBs
|
|
// Although they all support `lower()`, sqlite can't case transform unicode characters
|
|
// This is somewhat mute, as validator.isEmail() also doesn't support unicode, but this is much easier / more
|
|
// likely to be fixed in the near future.
|
|
options.require = true;
|
|
|
|
return Users.forge(options).fetch(options).then(function then(users) {
|
|
var userWithEmail = users.find(function findUser(user) {
|
|
return user.get('email').toLowerCase() === email.toLowerCase();
|
|
});
|
|
if (userWithEmail) {
|
|
return userWithEmail;
|
|
}
|
|
});
|
|
},
|
|
inactiveStates: inactiveStates
|
|
});
|
|
|
|
Users = ghostBookshelf.Collection.extend({
|
|
model: User
|
|
});
|
|
|
|
module.exports = {
|
|
User: ghostBookshelf.model('User', User),
|
|
Users: ghostBookshelf.collection('Users', Users)
|
|
};
|