// 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 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 . reservedNames = require ( path . join ( this . dtp . config . root , 'config' , 'reserved-names' ) ) ;
this . populateUser = [
{
path : 'picture.large' ,
} ,
{
path : 'picture.small' ,
} ,
] ;
}
async 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 ` ) ;
}
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 ,
} ;
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 ;
/ *
* Remove all pending EmailVerify tokens for the User .
* /
await emailService . removeVerificationTokensForUser ( user ) ;
/ *
* Create the new / o n l y E m a i l V e r i f y t o k e n f o r t h e u s e r . T h i s w i l l b e t h e o n l y
* 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 ,
'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 . 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' ,
'optIn.system' : userDefinition . optInSystem === 'on' ,
'optIn.marketing' : userDefinition . optInMarketing === 'on' ,
} ,
} ,
) ;
}
async updateSettings ( 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 ( ) ) ;
userDefinition . bio = striptags ( userDefinition . bio . trim ( ) ) ;
this . log . info ( 'updating user settings' , { userDefinition } ) ;
await User . updateOne (
{ _id : user . _id } ,
{
$set : {
username : userDefinition . username ,
username _lc ,
displayName : userDefinition . displayName ,
bio : userDefinition . bio ,
theme : userDefinition . theme || 'dtp-light' ,
} ,
} ,
) ;
}
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 } ) ;
const user = await User
. findOne ( {
$or : [
{ email : accountEmail } ,
{ username _lc : accountUsername } ,
]
} )
. select ( '+passwordSalt +password +flags +optIn +permissions' )
. lean ( ) ;
if ( ! user ) {
throw new SiteError ( 404 , 'Member credentials are invalid' ) ;
}
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 ;
}
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 , password } ) ;
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' ;
return user ;
}
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 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 ( username ) {
if ( ! username || ( typeof username !== 'string' ) || ( username . length === 0 ) ) {
throw new SiteError ( 406 , 'Invalid username' ) ;
}
username = username . trim ( ) . toLowerCase ( ) ;
const user = await User
. findOne ( { username _lc : username } )
. select ( '_id created username username_lc displayName bio picture header' )
. populate ( this . populateUser )
. lean ( ) ;
return user ;
}
async getRecent ( maxCount = 3 ) {
const users = User
. find ( )
. select ( '_id created username username_lc displayName picture' )
. 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 : {
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 ,
} ,
} ,
) ;
}
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 } ,
) ;
}
/ * *
* 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 ) ; } ,
} ;