const _ = require('lodash'), Promise = require('bluebird'), validator = require('validator'), ObjectId = require('bson-objectid'), ghostBookshelf = require('./base'), baseUtils = require('./base/utils'), common = require('../lib/common'), security = require('../lib/security'), imageLib = require('../lib/image'), pipeline = require('../lib/promise/pipeline'), validation = require('../data/validation'), 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); let User, Users; User = ghostBookshelf.Model.extend({ tableName: 'users', defaults: function defaults() { return { password: security.identifier.uid(50) }; }, emitChange: function emitChange(event, options) { const eventToTrigger = 'user' + '.' + event; ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options); }, onDestroyed: function onDestroyed(model, options) { if (_.includes(activeStates, model.previous('status'))) { model.emitChange('deactivated', options); } model.emitChange('deleted', options); }, onCreated: function onCreated(model, attrs, options) { model.emitChange('added', options); // 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', options); } }, 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', options); } } model.emitChange('edited', options); }, 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 = [], passwordValidation = {}; ghostBookshelf.Model.prototype.onSaving.apply(this, arguments); /** * Bookshelf call order: * - onSaving * - onValidate (validates the model against the schema) * * Before we can generate a slug, we have to ensure that the name is not blank. */ if (!this.get('name')) { throw new common.errors.ValidationError({ message: common.i18n.t('notices.data.validation.index.valueCannotBeBlank', { tableName: this.tableName, columnKey: 'name' }) }); } // If the user's email is set & has changed & we are not importing if (self.hasChanged('email') && self.get('email') && !options.importing) { tasks.gravatar = (function lookUpGravatar() { return imageLib.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 * - when importing, we set the password to a random uid and don't validate, just hash it and lock the user * - when importing with `importPersistUser` we check if the password is a bcrypt hash already and fall back to * normal behaviour if not (set random password, lock user, and hash password) * - no validations should run, when importing */ if (self.hasChanged('password')) { this.set('password', String(this.get('password'))); // CASE: import with `importPersistUser` should always be an bcrypt password already, // and won't re-hash or overwrite it. // In case the password is not bcrypt hashed we fall back to the standard behaviour. if (options.importPersistUser && this.get('password').match(/^\$2[ayb]\$.{56}$/i)) { return; } if (options.importing) { // always set password to a random uid when importing this.set('password', security.identifier.uid(50)); // lock users so they have to follow the password reset flow if (this.get('status') !== 'inactive') { this.set('status', 'locked'); } } else { // CASE: we're not importing data, run the validations passwordValidation = validation.validatePassword(this.get('password'), this.get('email')); if (!passwordValidation.isValid) { return Promise.reject(new common.errors.ValidationError({message: passwordValidation.message})); } } tasks.hashPassword = (function hashPassword() { return security.password.hash(self.get('password')) .then(function (hash) { self.set('password', hash); }); })(); } return Promise.props(tasks); }, toJSON: function toJSON(unfilteredOptions) { var options = User.filterOptions(unfilteredOptions, 'toJSON'), attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options); // remove password hash for security reasons delete attrs.password; delete attrs.ghost_auth_access_token; // NOTE: We don't expose the email address for for external, app and public context. // @TODO: Why? External+Public is actually the same context? Was also mentioned here https://github.com/TryGhost/Ghost/issues/9043 if (!options || !options.context || (!options.context.user && !options.context.internal)) { delete attrs.email; } // We don't expose these fields when fetching data via the public API. if (options && options.context && options.context.public) { delete attrs.created_at; delete attrs.created_by; delete attrs.updated_at; delete attrs.updated_by; delete attrs.last_seen; delete attrs.status; delete attrs.ghost_auth_id; } return attrs; }, emptyStringProperties: function emptyStringProperties() { // CASE: the client might send empty image properties with "" instead of setting them to null. // This can cause GQL to fail. We therefore enforce 'null' for empty image properties. // See https://github.com/TryGhost/GQL/issues/24 return ['profile_image', 'cover_image']; }, 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(options) { if (options.context && options.context.internal) { return null; } return options.context && options.context.public ? 'status:[' + allStates.join(',') + ']' : null; }, defaultFilters: function defaultFilters(options) { if (options.context && options.context.internal) { return null; } return options.context && options.context.public ? 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, options) { var permittedOptionsToReturn = 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', 'importPersistUser'], add: ['importPersistUser'], findPage: ['page', 'limit', 'columns', 'filter', 'order', 'status'], findAll: ['filter'] }; if (validOptions[methodName]) { permittedOptionsToReturn = permittedOptionsToReturn.concat(validOptions[methodName]); } // CASE: The `withRelated` parameter is allowed when using the public API, but not the `roles` value. // Otherwise we expose too much information. if (options && options.context && options.context.public) { if (options.withRelated && options.withRelated.indexOf('roles') !== -1) { options.withRelated.splice(options.withRelated.indexOf('roles'), 1); } } return permittedOptionsToReturn; }, /** * ### Find One * * We have to clone the data, because we remove values from this object. * This is not expected from outside! * * @TODO: use base class * * @extends ghostBookshelf.Model.findOne to include roles * **See:** [ghostBookshelf.Model.findOne](base.js.html#Find%20One) */ findOne: function findOne(dataToClone, unfilteredOptions) { var options = this.filterOptions(unfilteredOptions, 'findOne'), query, status, data = _.cloneDeep(dataToClone), lookupRole = data.role; delete data.role; data = _.defaults(data || {}, { status: 'all' }); status = data.status; delete data.status; data = this.filterData(data); // Support finding by role if (lookupRole) { options.withRelated = _.union(options.withRelated, ['roles']); query = this.forge(data); 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 { query = this.forge(data); } if (status === 'active') { query.query('whereIn', 'status', activeStates); } else if (status !== 'all') { query.query('where', {status: status}); } 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, unfilteredOptions) { var options = this.filterOptions(unfilteredOptions, 'edit'), self = this, ops = []; if (data.roles && data.roles.length > 1) { return Promise.reject( new common.errors.ValidationError({message: common.i18n.t('errors.models.user.onlyOneRolePerUserSupported')}) ); } 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 common.errors.ValidationError({message: common.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 common.errors.ValidationError({message: common.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} unfilteredOptions * @extends ghostBookshelf.Model.add to manage all aspects of user signup * **See:** [ghostBookshelf.Model.add](base.js.html#Add) */ add: function add(dataToClone, unfilteredOptions) { var options = this.filterOptions(unfilteredOptions, 'add'), self = this, data = _.cloneDeep(dataToClone), userData = this.filterData(data), roles; // check for too many roles if (data.roles && data.roles.length > 1) { return Promise.reject(new common.errors.ValidationError({message: common.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, unfilteredOptions) { var options = this.filterOptions(unfilteredOptions, 'setup'), self = this, userData = this.filterData(data), passwordValidation = {}; passwordValidation = validation.validatePassword(userData.password, userData.email, data.blogTitle); if (!passwordValidation.isValid) { return Promise.reject(new common.errors.ValidationError({message: passwordValidation.message})); } userData.slug = null; return self.edit(userData, options); }, /** * Right now the setup of the blog depends on the user status. * Only if the owner user is `inactive`, then the blog is not setup. * e.g. if you transfer ownership to a locked user, you blog is still setup. * * @TODO: Rename `inactive` status to something else, it's confusing. e.g. requires-setup * @TODO: Depending on the user status results in https://github.com/TryGhost/Ghost/issues/8003 */ isSetup: function isSetup(options) { return this.getOwnerUser(options) .then(function (owner) { return owner.get('status') !== 'inactive'; }); }, getOwnerUser: function getOwnerUser(options) { options = options || {}; return this.findOne({ role: 'Owner', status: 'all' }, options).then(function (owner) { if (!owner) { return Promise.reject(new common.errors.NotFoundError({ message: common.i18n.t('errors.models.user.ownerNotFound') })); } return owner; }); }, permissible: function permissible(userModelOrId, action, context, unsafeAttrs, 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' }, {withRelated: ['roles']}).then(function then(foundUserModel) { if (!foundUserModel) { throw new common.errors.NotFoundError({ message: common.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') { // Users with the role 'Editor', 'Author', and 'Contributor' have complex permissions when the action === 'edit' // We now have all the info we need to construct the permissions if (context.user === userModel.get('id')) { // If this is the same user that requests the operation allow it. hasUserPermission = true; } else if (loadedPermissions.user && userModel.hasRole('Owner')) { // Owner can only be edited by owner hasUserPermission = loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Owner'}); } else if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) { // If the user we are trying to edit is an Author or Contributor, allow it hasUserPermission = userModel.hasRole('Author') || userModel.hasRole('Contributor'); } } if (action === 'destroy') { // Owner cannot be deleted EVER if (userModel.hasRole('Owner')) { return Promise.reject(new common.errors.NoPermissionError({message: common.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'})) { // Alternatively, if the user we are trying to edit is an Author, allow it hasUserPermission = context.user === userModel.get('id') || userModel.hasRole('Author') || userModel.hasRole('Contributor'); } } if (hasUserPermission && hasAppPermission) { return Promise.resolve(); } return Promise.reject(new common.errors.NoPermissionError({message: common.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) { throw new common.errors.NotFoundError({ message: common.i18n.t('errors.models.user.noUserWithEnteredEmailAddr') }); } if (user.isLocked()) { throw new common.errors.NoPermissionError({ message: common.i18n.t('errors.models.user.accountLocked') }); } if (user.isInactive()) { throw new common.errors.NoPermissionError({ message: common.i18n.t('errors.models.user.accountSuspended') }); } return self.isPasswordCorrect({plainPassword: object.password, hashedPassword: user.get('password')}) .then(function then() { user.set({status: 'active', last_seen: new Date()}); return user.save(); }); }) .catch(function (err) { if (err.message === 'NotFound' || err.message === 'EmptyResponse') { throw new common.errors.NotFoundError({ message: common.i18n.t('errors.models.user.noUserWithEnteredEmailAddr') }); } throw err; }); }, isPasswordCorrect: function isPasswordCorrect(object) { var plainPassword = object.plainPassword, hashedPassword = object.hashedPassword; if (!plainPassword || !hashedPassword) { return Promise.reject(new common.errors.ValidationError({ message: common.i18n.t('errors.models.user.passwordRequiredForOperation') })); } return security.password.compare(plainPassword, hashedPassword) .then(function (matched) { if (matched) { return; } return Promise.reject(new common.errors.ValidationError({ context: common.i18n.t('errors.models.user.incorrectPassword'), message: common.i18n.t('errors.models.user.incorrectPassword'), help: common.i18n.t('errors.models.user.userUpdateError.help'), code: 'PASSWORD_INCORRECT' })); }); }, /** * Naive change password method * @param {Object} object * @param {Object} unfilteredOptions */ changePassword: function changePassword(object, unfilteredOptions) { var options = this.filterOptions(unfilteredOptions, 'changePassword'), self = this, newPassword = object.newPassword, userId = object.user_id, oldPassword = object.oldPassword, isLoggedInUser = userId === options.context.user, user; options.require = true; return self.forge({id: userId}).fetch(options) .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, unfilteredOptions) { var options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'transferOwnership'), ownerRole, contextUser; return Promise.join( ghostBookshelf.model('Role').findOne({name: 'Owner'}), User.findOne({id: options.context.user}, {withRelated: ['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 common.errors.NoPermissionError({message: common.i18n.t('errors.models.user.onlyOwnerCanTransferOwnerRole')})); } return Promise.join(ghostBookshelf.model('Role').findOne({name: 'Administrator'}), User.findOne({id: object.id}, {withRelated: ['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 common.errors.ValidationError({message: common.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.withRelated = ['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, unfilteredOptions) { var options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'getByEmail'); // 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().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) };