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"; import { CONFIG_FILE } from "src/constants"; /** * ConfigService extends EventEmitter to allow listening for config updates, * now only `update` event will be emitted. */ @Injectable() export class ConfigService extends EventEmitter { yamlConfig?: YamlConfig; logger = new Logger(ConfigService.name); constructor( @Inject("CONFIG_VARIABLES") private configVariables: Config[], private prisma: PrismaService, ) { super(); } // Initialize gets called by the ConfigModule async initialize() { await this.loadYamlConfig(); if (this.yamlConfig) { await this.migrateInitUser(); } } private async loadYamlConfig() { let configFile: string = ""; try { configFile = fs.readFileSync(CONFIG_FILE, "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]; this.emit("update", configVariable.name, configVariable.value); } } } catch (e) { this.logger.error( "Failed to parse config.yaml. Falling back to UI configuration: ", e, ); } } private async migrateInitUser(): Promise { 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, )[0]; if (!configVariable) throw new Error(`Config variable ${key} not found`); const value = configVariable.value ?? configVariable.defaultValue; if (configVariable.type == "number" || configVariable.type == "filesize") return parseInt(value); if (configVariable.type == "boolean") return value == "true"; if (configVariable.type == "string" || configVariable.type == "text") return value; if (configVariable.type == "timespan") return stringToTimespan(value); } async getByCategory(category: string) { 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 = this.configVariables.filter((c) => !c.secret); return configVariables.map((variable) => { return { ...variable, key: `${variable.category}.${variable.name}`, value: variable.value ?? variable.defaultValue, }; }); } 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)); } 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: { category: key.split(".")[0], name: key.split(".")[1], }, }, }); if (!configVariable || configVariable.locked) throw new NotFoundException("Config variable not found"); if (value === "") { value = null; } else if ( typeof value != configVariable.type && typeof value == "string" && configVariable.type != "text" && configVariable.type != "timespan" ) { throw new BadRequestException( `Config variable must be of type ${configVariable.type}`, ); } this.validateConfigVariable(key, value); const updatedVariable = await this.prisma.config.update({ where: { name_category: { category: key.split(".")[0], name: key.split(".")[1], }, }, data: { value: value === null ? null : value.toString() }, }); this.configVariables = await this.prisma.config.findMany(); this.emit("update", key, value); return updatedVariable; } validateConfigVariable(key: string, value: string | number | boolean) { const validations = [ { key: "share.shareIdLength", condition: (value: number) => value >= 2 && value <= 50, message: "Share ID length must be between 2 and 50", }, { key: "share.zipCompressionLevel", condition: (value: number) => value >= 0 && value <= 9, message: "Zip compression level must be between 0 and 9", }, // TODO add validation for timespan type ]; const validation = validations.find((validation) => validation.key == key); if (validation && !validation.condition(value as any)) { throw new BadRequestException(validation.message); } } isEditAllowed(): boolean { return this.yamlConfig === undefined || this.yamlConfig === null; } }