feat(web, server): Ability to use config file instead of admin UI (#3836)

* implement method to read config file

* getConfig returns config file if present

* return isConfigFile for http requests

* disable elements if config file is used, show message if config file is set, copy existing config to clipboard

* fix allowing partial configuration files

* add new env variable to docs

* fix tests

* minor refactoring, address review

* adapt config type in frontend

* remove unnecessary imports

* move config file reading to system-config repo

* add documentation

* fix code formatting in system settings page

* add validator for config file

* fix formatting in docs

* update generated files

* throw error when trying to update config. e.g. via cli or api

* switch to feature flags for isConfigFile

* refactoring

* refactor: config file

* chore: open api

* feat: always show copy/export buttons

* fix: default flags

* refactor: copy to clipboard

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Daniel Dietzler 2023-08-25 19:44:52 +02:00 committed by GitHub
parent 20e0c03b39
commit 59bb727636
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 359 additions and 84 deletions

View file

@ -10,10 +10,13 @@ import {
VideoCodec,
} from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial } from 'typeorm';
import { QueueName } from '../job/job.constants';
import { SystemConfigDto } from './dto';
import { ISystemConfigRepository } from './system-config.repository';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
@ -87,6 +90,7 @@ export enum FeatureFlag {
OAUTH = 'oauth',
OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch',
PASSWORD_LOGIN = 'passwordLogin',
CONFIG_FILE = 'configFile',
}
export type FeatureFlags = Record<FeatureFlag, boolean>;
@ -97,6 +101,7 @@ const singleton = new Subject<SystemConfig>();
export class SystemConfigCore {
private logger = new Logger(SystemConfigCore.name);
private validators: SystemConfigValidator[] = [];
private configCache: SystemConfig | null = null;
public config$ = singleton;
@ -120,6 +125,8 @@ export class SystemConfigCore {
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}`);
}
@ -146,6 +153,7 @@ export class SystemConfigCore {
[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,
};
}
@ -157,18 +165,16 @@ export class SystemConfigCore {
this.validators.push(validator);
}
public async getConfig() {
const overrides = await this.repository.load();
const config: DeepPartial<SystemConfig> = {};
for (const { key, value } of overrides) {
// set via dot notation
_.set(config, key, value);
}
return _.defaultsDeep(config, defaults) as SystemConfig;
public getConfig(force = false): Promise<SystemConfig> {
const configFilePath = process.env.IMMICH_CONFIG_FILE;
return configFilePath ? this.loadFromFile(configFilePath, force) : this.loadFromDatabase();
}
public async updateConfig(config: SystemConfig): Promise<SystemConfig> {
if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) {
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
}
try {
for (const validator of this.validators) {
await validator(config);
@ -211,8 +217,45 @@ export class SystemConfigCore {
}
public async refreshConfig() {
const newConfig = await this.getConfig();
const newConfig = await this.getConfig(true);
this.config$.next(newConfig);
}
private async loadFromDatabase() {
const config: DeepPartial<SystemConfig> = {};
const overrides = await this.repository.load();
for (const { key, value } of overrides) {
// set via dot notation
_.set(config, key, value);
}
return _.defaultsDeep(config, defaults) as SystemConfig;
}
private async loadFromFile(filepath: string, force = false) {
if (force || !this.configCache) {
try {
const overrides = JSON.parse((await this.repository.readFile(filepath)).toString());
const config = plainToClass(SystemConfigDto, _.defaultsDeep(overrides, defaults));
const errors = await validate(config, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
this.logger.error('Validation error', errors);
throw new Error(`Invalid value(s) in file: ${errors}`);
}
this.configCache = config;
} catch (error: Error | any) {
this.logger.error(`Unable to load configuration file: ${filepath} due to ${error}`, error?.stack);
throw new Error('Invalid configuration file');
}
}
return this.configCache;
}
}