feat(server,web): migrate oauth settings from env to system config (#1061)

This commit is contained in:
Jason Rasmussen
2022-12-09 15:51:42 -05:00
committed by GitHub
parent cefdd86b7f
commit 5e680551b9
69 changed files with 2079 additions and 1229 deletions

View File

@@ -1,3 +1,4 @@
import { ImmichConfigModule } from '@app/immich-config';
import { Module } from '@nestjs/common';
import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
import { UserModule } from '../user/user.module';
@@ -5,7 +6,7 @@ import { OAuthController } from './oauth.controller';
import { OAuthService } from './oauth.service';
@Module({
imports: [UserModule, ImmichJwtModule],
imports: [UserModule, ImmichJwtModule, ImmichConfigModule],
controllers: [OAuthController],
providers: [OAuthService],
exports: [OAuthService],

View File

@@ -1,24 +1,13 @@
import { SystemConfig } from '@app/database/entities/system-config.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { ImmichConfigService } from '@app/immich-config';
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generators, Issuer } from 'openid-client';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
import { OAuthService } from '../oauth/oauth.service';
import { IUserRepository } from '../user/user-repository';
interface OAuthConfig {
OAUTH_ENABLED: boolean;
OAUTH_AUTO_REGISTER: boolean;
OAUTH_ISSUER_URL: string;
OAUTH_SCOPE: string;
OAUTH_BUTTON_TEXT: string;
}
const mockConfig = (config: Partial<OAuthConfig>) => {
return (value: keyof OAuthConfig, defaultValue: any) => config[value] ?? defaultValue ?? null;
};
const email = 'user@immich.com';
const sub = 'my-auth-user-sub';
@@ -39,7 +28,7 @@ const loginResponse = {
describe('OAuthService', () => {
let sut: OAuthService;
let userRepositoryMock: jest.Mocked<IUserRepository>;
let configServiceMock: jest.Mocked<ConfigService>;
let immichConfigServiceMock: jest.Mocked<ImmichConfigService>;
let immichJwtServiceMock: jest.Mocked<ImmichJwtService>;
beforeEach(async () => {
@@ -80,11 +69,11 @@ describe('OAuthService', () => {
extractJwtFromCookie: jest.fn(),
} as unknown as jest.Mocked<ImmichJwtService>;
configServiceMock = {
get: jest.fn(),
} as unknown as jest.Mocked<ConfigService>;
immichConfigServiceMock = {
getConfig: jest.fn().mockResolvedValue({ oauth: { enabled: false } }),
} as unknown as jest.Mocked<ImmichConfigService>;
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
});
it('should be defined', () => {
@@ -94,17 +83,17 @@ describe('OAuthService', () => {
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({ enabled: false });
expect(configServiceMock.get).toHaveBeenCalled();
expect(immichConfigServiceMock.getConfig).toHaveBeenCalled();
});
it('should generate the config', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_BUTTON_TEXT: 'OAuth',
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
buttonText: 'OAuth',
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
@@ -119,13 +108,13 @@ describe('OAuthService', () => {
});
it('should not allow auto registering', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_AUTO_REGISTER: false,
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: false,
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null);
@@ -136,13 +125,13 @@ describe('OAuthService', () => {
});
it('should link an existing user', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_AUTO_REGISTER: false,
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: false,
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'warn').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(user);
@@ -156,8 +145,13 @@ describe('OAuthService', () => {
});
it('should allow auto registering by default', async () => {
configServiceMock.get.mockImplementation(mockConfig({ OAUTH_ENABLED: true }));
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
autoRegister: true,
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
jest.spyOn(sut['logger'], 'debug').mockImplementation(() => null);
jest.spyOn(sut['logger'], 'log').mockImplementation(() => null);
userRepositoryMock.getByEmail.mockResolvedValue(null);
@@ -178,13 +172,13 @@ describe('OAuthService', () => {
});
it('should get the session endpoint from the discovery document', async () => {
configServiceMock.get.mockImplementation(
mockConfig({
OAUTH_ENABLED: true,
OAUTH_ISSUER_URL: 'http://issuer',
}),
);
sut = new OAuthService(immichJwtServiceMock, configServiceMock, userRepositoryMock);
immichConfigServiceMock.getConfig.mockResolvedValue({
oauth: {
enabled: true,
issuerUrl: 'http://issuer,',
},
} as SystemConfig);
sut = new OAuthService(immichJwtServiceMock, immichConfigServiceMock, userRepositoryMock);
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
});

View File

@@ -1,5 +1,5 @@
import { ImmichConfigService } from '@app/immich-config';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ClientMetadata, generators, Issuer, UserinfoResponse } from 'openid-client';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { LoginResponseDto } from '../auth/response-dto/login-response.dto';
@@ -16,43 +16,26 @@ type OAuthProfile = UserinfoResponse & {
export class OAuthService {
private readonly logger = new Logger(OAuthService.name);
private readonly enabled: boolean;
private readonly autoRegister: boolean;
private readonly buttonText: string;
private readonly issuerUrl: string;
private readonly clientMetadata: ClientMetadata;
private readonly scope: string;
constructor(
private immichJwtService: ImmichJwtService,
configService: ConfigService,
private immichConfigService: ImmichConfigService,
@Inject(USER_REPOSITORY) private userRepository: IUserRepository,
) {
this.enabled = configService.get('OAUTH_ENABLED', false);
this.autoRegister = configService.get('OAUTH_AUTO_REGISTER', true);
this.issuerUrl = configService.get<string>('OAUTH_ISSUER_URL', '');
this.scope = configService.get<string>('OAUTH_SCOPE', '');
this.buttonText = configService.get<string>('OAUTH_BUTTON_TEXT', '');
this.clientMetadata = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
client_id: configService.get('OAUTH_CLIENT_ID')!,
client_secret: configService.get('OAUTH_CLIENT_SECRET'),
response_types: ['code'],
};
}
) {}
public async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
if (!this.enabled) {
const config = await this.immichConfigService.getConfig();
const { enabled, scope, buttonText } = config.oauth;
if (!enabled) {
return { enabled: false };
}
const url = (await this.getClient()).authorizationUrl({
redirect_uri: dto.redirectUri,
scope: this.scope,
scope,
state: generators.state(),
});
return { enabled: true, buttonText: this.buttonText, url };
return { enabled: true, buttonText, url };
}
public async callback(dto: OAuthCallbackDto): Promise<LoginResponseDto> {
@@ -75,9 +58,11 @@ export class OAuthService {
// register new user
if (!user) {
if (!this.autoRegister) {
const config = await this.immichConfigService.getConfig();
const { autoRegister } = config.oauth;
if (!autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable auto registering, set OAUTH_AUTO_REGISTER=true.`,
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
@@ -95,20 +80,31 @@ export class OAuthService {
}
public async getLogoutEndpoint(): Promise<string | null> {
if (!this.enabled) {
const config = await this.immichConfigService.getConfig();
const { enabled } = config.oauth;
if (!enabled) {
return null;
}
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
}
private async getClient() {
if (!this.enabled) {
const config = await this.immichConfigService.getConfig();
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const issuer = await Issuer.discover(this.issuerUrl);
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
const metadata = { ...this.clientMetadata };
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}

View File

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

View File

@@ -0,0 +1,32 @@
import { IsBoolean, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
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;
}

View File

@@ -0,0 +1,16 @@
import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
export class SystemConfigDto {
@ValidateNested()
ffmpeg!: SystemConfigFFmpegDto;
@ValidateNested()
oauth!: SystemConfigOAuthDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {
return config;
}

View File

@@ -1,20 +0,0 @@
import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, ValidateNested } from 'class-validator';
export class UpdateSystemConfigDto {
@IsNotEmpty()
@ValidateNested({ each: true })
config!: SystemConfigItem[];
}
export class SystemConfigItem {
@IsNotEmpty()
@IsEnum(SystemConfigKey)
@ApiProperty({
enum: SystemConfigKey,
enumName: 'SystemConfigKey',
})
key!: SystemConfigKey;
value!: SystemConfigValue;
}

View File

@@ -1,20 +0,0 @@
import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
import { ApiProperty } from '@nestjs/swagger';
export class SystemConfigResponseDto {
config!: SystemConfigResponseItem[];
}
export class SystemConfigResponseItem {
@ApiProperty({ type: 'string' })
name!: string;
@ApiProperty({ enumName: 'SystemConfigKey', enum: SystemConfigKey })
key!: SystemConfigKey;
@ApiProperty({ type: 'string' })
value!: SystemConfigValue;
@ApiProperty({ type: 'string' })
defaultValue!: SystemConfigValue;
}

View File

@@ -1,8 +1,7 @@
import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { UpdateSystemConfigDto } from './dto/update-system-config';
import { SystemConfigResponseDto } from './response-dto/system-config-response.dto';
import { SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigService } from './system-config.service';
@ApiTags('System Config')
@@ -13,12 +12,17 @@ export class SystemConfigController {
constructor(private readonly systemConfigService: SystemConfigService) {}
@Get()
getConfig(): Promise<SystemConfigResponseDto> {
public getConfig(): Promise<SystemConfigDto> {
return this.systemConfigService.getConfig();
}
@Get('defaults')
public getDefaults(): SystemConfigDto {
return this.systemConfigService.getDefaults();
}
@Put()
async updateConfig(@Body(ValidationPipe) dto: UpdateSystemConfigDto): Promise<SystemConfigResponseDto> {
public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.systemConfigService.updateConfig(dto);
}
}

View File

@@ -1,20 +1,23 @@
import { Injectable } from '@nestjs/common';
import { ImmichConfigService } from 'libs/immich-config/src';
import { UpdateSystemConfigDto } from './dto/update-system-config';
import { SystemConfigResponseDto } from './response-dto/system-config-response.dto';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
@Injectable()
export class SystemConfigService {
constructor(private immichConfigService: ImmichConfigService) {}
async getConfig(): Promise<SystemConfigResponseDto> {
const config = await this.immichConfigService.getSystemConfig();
return { config };
public async getConfig(): Promise<SystemConfigDto> {
const config = await this.immichConfigService.getConfig();
return mapConfig(config);
}
async updateConfig(dto: UpdateSystemConfigDto): Promise<SystemConfigResponseDto> {
await this.immichConfigService.updateSystemConfig(dto.config);
const config = await this.immichConfigService.getSystemConfig();
return { config };
public getDefaults(): SystemConfigDto {
const config = this.immichConfigService.getDefaults();
return mapConfig(config);
}
public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
await this.immichConfigService.updateConfig(dto);
return this.getConfig();
}
}

View File

@@ -42,16 +42,16 @@ export class VideoTranscodeProcessor {
}
async runFFMPEGPipeLine(asset: AssetEntity, savedEncodedPath: string): Promise<void> {
const config = await this.immichConfigService.getSystemConfigMap();
const config = await this.immichConfigService.getConfig();
return new Promise((resolve, reject) => {
ffmpeg(asset.originalPath)
.outputOptions([
`-crf ${config.ffmpeg_crf}`,
`-preset ${config.ffmpeg_preset}`,
`-vcodec ${config.ffmpeg_target_video_codec}`,
`-acodec ${config.ffmpeg_target_audio_codec}`,
`-vf scale=${config.ffmpeg_target_scaling}`,
`-crf ${config.ffmpeg.crf}`,
`-preset ${config.ffmpeg.preset}`,
`-vcodec ${config.ffmpeg.targetVideoCodec}`,
`-acodec ${config.ffmpeg.targetAudioCodec}`,
`-vf scale=${config.ffmpeg.targetScaling}`,
])
.output(savedEncodedPath)
.on('start', () => {

View File

@@ -2086,7 +2086,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigResponseDto"
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
@@ -2109,7 +2109,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSystemConfigDto"
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
@@ -2120,7 +2120,33 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigResponseDto"
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
}
},
"tags": [
"System Config"
],
"security": [
{
"bearer": []
}
]
}
},
"/system-config/defaults": {
"get": {
"operationId": "getDefaults",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigDto"
}
}
}
@@ -3568,56 +3594,82 @@
"command"
]
},
"SystemConfigKey": {
"type": "string",
"enum": [
"ffmpeg_crf",
"ffmpeg_preset",
"ffmpeg_target_video_codec",
"ffmpeg_target_audio_codec",
"ffmpeg_target_scaling"
]
},
"SystemConfigResponseItem": {
"SystemConfigFFmpegDto": {
"type": "object",
"properties": {
"name": {
"crf": {
"type": "string"
},
"key": {
"$ref": "#/components/schemas/SystemConfigKey"
},
"value": {
"preset": {
"type": "string"
},
"defaultValue": {
"targetVideoCodec": {
"type": "string"
},
"targetAudioCodec": {
"type": "string"
},
"targetScaling": {
"type": "string"
}
},
"required": [
"name",
"key",
"value",
"defaultValue"
"crf",
"preset",
"targetVideoCodec",
"targetAudioCodec",
"targetScaling"
]
},
"SystemConfigResponseDto": {
"SystemConfigOAuthDto": {
"type": "object",
"properties": {
"config": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SystemConfigResponseItem"
}
"enabled": {
"type": "boolean"
},
"issuerUrl": {
"type": "string"
},
"clientId": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"scope": {
"type": "string"
},
"buttonText": {
"type": "string"
},
"autoRegister": {
"type": "boolean"
}
},
"required": [
"config"
"enabled",
"issuerUrl",
"clientId",
"clientSecret",
"scope",
"buttonText",
"autoRegister"
]
},
"UpdateSystemConfigDto": {
"SystemConfigDto": {
"type": "object",
"properties": {}
"properties": {
"ffmpeg": {
"$ref": "#/components/schemas/SystemConfigFFmpegDto"
},
"oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto"
}
},
"required": [
"ffmpeg",
"oauth"
]
}
}
}

View File

@@ -16,12 +16,6 @@ const jwtSecretValidator: Joi.CustomValidator<string> = (value) => {
return value;
};
const WHEN_OAUTH_ENABLED = Joi.when('OAUTH_ENABLED', {
is: true,
then: Joi.string().required(),
otherwise: Joi.string().optional(),
});
export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env',
isGlobal: true,
@@ -34,12 +28,5 @@ export const immichAppConfig: ConfigModuleOptions = {
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose').default('simple'),
OAUTH_ENABLED: Joi.bool().valid(true, false).default(false),
OAUTH_BUTTON_TEXT: Joi.string().optional().default('Login with OAuth'),
OAUTH_AUTO_REGISTER: Joi.bool().valid(true, false).default(true),
OAUTH_ISSUER_URL: WHEN_OAUTH_ENABLED,
OAUTH_SCOPE: Joi.string().optional().default('openid email profile'),
OAUTH_CLIENT_ID: WHEN_OAUTH_ENABLED,
OAUTH_CLIENT_SECRET: WHEN_OAUTH_ENABLED,
}),
};

View File

@@ -1,27 +1,47 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('system_config')
export class SystemConfigEntity {
export class SystemConfigEntity<T = string> {
@PrimaryColumn()
key!: SystemConfigKey;
@Column({ type: 'varchar', nullable: true })
value!: SystemConfigValue;
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
value!: T;
}
export type SystemConfig = SystemConfigEntity[];
export type SystemConfigValue = any;
// dot notation matches path in `SystemConfig`
export enum SystemConfigKey {
FFMPEG_CRF = 'ffmpeg_crf',
FFMPEG_PRESET = 'ffmpeg_preset',
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg_target_video_codec',
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg_target_audio_codec',
FFMPEG_TARGET_SCALING = 'ffmpeg_target_scaling',
FFMPEG_CRF = 'ffmpeg.crf',
FFMPEG_PRESET = 'ffmpeg.preset',
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
FFMPEG_TARGET_SCALING = 'ffmpeg.targetScaling',
OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_CLIENT_ID = 'oauth.clientId',
OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
OAUTH_SCOPE = 'oauth.scope',
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
}
export type SystemConfigValue = string | null;
export interface SystemConfigItem {
key: SystemConfigKey;
value: SystemConfigValue;
export interface SystemConfig {
ffmpeg: {
crf: string;
preset: string;
targetVideoCodec: string;
targetAudioCodec: string;
targetScaling: string;
};
oauth: {
enabled: boolean;
issuerUrl: string;
clientId: string;
clientSecret: string;
scope: string;
buttonText: string;
autoRegister: boolean;
};
}

View File

@@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TruncateOldConfigItems1670607437008 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`TRUNCATE TABLE "system_config"`);
}
public async down(): Promise<void> {
// noop
}
}

View File

@@ -1,32 +1,27 @@
import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import * as _ from 'lodash';
import { DeepPartial, In, Repository } from 'typeorm';
type SystemConfigMap = Record<SystemConfigKey, SystemConfigValue>;
const configDefaults: Record<SystemConfigKey, { name: string; value: SystemConfigValue }> = {
[SystemConfigKey.FFMPEG_CRF]: {
name: 'FFmpeg Constant Rate Factor (-crf)',
value: '23',
const defaults: SystemConfig = Object.freeze({
ffmpeg: {
crf: '23',
preset: 'ultrafast',
targetVideoCodec: 'libx264',
targetAudioCodec: 'mp3',
targetScaling: '1280:-2',
},
[SystemConfigKey.FFMPEG_PRESET]: {
name: 'FFmpeg preset (-preset)',
value: 'ultrafast',
oauth: {
enabled: false,
issuerUrl: '',
clientId: '',
clientSecret: '',
scope: 'openid email profile',
buttonText: 'Login with OAuth',
autoRegister: true,
},
[SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC]: {
name: 'FFmpeg target video codec (-vcodec)',
value: 'libx264',
},
[SystemConfigKey.FFMPEG_TARGET_AUDIO_CODEC]: {
name: 'FFmpeg target audio codec (-acodec)',
value: 'mp3',
},
[SystemConfigKey.FFMPEG_TARGET_SCALING]: {
name: 'FFmpeg target scaling (-vf scale=)',
value: '1280:-2',
},
};
});
@Injectable()
export class ImmichConfigService {
@@ -35,38 +30,32 @@ export class ImmichConfigService {
private systemConfigRepository: Repository<SystemConfigEntity>,
) {}
public async getSystemConfig() {
const items = this._getDefaults();
public getDefaults(): SystemConfig {
return defaults;
}
// override default values
public async getConfig() {
const overrides = await this.systemConfigRepository.find();
for (const override of overrides) {
const item = items.find((_item) => _item.key === override.key);
if (item) {
item.value = override.value;
}
const config: DeepPartial<SystemConfig> = {};
for (const { key, value } of overrides) {
// set via dot notation
_.set(config, key, value);
}
return items;
return _.defaultsDeep(config, defaults) as SystemConfig;
}
public async getSystemConfigMap(): Promise<SystemConfigMap> {
const items = await this.getSystemConfig();
const map: Partial<SystemConfigMap> = {};
for (const { key, value } of items) {
map[key] = value;
}
return map as SystemConfigMap;
}
public async updateSystemConfig(items: SystemConfigEntity[]): Promise<void> {
const deletes: SystemConfigEntity[] = [];
public async updateConfig(config: DeepPartial<SystemConfig> | null): Promise<void> {
const updates: SystemConfigEntity[] = [];
const deletes: SystemConfigEntity[] = [];
for (const item of items) {
if (item.value === null || item.value === this._getDefaultValue(item.key)) {
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;
}
@@ -82,16 +71,4 @@ export class ImmichConfigService {
await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) });
}
}
private _getDefaults() {
return Object.values(SystemConfigKey).map((key) => ({
key,
defaultValue: configDefaults[key].value,
...configDefaults[key],
}));
}
private _getDefaultValue(key: SystemConfigKey) {
return this._getDefaults().find((item) => item.key === key)?.value || null;
}
}

View File

@@ -71,14 +71,14 @@
"tsConfigPath": "libs/job/tsconfig.lib.json"
}
},
"system-config": {
"immich-config": {
"type": "library",
"root": "libs/system-config",
"root": "libs/immich-config",
"entryFile": "index",
"sourceRoot": "libs/system-config/src",
"sourceRoot": "libs/immich-config/src",
"compilerOptions": {
"tsConfigPath": "libs/system-config/tsconfig.lib.json"
"tsConfigPath": "libs/immich-config/tsconfig.lib.json"
}
}
}
}
}

View File

@@ -142,7 +142,7 @@
"@app/database/config": "<rootDir>/libs/database/src/config",
"@app/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1",
"^@app/system-config(|/.*)$": "<rootDir>/libs/system-config/src/$1"
"^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1"
}
}
}

View File

@@ -22,8 +22,8 @@
"@app/database/*": ["libs/database/src/*"],
"@app/job": ["libs/job/src"],
"@app/job/*": ["libs/job/src/*"],
"@app/system-config": ["libs/immich-config/src"],
"@app/system-config/*": ["libs/immich-config/src/*"]
"@app/immich-config": ["libs/immich-config/src"],
"@app/immich-config/*": ["libs/immich-config/src/*"]
}
},
"exclude": ["dist", "node_modules", "upload"]