You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

979 lines
28 KiB

// 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 UserArchive = mongoose.model('UserArchive');
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');
/*
* The entire concept of "get a user" is in flux right now. It's best to just
* ignore what's happening in this service right now, and focus on other
* features in the sytem.
*/
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',
},
{
path: 'picture.small',
},
];
}
async start ( ) {
await super.start();
this.registerPassportLocal();
if (process.env.DTP_ADMIN === 'enabled') {
this.registerPassportAdmin();
}
const { jobQueue: jobQueueService } = this.dtp.services;
this.jobQueues = { };
this.log.info('connecting to job queue', { name: 'reeeper', config: this.dtp.config.jobQueues.reeeper });
this.jobQueues.reeeper = jobQueueService.getJobQueue(
'reeeper',
this.dtp.config.jobQueues.reeeper,
);
}
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 {
this.checkRestrictedKeys('create', userDefinition);
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: false,
isModerator: false,
isEmailVerified: false,
};
user.permissions = {
canLogin: true,
canChat: true,
canComment: true,
canReport: true,
};
user.optIn = {
system: true,
marketing: 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/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.getLocalUserAccount(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');
}
this.checkRestrictedKeys('create', userDefinition);
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 updateLocalForAdmin (user, userDefinition) {
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) {
const { crypto: cryptoService } = this.dtp.services;
const updateOp = { $set: { }, $unset: { } };
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 = 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 });
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 },
},
);
}
async getLocalUserId (username) {
const user = await User.findOne({ username_lc: username }).select('_id').lean();
if (!user) {
return; // undefined
}
return user._id;
}
async getCoreUserId (username) {
const user = await CoreUser.findOne({ username_lc: username }).select('_id').lean();
if (!user) {
return; // undefined
}
return user._id;
}
async getLocalUserAccount (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 getCoreUserAccount (userId) {
const user = await User
.findById(userId)
.select('+email +flags +permissions +optIn +picture')
.populate(this.populateUser)
.lean();
if (!user) {
throw new SiteError(404, 'Core member account not found');
}
user.type = 'CoreUser';
return user;
}
async getLocalUserProfile (userId) {
const user = await User
.findById(userId)
.select('+email +flags +settings')
.populate(this.populateUser)
.lean();
user.type = 'User';
return user;
}
async getCoreUserProfile (userId) {
const user = await CoreUser
.findById(userId)
.select('+core +flags +settings')
.populate(this.populateUser)
.lean();
user.type = 'CoreUser';
return user;
}
async searchLocalUserAccounts (pagination, username) {
let search = { };
if (username) {
username = this.filterUsername(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 searchCoreUserAccounts (pagination, username) {
let search = { };
username = this.filterUsername(username);
if (username) {
search.username_lc = { $regex: `^${username.toLowerCase().trim()}` };
}
const users = await CoreUser
.find(search)
.sort({ username_lc: 1 })
.select('+core +coreUserId +flags +permissions +optIn')
.skip(pagination.skip)
.limit(pagination.cpp)
.lean()
;
return users.map((user) => { user.type = 'CoreUser'; return user; });
}
async getRecent (maxCount = 3) {
const users = User
.find()
.select(UserService.USER_SELECT)
.sort({ created: -1 })
.limit(maxCount)
.lean();
return users;
}
async getAdmins ( ) {
const admins = await User
.find({ 'flags.isAdmin': true })
.select(UserService.USER_SELECT)
.sort({ username: 1 })
.lean();
return admins;
}
async getModerators ( ) {
const moderators = await User
.find({ 'flags.isModerator': true })
.select(UserService.USER_SELECT)
.sort({ username: 1 })
.lean();
return moderators;
}
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 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');
}
}
filterUsername (username) {
while (username[0] === '@') {
username = username.slice(1);
}
return striptags(username.trim().toLowerCase()).replace(/\W/g, '');
}
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 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 });
switch (user.type) {
case 'User':
user = await this.getLocalUserAccount(user._id);
break;
case 'CoreUser':
user = await this.getCoreUserAccount(user._id);
break;
default:
throw new SiteError(400, 'Invalid User type');
}
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 updateHeaderImage (user, file) {
const { image: imageService } = this.dtp.services;
await this.removeHeaderImage(user.header);
const images = [
{
width: 1400,
height: 400,
format: 'jpeg',
formatParameters: {
quality: 80,
},
},
];
await imageService.processImageFile(user, file, images);
await User.updateOne(
{ _id: user._id },
{
$set: {
'header': images[0].image._id,
},
},
);
}
async removeHeaderImage (user) {
const { image: imageService } = this.dtp.services;
user = await this.getUserAccount(user._id);
if (user.header) {
await imageService.deleteImage(user.header);
}
await User.updateOne({ _id: user._id }, { $unset: { 'header': '' } });
}
async blockUser (user, blockedUser) {
if (user._id.equals(blockedUser._id)) {
throw new SiteError(406, "You can't block yourself");
}
await UserBlock.updateOne(
{ 'member.user': user._id },
{
$addToSet: {
blockedMembers: {
userType: blockedUser.type,
user: blockedUser._id,
},
},
},
{ upsert: true },
);
}
async unblockUser (user, blockedUser) {
if (user._id.equals(blockedUser._id)) {
throw new SiteError(406, "You can't un-block yourself");
}
await UserBlock.updateOne(
{ 'member.user': user._id },
{
$removeFromSet: {
blockedUsers: {
userType: blockedUser.type,
user: blockedUser._id,
},
},
},
);
}
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 },
},
);
}
async ban (user) {
const {
attachment: attachmentService,
chat: chatService,
comment: commentService,
contentReport: contentReportService,
csrfToken: csrfTokenService,
otpAuth: otpAuthService,
sticker: stickerService,
userNotification: userNotificationService,
} = this.dtp.services;
const userModel = mongoose.model(user.type);
await userModel.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,
'optIn.system': false,
'optIn.marketing': false,
},
},
);
await chatService.removeForUser(user);
await commentService.removeForAuthor(user);
await contentReportService.removeForUser(user);
await csrfTokenService.removeForUser(user);
await otpAuthService.removeForUser(user);
await stickerService.removeForUser(user);
await userNotificationService.removeForUser(user);
await attachmentService.removeForOwner(user);
}
checkRestrictedKeys (method, definition) {
const { logan: loganService } = this.dtp.services;
const restrictedKeys = [
'isAdmin', 'isModerator', 'isEmailVerified',
'canLogin', 'canChat', 'canComment', 'canReport',
'optInSystem', 'optInMarketing',
];
const keys = Object.keys(definition);
for (const restrictedKey of restrictedKeys) {
if (keys.includes(restrictedKey)) {
loganService.sendEvent(module.exports, {
level: 'alert',
event: method,
message: 'malicious fields detected',
data: { definition },
});
throw new SiteError(403, 'invalid request');
}
}
}
/**
* Create a job to archive and ban a User (local). The job will immediately
* disable the specified user, create a .zip file of their content on storage.
* Once the worker confirms that the archive file is on storage, it creates a
* UserArchive record for it, then completely bans the User. That removes all
* of the User's content.
*
* It then removes the User record entirely.
*
* @param {User} user the User to be archived
* @returns the newly created Bull queue job
*/
async archiveLocalUser (user) {
return this.jobQueues.reeeper.add('archive-user-local', { userId: user._id });
}
/**
* Update a UserArchive document
* @param {UserArchive} archive the existing archive to be updated
* @param {*} archiveDefinition new values to be applied
*/
async updateArchive (archive, archiveDefinition) {
const update = { $set: { }, $unset: { } };
archiveDefinition.notes = archiveDefinition.notes.trim();
if (archiveDefinition.notes && (archiveDefinition.notes.length > 0)) {
update.$set.notes = archiveDefinition.notes;
} else {
update.$unset.notes = 1;
}
await UserArchive.updateOne({ _id: archive._id }, update);
}
/**
* Fetch an Array of UserArchive documents with pagination.
* @param {DtpPagination} pagination self explanatory
* @returns Array of UserArchive documents (can be empty)
*/
async getArchives (pagination) {
const search = { };
const archives = await UserArchive
.find(search)
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
const totalArchiveCount = await UserArchive.estimatedDocumentCount();
return { archives, totalArchiveCount };
}
/**
* Fetch a UserArchive record. This does not fetch the archive file.
* @param {UserArchive} archiveId the ID of the archive to fetch
* @returns the requested UserArchive, or null/undefined.
*/
async getArchiveById (archiveId) {
const archive = await UserArchive.findOne({ _id: archiveId }).lean();
return archive;
}
/**
* Removes the .zip file attached to a UserArchive.
* @param {UserArchive} archive the archive for which an associated .zip file
* is to be removed
*/
async deleteArchiveFile (archive) {
const { minio: minioService } = this.dtp.services;
if (!archive.archive || !archive.archive.bucket || !archive.archive.key) {
return; // no archive file present, abort
}
await minioService.removeObject(archive.archive.bucket, archive.archive.key);
await UserArchive.updateOne(
{ _id: archive._id },
{
$unset: { archive: 1 },
},
);
}
/**
* Removes a UserArchive and any attached data.
* @param {UserArchive} archive the UserArchive to be removed.
*/
async deleteArchive (archive) {
await this.deleteArchiveFile(archive);
await UserArchive.deleteOne({ _id: archive._id });
}
}
module.exports = {
logId: 'svc:user',
index: 'user',
className: 'UserService',
create: (dtp) => { return new UserService(dtp); },
};