diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 824dd3883..2be639a0f 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -2582,6 +2582,12 @@ export interface SearchResponseDto { * @interface ServerConfigDto */ export interface ServerConfigDto { + /** + * + * @type {boolean} + * @memberof ServerConfigDto + */ + 'isInitialized': boolean; /** * * @type {string} @@ -15081,6 +15087,15 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (admin !== undefined) { localVarQueryParameter['admin'] = admin; } diff --git a/mobile/lib/shared/providers/server_info.provider.dart b/mobile/lib/shared/providers/server_info.provider.dart index b4e05ffc9..ce431e7ab 100644 --- a/mobile/lib/shared/providers/server_info.provider.dart +++ b/mobile/lib/shared/providers/server_info.provider.dart @@ -34,6 +34,7 @@ class ServerInfoNotifier extends StateNotifier { mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", oauthButtonText: "", trashDays: 30, + isInitialized: false, ), isVersionMismatch: false, versionMismatchErrorMessage: "", diff --git a/mobile/openapi/doc/ServerConfigDto.md b/mobile/openapi/doc/ServerConfigDto.md index 6b0029c99..cbe56d2f4 100644 Binary files a/mobile/openapi/doc/ServerConfigDto.md and b/mobile/openapi/doc/ServerConfigDto.md differ diff --git a/mobile/openapi/doc/UserApi.md b/mobile/openapi/doc/UserApi.md index 638c59fa3..165a54335 100644 Binary files a/mobile/openapi/doc/UserApi.md and b/mobile/openapi/doc/UserApi.md differ diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 3f4950ec5..25bdeb6d5 100644 Binary files a/mobile/openapi/lib/model/server_config_dto.dart and b/mobile/openapi/lib/model/server_config_dto.dart differ diff --git a/mobile/openapi/test/server_config_dto_test.dart b/mobile/openapi/test/server_config_dto_test.dart index 44e947a44..0aa581b13 100644 Binary files a/mobile/openapi/test/server_config_dto_test.dart and b/mobile/openapi/test/server_config_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 5a5ecd9ad..19b553b9e 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -5004,6 +5004,17 @@ "description": "" } }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], "tags": [ "User" ] @@ -7340,6 +7351,9 @@ }, "ServerConfigDto": { "properties": { + "isInitialized": { + "type": "boolean" + }, "loginPageMessage": { "type": "string" }, @@ -7357,7 +7371,8 @@ "trashDays", "oauthButtonText", "loginPageMessage", - "mapTileUrl" + "mapTileUrl", + "isInitialized" ], "type": "object" }, diff --git a/server/src/domain/repositories/user.repository.ts b/server/src/domain/repositories/user.repository.ts index 984a7beba..3e546c05e 100644 --- a/server/src/domain/repositories/user.repository.ts +++ b/server/src/domain/repositories/user.repository.ts @@ -18,6 +18,7 @@ export const IUserRepository = 'IUserRepository'; export interface IUserRepository { get(id: string, withDeleted?: boolean): Promise; getAdmin(): Promise; + hasAdmin(): Promise; getByEmail(email: string, withPassword?: boolean): Promise; getByStorageLabel(storageLabel: string): Promise; getByOAuthId(oauthId: string): Promise; diff --git a/server/src/domain/server-info/server-info.dto.ts b/server/src/domain/server-info/server-info.dto.ts index 9bbda0f87..2b9ac95cc 100644 --- a/server/src/domain/server-info/server-info.dto.ts +++ b/server/src/domain/server-info/server-info.dto.ts @@ -85,6 +85,7 @@ export class ServerConfigDto { mapTileUrl!: string; @ApiProperty({ type: 'integer' }) trashDays!: number; + isInitialized!: boolean; } export class ServerFeaturesDto implements FeatureFlags { diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index 69a925e86..d68b48473 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -74,11 +74,14 @@ export class ServerInfoService { // TODO move to system config const loginPageMessage = process.env.PUBLIC_LOGIN_PAGE_MESSAGE || ''; + const isInitialized = await this.userRepository.hasAdmin(); + return { loginPageMessage, mapTileUrl: config.map.tileUrl, trashDays: config.trash.days, oauthButtonText: config.oauth.buttonText, + isInitialized, }; } diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 925fcf2e2..01bc676c5 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -26,7 +26,7 @@ import { } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; -import { AdminRoute, AuthUser, Authenticated, PublicRoute } from '../app.guard'; +import { AdminRoute, AuthUser, Authenticated } from '../app.guard'; import { FileUploadInterceptor, Route } from '../app.interceptor'; import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @@ -59,7 +59,7 @@ export class UserController { return this.service.create(createUserDto); } - @PublicRoute() + @AdminRoute() @Get('count') getUserCount(@Query() dto: CountDto): Promise { return this.service.getCount(dto); diff --git a/server/src/infra/repositories/user.repository.ts b/server/src/infra/repositories/user.repository.ts index 0fa112128..559f16aa2 100644 --- a/server/src/infra/repositories/user.repository.ts +++ b/server/src/infra/repositories/user.repository.ts @@ -16,6 +16,10 @@ export class UserRepository implements IUserRepository { return this.userRepository.findOne({ where: { isAdmin: true } }); } + async hasAdmin(): Promise { + return this.userRepository.exist({ where: { isAdmin: true } }); + } + async getByEmail(email: string, withPassword?: boolean): Promise { let builder = this.userRepository.createQueryBuilder('user').where({ email }); diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts index efdbbe521..43cf471f4 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/test/e2e/server-info.e2e-spec.ts @@ -102,6 +102,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { oauthButtonText: 'Login with OAuth', mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', trashDays: 30, + isInitialized: true, }); }); }); diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts index 9b976bc26..af0cbde74 100644 --- a/server/test/e2e/user.e2e-spec.ts +++ b/server/test/e2e/user.e2e-spec.ts @@ -311,10 +311,10 @@ describe(`${UserController.name}`, () => { }); describe('GET /user/count', () => { - it('should not require authentication', async () => { + it('should require authentication', async () => { const { status, body } = await request(server).get(`/user/count`); - expect(status).toBe(200); - expect(body).toEqual({ userCount: 1 }); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); }); it('should start with just the admin', async () => { diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index 09e2ef7bf..30017e758 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -14,5 +14,6 @@ export const newUserRepositoryMock = (): jest.Mocked => { delete: jest.fn(), getDeletedUsers: jest.fn(), restore: jest.fn(), + hasAdmin: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 824dd3883..2be639a0f 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -2582,6 +2582,12 @@ export interface SearchResponseDto { * @interface ServerConfigDto */ export interface ServerConfigDto { + /** + * + * @type {boolean} + * @memberof ServerConfigDto + */ + 'isInitialized': boolean; /** * * @type {string} @@ -15081,6 +15087,15 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration) const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + if (admin !== undefined) { localVarQueryParameter['admin'] = admin; } diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 0cc9911e0..723ad49bd 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -27,6 +27,7 @@ export const serverConfig = writable({ mapTileUrl: '', loginPageMessage: '', trashDays: 30, + isInitialized: false, }); export const loadConfig = async () => { diff --git a/web/src/routes/+page.server.ts b/web/src/routes/+page.server.ts index 51a6ef71d..b469170be 100644 --- a/web/src/routes/+page.server.ts +++ b/web/src/routes/+page.server.ts @@ -10,10 +10,10 @@ export const load = (async ({ parent, locals: { api } }) => { throw redirect(302, AppRoute.PHOTOS); } - const { data } = await api.userApi.getUserCount({ admin: true }); + const { data } = await api.serverInfoApi.getServerConfig(); - if (data.userCount > 0) { - // Redirect to login page if an admin is already registered. + if (data.isInitialized) { + // Redirect to login page if there exists an admin account (i.e. server is initialized) throw redirect(302, AppRoute.AUTH_LOGIN); } diff --git a/web/src/routes/auth/login/+page.server.ts b/web/src/routes/auth/login/+page.server.ts index f325c7cd2..a294173b7 100644 --- a/web/src/routes/auth/login/+page.server.ts +++ b/web/src/routes/auth/login/+page.server.ts @@ -3,8 +3,8 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; export const load = (async ({ locals: { api } }) => { - const { data } = await api.userApi.getUserCount({ admin: true }); - if (data.userCount === 0) { + const { data } = await api.serverInfoApi.getServerConfig(); + if (!data.isInitialized) { // Admin not registered throw redirect(302, AppRoute.AUTH_REGISTER); } diff --git a/web/src/routes/auth/register/+page.server.ts b/web/src/routes/auth/register/+page.server.ts index 85b4d9bc7..186ab2e3d 100644 --- a/web/src/routes/auth/register/+page.server.ts +++ b/web/src/routes/auth/register/+page.server.ts @@ -3,8 +3,8 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; export const load = (async ({ locals: { api } }) => { - const { data } = await api.userApi.getUserCount({ admin: true }); - if (data.userCount != 0) { + const { data } = await api.serverInfoApi.getServerConfig(); + if (data.isInitialized) { // Admin has been registered, redirect to login throw redirect(302, AppRoute.AUTH_LOGIN); }