mirror of
https://github.com/KevinMidboe/immich.git
synced 2026-01-17 14:46:20 +00:00
feat(server,web): migrate oauth settings from env to system config (#1061)
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user