From a8edc19f348963014a392343c8dacef01ecbb2a6 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sun, 20 Apr 2025 15:32:57 +0200 Subject: [PATCH] optimize settings helper --- src/command/refreshCommandHandler.ts | 4 +- .../admin/management/webapiController.ts | 2 +- src/controller/publicController.ts | 20 ++ src/helpers/backupHelper.ts | 4 +- src/helpers/calendarHelper.ts | 8 +- src/helpers/convertHelper.ts | 69 ++++ src/helpers/mailHelper.ts | 11 +- src/helpers/settingsHelper.ts | 299 ++++++++++++------ src/index.ts | 2 +- .../1745059495808-settingsFromEnv.ts | 1 - src/type/settingTypes.ts | 42 ++- 11 files changed, 343 insertions(+), 119 deletions(-) create mode 100644 src/helpers/convertHelper.ts diff --git a/src/command/refreshCommandHandler.ts b/src/command/refreshCommandHandler.ts index 959996a..15f4f77 100644 --- a/src/command/refreshCommandHandler.ts +++ b/src/command/refreshCommandHandler.ts @@ -23,8 +23,8 @@ export default abstract class RefreshCommandHandler { token: refreshToken, userId: createRefresh.userId, expiry: createRefresh.isFromPwa - ? new Date(Date.now() + ms(SettingHelper.getSetting("session.pwa_refresh_expiration") as ms.StringValue)) - : new Date(Date.now() + ms(SettingHelper.getSetting("session.refresh_expiration") as ms.StringValue)), + ? new Date(Date.now() + ms(SettingHelper.getSetting("session.pwa_refresh_expiration"))) + : new Date(Date.now() + ms(SettingHelper.getSetting("session.refresh_expiration"))), }) .execute() .then((result) => { diff --git a/src/controller/admin/management/webapiController.ts b/src/controller/admin/management/webapiController.ts index 38d40cd..75a3011 100644 --- a/src/controller/admin/management/webapiController.ts +++ b/src/controller/admin/management/webapiController.ts @@ -78,7 +78,7 @@ export async function createWebapi(req: Request, res: Response): Promise { let token = await JWTHelper.create( { - iss: SettingHelper.getSetting("club.name") as string, + iss: SettingHelper.getSetting("club.name"), sub: "api_token_retrieve", aud: StringHelper.random(32), }, diff --git a/src/controller/publicController.ts b/src/controller/publicController.ts index f44100d..59db122 100644 --- a/src/controller/publicController.ts +++ b/src/controller/publicController.ts @@ -7,6 +7,7 @@ import moment from "moment"; import InternalException from "../exceptions/internalException"; import CalendarFactory from "../factory/admin/club/calendar"; import { CalendarHelper } from "../helpers/calendarHelper"; +import SettingHelper from "../helpers/settingsHelper"; /** * @description get all calendar items by types or nscdr @@ -51,3 +52,22 @@ export async function getCalendarItemsByTypes(req: Request, res: Response): Prom res.type("ics").send(value); } } + +/** + * @description get configuration of UI + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationConfig(req: Request, res: Response): Promise { + let config = { + "club.name": SettingHelper.getSetting("club.name"), + "club.imprint": SettingHelper.getSetting("club.imprint"), + "club.privacy": SettingHelper.getSetting("club.privacy"), + "club.website": SettingHelper.getSetting("club.website"), + "app.custom_login_message": SettingHelper.getSetting("app.custom_login_message"), + "app.show_link_to_calendar": SettingHelper.getSetting("app.show_link_to_calendar"), + }; + + res.json(config); +} diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index 76f7693..159df26 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -103,7 +103,7 @@ export default abstract class BackupHelper { let files = FileSystemHelper.getFilesInDirectory("backup", ".json"); let sorted = files.sort((a, b) => new Date(b.split(".")[0]).getTime() - new Date(a.split(".")[0]).getTime()); - const filesToDelete = sorted.slice(SettingHelper.getSetting("backup.copies") as number); + const filesToDelete = sorted.slice(SettingHelper.getSetting("backup.copies")); for (const file of filesToDelete) { FileSystemHelper.deleteFile("backup", file); } @@ -117,7 +117,7 @@ export default abstract class BackupHelper { let diffInMs = new Date().getTime() - lastBackup.getTime(); let diffInDays = diffInMs / (1000 * 60 * 60 * 24); - if (diffInDays >= (SettingHelper.getSetting("backup.interval") as number)) { + if (diffInDays >= SettingHelper.getSetting("backup.interval")) { await this.createBackup({}); } } diff --git a/src/helpers/calendarHelper.ts b/src/helpers/calendarHelper.ts index fb60496..e10e337 100644 --- a/src/helpers/calendarHelper.ts +++ b/src/helpers/calendarHelper.ts @@ -36,8 +36,8 @@ export abstract class CalendarHelper { location: i.location, categories: [i.type.type], organizer: { - name: SettingHelper.getSetting("club.name") as string, - email: SettingHelper.getSetting("mail.username") as string, + name: SettingHelper.getSetting("club.name"), + email: SettingHelper.getSetting("mail.username"), }, created: moment(i.createdAt) .format("YYYY-M-D-H-m") @@ -49,9 +49,7 @@ export abstract class CalendarHelper { .map((a) => parseInt(a)) as [number, number, number, number, number], transp: "OPAQUE" as "OPAQUE", status: "CONFIRMED", - ...(SettingHelper.getSetting("club.website") != "" - ? { url: SettingHelper.getSetting("club.website") as string } - : {}), + ...(SettingHelper.getSetting("club.website") != "" ? { url: SettingHelper.getSetting("club.website") } : {}), alarms: [ { action: "display", diff --git a/src/helpers/convertHelper.ts b/src/helpers/convertHelper.ts new file mode 100644 index 0000000..ec50a7b --- /dev/null +++ b/src/helpers/convertHelper.ts @@ -0,0 +1,69 @@ +import ms from "ms"; + +export abstract class TypeConverter { + abstract fromString(value: string): T; + abstract toString(value: T): string; + abstract validate(value: string): boolean; +} + +export abstract class StringTypeConverter extends TypeConverter { + fromString(value: string): string { + return value; + } + toString(value: string): string { + return value; + } + validate(value: string): boolean { + return typeof value === "string"; + } +} + +export abstract class NumberTypeConverter extends TypeConverter { + fromString(value: string): number { + return Number(value); + } + toString(value: number): string { + return String(value); + } + validate(value: string): boolean { + const num = Number(value); + return !isNaN(num); + } +} + +export abstract class BooleanTypeConverter extends TypeConverter { + fromString(value: string): boolean { + return value === "true"; + } + toString(value: boolean): string { + return value ? "true" : "false"; + } + validate(value: string): boolean { + return value === "true" || value === "false"; + } +} + +export abstract class MsTypeConverter extends TypeConverter { + fromString(value: string): ms.StringValue { + return value as ms.StringValue; + } + toString(value: ms.StringValue): string { + return String(value); + } + validate(value: string): boolean { + try { + const result = ms(value as ms.StringValue); + return result !== undefined; + } catch (e) { + return false; + } + } +} + +// Konkrete Implementierungen der Converter +export class StringConverter extends StringTypeConverter {} +export class LongStringConverter extends StringTypeConverter {} +export class UrlConverter extends StringTypeConverter {} +export class NumberConverter extends NumberTypeConverter {} +export class BooleanConverter extends BooleanTypeConverter {} +export class MsConverter extends MsTypeConverter {} diff --git a/src/helpers/mailHelper.ts b/src/helpers/mailHelper.ts index 1bf8634..1b0f728 100644 --- a/src/helpers/mailHelper.ts +++ b/src/helpers/mailHelper.ts @@ -6,10 +6,12 @@ export default abstract class MailHelper { private static transporter: Transporter; static createTransport() { + this.transporter?.close(); + this.transporter = createTransport({ host: SettingHelper.getSetting("mail.host"), port: SettingHelper.getSetting("mail.port"), - secure: SettingHelper.getSetting("mail.secure") as boolean, + secure: SettingHelper.getSetting("mail.secure"), auth: { user: SettingHelper.getSetting("mail.username"), pass: SettingHelper.getSetting("mail.password"), @@ -17,6 +19,13 @@ export default abstract class MailHelper { } as TransportOptions); } + static initialize() { + SettingHelper.onSettingTopicChanged("mail", () => { + this.createTransport(); + }); + this.createTransport(); + } + /** * @description send mail * @param {string} target diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index 3652bce..35ce974 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -1,135 +1,242 @@ -import { SettingString, settingsType } from "../type/settingTypes"; -import ms from "ms"; +import { SettingString, settingsType, SettingTopic, SettingTypeAtom, SettingValueMapping } from "../type/settingTypes"; import { CodingHelper } from "./codingHelper"; import SettingCommandHandler from "../command/settingCommandHandler"; import SettingService from "../service/settingService"; import { APPLICATION_SECRET } from "../env.defaults"; +import { + BooleanConverter, + LongStringConverter, + MsConverter, + NumberConverter, + StringConverter, + TypeConverter, + UrlConverter, +} from "./convertHelper"; export default abstract class SettingHelper { private static settings: { [key in SettingString]?: string } = {}; - public static getSetting(key: SettingString): string | number | boolean | ms.StringValue { - let settingType = settingsType[key]; - let setting = this.settings[key] ?? String(settingType.default ?? ""); + private static listeners: Map void>> = new Map(); + private static topicListeners: Map void>> = new Map(); + + private static readonly converters: Record> = { + longstring: new LongStringConverter(), + string: new StringConverter(), + url: new UrlConverter(), + number: new NumberConverter(), + boolean: new BooleanConverter(), + ms: new MsConverter(), + }; + + /** + * Returns the value of a setting with the correct type based on the key + * @param key The key of the setting + * @returns The typed value of the setting + */ + public static getSetting(key: K): SettingValueMapping[K] { + const settingType = settingsType[key]; + const rawValue = this.settings[key] ?? String(settingType.default ?? ""); if (Array.isArray(settingType.type)) { - return setting; + return rawValue as unknown as SettingValueMapping[K]; } - if (settingType.type.includes("/crypt")) { - setting = CodingHelper.decrypt(APPLICATION_SECRET, String(setting)); + let processedValue = rawValue; + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + processedValue = CodingHelper.decrypt(APPLICATION_SECRET, processedValue); } - if (settingType.type.startsWith("string")) { - return setting; - } - if (settingType.type.startsWith("ms")) { - return setting as ms.StringValue; - } - if (settingType.type.startsWith("number")) { - return Number(setting); - } - if (settingType.type.startsWith("boolean")) { - return setting == "true"; - } - return setting; + const baseType = + typeof settingType.type === "string" + ? (settingType.type.split("/")[0] as SettingTypeAtom) + : (settingType.type as SettingTypeAtom); + + return this.converters[baseType].fromString(processedValue) as unknown as SettingValueMapping[K]; } - public static async setSetting(key: SettingString, value: string) { - if (value == undefined || value == null) return; - let settingType = settingsType[key]; + /** + * Sets a setting + * @param key The key of the setting + * @param value The value to set + */ + public static async setSetting(key: SettingString, value: string): Promise { + if (value === undefined || value === null) return; - let result = value; + const settingType = settingsType[key]; + this.validateSetting(key, value); - this.checkSettings(key, result); + const oldValue = this.getSetting(key); + let finalValue = value; - if (!Array.isArray(settingType.type) && settingType.type.includes("/crypt")) { - result = CodingHelper.encrypt(APPLICATION_SECRET, value); + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + finalValue = CodingHelper.encrypt(APPLICATION_SECRET, value); } + this.settings[key] = finalValue; + const [topic, settingKey] = key.split(".") as [SettingTopic, string]; + await SettingCommandHandler.create({ - topic: key.split(".")[0], - key: key.split(".")[1], - value: result, + topic, + key: settingKey, + value: finalValue, }); + + const newValue = this.getSetting(key); + this.notifyListeners(key, newValue, oldValue); } - public static async resetSetting(key: SettingString) { - let settingType = settingsType[key]; + /** + * Resets a setting to its default value + * @param key The key of the setting + */ + public static async resetSetting(key: SettingString): Promise { + const oldValue = this.getSetting(key); + + const settingType = settingsType[key]; this.settings[key] = String(settingType.default ?? ""); + const [topic, settingKey] = key.split(".") as [SettingTopic, string]; await SettingCommandHandler.delete({ - topic: key.split(".")[0], - key: key.split(".")[1], + topic, + key: settingKey, }); + + const newValue = this.getSetting(key); + this.notifyListeners(key, newValue, oldValue); } - public static async configure() { - console.log("Configured Settings"); - let settings = await SettingService.getSettings(); + public static async configure(): Promise { + console.log("Configuring Settings"); + const settings = await SettingService.getSettings(); for (const element of settings) { - let ref = `${element.topic}.${element.key}` as SettingString; + const ref = `${element.topic}.${element.key}` as SettingString; this.settings[ref] = element.value; - this.checkSettings(ref); - } - } - private static checkSettings(key: SettingString, value?: string) { - let settingType = settingsType[key]; - - if (!value) { - value = this.getSetting(key).toString(); - } - - if ( - !Array.isArray(settingType.type) && - settingType.type.startsWith("string") && - this.checkIfEmptyOrNotString(value) - ) { - throw new Error(`set valid value to ${key}`); - } - if ( - !Array.isArray(settingType.type) && - settingType.type.startsWith("ms") && - this.checkIfNotMS(value as ms.StringValue) - ) { - throw new Error(`set valid ms value to ${key} -> [0-9]*(y|d|h|m|s)`); - } - if (!Array.isArray(settingType.type) && settingType.type.startsWith("number") && isNaN(Number(value))) { - throw new Error(`set valid numeric value to ${key}`); - } - if ( - !Array.isArray(settingType.type) && - settingType.type.startsWith("number") && - settingType.min && - Number(value) < settingType.min - ) { - throw new Error(`${key} has to be at least ${settingType.min}`); - } - if ( - !Array.isArray(settingType.type) && - settingType.type.startsWith("boolean") && - value != "true" && - value != "false" - ) { - throw new Error(`"set 'true' or 'false' to ${key}`); - } - } - - private static checkIfEmptyOrNotString(val: any) { - return typeof val != "string" || val == ""; - } - - private static checkIfNotMS(input: ms.StringValue): boolean { - try { - const result = ms(input); - if (result === undefined) { - return true; + try { + this.validateSetting(ref); + } catch (error) { + console.warn(`Invalid setting ${ref}: ${error.message}`); + } + } + } + + /** + * Validates a setting + * @param key The key of the setting + * @param value Optional value to validate + */ + private static validateSetting(key: SettingString, value?: string): void { + const settingType = settingsType[key]; + const valueToCheck = value ?? this.settings[key] ?? String(settingType.default ?? ""); + + if (Array.isArray(settingType.type)) { + return; + } + + let processedValue = valueToCheck; + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + try { + processedValue = CodingHelper.decrypt(APPLICATION_SECRET, processedValue); + } catch (error) { + throw new Error(`Unable to decrypt value for ${key}: ${error.message}`); + } + } + + const baseType = + typeof settingType.type === "string" + ? (settingType.type.split("/")[0] as SettingTypeAtom) + : (settingType.type as SettingTypeAtom); + + if (!this.converters[baseType].validate(processedValue)) { + throw new Error(`Invalid value for ${key} of type ${baseType}`); + } + + if (baseType === "number" && settingType.min !== undefined) { + const numValue = Number(processedValue); + if (numValue < settingType.min) { + throw new Error(`${key} must be at least ${settingType.min}`); + } + } + } + + /** + * Registers a listener for changes to a specific setting + * @param key The setting to monitor + * @param callback Function to be called when changes occur + */ + public static onSettingChanged( + key: K, + callback: (newValue: SettingValueMapping[K], oldValue: SettingValueMapping[K]) => void + ): void { + if (!this.listeners.has(key)) { + this.listeners.set(key, []); + } + + this.listeners.get(key)!.push(callback); + } + + /** + * Registers a listener for changes to a specific setting + * @param key The setting to monitor + * @param callback Function to be called when changes occur + */ + public static onSettingTopicChanged(key: K, callback: () => void): void { + if (!this.topicListeners.has(key)) { + this.topicListeners.set(key, []); + } + + this.topicListeners.get(key)!.push(callback); + } + + /** + * Removes a registered listener + * @param key The setting + * @param callback The callback to remove + */ + public static removeSettingListener( + key: K, + callback: (newValue: SettingValueMapping[K], oldValue: SettingValueMapping[K]) => void + ): void { + if (!this.listeners.has(key)) return; + + const callbacks = this.listeners.get(key)!; + const index = callbacks.indexOf(callback); + + if (index !== -1) { + callbacks.splice(index, 1); + } + + if (callbacks.length === 0) { + this.listeners.delete(key); + } + } + + /** + * Notifies all registered listeners about changes + * @param key The changed setting + * @param newValue The new value + * @param oldValue The old value + */ + private static notifyListeners(key: SettingString, newValue: any, oldValue: any): void { + if (!this.listeners.has(key)) return; + + const callbacks = this.listeners.get(key)!; + for (const callback of callbacks) { + try { + callback(newValue, oldValue); + } catch (error) { + console.error(`Error in setting listener for ${key}:`, error); + } + } + + const topicCallbacks = this.topicListeners.get(key.split(".")[0] as SettingTopic)!; + for (const callback of topicCallbacks) { + try { + callback(); + } catch (error) { + console.error(`Error in setting listener for ${key.split(".")[0]}:`, error); } - } catch (e) { - return true; } - return false; } } diff --git a/src/index.ts b/src/index.ts index 4aa928c..13de2ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ dataSource.initialize().then(async () => { }); } await SettingHelper.configure(); - MailHelper.createTransport(); + MailHelper.initialize(); }); const app = express(); diff --git a/src/migrations/1745059495808-settingsFromEnv.ts b/src/migrations/1745059495808-settingsFromEnv.ts index 6cc8f56..ca5192b 100644 --- a/src/migrations/1745059495808-settingsFromEnv.ts +++ b/src/migrations/1745059495808-settingsFromEnv.ts @@ -1,6 +1,5 @@ import { MigrationInterface, QueryRunner } from "typeorm"; import { setting_table } from "./baseSchemaTables/admin"; -import { envSettingsType } from "../type/settingTypes"; import SettingHelper from "../helpers/settingsHelper"; export class SettingsFromEnv1745059495808 implements MigrationInterface { diff --git a/src/type/settingTypes.ts b/src/type/settingTypes.ts index f1ada64..48ba231 100644 --- a/src/type/settingTypes.ts +++ b/src/type/settingTypes.ts @@ -1,8 +1,6 @@ import ms from "ms"; -import { StringHelper } from "../helpers/stringHelper"; export type SettingTopic = "club" | "app" | "session" | "mail" | "backup" | "security"; - export type SettingString = | "club.name" | "club.imprint" @@ -24,14 +22,38 @@ export type SettingString = export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boolean" | "url"; export type SettingType = SettingTypeAtom | `${SettingTypeAtom}/crypt` | `${SettingTypeAtom}/rand`; -export const settingsType: { - [key in SettingString]: { - type: SettingType | SettingTypeAtom[]; - default?: string | number | boolean | ms.StringValue; - optional?: boolean; - min?: number; - }; -} = { +export type SettingValueMapping = { + "club.name": string; + "club.imprint": string; + "club.privacy": string; + "club.website": string; + "app.custom_login_message": string; + "app.show_link_to_calendar": boolean; + "session.jwt_expiration": ms.StringValue; + "session.refresh_expiration": ms.StringValue; + "session.pwa_refresh_expiration": ms.StringValue; + "mail.username": string; + "mail.password": string; + "mail.host": string; + "mail.port": number; + "mail.secure": boolean; + "backup.interval": number; + "backup.copies": number; +}; + +// Typsicherer Zugriff auf Settings +export type SettingDefinition = { + type: T; + default?: string | number | boolean; + optional?: boolean; + min?: T extends "number" | `number/crypt` | `number/rand` ? number : never; +}; + +export type SettingsSchema = { + [key in SettingString]: SettingDefinition; +}; + +export const settingsType: SettingsSchema = { "club.name": { type: "string", default: "FF Admin" }, "club.imprint": { type: "url", optional: true }, "club.privacy": { type: "url", optional: true },