mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user