mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
refactor(server): feature flags (#9492)
This commit is contained in:
parent
5583635947
commit
0f129cae4a
11 changed files with 59 additions and 132 deletions
|
@ -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<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()
|
||||
|
@ -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;
|
||||
|
@ -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[] = [];
|
||||
|
||||
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 } });
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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<JobStatus> {
|
||||
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<JobStatus> {
|
||||
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<JobStatus> {
|
||||
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<JobStatus> {
|
||||
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<JobStatus> {
|
||||
const { machineLearning, image } = await this.configCore.getConfig();
|
||||
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
|
|
|
@ -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<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),
|
||||
|
@ -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(
|
||||
|
|
|
@ -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<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() {
|
||||
|
|
|
@ -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<JobStatus> {
|
||||
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<JobStatus> {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
if (!machineLearning.enabled || !machineLearning.clip.enabled) {
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
|
|
|
@ -67,6 +67,10 @@ export class SystemConfigService {
|
|||
}
|
||||
|
||||
async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
|
||||
if (this.core.isUsingConfigFile()) {
|
||||
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
|
||||
}
|
||||
|
||||
const oldConfig = await this.core.getConfig();
|
||||
|
||||
try {
|
||||
|
|
|
@ -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 = <T>(promise: Promise<T>, logger: ILoggerRepository): void => {
|
||||
|
|
Loading…
Reference in a new issue