// user.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; import path from 'node:path'; import mongoose from 'mongoose'; const User = mongoose.model('User'); import passport from 'passport'; import PassportLocal from 'passport-local'; import { v4 as uuidv4 } from 'uuid'; import { SiteService, SiteError } from '../../lib/site-lib.js'; export default class UserService extends SiteService { static get name ( ) { return 'UserService'; } static get slug ( ) { return 'user'; } constructor (dtp) { super(dtp, UserService); this.USER_SELECT = '_id created username username_lc displayName picture flags permissions'; this.ADMIN_SELECT = '_id created email username username_lc displayName picture flags permissions'; this.populateUser = [ { path: 'picture.large', }, { path: 'picture.small', }, ]; } async start ( ) { await super.start(); this.registerPassportLocal(); if (process.env.DTP_ADMIN === 'enabled') { this.registerPassportAdmin(); } this.reservedNames = (await import(path.join(this.dtp.config.root, 'config', 'reserved-names.js'))).default; } async create (userDefinition) { const NOW = new Date(); const { crypto: cryptoService, email: emailService, text: textService, } = this.dtp.services; try { userDefinition.email = userDefinition.email.trim().toLowerCase(); // strip characters we don't want to allow in username userDefinition.username = await this.filterUsername(userDefinition.username); const username_lc = userDefinition.username.toLowerCase(); // test the email address for validity, blacklisting, etc. await emailService.checkEmailAddress(userDefinition.email); // test if we already have a user with this email address let user = await User.findOne({ $or: [ { 'email': userDefinition.email }, { username_lc }, ], }).lean(); if (user) { throw new SiteError(400, 'That account is not available for use'); } user = new User(); user.created = NOW; user.email = userDefinition.email; user.username = userDefinition.username; user.username_lc = username_lc; user.displayName = textService.clean(userDefinition.displayName || userDefinition.username); user.passwordSalt = uuidv4(); user.password = cryptoService.maskPassword(user.passwordSalt, userDefinition.password); this.log.debug('creating new user account', { user: user.toObject() }); this.log.info('creating new user account', { email: userDefinition.email }); await user.save(); await emailService.sendWelcomeEmail(user); return user.toObject(); } catch (error) { this.log.error('failed to create user', { error }); throw error; } } async filterUsername (username, options) { const { text: textService } = this.dtp.services; options = Object.assign({ checkReserved: true }, options); if (!username || (username.length < 1)) { throw new SiteError(400, 'Username must not be blank'); } username = textService.clean(username); username = username.replace(/[^A-Za-z0-9\-_]/gi, ''); if (options.checkReserved && (await this.isUsernameReserved(username))) { throw new SiteError(403, 'The username you entered is not available for use.'); } return username; } 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}.*`); const user = await User .findOne({ $or: [ { email: account.email }, { username_lc: usernameRegex }, ] }) .select(selects.join(' ')) .lean(); return user; } async autocomplete (pagination, username) { if (!username || (username.length === 0)) { throw new SiteError(406, "Username must not be empty"); } this.log.debug('autocompleting username partial', { username }); let search = { username_lc: new RegExp(username.toLowerCase().trim(), 'gi'), }; const users = await User .find(search) .sort({ username_lc: 1 }) .select({ username: 1, username_lc: 1 }) .skip(pagination.skip) .limit(pagination.cpp) .lean() ; return users.map((u) => { return { username: u.username, username_lc: u.username_lc, }; }); } async getByUsername (username, options) { options = options || { }; username = username.trim().toLowerCase(); let select = ['+flags', '+permissions']; const user = await User .findOne({ username_lc: username }) .select(select.join(' ')) .populate(this.populateUser) .lean(); return user; } async getUserAccount (userId) { const user = await User .findById(userId) .select('+email +flags +flags.isCloaked +permissions +optIn +paymentTokens.handcash +paymentTokens.stripe') .populate([ { path: 'picture.large', }, { path: 'picture.small', }, { path: 'membership', populate: [ { path: 'plan', }, ], }, ]) .lean(); if (!user) { throw new SiteError(404, 'Member account not found'); } return user; } async getUserAccounts (pagination, username) { let search = { }; if (username) { search.$or = [ { $text: { $search: username } }, { username_lc: new RegExp(username.toLowerCase().trim(), 'gi') }, ]; } const users = await User .find(search) .sort({ username_lc: 1 }) .select('+email +flags +permissions, +optIn') .skip(pagination.skip) .limit(pagination.cpp) .lean() ; return users; } async getLatestSignups (count = 5) { const users = await User .find() .sort({ created: -1}) .select(this.ADMIN_SELECT) .limit(count) .populate(this.populateUser) .lean(); return users; } async isUsernameReserved (username) { if (this.reservedNames.includes(username)) { this.log.alert('prohibiting use of reserved username', { username }); return true; } const user = await User.findOne({ username: username}).select('username').lean(); if (user) { this.log.alert('username is already registered', { username }); return true; } return false; } async setEmailVerification (user, isVerified) { await User.updateOne( { _id: user._id }, { $set: { 'flags.isEmailVerified': isVerified }, }, ); } async emailOptOut (userId, category) { if (!mongoose.Types.ObjectId.isValid(userId)) { throw new SiteError(400, 'Invalid user ID'); } 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 requestPasswordReset (usernameOrEmail) { const { authToken: authTokenService, email: emailService } = this.dtp.services; const { site } = this.dtp.config; const lookupAccount = { email: usernameOrEmail.trim().toLowerCase(), username: (await this.filterUsername(usernameOrEmail)).toLowerCase(), }; const recipient = await this.lookup(lookupAccount, { withEmail: true }); if (!recipient) { throw new SiteError(404, 'User account not found'); } /* * Generate a password reset email and send it to the user. They will have * to click a link in the email with a token on it that brings them to the * password reset view to enter a new password with a fresh reminder to * write it down and store that in a safe place. */ const passwordResetToken = await authTokenService.create('password-reset', { _id: recipient._id, username: recipient.username, }); const templateModel = { site, messageType: 'system', recipient, passwordResetToken, }; const message = { from: process.env.DTP_EMAIL_SMTP_FROM, to: recipient.email, subject: `Password reset request for ${recipient.username_lc}@${this.dtp.config.site.domain}!`, html: await emailService.renderTemplate('passwordReset', 'html', templateModel), text: await emailService.renderTemplate('passwordReset', 'text', templateModel), }; this.log.info('sending password-reset email', { site: site.domain, recipient: recipient.username, }); await emailService.send(message); return recipient; } async hasPaidMembership (user) { if (!user || !user.membership) { return false; } return user.membership.tier !== 'free'; } 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 { if (!username || (username.length < 1)) { throw new SiteError(403, 'Username is required'); } const user = await this.authenticate({ username, password }, { adminRequired: false }); await this.startSession(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.startSession(user, now); done(null, this.filterUserObject(user)); } catch (error) { this.log.error('failed to process admin user login', { error }); done(error); } } async login (req, res, next, options) { const { actionAudit: actionAuditService } = this.dtp.services; options = Object.assign({ loginUrl: '/welcome/login', }, options); try { passport.authenticate('dtp-local', (error, user/*, info*/) => { if (error) { req.session.loginResult = error.toString(); return next(error); } if (!user) { req.session.loginResult = 'Username or email address is unknown.'; if (options.onLoginFailed) { return options.onLoginFailed(req, res, next); } return res.redirect(options.loginUrl); } this.log.alert('user login', { user: { _id: user._id, username: user.username, ip: req.ip, } }); req.login(user, async (error) => { if (error) { return next(error); } await actionAuditService.auditRequest(req, 'User login'); if (options.onLoginSuccess) { return options.onLoginSuccess(req, res, next); } if (!options.redirectUrl) { return; } return res.redirect(options.redirectUrl); }); })(req, res, next); } catch (error) { this.log.error('failed to process user login', { error }); return next(error); } } 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: { conpressionLevel: 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, }, }, ); return images[0].image; } 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) { 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 authenticate (account, options) { const { crypto } = this.dtp.services; options = Object.assign({ adminRequired: false, }, options); const user = await User .findOne({ $or: [ { email: account.username.trim().toLowerCase() }, { username_lc: (await this.filterUsername(account.username, { checkReserved: false })).toLowerCase() }, ] }) .select('_id created username username_lc displayName picture flags permissions email passwordSalt password') .lean(); if (!user) { throw new SiteError(404, 'User account not found'); } if (options.adminRequired && !user.flags.isAdmin) { throw new SiteError(403, 'Admin privileges required'); } const maskedPassword = crypto.maskPassword( user.passwordSalt, account.password, ); if (maskedPassword !== user.password) { throw new SiteError(403, 'Account credentials do not match'); } // remove these critical fields from the user object delete user.passwordSalt; delete user.password; this.log.debug('user authenticated', { username: user.username }); return user; } async startSession (user, now) { await User.updateOne( { _id: user._id }, { $set: { 'stats.lastLogin': now }, $inc: { 'stats.loginCount': 1 }, }, ); } 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, } } ); } async setUserSettings (user, settings) { const { crypto: cryptoService, email: emailService, text: textService, } = this.dtp.services; const update = { $set: { } }; const actions = [ ]; let emailChanged = false; if (settings.displayName && (settings.displayName !== user.displayName)) { update.$set.displayName = textService.clean(settings.displayName).trim(); actions.push('Display name updated'); } if (settings.bio && (settings.bio !== user.bio)) { update.$set.bio = textService.filter(settings.bio).trim(); actions.push('bio updated'); } if (!settings.username) { throw new SiteError(400, 'Username must not be empty'); } if (settings.username && (settings.username !== user.username)) { update.$set.username = await this.filterUsername(settings.username); update.$set.username_lc = update.$set.username.toLowerCase(); } if (settings.email && (settings.email !== user.email)) { settings.email = settings.email.toLowerCase().trim(); await emailService.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.'); emailChanged = true; } 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.'); } update.$set.optIn = { system: settings['optIn.system'] === 'on', newsletter: settings['optIn.newsletter'] === 'on', }; if (user.flags && user.flags.isModerator) { update.$set['flags.isCloaked'] = settings['flags.isCloaked'] === 'on'; } update.$set['ui.theme'] = settings.uiTheme; await User.updateOne({ _id: user._id }, update); /* * Re-load the User from the database, and use the updated User to send a * new welcome email if the email address was changed. */ if (emailChanged) { user = await this.getUserAccount(user._id); await this.sendWelcomeEmail(user); } return actions; } async setLastAnnouncement (user, announcement) { await User.updateOne( { _id: user._id }, { $set: { lastAnnouncement: announcement.created }, }, ); } filterUserObject (user) { return { _id: user._id, created: user.created, username: user.username, username_lc: user.username_lc, displayName: user.displayName, bio: user.bio, picture: user.picture, flags: user.flags, permissions: user.permissions, membership: user.membership, stats: user.stats, }; } async updateForAdmin (user, userDefinition) { await User.updateOne( { _id: user._id }, { $set: { 'flags.isAdmin': userDefinition['flags.isAdmin'] === 'on', 'flags.isModerator': userDefinition['flags.isModerator'] === 'on', 'flags.isEmailVerified': userDefinition['flags.isEmailVerified'] === 'on', 'permissions.canLogin': userDefinition['permissions.canLogin'] === 'on', 'permissions.canChat': userDefinition['permissions.canChat'] === 'on', 'permissions.canReport': userDefinition['permissions.canReport'] === 'on', 'permissions.canShareLinks': userDefinition['permissions.canShareLinks'] === 'on', 'optIn.system': userDefinition['optIn.system'] === 'on', 'optIn.marketing': userDefinition['optIn.marketing'] === 'on', }, }, ); } async banUser (user) { const { chat: chatService, image: imageService, link: linkService, otpAuth: otpAuthService, video: videoService, } = this.dtp.services; const userTag = { _id: user._id, username: user.username }; this.log.alert('banning user', userTag); await chatService.removeAllForUser(user); await otpAuthService.removeForUser(user); await linkService.removeForUser(user); await imageService.removeForUser(user); await videoService.removeForUser(user); this.log.info('removing all user privileges', userTag); await User.updateOne( { _id: user._id }, { $set: { 'flags.isAdmin': false, 'flags.isModerator': false, 'flags.isEmailVerified': false, 'permissions.canLogin': false, 'permissions.canChat': false, 'permissions.canComment': false, 'permissions.canReport': false, 'permissions.canShareLinks': false, 'optIn.system': false, 'optIn.marketing': false, badges: [ ], favoriteStickers: [ ], }, }, ); } async block (user, blockedUserId) { blockedUserId = mongoose.Types.ObjectId.createFromHexString(blockedUserId); this.log.info('blocking user', { user: user._id, blockedUserId }); await User.updateOne( { _id: user._id }, { $addToSet: { blockedUsers: blockedUserId } }, ); } async getBlockList (userId) { const user = await User .findOne({ _id: userId }) .select('blockedUsers') .lean(); if (!user) { return [ ]; } return user.blockedUsers || [ ]; } async getBlockedUsers (userId) { const user = await User .findOne({ _id: userId }) .select('blockedUsers') .populate({ path: 'blockedUsers', select: this.USER_SELECT, }) .lean(); if (!user) { return [ ]; } return user.blockedUsers || [ ]; } async unblock (user, blockedUserId) { await User.updateOne( { _id: user._id }, { $pull: { blockedUsers: blockedUserId } }, ); } async isBlocked (user, blockedUserId) { /* * This is faster than using countDocuments - just fetch the _id */ const test = await User .findOne({ _id: user._id, blockedUsers: blockedUserId }) .select('_id') // ignoring all data, do they exist? .lean(); return !!test; } }