diff --git a/graph/mutators/user.js b/graph/mutators/user.js index 2934bf7d7..1167372f7 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -1,5 +1,4 @@ const errors = require('../../errors'); -const UserModel = require('../../models/user'); const UsersService = require('../../services/users'); const { CHANGE_USERNAME, @@ -19,48 +18,14 @@ const setUserUsernameStatus = async (ctx, id, status) => { }; const setUserBanStatus = async (ctx, id, status) => { - const user = await UserModel.findOneAndUpdate({id}, { - $set: { - 'status.banned.status': status - }, - $push: { - 'status.banned.history': { - status, - assigned_by: ctx.user.id, - created_at: Date.now() - } - } - }, { - new: true - }); - if (user === null) { - throw errors.ErrNotFound; - } - + const user = await UsersService.setBanStatus(id, status, ctx.user.id); if (user.banned) { ctx.pubsub.publish('userBanned', user); } }; const setUserSuspensionStatus = async (ctx, id, until) => { - const user = await UserModel.findOneAndUpdate({id}, { - $set: { - 'status.suspension.until': until - }, - $push: { - 'status.suspension.history': { - until, - assigned_by: ctx.user.id, - created_at: Date.now() - } - } - }, { - new: true - }); - if (user === null) { - throw errors.ErrNotFound; - } - + const user = await UsersService.setSuspensionStatus(id, until, ctx.user.id); if (user.suspended) { ctx.pubsub.publish('userSuspended', user); } diff --git a/services/events/constants.js b/services/events/constants.js index a1da8dce6..78729c73a 100644 --- a/services/events/constants.js +++ b/services/events/constants.js @@ -1,4 +1,9 @@ -module.exports.ACTIONS_DELETE = 'actions.delete'; -module.exports.ACTIONS_NEW = 'actions.new'; -module.exports.COMMENTS_NEW = 'comments.new'; -module.exports.COMMENTS_EDIT = 'comments.edit'; +module.exports = { + ACTIONS_DELETE: 'ACTIONS_DELETE', + ACTIONS_NEW: 'ACTIONS_NEW', + COMMENTS_NEW: 'COMMENTS_NEW', + COMMENTS_EDIT: 'COMMENTS_EDIT', + USERS_SUSPENSION_CHANGE: 'USERS_SUSPENSION_CHANGE', + USERS_BAN_CHANGE: 'USERS_BAN_CHANGE', + USERS_USERNAME_STATUS_CHANGE: 'USERS_USERNAME_STATUS_CHANGE' +}; diff --git a/services/timeago.js b/services/timeago.js new file mode 100644 index 000000000..1bb9939c8 --- /dev/null +++ b/services/timeago.js @@ -0,0 +1,12 @@ +const ta = require('timeago.js'); + +ta.register('es', require('timeago.js/locales/es')); +ta.register('da', require('timeago.js/locales/da')); +ta.register('fr', require('timeago.js/locales/fr')); +ta.register('pt_BR', require('timeago.js/locales/pt_BR')); + +const timeago = ta(); + +module.exports = (time) => { + return timeago.format(new Date(time), 'en'); +}; diff --git a/services/users.js b/services/users.js index 04e85f541..eab441a30 100644 --- a/services/users.js +++ b/services/users.js @@ -2,6 +2,15 @@ const uuid = require('uuid'); const bcrypt = require('bcryptjs'); const errors = require('../errors'); const some = require('lodash/some'); +const merge = require('lodash/merge'); +const events = require('./events'); +const timeago = require('./timeago'); + +const { + USERS_SUSPENSION_CHANGE, + USERS_BAN_CHANGE, + USERS_USERNAME_STATUS_CHANGE, +} = require('./events/constants'); const { ROOT_URL @@ -24,6 +33,7 @@ const MailerService = require('./mailer'); const i18n = require('./i18n'); const Wordlist = require('./wordlist'); const DomainList = require('./domain_list'); +const SettingsService = require('./settings'); const {escapeRegExp} = require('./regex'); const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; @@ -39,7 +49,7 @@ const loginRateLimiter = new Limit('loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, // UsersService is the interface for the application to interact with the // UserModel through. -module.exports = class UsersService { +class UsersService { /** * Returns a user (if found) for the given email address. @@ -80,8 +90,92 @@ module.exports = class UsersService { } } + static async setSuspensionStatus(id, until, assignedBy = null) { + let user = await UserModel.findOneAndUpdate({id}, { + $set: { + 'status.suspension.until': until + }, + $push: { + 'status.suspension.history': { + until, + assigned_by: assignedBy, + created_at: Date.now() + } + } + }, { + new: true + }); + if (user === null) { + user = await UserModel.findOne({id}); + if (user === null) { + throw errors.ErrNotFound; + } + + if ( + user.status.suspension.until === until || + ( + user.status.suspension.until.getTime() > until.getTime() - 1000 && + user.status.suspension.until.getTime() < until.getTime() + 1000 + ) + ) { + return user; + } + + throw new Error('suspension status change edit failed for an unknown reason'); + } + + // Emit that the user username status was changed. + await events.emitAsync(USERS_SUSPENSION_CHANGE, user, until); + + return user; + } + + static async setBanStatus(id, status, assignedBy = null) { + let user = await UserModel.findOneAndUpdate({ + id, + status: { + $ne: status + } + }, { + $set: { + 'status.banned.status': status + }, + $push: { + 'status.banned.history': { + status, + assigned_by: assignedBy, + created_at: Date.now() + } + } + }, { + new: true + }); + if (user === null) { + user = await UserModel.findOne({id}); + if (user === null) { + throw errors.ErrNotFound; + } + + if (user.status.banned.status === status) { + return user; + } + + throw new Error('ban status change edit failed for an unknown reason'); + } + + // Emit that the user ban status was changed. + await events.emitAsync(USERS_BAN_CHANGE, user, status); + + return user; + } + static async setUsernameStatus(id, status, assignedBy = null) { - const user = await UserModel.findOneAndUpdate({id}, { + let user = await UserModel.findOneAndUpdate({ + id, + status: { + $ne: status + } + }, { $set: { 'status.username.status': status }, @@ -96,9 +190,21 @@ module.exports = class UsersService { new: true }); if (user === null) { - throw errors.ErrNotFound; + user = await UserModel.findOne({id}); + if (user === null) { + throw errors.ErrNotFound; + } + + if (user.status.username.status === status) { + return user; + } + + throw new Error('username status change edit failed for an unknown reason'); } + // Emit that the user username status was changed. + await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, status); + return user; } @@ -145,6 +251,9 @@ module.exports = class UsersService { throw new Error('edit username failed for an unexpected reason'); } + // Emit that the user username status was changed. + await events.emitAsync(USERS_USERNAME_STATUS_CHANGE, user, toStatus); + return user; } catch (err) { if (err.code === 11000) { @@ -297,6 +406,21 @@ module.exports = class UsersService { }); } + static async sendEmail(user, options) { + const localProfile = user.profiles.find((profile) => profile.provider === 'local'); + if (!localProfile) { + throw new Error('user does not have an email'); + } + + const {id: to} = localProfile; + + options = merge(options, { + to, + }); + + return MailerService.sendSimple(options); + } + static async changePassword(id, password) { const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS); @@ -789,7 +913,60 @@ module.exports = class UsersService { } }); } -}; +} + +module.exports = UsersService; + +events.on(USERS_BAN_CHANGE, async (user, status) => { + + // Check to see if the user was banned now and is currently banned. + if (user.banned && status) { + await UsersService.sendEmail(user, { + template: 'banned', + locals: { + body: 'In accordance with The Coral Project’s community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.' + }, + subject: 'Your account has been banned', + }); + } +}); + +events.on(USERS_SUSPENSION_CHANGE, async (user, until) => { + + // Check to see if the user was suspended now and is currently suspended. + if (user.suspended && until !== null && until > Date.now()) { + const {organizationName} = await SettingsService.retrieve(); + + const message = i18n.t( + 'suspenduser.email_message_suspend', + user.username, + organizationName, + timeago(until), + ); + + await UsersService.sendEmail(user, { + template: 'suspension', + locals: { + body: message, + }, + subject: 'Your account has been banned', + }); + } +}); + +events.on(USERS_USERNAME_STATUS_CHANGE, async (user, status) => { + if (status === 'REJECTED') { + const message = i18n.t('reject_username.email_message_reject'); + + await UsersService.sendEmail(user, { + template: 'suspension', + locals: { + body: message + }, + subject: 'Username Rejected' + }); + } +}); // Extract all the tokenUserNotFound plugins so we can integrate with other // providers.