import { BadRequestException, Inject, Injectable, Logger, } from '@nestjs/common'; import { ILike, Repository } from 'typeorm'; import * as bcrypt from 'bcrypt'; import { UserTokenType } from '../user-token/user-token.entity'; import { User } from './user.entity'; import { TokenService } from 'src/modules/utility/services/token.service'; import { EmailService } from '../email/email.service'; import { RegistrationEmail } from './email/registration.email'; import { ForgotPasswordEmail } from './email/forgot-password.email'; import { UserTokenService } from '../user-token/user-token.service'; import { ConfigurationService } from 'src/modules/config/config.service'; import { Upload } from '../upload/upload.entity'; import { UploadService } from '../upload/upload.service'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InvitationEmail } from './email/invitation.email'; @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); constructor( @Inject('USER_REPOSITORY') private userRepository: Repository, private userToken: UserTokenService, private token: TokenService, private email: EmailService, private config: ConfigurationService, private upload: UploadService, @Inject(CACHE_MANAGER) private readonly cache: Cache, ) {} public async getById(id: number, relations?: string[]): Promise { if (!id) { return null; } return this.userRepository.findOne({ where: { id }, relations }); } public async getByUUID(uuid: string, relations?: string[]): Promise { if (!uuid) { return null; } return this.userRepository.findOne({ where: { uuid }, relations }); } public async getByEmail(email: string, relations?: string[]): Promise { if (!email) { return null; } return this.userRepository.findOne({ where: { email }, relations }); } public async searchUsers( limit = 50, offset = 0, search?: string, relations?: string[], ): Promise<[User[], number]> { const searchTerm = `%${search}%`; return this.userRepository.findAndCount({ where: search ? [ { display_name: ILike(searchTerm), }, { username: ILike(searchTerm), }, { email: ILike(searchTerm), }, ] : undefined, skip: offset, take: limit, relations, }); } public async searchUsersCount( search?: string, relations?: string[], ): Promise { const searchTerm = `%${search}%`; return this.userRepository.count({ where: search ? [ { display_name: ILike(searchTerm), }, { username: ILike(searchTerm), }, { email: ILike(searchTerm), }, ] : undefined, relations, }); } public async getByUsername( username: string, relations?: string[], ): Promise { if (!username) { return null; } return this.userRepository.findOne({ where: { username }, relations }); } public async get( input: string | number, relations?: string[], ): Promise { if (typeof input === 'number') { return this.getById(input, relations); } if (input.includes('@')) { return this.getByEmail(input, relations); } if (input.length === 36 && input.includes('-')) { return this.getByUUID(input, relations); } return this.getByUsername(input, relations); } public async updateUser(user: User): Promise { await this.userRepository.save(user); return user; } public async updateAvatar(user: User, upload: Upload): Promise { if (user.picture) { await this.upload.delete(user.picture); } user.picture = upload; await this.updateUser(user); this.logger.log(`User ID: ${user.id} avatar has been updated`); return user; } public async deleteAvatar(user: User): Promise { if (user.picture) { await this.upload.delete(user.picture); this.logger.log(`User ID: ${user.id} avatar has been deleted`); } } public async comparePasswords( hash: string, password: string, ): Promise { return bcrypt.compare(password, hash); } public async hashPassword(password: string): Promise { const salt = await bcrypt.genSalt(10); return bcrypt.hash(password, salt); } public async sendActivationEmail( user: User, redirectTo?: string, ): Promise { this.logger.log( `Sending an activation email to User ID: ${user.id} (${user.email})`, ); const activationToken = await this.userToken.create( user, UserTokenType.ACTIVATION, new Date(Date.now() + 3600 * 1000), ); const params = new URLSearchParams({ token: activationToken.token }); if (redirectTo) { params.append('redirectTo', redirectTo); } try { const content = RegistrationEmail( user.username, `${this.config.get( 'app.base_url', )}/login/activate?${params.toString()}`, ); await this.email.sendEmailTemplate( user.email, 'Activate your account on Icy Network', content, ); this.logger.log( `Sending an activation email to User ID: ${user.id} (${user.email}) was successful.`, ); } catch (e) { this.logger.error( `Sending an activation email to User ID: ${user.id} (${user.email}) failed`, ); await this.userToken.delete(activationToken); throw e; } } public async sendPasswordEmail(user: User): Promise { this.logger.log( `Sending a password reset email to User ID: ${user.id} (${user.email})`, ); const passwordToken = await this.userToken.create( user, UserTokenType.PASSWORD, new Date(Date.now() + 3600 * 1000), ); try { const content = ForgotPasswordEmail( user.username, `${this.config.get('app.base_url')}/login/password?token=${ passwordToken.token }`, ); await this.email.sendEmailTemplate( user.email, 'Reset your password on Icy Network', content, ); this.logger.log( `Sent a password reset email to User ID: ${user.id} (${user.email}) successfully`, ); } catch (e) { this.logger.error( `Sending a password reset email to User ID: ${user.id} (${user.email}) failed: ${e}`, ); await this.userToken.delete(passwordToken); // silently fail } } public async userPassword(email: string): Promise { const user = await this.getByEmail(email); if (!user || !user.activated) { return; } await this.sendPasswordEmail(user); } public async userRegistration( newUserInfo: { username: string; display_name: string; email: string; password: string; }, redirectTo?: string, activate = false, ): Promise { this.logger.log( `Starting registration of new user ${newUserInfo.username} (${newUserInfo.email})`, ); if (!!(await this.getByEmail(newUserInfo.email))) { throw new Error('Email is already in use!'); } if (!!(await this.getByUsername(newUserInfo.username))) { throw new Error('Username is already in use!'); } const hashword = await this.hashPassword(newUserInfo.password); const user = new User(); user.email = newUserInfo.email; user.uuid = this.token.createUUID(); user.username = newUserInfo.username; user.display_name = newUserInfo.display_name; user.password = hashword; user.activity_at = new Date(); user.activated = activate; await this.userRepository.save(user); if (!user.activated) { try { await this.sendActivationEmail(user, redirectTo); } catch (e) { await this.userRepository.remove(user); throw new Error( 'Failed to send activation email! Please check your email address and try again!', ); } } this.logger.log( `Registered a new user ${newUserInfo.username} (${newUserInfo.email}) ID: ${user.id}`, ); return user; } public async issueRegistrationToken(email: string) { this.logger.log(`Issuing a new registration token for ${email}`); const existingUser = await this.getByEmail(email); if (existingUser) { throw new BadRequestException('User by email already exists'); } const newToken = this.token.generateString(64); await this.cache.set( `register-${newToken}`, email, 7 * 24 * 60 * 60 * 1000, // 7 days ); try { const content = InvitationEmail( `${this.config.get('app.base_url')}/register?token=${newToken}`, ); await this.email.sendEmailTemplate( email, 'You have been invited to create an account on Icy Network', content, ); this.logger.log( `Issuing a new registration token for ${email} was successful`, ); } catch (error) { this.logger.error( `Issuing a new registration token for ${email} failed: ${error}`, ); await this.cache.del(`register-${newToken}`); throw error; } } public async checkRegistrationToken(token: string) { return await this.cache.get(`register-${token}`); } public async invalidateRegistrationToken(token: string) { return await this.cache.del(`register-${token}`); } }