mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
refactor(server): system config (#1353)
* refactor(server): system config * fix: jest circular import * chore: ignore migrations in coverage report * chore: tests * chore: tests * chore: todo note * chore: remove vite config backup * chore: fix redis hostname
This commit is contained in:
5
server/libs/domain/src/system-config/dto/index.ts
Normal file
5
server/libs/domain/src/system-config/dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './system-config-ffmpeg.dto';
|
||||
export * from './system-config-oauth.dto';
|
||||
export * from './system-config-password-login.dto';
|
||||
export * from './system-config-storage-template.dto';
|
||||
export * from './system-config.dto';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SystemConfigFFmpegDto {
|
||||
@IsString()
|
||||
crf!: string;
|
||||
|
||||
@IsString()
|
||||
preset!: string;
|
||||
|
||||
@IsString()
|
||||
targetVideoCodec!: string;
|
||||
|
||||
@IsString()
|
||||
targetAudioCodec!: string;
|
||||
|
||||
@IsString()
|
||||
targetScaling!: string;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { IsBoolean, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
|
||||
const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
|
||||
|
||||
export class SystemConfigOAuthDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
issuerUrl!: string;
|
||||
|
||||
@ValidateIf(isEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
clientId!: string;
|
||||
|
||||
@ValidateIf(isEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
clientSecret!: string;
|
||||
|
||||
@IsString()
|
||||
scope!: string;
|
||||
|
||||
@IsString()
|
||||
buttonText!: string;
|
||||
|
||||
@IsBoolean()
|
||||
autoRegister!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
autoLaunch!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
mobileOverrideEnabled!: boolean;
|
||||
|
||||
@ValidateIf(isOverrideEnabled)
|
||||
@IsUrl()
|
||||
mobileRedirectUri!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class SystemConfigPasswordLoginDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SystemConfigStorageTemplateDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
template!: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SystemConfig } from '@app/infra/db/entities';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
|
||||
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
|
||||
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
|
||||
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
|
||||
|
||||
export class SystemConfigDto {
|
||||
@ValidateNested()
|
||||
ffmpeg!: SystemConfigFFmpegDto;
|
||||
|
||||
@ValidateNested()
|
||||
oauth!: SystemConfigOAuthDto;
|
||||
|
||||
@ValidateNested()
|
||||
passwordLogin!: SystemConfigPasswordLoginDto;
|
||||
|
||||
@ValidateNested()
|
||||
storageTemplate!: SystemConfigStorageTemplateDto;
|
||||
}
|
||||
|
||||
export function mapConfig(config: SystemConfig): SystemConfigDto {
|
||||
return config;
|
||||
}
|
||||
5
server/libs/domain/src/system-config/index.ts
Normal file
5
server/libs/domain/src/system-config/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
export * from './system-config.repository';
|
||||
export * from './system-config.service';
|
||||
export * from './system-config.datetime-variables';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './system-config-template-storage-option.dto';
|
||||
@@ -0,0 +1,9 @@
|
||||
export class SystemConfigTemplateStorageOptionDto {
|
||||
yearOptions!: string[];
|
||||
monthOptions!: string[];
|
||||
dayOptions!: string[];
|
||||
hourOptions!: string[];
|
||||
minuteOptions!: string[];
|
||||
secondOptions!: string[];
|
||||
presetOptions!: string[];
|
||||
}
|
||||
114
server/libs/domain/src/system-config/system-config.core.ts
Normal file
114
server/libs/domain/src/system-config/system-config.core.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/infra/db/entities';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import * as _ from 'lodash';
|
||||
import { Subject } from 'rxjs';
|
||||
import { DeepPartial } from 'typeorm';
|
||||
import { ISystemConfigRepository } from './system-config.repository';
|
||||
|
||||
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
|
||||
|
||||
const defaults: SystemConfig = Object.freeze({
|
||||
ffmpeg: {
|
||||
crf: '23',
|
||||
preset: 'ultrafast',
|
||||
targetVideoCodec: 'libx264',
|
||||
targetAudioCodec: 'mp3',
|
||||
targetScaling: '1280:-2',
|
||||
},
|
||||
oauth: {
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
scope: 'openid email profile',
|
||||
buttonText: 'Login with OAuth',
|
||||
autoRegister: true,
|
||||
autoLaunch: false,
|
||||
},
|
||||
passwordLogin: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
storageTemplate: {
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigCore {
|
||||
private logger = new Logger(SystemConfigCore.name);
|
||||
private validators: SystemConfigValidator[] = [];
|
||||
|
||||
public config$ = new Subject<SystemConfig>();
|
||||
|
||||
constructor(private repository: ISystemConfigRepository) {}
|
||||
|
||||
public getDefaults(): SystemConfig {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public addValidator(validator: SystemConfigValidator) {
|
||||
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 async updateConfig(config: SystemConfig): Promise<SystemConfig> {
|
||||
try {
|
||||
for (const validator of this.validators) {
|
||||
await validator(config);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Unable to save system config due to a validation error: ${e}`);
|
||||
throw new BadRequestException(e instanceof Error ? e.message : e);
|
||||
}
|
||||
|
||||
const updates: SystemConfigEntity[] = [];
|
||||
const deletes: SystemConfigEntity[] = [];
|
||||
|
||||
for (const key of Object.values(SystemConfigKey)) {
|
||||
// get via dot notation
|
||||
const item = { key, value: _.get(config, key) };
|
||||
const defaultValue = _.get(defaults, key);
|
||||
const isMissing = !_.has(config, key);
|
||||
|
||||
if (isMissing || item.value === null || item.value === '' || item.value === defaultValue) {
|
||||
deletes.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
updates.push(item);
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await this.repository.saveAll(updates);
|
||||
}
|
||||
|
||||
if (deletes.length > 0) {
|
||||
await this.repository.deleteKeys(deletes.map((item) => item.key));
|
||||
}
|
||||
|
||||
const newConfig = await this.getConfig();
|
||||
|
||||
this.config$.next(newConfig);
|
||||
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
public async refreshConfig() {
|
||||
const newConfig = await this.getConfig();
|
||||
|
||||
this.config$.next(newConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const supportedYearTokens = ['y', 'yy'];
|
||||
export const supportedMonthTokens = ['M', 'MM', 'MMM', 'MMMM'];
|
||||
export const supportedDayTokens = ['d', 'dd'];
|
||||
export const supportedHourTokens = ['h', 'hh', 'H', 'HH'];
|
||||
export const supportedMinuteTokens = ['m', 'mm'];
|
||||
export const supportedSecondTokens = ['s', 'ss'];
|
||||
export const supportedPresetTokens = [
|
||||
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{filename}}',
|
||||
'{{y}}/{{MMM}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{dd}}/{{filename}}',
|
||||
'{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SystemConfigEntity } from '@app/infra/db/entities';
|
||||
|
||||
export const ISystemConfigRepository = 'ISystemConfigRepository';
|
||||
|
||||
export interface ISystemConfigRepository {
|
||||
load(): Promise<SystemConfigEntity[]>;
|
||||
saveAll(items: SystemConfigEntity[]): Promise<SystemConfigEntity[]>;
|
||||
deleteKeys(keys: string[]): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { SystemConfigEntity, SystemConfigKey } from '@app/infra';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { newJobRepositoryMock, newSystemConfigRepositoryMock, systemConfigStub } from '../../test';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { SystemConfigValidator } from './system-config.core';
|
||||
import { ISystemConfigRepository } from './system-config.repository';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
|
||||
const updates: SystemConfigEntity[] = [
|
||||
{ key: SystemConfigKey.FFMPEG_CRF, value: 'a new value' },
|
||||
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
||||
];
|
||||
|
||||
const updatedConfig = Object.freeze({
|
||||
ffmpeg: {
|
||||
crf: 'a new value',
|
||||
preset: 'ultrafast',
|
||||
targetAudioCodec: 'mp3',
|
||||
targetScaling: '1280:-2',
|
||||
targetVideoCodec: 'libx264',
|
||||
},
|
||||
oauth: {
|
||||
autoLaunch: true,
|
||||
autoRegister: true,
|
||||
buttonText: 'Login with OAuth',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
scope: 'openid email profile',
|
||||
},
|
||||
passwordLogin: {
|
||||
enabled: true,
|
||||
},
|
||||
storageTemplate: {
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
});
|
||||
|
||||
describe(SystemConfigService.name, () => {
|
||||
let sut: SystemConfigService;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
sut = new SystemConfigService(configMock, jobMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getDefaults', () => {
|
||||
it('should return the default config', () => {
|
||||
configMock.load.mockResolvedValue(updates);
|
||||
|
||||
expect(sut.getDefaults()).toEqual(systemConfigStub.defaults);
|
||||
expect(configMock.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addValidator', () => {
|
||||
it('should call the validator on config changes', async () => {
|
||||
const validator: SystemConfigValidator = jest.fn();
|
||||
|
||||
sut.addValidator(validator);
|
||||
|
||||
await sut.updateConfig(systemConfigStub.defaults);
|
||||
|
||||
expect(validator).toHaveBeenCalledWith(systemConfigStub.defaults);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('should return the default config', async () => {
|
||||
configMock.load.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getConfig()).resolves.toEqual(systemConfigStub.defaults);
|
||||
});
|
||||
|
||||
it('should merge the overrides', async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_CRF, value: 'a new value' },
|
||||
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
||||
]);
|
||||
|
||||
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStorageTemplateOptions', () => {
|
||||
it('should send back the datetime variables', () => {
|
||||
expect(sut.getStorageTemplateOptions()).toEqual({
|
||||
dayOptions: ['d', 'dd'],
|
||||
hourOptions: ['h', 'hh', 'H', 'HH'],
|
||||
minuteOptions: ['m', 'mm'],
|
||||
monthOptions: ['M', 'MM', 'MMM', 'MMMM'],
|
||||
presetOptions: [
|
||||
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{filename}}',
|
||||
'{{y}}/{{MMM}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{filename}}',
|
||||
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
|
||||
'{{y}}/{{MMMM}}/{{dd}}/{{filename}}',
|
||||
'{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MMM}}-{{dd}}/{{filename}}',
|
||||
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
|
||||
],
|
||||
secondOptions: ['s', 'ss'],
|
||||
yearOptions: ['y', 'yy'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateConfig', () => {
|
||||
it('should notify the microservices process', async () => {
|
||||
configMock.load.mockResolvedValue(updates);
|
||||
|
||||
await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
|
||||
|
||||
expect(configMock.saveAll).toHaveBeenCalledWith(updates);
|
||||
expect(jobMock.add).toHaveBeenCalledWith({ name: JobName.CONFIG_CHANGE });
|
||||
});
|
||||
|
||||
it('should throw an error if the config is not valid', async () => {
|
||||
const validator = jest.fn().mockRejectedValue('invalid config');
|
||||
|
||||
sut.addValidator(validator);
|
||||
|
||||
await expect(sut.updateConfig(updatedConfig)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(validator).toHaveBeenCalledWith(updatedConfig);
|
||||
expect(configMock.saveAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshConfig', () => {
|
||||
it('should notify the subscribers', async () => {
|
||||
const changeMock = jest.fn();
|
||||
const subscription = sut.config$.subscribe(changeMock);
|
||||
|
||||
await sut.refreshConfig();
|
||||
|
||||
expect(changeMock).toHaveBeenCalledWith(systemConfigStub.defaults);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import {
|
||||
supportedDayTokens,
|
||||
supportedHourTokens,
|
||||
supportedMinuteTokens,
|
||||
supportedMonthTokens,
|
||||
supportedPresetTokens,
|
||||
supportedSecondTokens,
|
||||
supportedYearTokens,
|
||||
} from './system-config.datetime-variables';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
|
||||
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
|
||||
import { SystemConfigCore, SystemConfigValidator } from './system-config.core';
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigService {
|
||||
private core: SystemConfigCore;
|
||||
constructor(
|
||||
@Inject(ISystemConfigRepository) repository: ISystemConfigRepository,
|
||||
@Inject(IJobRepository) private queue: IJobRepository,
|
||||
) {
|
||||
this.core = new SystemConfigCore(repository);
|
||||
}
|
||||
|
||||
get config$() {
|
||||
return this.core.config$;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<SystemConfigDto> {
|
||||
const config = await this.core.getConfig();
|
||||
return mapConfig(config);
|
||||
}
|
||||
|
||||
getDefaults(): SystemConfigDto {
|
||||
const config = this.core.getDefaults();
|
||||
return mapConfig(config);
|
||||
}
|
||||
|
||||
async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
|
||||
const config = await this.core.updateConfig(dto);
|
||||
await this.queue.add({ name: JobName.CONFIG_CHANGE });
|
||||
return mapConfig(config);
|
||||
}
|
||||
|
||||
async refreshConfig() {
|
||||
await this.core.refreshConfig();
|
||||
}
|
||||
|
||||
addValidator(validator: SystemConfigValidator) {
|
||||
this.core.addValidator(validator);
|
||||
}
|
||||
|
||||
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
|
||||
const options = new SystemConfigTemplateStorageOptionDto();
|
||||
|
||||
options.dayOptions = supportedDayTokens;
|
||||
options.monthOptions = supportedMonthTokens;
|
||||
options.yearOptions = supportedYearTokens;
|
||||
options.hourOptions = supportedHourTokens;
|
||||
options.secondOptions = supportedSecondTokens;
|
||||
options.minuteOptions = supportedMinuteTokens;
|
||||
options.presetOptions = supportedPresetTokens;
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user