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
33 changed files with 359 additions and 84 deletions

View File

@@ -6478,6 +6478,9 @@
"clipEncode": {
"type": "boolean"
},
"configFile": {
"type": "boolean"
},
"facialRecognition": {
"type": "boolean"
},
@@ -6501,6 +6504,7 @@
}
},
"required": [
"configFile",
"clipEncode",
"facialRecognition",
"sidecar",

View File

@@ -80,6 +80,7 @@ export class ServerMediaTypesResponseDto {
}
export class ServerFeaturesDto implements FeatureFlags {
configFile!: boolean;
clipEncode!: boolean;
facialRecognition!: boolean;
sidecar!: boolean;

View File

@@ -155,6 +155,7 @@ describe(ServerInfoService.name, () => {
search: true,
sidecar: true,
tagImage: true,
configFile: false,
});
expect(configMock.load).toHaveBeenCalled();
});

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;
}
}

View File

@@ -4,6 +4,7 @@ export const ISystemConfigRepository = 'ISystemConfigRepository';
export interface ISystemConfigRepository {
load(): Promise<SystemConfigEntity[]>;
readFile(filename: string): Promise<Buffer>;
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
deleteKeys(keys: string[]): Promise<void>;
}

View File

@@ -84,6 +84,7 @@ describe(SystemConfigService.name, () => {
let jobMock: jest.Mocked<IJobRepository>;
beforeEach(async () => {
delete process.env.IMMICH_CONFIG_FILE;
configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new SystemConfigService(configMock, jobMock);
@@ -126,6 +127,43 @@ describe(SystemConfigService.name, () => {
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
});
it('should load the config from a file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true } };
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig)));
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json');
});
it('should accept an empty configuration file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
await expect(sut.getConfig()).resolves.toEqual(defaults);
expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json');
});
const tests = [
{ should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } },
{ should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },
{ should: 'validate enums', config: { ffmpeg: { transcode: 'unknown' } } },
{ should: 'validate top level unknown options', config: { unknownOption: true } },
{ should: 'validate nested unknown options', config: { ffmpeg: { unknownOption: true } } },
{ should: 'validate required oauth fields', config: { oauth: { enabled: true } } },
];
for (const test of tests) {
it(`should ${test.should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(test.config)));
await expect(sut.getConfig()).rejects.toBeInstanceOf(Error);
});
}
});
describe('getStorageTemplateOptions', () => {
@@ -176,6 +214,13 @@ describe(SystemConfigService.name, () => {
expect(validator).toHaveBeenCalledWith(updatedConfig);
expect(configMock.saveAll).not.toHaveBeenCalled();
});
it('should throw an error if a config file is in use', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({})));
await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(configMock.saveAll).not.toHaveBeenCalled();
});
});
describe('refreshConfig', () => {

View File

@@ -1,5 +1,6 @@
import { ISystemConfigRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { readFile } from 'fs/promises';
import { In, Repository } from 'typeorm';
import { SystemConfigEntity } from '../entities';
@@ -13,6 +14,8 @@ export class SystemConfigRepository implements ISystemConfigRepository {
return this.repository.find();
}
readFile = readFile;
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]> {
return this.repository.save(items);
}

View File

@@ -3,6 +3,7 @@ import { ISystemConfigRepository } from '@app/domain';
export const newSystemConfigRepositoryMock = (): jest.Mocked<ISystemConfigRepository> => {
return {
load: jest.fn().mockResolvedValue([]),
readFile: jest.fn(),
saveAll: jest.fn().mockResolvedValue([]),
deleteKeys: jest.fn(),
};