2024-03-20 19:32:04 +01:00
|
|
|
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
2024-05-10 14:15:25 -04:00
|
|
|
import AsyncLock from 'async-lock';
|
2024-03-20 19:32:04 +01:00
|
|
|
import { plainToInstance } from 'class-transformer';
|
|
|
|
|
import { validate } from 'class-validator';
|
|
|
|
|
import { load as loadYaml } from 'js-yaml';
|
|
|
|
|
import * as _ from 'lodash';
|
|
|
|
|
import { Subject } from 'rxjs';
|
2024-05-14 14:43:49 -04:00
|
|
|
import { SystemConfig, defaults } from 'src/config';
|
2024-03-20 23:53:07 +01:00
|
|
|
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
2024-05-14 14:43:49 -04:00
|
|
|
import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from 'src/entities/system-config.entity';
|
2024-05-10 14:15:25 -04:00
|
|
|
import { DatabaseLock } from 'src/interfaces/database.interface';
|
2024-04-17 03:00:31 +05:30
|
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
2022-12-09 15:51:42 -05:00
|
|
|
|
2023-12-14 11:55:40 -05:00
|
|
|
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
|
2022-12-16 14:26:12 -06:00
|
|
|
|
2023-08-25 00:15:03 -04:00
|
|
|
export enum FeatureFlag {
|
2024-01-29 09:51:22 -05:00
|
|
|
SMART_SEARCH = 'smartSearch',
|
2023-08-25 00:15:03 -04:00
|
|
|
FACIAL_RECOGNITION = 'facialRecognition',
|
2023-09-08 22:51:46 -04:00
|
|
|
MAP = 'map',
|
2023-09-26 09:03:57 +02:00
|
|
|
REVERSE_GEOCODING = 'reverseGeocoding',
|
2023-08-25 00:15:03 -04:00
|
|
|
SIDECAR = 'sidecar',
|
|
|
|
|
SEARCH = 'search',
|
|
|
|
|
OAUTH = 'oauth',
|
|
|
|
|
OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch',
|
|
|
|
|
PASSWORD_LOGIN = 'passwordLogin',
|
2023-08-25 19:44:52 +02:00
|
|
|
CONFIG_FILE = 'configFile',
|
2023-10-06 07:01:14 +00:00
|
|
|
TRASH = 'trash',
|
2024-05-02 16:43:18 +02:00
|
|
|
EMAIL = 'email',
|
2023-08-25 00:15:03 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type FeatureFlags = Record<FeatureFlag, boolean>;
|
|
|
|
|
|
2023-10-09 02:51:03 +02:00
|
|
|
let instance: SystemConfigCore | null;
|
2023-01-23 23:13:42 -05:00
|
|
|
|
2022-11-14 23:39:32 -05:00
|
|
|
@Injectable()
|
2023-01-21 11:11:55 -05:00
|
|
|
export class SystemConfigCore {
|
2024-05-10 14:15:25 -04:00
|
|
|
private readonly asyncLock = new AsyncLock();
|
|
|
|
|
private config: SystemConfig | null = null;
|
|
|
|
|
private lastUpdated: number | null = null;
|
2022-12-16 14:26:12 -06:00
|
|
|
|
2024-05-14 14:43:49 -04:00
|
|
|
config$ = new Subject<SystemConfig>();
|
2022-12-16 14:26:12 -06:00
|
|
|
|
2024-04-17 03:00:31 +05:30
|
|
|
private constructor(
|
|
|
|
|
private repository: ISystemConfigRepository,
|
|
|
|
|
private logger: ILoggerRepository,
|
|
|
|
|
) {}
|
2023-10-09 02:51:03 +02:00
|
|
|
|
2024-04-17 03:00:31 +05:30
|
|
|
static create(repository: ISystemConfigRepository, logger: ILoggerRepository) {
|
2023-10-09 02:51:03 +02:00
|
|
|
if (!instance) {
|
2024-04-17 03:00:31 +05:30
|
|
|
instance = new SystemConfigCore(repository, logger);
|
2023-10-09 02:51:03 +02:00
|
|
|
}
|
|
|
|
|
return instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static reset() {
|
|
|
|
|
instance = null;
|
|
|
|
|
}
|
2022-11-14 23:39:32 -05:00
|
|
|
|
2023-08-25 00:15:03 -04:00
|
|
|
async requireFeature(feature: FeatureFlag) {
|
|
|
|
|
const hasFeature = await this.hasFeature(feature);
|
|
|
|
|
if (!hasFeature) {
|
|
|
|
|
switch (feature) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case FeatureFlag.SMART_SEARCH: {
|
2024-01-29 09:51:22 -05:00
|
|
|
throw new BadRequestException('Smart search is not enabled');
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case FeatureFlag.FACIAL_RECOGNITION: {
|
2023-08-25 00:15:03 -04:00
|
|
|
throw new BadRequestException('Facial recognition is not enabled');
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case FeatureFlag.SIDECAR: {
|
2023-08-25 00:15:03 -04:00
|
|
|
throw new BadRequestException('Sidecar is not enabled');
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case FeatureFlag.SEARCH: {
|
2023-08-25 00:15:03 -04:00
|
|
|
throw new BadRequestException('Search is not enabled');
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case FeatureFlag.OAUTH: {
|
2023-08-25 00:15:03 -04:00
|
|
|
throw new BadRequestException('OAuth is not enabled');
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case FeatureFlag.PASSWORD_LOGIN: {
|
2023-08-25 00:15:03 -04:00
|
|
|
throw new BadRequestException('Password login is not enabled');
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case FeatureFlag.CONFIG_FILE: {
|
2023-08-25 19:44:52 +02:00
|
|
|
throw new BadRequestException('Config file is not set');
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
default: {
|
2023-08-25 00:15:03 -04:00
|
|
|
throw new ForbiddenException(`Missing required feature: ${feature}`);
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-08-25 00:15:03 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2024-01-29 09:51:22 -05:00
|
|
|
[FeatureFlag.SMART_SEARCH]: mlEnabled && config.machineLearning.clip.enabled,
|
2023-08-29 09:58:00 -04:00
|
|
|
[FeatureFlag.FACIAL_RECOGNITION]: mlEnabled && config.machineLearning.facialRecognition.enabled,
|
2023-09-08 22:51:46 -04:00
|
|
|
[FeatureFlag.MAP]: config.map.enabled,
|
2023-09-26 09:03:57 +02:00
|
|
|
[FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled,
|
2023-08-25 00:15:03 -04:00
|
|
|
[FeatureFlag.SIDECAR]: true,
|
2023-12-08 11:15:46 -05:00
|
|
|
[FeatureFlag.SEARCH]: true,
|
2023-10-06 07:01:14 +00:00
|
|
|
[FeatureFlag.TRASH]: config.trash.enabled,
|
2023-08-25 00:15:03 -04:00
|
|
|
[FeatureFlag.OAUTH]: config.oauth.enabled,
|
|
|
|
|
[FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch,
|
|
|
|
|
[FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled,
|
2023-08-25 19:44:52 +02:00
|
|
|
[FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE,
|
2024-05-02 16:43:18 +02:00
|
|
|
[FeatureFlag.EMAIL]: config.notifications.smtp.enabled,
|
2023-08-25 00:15:03 -04:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-14 14:43:49 -04:00
|
|
|
async getConfig(force = false): Promise<SystemConfig> {
|
2024-05-10 14:15:25 -04:00
|
|
|
if (force || !this.config) {
|
|
|
|
|
const lastUpdated = this.lastUpdated;
|
|
|
|
|
await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => {
|
|
|
|
|
if (lastUpdated === this.lastUpdated) {
|
|
|
|
|
this.config = await this.buildConfig();
|
|
|
|
|
this.lastUpdated = Date.now();
|
|
|
|
|
}
|
|
|
|
|
});
|
2024-01-26 18:02:56 +01:00
|
|
|
}
|
|
|
|
|
|
2024-05-10 14:15:25 -04:00
|
|
|
return this.config!;
|
2022-11-14 23:39:32 -05:00
|
|
|
}
|
|
|
|
|
|
2024-05-14 14:43:49 -04:00
|
|
|
async updateConfig(newConfig: SystemConfig): Promise<SystemConfig> {
|
2023-08-25 19:44:52 +02:00
|
|
|
if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) {
|
|
|
|
|
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-14 23:39:32 -05:00
|
|
|
const updates: SystemConfigEntity[] = [];
|
2022-12-09 15:51:42 -05:00
|
|
|
const deletes: SystemConfigEntity[] = [];
|
|
|
|
|
|
|
|
|
|
for (const key of Object.values(SystemConfigKey)) {
|
|
|
|
|
// get via dot notation
|
2023-12-14 11:55:40 -05:00
|
|
|
const item = { key, value: _.get(newConfig, key) as SystemConfigValue };
|
2022-12-09 15:51:42 -05:00
|
|
|
const defaultValue = _.get(defaults, key);
|
2023-12-14 11:55:40 -05:00
|
|
|
const isMissing = !_.has(newConfig, key);
|
2022-11-14 23:39:32 -05:00
|
|
|
|
2023-10-22 17:14:32 +02:00
|
|
|
if (
|
|
|
|
|
isMissing ||
|
|
|
|
|
item.value === null ||
|
|
|
|
|
item.value === '' ||
|
|
|
|
|
item.value === defaultValue ||
|
|
|
|
|
_.isEqual(item.value, defaultValue)
|
|
|
|
|
) {
|
2022-11-14 23:39:32 -05:00
|
|
|
deletes.push(item);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updates.push(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (updates.length > 0) {
|
2023-01-21 11:11:55 -05:00
|
|
|
await this.repository.saveAll(updates);
|
2022-11-14 23:39:32 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (deletes.length > 0) {
|
2023-01-21 11:11:55 -05:00
|
|
|
await this.repository.deleteKeys(deletes.map((item) => item.key));
|
2022-11-14 23:39:32 -05:00
|
|
|
}
|
2022-12-16 14:26:12 -06:00
|
|
|
|
2024-05-13 12:29:39 -04:00
|
|
|
const config = await this.getConfig(true);
|
2022-12-16 14:26:12 -06:00
|
|
|
|
2023-12-14 11:55:40 -05:00
|
|
|
this.config$.next(config);
|
2022-12-16 14:26:12 -06:00
|
|
|
|
2023-12-14 11:55:40 -05:00
|
|
|
return config;
|
2022-11-14 23:39:32 -05:00
|
|
|
}
|
2022-12-19 12:13:10 -06:00
|
|
|
|
2024-05-14 14:43:49 -04:00
|
|
|
async refreshConfig() {
|
2023-08-25 19:44:52 +02:00
|
|
|
const newConfig = await this.getConfig(true);
|
2022-12-19 12:13:10 -06:00
|
|
|
this.config$.next(newConfig);
|
|
|
|
|
}
|
2023-08-25 19:44:52 +02:00
|
|
|
|
2024-05-10 14:15:25 -04:00
|
|
|
private async buildConfig() {
|
|
|
|
|
const config = _.cloneDeep(defaults);
|
|
|
|
|
const overrides = process.env.IMMICH_CONFIG_FILE
|
|
|
|
|
? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE)
|
|
|
|
|
: await this.repository.load();
|
2023-08-25 19:44:52 +02:00
|
|
|
|
2024-05-10 14:15:25 -04:00
|
|
|
for (const { key, value } of overrides) {
|
|
|
|
|
// set via dot notation
|
|
|
|
|
_.set(config, key, value);
|
|
|
|
|
}
|
2023-10-22 17:14:32 +02:00
|
|
|
|
2024-05-10 14:15:25 -04:00
|
|
|
const errors = await validate(plainToInstance(SystemConfigDto, config));
|
|
|
|
|
if (errors.length > 0) {
|
|
|
|
|
if (process.env.IMMICH_CONFIG_FILE) {
|
|
|
|
|
throw new Error(`Invalid value(s) in file: ${errors}`);
|
|
|
|
|
} else {
|
|
|
|
|
this.logger.error('Validation error', errors);
|
2023-08-25 19:44:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-10 14:15:25 -04:00
|
|
|
if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) {
|
|
|
|
|
config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) {
|
|
|
|
|
config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return config;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async loadFromFile(filepath: string) {
|
|
|
|
|
try {
|
|
|
|
|
const file = await this.repository.readFile(filepath);
|
|
|
|
|
const config = loadYaml(file.toString()) as any;
|
|
|
|
|
const overrides: SystemConfigEntity<SystemConfigValue>[] = [];
|
|
|
|
|
|
|
|
|
|
for (const key of Object.values(SystemConfigKey)) {
|
|
|
|
|
const value = _.get(config, key);
|
|
|
|
|
this.unsetDeep(config, key);
|
|
|
|
|
if (value !== undefined) {
|
|
|
|
|
overrides.push({ key, value });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!_.isEmpty(config)) {
|
|
|
|
|
this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return overrides;
|
|
|
|
|
} catch (error: Error | any) {
|
|
|
|
|
this.logger.error(`Unable to load configuration file: ${filepath}`);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
2023-08-25 19:44:52 +02:00
|
|
|
}
|
2023-10-22 17:14:32 +02:00
|
|
|
|
|
|
|
|
private unsetDeep(object: object, key: string) {
|
|
|
|
|
_.unset(object, key);
|
|
|
|
|
const path = key.split('.');
|
|
|
|
|
while (path.pop()) {
|
|
|
|
|
if (!_.isEmpty(_.get(object, path))) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_.unset(object, path);
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-11-14 23:39:32 -05:00
|
|
|
}
|