mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:22:37 -04:00 
			
		
		
		
	Add email validation in the API when creating new users (#350)
* Refactor user.service - add user-repository * Add email validation for creating users
This commit is contained in:
		
							parent
							
								
									ef17668871
								
							
						
					
					
						commit
						1887b5a860
					
				
							
								
								
									
										27
									
								
								server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								server/apps/immich/src/api-v1/auth/dto/sign-up.dto.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | import { plainToInstance } from 'class-transformer'; | ||||||
|  | import { validate } from 'class-validator'; | ||||||
|  | import { SignUpDto } from './sign-up.dto'; | ||||||
|  | 
 | ||||||
|  | describe('sign up DTO', () => { | ||||||
|  |   it('validates the email', async () => { | ||||||
|  |     const params: Partial<SignUpDto> = { | ||||||
|  |       email: undefined, | ||||||
|  |       password: 'password', | ||||||
|  |       firstName: 'first name', | ||||||
|  |       lastName: 'last name', | ||||||
|  |     }; | ||||||
|  |     let dto: SignUpDto = plainToInstance(SignUpDto, params); | ||||||
|  |     let errors = await validate(dto); | ||||||
|  |     expect(errors).toHaveLength(1); | ||||||
|  | 
 | ||||||
|  |     params.email = 'invalid email'; | ||||||
|  |     dto = plainToInstance(SignUpDto, params); | ||||||
|  |     errors = await validate(dto); | ||||||
|  |     expect(errors).toHaveLength(1); | ||||||
|  | 
 | ||||||
|  |     params.email = 'valid@email.com'; | ||||||
|  |     dto = plainToInstance(SignUpDto, params); | ||||||
|  |     errors = await validate(dto); | ||||||
|  |     expect(errors).toHaveLength(0); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -1,8 +1,8 @@ | |||||||
| import { ApiProperty } from '@nestjs/swagger'; | import { ApiProperty } from '@nestjs/swagger'; | ||||||
| import { IsNotEmpty } from 'class-validator'; | import { IsNotEmpty, IsEmail } from 'class-validator'; | ||||||
| 
 | 
 | ||||||
| export class SignUpDto { | export class SignUpDto { | ||||||
|   @IsNotEmpty() |   @IsEmail() | ||||||
|   @ApiProperty({ example: 'testuser@email.com' }) |   @ApiProperty({ example: 'testuser@email.com' }) | ||||||
|   email!: string; |   email!: string; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -0,0 +1,27 @@ | |||||||
|  | import { plainToInstance } from 'class-transformer'; | ||||||
|  | import { validate } from 'class-validator'; | ||||||
|  | import { CreateUserDto } from './create-user.dto'; | ||||||
|  | 
 | ||||||
|  | describe('create user DTO', () => { | ||||||
|  |   it('validates the email', async() => { | ||||||
|  |     const params: Partial<CreateUserDto> = { | ||||||
|  |       email: undefined, | ||||||
|  |       password: 'password', | ||||||
|  |       firstName: 'first name', | ||||||
|  |       lastName: 'last name', | ||||||
|  |     } | ||||||
|  |     let dto: CreateUserDto = plainToInstance(CreateUserDto, params); | ||||||
|  |     let errors = await validate(dto); | ||||||
|  |     expect(errors).toHaveLength(1); | ||||||
|  | 
 | ||||||
|  |     params.email = 'invalid email'; | ||||||
|  |     dto = plainToInstance(CreateUserDto, params); | ||||||
|  |     errors = await validate(dto); | ||||||
|  |     expect(errors).toHaveLength(1); | ||||||
|  | 
 | ||||||
|  |     params.email = 'valid@email.com'; | ||||||
|  |     dto = plainToInstance(CreateUserDto, params); | ||||||
|  |     errors = await validate(dto); | ||||||
|  |     expect(errors).toHaveLength(0); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -1,8 +1,8 @@ | |||||||
| import { ApiProperty } from '@nestjs/swagger'; | import { ApiProperty } from '@nestjs/swagger'; | ||||||
| import { IsNotEmpty, IsOptional } from 'class-validator'; | import { IsNotEmpty, IsEmail } from 'class-validator'; | ||||||
| 
 | 
 | ||||||
| export class CreateUserDto { | export class CreateUserDto { | ||||||
|   @IsNotEmpty() |   @IsEmail() | ||||||
|   @ApiProperty({ example: 'testuser@email.com' }) |   @ApiProperty({ example: 'testuser@email.com' }) | ||||||
|   email!: string; |   email!: string; | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										95
									
								
								server/apps/immich/src/api-v1/user/user-repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								server/apps/immich/src/api-v1/user/user-repository.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,95 @@ | |||||||
|  | import { UserEntity } from '@app/database/entities/user.entity'; | ||||||
|  | import { BadRequestException } from '@nestjs/common'; | ||||||
|  | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
|  | import { Not, Repository } from 'typeorm'; | ||||||
|  | import { CreateUserDto } from './dto/create-user.dto'; | ||||||
|  | import * as bcrypt from 'bcrypt'; | ||||||
|  | import { UpdateUserDto } from './dto/update-user.dto' | ||||||
|  | 
 | ||||||
|  | export interface IUserRepository { | ||||||
|  |   get(userId: string): Promise<UserEntity | null>; | ||||||
|  |   getByEmail(email: string): Promise<UserEntity | null>; | ||||||
|  |   getList(filter?: { excludeId?: string }): Promise<UserEntity[]>; | ||||||
|  |   create(createUserDto: CreateUserDto): Promise<UserEntity>; | ||||||
|  |   update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>; | ||||||
|  |   createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const USER_REPOSITORY = 'USER_REPOSITORY'; | ||||||
|  | 
 | ||||||
|  | export class UserRepository implements IUserRepository { | ||||||
|  |   constructor( | ||||||
|  |     @InjectRepository(UserEntity) | ||||||
|  |     private userRepository: Repository<UserEntity>, | ||||||
|  |   ) {} | ||||||
|  | 
 | ||||||
|  |   private async hashPassword(password: string, salt: string): Promise<string> { | ||||||
|  |     return bcrypt.hash(password, salt); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async get(userId: string): Promise<UserEntity | null> { | ||||||
|  |     return this.userRepository.findOne({ where: { id: userId } }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async getByEmail(email: string): Promise<UserEntity | null> { | ||||||
|  |     return this.userRepository.findOne({ where: { email } }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // TODO add DTO for filtering
 | ||||||
|  |   async getList({ excludeId }: { excludeId?: string } = {}): Promise<UserEntity[]> { | ||||||
|  |     if (!excludeId) { | ||||||
|  |       return this.userRepository.find(); // TODO: this should also be ordered the same as below
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return this.userRepository.find({ | ||||||
|  |       where: { id: Not(excludeId) }, | ||||||
|  |       order: { | ||||||
|  |         createdAt: 'DESC', | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async create(createUserDto: CreateUserDto): Promise<UserEntity> { | ||||||
|  |     const newUser = new UserEntity(); | ||||||
|  |     newUser.email = createUserDto.email; | ||||||
|  |     newUser.salt = await bcrypt.genSalt(); | ||||||
|  |     newUser.password = await this.hashPassword(createUserDto.password, newUser.salt); | ||||||
|  |     newUser.firstName = createUserDto.firstName; | ||||||
|  |     newUser.lastName = createUserDto.lastName; | ||||||
|  |     newUser.isAdmin = false; | ||||||
|  | 
 | ||||||
|  |     return this.userRepository.save(newUser); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity> { | ||||||
|  |     user.lastName = updateUserDto.lastName || user.lastName; | ||||||
|  |     user.firstName = updateUserDto.firstName || user.firstName; | ||||||
|  |     user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath; | ||||||
|  |     user.shouldChangePassword = | ||||||
|  |       updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword; | ||||||
|  | 
 | ||||||
|  |     // If payload includes password - Create new password for user
 | ||||||
|  |     if (updateUserDto.password) { | ||||||
|  |       user.salt = await bcrypt.genSalt(); | ||||||
|  |       user.password = await this.hashPassword(updateUserDto.password, user.salt); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // TODO: can this happen? If so we can move it to the service, otherwise remove it (also from DTO)
 | ||||||
|  |     if (updateUserDto.isAdmin) { | ||||||
|  |       const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); | ||||||
|  | 
 | ||||||
|  |       if (adminUser) { | ||||||
|  |         throw new BadRequestException('Admin user exists'); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       user.isAdmin = true; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return this.userRepository.save(user); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> { | ||||||
|  |     user.profileImagePath = fileInfo.path; | ||||||
|  |     return this.userRepository.save(user); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -7,10 +7,18 @@ import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module'; | |||||||
| import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; | ||||||
| import { JwtModule } from '@nestjs/jwt'; | import { JwtModule } from '@nestjs/jwt'; | ||||||
| import { jwtConfig } from '../../config/jwt.config'; | import { jwtConfig } from '../../config/jwt.config'; | ||||||
|  | import { UserRepository, USER_REPOSITORY } from './user-repository'; | ||||||
| 
 | 
 | ||||||
| @Module({ | @Module({ | ||||||
|   imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)], |   imports: [TypeOrmModule.forFeature([UserEntity]), ImmichJwtModule, JwtModule.register(jwtConfig)], | ||||||
|   controllers: [UserController], |   controllers: [UserController], | ||||||
|   providers: [UserService, ImmichJwtService], |   providers: [ | ||||||
|  |     UserService, | ||||||
|  |     ImmichJwtService, | ||||||
|  |     { | ||||||
|  |       provide: USER_REPOSITORY, | ||||||
|  |       useClass: UserRepository | ||||||
|  |     } | ||||||
|  |   ], | ||||||
| }) | }) | ||||||
| export class UserModule {} | export class UserModule {} | ||||||
|  | |||||||
| @ -1,18 +1,15 @@ | |||||||
| import { | import { | ||||||
|   BadRequestException, |   BadRequestException, | ||||||
|  |   Inject, | ||||||
|   Injectable, |   Injectable, | ||||||
|   InternalServerErrorException, |   InternalServerErrorException, | ||||||
|   Logger, |   Logger, | ||||||
|   NotFoundException, |   NotFoundException, | ||||||
|   StreamableFile, |   StreamableFile, | ||||||
| } from '@nestjs/common'; | } from '@nestjs/common'; | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; |  | ||||||
| import { Not, Repository } from 'typeorm'; |  | ||||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||||
| import { CreateUserDto } from './dto/create-user.dto'; | import { CreateUserDto } from './dto/create-user.dto'; | ||||||
| import { UpdateUserDto } from './dto/update-user.dto'; | import { UpdateUserDto } from './dto/update-user.dto'; | ||||||
| import { UserEntity } from '@app/database/entities/user.entity'; |  | ||||||
| import * as bcrypt from 'bcrypt'; |  | ||||||
| import { createReadStream } from 'fs'; | import { createReadStream } from 'fs'; | ||||||
| import { Response as Res } from 'express'; | import { Response as Res } from 'express'; | ||||||
| import { mapUser, UserResponseDto } from './response-dto/user-response.dto'; | import { mapUser, UserResponseDto } from './response-dto/user-response.dto'; | ||||||
| @ -21,32 +18,28 @@ import { | |||||||
|   CreateProfileImageResponseDto, |   CreateProfileImageResponseDto, | ||||||
|   mapCreateProfileImageResponse, |   mapCreateProfileImageResponse, | ||||||
| } from './response-dto/create-profile-image-response.dto'; | } from './response-dto/create-profile-image-response.dto'; | ||||||
|  | import { IUserRepository, USER_REPOSITORY } from './user-repository'; | ||||||
| 
 | 
 | ||||||
| @Injectable() | @Injectable() | ||||||
| export class UserService { | export class UserService { | ||||||
|   constructor( |   constructor( | ||||||
|     @InjectRepository(UserEntity) |     @Inject(USER_REPOSITORY) | ||||||
|     private userRepository: Repository<UserEntity>, |     private userRepository: IUserRepository, | ||||||
|   ) {} |   ) {} | ||||||
| 
 | 
 | ||||||
|   async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> { |   async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> { | ||||||
|     if (isAll) { |     if (isAll) { | ||||||
|       const allUsers = await this.userRepository.find(); |       const allUsers = await this.userRepository.getList(); | ||||||
|       return allUsers.map(mapUser); |       return allUsers.map(mapUser); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const allUserExceptRequestedUser = await this.userRepository.find({ |     const allUserExceptRequestedUser = await this.userRepository.getList({ excludeId: authUser.id }); | ||||||
|       where: { id: Not(authUser.id) }, |  | ||||||
|       order: { |  | ||||||
|         createdAt: 'DESC', |  | ||||||
|       }, |  | ||||||
|     }); |  | ||||||
| 
 | 
 | ||||||
|     return allUserExceptRequestedUser.map(mapUser); |     return allUserExceptRequestedUser.map(mapUser); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async getUserInfo(authUser: AuthUserDto): Promise<UserResponseDto> { |   async getUserInfo(authUser: AuthUserDto): Promise<UserResponseDto> { | ||||||
|     const user = await this.userRepository.findOne({ where: { id: authUser.id } }); |     const user = await this.userRepository.get(authUser.id); | ||||||
|     if (!user) { |     if (!user) { | ||||||
|       throw new BadRequestException('User not found'); |       throw new BadRequestException('User not found'); | ||||||
|     } |     } | ||||||
| @ -54,28 +47,20 @@ export class UserService { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async getUserCount(): Promise<UserCountResponseDto> { |   async getUserCount(): Promise<UserCountResponseDto> { | ||||||
|     const users = await this.userRepository.find(); |     const users = await this.userRepository.getList(); | ||||||
| 
 | 
 | ||||||
|     return mapUserCountResponse(users.length); |     return mapUserCountResponse(users.length); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async createUser(createUserDto: CreateUserDto): Promise<UserResponseDto> { |   async createUser(createUserDto: CreateUserDto): Promise<UserResponseDto> { | ||||||
|     const user = await this.userRepository.findOne({ where: { email: createUserDto.email } }); |     const user = await this.userRepository.getByEmail(createUserDto.email); | ||||||
| 
 | 
 | ||||||
|     if (user) { |     if (user) { | ||||||
|       throw new BadRequestException('User exists'); |       throw new BadRequestException('User exists'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const newUser = new UserEntity(); |  | ||||||
|     newUser.email = createUserDto.email; |  | ||||||
|     newUser.salt = await bcrypt.genSalt(); |  | ||||||
|     newUser.password = await this.hashPassword(createUserDto.password, newUser.salt); |  | ||||||
|     newUser.firstName = createUserDto.firstName; |  | ||||||
|     newUser.lastName = createUserDto.lastName; |  | ||||||
|     newUser.isAdmin = false; |  | ||||||
| 
 |  | ||||||
|     try { |     try { | ||||||
|       const savedUser = await this.userRepository.save(newUser); |       const savedUser = await this.userRepository.create(createUserDto); | ||||||
| 
 | 
 | ||||||
|       return mapUser(savedUser); |       return mapUser(savedUser); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
| @ -84,40 +69,13 @@ export class UserService { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private async hashPassword(password: string, salt: string): Promise<string> { |  | ||||||
|     return bcrypt.hash(password, salt); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   async updateUser(updateUserDto: UpdateUserDto): Promise<UserResponseDto> { |   async updateUser(updateUserDto: UpdateUserDto): Promise<UserResponseDto> { | ||||||
|     const user = await this.userRepository.findOne({ where: { id: updateUserDto.id } }); |     const user = await this.userRepository.get(updateUserDto.id); | ||||||
|     if (!user) { |     if (!user) { | ||||||
|       throw new NotFoundException('User not found'); |       throw new NotFoundException('User not found'); | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     user.lastName = updateUserDto.lastName || user.lastName; |  | ||||||
|     user.firstName = updateUserDto.firstName || user.firstName; |  | ||||||
|     user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath; |  | ||||||
|     user.shouldChangePassword = |  | ||||||
|       updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword; |  | ||||||
| 
 |  | ||||||
|     // If payload includes password - Create new password for user
 |  | ||||||
|     if (updateUserDto.password) { |  | ||||||
|       user.salt = await bcrypt.genSalt(); |  | ||||||
|       user.password = await this.hashPassword(updateUserDto.password, user.salt); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (updateUserDto.isAdmin) { |  | ||||||
|       const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); |  | ||||||
| 
 |  | ||||||
|       if (adminUser) { |  | ||||||
|         throw new BadRequestException('Admin user exists'); |  | ||||||
|       } |  | ||||||
| 
 |  | ||||||
|       user.isAdmin = true; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     try { |     try { | ||||||
|       const updatedUser = await this.userRepository.save(user); |       const updatedUser = await this.userRepository.update(user, updateUserDto); | ||||||
| 
 | 
 | ||||||
|       return mapUser(updatedUser); |       return mapUser(updatedUser); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
| @ -130,10 +88,13 @@ export class UserService { | |||||||
|     authUser: AuthUserDto, |     authUser: AuthUserDto, | ||||||
|     fileInfo: Express.Multer.File, |     fileInfo: Express.Multer.File, | ||||||
|   ): Promise<CreateProfileImageResponseDto> { |   ): Promise<CreateProfileImageResponseDto> { | ||||||
|  |     const user = await this.userRepository.get(authUser.id); | ||||||
|  |     if (!user) { | ||||||
|  |       throw new NotFoundException('User not found'); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     try { |     try { | ||||||
|       await this.userRepository.update(authUser.id, { |       await this.userRepository.createProfileImage(user, fileInfo) | ||||||
|         profileImagePath: fileInfo.path, |  | ||||||
|       }); |  | ||||||
| 
 | 
 | ||||||
|       return mapCreateProfileImageResponse(authUser.id, fileInfo.path); |       return mapCreateProfileImageResponse(authUser.id, fileInfo.path); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
| @ -144,7 +105,7 @@ export class UserService { | |||||||
| 
 | 
 | ||||||
|   async getUserProfileImage(userId: string, res: Res) { |   async getUserProfileImage(userId: string, res: Res) { | ||||||
|     try { |     try { | ||||||
|       const user = await this.userRepository.findOne({ where: { id: userId } }); |       const user = await this.userRepository.get(userId); | ||||||
|       if (!user) { |       if (!user) { | ||||||
|         throw new NotFoundException('User not found'); |         throw new NotFoundException('User not found'); | ||||||
|       } |       } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user