mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	fix(server): require local admin account (#1070)
This commit is contained in:
		
							parent
							
								
									3bb103c6b6
								
							
						
					
					
						commit
						14889e7d85
					
				
							
								
								
									
										10
									
								
								mobile/openapi/doc/UserApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								mobile/openapi/doc/UserApi.md
									
									
									
										generated
									
									
									
								
							| @ -335,7 +335,7 @@ No authorization required | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **getUserCount** | ||||
| > UserCountResponseDto getUserCount() | ||||
| > UserCountResponseDto getUserCount(admin) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @ -344,9 +344,10 @@ No authorization required | ||||
| import 'package:openapi/api.dart'; | ||||
| 
 | ||||
| final api_instance = UserApi(); | ||||
| final admin = true; // bool |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getUserCount(); | ||||
|     final result = api_instance.getUserCount(admin); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling UserApi->getUserCount: $e\n'); | ||||
| @ -354,7 +355,10 @@ try { | ||||
| ``` | ||||
| 
 | ||||
| ### Parameters | ||||
| This endpoint does not need any parameter. | ||||
| 
 | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **admin** | **bool**|  | [optional] [default to false] | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										16
									
								
								mobile/openapi/lib/api/user_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/lib/api/user_api.dart
									
									
									
										generated
									
									
									
								
							| @ -358,7 +358,10 @@ class UserApi { | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'GET /user/count' operation and returns the [Response]. | ||||
|   Future<Response> getUserCountWithHttpInfo() async { | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [bool] admin: | ||||
|   Future<Response> getUserCountWithHttpInfo({ bool? admin, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/user/count'; | ||||
| 
 | ||||
| @ -369,6 +372,10 @@ class UserApi { | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     if (admin != null) { | ||||
|       queryParams.addAll(_queryParams('', 'admin', admin)); | ||||
|     } | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| 
 | ||||
| @ -383,8 +390,11 @@ class UserApi { | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   Future<UserCountResponseDto?> getUserCount() async { | ||||
|     final response = await getUserCountWithHttpInfo(); | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [bool] admin: | ||||
|   Future<UserCountResponseDto?> getUserCount({ bool? admin, }) async { | ||||
|     final response = await getUserCountWithHttpInfo( admin: admin, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|  | ||||
							
								
								
									
										2
									
								
								mobile/openapi/test/user_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/test/user_api_test.dart
									
									
									
										generated
									
									
									
								
							| @ -52,7 +52,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<UserCountResponseDto> getUserCount() async | ||||
|     //Future<UserCountResponseDto> getUserCount({ bool admin }) async | ||||
|     test('test getUserCount', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
							
								
								
									
										12
									
								
								server/apps/immich/src/api-v1/user/dto/user-count.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/apps/immich/src/api-v1/user/dto/user-count.dto.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| import { Transform } from 'class-transformer'; | ||||
| import { IsBoolean, IsOptional } from 'class-validator'; | ||||
| 
 | ||||
| export class UserCountDto { | ||||
|   @IsBoolean() | ||||
|   @IsOptional() | ||||
|   @Transform(({ value }) => value === 'true') | ||||
|   /** | ||||
|    * When true, return the number of admins accounts | ||||
|    */ | ||||
|   admin?: boolean = false; | ||||
| } | ||||
							
								
								
									
										30
									
								
								server/apps/immich/src/api-v1/user/user-repository.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								server/apps/immich/src/api-v1/user/user-repository.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| import { UserEntity } from '@app/database/entities/user.entity'; | ||||
| import { BadRequestException } from '@nestjs/common'; | ||||
| import { Repository } from 'typeorm'; | ||||
| import { UserRepository } from './user-repository'; | ||||
| 
 | ||||
| describe('UserRepository', () => { | ||||
|   let sui: UserRepository; | ||||
|   let userRepositoryMock: jest.Mocked<Repository<UserEntity>>; | ||||
| 
 | ||||
|   beforeAll(() => { | ||||
|     userRepositoryMock = { | ||||
|       findOne: jest.fn(), | ||||
|       save: jest.fn(), | ||||
|     } as unknown as jest.Mocked<Repository<UserEntity>>; | ||||
| 
 | ||||
|     sui = new UserRepository(userRepositoryMock); | ||||
|   }); | ||||
| 
 | ||||
|   it('should be defined', () => { | ||||
|     expect(sui).toBeDefined(); | ||||
|   }); | ||||
| 
 | ||||
|   describe('create', () => { | ||||
|     it('should not create a user if there is no local admin account', async () => { | ||||
|       userRepositoryMock.findOne.mockResolvedValue(null); | ||||
|       await expect(sui.create({ isAdmin: false })).rejects.toBeInstanceOf(BadRequestException); | ||||
|       expect(userRepositoryMock.findOne).toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| @ -60,6 +60,11 @@ export class UserRepository implements IUserRepository { | ||||
|   } | ||||
| 
 | ||||
|   public async create(user: Partial<UserEntity>): Promise<UserEntity> { | ||||
|     const localAdmin = await this.getAdmin(); | ||||
|     if (!localAdmin && !user.isAdmin) { | ||||
|       throw new BadRequestException('The first registered account must the administrator.'); | ||||
|     } | ||||
| 
 | ||||
|     if (user.password) { | ||||
|       user.salt = await bcrypt.genSalt(); | ||||
|       user.password = await this.hashPassword(user.password, user.salt); | ||||
|  | ||||
| @ -26,6 +26,7 @@ import { UserResponseDto } from './response-dto/user-response.dto'; | ||||
| import { UserCountResponseDto } from './response-dto/user-count-response.dto'; | ||||
| import { CreateProfileImageDto } from './dto/create-profile-image.dto'; | ||||
| import { CreateProfileImageResponseDto } from './response-dto/create-profile-image-response.dto'; | ||||
| import { UserCountDto } from './dto/user-count.dto'; | ||||
| 
 | ||||
| @ApiTags('User') | ||||
| @Controller('user') | ||||
| @ -64,8 +65,8 @@ export class UserController { | ||||
|   } | ||||
| 
 | ||||
|   @Get('/count') | ||||
|   async getUserCount(): Promise<UserCountResponseDto> { | ||||
|     return await this.userService.getUserCount(); | ||||
|   async getUserCount(@Query(new ValidationPipe({ transform: true })) dto: UserCountDto): Promise<UserCountResponseDto> { | ||||
|     return await this.userService.getUserCount(dto); | ||||
|   } | ||||
| 
 | ||||
|   @Authenticated({ admin: true }) | ||||
|  | ||||
| @ -14,6 +14,7 @@ import { createReadStream } from 'fs'; | ||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||
| import { CreateUserDto } from './dto/create-user.dto'; | ||||
| import { UpdateUserDto } from './dto/update-user.dto'; | ||||
| import { UserCountDto } from './dto/user-count.dto'; | ||||
| import { | ||||
|   CreateProfileImageResponseDto, | ||||
|   mapCreateProfileImageResponse, | ||||
| @ -57,8 +58,12 @@ export class UserService { | ||||
|     return mapUser(user); | ||||
|   } | ||||
| 
 | ||||
|   async getUserCount(): Promise<UserCountResponseDto> { | ||||
|     const users = await this.userRepository.getList(); | ||||
|   async getUserCount(dto: UserCountDto): Promise<UserCountResponseDto> { | ||||
|     let users = await this.userRepository.getList(); | ||||
| 
 | ||||
|     if (dto.admin) { | ||||
|       users = users.filter((user) => user.isAdmin); | ||||
|     } | ||||
| 
 | ||||
|     return mapUserCountResponse(users.length); | ||||
|   } | ||||
|  | ||||
| @ -166,7 +166,17 @@ | ||||
|     "/user/count": { | ||||
|       "get": { | ||||
|         "operationId": "getUserCount", | ||||
|         "parameters": [], | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "admin", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "default": false, | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|  | ||||
							
								
								
									
										22
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										22
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @ -6108,10 +6108,11 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration) | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {boolean} [admin]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getUserCount: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         getUserCount: async (admin?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             const localVarPath = `/user/count`; | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
| @ -6124,6 +6125,10 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration) | ||||
|             const localVarHeaderParameter = {} as any; | ||||
|             const localVarQueryParameter = {} as any; | ||||
| 
 | ||||
|             if (admin !== undefined) { | ||||
|                 localVarQueryParameter['admin'] = admin; | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
| @ -6292,11 +6297,12 @@ export const UserApiFp = function(configuration?: Configuration) { | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {boolean} [admin]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getUserCount(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserCountResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(options); | ||||
|         async getUserCount(admin?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserCountResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(admin, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
| @ -6393,11 +6399,12 @@ export const UserApiFactory = function (configuration?: Configuration, basePath? | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {boolean} [admin]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getUserCount(options?: any): AxiosPromise<UserCountResponseDto> { | ||||
|             return localVarFp.getUserCount(options).then((request) => request(axios, basePath)); | ||||
|         getUserCount(admin?: boolean, options?: any): AxiosPromise<UserCountResponseDto> { | ||||
|             return localVarFp.getUserCount(admin, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
| @ -6505,12 +6512,13 @@ export class UserApi extends BaseAPI { | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {boolean} [admin]  | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof UserApi | ||||
|      */ | ||||
|     public getUserCount(options?: AxiosRequestConfig) { | ||||
|         return UserApiFp(this.configuration).getUserCount(options).then((request) => request(this.axios, this.basePath)); | ||||
|     public getUserCount(admin?: boolean, options?: AxiosRequestConfig) { | ||||
|         return UserApiFp(this.configuration).getUserCount(admin, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -1,12 +1,5 @@ | ||||
| <script lang="ts"> | ||||
| 	import { goto } from '$app/navigation'; | ||||
| 	import type { PageData } from './$types'; | ||||
| 
 | ||||
| 	export let data: PageData; | ||||
| 
 | ||||
| 	async function onGettingStartedClicked() { | ||||
| 		data.isAdminUserExist ? await goto('/auth/login') : await goto('/auth/register'); | ||||
| 	} | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| @ -26,7 +19,7 @@ | ||||
| 		</h1> | ||||
| 		<button | ||||
| 			class="border px-4 py-4 rounded-md bg-immich-primary dark:bg-immich-dark-primary dark:text-immich-dark-gray dark:border-immich-dark-gray hover:bg-immich-primary/75 text-white font-bold w-[200px]" | ||||
| 			on:click={onGettingStartedClicked} | ||||
| 			on:click={() => goto('/auth/login')} | ||||
| 			>Getting Started | ||||
| 		</button> | ||||
| 	</div> | ||||
|  | ||||
| @ -1,20 +1,10 @@ | ||||
| export const prerender = false; | ||||
| import { redirect } from '@sveltejs/kit'; | ||||
| import { api } from '@api'; | ||||
| import type { PageLoad } from './$types'; | ||||
| import { browser } from '$app/environment'; | ||||
| 
 | ||||
| export const load: PageLoad = async ({ parent }) => { | ||||
| 	const { user } = await parent(); | ||||
| 	if (user) { | ||||
| 		throw redirect(302, '/photos'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (browser) { | ||||
| 		const { data } = await api.userApi.getUserCount(); | ||||
| 
 | ||||
| 		return { | ||||
| 			isAdminUserExist: data.userCount != 0 | ||||
| 		}; | ||||
| 	} | ||||
| }; | ||||
|  | ||||
							
								
								
									
										13
									
								
								web/src/routes/auth/login/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/src/routes/auth/login/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| import { redirect } from '@sveltejs/kit'; | ||||
| import type { PageServerLoad } from './$types'; | ||||
| import { serverApi } from '@api'; | ||||
| 
 | ||||
| export const load: PageServerLoad = async () => { | ||||
| 	const { data } = await serverApi.userApi.getUserCount(true); | ||||
| 	if (data.userCount === 0) { | ||||
| 		// Admin not registered
 | ||||
| 		throw redirect(302, '/auth/register'); | ||||
| 	} | ||||
| 
 | ||||
| 	return; | ||||
| }; | ||||
| @ -3,7 +3,7 @@ import type { PageServerLoad } from './$types'; | ||||
| import { serverApi } from '@api'; | ||||
| 
 | ||||
| export const load: PageServerLoad = async () => { | ||||
| 	const { data } = await serverApi.userApi.getUserCount(); | ||||
| 	const { data } = await serverApi.userApi.getUserCount(true); | ||||
| 	if (data.userCount != 0) { | ||||
| 		// Admin has been registered, redirect to login
 | ||||
| 		throw redirect(302, '/auth/login'); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user