feat: add ability to configure application with a config file (#740)

* add config file possibility

* revert port in docker compose

* Update docker-compose.yml

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* Update docker-compose.yml

Co-authored-by: Elias Schneider <login@eliasschneider.com>

* add attribute description to config file

* remove email message config

* add package to resolve errors

* remove email messages from config

* move config initialization to config module

* revert unnecessary change

* add order

* improve alert

* run formatter

* remove unnecessary packages

* remove unnecessary types

* use logger

* don't save yaml config to db

* allowEdit if no yaml config is set

* improve docs

* fix allow edit state

* remove unnecessary check and refactor code

* restore old config file

* add script that generates `config.example.yaml` automatically

* allow config variables to be changed if they are not set in the `config.yml`

* add back init user

* Revert "allow config variables to be changed if they are not set in the `config.yml`"

This reverts commit 7dbdb6729034be5b083f126f854d5e1411735a54.

* improve info box text

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Mattia Müggler
2025-02-28 11:01:54 +01:00
committed by GitHub
parent f4291421b5
commit 9dfb52a145
21 changed files with 2716 additions and 2077 deletions

View File

@@ -2,12 +2,17 @@ import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import { Config } from "@prisma/client";
import * as argon from "argon2";
import { EventEmitter } from "events";
import * as fs from "fs";
import { PrismaService } from "src/prisma/prisma.service";
import { stringToTimespan } from "src/utils/date.util";
import { parse as yamlParse } from "yaml";
import { YamlConfig } from "../../prisma/seed/config.seed";
/**
* ConfigService extends EventEmitter to allow listening for config updates,
@@ -15,6 +20,9 @@ import { stringToTimespan } from "src/utils/date.util";
*/
@Injectable()
export class ConfigService extends EventEmitter {
yamlConfig?: YamlConfig;
logger = new Logger(ConfigService.name);
constructor(
@Inject("CONFIG_VARIABLES") private configVariables: Config[],
private prisma: PrismaService,
@@ -22,6 +30,65 @@ export class ConfigService extends EventEmitter {
super();
}
async onModuleInit() {
await this.loadYamlConfig();
if (this.yamlConfig) {
await this.migrateInitUser();
}
}
private async loadYamlConfig() {
let configFile: string = "";
try {
configFile = fs.readFileSync("../config.yaml", "utf8");
} catch (e) {
this.logger.log(
"Config.yaml is not set. Falling back to UI configuration.",
);
}
try {
this.yamlConfig = yamlParse(configFile);
if (this.yamlConfig) {
for (const configVariable of this.configVariables) {
const category = this.yamlConfig[configVariable.category];
if (!category) continue;
configVariable.value = category[configVariable.name];
}
}
} catch (e) {
this.logger.error(
"Failed to parse config.yaml. Falling back to UI configuration: ",
e,
);
}
}
private async migrateInitUser(): Promise<void> {
if (!this.yamlConfig.initUser.enabled) return;
const userCount = await this.prisma.user.count({
where: { isAdmin: true },
});
if (userCount === 1) {
this.logger.log(
"Skip initial user creation. Admin user is already existent.",
);
return;
}
await this.prisma.user.create({
data: {
email: this.yamlConfig.initUser.email,
username: this.yamlConfig.initUser.username,
password: this.yamlConfig.initUser.password
? await argon.hash(this.yamlConfig.initUser.password)
: null,
isAdmin: this.yamlConfig.initUser.isAdmin,
},
});
}
get(key: `${string}.${string}`): any {
const configVariable = this.configVariables.filter(
(variable) => `${variable.category}.${variable.name}` == key,
@@ -40,24 +107,22 @@ export class ConfigService extends EventEmitter {
}
async getByCategory(category: string) {
const configVariables = await this.prisma.config.findMany({
orderBy: { order: "asc" },
where: { category, locked: { equals: false } },
});
const configVariables = this.configVariables
.filter((c) => !c.locked && category == c.category)
.sort((c) => c.order);
return configVariables.map((variable) => {
return {
...variable,
key: `${variable.category}.${variable.name}`,
value: variable.value ?? variable.defaultValue,
allowEdit: this.isEditAllowed(),
};
});
}
async list() {
const configVariables = await this.prisma.config.findMany({
where: { secret: { equals: false } },
});
const configVariables = this.configVariables.filter((c) => !c.secret);
return configVariables.map((variable) => {
return {
@@ -69,16 +134,26 @@ export class ConfigService extends EventEmitter {
}
async updateMany(data: { key: string; value: string | number | boolean }[]) {
if (!this.isEditAllowed())
throw new BadRequestException(
"You are only allowed to update config variables via the config.yaml file",
);
const response: Config[] = [];
for (const variable of data) {
response.push(await this.update(variable.key, variable.value));
response.push(await this.update(variable.key, variable.value));
}
return response;
}
async update(key: string, value: string | number | boolean) {
if (!this.isEditAllowed())
throw new BadRequestException(
"You are only allowed to update config variables via the config.yaml file",
);
const configVariable = await this.prisma.config.findUnique({
where: {
name_category: {
@@ -143,4 +218,8 @@ export class ConfigService extends EventEmitter {
throw new BadRequestException(validation.message);
}
}
isEditAllowed(): boolean {
return this.yamlConfig === undefined || this.yamlConfig === null;
}
}

View File

@@ -17,6 +17,9 @@ export class AdminConfigDTO extends ConfigDTO {
@Expose()
obscured: boolean;
@Expose()
allowEdit: boolean;
from(partial: Partial<AdminConfigDTO>) {
return plainToClass(AdminConfigDTO, partial, {
excludeExtraneousValues: true,