diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index adf89363ea976..e5fda4d376de3 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import AsyncLock from 'async-lock'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; @@ -14,23 +14,6 @@ import { ISystemConfigRepository } from 'src/interfaces/system-config.interface' export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; -export enum FeatureFlag { - SMART_SEARCH = 'smartSearch', - FACIAL_RECOGNITION = 'facialRecognition', - MAP = 'map', - REVERSE_GEOCODING = 'reverseGeocoding', - SIDECAR = 'sidecar', - SEARCH = 'search', - OAUTH = 'oauth', - OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch', - PASSWORD_LOGIN = 'passwordLogin', - CONFIG_FILE = 'configFile', - TRASH = 'trash', - EMAIL = 'email', -} - -export type FeatureFlags = Record; - let instance: SystemConfigCore | null; @Injectable() @@ -57,63 +40,6 @@ export class SystemConfigCore { instance = null; } - async requireFeature(feature: FeatureFlag) { - const hasFeature = await this.hasFeature(feature); - if (!hasFeature) { - switch (feature) { - case FeatureFlag.SMART_SEARCH: { - throw new BadRequestException('Smart search is not enabled'); - } - case FeatureFlag.FACIAL_RECOGNITION: { - throw new BadRequestException('Facial recognition is not enabled'); - } - case FeatureFlag.SIDECAR: { - throw new BadRequestException('Sidecar is not enabled'); - } - case FeatureFlag.SEARCH: { - throw new BadRequestException('Search is not enabled'); - } - case FeatureFlag.OAUTH: { - throw new BadRequestException('OAuth is not enabled'); - } - case FeatureFlag.PASSWORD_LOGIN: { - throw new BadRequestException('Password login is not enabled'); - } - case FeatureFlag.CONFIG_FILE: { - throw new BadRequestException('Config file is not set'); - } - default: { - throw new ForbiddenException(`Missing required feature: ${feature}`); - } - } - } - } - - async hasFeature(feature: FeatureFlag) { - const features = await this.getFeatures(); - return features[feature] ?? false; - } - - async getFeatures(): Promise { - const config = await this.getConfig(); - const mlEnabled = config.machineLearning.enabled; - - return { - [FeatureFlag.SMART_SEARCH]: mlEnabled && config.machineLearning.clip.enabled, - [FeatureFlag.FACIAL_RECOGNITION]: mlEnabled && config.machineLearning.facialRecognition.enabled, - [FeatureFlag.MAP]: config.map.enabled, - [FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled, - [FeatureFlag.SIDECAR]: true, - [FeatureFlag.SEARCH]: true, - [FeatureFlag.TRASH]: config.trash.enabled, - [FeatureFlag.OAUTH]: config.oauth.enabled, - [FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch, - [FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled, - [FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE, - [FeatureFlag.EMAIL]: config.notifications.smtp.enabled, - }; - } - async getConfig(force = false): Promise { if (force || !this.config) { const lastUpdated = this.lastUpdated; @@ -129,10 +55,6 @@ export class SystemConfigCore { } async updateConfig(newConfig: SystemConfig): Promise { - if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) { - throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); - } - const updates: SystemConfigEntity[] = []; const deletes: SystemConfigEntity[] = []; @@ -176,10 +98,14 @@ export class SystemConfigCore { this.config$.next(newConfig); } + isUsingConfigFile() { + return !!process.env.IMMICH_CONFIG_FILE; + } + private async buildConfig() { const config = _.cloneDeep(defaults); - const overrides = process.env.IMMICH_CONFIG_FILE - ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE) + const overrides = this.isUsingConfigFile() + ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) : await this.repository.load(); for (const { key, value } of overrides) { @@ -189,7 +115,7 @@ export class SystemConfigCore { const errors = await validate(plainToInstance(SystemConfigDto, config)); if (errors.length > 0) { - if (process.env.IMMICH_CONFIG_FILE) { + if (this.isUsingConfigFile()) { throw new Error(`Invalid value(s) in file: ${errors}`); } else { this.logger.error('Validation error', errors); diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server-info.dto.ts index fae016b718eeb..513329d063a62 100644 --- a/server/src/dtos/server-info.dto.ts +++ b/server/src/dtos/server-info.dto.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger'; import type { DateTime } from 'luxon'; -import { FeatureFlags } from 'src/cores/system-config.core'; import { SystemConfigThemeDto } from 'src/dtos/system-config.dto'; import { IVersion, VersionType } from 'src/utils/version'; @@ -96,7 +95,7 @@ export class ServerConfigDto { externalDomain!: string; } -export class ServerFeaturesDto implements FeatureFlags { +export class ServerFeaturesDto { smartSearch!: boolean; configFile!: boolean; facialRecognition!: boolean; diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 4801a6814cbfe..22ccf6766ffa7 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,7 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { SystemConfig } from 'src/config'; -import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core'; -import { SystemConfigKey, SystemConfigKeyPaths } from 'src/entities/system-config.entity'; +import { SystemConfigCore } from 'src/cores/system-config.core'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { @@ -368,32 +367,5 @@ describe(JobService.name, () => { expect(jobMock.queueAll).not.toHaveBeenCalled(); }); } - - const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKeyPaths }> = [ - { - queue: QueueName.SMART_SEARCH, - feature: FeatureFlag.SMART_SEARCH, - configKey: SystemConfigKey.MACHINE_LEARNING_CLIP_ENABLED, - }, - { - queue: QueueName.FACE_DETECTION, - feature: FeatureFlag.FACIAL_RECOGNITION, - configKey: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED, - }, - { - queue: QueueName.FACIAL_RECOGNITION, - feature: FeatureFlag.FACIAL_RECOGNITION, - configKey: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED, - }, - ]; - - for (const { queue, feature, configKey } of featureTests) { - it(`should throw an error if attempting to queue ${queue} when ${feature} is disabled`, async () => { - configMock.load.mockResolvedValue([{ key: configKey, value: false }]); - jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - - await expect(sut.handleCommand(queue, { command: JobCommand.START, force: false })).rejects.toThrow(); - }); - } }); }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 7a56cd61dd9b1..aa625da1dea40 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; -import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core'; +import { SystemConfigCore } from 'src/cores/system-config.core'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; import { AssetType } from 'src/entities/asset.entity'; @@ -112,7 +112,6 @@ export class JobService { } case QueueName.SMART_SEARCH: { - await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH); return this.jobRepository.queue({ name: JobName.QUEUE_SMART_SEARCH, data: { force } }); } @@ -121,7 +120,6 @@ export class JobService { } case QueueName.SIDECAR: { - await this.configCore.requireFeature(FeatureFlag.SIDECAR); return this.jobRepository.queue({ name: JobName.QUEUE_SIDECAR, data: { force } }); } @@ -130,12 +128,10 @@ export class JobService { } case QueueName.FACE_DETECTION: { - await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION); return this.jobRepository.queue({ name: JobName.QUEUE_FACE_DETECTION, data: { force } }); } case QueueName.FACIAL_RECOGNITION: { - await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION); return this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force } }); } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index ae7df3655939e..2d9333d1e46b1 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -7,7 +7,7 @@ import { constants } from 'node:fs/promises'; import path from 'node:path'; import { Subscription } from 'rxjs'; import { StorageCore } from 'src/cores/storage.core'; -import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core'; +import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -331,7 +331,8 @@ export class MetadataService { private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { const { latitude, longitude } = exifData; - if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) { + const { reverseGeocoding } = await this.configCore.getConfig(); + if (!reverseGeocoding.enabled || !longitude || !latitude) { return; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 0c0ece9fdef39..3e9f3a4bb6d5e 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -49,6 +49,7 @@ import { ISystemConfigRepository } from 'src/interfaces/system-config.interface' import { Orientation } from 'src/services/metadata.service'; import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; +import { isFacialRecognitionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { IsNull } from 'typeorm'; @@ -282,7 +283,7 @@ export class PersonService { async handleQueueDetectFaces({ force }: IBaseJob): Promise { const { machineLearning } = await this.configCore.getConfig(); - if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { + if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -313,7 +314,7 @@ export class PersonService { async handleDetectFaces({ id }: IEntityJob): Promise { const { machineLearning } = await this.configCore.getConfig(); - if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { + if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -369,7 +370,7 @@ export class PersonService { async handleQueueRecognizeFaces({ force }: IBaseJob): Promise { const { machineLearning } = await this.configCore.getConfig(); - if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { + if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -400,7 +401,7 @@ export class PersonService { async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise { const { machineLearning } = await this.configCore.getConfig(); - if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { + if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -484,7 +485,7 @@ export class PersonService { async handleGeneratePersonThumbnail(data: IEntityJob): Promise { const { machineLearning, image } = await this.configCore.getConfig(); - if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { + if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index d2636b91cf227..e528142e62888 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,5 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; @@ -24,6 +24,7 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SearchService { @@ -53,7 +54,6 @@ export class SearchService { } async getExploreData(auth: AuthDto): Promise[]> { - await this.configCore.requireFeature(FeatureFlag.SEARCH); const options = { maxFields: 12, minAssetsPerField: 5 }; const results = await Promise.all([ this.assetRepository.getAssetIdByCity(auth.user.id, options), @@ -98,8 +98,11 @@ export class SearchService { } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { - await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH); const { machineLearning } = await this.configCore.getConfig(); + if (!isSmartSearchEnabled(machineLearning)) { + throw new BadRequestException('Smart search is not enabled'); + } + const userIds = await this.getUserIdsToSearch(auth); const embedding = await this.machineLearning.encodeText( diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 37fdbe0fb8413..198e71dea6690 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -23,6 +23,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; +import { isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; import { Version } from 'src/utils/version'; @Injectable() @@ -83,7 +84,23 @@ export class ServerInfoService { } async getFeatures(): Promise { - return this.configCore.getFeatures(); + const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } = + await this.configCore.getConfig(); + + return { + smartSearch: isSmartSearchEnabled(machineLearning), + facialRecognition: isFacialRecognitionEnabled(machineLearning), + map: map.enabled, + reverseGeocoding: reverseGeocoding.enabled, + sidecar: true, + search: true, + trash: trash.enabled, + oauth: oauth.enabled, + oauthAutoLaunch: oauth.autoLaunch, + passwordLogin: passwordLogin.enabled, + configFile: this.configCore.isUsingConfigFile(), + email: notifications.smtp.enabled, + }; } async getTheme() { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 929d15beca6a8..ef2c379770764 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -15,6 +15,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; +import { isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() @@ -50,7 +51,7 @@ export class SmartInfoService { async handleQueueEncodeClip({ force }: IBaseJob): Promise { const { machineLearning } = await this.configCore.getConfig(); - if (!machineLearning.enabled || !machineLearning.clip.enabled) { + if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -75,7 +76,7 @@ export class SmartInfoService { async handleEncodeClip({ id }: IEntityJob): Promise { const { machineLearning } = await this.configCore.getConfig(); - if (!machineLearning.enabled || !machineLearning.clip.enabled) { + if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index eb83e8733d0b1..2e4ae8cb5f4d6 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -67,6 +67,10 @@ export class SystemConfigService { } async updateConfig(dto: SystemConfigDto): Promise { + if (this.core.isUsingConfigFile()) { + throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); + } + const oldConfig = await this.core.getConfig(); try { diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 95eefe703962a..7a98fea139ec9 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -10,11 +10,18 @@ import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.inte import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; +import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Metadata } from 'src/middleware/auth.guard'; +const isMachineLearningEnabled = (machineLearning: SystemConfig['machineLearning']) => machineLearning.enabled; +export const isSmartSearchEnabled = (machineLearning: SystemConfig['machineLearning']) => + isMachineLearningEnabled(machineLearning) && machineLearning.clip.enabled; +export const isFacialRecognitionEnabled = (machineLearning: SystemConfig['machineLearning']) => + isMachineLearningEnabled(machineLearning) && machineLearning.facialRecognition.enabled; + export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; export const handlePromiseError = (promise: Promise, logger: ILoggerRepository): void => {