// user.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const path = require('path'); const mongoose = require('mongoose'); const User = mongoose.model('User'); const CoreUser = mongoose.model('CoreUser'); const UserBlock = mongoose.model('UserBlock'); const passport = require('passport'); const PassportLocal = require('passport-local'); const striptags = require('striptags'); const uuidv4 = require('uuid').v4; const { SiteError, SiteService } = require('../../lib/site-lib'); class UserService extends SiteService { constructor (dtp) { super(dtp, module.exports); this.USER_SELECT = '_id username username_lc displayName picture'; this.reservedNames = require(path.join(this.dtp.config.root, 'config', 'reserved-names')); this.populateUser = [ { path: 'picture.large', strictPopulate: false, }, { path: 'picture.small', strictPopulate: false, }, ]; } async start ( ) { await super.start(); this.log.info(`starting ${module.exports.name} service`); this.registerPassportLocal(); if (process.env.DTP_ADMIN === 'enabled') { this.registerPassportAdmin(); } } async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); await super.stop(); } async create (userDefinition) { const NOW = new Date(); const { crypto: cryptoService, email: mailService, } = this.dtp.services; try { userDefinition.email = userDefinition.email.trim().toLowerCase(); // strip characters we don't want to allow in username userDefinition.username = userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''); const username_lc = userDefinition.username.toLowerCase(); await this.checkUsername(username_lc); // test the email address for validity, blacklisting, etc. await mailService.checkEmailAddress(userDefinition.email); // test if we already have a user with this email address let user = await User.findOne({ 'email': userDefinition.email.toLowerCase().trim() }).lean(); if (user) { throw new SiteError(400, `An account with email address ${userDefinition.email} already exists.`); } // test if we already have a user with this username user = await User.findOne({ username_lc }).lean(); if (user) { throw new SiteError(400, `An account with username ${userDefinition.username} already exists.`); } const passwordSalt = uuidv4(); const maskedPassword = cryptoService.maskPassword(passwordSalt, userDefinition.password); user = new User(); user.created = NOW; user.email = userDefinition.email; user.username = userDefinition.username; user.username_lc = username_lc; user.displayName = striptags(userDefinition.displayName || userDefinition.username); user.passwordSalt = passwordSalt; user.password = maskedPassword; user.flags = { isAdmin: userDefinition.isAdmin || false, isModerator: userDefinition.isModerator || false, isEmailVerified: userDefinition.isEmailVerified || false, }; user.permissions = { canLogin: userDefinition.canLogin || true, canChat: userDefinition.canChat || true, canComment: userDefinition.canComment || true, canReport: userDefinition.canReport || true, canAuthorPosts: userDefinition.canAuthorPosts || false, canAuthorPages: userDefinition.canAuthorPages || false, canPublishPosts: userDefinition.canPublishPosts || false, canPublishPages: userDefinition.canPublishPages || false, }; user.optIn = { system: userDefinition.optInSystem || true, marketing: userDefinition.optInMarketing || false, }; this.log.info('creating new user account', { email: userDefinition.email }); await user.save(); await this.sendWelcomeEmail(user); return user.toObject(); } catch (error) { this.log.error('failed to create user', { error }); throw error; } } async sendWelcomeEmail (user) { const { email: emailService } = this.dtp.services; if (process.env.DTP_EMAIL_SERVICE !== 'enabled') { this.log.info("Welcome email will not be sent: DTP_EMAIL_SERVICE is disabled, the system can't send email and will not try."); return; } /* * Remove all pending EmailVerify tokens for the User. */ await emailService.removeVerificationTokensForUser(user); /* * Create the new/only EmailVerify token for the user. This will be the only * token accepted. Previous emails sent (if they were received) are invalid * after this. */ const verifyToken = await emailService.createVerificationToken(user); /* * Send the welcome email using the new EmailVerify token so it can * construct a new, valid link to use for verifying the email address. */ const templateModel = { site: this.dtp.config.site, recipient: user, emailVerifyToken: verifyToken.token, }; const message = { from: process.env.DTP_EMAIL_SMTP_FROM, to: user.email, subject: `Welcome to ${this.dtp.config.site.name}!`, html: await emailService.renderTemplate('welcome', 'html', templateModel), text: await emailService.renderTemplate('welcome', 'text', templateModel), }; await emailService.send(message); } async setEmailVerification (user, isVerified) { await User.updateOne( { _id: user._id }, { $set: { 'flags.isEmailVerified': isVerified }, }, ); } async emailOptOut (userId, category) { userId = mongoose.Types.ObjectId(userId); const user = await this.getUserAccount(userId); if (!user) { throw new SiteError(406, 'Invalid opt-out token'); } const updateOp = { $set: { } }; switch (category) { case 'marketing': updateOp.$set['optIn.marketing'] = false; break; case 'system': updateOp.$set['optIn.system'] = false; break; default: throw new SiteError(406, 'Invalid opt-out category'); } await User.updateOne({ _id: userId }, updateOp); } async update (user, userDefinition) { if (!user.flags.canLogin) { throw SiteError(403, 'Invalid user account operation'); } // strip characters we don't want to allow in username userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); const username_lc = userDefinition.username.toLowerCase(); userDefinition.displayName = striptags(userDefinition.displayName.trim()); this.log.info('updating user', { userDefinition }); await User.updateOne( { _id: user._id }, { $set: { username: userDefinition.username, username_lc, displayName: userDefinition.displayName, bio: userDefinition.bio, 'optIn.system': userDefinition['optIn.system'] === 'on', 'optIn.marketing': userDefinition['optIn.marketing'] === 'on', }, }, ); } async updateForAdmin (user, userDefinition) { // strip characters we don't want to allow in username userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); const username_lc = userDefinition.username.toLowerCase(); userDefinition.displayName = striptags(userDefinition.displayName.trim()); if (userDefinition.bio) { userDefinition.bio = striptags(userDefinition.bio.trim()); } if (userDefinition.badges) { userDefinition.badges = userDefinition.badges.split(',').map((badge) => striptags(badge.trim())); } else { userDefinition.badges = [ ]; } this.log.info('updating user for admin', { userDefinition }); await User.updateOne( { _id: user._id }, { $set: { username: userDefinition.username, username_lc, displayName: userDefinition.displayName, bio: striptags(userDefinition.bio.trim()), badges: userDefinition.badges, 'flags.isAdmin': userDefinition.isAdmin === 'on', 'flags.isModerator': userDefinition.isModerator === 'on', 'flags.isEmailVerified': userDefinition.isEmailVerified === 'on', 'permissions.canLogin': userDefinition.canLogin === 'on', 'permissions.canChat': userDefinition.canChat === 'on', 'permissions.canComment': userDefinition.canComment === 'on', 'permissions.canReport': userDefinition.canReport === 'on', 'permissions.canAuthorPages': userDefinition.canAuthorPages === 'on', 'permissions.canAuthorPosts': userDefinition.canAuthorPosts === 'on', 'permissions.canPublishPages': userDefinition.canPublishPages === 'on', 'permissions.canPublishPosts': userDefinition.canPublishPosts === 'on', 'optIn.system': userDefinition.optInSystem === 'on', 'optIn.marketing': userDefinition.optInMarketing === 'on', }, }, ); } async updateSettings (user, userDefinition) { const { crypto: cryptoService } = this.dtp.services; const updateOp = { $set: { }, $unset: { } }; // strip characters we don't want to allow in username updateOp.$set.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); if (!updateOp.$set.username || (updateOp.$set.username.length === 0)) { throw new SiteError(400, 'Must include a username'); } updateOp.$set.username_lc = updateOp.$set.username.toLowerCase(); if (userDefinition.displayName && (userDefinition.displayName.length > 0)) { updateOp.$set.displayName = striptags(userDefinition.displayName.trim()); } else { updateOp.$unset.displayName = 1; } if (userDefinition.bio && (userDefinition.bio.length > 0)) { updateOp.$set.bio = striptags(userDefinition.bio.trim()); } else { updateOp.$unset.bio = 1; } if (userDefinition.password && userDefinition.password.length > 0) { updateOp.$set.passwordSalt = uuidv4(); updateOp.$set.password = cryptoService.maskPassword(updateOp.$set.passwordSalt, userDefinition.password); } updateOp.$set.theme = userDefinition.theme || 'dtp-light'; this.log.info('updating user settings', { userId: user._id }); await User.updateOne({ _id: user._id }, updateOp); } async authenticate (account, options) { const { crypto } = this.dtp.services; options = Object.assign({ adminRequired: false, }, options); const accountEmail = account.username.trim().toLowerCase(); const accountUsername = await this.filterUsername(accountEmail); this.log.debug('locating user record', { accountEmail, accountUsername }); let user = await User .findOne({ $or: [ { email: accountEmail }, { username_lc: accountUsername }, ] }) .select('+passwordSalt +password +flags +optIn +permissions'); if (!user) { throw new SiteError(404, 'Member credentials are invalid'); } user = user.toObject(); this.log.debug('user debug', { user }); const maskedPassword = crypto.maskPassword( user.passwordSalt, account.password, ); if (maskedPassword !== user.password) { throw new SiteError(403, 'Member credentials are invalid'); } // remove these critical fields from the user object delete user.passwordSalt; delete user.password; if (options.adminRequired && !user.flags.isAdmin) { throw new SiteError(403, 'Admin privileges required'); } return user; } async lookup (account, options) { options = Object.assign({ withEmail: false, withCredentials: false }, options); this.log.debug('locating user record', { account }); const selects = [ '_id', 'created', 'username', 'username_lc', 'displayName', 'picture', 'flags', 'permissions', ]; if (options.withEmail) { selects.push('email'); } if (options.withCredentials) { selects.push('passwordSalt'); selects.push('password'); } const usernameRegex = new RegExp(`^${account.trim().toLowerCase()}.*`); /* * First, check our local db */ let user = await User .findOne({ $or: [ { email: account.email }, { username_lc: usernameRegex }, ] }) .select(selects.join(' ')) .lean(); if (user) { // found, mark as 'User' user.type = 'User'; } else { // check for a matching CoreUser user = await CoreUser .findOne({ $or: [ { username_lc: usernameRegex }, ] }) .select(selects.join(' ')) .lean(); if (user) { // mark as CoreUser user.type = 'CoreUser'; } } return user; // undefined means not found } registerPassportLocal ( ) { const options = { usernameField: 'username', passwordField: 'password', session: true, }; passport.use('dtp-local', new PassportLocal(options, this.handleLocalLogin.bind(this))); } async handleLocalLogin (username, password, done) { const now = new Date(); this.log.info('handleLocalLogin', { username }); try { const user = await this.authenticate({ username, password }, { adminRequired: false }); await this.startUserSession(user, now); done(null, this.filterUserObject(user)); } catch (error) { this.log.error('failed to process local user login', { error }); done(error); } } registerPassportAdmin ( ) { const options = { usernameField: 'username', passwordField: 'password', session: true, }; this.log.info('registering PassportJS admin strategy', { options }); passport.use('dtp-admin', new PassportLocal(options, this.handleAdminLogin.bind(this))); } async handleAdminLogin (email, password, done) { const now = new Date(); try { const user = await this.authenticate({ email, password }, { adminRequired: true }); await this.startUserSession(user, now); done(null, this.filterUserObject(user)); } catch (error) { this.log.error('failed to process admin user login', { error }); done(error); } } async startUserSession (user, now) { user.type = 'User'; await User.updateOne( { _id: user._id }, { $set: { 'stats.lastLogin': now }, $inc: { 'stats.loginCount': 1 }, }, ); } filterUserObject (user) { const filteredUser = { _id: user._id, created: user.created, displayName: user.displayName, username: user.username, username_lc: user.username_lc, bio: user.bio, flags: user.flags, permissions: user.permissions, picture: user.picture, }; if (filteredUser.flags && filteredUser.flags._id) { delete filteredUser.flags._id; } if (filteredUser.permissions && filteredUser.permissions._id) { delete filteredUser.permissions._id; } return filteredUser; } async getUserAccount (userId) { const user = await User .findById(userId) .select('+email +flags +permissions +optIn +picture') .populate(this.populateUser) .lean(); if (!user) { throw new SiteError(404, 'Member account not found'); } user.type = 'User'; this.decorateUserObject(user); return user; } decorateUserObject (user) { user.hasAuthorPermissions = user.permissions.canAuthorPosts; user.hasPublishPermissions = user.permissions.canPublishPages || user.permissions.canPublishPosts; user.hasAuthorDashboard = user.hasAuthorPermissions || user.hasPublishPermissions; } async getUserAccounts (pagination, username) { let search = { }; if (username) { search.username_lc = { $regex: `^${username.toLowerCase().trim()}` }; } const users = await User .find(search) .sort({ username_lc: 1 }) .select('+email +flags +permissions +optIn') .skip(pagination.skip) .limit(pagination.cpp) .lean() ; return users.map((user) => { user.type = 'User'; return user; }); } async getAuthors (pagination) { const authors = await User .find({ 'permissions.canAuthorPosts': true }) .sort({ displayName: 1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateUser) .lean(); const totalAuthorCount = await User.countDocuments( {'permissions.canAuthorPosts': true } ); return {authors, totalAuthorCount}; } async getUserProfile (userId) { let user; try { userId = mongoose.Types.ObjectId(userId); // will throw if invalid format user = User.findById(userId); } catch (error) { user = User.findOne({ username: userId }); } user = await user .select('+email +flags +settings') .populate(this.populateUser) .lean(); return user; } async getPublicProfile (type, username) { if (!username || (typeof username !== 'string')) { throw new SiteError(406, 'Invalid username'); } username = username.trim().toLowerCase(); if (username.length === 0) { throw new SiteError(406, 'Invalid username'); } let user; switch (type) { case 'CoreUser': user = await CoreUser .findOne({ username_lc: username }) .select('_id created username username_lc displayName bio picture header core') .populate(this.populateUser) .lean(); if (user) { user.type = 'CoreUser'; } break; case 'User': user = await User .findOne({ username_lc: username }) .select('_id created username username_lc displayName bio picture header') .populate(this.populateUser) .lean(); if (user) { user.type = 'User'; } break; default: throw new SiteError(400, 'Invalid user account type'); } return user; } async getRecent (maxCount = 3) { const users = User .find() .select(UserService.USER_SELECT) .sort({ created: -1 }) .limit(maxCount) .lean(); return users; } async setUserSettings (user, settings) { const { crypto: cryptoService, mail: mailService, phone: phoneService, } = this.dtp.platform.services; const update = { $set: { } }; const actions = [ ]; if (settings.name && (settings.name !== user.name)) { update.name = striptags(settings.name.trim()); update.name_lc = update.name.toLowerCase(); actions.push('Display name updated'); } if (settings.username && (settings.username !== user.username)) { update.username = this.filterUsername(settings.username); await this.checkUsername(update.username); } if (settings.email && (settings.email !== user.email)) { settings.email = settings.email.toLowerCase().trim(); await mailService.checkEmailAddress(settings.email); update.$set['flags.isEmailVerified'] = false; update.$set.email = settings.email; actions.push('Email address updated and verification email sent. Please check your inbox and follow the instructions included to complete the change of your email address.'); } /* * User is changing the phone number stored on the account. * "There's a lot to unpack here" */ if (settings.phone) { // update the phone number (there's a lot going on here) try { update.$set.phone = await phoneService.processPhoneNumberInput(settings.phone); } catch (error) { throw error; } // un-verify the account's phone number update.$set['flags.isPhoneVerified'] = false; actions.push('Phone number updated and verification message sent. Please follow the instructions in the text message to complete the change of your mobile phone number.'); } if (settings.password) { if (settings.password !== settings.passwordv) { throw new SiteError(400, 'Password and password verification do not match.'); } update.$set.passwordSalt = uuidv4(); update.$set.password = cryptoService.maskPassword(update.$set.passwordSalt, settings.password); actions.push('Password changed successfully.'); } if (settings.theme) { update.$set['settings.theme'] = striptags(settings.theme.trim()); } if (settings.language) { update.$set['settings.language'] = mongoose.Types.ObjectId(settings.language); actions.push('Interface language changed.'); } await User.updateOne({ _id: user._id }, update); return actions; } async filterUsername (username) { return striptags(username.trim().toLowerCase()).replace(/\W/g, ''); } async checkUsername (username) { if (!username || (typeof username !== 'string') || (username.length === 0)) { throw new SiteError(406, 'Invalid username'); } if (this.reservedNames.includes(username.trim().toLowerCase())) { throw new SiteError(403, 'That username is reserved for system use'); } const user = await User.findOne({ username: username}).select('username').lean(); if (user) { this.log.alert('username is already registered', { username }); throw new SiteError(403, 'Username is already registered'); } } async recordProfileView (user, req) { const { resource: resourceService } = this.dtp.services; await resourceService.recordView(req, 'User', user._id); } async getTotalCount ( ) { return await User.estimatedDocumentCount(); } async updatePhoto (user, file) { const { image: imageService } = this.dtp.services; const images = [ { width: 512, height: 512, format: 'jpeg', formatParameters: { quality: 80, }, }, { width: 64, height: 64, format: 'jpeg', formatParameters: { compressionLevel: 9, }, }, ]; await imageService.processImageFile(user, file, images); await User.updateOne( { _id: user._id }, { $set: { 'picture.large': images[0].image._id, 'picture.small': images[1].image._id, }, }, ); } async removePhoto (user) { const { image: imageService } = this.dtp.services; this.log.info('remove profile photo', { user: user._id }); user = await this.getUserAccount(user._id); if (user.picture.large) { await imageService.deleteImage(user.picture.large); } if (user.picture.small) { await imageService.deleteImage(user.picture.small); } await User.updateOne({ _id: user._id }, { $unset: { 'picture': '' } }); } async blockUser (userId, blockedUserId) { userId = mongoose.Types.ObjectId(userId); blockedUserId = mongoose.Types.ObjectId(blockedUserId); if (userId.equals(blockedUserId)) { throw new SiteError(406, "You can't block yourself"); } await UserBlock.updateOne( { user: userId }, { $addToSet: { blockedUsers: blockedUserId }, }, { upsert: true }, ); } async unblockUser (userId, blockedUserId) { userId = mongoose.Types.ObjectId(userId); blockedUserId = mongoose.Types.ObjectId(blockedUserId); if (userId.equals(blockedUserId)) { throw new SiteError(406, "You can't un-block yourself"); } await UserBlock.updateOne( { user: userId }, { $removeFromSet: { blockedUsers: blockedUserId }, }, { upsert: true }, ); } async updatePassword (user, password) { const { crypto: cryptoService } = this.dtp.services; const passwordSalt = uuidv4(); const passwordHash = cryptoService.maskPassword(passwordSalt, password); this.log.info('updating user password', { userId: user._id }); await User.updateOne( { _id: user._id }, { $set: { passwordSalt: passwordSalt, password: passwordHash, } } ); } /** * Updates the `lastAnnouncement` field of a User to the `created` date of the * specified announcement (for tracking last-seen announcements). * @param {User} user The user being updated * @param {Announcement} announcement The announcement being seen by the User */ async setLastAnnouncement (user, announcement) { await User.updateOne( { _id: user._id }, { $set: { lastAnnouncement: announcement.created }, }, ); } } module.exports = { slug: 'user', name: 'user', create: (dtp) => { return new UserService(dtp); }, };