Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(server): feature flags #9492

Merged
merged 1 commit into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 8 additions & 82 deletions server/src/cores/system-config.core.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,23 +14,6 @@ import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'

export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;

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<FeatureFlag, boolean>;

let instance: SystemConfigCore | null;

@Injectable()
Expand All @@ -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<FeatureFlags> {
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<SystemConfig> {
if (force || !this.config) {
const lastUpdated = this.lastUpdated;
Expand All @@ -129,10 +55,6 @@ export class SystemConfigCore {
}

async updateConfig(newConfig: SystemConfig): Promise<SystemConfig> {
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[] = [];

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions server/src/dtos/server-info.dto.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -96,7 +95,7 @@ export class ServerConfigDto {
externalDomain!: string;
}

export class ServerFeaturesDto implements FeatureFlags {
export class ServerFeaturesDto {
smartSearch!: boolean;
configFile!: boolean;
facialRecognition!: boolean;
Expand Down
30 changes: 1 addition & 29 deletions server/src/services/job.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
});
}
});
});
6 changes: 1 addition & 5 deletions server/src/services/job.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 } });
}

Expand All @@ -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 } });
}

Expand All @@ -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 } });
}

Expand Down
5 changes: 3 additions & 2 deletions server/src/services/metadata.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
11 changes: 6 additions & 5 deletions server/src/services/person.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -282,7 +283,7 @@ export class PersonService {

async handleQueueDetectFaces({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}

Expand Down Expand Up @@ -313,7 +314,7 @@ export class PersonService {

async handleDetectFaces({ id }: IEntityJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}

Expand Down Expand Up @@ -369,7 +370,7 @@ export class PersonService {

async handleQueueRecognizeFaces({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}

Expand Down Expand Up @@ -400,7 +401,7 @@ export class PersonService {

async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}

Expand Down Expand Up @@ -484,7 +485,7 @@ export class PersonService {

async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> {
const { machineLearning, image } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}

Expand Down
11 changes: 7 additions & 4 deletions server/src/services/search.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -53,7 +54,6 @@ export class SearchService {
}

async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
await this.configCore.requireFeature(FeatureFlag.SEARCH);
const options = { maxFields: 12, minAssetsPerField: 5 };
const results = await Promise.all([
this.assetRepository.getAssetIdByCity(auth.user.id, options),
Expand Down Expand Up @@ -98,8 +98,11 @@ export class SearchService {
}

async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
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(
Expand Down
19 changes: 18 additions & 1 deletion server/src/services/server-info.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -83,7 +84,23 @@ export class ServerInfoService {
}

async getFeatures(): Promise<ServerFeaturesDto> {
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() {
Expand Down
5 changes: 3 additions & 2 deletions server/src/services/smart-info.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -50,7 +51,7 @@ export class SmartInfoService {

async handleQueueEncodeClip({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.clip.enabled) {
if (!isSmartSearchEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}

Expand All @@ -75,7 +76,7 @@ export class SmartInfoService {

async handleEncodeClip({ id }: IEntityJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.clip.enabled) {
if (!isSmartSearchEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}

Expand Down
Loading
Loading