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 --- a/mobile/openapi/doc/ServerConfigDto.md +++ b/mobile/openapi/doc/ServerConfigDto.md @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**isInitialized** | **bool** | | **loginPageMessage** | **String** | | **mapTileUrl** | **String** | | **oauthButtonText** | **String** | | diff --git a/mobile/openapi/doc/UserApi.md b/mobile/openapi/doc/UserApi.md index 638c59fa3..165a54335 100644 --- a/mobile/openapi/doc/UserApi.md +++ b/mobile/openapi/doc/UserApi.md @@ -410,6 +410,20 @@ Name | Type | Description | Notes ### Example ```dart import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = UserApi(); final admin = true; // bool | @@ -434,7 +448,7 @@ Name | Type | Description | Notes ### Authorization -No authorization required +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) ### HTTP request headers diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 3f4950ec5..25bdeb6d5 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -13,12 +13,15 @@ part of openapi.api; class ServerConfigDto { /// Returns a new [ServerConfigDto] instance. ServerConfigDto({ + required this.isInitialized, required this.loginPageMessage, required this.mapTileUrl, required this.oauthButtonText, required this.trashDays, }); + bool isInitialized; + String loginPageMessage; String mapTileUrl; @@ -29,6 +32,7 @@ class ServerConfigDto { @override bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto && + other.isInitialized == isInitialized && other.loginPageMessage == loginPageMessage && other.mapTileUrl == mapTileUrl && other.oauthButtonText == oauthButtonText && @@ -37,16 +41,18 @@ class ServerConfigDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (isInitialized.hashCode) + (loginPageMessage.hashCode) + (mapTileUrl.hashCode) + (oauthButtonText.hashCode) + (trashDays.hashCode); @override - String toString() => 'ServerConfigDto[loginPageMessage=$loginPageMessage, mapTileUrl=$mapTileUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays]'; + String toString() => 'ServerConfigDto[isInitialized=$isInitialized, loginPageMessage=$loginPageMessage, mapTileUrl=$mapTileUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays]'; Map toJson() { final json = {}; + json[r'isInitialized'] = this.isInitialized; json[r'loginPageMessage'] = this.loginPageMessage; json[r'mapTileUrl'] = this.mapTileUrl; json[r'oauthButtonText'] = this.oauthButtonText; @@ -62,6 +68,7 @@ class ServerConfigDto { final json = value.cast(); return ServerConfigDto( + isInitialized: mapValueOfType(json, r'isInitialized')!, loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, mapTileUrl: mapValueOfType(json, r'mapTileUrl')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, @@ -113,6 +120,7 @@ class ServerConfigDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'isInitialized', 'loginPageMessage', 'mapTileUrl', 'oauthButtonText', diff --git a/mobile/openapi/test/server_config_dto_test.dart b/mobile/openapi/test/server_config_dto_test.dart index 44e947a44..0aa581b13 100644 --- a/mobile/openapi/test/server_config_dto_test.dart +++ b/mobile/openapi/test/server_config_dto_test.dart @@ -16,6 +16,11 @@ void main() { // final instance = ServerConfigDto(); group('test ServerConfigDto', () { + // bool isInitialized + test('to test the property `isInitialized`', () async { + // TODO + }); + // String loginPageMessage test('to test the property `loginPageMessage`', () async { // TODO 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); }