From 63f206cc6ac5d52a291b1a45f89e8eed12a73333 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 19 Apr 2025 09:29:05 +0200 Subject: [PATCH 01/29] do not print if config is null --- src/helpers/newsletterHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/newsletterHelper.ts b/src/helpers/newsletterHelper.ts index e84427f..5768c8a 100644 --- a/src/helpers/newsletterHelper.ts +++ b/src/helpers/newsletterHelper.ts @@ -145,7 +145,7 @@ export abstract class NewsletterHelper { return []; } else { let members = await MemberService.getAll({ noLimit: true, ids: queryMemberIds }); - return members[0]; + return members[0].filter((m) => m.sendNewsletter != null); } } From f1395357c58971635c12346cbd61bf6727b16d7f Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 19 Apr 2025 09:42:11 +0200 Subject: [PATCH 02/29] add send none state to newsletter config --- .../newsletterConfigCommand.ts | 4 ++-- src/entity/configuration/newsletterConfig.ts | 8 ++++---- src/enums/newsletterConfigEnum.ts | 5 +++++ src/enums/newsletterConfigType.ts | 4 ---- src/helpers/newsletterHelper.ts | 19 ++++++++----------- .../configuration/newsletterConfig.models.ts | 4 ++-- 6 files changed, 21 insertions(+), 23 deletions(-) create mode 100644 src/enums/newsletterConfigEnum.ts delete mode 100644 src/enums/newsletterConfigType.ts diff --git a/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts b/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts index f07de54..5573062 100644 --- a/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts +++ b/src/command/configuration/newsletterConfig/newsletterConfigCommand.ts @@ -1,8 +1,8 @@ -import { NewsletterConfigType } from "../../../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../../../enums/newsletterConfigEnum"; export interface SetNewsletterConfigCommand { comTypeId: number; - config: NewsletterConfigType; + config: NewsletterConfigEnum; } export interface DeleteNewsletterConfigCommand { diff --git a/src/entity/configuration/newsletterConfig.ts b/src/entity/configuration/newsletterConfig.ts index 17dde81..2d5d69c 100644 --- a/src/entity/configuration/newsletterConfig.ts +++ b/src/entity/configuration/newsletterConfig.ts @@ -1,5 +1,5 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm"; -import { NewsletterConfigType } from "../../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../../enums/newsletterConfigEnum"; import { communicationType } from "./communicationType"; @Entity() @@ -11,15 +11,15 @@ export class newsletterConfig { type: "varchar", length: "255", transformer: { - to(value: NewsletterConfigType) { + to(value: NewsletterConfigEnum) { return value.toString(); }, from(value: string) { - return NewsletterConfigType[value as keyof typeof NewsletterConfigType]; + return NewsletterConfigEnum[value as keyof typeof NewsletterConfigEnum]; }, }, }) - config: NewsletterConfigType; + config: NewsletterConfigEnum; @ManyToOne(() => communicationType, { nullable: false, diff --git a/src/enums/newsletterConfigEnum.ts b/src/enums/newsletterConfigEnum.ts new file mode 100644 index 0000000..1e7313f --- /dev/null +++ b/src/enums/newsletterConfigEnum.ts @@ -0,0 +1,5 @@ +export enum NewsletterConfigEnum { + pdf = "pdf", + mail = "mail", + none = "none", +} diff --git a/src/enums/newsletterConfigType.ts b/src/enums/newsletterConfigType.ts deleted file mode 100644 index 4703494..0000000 --- a/src/enums/newsletterConfigType.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum NewsletterConfigType { - pdf = "pdf", - mail = "mail", -} diff --git a/src/helpers/newsletterHelper.ts b/src/helpers/newsletterHelper.ts index 5768c8a..32cdd16 100644 --- a/src/helpers/newsletterHelper.ts +++ b/src/helpers/newsletterHelper.ts @@ -14,7 +14,7 @@ import { CLUB_NAME } from "../env.defaults"; import { TemplateHelper } from "./templateHelper"; import { PdfExport } from "./pdfExport"; import NewsletterConfigService from "../service/configuration/newsletterConfigService"; -import { NewsletterConfigType } from "../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../enums/newsletterConfigEnum"; import InternalException from "../exceptions/internalException"; import EventEmitter from "events"; @@ -154,14 +154,11 @@ export abstract class NewsletterHelper { let recipients = await NewsletterRecipientsService.getAll(newsletterId); let config = await NewsletterConfigService.getAll(); - let allowedForMail = config.filter((c) => c.config == NewsletterConfigType.mail).map((c) => c.comTypeId); + let allowedForMail = config.filter((c) => c.config == NewsletterConfigEnum.mail).map((c) => c.comTypeId); const members = await this.transformRecipientsToMembers(newsletter, recipients); const mailRecipients = members.filter( - (m) => - m.sendNewsletter != null && - m.sendNewsletter?.email != null && - allowedForMail.includes(m.sendNewsletter?.type?.id) + (m) => m.sendNewsletter?.email != "" && allowedForMail.includes(m.sendNewsletter?.type?.id) ); return mailRecipients; @@ -172,12 +169,12 @@ export abstract class NewsletterHelper { let recipients = await NewsletterRecipientsService.getAll(newsletterId); let config = await NewsletterConfigService.getAll(); - let notAllowedForPdf = config.filter((c) => c.config == NewsletterConfigType.mail).map((c) => c.comTypeId); + let notAllowedForPdf = config + .filter((c) => c.config == NewsletterConfigEnum.none || c.config == NewsletterConfigEnum.mail) + .map((c) => c.comTypeId); const members = await this.transformRecipientsToMembers(newsletter, recipients); - const pdfRecipients = members.filter( - (m) => !notAllowedForPdf.includes(m.sendNewsletter?.type?.id) || m.sendNewsletter == null - ); + const pdfRecipients = members.filter((m) => !notAllowedForPdf.includes(m.sendNewsletter?.type?.id)); pdfRecipients.unshift({ id: "0", @@ -278,7 +275,7 @@ export abstract class NewsletterHelper { if (error) throw new InternalException("Failed Building ICS form Pdf", error); this.saveIcsToFile(newsletter, value); - let printWithAdress = config.filter((c) => c.config == NewsletterConfigType.pdf).map((c) => c.comTypeId); + let printWithAdress = config.filter((c) => c.config == NewsletterConfigEnum.pdf).map((c) => c.comTypeId); const pdfRecipients = await this.getPrintRecipients(newsletterId); diff --git a/src/viewmodel/admin/configuration/newsletterConfig.models.ts b/src/viewmodel/admin/configuration/newsletterConfig.models.ts index 3538bfe..6bb2041 100644 --- a/src/viewmodel/admin/configuration/newsletterConfig.models.ts +++ b/src/viewmodel/admin/configuration/newsletterConfig.models.ts @@ -1,8 +1,8 @@ -import { NewsletterConfigType } from "../../../enums/newsletterConfigType"; +import { NewsletterConfigEnum } from "../../../enums/newsletterConfigEnum"; import { CommunicationTypeViewModel } from "./communicationType.models"; export interface NewsletterConfigViewModel { comTypeId: number; - config: NewsletterConfigType; + config: NewsletterConfigEnum; comType: CommunicationTypeViewModel; } From a827185bf15eb8d864e208d9a83c993b9d3d73d1 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 19 Apr 2025 10:04:28 +0200 Subject: [PATCH 03/29] change: remove queryObj from newsletter --- src/factory/admin/club/newsletter/newsletter.ts | 1 - src/viewmodel/admin/club/newsletter/newsletter.models.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/factory/admin/club/newsletter/newsletter.ts b/src/factory/admin/club/newsletter/newsletter.ts index 39c19e3..6cc9685 100644 --- a/src/factory/admin/club/newsletter/newsletter.ts +++ b/src/factory/admin/club/newsletter/newsletter.ts @@ -18,7 +18,6 @@ export default abstract class NewsletterFactory { newsletterSignatur: record.newsletterSignatur, isSent: record.isSent, recipientsByQueryId: record?.recipientsByQuery ? record.recipientsByQuery.id : null, - recipientsByQuery: record?.recipientsByQuery ? QueryStoreFactory.mapToSingle(record.recipientsByQuery) : null, }; } diff --git a/src/viewmodel/admin/club/newsletter/newsletter.models.ts b/src/viewmodel/admin/club/newsletter/newsletter.models.ts index 37bf30c..3a1c47f 100644 --- a/src/viewmodel/admin/club/newsletter/newsletter.models.ts +++ b/src/viewmodel/admin/club/newsletter/newsletter.models.ts @@ -1,5 +1,3 @@ -import { QueryStoreViewModel } from "../../configuration/queryStore.models"; - export interface NewsletterViewModel { id: number; title: string; @@ -9,5 +7,4 @@ export interface NewsletterViewModel { newsletterSignatur: string; isSent: boolean; recipientsByQueryId?: string; - recipientsByQuery?: QueryStoreViewModel; } From f32143b7acd5732dfc8f56dcb1734acba19faa47 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 19 Apr 2025 11:26:35 +0200 Subject: [PATCH 04/29] SettingsHelper --- src/data-source.ts | 14 +-- src/entity/management/settings.ts | 13 +++ src/env.defaults.ts | 24 ----- .../admin/club/member/dateMappingHelper.ts | 4 +- src/helpers/settingsHelper.ts | 57 ++++++++++++ src/index.ts | 13 ++- .../1738166124200-BackupAndResetDatabase.ts | 7 +- src/migrations/1738166167472-CreateSchema.ts | 3 +- src/migrations/ormHelper.ts | 6 +- src/service/club/member/memberService.ts | 6 +- src/type/settingTypes.ts | 89 +++++++++++++++++++ src/views/memberExecutivePositionView.ts | 3 +- src/views/memberQualificationsView.ts | 3 +- src/views/memberView.ts | 3 +- src/views/membershipsView.ts | 3 +- 15 files changed, 199 insertions(+), 49 deletions(-) create mode 100644 src/entity/management/settings.ts create mode 100644 src/helpers/settingsHelper.ts create mode 100644 src/type/settingTypes.ts diff --git a/src/data-source.ts b/src/data-source.ts index 37f957d..d7be30d 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import "reflect-metadata"; import { DataSource } from "typeorm"; -import { DB_HOST, DB_USERNAME, DB_PASSWORD, DB_NAME, DB_TYPE, DB_PORT } from "./env.defaults"; +import { SettingHelper } from "./helpers/settingsHelper"; import { user } from "./entity/management/user"; import { refresh } from "./entity/refresh"; @@ -53,12 +53,12 @@ import { NewsletterColumnType1744351418751 } from "./migrations/1744351418751-ne import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpdatedAt"; const dataSource = new DataSource({ - type: DB_TYPE as any, - host: DB_HOST, - port: DB_PORT, - username: DB_USERNAME, - password: DB_PASSWORD, - database: DB_NAME, + type: SettingHelper.getEnvSetting("database.type") as any, + host: SettingHelper.getEnvSetting("database.host"), + port: Number(SettingHelper.getEnvSetting("database.port")), + username: SettingHelper.getEnvSetting("database.username"), + password: SettingHelper.getEnvSetting("database.password"), + database: SettingHelper.getEnvSetting("database.name"), synchronize: false, logging: process.env.NODE_ENV ? true : ["schema", "error", "warn", "log", "migration"], bigNumberStrings: false, diff --git a/src/entity/management/settings.ts b/src/entity/management/settings.ts new file mode 100644 index 0000000..0057cbc --- /dev/null +++ b/src/entity/management/settings.ts @@ -0,0 +1,13 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity() +export class user { + @PrimaryColumn({ type: "varchar", length: 255 }) + topic: string; + + @PrimaryColumn({ type: "varchar", length: 255 }) + key: string; + + @Column({ type: "varchar", length: 255 }) + value: string; +} diff --git a/src/env.defaults.ts b/src/env.defaults.ts index 739fd87..97a3928 100644 --- a/src/env.defaults.ts +++ b/src/env.defaults.ts @@ -2,15 +2,6 @@ import "dotenv/config"; import ms from "ms"; import ip from "ip"; -export const DB_TYPE = process.env.DB_TYPE ?? "mysql"; -export const DB_HOST = process.env.DB_HOST ?? ""; -export const DB_PORT = Number(process.env.DB_PORT ?? 3306); -export const DB_NAME = process.env.DB_NAME ?? ""; -export const DB_USERNAME = process.env.DB_USERNAME ?? ""; -export const DB_PASSWORD = process.env.DB_PASSWORD ?? ""; - -export const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5000); - export const JWT_SECRET = process.env.JWT_SECRET ?? "my_jwt_secret_string_ilughfnadiuhgq§$IUZGFVRweiouarbt1oub3h5q4a"; export const JWT_EXPIRATION = (process.env.JWT_EXPIRATION ?? "15m") as ms.StringValue; export const REFRESH_EXPIRATION = (process.env.REFRESH_EXPIRATION ?? "1d") as ms.StringValue; @@ -27,7 +18,6 @@ export const CLUB_WEBSITE = process.env.CLUB_WEBSITE ?? ""; export const BACKUP_INTERVAL = Number(process.env.BACKUP_INTERVAL ?? "1"); export const BACKUP_COPIES = Number(process.env.BACKUP_COPIES ?? "7"); -export const BACKUP_AUTO_RESTORE = process.env.BACKUP_AUTO_RESTORE ?? "true"; export const USE_SECURITY_STRICT_LIMIT = process.env.USE_SECURITY_STRICT_LIMIT ?? "true"; export const SECURITY_STRICT_LIMIT_WINDOW = (process.env.SECURITY_STRICT_LIMIT_WINDOW ?? "15m") as ms.StringValue; @@ -55,18 +45,6 @@ export const TRUST_PROXY = ((): Array | string | boolean | number | null })(); export function configCheck() { - if (DB_TYPE != "mysql" && DB_TYPE != "sqlite" && DB_TYPE != "postgres") - throw new Error("set valid value to DB_TYPE (mysql|sqlite|postgres)"); - if ((DB_HOST == "" || typeof DB_HOST != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_HOST"); - if (DB_NAME == "" || typeof DB_NAME != "string") throw new Error("set valid value to DB_NAME"); - if ((DB_USERNAME == "" || typeof DB_USERNAME != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_USERNAME"); - if ((DB_PASSWORD == "" || typeof DB_PASSWORD != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_PASSWORD"); - - if (isNaN(SERVER_PORT)) throw new Error("set valid numeric value to SERVER_PORT"); - if (JWT_SECRET == "" || typeof JWT_SECRET != "string") throw new Error("set valid value to JWT_SECRET"); checkMS(JWT_EXPIRATION, "JWT_EXPIRATION"); checkMS(REFRESH_EXPIRATION, "REFRESH_EXPIRATION"); @@ -84,8 +62,6 @@ export function configCheck() { ) throw new Error("CLUB_WEBSITE is not valid url"); - if (BACKUP_AUTO_RESTORE != "true" && BACKUP_AUTO_RESTORE != "false") - throw new Error("set 'true' or 'false' to BACKUP_AUTO_RESTORE"); if (BACKUP_INTERVAL < 1) throw new Error("BACKUP_INTERVAL has to be at least 1"); if (BACKUP_COPIES < 1) throw new Error("BACKUP_COPIES has to be at least 1"); diff --git a/src/factory/admin/club/member/dateMappingHelper.ts b/src/factory/admin/club/member/dateMappingHelper.ts index 8fdd072..e4b6a2c 100644 --- a/src/factory/admin/club/member/dateMappingHelper.ts +++ b/src/factory/admin/club/member/dateMappingHelper.ts @@ -1,8 +1,8 @@ -import { DB_TYPE } from "../../../../env.defaults"; +import { SettingHelper } from "../../../../helpers/settingsHelper"; export default abstract class DateMappingHelper { static mapDate(entry: any) { - switch (DB_TYPE) { + switch (SettingHelper.getEnvSetting("database.type")) { case "postgres": return `${entry?.years ?? 0} years ${entry?.months ?? 0} months ${entry?.days ?? 0} days`; case "mysql": diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts new file mode 100644 index 0000000..d176cc2 --- /dev/null +++ b/src/helpers/settingsHelper.ts @@ -0,0 +1,57 @@ +import ms from "ms"; +import { EnvSettingString, envSettingsType, SettingString, settingsType } from "../type/settingTypes"; + +export abstract class SettingHelper { + private static settings: { [key in SettingString]?: string } = {}; + private static envSettings: { [key in EnvSettingString]?: string } = {}; + + public static getSetting(key: SettingString): string | number | boolean | ms.StringValue { + let settingType = settingsType[key]; + return this.settings[key] ?? settingType.default ?? ""; + } + + public static getEnvSetting(key: EnvSettingString): string { + let settingType = envSettingsType[key]; + return this.envSettings[key] ?? settingType.default ?? ""; + } + + public static async configure() {} + + public static async configurEnv() { + this.envSettings = { + "database.type": process.env.DB_TYPE, + "database.host": process.env.DB_HOST, + "database.port": process.env.DB_PORT, + "database.name": process.env.DB_NAME, + "database.username": process.env.DB_USERNAME, + "database.password": process.env.DB_PASSWORD, + }; + this.checkEnvSettings(); + } + + private static checkEnvSettings() { + if (!["mysql", "sqlite", "postgres"].includes(this.envSettings["database.type"])) + throw new Error("set valid value to DB_TYPE (mysql|sqlite|postgres)"); + if (this.checkIfEmptyOrNotString(this.envSettings["database.name"])) + throw new Error("set valid value to DB_NAME (name of database or filepath for sqlite)"); + if ( + this.checkIfEmptyOrNotString(this.envSettings["database.host"]) && + this.envSettings["database.type"] != "sqlite" + ) + throw new Error("set valid value to DB_HOST"); + if ( + this.checkIfEmptyOrNotString(this.envSettings["database.username"]) && + this.envSettings["database.type"] != "sqlite" + ) + throw new Error("set valid value to DB_USERNAME"); + if ( + this.checkIfEmptyOrNotString(this.envSettings["database.password"]) && + this.envSettings["database.type"] != "sqlite" + ) + throw new Error("set valid value to DB_PASSWORD"); + } + + private static checkIfEmptyOrNotString(val: any) { + return typeof val != "string" || val == ""; + } +} diff --git a/src/index.ts b/src/index.ts index a282863..973f7bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ import "dotenv/config"; import "./handlebars.config"; import express from "express"; +import { SettingHelper } from "./helpers/settingsHelper"; -import { BACKUP_AUTO_RESTORE, configCheck, SERVER_PORT } from "./env.defaults"; +SettingHelper.configurEnv(); +import { configCheck } from "./env.defaults"; configCheck(); import { PermissionObject } from "./type/permissionTypes"; @@ -22,18 +24,21 @@ declare global { import { dataSource } from "./data-source"; import BackupHelper from "./helpers/backupHelper"; dataSource.initialize().then(async () => { - if ((BACKUP_AUTO_RESTORE as "true" | "false") == "true" && (await dataSource.createQueryRunner().hasTable("user"))) { + if (await dataSource.createQueryRunner().hasTable("user")) { await BackupHelper.autoRestoreBackup().catch((err) => { console.log(`${new Date().toISOString()}: failed auto-restoring database`, err); }); } + SettingHelper.configure(); }); const app = express(); import router from "./routes/index"; router(app); -app.listen(process.env.NODE_ENV ? SERVER_PORT : 5000, () => { - console.log(`${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? SERVER_PORT : 5000}`); +app.listen(process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000, () => { + console.log( + `${new Date().toISOString()}: listening on port ${process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000}` + ); }); import schedule from "node-schedule"; diff --git a/src/migrations/1738166124200-BackupAndResetDatabase.ts b/src/migrations/1738166124200-BackupAndResetDatabase.ts index f0656ba..30595da 100644 --- a/src/migrations/1738166124200-BackupAndResetDatabase.ts +++ b/src/migrations/1738166124200-BackupAndResetDatabase.ts @@ -2,13 +2,16 @@ import { MigrationInterface, QueryRunner, Table } from "typeorm"; import BackupHelper from "../helpers/backupHelper"; import { getDefaultByORM, getTypeByORM, isIncrementPrimary } from "./ormHelper"; import InternalException from "../exceptions/internalException"; -import { DB_TYPE } from "../env.defaults"; +import { SettingHelper } from "../helpers/settingsHelper"; export class BackupAndResetDatabase1738166124200 implements MigrationInterface { name = "BackupAndResetDatabase1738166124200"; public async up(queryRunner: QueryRunner): Promise { - let query = DB_TYPE == "postgres" ? "SELECT name FROM migrations" : "SELECT `name` FROM `migrations`"; + let query = + SettingHelper.getEnvSetting("database.type") == "postgres" + ? "SELECT name FROM migrations" + : "SELECT `name` FROM `migrations`"; let migrations = await queryRunner.query(query); if ( (await queryRunner.hasTable("user")) && diff --git a/src/migrations/1738166167472-CreateSchema.ts b/src/migrations/1738166167472-CreateSchema.ts index df9c6fd..c9a4c8d 100644 --- a/src/migrations/1738166167472-CreateSchema.ts +++ b/src/migrations/1738166167472-CreateSchema.ts @@ -54,7 +54,7 @@ import { newsletter_recipients_table, newsletter_table, } from "./baseSchemaTables/newsletter"; -import { DB_TYPE } from "../env.defaults"; +import { SettingHelper } from "../helpers/settingsHelper"; export class CreateSchema1738166167472 implements MigrationInterface { name = "CreateSchema1738166167472"; @@ -84,6 +84,7 @@ export class CreateSchema1738166167472 implements MigrationInterface { await queryRunner.createTable(member_executive_positions_table, true, true, true); await queryRunner.createTable(member_qualifications_table, true, true, true); + const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") await queryRunner.createView(member_view_postgres, true); else if (DB_TYPE == "mysql") await queryRunner.createView(member_view_mysql, true); else if (DB_TYPE == "sqlite") await queryRunner.createView(member_view_sqlite, true); diff --git a/src/migrations/ormHelper.ts b/src/migrations/ormHelper.ts index 20a57ad..099e006 100644 --- a/src/migrations/ormHelper.ts +++ b/src/migrations/ormHelper.ts @@ -1,3 +1,5 @@ +import { SettingHelper } from "../helpers/settingsHelper"; + export type ORMType = "int" | "bigint" | "boolean" | "date" | "datetime" | "time" | "text" | "varchar" | "uuid"; export type ORMDefault = "currentTimestamp" | "string" | "boolean" | "number" | "null"; export type ColumnConfig = { @@ -13,7 +15,7 @@ export type Primary = { }; export function getTypeByORM(type: ORMType, nullable: boolean = false, length: number = 255): ColumnConfig { - const dbType = process.env.DB_TYPE; + const dbType = SettingHelper.getEnvSetting("database.type"); const typeMap: Record> = { mysql: { @@ -63,7 +65,7 @@ export function getTypeByORM(type: ORMType, nullable: boolean = false, length: n } export function getDefaultByORM(type: ORMDefault, data?: string | number | boolean): T { - const dbType = process.env.DB_TYPE; + const dbType = SettingHelper.getEnvSetting("database.type"); const typeMap: Record> = { mysql: { diff --git a/src/service/club/member/memberService.ts b/src/service/club/member/memberService.ts index 3eae4e6..eb791b3 100644 --- a/src/service/club/member/memberService.ts +++ b/src/service/club/member/memberService.ts @@ -5,7 +5,7 @@ import { membership } from "../../../entity/club/member/membership"; import DatabaseActionException from "../../../exceptions/databaseActionException"; import InternalException from "../../../exceptions/internalException"; import { memberView } from "../../../views/memberView"; -import { DB_TYPE } from "../../../env.defaults"; +import { SettingHelper } from "../../../helpers/settingsHelper"; export default abstract class MemberService { /** @@ -169,7 +169,7 @@ export default abstract class MemberService { "member.firstMembershipEntry", "member.memberships", "membership_first", - DB_TYPE == "postgres" + SettingHelper.getEnvSetting("database.type") == "postgres" ? 'membership_first.memberId = member.id AND membership_first.start = (SELECT MIN("m_first"."start") FROM "membership" "m_first" WHERE "m_first"."memberId" = "member"."id")' : "membership_first.memberId = member.id AND membership_first.start = (SELECT MIN(m_first.start) FROM membership m_first WHERE m_first.memberId = member.id)" ) @@ -177,7 +177,7 @@ export default abstract class MemberService { "member.lastMembershipEntry", "member.memberships", "membership_last", - DB_TYPE == "postgres" + SettingHelper.getEnvSetting("database.type") == "postgres" ? 'membership_last.memberId = member.id AND membership_last.start = (SELECT MAX("m_last"."start") FROM "membership" "m_last" WHERE "m_last"."memberId" = "member"."id")' : "membership_last.memberId = member.id AND membership_last.start = (SELECT MAX(m_last.start) FROM membership m_last WHERE m_last.memberId = member.id)" ) diff --git a/src/type/settingTypes.ts b/src/type/settingTypes.ts new file mode 100644 index 0000000..6c7935c --- /dev/null +++ b/src/type/settingTypes.ts @@ -0,0 +1,89 @@ +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" + | "club.privacy" + | "club.website" + | "app.custom_login_message" + | "app.show_link_to_calendar" + | "session.jwt_secret" + | "session.jwt_expiration" + | "session.refresh_expiration" + | "session.pwa_refresh_expiration" + | "mail.username" + | "mail.password" + | "mail.host" + | "mail.port" + | "mail.secure" + | "backup.interval" + | "backup.copies" + | "security.strict_limit" + | "security.strict_limit_window" + | "security.strict_limit_request_count" + | "security.limit" + | "security.limit_window" + | "security.limit_request_count" + | "security.trust_proxy"; + +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 | SettingType[]; + default?: string | number | boolean | ms.StringValue; + optional?: boolean; + }; +} = { + "club.name": { type: "string", default: "FF Admin" }, + "club.imprint": { type: "url", optional: true }, + "club.privacy": { type: "url", optional: true }, + "club.website": { type: "url", optional: true }, + "app.custom_login_message": { type: "string", optional: true }, + "app.show_link_to_calendar": { type: "boolean", default: true }, + "session.jwt_secret": { type: "longstring/rand", default: StringHelper.random(64) }, + "session.jwt_expiration": { type: "ms", default: "15m" }, + "session.refresh_expiration": { type: "ms", default: "1d" }, + "session.pwa_refresh_expiration": { type: "ms", default: "5d" }, + "mail.username": { type: "string", optional: false }, + "mail.password": { type: "string/crypt", optional: false }, + "mail.host": { type: "url", optional: false }, + "mail.port": { type: "number", default: 587 }, + "mail.secure": { type: "boolean", default: false }, + "backup.interval": { type: "number", default: 1 }, + "backup.copies": { type: "number", default: 7 }, + "security.strict_limit": { type: "boolean", default: true }, + "security.strict_limit_window": { type: "ms", default: "15m" }, + "security.strict_limit_request_count": { type: "number", default: 15 }, + "security.limit": { type: "boolean", default: true }, + "security.limit_window": { type: "ms", default: "1m" }, + "security.limit_request_count": { type: "number", default: 500 }, + "security.trust_proxy": { type: ["boolean", "number"], optional: true }, +}; + +/** ENV Settings */ +export type EnvSettingString = + | "database.type" + | "database.host" + | "database.port" + | "database.name" + | "database.username" + | "database.password"; + +export const envSettingsType: { + [key in EnvSettingString]: { + type: SettingType | SettingType[]; + default?: string; + }; +} = { + "database.type": { type: "string", default: "postgres" }, + "database.host": { type: "string" }, + "database.port": { type: "string", default: "5432" }, + "database.name": { type: "string" }, + "database.username": { type: "string" }, + "database.password": { type: "string" }, +}; diff --git a/src/views/memberExecutivePositionView.ts b/src/views/memberExecutivePositionView.ts index 8b8f328..fb85148 100644 --- a/src/views/memberExecutivePositionView.ts +++ b/src/views/memberExecutivePositionView.ts @@ -1,10 +1,11 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { memberExecutivePositions } from "../entity/club/member/memberExecutivePositions"; -import { DB_TYPE } from "../env.defaults"; +import { SettingHelper } from "../helpers/settingsHelper"; let durationInDays: string; let durationInYears: string; let exactDuration: string; +const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { durationInDays = `SUM(COALESCE("memberExecutivePositions"."end", CURRENT_DATE) - "memberExecutivePositions"."start")`; durationInYears = `SUM(EXTRACT(YEAR FROM AGE(COALESCE("memberExecutivePositions"."end", CURRENT_DATE), "memberExecutivePositions"."start")))`; diff --git a/src/views/memberQualificationsView.ts b/src/views/memberQualificationsView.ts index 6f88f39..0d7c05b 100644 --- a/src/views/memberQualificationsView.ts +++ b/src/views/memberQualificationsView.ts @@ -1,10 +1,11 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { memberQualifications } from "../entity/club/member/memberQualifications"; -import { DB_TYPE } from "../env.defaults"; +import { SettingHelper } from "../helpers/settingsHelper"; let durationInDays: string; let durationInYears: string; let exactDuration: string; +const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { durationInDays = `SUM(COALESCE("memberQualifications"."end", CURRENT_DATE) - "memberQualifications"."start") `; durationInYears = `SUM(EXTRACT(YEAR FROM AGE(COALESCE("memberQualifications"."end", CURRENT_DATE), "memberQualifications"."start")))`; diff --git a/src/views/memberView.ts b/src/views/memberView.ts index 194bade..748d07e 100644 --- a/src/views/memberView.ts +++ b/src/views/memberView.ts @@ -1,10 +1,11 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { member } from "../entity/club/member/member"; -import { DB_TYPE } from "../env.defaults"; +import { SettingHelper } from "../helpers/settingsHelper"; let todayAge: string; let ageThisYear: string; let exactAge: string; +const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { todayAge = `DATE_PART('year', AGE(CURRENT_DATE, member.birthdate))`; ageThisYear = `EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM member.birthdate)`; diff --git a/src/views/membershipsView.ts b/src/views/membershipsView.ts index fc38c23..4e21191 100644 --- a/src/views/membershipsView.ts +++ b/src/views/membershipsView.ts @@ -1,10 +1,11 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { membership } from "../entity/club/member/membership"; -import { DB_TYPE } from "../env.defaults"; +import { SettingHelper } from "../helpers/settingsHelper"; let durationInDays: string; let durationInYears: string; let exactDuration: string; +const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { durationInDays = `SUM(COALESCE("membership"."end", CURRENT_DATE) - "membership"."start") `; durationInYears = `SUM(EXTRACT(YEAR FROM AGE(COALESCE("membership"."end", CURRENT_DATE), "membership"."start")))`; From 730c25a9a1ec1b016f7f327edd51eec0d71045a9 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 19 Apr 2025 16:51:37 +0200 Subject: [PATCH 05/29] split in env required and dynamic values --- .env.example | 19 +-- package-lock.json | 8 + package.json | 2 + src/command/refreshCommandHandler.ts | 8 +- src/command/settingCommand.ts | 10 ++ src/command/settingCommandHandler.ts | 53 +++++++ .../admin/management/userController.ts | 4 +- .../admin/management/webapiController.ts | 4 +- src/controller/inviteController.ts | 11 +- src/controller/resetController.ts | 12 +- src/controller/userController.ts | 4 +- src/data-source.ts | 18 ++- .../{management/settings.ts => setting.ts} | 4 +- src/env.defaults.ts | 50 +++--- .../admin/club/member/dateMappingHelper.ts | 4 +- src/helpers/backupHelper.ts | 6 +- src/helpers/calendarHelper.ts | 11 +- src/helpers/codingHelper.ts | 86 ++++++++++ src/helpers/jwtHelper.ts | 14 +- src/helpers/mailHelper.ts | 26 +-- src/helpers/newsletterHelper.ts | 17 +- src/helpers/settingsHelper.ts | 148 +++++++++++++----- src/index.ts | 7 +- .../1738166124200-BackupAndResetDatabase.ts | 7 +- src/migrations/1738166167472-CreateSchema.ts | 3 +- .../1745059495808-settingsFromEnv.ts | 30 ++++ src/migrations/baseSchemaTables/admin.ts | 9 ++ src/migrations/ormHelper.ts | 6 +- src/service/club/member/memberService.ts | 8 +- src/service/settingService.ts | 43 +++++ src/type/settingTypes.ts | 45 +++--- src/views/memberExecutivePositionView.ts | 3 +- src/views/memberQualificationsView.ts | 3 +- src/views/memberView.ts | 3 +- src/views/membershipsView.ts | 3 +- 35 files changed, 491 insertions(+), 198 deletions(-) create mode 100644 src/command/settingCommand.ts create mode 100644 src/command/settingCommandHandler.ts rename src/entity/{management/settings.ts => setting.ts} (77%) create mode 100644 src/helpers/codingHelper.ts create mode 100644 src/migrations/1745059495808-settingsFromEnv.ts create mode 100644 src/service/settingService.ts diff --git a/.env.example b/.env.example index 0d9bf9d..c1fe1dc 100644 --- a/.env.example +++ b/.env.example @@ -17,25 +17,10 @@ DB_PASSWORD = database_password ## BSP für sqlite DB_HOST = filename.db +## Dev only SERVER_PORT = portnumber -JWT_SECRET = ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 # besitzt default -JWT_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 15m -REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 1d -PWA_REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s) # default ist 5d - -MAIL_USERNAME = mail_username -MAIL_PASSWORD = mail_password -MAIL_HOST = mail_hoststring -MAIL_PORT = mail_portnumber # default ist 587 -MAIL_SECURE = (true|false) # true für port 465, false für anders gewählten port - -CLUB_NAME = clubname #default FF Admin -CLUB_WEBSITE = https://my-club-website-url #optional, muss aber mit http:// oder https:// beginnen - -BACKUP_INTERVAL = number of days (min 1) # default 1 -BACKUP_COPIES = number of parallel copies # default 7 -BACKUP_AUTO_RESTORE = (true|false) # default ist true +APPLICATION_SECRET = mysecret USE_SECURITY_STRICT_LIMIT = (true|false) # default ist true SECURITY_STRICT_LIMIT_WINDOW = [0-9]*(y|d|h|m|s) # default ist 15m diff --git a/package-lock.json b/package-lock.json index 901f487..a7c3021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "cors": "^2.8.5", + "crypto": "^1.0.1", "dotenv": "^16.4.5", "express": "^5.1.0", "express-rate-limit": "^7.5.0", @@ -1522,6 +1523,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", diff --git a/package.json b/package.json index 357a7de..9f7d24c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start_ts": "ts-node src/index.ts", "typeorm": "typeorm-ts-node-commonjs", "migrate": "set DBMODE=migration && npx typeorm-ts-node-commonjs migration:generate ./src/migrations/%npm_config_name% -d ./src/data-source.ts", + "migrate-empty": "set DBMODE=migration && npx typeorm-ts-node-commonjs migration:create ./src/migrations/%npm_config_name%", "synchronize-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs schema:sync -d ./src/data-source.ts", "update-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts", "revert-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts", @@ -25,6 +26,7 @@ "license": "AGPL-3.0-only", "dependencies": { "cors": "^2.8.5", + "crypto": "^1.0.1", "dotenv": "^16.4.5", "express": "^5.1.0", "express-rate-limit": "^7.5.0", diff --git a/src/command/refreshCommandHandler.ts b/src/command/refreshCommandHandler.ts index df6a8ea..959996a 100644 --- a/src/command/refreshCommandHandler.ts +++ b/src/command/refreshCommandHandler.ts @@ -1,10 +1,8 @@ import { dataSource } from "../data-source"; import { refresh } from "../entity/refresh"; -import { PWA_REFRESH_EXPIRATION, REFRESH_EXPIRATION } from "../env.defaults"; import DatabaseActionException from "../exceptions/databaseActionException"; -import InternalException from "../exceptions/internalException"; +import SettingHelper from "../helpers/settingsHelper"; import { StringHelper } from "../helpers/stringHelper"; -import UserService from "../service/management/userService"; import { CreateRefreshCommand, DeleteRefreshCommand } from "./refreshCommand"; import ms from "ms"; @@ -25,8 +23,8 @@ export default abstract class RefreshCommandHandler { token: refreshToken, userId: createRefresh.userId, expiry: createRefresh.isFromPwa - ? new Date(Date.now() + ms(PWA_REFRESH_EXPIRATION)) - : new Date(Date.now() + ms(REFRESH_EXPIRATION)), + ? 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)), }) .execute() .then((result) => { diff --git a/src/command/settingCommand.ts b/src/command/settingCommand.ts new file mode 100644 index 0000000..e0c9d84 --- /dev/null +++ b/src/command/settingCommand.ts @@ -0,0 +1,10 @@ +export interface CreateOrUpdateSettingCommand { + topic: string; + key: string; + value: string; +} + +export interface DeleteSettingCommand { + topic: string; + key: string; +} diff --git a/src/command/settingCommandHandler.ts b/src/command/settingCommandHandler.ts new file mode 100644 index 0000000..148bdef --- /dev/null +++ b/src/command/settingCommandHandler.ts @@ -0,0 +1,53 @@ +import { dataSource } from "../data-source"; +import { setting } from "../entity/setting"; +import DatabaseActionException from "../exceptions/databaseActionException"; +import { StringHelper } from "../helpers/stringHelper"; +import { CreateOrUpdateSettingCommand, DeleteSettingCommand } from "./settingCommand"; + +export default abstract class SettingCommandHandler { + /** + * @description create setting + * @param {CreateOrUpdateSettingCommand} createSetting + * @returns {Promise} + */ + static async create(createSetting: CreateOrUpdateSettingCommand): Promise { + const token = StringHelper.random(32); + + return await dataSource + .createQueryBuilder() + .insert() + .into(setting) + .values({ + topic: createSetting.topic, + key: createSetting.key, + value: createSetting.value, + }) + .orUpdate(["value"], ["topic", "key"]) + .execute() + .then((result) => { + return token; + }) + .catch((err) => { + throw new DatabaseActionException("CREATE OR UPDATE", "setting", err); + }); + } + + /** + * @description delete setting by topic and key + * @param {DeleteRefreshCommand} deleteSetting + * @returns {Promise} + */ + static async delete(deleteSetting: DeleteSettingCommand): Promise { + return await dataSource + .createQueryBuilder() + .delete() + .from(setting) + .where("setting.topic = :topic", { topic: deleteSetting.topic }) + .andWhere("setting.key = :key", { key: deleteSetting.key }) + .execute() + .then((res) => {}) + .catch((err) => { + throw new DatabaseActionException("DELETE", "setting", err); + }); + } +} diff --git a/src/controller/admin/management/userController.ts b/src/controller/admin/management/userController.ts index ba2a134..7da2460 100644 --- a/src/controller/admin/management/userController.ts +++ b/src/controller/admin/management/userController.ts @@ -11,10 +11,10 @@ import { } from "../../../command/management/user/userCommand"; import UserCommandHandler from "../../../command/management/user/userCommandHandler"; import MailHelper from "../../../helpers/mailHelper"; -import { CLUB_NAME } from "../../../env.defaults"; import { UpdateUserPermissionsCommand } from "../../../command/management/user/userPermissionCommand"; import UserPermissionCommandHandler from "../../../command/management/user/userPermissionCommandHandler"; import BadRequestException from "../../../exceptions/badRequestException"; +import SettingHelper from "../../../helpers/settingsHelper"; /** * @description get All users @@ -157,7 +157,7 @@ export async function deleteUser(req: Request, res: Response): Promise { // sendmail await MailHelper.sendMail( mail, - `Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`, + `Email Bestätigung für Mitglieder Admin-Portal von ${SettingHelper.getSetting("club.name")}`, `Ihr Nutzerkonto des Adminportals wurde erfolgreich gelöscht.` ); } catch (error) {} diff --git a/src/controller/admin/management/webapiController.ts b/src/controller/admin/management/webapiController.ts index 80df60a..38d40cd 100644 --- a/src/controller/admin/management/webapiController.ts +++ b/src/controller/admin/management/webapiController.ts @@ -12,8 +12,8 @@ import WebapiCommandHandler from "../../../command/management/webapi/webapiComma import { UpdateWebapiPermissionsCommand } from "../../../command/management/webapi/webapiPermissionCommand"; import WebapiPermissionCommandHandler from "../../../command/management/webapi/webapiPermissionCommandHandler"; import { JWTHelper } from "../../../helpers/jwtHelper"; -import { CLUB_NAME } from "../../../env.defaults"; import { StringHelper } from "../../../helpers/stringHelper"; +import SettingHelper from "../../../helpers/settingsHelper"; /** * @description get All apis @@ -78,7 +78,7 @@ export async function createWebapi(req: Request, res: Response): Promise { let token = await JWTHelper.create( { - iss: CLUB_NAME, + iss: SettingHelper.getSetting("club.name") as string, sub: "api_token_retrieve", aud: StringHelper.random(32), }, diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index 14e346c..769260f 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -1,6 +1,5 @@ import { Request, Response } from "express"; import { JWTHelper } from "../helpers/jwtHelper"; -import { JWTToken } from "../type/jwtTypes"; import InternalException from "../exceptions/internalException"; import RefreshCommandHandler from "../command/refreshCommandHandler"; import { CreateRefreshCommand } from "../command/refreshCommand"; @@ -15,10 +14,8 @@ import MailHelper from "../helpers/mailHelper"; import InviteService from "../service/management/inviteService"; import UserService from "../service/management/userService"; import CustomRequestException from "../exceptions/customRequestException"; -import { CLUB_NAME } from "../env.defaults"; -import { CreateUserPermissionCommand } from "../command/management/user/userPermissionCommand"; -import UserPermissionCommandHandler from "../command/management/user/userPermissionCommandHandler"; import InviteFactory from "../factory/admin/management/invite"; +import SettingHelper from "../helpers/settingsHelper"; /** * @description get all invites @@ -59,7 +56,7 @@ export async function inviteUser(req: Request, res: Response, isInvite: boolean throw new CustomRequestException(409, "Username and Mail are already in use"); } - var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${CLUB_NAME}` }); + var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${SettingHelper.getSetting("club.name")}` }); let createInvite: CreateInviteCommand = { username: username, @@ -73,7 +70,7 @@ export async function inviteUser(req: Request, res: Response, isInvite: boolean // sendmail await MailHelper.sendMail( mail, - `Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`, + `Email Bestätigung für Mitglieder Admin-Portal von ${SettingHelper.getSetting("club.name")}`, `Öffne folgenden Link: ${origin}/${isInvite ? "invite" : "setup"}/verify?mail=${mail}&token=${token}` ); @@ -92,7 +89,7 @@ export async function verifyInvite(req: Request, res: Response): Promise { let { secret, username } = await InviteService.getByMailAndToken(mail, token); - const url = `otpauth://totp/FF Admin ${CLUB_NAME}?secret=${secret}`; + const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) .then((result) => { diff --git a/src/controller/resetController.ts b/src/controller/resetController.ts index fc0dc3e..3592639 100644 --- a/src/controller/resetController.ts +++ b/src/controller/resetController.ts @@ -1,6 +1,5 @@ import { Request, Response } from "express"; import { JWTHelper } from "../helpers/jwtHelper"; -import { JWTToken } from "../type/jwtTypes"; import InternalException from "../exceptions/internalException"; import RefreshCommandHandler from "../command/refreshCommandHandler"; import { CreateRefreshCommand } from "../command/refreshCommand"; @@ -12,12 +11,9 @@ import ResetCommandHandler from "../command/resetCommandHandler"; import MailHelper from "../helpers/mailHelper"; import ResetService from "../service/resetService"; import UserService from "../service/management/userService"; -import { CLUB_NAME } from "../env.defaults"; -import PermissionHelper from "../helpers/permissionHelper"; -import RolePermissionService from "../service/management/rolePermissionService"; -import UserPermissionService from "../service/management/userPermissionService"; import { UpdateUserSecretCommand } from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; +import SettingHelper from "../helpers/settingsHelper"; /** * @description request totp reset @@ -31,7 +27,7 @@ export async function startReset(req: Request, res: Response): Promise { let { mail } = await UserService.getByUsername(username); - var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${CLUB_NAME}` }); + var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${SettingHelper.getSetting("club.name")}` }); let createReset: CreateResetCommand = { username: username, @@ -43,7 +39,7 @@ export async function startReset(req: Request, res: Response): Promise { // sendmail await MailHelper.sendMail( mail, - `Email Bestätigung für Mitglieder Admin-Portal von ${CLUB_NAME}`, + `Email Bestätigung für Mitglieder Admin-Portal von ${SettingHelper.getSetting("club.name")}`, `Öffne folgenden Link: ${origin}/reset/reset?mail=${mail}&token=${token}` ); @@ -62,7 +58,7 @@ export async function verifyReset(req: Request, res: Response): Promise { let { secret } = await ResetService.getByMailAndToken(mail, token); - const url = `otpauth://totp/FF Admin ${CLUB_NAME}?secret=${secret}`; + const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) .then((result) => { diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 1e764f1..8827dc9 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -2,12 +2,12 @@ import { Request, Response } from "express"; import speakeasy from "speakeasy"; import QRCode from "qrcode"; import InternalException from "../exceptions/internalException"; -import { CLUB_NAME } from "../env.defaults"; import UserService from "../service/management/userService"; import UserFactory from "../factory/admin/management/user"; import { TransferUserOwnerCommand, UpdateUserCommand } from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; +import SettingHelper from "../helpers/settingsHelper"; /** * @description get my by id @@ -33,7 +33,7 @@ export async function getMyTotp(req: Request, res: Response): Promise { let { secret } = await UserService.getById(userId); - const url = `otpauth://totp/FF Admin ${CLUB_NAME}?secret=${secret}`; + const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) .then((result) => { diff --git a/src/data-source.ts b/src/data-source.ts index d7be30d..d94d77f 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,7 +1,6 @@ import "dotenv/config"; import "reflect-metadata"; import { DataSource } from "typeorm"; -import { SettingHelper } from "./helpers/settingsHelper"; import { user } from "./entity/management/user"; import { refresh } from "./entity/refresh"; @@ -51,14 +50,17 @@ import { TemplatesAndProtocolSort1742549956787 } from "./migrations/174254995678 import { QueryToUUID1742922178643 } from "./migrations/1742922178643-queryToUUID"; import { NewsletterColumnType1744351418751 } from "./migrations/1744351418751-newsletterColumnType"; import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpdatedAt"; +import { setting } from "./entity/setting"; +import { SettingsFromEnv1745059495808 } from "./migrations/1745059495808-settingsFromEnv"; +import { DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_TYPE, DB_USERNAME } from "./env.defaults"; const dataSource = new DataSource({ - type: SettingHelper.getEnvSetting("database.type") as any, - host: SettingHelper.getEnvSetting("database.host"), - port: Number(SettingHelper.getEnvSetting("database.port")), - username: SettingHelper.getEnvSetting("database.username"), - password: SettingHelper.getEnvSetting("database.password"), - database: SettingHelper.getEnvSetting("database.name"), + type: DB_TYPE as any, + host: DB_HOST, + port: DB_PORT, + username: DB_USERNAME, + password: DB_PASSWORD, + database: DB_NAME, synchronize: false, logging: process.env.NODE_ENV ? true : ["schema", "error", "warn", "log", "migration"], bigNumberStrings: false, @@ -103,6 +105,7 @@ const dataSource = new DataSource({ membershipView, webapi, webapiPermission, + setting, ], migrations: [ BackupAndResetDatabase1738166124200, @@ -111,6 +114,7 @@ const dataSource = new DataSource({ QueryToUUID1742922178643, NewsletterColumnType1744351418751, QueryUpdatedAt1744795756230, + SettingsFromEnv1745059495808, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/management/settings.ts b/src/entity/setting.ts similarity index 77% rename from src/entity/management/settings.ts rename to src/entity/setting.ts index 0057cbc..99d3cda 100644 --- a/src/entity/management/settings.ts +++ b/src/entity/setting.ts @@ -1,13 +1,13 @@ import { Column, Entity, PrimaryColumn } from "typeorm"; @Entity() -export class user { +export class setting { @PrimaryColumn({ type: "varchar", length: 255 }) topic: string; @PrimaryColumn({ type: "varchar", length: 255 }) key: string; - @Column({ type: "varchar", length: 255 }) + @Column({ type: "text" }) value: string; } diff --git a/src/env.defaults.ts b/src/env.defaults.ts index 97a3928..f7bc48d 100644 --- a/src/env.defaults.ts +++ b/src/env.defaults.ts @@ -2,22 +2,16 @@ import "dotenv/config"; import ms from "ms"; import ip from "ip"; -export const JWT_SECRET = process.env.JWT_SECRET ?? "my_jwt_secret_string_ilughfnadiuhgq§$IUZGFVRweiouarbt1oub3h5q4a"; -export const JWT_EXPIRATION = (process.env.JWT_EXPIRATION ?? "15m") as ms.StringValue; -export const REFRESH_EXPIRATION = (process.env.REFRESH_EXPIRATION ?? "1d") as ms.StringValue; -export const PWA_REFRESH_EXPIRATION = (process.env.PWA_REFRESH_EXPIRATION ?? "5d") as ms.StringValue; +export const DB_TYPE = process.env.DB_TYPE ?? "mysql"; +export const DB_HOST = process.env.DB_HOST ?? ""; +export const DB_PORT = Number(process.env.DB_PORT ?? 3306); +export const DB_NAME = process.env.DB_NAME ?? ""; +export const DB_USERNAME = process.env.DB_USERNAME ?? ""; +export const DB_PASSWORD = process.env.DB_PASSWORD ?? ""; -export const MAIL_USERNAME = process.env.MAIL_USERNAME ?? ""; -export const MAIL_PASSWORD = process.env.MAIL_PASSWORD ?? ""; -export const MAIL_HOST = process.env.MAIL_HOST ?? ""; -export const MAIL_PORT = Number(process.env.MAIL_PORT ?? "587"); -export const MAIL_SECURE = process.env.MAIL_SECURE ?? "false"; +export const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5000); -export const CLUB_NAME = process.env.CLUB_NAME ?? "FF Admin"; -export const CLUB_WEBSITE = process.env.CLUB_WEBSITE ?? ""; - -export const BACKUP_INTERVAL = Number(process.env.BACKUP_INTERVAL ?? "1"); -export const BACKUP_COPIES = Number(process.env.BACKUP_COPIES ?? "7"); +export const APPLICATION_SECRET = process.env.APPLICATION_SECRET; export const USE_SECURITY_STRICT_LIMIT = process.env.USE_SECURITY_STRICT_LIMIT ?? "true"; export const SECURITY_STRICT_LIMIT_WINDOW = (process.env.SECURITY_STRICT_LIMIT_WINDOW ?? "15m") as ms.StringValue; @@ -45,25 +39,17 @@ export const TRUST_PROXY = ((): Array | string | boolean | number | null })(); export function configCheck() { - if (JWT_SECRET == "" || typeof JWT_SECRET != "string") throw new Error("set valid value to JWT_SECRET"); - checkMS(JWT_EXPIRATION, "JWT_EXPIRATION"); - checkMS(REFRESH_EXPIRATION, "REFRESH_EXPIRATION"); - checkMS(PWA_REFRESH_EXPIRATION, "PWA_REFRESH_EXPIRATION"); + if (DB_TYPE != "mysql" && DB_TYPE != "sqlite" && DB_TYPE != "postgres") + throw new Error("set valid value to DB_TYPE (mysql|sqlite|postgres)"); + if ((DB_HOST == "" || typeof DB_HOST != "string") && DB_TYPE != "sqlite") + throw new Error("set valid value to DB_HOST"); + if (DB_NAME == "" || typeof DB_NAME != "string") throw new Error("set valid value to DB_NAME"); + if ((DB_USERNAME == "" || typeof DB_USERNAME != "string") && DB_TYPE != "sqlite") + throw new Error("set valid value to DB_USERNAME"); + if ((DB_PASSWORD == "" || typeof DB_PASSWORD != "string") && DB_TYPE != "sqlite") + throw new Error("set valid value to DB_PASSWORD"); - if (MAIL_USERNAME == "" || typeof MAIL_USERNAME != "string") throw new Error("set valid value to MAIL_USERNAME"); - if (MAIL_PASSWORD == "" || typeof MAIL_PASSWORD != "string") throw new Error("set valid value to MAIL_PASSWORD"); - if (MAIL_HOST == "" || typeof MAIL_HOST != "string") throw new Error("set valid value to MAIL_HOST"); - if (isNaN(MAIL_PORT)) throw new Error("set valid numeric value to MAIL_PORT"); - if (MAIL_SECURE != "true" && MAIL_SECURE != "false") throw new Error("set 'true' or 'false' to MAIL_SECURE"); - - if ( - CLUB_WEBSITE != "" && - !/^(http(s):\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/.test(CLUB_WEBSITE) - ) - throw new Error("CLUB_WEBSITE is not valid url"); - - if (BACKUP_INTERVAL < 1) throw new Error("BACKUP_INTERVAL has to be at least 1"); - if (BACKUP_COPIES < 1) throw new Error("BACKUP_COPIES has to be at least 1"); + if (isNaN(SERVER_PORT)) throw new Error("set valid numeric value to SERVER_PORT"); if (USE_SECURITY_STRICT_LIMIT != "true" && USE_SECURITY_STRICT_LIMIT != "false") throw new Error("set 'true' or 'false' to USE_SECURITY_STRICT_LIMIT"); diff --git a/src/factory/admin/club/member/dateMappingHelper.ts b/src/factory/admin/club/member/dateMappingHelper.ts index e4b6a2c..8fdd072 100644 --- a/src/factory/admin/club/member/dateMappingHelper.ts +++ b/src/factory/admin/club/member/dateMappingHelper.ts @@ -1,8 +1,8 @@ -import { SettingHelper } from "../../../../helpers/settingsHelper"; +import { DB_TYPE } from "../../../../env.defaults"; export default abstract class DateMappingHelper { static mapDate(entry: any) { - switch (SettingHelper.getEnvSetting("database.type")) { + switch (DB_TYPE) { case "postgres": return `${entry?.years ?? 0} years ${entry?.months ?? 0} months ${entry?.days ?? 0} days`; case "mysql": diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index 319a298..76f7693 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -4,9 +4,9 @@ import { EntityManager } from "typeorm"; import uniqBy from "lodash.uniqby"; import InternalException from "../exceptions/internalException"; import UserService from "../service/management/userService"; -import { BACKUP_COPIES, BACKUP_INTERVAL } from "../env.defaults"; import DatabaseActionException from "../exceptions/databaseActionException"; import { availableTemplates } from "../type/templateTypes"; +import SettingHelper from "./settingsHelper"; export type BackupSection = | "member" @@ -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(BACKUP_COPIES); + const filesToDelete = sorted.slice(SettingHelper.getSetting("backup.copies") as number); 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 >= BACKUP_INTERVAL) { + if (diffInDays >= (SettingHelper.getSetting("backup.interval") as number)) { await this.createBackup({}); } } diff --git a/src/helpers/calendarHelper.ts b/src/helpers/calendarHelper.ts index 7418089..fb60496 100644 --- a/src/helpers/calendarHelper.ts +++ b/src/helpers/calendarHelper.ts @@ -1,7 +1,7 @@ import { createEvents } from "ics"; import { calendar } from "../entity/club/calendar"; import moment from "moment"; -import { CLUB_NAME, CLUB_WEBSITE, MAIL_USERNAME } from "../env.defaults"; +import SettingHelper from "./settingsHelper"; export abstract class CalendarHelper { public static buildICS(entries: Array): { error?: Error; value?: string } { @@ -35,7 +35,10 @@ export abstract class CalendarHelper { description: i.content, location: i.location, categories: [i.type.type], - organizer: { name: CLUB_NAME, email: MAIL_USERNAME }, + organizer: { + name: SettingHelper.getSetting("club.name") as string, + email: SettingHelper.getSetting("mail.username") as string, + }, created: moment(i.createdAt) .format("YYYY-M-D-H-m") .split("-") @@ -46,7 +49,9 @@ export abstract class CalendarHelper { .map((a) => parseInt(a)) as [number, number, number, number, number], transp: "OPAQUE" as "OPAQUE", status: "CONFIRMED", - ...(CLUB_WEBSITE != "" ? { url: CLUB_WEBSITE } : {}), + ...(SettingHelper.getSetting("club.website") != "" + ? { url: SettingHelper.getSetting("club.website") as string } + : {}), alarms: [ { action: "display", diff --git a/src/helpers/codingHelper.ts b/src/helpers/codingHelper.ts new file mode 100644 index 0000000..e6a79f9 --- /dev/null +++ b/src/helpers/codingHelper.ts @@ -0,0 +1,86 @@ +import { createCipheriv, createDecipheriv, scryptSync, randomBytes } from "crypto"; +import { ValueTransformer } from "typeorm"; + +export abstract class CodingHelper { + private static readonly algorithm = "aes-256-gcm"; + private static readonly ivLength = 16; + private static readonly authTagLength = 16; + + static entityBaseCoding(key: string = "", fallback: string = ""): ValueTransformer { + return { + from(val: string | null | undefined): string { + if (!val) return fallback; + try { + return CodingHelper.decrypt(key, val) || fallback; + } catch (error) { + console.error("Decryption error:", error); + return fallback; + } + }, + to(val: string | null | undefined): string { + const valueToEncrypt = val || fallback; + if (valueToEncrypt === "") return ""; + + try { + return CodingHelper.encrypt(key, valueToEncrypt); + } catch (error) { + console.error("Encryption error:", error); + return ""; + } + }, + }; + } + + public static encrypt(phrase: string, content: string): string { + if (!content) return ""; + + // Generiere zufälligen IV für jede Verschlüsselung (sicherer als statischer IV) + const iv = randomBytes(this.ivLength); + const key = scryptSync(phrase, "salt", 32); + + const cipher = createCipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); + + // Verschlüssele den Inhalt + let encrypted = cipher.update(content, "utf8", "hex"); + encrypted += cipher.final("hex"); + + // Speichere das Auth-Tag für GCM (wichtig für die Entschlüsselung) + const authTag = cipher.getAuthTag(); + + // Gib das Format: iv:verschlüsselter_text:authTag zurück + return Buffer.concat([ + Uint8Array.from(iv), + Uint8Array.from(Buffer.from(encrypted, "hex")), + Uint8Array.from(authTag), + ]).toString("base64"); + } + + public static decrypt(phrase: string, content: string): string { + if (!content) return ""; + + try { + // Dekodiere den Base64-String + const buffer = Buffer.from(content, "base64"); + + // Extrahiere IV, verschlüsselten Text und Auth-Tag + const iv = buffer.subarray(0, this.ivLength); + const authTag = buffer.subarray(buffer.length - this.authTagLength); + const encryptedText = buffer.subarray(this.ivLength, buffer.length - this.authTagLength).toString("hex"); + + const key = scryptSync(phrase, "salt", 32); + + // Erstelle Decipher und setze Auth-Tag + const decipher = createDecipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); + decipher.setAuthTag(Uint8Array.from(authTag)); + + // Entschlüssele den Text + let decrypted = decipher.update(encryptedText, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } catch (error) { + console.error("Decryption failed:", error); + return ""; + } + } +} diff --git a/src/helpers/jwtHelper.ts b/src/helpers/jwtHelper.ts index 5708ab8..066bb8b 100644 --- a/src/helpers/jwtHelper.ts +++ b/src/helpers/jwtHelper.ts @@ -1,6 +1,5 @@ import jwt from "jsonwebtoken"; import { JWTData, JWTToken } from "../type/jwtTypes"; -import { JWT_SECRET, JWT_EXPIRATION } from "../env.defaults"; import InternalException from "../exceptions/internalException"; import RolePermissionService from "../service/management/rolePermissionService"; import UserPermissionService from "../service/management/userPermissionService"; @@ -9,11 +8,13 @@ import PermissionHelper from "./permissionHelper"; import WebapiService from "../service/management/webapiService"; import WebapiPermissionService from "../service/management/webapiPermissionService"; import ms from "ms"; +import SettingHelper from "./settingsHelper"; +import { APPLICATION_SECRET } from "../env.defaults"; export abstract class JWTHelper { static validate(token: string): Promise { return new Promise((resolve, reject) => { - jwt.verify(token, JWT_SECRET, (err, decoded) => { + jwt.verify(token, APPLICATION_SECRET, (err, decoded) => { if (err) reject(err.message); else resolve(decoded); }); @@ -27,9 +28,11 @@ export abstract class JWTHelper { return new Promise((resolve, reject) => { jwt.sign( data, - JWT_SECRET, + APPLICATION_SECRET, { - ...(useExpiration ?? true ? { expiresIn: expOverwrite ?? JWT_EXPIRATION } : {}), + ...(useExpiration ?? true + ? { expiresIn: expOverwrite ?? (SettingHelper.getSetting("session.jwt_expiration") as ms.StringValue) } + : {}), }, (err, token) => { if (err) reject(err.message); @@ -100,7 +103,8 @@ export abstract class JWTHelper { }; let overwriteExpiration = - ms(JWT_EXPIRATION) < new Date().getTime() - new Date(expiration).getTime() + ms(SettingHelper.getSetting("session.jwt_expiration") as ms.StringValue) < + new Date().getTime() - new Date(expiration).getTime() ? null : Date.now() - new Date(expiration).getTime(); diff --git a/src/helpers/mailHelper.ts b/src/helpers/mailHelper.ts index ab44a26..1bf8634 100644 --- a/src/helpers/mailHelper.ts +++ b/src/helpers/mailHelper.ts @@ -1,17 +1,21 @@ import { Transporter, createTransport, TransportOptions } from "nodemailer"; -import { CLUB_NAME, MAIL_HOST, MAIL_PASSWORD, MAIL_PORT, MAIL_SECURE, MAIL_USERNAME } from "../env.defaults"; import { Attachment } from "nodemailer/lib/mailer"; +import SettingHelper from "./settingsHelper"; export default abstract class MailHelper { - private static readonly transporter: Transporter = createTransport({ - host: MAIL_HOST, - port: MAIL_PORT, - secure: (MAIL_SECURE as "true" | "false") == "true", - auth: { - user: MAIL_USERNAME, - pass: MAIL_PASSWORD, - }, - } as TransportOptions); + private static transporter: Transporter; + + static createTransport() { + this.transporter = createTransport({ + host: SettingHelper.getSetting("mail.host"), + port: SettingHelper.getSetting("mail.port"), + secure: SettingHelper.getSetting("mail.secure") as boolean, + auth: { + user: SettingHelper.getSetting("mail.username"), + pass: SettingHelper.getSetting("mail.password"), + }, + } as TransportOptions); + } /** * @description send mail @@ -29,7 +33,7 @@ export default abstract class MailHelper { return new Promise((resolve, reject) => { this.transporter .sendMail({ - from: `"${CLUB_NAME}" <${MAIL_USERNAME}>`, + from: `"${SettingHelper.getSetting("club.name")}" <${SettingHelper.getSetting("mail.username")}>`, to: target, subject, text: content, diff --git a/src/helpers/newsletterHelper.ts b/src/helpers/newsletterHelper.ts index 32cdd16..c09da94 100644 --- a/src/helpers/newsletterHelper.ts +++ b/src/helpers/newsletterHelper.ts @@ -10,13 +10,13 @@ import { CalendarHelper } from "./calendarHelper"; import DynamicQueryBuilder from "./dynamicQueryBuilder"; import { FileSystemHelper } from "./fileSystemHelper"; import MailHelper from "./mailHelper"; -import { CLUB_NAME } from "../env.defaults"; import { TemplateHelper } from "./templateHelper"; import { PdfExport } from "./pdfExport"; import NewsletterConfigService from "../service/configuration/newsletterConfigService"; import { NewsletterConfigEnum } from "../enums/newsletterConfigEnum"; import InternalException from "../exceptions/internalException"; import EventEmitter from "events"; +import SettingHelper from "./settingsHelper"; export interface NewsletterEventType { kind: "pdf" | "mail"; @@ -179,7 +179,7 @@ export abstract class NewsletterHelper { pdfRecipients.unshift({ id: "0", firstname: "Alle Mitglieder", - lastname: CLUB_NAME, + lastname: SettingHelper.getSetting("club.name"), nameaffix: "", salutation: { salutation: "" }, } as member); @@ -221,11 +221,14 @@ export abstract class NewsletterHelper { const { body } = await TemplateHelper.renderFileForModule({ module: "newsletter", bodyData: data, - title: `Newsletter von ${CLUB_NAME}`, + title: `Newsletter von ${SettingHelper.getSetting("club.name")}`, }); - await MailHelper.sendMail(rec.sendNewsletter.email, `Newsletter von ${CLUB_NAME}`, body, [ - { filename: "events.ics", path: this.getICSFilePath(newsletter) }, - ]) + await MailHelper.sendMail( + rec.sendNewsletter.email, + `Newsletter von ${SettingHelper.getSetting("club.name")}`, + body, + [{ filename: "events.ics", path: this.getICSFilePath(newsletter) }] + ) .then(() => { this.formatJobEmit( "progress", @@ -286,7 +289,7 @@ export abstract class NewsletterHelper { await PdfExport.renderFile({ template: "newsletter", - title: `Newsletter von ${CLUB_NAME}`, + title: `Newsletter von ${SettingHelper.getSetting("club.name")}`, filename: `${rec.lastname}_${rec.firstname}_${rec.id}`.replaceAll(" ", "-"), folder: `newsletter/${newsletter.id}_${newsletter.title.replaceAll(" ", "")}`, data: data, diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index d176cc2..3652bce 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -1,57 +1,135 @@ +import { SettingString, settingsType } from "../type/settingTypes"; import ms from "ms"; -import { EnvSettingString, envSettingsType, SettingString, settingsType } from "../type/settingTypes"; +import { CodingHelper } from "./codingHelper"; +import SettingCommandHandler from "../command/settingCommandHandler"; +import SettingService from "../service/settingService"; +import { APPLICATION_SECRET } from "../env.defaults"; -export abstract class SettingHelper { +export default abstract class SettingHelper { private static settings: { [key in SettingString]?: string } = {}; - private static envSettings: { [key in EnvSettingString]?: string } = {}; public static getSetting(key: SettingString): string | number | boolean | ms.StringValue { let settingType = settingsType[key]; - return this.settings[key] ?? settingType.default ?? ""; + let setting = this.settings[key] ?? String(settingType.default ?? ""); + + if (Array.isArray(settingType.type)) { + return setting; + } + + if (settingType.type.includes("/crypt")) { + setting = CodingHelper.decrypt(APPLICATION_SECRET, String(setting)); + } + + 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; } - public static getEnvSetting(key: EnvSettingString): string { - let settingType = envSettingsType[key]; - return this.envSettings[key] ?? settingType.default ?? ""; + public static async setSetting(key: SettingString, value: string) { + if (value == undefined || value == null) return; + let settingType = settingsType[key]; + + let result = value; + + this.checkSettings(key, result); + + if (!Array.isArray(settingType.type) && settingType.type.includes("/crypt")) { + result = CodingHelper.encrypt(APPLICATION_SECRET, value); + } + + await SettingCommandHandler.create({ + topic: key.split(".")[0], + key: key.split(".")[1], + value: result, + }); } - public static async configure() {} + public static async resetSetting(key: SettingString) { + let settingType = settingsType[key]; + this.settings[key] = String(settingType.default ?? ""); - public static async configurEnv() { - this.envSettings = { - "database.type": process.env.DB_TYPE, - "database.host": process.env.DB_HOST, - "database.port": process.env.DB_PORT, - "database.name": process.env.DB_NAME, - "database.username": process.env.DB_USERNAME, - "database.password": process.env.DB_PASSWORD, - }; - this.checkEnvSettings(); + await SettingCommandHandler.delete({ + topic: key.split(".")[0], + key: key.split(".")[1], + }); } - private static checkEnvSettings() { - if (!["mysql", "sqlite", "postgres"].includes(this.envSettings["database.type"])) - throw new Error("set valid value to DB_TYPE (mysql|sqlite|postgres)"); - if (this.checkIfEmptyOrNotString(this.envSettings["database.name"])) - throw new Error("set valid value to DB_NAME (name of database or filepath for sqlite)"); + public static async configure() { + console.log("Configured Settings"); + let settings = await SettingService.getSettings(); + + for (const element of settings) { + let 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 ( - this.checkIfEmptyOrNotString(this.envSettings["database.host"]) && - this.envSettings["database.type"] != "sqlite" - ) - throw new Error("set valid value to DB_HOST"); + !Array.isArray(settingType.type) && + settingType.type.startsWith("string") && + this.checkIfEmptyOrNotString(value) + ) { + throw new Error(`set valid value to ${key}`); + } if ( - this.checkIfEmptyOrNotString(this.envSettings["database.username"]) && - this.envSettings["database.type"] != "sqlite" - ) - throw new Error("set valid value to DB_USERNAME"); + !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 ( - this.checkIfEmptyOrNotString(this.envSettings["database.password"]) && - this.envSettings["database.type"] != "sqlite" - ) - throw new Error("set valid value to DB_PASSWORD"); + !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; + } + } catch (e) { + return true; + } + return false; + } } diff --git a/src/index.ts b/src/index.ts index 973f7bf..4aa928c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,7 @@ import "dotenv/config"; import "./handlebars.config"; import express from "express"; -import { SettingHelper } from "./helpers/settingsHelper"; -SettingHelper.configurEnv(); import { configCheck } from "./env.defaults"; configCheck(); @@ -23,13 +21,15 @@ declare global { import { dataSource } from "./data-source"; import BackupHelper from "./helpers/backupHelper"; +import SettingHelper from "./helpers/settingsHelper"; dataSource.initialize().then(async () => { if (await dataSource.createQueryRunner().hasTable("user")) { await BackupHelper.autoRestoreBackup().catch((err) => { console.log(`${new Date().toISOString()}: failed auto-restoring database`, err); }); } - SettingHelper.configure(); + await SettingHelper.configure(); + MailHelper.createTransport(); }); const app = express(); @@ -43,6 +43,7 @@ app.listen(process.env.NODE_ENV ? process.env.SERVER_PORT ?? 5000 : 5000, () => import schedule from "node-schedule"; import RefreshCommandHandler from "./command/refreshCommandHandler"; +import MailHelper from "./helpers/mailHelper"; const job = schedule.scheduleJob("0 0 * * *", async () => { console.log(`${new Date().toISOString()}: running Cron`); await RefreshCommandHandler.deleteExpired(); diff --git a/src/migrations/1738166124200-BackupAndResetDatabase.ts b/src/migrations/1738166124200-BackupAndResetDatabase.ts index 30595da..f0656ba 100644 --- a/src/migrations/1738166124200-BackupAndResetDatabase.ts +++ b/src/migrations/1738166124200-BackupAndResetDatabase.ts @@ -2,16 +2,13 @@ import { MigrationInterface, QueryRunner, Table } from "typeorm"; import BackupHelper from "../helpers/backupHelper"; import { getDefaultByORM, getTypeByORM, isIncrementPrimary } from "./ormHelper"; import InternalException from "../exceptions/internalException"; -import { SettingHelper } from "../helpers/settingsHelper"; +import { DB_TYPE } from "../env.defaults"; export class BackupAndResetDatabase1738166124200 implements MigrationInterface { name = "BackupAndResetDatabase1738166124200"; public async up(queryRunner: QueryRunner): Promise { - let query = - SettingHelper.getEnvSetting("database.type") == "postgres" - ? "SELECT name FROM migrations" - : "SELECT `name` FROM `migrations`"; + let query = DB_TYPE == "postgres" ? "SELECT name FROM migrations" : "SELECT `name` FROM `migrations`"; let migrations = await queryRunner.query(query); if ( (await queryRunner.hasTable("user")) && diff --git a/src/migrations/1738166167472-CreateSchema.ts b/src/migrations/1738166167472-CreateSchema.ts index c9a4c8d..df9c6fd 100644 --- a/src/migrations/1738166167472-CreateSchema.ts +++ b/src/migrations/1738166167472-CreateSchema.ts @@ -54,7 +54,7 @@ import { newsletter_recipients_table, newsletter_table, } from "./baseSchemaTables/newsletter"; -import { SettingHelper } from "../helpers/settingsHelper"; +import { DB_TYPE } from "../env.defaults"; export class CreateSchema1738166167472 implements MigrationInterface { name = "CreateSchema1738166167472"; @@ -84,7 +84,6 @@ export class CreateSchema1738166167472 implements MigrationInterface { await queryRunner.createTable(member_executive_positions_table, true, true, true); await queryRunner.createTable(member_qualifications_table, true, true, true); - const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") await queryRunner.createView(member_view_postgres, true); else if (DB_TYPE == "mysql") await queryRunner.createView(member_view_mysql, true); else if (DB_TYPE == "sqlite") await queryRunner.createView(member_view_sqlite, true); diff --git a/src/migrations/1745059495808-settingsFromEnv.ts b/src/migrations/1745059495808-settingsFromEnv.ts new file mode 100644 index 0000000..6cc8f56 --- /dev/null +++ b/src/migrations/1745059495808-settingsFromEnv.ts @@ -0,0 +1,30 @@ +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 { + name = "SettingsFromEnv1745059495808"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable(setting_table, true, true, true); + + //transfer settings of env to database + await SettingHelper.setSetting("club.name", process.env.CLUB_NAME); + await SettingHelper.setSetting("club.website", process.env.CLUB_WEBSITE); + await SettingHelper.setSetting("session.jwt_expiration", process.env.JWT_EXPIRATION); + await SettingHelper.setSetting("session.refresh_expiration", process.env.REFRESH_EXPIRATION); + await SettingHelper.setSetting("session.pwa_refresh_expiration", process.env.PWA_REFRESH_EXPIRATION); + await SettingHelper.setSetting("mail.username", process.env.MAIL_USERNAME); + await SettingHelper.setSetting("mail.password", process.env.MAIL_PASSWORD); + await SettingHelper.setSetting("mail.host", process.env.MAIL_HOST); + await SettingHelper.setSetting("mail.port", process.env.MAIL_PORT); + await SettingHelper.setSetting("mail.secure", process.env.MAIL_SECURE); + await SettingHelper.setSetting("backup.interval", process.env.BACKUP_INTERVAL); + await SettingHelper.setSetting("backup.copies", process.env.BACKUP_COPIES); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable(setting_table.name, true, true, true); + } +} diff --git a/src/migrations/baseSchemaTables/admin.ts b/src/migrations/baseSchemaTables/admin.ts index c3eb94b..6ece9ad 100644 --- a/src/migrations/baseSchemaTables/admin.ts +++ b/src/migrations/baseSchemaTables/admin.ts @@ -148,3 +148,12 @@ export const reset_table = new Table({ { name: "secret", ...getTypeByORM("varchar") }, ], }); + +export const setting_table = new Table({ + name: "setting", + columns: [ + { name: "topic", ...getTypeByORM("varchar"), isPrimary: true }, + { name: "key", ...getTypeByORM("varchar"), isPrimary: true }, + { name: "value", ...getTypeByORM("text") }, + ], +}); diff --git a/src/migrations/ormHelper.ts b/src/migrations/ormHelper.ts index 099e006..8ebe5c9 100644 --- a/src/migrations/ormHelper.ts +++ b/src/migrations/ormHelper.ts @@ -1,4 +1,4 @@ -import { SettingHelper } from "../helpers/settingsHelper"; +import { DB_TYPE } from "../env.defaults"; export type ORMType = "int" | "bigint" | "boolean" | "date" | "datetime" | "time" | "text" | "varchar" | "uuid"; export type ORMDefault = "currentTimestamp" | "string" | "boolean" | "number" | "null"; @@ -15,7 +15,7 @@ export type Primary = { }; export function getTypeByORM(type: ORMType, nullable: boolean = false, length: number = 255): ColumnConfig { - const dbType = SettingHelper.getEnvSetting("database.type"); + const dbType = DB_TYPE; const typeMap: Record> = { mysql: { @@ -65,7 +65,7 @@ export function getTypeByORM(type: ORMType, nullable: boolean = false, length: n } export function getDefaultByORM(type: ORMDefault, data?: string | number | boolean): T { - const dbType = SettingHelper.getEnvSetting("database.type"); + const dbType = DB_TYPE; const typeMap: Record> = { mysql: { diff --git a/src/service/club/member/memberService.ts b/src/service/club/member/memberService.ts index eb791b3..cba1e47 100644 --- a/src/service/club/member/memberService.ts +++ b/src/service/club/member/memberService.ts @@ -1,11 +1,9 @@ import { Brackets, Like, SelectQueryBuilder } from "typeorm"; import { dataSource } from "../../../data-source"; import { member } from "../../../entity/club/member/member"; -import { membership } from "../../../entity/club/member/membership"; import DatabaseActionException from "../../../exceptions/databaseActionException"; -import InternalException from "../../../exceptions/internalException"; import { memberView } from "../../../views/memberView"; -import { SettingHelper } from "../../../helpers/settingsHelper"; +import { DB_TYPE } from "../../../env.defaults"; export default abstract class MemberService { /** @@ -169,7 +167,7 @@ export default abstract class MemberService { "member.firstMembershipEntry", "member.memberships", "membership_first", - SettingHelper.getEnvSetting("database.type") == "postgres" + DB_TYPE == "postgres" ? 'membership_first.memberId = member.id AND membership_first.start = (SELECT MIN("m_first"."start") FROM "membership" "m_first" WHERE "m_first"."memberId" = "member"."id")' : "membership_first.memberId = member.id AND membership_first.start = (SELECT MIN(m_first.start) FROM membership m_first WHERE m_first.memberId = member.id)" ) @@ -177,7 +175,7 @@ export default abstract class MemberService { "member.lastMembershipEntry", "member.memberships", "membership_last", - SettingHelper.getEnvSetting("database.type") == "postgres" + DB_TYPE == "postgres" ? 'membership_last.memberId = member.id AND membership_last.start = (SELECT MAX("m_last"."start") FROM "membership" "m_last" WHERE "m_last"."memberId" = "member"."id")' : "membership_last.memberId = member.id AND membership_last.start = (SELECT MAX(m_last.start) FROM membership m_last WHERE m_last.memberId = member.id)" ) diff --git a/src/service/settingService.ts b/src/service/settingService.ts new file mode 100644 index 0000000..d5f7701 --- /dev/null +++ b/src/service/settingService.ts @@ -0,0 +1,43 @@ +import { dataSource } from "../data-source"; +import { setting } from "../entity/setting"; +import InternalException from "../exceptions/internalException"; +import { SettingString } from "../type/settingTypes"; + +export default abstract class SettingService { + /** + * @description get settings + * @returns {Promise} + */ + static async getSettings(): Promise { + return await dataSource + .getRepository(setting) + .createQueryBuilder("setting") + .getMany() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("setting not found", err); + }); + } + + /** + * @description get setting + * @param token SettingString + * @returns {Promise} + */ + static async getBySettingString(key: SettingString): Promise { + return await dataSource + .getRepository(setting) + .createQueryBuilder("setting") + .where("setting.topic = :topic", { topic: key.split(".")[0] }) + .andWhere("setting.key >= :key", { key: key.split(".")[1] }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + throw new InternalException("setting not found", err); + }); + } +} diff --git a/src/type/settingTypes.ts b/src/type/settingTypes.ts index 6c7935c..f1ada64 100644 --- a/src/type/settingTypes.ts +++ b/src/type/settingTypes.ts @@ -10,7 +10,6 @@ export type SettingString = | "club.website" | "app.custom_login_message" | "app.show_link_to_calendar" - | "session.jwt_secret" | "session.jwt_expiration" | "session.refresh_expiration" | "session.pwa_refresh_expiration" @@ -20,23 +19,17 @@ export type SettingString = | "mail.port" | "mail.secure" | "backup.interval" - | "backup.copies" - | "security.strict_limit" - | "security.strict_limit_window" - | "security.strict_limit_request_count" - | "security.limit" - | "security.limit_window" - | "security.limit_request_count" - | "security.trust_proxy"; + | "backup.copies"; 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 | SettingType[]; + type: SettingType | SettingTypeAtom[]; default?: string | number | boolean | ms.StringValue; optional?: boolean; + min?: number; }; } = { "club.name": { type: "string", default: "FF Admin" }, @@ -45,7 +38,6 @@ export const settingsType: { "club.website": { type: "url", optional: true }, "app.custom_login_message": { type: "string", optional: true }, "app.show_link_to_calendar": { type: "boolean", default: true }, - "session.jwt_secret": { type: "longstring/rand", default: StringHelper.random(64) }, "session.jwt_expiration": { type: "ms", default: "15m" }, "session.refresh_expiration": { type: "ms", default: "1d" }, "session.pwa_refresh_expiration": { type: "ms", default: "5d" }, @@ -54,15 +46,8 @@ export const settingsType: { "mail.host": { type: "url", optional: false }, "mail.port": { type: "number", default: 587 }, "mail.secure": { type: "boolean", default: false }, - "backup.interval": { type: "number", default: 1 }, - "backup.copies": { type: "number", default: 7 }, - "security.strict_limit": { type: "boolean", default: true }, - "security.strict_limit_window": { type: "ms", default: "15m" }, - "security.strict_limit_request_count": { type: "number", default: 15 }, - "security.limit": { type: "boolean", default: true }, - "security.limit_window": { type: "ms", default: "1m" }, - "security.limit_request_count": { type: "number", default: 500 }, - "security.trust_proxy": { type: ["boolean", "number"], optional: true }, + "backup.interval": { type: "number", default: 1, min: 1 }, + "backup.copies": { type: "number", default: 7, min: 1 }, }; /** ENV Settings */ @@ -72,12 +57,20 @@ export type EnvSettingString = | "database.port" | "database.name" | "database.username" - | "database.password"; + | "database.password" + | "application.secret" + | "security.strict_limit" + | "security.strict_limit_window" + | "security.strict_limit_request_count" + | "security.limit" + | "security.limit_window" + | "security.limit_request_count" + | "security.trust_proxy"; export const envSettingsType: { [key in EnvSettingString]: { type: SettingType | SettingType[]; - default?: string; + default?: string | number | boolean; }; } = { "database.type": { type: "string", default: "postgres" }, @@ -86,4 +79,12 @@ export const envSettingsType: { "database.name": { type: "string" }, "database.username": { type: "string" }, "database.password": { type: "string" }, + "application.secret": { type: "string" }, + "security.strict_limit": { type: "boolean", default: true }, + "security.strict_limit_window": { type: "ms", default: "15m" }, + "security.strict_limit_request_count": { type: "number", default: 15 }, + "security.limit": { type: "boolean", default: true }, + "security.limit_window": { type: "ms", default: "1m" }, + "security.limit_request_count": { type: "number", default: 500 }, + "security.trust_proxy": { type: ["boolean", "number", "string"] }, }; diff --git a/src/views/memberExecutivePositionView.ts b/src/views/memberExecutivePositionView.ts index fb85148..8b8f328 100644 --- a/src/views/memberExecutivePositionView.ts +++ b/src/views/memberExecutivePositionView.ts @@ -1,11 +1,10 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { memberExecutivePositions } from "../entity/club/member/memberExecutivePositions"; -import { SettingHelper } from "../helpers/settingsHelper"; +import { DB_TYPE } from "../env.defaults"; let durationInDays: string; let durationInYears: string; let exactDuration: string; -const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { durationInDays = `SUM(COALESCE("memberExecutivePositions"."end", CURRENT_DATE) - "memberExecutivePositions"."start")`; durationInYears = `SUM(EXTRACT(YEAR FROM AGE(COALESCE("memberExecutivePositions"."end", CURRENT_DATE), "memberExecutivePositions"."start")))`; diff --git a/src/views/memberQualificationsView.ts b/src/views/memberQualificationsView.ts index 0d7c05b..6f88f39 100644 --- a/src/views/memberQualificationsView.ts +++ b/src/views/memberQualificationsView.ts @@ -1,11 +1,10 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { memberQualifications } from "../entity/club/member/memberQualifications"; -import { SettingHelper } from "../helpers/settingsHelper"; +import { DB_TYPE } from "../env.defaults"; let durationInDays: string; let durationInYears: string; let exactDuration: string; -const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { durationInDays = `SUM(COALESCE("memberQualifications"."end", CURRENT_DATE) - "memberQualifications"."start") `; durationInYears = `SUM(EXTRACT(YEAR FROM AGE(COALESCE("memberQualifications"."end", CURRENT_DATE), "memberQualifications"."start")))`; diff --git a/src/views/memberView.ts b/src/views/memberView.ts index 748d07e..194bade 100644 --- a/src/views/memberView.ts +++ b/src/views/memberView.ts @@ -1,11 +1,10 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { member } from "../entity/club/member/member"; -import { SettingHelper } from "../helpers/settingsHelper"; +import { DB_TYPE } from "../env.defaults"; let todayAge: string; let ageThisYear: string; let exactAge: string; -const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { todayAge = `DATE_PART('year', AGE(CURRENT_DATE, member.birthdate))`; ageThisYear = `EXTRACT(YEAR FROM CURRENT_DATE) - EXTRACT(YEAR FROM member.birthdate)`; diff --git a/src/views/membershipsView.ts b/src/views/membershipsView.ts index 4e21191..fc38c23 100644 --- a/src/views/membershipsView.ts +++ b/src/views/membershipsView.ts @@ -1,11 +1,10 @@ import { DataSource, ViewColumn, ViewEntity } from "typeorm"; import { membership } from "../entity/club/member/membership"; -import { SettingHelper } from "../helpers/settingsHelper"; +import { DB_TYPE } from "../env.defaults"; let durationInDays: string; let durationInYears: string; let exactDuration: string; -const DB_TYPE = SettingHelper.getEnvSetting("database.type"); if (DB_TYPE == "postgres") { durationInDays = `SUM(COALESCE("membership"."end", CURRENT_DATE) - "membership"."start") `; durationInYears = `SUM(EXTRACT(YEAR FROM AGE(COALESCE("membership"."end", CURRENT_DATE), "membership"."start")))`; From a8edc19f348963014a392343c8dacef01ecbb2a6 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sun, 20 Apr 2025 15:32:57 +0200 Subject: [PATCH 06/29] 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 }, From 7aa9038a61350708364b4aade0d7c4bafd6a1a90 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sun, 20 Apr 2025 16:15:27 +0200 Subject: [PATCH 07/29] provide setting endbpoints --- package-lock.json | 18 ++++++ package.json | 2 + .../setting}/settingCommand.ts | 0 .../setting}/settingCommandHandler.ts | 8 +-- src/controller/admin/management/setting.ts | 60 +++++++++++++++++++ src/helpers/settingsHelper.ts | 9 ++- src/routes/admin/index.ts | 2 + src/routes/admin/management/setting.ts | 31 ++++++++++ src/routes/public.ts | 6 +- .../{ => management}/settingService.ts | 8 +-- src/type/permissionTypes.ts | 6 +- 11 files changed, 137 insertions(+), 13 deletions(-) rename src/command/{ => management/setting}/settingCommand.ts (100%) rename src/command/{ => management/setting}/settingCommandHandler.ts (85%) create mode 100644 src/controller/admin/management/setting.ts create mode 100644 src/routes/admin/management/setting.ts rename src/service/{ => management}/settingService.ts (82%) diff --git a/package-lock.json b/package-lock.json index a7c3021..6d738ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "ics": "^3.8.1", "ip": "^2.0.1", "jsonwebtoken": "^9.0.2", + "lodash.clonedeep": "^4.5.0", "lodash.uniqby": "^4.7.0", "moment": "^2.30.1", "morgan": "^1.10.0", @@ -45,6 +46,7 @@ "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", @@ -462,6 +464,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.9.tgz", + "integrity": "sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/lodash.uniqby": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/@types/lodash.uniqby/-/lodash.uniqby-4.7.9.tgz", @@ -2770,6 +2782,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/package.json b/package.json index 9f7d24c..0483b20 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "ics": "^3.8.1", "ip": "^2.0.1", "jsonwebtoken": "^9.0.2", + "lodash.clonedeep": "^4.5.0", "lodash.uniqby": "^4.7.0", "moment": "^2.30.1", "morgan": "^1.10.0", @@ -61,6 +62,7 @@ "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", + "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", diff --git a/src/command/settingCommand.ts b/src/command/management/setting/settingCommand.ts similarity index 100% rename from src/command/settingCommand.ts rename to src/command/management/setting/settingCommand.ts diff --git a/src/command/settingCommandHandler.ts b/src/command/management/setting/settingCommandHandler.ts similarity index 85% rename from src/command/settingCommandHandler.ts rename to src/command/management/setting/settingCommandHandler.ts index 148bdef..a6bde0f 100644 --- a/src/command/settingCommandHandler.ts +++ b/src/command/management/setting/settingCommandHandler.ts @@ -1,7 +1,7 @@ -import { dataSource } from "../data-source"; -import { setting } from "../entity/setting"; -import DatabaseActionException from "../exceptions/databaseActionException"; -import { StringHelper } from "../helpers/stringHelper"; +import { dataSource } from "../../../data-source"; +import { setting } from "../../../entity/setting"; +import DatabaseActionException from "../../../exceptions/databaseActionException"; +import { StringHelper } from "../../../helpers/stringHelper"; import { CreateOrUpdateSettingCommand, DeleteSettingCommand } from "./settingCommand"; export default abstract class SettingCommandHandler { diff --git a/src/controller/admin/management/setting.ts b/src/controller/admin/management/setting.ts new file mode 100644 index 0000000..84a302f --- /dev/null +++ b/src/controller/admin/management/setting.ts @@ -0,0 +1,60 @@ +import { Request, Response } from "express"; +import SettingHelper from "../../../helpers/settingsHelper"; +import { SettingString } from "../../../type/settingTypes"; + +/** + * @description get All settings + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getSettings(req: Request, res: Response): Promise { + res.json(SettingHelper.getAllSettings()); +} + +/** + * @description get setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getSetting(req: Request, res: Response): Promise { + let setting = req.params.setting as SettingString; + + let value = SettingHelper.getSetting(setting); + + if (setting == "mail.password") { + value = undefined; + } + + res.send(value); +} + +/** + * @description set setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setSetting(req: Request, res: Response): Promise { + let setting = req.body.setting as SettingString; + let value = req.body.value as string; + + SettingHelper.setSetting(setting, value); + + res.sendStatus(204); +} + +/** + * @description reset setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function resetSetting(req: Request, res: Response): Promise { + let setting = req.params.setting as SettingString; + + SettingHelper.resetSetting(setting); + + res.sendStatus(204); +} diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index 35ce974..8418cab 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -1,7 +1,7 @@ import { SettingString, settingsType, SettingTopic, SettingTypeAtom, SettingValueMapping } from "../type/settingTypes"; import { CodingHelper } from "./codingHelper"; -import SettingCommandHandler from "../command/settingCommandHandler"; -import SettingService from "../service/settingService"; +import SettingCommandHandler from "../command/management/setting/settingCommandHandler"; +import SettingService from "../service/management/settingService"; import { APPLICATION_SECRET } from "../env.defaults"; import { BooleanConverter, @@ -12,6 +12,7 @@ import { TypeConverter, UrlConverter, } from "./convertHelper"; +import cloneDeep from "lodash.clonedeep"; export default abstract class SettingHelper { private static settings: { [key in SettingString]?: string } = {}; @@ -28,6 +29,10 @@ export default abstract class SettingHelper { ms: new MsConverter(), }; + public static getAllSettings() { + return cloneDeep(this.settings); + } + /** * Returns the value of a setting with the correct type based on the key * @param key The key of the setting diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index c2bab0f..5c3444b 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -26,6 +26,7 @@ import user from "./management/user"; import invite from "./management/invite"; import api from "./management/webapi"; import backup from "./management/backup"; +import setting from "./management/setting"; var router = express.Router({ mergeParams: true }); @@ -159,5 +160,6 @@ router.use( PermissionHelper.passCheckMiddleware("read", "management", "backup"), backup ); +router.use("/setting", PermissionHelper.passCheckMiddleware("read", "management", "setting"), setting); export default router; diff --git a/src/routes/admin/management/setting.ts b/src/routes/admin/management/setting.ts new file mode 100644 index 0000000..e0ffe14 --- /dev/null +++ b/src/routes/admin/management/setting.ts @@ -0,0 +1,31 @@ +import express, { Request, Response } from "express"; +import PermissionHelper from "../../../helpers/permissionHelper"; +import { getSetting, getSettings, resetSetting, setSetting } from "../../../controller/admin/management/setting"; + +var router = express.Router({ mergeParams: true }); + +router.get("/", async (req: Request, res: Response) => { + await getSettings(req, res); +}); + +router.get("/:setting", async (req: Request, res: Response) => { + await getSetting(req, res); +}); + +router.put( + "/", + PermissionHelper.passCheckMiddleware("create", "management", "setting"), + async (req: Request, res: Response) => { + await setSetting(req, res); + } +); + +router.delete( + "/:setting", + PermissionHelper.passCheckMiddleware("delete", "management", "setting"), + async (req: Request, res: Response) => { + await resetSetting(req, res); + } +); + +export default router; diff --git a/src/routes/public.ts b/src/routes/public.ts index 56b4784..49ec98a 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,5 +1,5 @@ import express from "express"; -import { getCalendarItemsByTypes } from "../controller/publicController"; +import { getApplicationConfig, getCalendarItemsByTypes } from "../controller/publicController"; var router = express.Router({ mergeParams: true }); @@ -7,4 +7,8 @@ router.get("/calendar", async (req, res) => { await getCalendarItemsByTypes(req, res); }); +router.get("/configuration", async (req, res) => { + await getApplicationConfig(req, res); +}); + export default router; diff --git a/src/service/settingService.ts b/src/service/management/settingService.ts similarity index 82% rename from src/service/settingService.ts rename to src/service/management/settingService.ts index d5f7701..1860065 100644 --- a/src/service/settingService.ts +++ b/src/service/management/settingService.ts @@ -1,7 +1,7 @@ -import { dataSource } from "../data-source"; -import { setting } from "../entity/setting"; -import InternalException from "../exceptions/internalException"; -import { SettingString } from "../type/settingTypes"; +import { dataSource } from "../../data-source"; +import { setting } from "../../entity/setting"; +import InternalException from "../../exceptions/internalException"; +import { SettingString } from "../../type/settingTypes"; export default abstract class SettingService { /** diff --git a/src/type/permissionTypes.ts b/src/type/permissionTypes.ts index 1061d49..c3babac 100644 --- a/src/type/permissionTypes.ts +++ b/src/type/permissionTypes.ts @@ -21,7 +21,8 @@ export type PermissionModule = | "query_store" | "template" | "template_usage" - | "backup"; + | "backup" + | "setting"; export type PermissionType = "read" | "create" | "update" | "delete"; @@ -78,6 +79,7 @@ export const permissionModules: Array = [ "template", "template_usage", "backup", + "setting", ]; export const permissionTypes: Array = ["read", "create", "update", "delete"]; export const sectionsAndModules: SectionsAndModulesObject = { @@ -95,6 +97,6 @@ export const sectionsAndModules: SectionsAndModulesObject = { "template_usage", "newsletter_config", ], - management: ["user", "role", "webapi", "backup"], + management: ["user", "role", "webapi", "backup", "setting"], additional: [], }; From b4a7986c8a2e631cc2c747206b5814a69b941637 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Thu, 24 Apr 2025 16:49:16 +0200 Subject: [PATCH 08/29] move pwa manifest to backend --- Dockerfile | 1 + package-lock.json | 530 ++++++++++++++++++ package.json | 2 + src/assets/admin-logo.png | Bin 0 -> 35299 bytes src/assets/icon.png | Bin 0 -> 29706 bytes .../setting/settingCommandHandler.ts | 2 +- src/controller/publicController.ts | 138 ++++- src/data-source.ts | 2 +- src/entity/{ => management}/setting.ts | 0 src/helpers/fileSystemHelper.ts | 15 +- src/helpers/templateHelper.ts | 4 +- src/routes/public.ts | 25 +- src/routes/server.ts | 2 +- src/routes/setup.ts | 6 + src/service/management/settingService.ts | 2 +- src/type/settingTypes.ts | 6 + 16 files changed, 724 insertions(+), 11 deletions(-) create mode 100644 src/assets/admin-logo.png create mode 100644 src/assets/icon.png rename src/entity/{ => management}/setting.ts (100%) diff --git a/Dockerfile b/Dockerfile index 4846250..08f68ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ RUN mkdir -p /app/files ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser COPY --from=build /app/src/templates /app/src/templates +COPY --from=build /app/src/assets /app/src/assets COPY --from=build /app/dist /app/dist COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/package.json /app/package.json diff --git a/package-lock.json b/package-lock.json index 6d738ff..ab0d1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", + "sharp": "^0.34.1", + "sharp-ico": "^0.1.5", "socket.io": "^4.7.5", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", @@ -86,6 +88,12 @@ "node": ">=6.9.0" } }, + "node_modules/@canvas/image-data": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.0.0.tgz", + "integrity": "sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==", + "license": "MIT" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -99,6 +107,23 @@ "node": ">=12" } }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -106,6 +131,383 @@ "license": "MIT", "optional": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.1.tgz", + "integrity": "sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", + "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", + "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", + "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", + "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", + "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", + "integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", + "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", + "integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.1.tgz", + "integrity": "sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.1.tgz", + "integrity": "sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.1.tgz", + "integrity": "sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.1.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.1.tgz", + "integrity": "sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.1.tgz", + "integrity": "sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.1.tgz", + "integrity": "sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.1.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.1.tgz", + "integrity": "sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.1.tgz", + "integrity": "sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.1.tgz", + "integrity": "sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1361,6 +1763,19 @@ "node": ">=12" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1379,6 +1794,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -1583,6 +2008,33 @@ "node": ">=0.10.0" } }, + "node_modules/decode-bmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/decode-bmp/-/decode-bmp-0.2.1.tgz", + "integrity": "sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==", + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/decode-ico": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/decode-ico/-/decode-ico-0.4.1.tgz", + "integrity": "sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==", + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0", + "decode-bmp": "^0.2.0", + "to-data-view": "^1.1.0" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -2492,6 +2944,12 @@ "ms": "^2.0.0" } }, + "node_modules/ico-endec": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/ico-endec/-/ico-endec-0.1.6.tgz", + "integrity": "sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==", + "license": "MPL-2.0" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4475,6 +4933,57 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.1.tgz", + "integrity": "sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.7.1" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.1", + "@img/sharp-darwin-x64": "0.34.1", + "@img/sharp-libvips-darwin-arm64": "1.1.0", + "@img/sharp-libvips-darwin-x64": "1.1.0", + "@img/sharp-libvips-linux-arm": "1.1.0", + "@img/sharp-libvips-linux-arm64": "1.1.0", + "@img/sharp-libvips-linux-ppc64": "1.1.0", + "@img/sharp-libvips-linux-s390x": "1.1.0", + "@img/sharp-libvips-linux-x64": "1.1.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", + "@img/sharp-libvips-linuxmusl-x64": "1.1.0", + "@img/sharp-linux-arm": "0.34.1", + "@img/sharp-linux-arm64": "0.34.1", + "@img/sharp-linux-s390x": "0.34.1", + "@img/sharp-linux-x64": "0.34.1", + "@img/sharp-linuxmusl-arm64": "0.34.1", + "@img/sharp-linuxmusl-x64": "0.34.1", + "@img/sharp-wasm32": "0.34.1", + "@img/sharp-win32-ia32": "0.34.1", + "@img/sharp-win32-x64": "0.34.1" + } + }, + "node_modules/sharp-ico": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/sharp-ico/-/sharp-ico-0.1.5.tgz", + "integrity": "sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==", + "license": "MIT", + "dependencies": { + "decode-ico": "*", + "ico-endec": "*", + "sharp": "*" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4620,6 +5129,21 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -5104,6 +5628,12 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/to-data-view": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-data-view/-/to-data-view-1.1.0.tgz", + "integrity": "sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index 0483b20..02fbe5a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", + "sharp": "^0.34.1", + "sharp-ico": "^0.1.5", "socket.io": "^4.7.5", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", diff --git a/src/assets/admin-logo.png b/src/assets/admin-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a2760bc868de85b44416af5d33796f9672566b06 GIT binary patch literal 35299 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 literal 0 HcmV?d00001 diff --git a/src/assets/icon.png b/src/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..39a8174e006b997c1611424429a4054898f06329 GIT binary patch literal 29706 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≺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 i4} + */ +export async function getApplicationManifest(req: Request, res: Response): Promise { + const backendUrl = `${req.protocol}://${req.get("host")}`; + const frontenUrl = `${req.get("referer")}`; + + const manifest = { + id: "ff_admin_webapp", + lang: "de", + name: SettingHelper.getSetting("club.name"), + short_name: SettingHelper.getSetting("club.name"), + theme_color: "#990b00", + display: "standalone", + orientation: "portrait-primary", + start_url: frontenUrl, + icons: [ + { + src: `${backendUrl}/api/public/favicon.ico`, + sizes: "48x48", + type: "image/ico", + }, + { + src: `${backendUrl}/api/public/icon.png?width=512&height=512`, + sizes: "512x512", + type: "image/png", + }, + ], + }; + + res.set({ + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/manifest+json", + }); + + res.json(manifest); +} + +/** + * @description get application Logo + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationLogo(req: Request, res: Response): Promise { + let setLogo = SettingHelper.getSetting("club.logo"); + + res.set({ + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + "Timing-Allow-Origin": "*", + }); + + if (setLogo == "") { + res.sendFile(FileSystemHelper.readAssetFile("admin-logo.png", true)); + } else { + res.sendFile(FileSystemHelper.formatPath("/app/admin-logo.png")); + } +} + +/** + * @description get application Favicon + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationFavicon(req: Request, res: Response): Promise { + let icon = FileSystemHelper.readAssetFile("icon.png", true); + let setLogo = SettingHelper.getSetting("club.icon"); + + if (setLogo != "") { + icon = FileSystemHelper.formatPath("/app/icon.png"); + } + + let image = await sharp(icon) + .resize(48, 48, { + fit: "inside", + }) + .png() + .toBuffer(); + + let buffer = ico.encode([image]); + + res.set({ + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + "Timing-Allow-Origin": "*", + }); + + res.setHeader("Content-Type", "image/x-icon"); + res.send(buffer); +} + +/** + * @description get application Icon + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getApplicationIcon(req: Request, res: Response): Promise { + const width = parseInt((req.query.width as string) ?? ""); + const height = parseInt((req.query.height as string) ?? ""); + + let icon = FileSystemHelper.readAssetFile("icon.png", true); + let setLogo = SettingHelper.getSetting("club.icon"); + + if (setLogo != "") { + icon = FileSystemHelper.formatPath("/app/icon.png"); + } + + let image = await sharp(icon) + .resize(width, height, { + fit: "inside", + }) + .png() + .toBuffer(); + + res.set({ + "Access-Control-Allow-Origin": "*", + "Cross-Origin-Resource-Policy": "cross-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + "Timing-Allow-Origin": "*", + }); + + res.setHeader("Content-Type", "image/png"); + res.send(image); +} diff --git a/src/data-source.ts b/src/data-source.ts index d94d77f..2fabc96 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -50,7 +50,7 @@ import { TemplatesAndProtocolSort1742549956787 } from "./migrations/174254995678 import { QueryToUUID1742922178643 } from "./migrations/1742922178643-queryToUUID"; import { NewsletterColumnType1744351418751 } from "./migrations/1744351418751-newsletterColumnType"; import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpdatedAt"; -import { setting } from "./entity/setting"; +import { setting } from "./entity/management/setting"; import { SettingsFromEnv1745059495808 } from "./migrations/1745059495808-settingsFromEnv"; import { DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_TYPE, DB_USERNAME } from "./env.defaults"; diff --git a/src/entity/setting.ts b/src/entity/management/setting.ts similarity index 100% rename from src/entity/setting.ts rename to src/entity/management/setting.ts diff --git a/src/helpers/fileSystemHelper.ts b/src/helpers/fileSystemHelper.ts index f77bef2..9bc409a 100644 --- a/src/helpers/fileSystemHelper.ts +++ b/src/helpers/fileSystemHelper.ts @@ -20,9 +20,20 @@ export abstract class FileSystemHelper { return readFileSync(this.formatPath(...filePath), "base64"); } + static readRootFile(filePath: string) { + return readFileSync(this.normalizePath(process.cwd(), filePath), "utf8"); + } + static readTemplateFile(filePath: string) { - this.createFolder(filePath); - return readFileSync(process.cwd() + filePath, "utf8"); + return readFileSync(this.normalizePath(process.cwd(), "src", "templates", filePath), "utf8"); + } + + static readAssetFile(filePath: string, returnPath: boolean = false) { + let path = this.normalizePath(process.cwd(), "src", "assets", filePath); + if (returnPath) { + return path; + } + return readFileSync(path, "utf8"); } static writeFile(filePath: string, filename: string, file: any) { diff --git a/src/helpers/templateHelper.ts b/src/helpers/templateHelper.ts index 3dc5ad8..dd77a74 100644 --- a/src/helpers/templateHelper.ts +++ b/src/helpers/templateHelper.ts @@ -9,10 +9,10 @@ export abstract class TemplateHelper { static getTemplateFromFile(template: string) { let tmpFile; try { - tmpFile = FileSystemHelper.readTemplateFile(`/src/templates/${template}.template.html`); + tmpFile = FileSystemHelper.readTemplateFile(`${template}.template.html`); } catch (err) { tmpFile = FileSystemHelper.readTemplateFile( - `/src/templates/${template.split(".")[template.split(".").length - 1]}.template.html` + `${template.split(".")[template.split(".").length - 1]}.template.html` ); } return tmpFile; diff --git a/src/routes/public.ts b/src/routes/public.ts index 49ec98a..d557dd7 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -1,5 +1,12 @@ import express from "express"; -import { getApplicationConfig, getCalendarItemsByTypes } from "../controller/publicController"; +import { + getApplicationConfig, + getApplicationFavicon, + getApplicationIcon, + getApplicationLogo, + getApplicationManifest, + getCalendarItemsByTypes, +} from "../controller/publicController"; var router = express.Router({ mergeParams: true }); @@ -11,4 +18,20 @@ router.get("/configuration", async (req, res) => { await getApplicationConfig(req, res); }); +router.get("/manifest.webmanifest", async (req, res) => { + await getApplicationManifest(req, res); +}); + +router.get("/applogo.png", async (req, res) => { + await getApplicationLogo(req, res); +}); + +router.get("/favicon.ico", async (req, res) => { + await getApplicationFavicon(req, res); +}); + +router.get("/icon.png", async (req, res) => { + await getApplicationIcon(req, res); +}); + export default router; diff --git a/src/routes/server.ts b/src/routes/server.ts index 964d207..782609d 100644 --- a/src/routes/server.ts +++ b/src/routes/server.ts @@ -5,7 +5,7 @@ import Parser from "rss-parser"; var router = express.Router({ mergeParams: true }); router.get("/version", async (req: Request, res: Response) => { - let serverPackage = FileSystemHelper.readTemplateFile("/package.json"); + let serverPackage = FileSystemHelper.readRootFile("/package.json"); let serverJson = JSON.parse(serverPackage); res.send({ name: serverJson.name, diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 159e04e..36cd725 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -25,4 +25,10 @@ router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "to await finishInvite(req, res, true); }); +/** + * TODO: + * set basic settings like clubname ... + * enable upload of images and icons: transform pwa-> 512x512 png / 48x48 ico + */ + export default router; diff --git a/src/service/management/settingService.ts b/src/service/management/settingService.ts index 1860065..4f9b5e2 100644 --- a/src/service/management/settingService.ts +++ b/src/service/management/settingService.ts @@ -1,5 +1,5 @@ import { dataSource } from "../../data-source"; -import { setting } from "../../entity/setting"; +import { setting } from "../../entity/management/setting"; import InternalException from "../../exceptions/internalException"; import { SettingString } from "../../type/settingTypes"; diff --git a/src/type/settingTypes.ts b/src/type/settingTypes.ts index 48ba231..8f131ee 100644 --- a/src/type/settingTypes.ts +++ b/src/type/settingTypes.ts @@ -2,6 +2,8 @@ import ms from "ms"; export type SettingTopic = "club" | "app" | "session" | "mail" | "backup" | "security"; export type SettingString = + | "club.icon" + | "club.logo" | "club.name" | "club.imprint" | "club.privacy" @@ -23,6 +25,8 @@ export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boole export type SettingType = SettingTypeAtom | `${SettingTypeAtom}/crypt` | `${SettingTypeAtom}/rand`; export type SettingValueMapping = { + "club.icon": string; + "club.logo": string; "club.name": string; "club.imprint": string; "club.privacy": string; @@ -54,6 +58,8 @@ export type SettingsSchema = { }; export const settingsType: SettingsSchema = { + "club.icon": { type: "string", optional: true }, + "club.logo": { type: "string", optional: true }, "club.name": { type: "string", default: "FF Admin" }, "club.imprint": { type: "url", optional: true }, "club.privacy": { type: "url", optional: true }, From 70edd165ee37fee68e6258e5451d0928fbcb52e7 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Fri, 25 Apr 2025 08:07:53 +0200 Subject: [PATCH 09/29] typesave set Setting --- src/helpers/settingsHelper.ts | 12 ++++++++---- .../1745059495808-settingsFromEnv.ts | 18 +++++++++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index 8418cab..63fcf66 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -64,17 +64,19 @@ export default abstract class SettingHelper { * @param key The key of the setting * @param value The value to set */ - public static async setSetting(key: SettingString, value: string): Promise { + public static async setSetting(key: K, value: SettingValueMapping[K]): Promise { if (value === undefined || value === null) return; + const stringValue = String(value); + const settingType = settingsType[key]; - this.validateSetting(key, value); + this.validateSetting(key, stringValue); const oldValue = this.getSetting(key); - let finalValue = value; + let finalValue = stringValue; if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { - finalValue = CodingHelper.encrypt(APPLICATION_SECRET, value); + finalValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); } this.settings[key] = finalValue; @@ -95,6 +97,8 @@ export default abstract class SettingHelper { * @param key The key of the setting */ public static async resetSetting(key: SettingString): Promise { + if (this.getSetting(key) == String(settingsType[key].default ?? "")) return; + const oldValue = this.getSetting(key); const settingType = settingsType[key]; diff --git a/src/migrations/1745059495808-settingsFromEnv.ts b/src/migrations/1745059495808-settingsFromEnv.ts index ca5192b..6028f1d 100644 --- a/src/migrations/1745059495808-settingsFromEnv.ts +++ b/src/migrations/1745059495808-settingsFromEnv.ts @@ -1,6 +1,7 @@ import { MigrationInterface, QueryRunner } from "typeorm"; import { setting_table } from "./baseSchemaTables/admin"; import SettingHelper from "../helpers/settingsHelper"; +import ms from "ms"; export class SettingsFromEnv1745059495808 implements MigrationInterface { name = "SettingsFromEnv1745059495808"; @@ -11,16 +12,19 @@ export class SettingsFromEnv1745059495808 implements MigrationInterface { //transfer settings of env to database await SettingHelper.setSetting("club.name", process.env.CLUB_NAME); await SettingHelper.setSetting("club.website", process.env.CLUB_WEBSITE); - await SettingHelper.setSetting("session.jwt_expiration", process.env.JWT_EXPIRATION); - await SettingHelper.setSetting("session.refresh_expiration", process.env.REFRESH_EXPIRATION); - await SettingHelper.setSetting("session.pwa_refresh_expiration", process.env.PWA_REFRESH_EXPIRATION); + await SettingHelper.setSetting("session.jwt_expiration", process.env.JWT_EXPIRATION as ms.StringValue); + await SettingHelper.setSetting("session.refresh_expiration", process.env.REFRESH_EXPIRATION as ms.StringValue); + await SettingHelper.setSetting( + "session.pwa_refresh_expiration", + process.env.PWA_REFRESH_EXPIRATION as ms.StringValue + ); await SettingHelper.setSetting("mail.username", process.env.MAIL_USERNAME); await SettingHelper.setSetting("mail.password", process.env.MAIL_PASSWORD); await SettingHelper.setSetting("mail.host", process.env.MAIL_HOST); - await SettingHelper.setSetting("mail.port", process.env.MAIL_PORT); - await SettingHelper.setSetting("mail.secure", process.env.MAIL_SECURE); - await SettingHelper.setSetting("backup.interval", process.env.BACKUP_INTERVAL); - await SettingHelper.setSetting("backup.copies", process.env.BACKUP_COPIES); + await SettingHelper.setSetting("mail.port", Number(process.env.MAIL_PORT)); + await SettingHelper.setSetting("mail.secure", Boolean(process.env.MAIL_SECURE)); + await SettingHelper.setSetting("backup.interval", Number(process.env.BACKUP_INTERVAL)); + await SettingHelper.setSetting("backup.copies", Number(process.env.BACKUP_COPIES)); } public async down(queryRunner: QueryRunner): Promise { From 2e3d0a755cc51619c90a5ed407420fbaa6ab692b Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Fri, 25 Apr 2025 08:18:49 +0200 Subject: [PATCH 10/29] setup routine --- .../{setting.ts => settingController.ts} | 2 +- src/controller/inviteController.ts | 4 +- src/controller/setupController.ts | 87 +++++++++++++++++++ src/middleware/multer.ts | 27 ++++++ src/routes/admin/management/setting.ts | 7 +- src/routes/admin/management/user.ts | 1 - src/routes/invite.ts | 3 +- src/routes/setup.ts | 29 +++++-- 8 files changed, 147 insertions(+), 13 deletions(-) rename src/controller/admin/management/{setting.ts => settingController.ts} (95%) create mode 100644 src/middleware/multer.ts diff --git a/src/controller/admin/management/setting.ts b/src/controller/admin/management/settingController.ts similarity index 95% rename from src/controller/admin/management/setting.ts rename to src/controller/admin/management/settingController.ts index 84a302f..784ffb3 100644 --- a/src/controller/admin/management/setting.ts +++ b/src/controller/admin/management/settingController.ts @@ -9,7 +9,7 @@ import { SettingString } from "../../../type/settingTypes"; * @returns {Promise<*>} */ export async function getSettings(req: Request, res: Response): Promise { - res.json(SettingHelper.getAllSettings()); + res.json({ ...SettingHelper.getAllSettings(), ["mail.password"]: undefined }); } /** diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index 769260f..a183cbc 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -35,7 +35,7 @@ export async function getInvites(req: Request, res: Response): Promise { * @param res {Response} Express res object * @returns {Promise<*>} */ -export async function inviteUser(req: Request, res: Response, isInvite: boolean = true): Promise { +export async function inviteUser(req: Request, res: Response, isSetup: boolean = false): Promise { let origin = req.headers.origin; let username = req.body.username; let mail = req.body.mail; @@ -71,7 +71,7 @@ export async function inviteUser(req: Request, res: Response, isInvite: boolean await MailHelper.sendMail( mail, `Email Bestätigung für Mitglieder Admin-Portal von ${SettingHelper.getSetting("club.name")}`, - `Öffne folgenden Link: ${origin}/${isInvite ? "invite" : "setup"}/verify?mail=${mail}&token=${token}` + `Öffne folgenden Link: ${origin}/${isSetup ? "setup" : "invite"}/verify?mail=${mail}&token=${token}` ); res.sendStatus(204); diff --git a/src/controller/setupController.ts b/src/controller/setupController.ts index f71194f..0221fdb 100644 --- a/src/controller/setupController.ts +++ b/src/controller/setupController.ts @@ -1,4 +1,5 @@ import { Request, Response } from "express"; +import SettingHelper from "../helpers/settingsHelper"; /** * @description Service is currently not configured @@ -9,3 +10,89 @@ import { Request, Response } from "express"; export async function isSetup(req: Request, res: Response): Promise { res.sendStatus(204); } + +/** + * @description set club identity + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setClubIdentity(req: Request, res: Response): Promise { + const name = req.body.name; + const imprint = req.body.imprint; + const privacy = req.body.privacy; + const website = req.body.website; + + if (name) { + await SettingHelper.setSetting("club.name", name); + } else { + await SettingHelper.resetSetting("club.name"); + } + + if (imprint) { + await SettingHelper.setSetting("club.imprint", imprint); + } else { + await SettingHelper.resetSetting("club.imprint"); + } + + if (privacy) { + await SettingHelper.setSetting("club.privacy", privacy); + } else { + await SettingHelper.resetSetting("club.privacy"); + } + + if (website) { + await SettingHelper.setSetting("club.website", website); + } else { + await SettingHelper.resetSetting("club.website"); + } + + res.sendStatus(204); +} + +/** + * @description set applucation icon and logo + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function uploadClubImages(req: Request, res: Response): Promise { + if (req.files && !Array.isArray(req.files) && req.files.icon) { + await SettingHelper.setSetting("club.icon", "configured"); + } else { + await SettingHelper.resetSetting("club.icon"); + } + + if (req.files && !Array.isArray(req.files) && req.files.logo) { + await SettingHelper.setSetting("club.logo", "configured"); + } else { + await SettingHelper.resetSetting("club.logo"); + } + + res.sendStatus(204); +} + +/** + * @description set app identity + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setAppIdentity(req: Request, res: Response): Promise { + const custom_login_message = req.body.custom_login_message; + const show_link_to_calendar = req.body.show_link_to_calendar; + + if (custom_login_message) { + await SettingHelper.setSetting("app.custom_login_message", custom_login_message); + } else { + await SettingHelper.resetSetting("app.custom_login_message"); + } + + if (show_link_to_calendar) { + await SettingHelper.setSetting("app.show_link_to_calendar", show_link_to_calendar); + } else { + await SettingHelper.resetSetting("app.show_link_to_calendar"); + } + + res.sendStatus(204); +} diff --git a/src/middleware/multer.ts b/src/middleware/multer.ts new file mode 100644 index 0000000..fbd6822 --- /dev/null +++ b/src/middleware/multer.ts @@ -0,0 +1,27 @@ +import multer from "multer"; +import { FileSystemHelper } from "../helpers/fileSystemHelper"; +import path from "path"; + +export const clubImageStorage = multer.diskStorage({ + destination: FileSystemHelper.formatPath("/app"), + filename: function (req, file, cb) { + const fileExtension = path.extname(file.originalname).toLowerCase(); + + if (file.fieldname === "icon") { + cb(null, "company-icon" + fileExtension); + } else if (file.fieldname === "logo") { + cb(null, "company-logo" + fileExtension); + } else { + cb(null, file.originalname); + } + }, +}); + +export const clubImageMulter = multer({ + storage: clubImageStorage, +}); + +export const clubImageUpload = clubImageMulter.fields([ + { name: "icon", maxCount: 1 }, + { name: "logo", maxCount: 1 }, +]); diff --git a/src/routes/admin/management/setting.ts b/src/routes/admin/management/setting.ts index e0ffe14..8f3ef61 100644 --- a/src/routes/admin/management/setting.ts +++ b/src/routes/admin/management/setting.ts @@ -1,6 +1,11 @@ import express, { Request, Response } from "express"; import PermissionHelper from "../../../helpers/permissionHelper"; -import { getSetting, getSettings, resetSetting, setSetting } from "../../../controller/admin/management/setting"; +import { + getSetting, + getSettings, + resetSetting, + setSetting, +} from "../../../controller/admin/management/settingController"; var router = express.Router({ mergeParams: true }); diff --git a/src/routes/admin/management/user.ts b/src/routes/admin/management/user.ts index 3a419d6..15d3e27 100644 --- a/src/routes/admin/management/user.ts +++ b/src/routes/admin/management/user.ts @@ -10,7 +10,6 @@ import { updateUserPermissions, updateUserRoles, } from "../../../controller/admin/management/userController"; -import { inviteUser } from "../../../controller/inviteController"; var router = express.Router({ mergeParams: true }); diff --git a/src/routes/invite.ts b/src/routes/invite.ts index 783ecef..5d8794e 100644 --- a/src/routes/invite.ts +++ b/src/routes/invite.ts @@ -1,6 +1,5 @@ import express from "express"; -import { isSetup } from "../controller/setupController"; -import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController"; +import { finishInvite, verifyInvite } from "../controller/inviteController"; import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper"; var router = express.Router({ mergeParams: true }); diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 36cd725..8f31a0b 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -1,7 +1,8 @@ import express from "express"; -import { isSetup } from "../controller/setupController"; +import { isSetup, setAppIdentity, setClubIdentity, uploadClubImages } from "../controller/setupController"; import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController"; import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper"; +import { clubImageUpload } from "../middleware/multer"; var router = express.Router({ mergeParams: true }); @@ -9,21 +10,37 @@ router.get("/", async (req, res) => { await isSetup(req, res); }); +router.post("/club", async (req, res) => { + await setClubIdentity(req, res); +}); + +router.post("/club/images", clubImageUpload, async (req, res) => { + await uploadClubImages(req, res); +}); + +router.post("/app", async (req, res) => { + await setAppIdentity(req, res); +}); + router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => { await verifyInvite(req, res); }); router.post( - "/", + "/me", ParamaterPassCheckHelper.requiredIncludedMiddleware(["username", "mail", "firstname", "lastname"]), async (req, res) => { - await inviteUser(req, res, false); + await inviteUser(req, res, true); } ); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishInvite(req, res, true); -}); +router.post( + "/finish", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), + async (req, res) => { + await finishInvite(req, res, true); + } +); /** * TODO: From ce9f621b8bef91758e8de691242baf600e72992c Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Fri, 25 Apr 2025 12:13:26 +0200 Subject: [PATCH 11/29] add mail type and validation --- package-lock.json | 30 +++++++++++++++++++ package.json | 2 ++ src/controller/publicController.ts | 8 ++--- src/controller/setupController.ts | 46 +++++++++++++++++++++++++++- src/helpers/convertHelper.ts | 36 ++++++++++++++++++++++ src/helpers/mailHelper.ts | 48 +++++++++++++++++++++++++++++- src/helpers/settingsHelper.ts | 19 +++++++----- src/index.ts | 2 +- src/middleware/multer.ts | 4 +-- src/routes/setup.ts | 12 +++++++- src/type/settingTypes.ts | 5 +++- 11 files changed, 193 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab0d1a9..f4314bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^16.4.5", + "email-check": "^1.1.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", @@ -45,6 +46,7 @@ }, "devDependencies": { "@types/cors": "^2.8.14", + "@types/email-check": "^1.1.3", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", @@ -806,6 +808,13 @@ "@types/node": "*" } }, + "node_modules/@types/email-check": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/email-check/-/email-check-1.1.3.tgz", + "integrity": "sha512-XgU2uxm8JjfK9e/CJg389b96XeLxJbUSCfe4hZxxwTu3XYT7A70punAWfpdppFHWPDl/qNtHC9vl3TmRHom+8w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", @@ -2167,6 +2176,18 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/email-check": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/email-check/-/email-check-1.1.0.tgz", + "integrity": "sha512-VoqdsHtP/Ct+Dsl9nJRlvVXhcHicWjmmp2KvLbyg+WovdUXihe8EbDKC5u+3SlBQIlh8RK1qFD5A4RCgTrW9Wg==", + "license": "MIT", + "dependencies": { + "js-promisify": "1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3143,6 +3164,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/js-promisify/-/js-promisify-1.0.1.tgz", + "integrity": "sha512-/IBrGxYbrmRWA+rLtHVSiX7R92NuVqc84aSWXReEjwcj7NchYf+Wy/ShAapCmMM5ev0mvD2IhWmZIDk/7f/utQ==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 02fbe5a..03e70f5 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^16.4.5", + "email-check": "^1.1.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", @@ -61,6 +62,7 @@ }, "devDependencies": { "@types/cors": "^2.8.14", + "@types/email-check": "^1.1.3", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", diff --git a/src/controller/publicController.ts b/src/controller/publicController.ts index a4a3483..b608b3b 100644 --- a/src/controller/publicController.ts +++ b/src/controller/publicController.ts @@ -165,9 +165,9 @@ export async function getApplicationFavicon(req: Request, res: Response): Promis "Cross-Origin-Resource-Policy": "cross-origin", "Cross-Origin-Embedder-Policy": "credentialless", "Timing-Allow-Origin": "*", + "Content-Type": "image/x-icon", }); - res.setHeader("Content-Type", "image/x-icon"); res.send(buffer); } @@ -178,8 +178,8 @@ export async function getApplicationFavicon(req: Request, res: Response): Promis * @returns {Promise<*>} */ export async function getApplicationIcon(req: Request, res: Response): Promise { - const width = parseInt((req.query.width as string) ?? ""); - const height = parseInt((req.query.height as string) ?? ""); + const width = parseInt((req.query.width as string) ?? "512"); + const height = parseInt((req.query.height as string) ?? "512"); let icon = FileSystemHelper.readAssetFile("icon.png", true); let setLogo = SettingHelper.getSetting("club.icon"); @@ -200,8 +200,8 @@ export async function getApplicationIcon(req: Request, res: Response): Promise await SettingHelper.resetSetting("app.custom_login_message"); } - if (show_link_to_calendar) { + if (show_link_to_calendar == false || show_link_to_calendar == true) { await SettingHelper.setSetting("app.show_link_to_calendar", show_link_to_calendar); } else { await SettingHelper.resetSetting("app.show_link_to_calendar"); @@ -96,3 +98,45 @@ export async function setAppIdentity(req: Request, res: Response): Promise res.sendStatus(204); } + +/** + * @description set app identity + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setMailConfig(req: Request, res: Response): Promise { + const mail = req.body.mail; + const username = req.body.username; + const password = req.body.password; + const host = req.body.host; + const port = req.body.port; + const secure = req.body.secure; + + let checkMail = await MailHelper.checkMail(mail); + + if (!checkMail) { + throw new InternalException("Mail is not valid"); + } + + let checkConfig = await MailHelper.verifyTransport({ + user: username, + password, + host, + port, + secure, + }); + + if (!checkConfig) { + throw new InternalException("Config is not valid"); + } + + await SettingHelper.setSetting("mail.email", mail); + await SettingHelper.setSetting("mail.username", username); + await SettingHelper.setSetting("mail.password", password); + await SettingHelper.setSetting("mail.host", host); + await SettingHelper.setSetting("mail.port", port); + await SettingHelper.setSetting("mail.secure", secure); + + res.sendStatus(204); +} diff --git a/src/helpers/convertHelper.ts b/src/helpers/convertHelper.ts index ec50a7b..8857834 100644 --- a/src/helpers/convertHelper.ts +++ b/src/helpers/convertHelper.ts @@ -60,6 +60,41 @@ export abstract class MsTypeConverter extends TypeConverter { } } +export abstract class EmailTypeConverter extends TypeConverter { + fromString(value: string): string { + return value; + } + toString(value: string): string { + return value; + } + validate(value: string): boolean { + var tester = + /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; + if (!value) return false; + + var emailParts = value.split("@"); + + if (emailParts.length !== 2) return false; + + var account = emailParts[0]; + var address = emailParts[1]; + + if (account.length > 64) return false; + else if (address.length > 255) return false; + + var domainParts = address.split("."); + + if ( + domainParts.some(function (part) { + return part.length > 63; + }) + ) + return false; + + return tester.test(value); + } +} + // Konkrete Implementierungen der Converter export class StringConverter extends StringTypeConverter {} export class LongStringConverter extends StringTypeConverter {} @@ -67,3 +102,4 @@ export class UrlConverter extends StringTypeConverter {} export class NumberConverter extends NumberTypeConverter {} export class BooleanConverter extends BooleanTypeConverter {} export class MsConverter extends MsTypeConverter {} +export class EmailConverter extends EmailTypeConverter {} diff --git a/src/helpers/mailHelper.ts b/src/helpers/mailHelper.ts index 1b0f728..b310bef 100644 --- a/src/helpers/mailHelper.ts +++ b/src/helpers/mailHelper.ts @@ -1,6 +1,7 @@ import { Transporter, createTransport, TransportOptions } from "nodemailer"; import { Attachment } from "nodemailer/lib/mailer"; import SettingHelper from "./settingsHelper"; +import emailCheck from "email-check"; export default abstract class MailHelper { private static transporter: Transporter; @@ -19,6 +20,51 @@ export default abstract class MailHelper { } as TransportOptions); } + static async verifyTransport({ + host, + port, + secure, + user, + password, + }: { + host: string; + port: number; + secure: boolean; + user: string; + password: string; + }): Promise { + let transport = createTransport({ + host, + port, + secure, + auth: { user, pass: password }, + }); + + return await transport + .verify() + .then(() => { + return true; + }) + .catch(() => { + return false; + }) + .finally(() => { + try { + transport?.close(); + } catch (error) {} + }); + } + + static async checkMail(mail: string): Promise { + return await emailCheck(mail) + .then((res) => { + return res; + }) + .catch((err) => { + return false; + }); + } + static initialize() { SettingHelper.onSettingTopicChanged("mail", () => { this.createTransport(); @@ -42,7 +88,7 @@ export default abstract class MailHelper { return new Promise((resolve, reject) => { this.transporter .sendMail({ - from: `"${SettingHelper.getSetting("club.name")}" <${SettingHelper.getSetting("mail.username")}>`, + from: `"${SettingHelper.getSetting("club.name")}" <${SettingHelper.getSetting("mail.email")}>`, to: target, subject, text: content, diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index 63fcf66..c8e27f6 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -5,6 +5,7 @@ import SettingService from "../service/management/settingService"; import { APPLICATION_SECRET } from "../env.defaults"; import { BooleanConverter, + EmailConverter, LongStringConverter, MsConverter, NumberConverter, @@ -27,6 +28,7 @@ export default abstract class SettingHelper { number: new NumberConverter(), boolean: new BooleanConverter(), ms: new MsConverter(), + email: new EmailConverter(), }; public static getAllSettings() { @@ -79,7 +81,7 @@ export default abstract class SettingHelper { finalValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); } - this.settings[key] = finalValue; + this.settings[key] = stringValue; const [topic, settingKey] = key.split(".") as [SettingTopic, string]; await SettingCommandHandler.create({ @@ -144,13 +146,14 @@ export default abstract class SettingHelper { } 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}`); - } - } + // do not encypt data here - data is only crypted towards database + // 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" diff --git a/src/index.ts b/src/index.ts index 13de2ee..2e248f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ import { dataSource } from "./data-source"; import BackupHelper from "./helpers/backupHelper"; import SettingHelper from "./helpers/settingsHelper"; dataSource.initialize().then(async () => { - if (await dataSource.createQueryRunner().hasTable("user")) { + if (false && (await dataSource.createQueryRunner().hasTable("user"))) { await BackupHelper.autoRestoreBackup().catch((err) => { console.log(`${new Date().toISOString()}: failed auto-restoring database`, err); }); diff --git a/src/middleware/multer.ts b/src/middleware/multer.ts index fbd6822..c9bad44 100644 --- a/src/middleware/multer.ts +++ b/src/middleware/multer.ts @@ -8,9 +8,9 @@ export const clubImageStorage = multer.diskStorage({ const fileExtension = path.extname(file.originalname).toLowerCase(); if (file.fieldname === "icon") { - cb(null, "company-icon" + fileExtension); + cb(null, "admin-icon" + fileExtension); } else if (file.fieldname === "logo") { - cb(null, "company-logo" + fileExtension); + cb(null, "admin-logo" + fileExtension); } else { cb(null, file.originalname); } diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 8f31a0b..2d69e07 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -1,5 +1,11 @@ import express from "express"; -import { isSetup, setAppIdentity, setClubIdentity, uploadClubImages } from "../controller/setupController"; +import { + isSetup, + setAppIdentity, + setClubIdentity, + setMailConfig, + uploadClubImages, +} from "../controller/setupController"; import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController"; import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper"; import { clubImageUpload } from "../middleware/multer"; @@ -22,6 +28,10 @@ router.post("/app", async (req, res) => { await setAppIdentity(req, res); }); +router.post("/mail", async (req, res) => { + await setMailConfig(req, res); +}); + router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => { await verifyInvite(req, res); }); diff --git a/src/type/settingTypes.ts b/src/type/settingTypes.ts index 8f131ee..568bc64 100644 --- a/src/type/settingTypes.ts +++ b/src/type/settingTypes.ts @@ -13,6 +13,7 @@ export type SettingString = | "session.jwt_expiration" | "session.refresh_expiration" | "session.pwa_refresh_expiration" + | "mail.email" | "mail.username" | "mail.password" | "mail.host" @@ -21,7 +22,7 @@ export type SettingString = | "backup.interval" | "backup.copies"; -export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boolean" | "url"; +export type SettingTypeAtom = "longstring" | "string" | "ms" | "number" | "boolean" | "url" | "email"; export type SettingType = SettingTypeAtom | `${SettingTypeAtom}/crypt` | `${SettingTypeAtom}/rand`; export type SettingValueMapping = { @@ -36,6 +37,7 @@ export type SettingValueMapping = { "session.jwt_expiration": ms.StringValue; "session.refresh_expiration": ms.StringValue; "session.pwa_refresh_expiration": ms.StringValue; + "mail.email": string; "mail.username": string; "mail.password": string; "mail.host": string; @@ -69,6 +71,7 @@ export const settingsType: SettingsSchema = { "session.jwt_expiration": { type: "ms", default: "15m" }, "session.refresh_expiration": { type: "ms", default: "1d" }, "session.pwa_refresh_expiration": { type: "ms", default: "5d" }, + "mail.email": { type: "email", optional: false }, "mail.username": { type: "string", optional: false }, "mail.password": { type: "string/crypt", optional: false }, "mail.host": { type: "url", optional: false }, From 99eafcb3527b45d7b30df475fd3513556ad5d84f Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Fri, 25 Apr 2025 12:21:25 +0200 Subject: [PATCH 12/29] add settings to backup --- src/helpers/backupHelper.ts | 18 +++++++++++++++++- src/index.ts | 2 +- src/routes/setup.ts | 16 +++++++--------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index 159df26..4534a30 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -18,7 +18,8 @@ export type BackupSection = | "query" | "template" | "user" - | "webapi"; + | "webapi" + | "settings"; export type BackupSectionRefered = { [key in BackupSection]?: Array; @@ -42,6 +43,7 @@ export default abstract class BackupHelper { { type: "template", orderOnInsert: 2, orderOnClear: 1 }, // INSERT depends on member com { type: "user", orderOnInsert: 1, orderOnClear: 1 }, { type: "webapi", orderOnInsert: 1, orderOnClear: 1 }, + { type: "settings", orderOnInsert: 1, orderOnClear: 1 }, ]; private static readonly backupSectionRefered: BackupSectionRefered = { @@ -76,6 +78,7 @@ export default abstract class BackupHelper { template: ["template", "template_usage"], user: ["user", "user_permission", "role", "role_permission", "invite"], webapi: ["webapi", "webapi_permission"], + settings: ["setting"], }; private static transactionManager: EntityManager; @@ -220,6 +223,8 @@ export default abstract class BackupHelper { return await this.getUser(collectIds); case "webapi": return await this.getWebapi(); + case "settings": + return await this.getSettings(); default: return []; } @@ -460,6 +465,13 @@ export default abstract class BackupHelper { .addSelect(["permissions.permission"]) .getMany(); } + private static async getSettings(): Promise> { + return await dataSource + .getRepository("setting") + .createQueryBuilder("setting") + .select(["setting.topic", "setting.key", "setting.value"]) + .getMany(); + } private static async setSectionData( section: BackupSection, @@ -476,6 +488,7 @@ export default abstract class BackupHelper { if (section == "template" && !Array.isArray(data)) await this.setTemplate(data); if (section == "user" && !Array.isArray(data)) await this.setUser(data); if (section == "webapi" && Array.isArray(data)) await this.setWebapi(data); + if (section == "settings" && Array.isArray(data)) await this.setSettings(data); } private static async setMemberData(data: Array): Promise { @@ -810,4 +823,7 @@ export default abstract class BackupHelper { private static async setWebapi(data: Array): Promise { await this.transactionManager.getRepository("webapi").save(data); } + private static async setSettings(data: Array): Promise { + await this.transactionManager.getRepository("setting").save(data); + } } diff --git a/src/index.ts b/src/index.ts index 2e248f0..13de2ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ import { dataSource } from "./data-source"; import BackupHelper from "./helpers/backupHelper"; import SettingHelper from "./helpers/settingsHelper"; dataSource.initialize().then(async () => { - if (false && (await dataSource.createQueryRunner().hasTable("user"))) { + if (await dataSource.createQueryRunner().hasTable("user")) { await BackupHelper.autoRestoreBackup().catch((err) => { console.log(`${new Date().toISOString()}: failed auto-restoring database`, err); }); diff --git a/src/routes/setup.ts b/src/routes/setup.ts index 2d69e07..4830d4a 100644 --- a/src/routes/setup.ts +++ b/src/routes/setup.ts @@ -28,9 +28,13 @@ router.post("/app", async (req, res) => { await setAppIdentity(req, res); }); -router.post("/mail", async (req, res) => { - await setMailConfig(req, res); -}); +router.post( + "/mail", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "username", "password", "host", "port", "secure"]), + async (req, res) => { + await setMailConfig(req, res); + } +); router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => { await verifyInvite(req, res); @@ -52,10 +56,4 @@ router.post( } ); -/** - * TODO: - * set basic settings like clubname ... - * enable upload of images and icons: transform pwa-> 512x512 png / 48x48 ico - */ - export default router; From 964af829041c555a29c80619cb4ebc3013d084b9 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 26 Apr 2025 09:16:46 +0200 Subject: [PATCH 13/29] update to typesafe all-settings provider --- src/helpers/settingsHelper.ts | 9 +++++++-- src/type/settingTypes.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index c8e27f6..554e0d9 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -31,8 +31,13 @@ export default abstract class SettingHelper { email: new EmailConverter(), }; - public static getAllSettings() { - return cloneDeep(this.settings); + public static getAllSettings(): { [key in SettingString]: SettingValueMapping[key] } { + return Object.keys(settingsType).reduce((acc, key) => { + const typedKey = key as SettingString; + //@ts-expect-error + acc[typedKey] = this.getSetting(typedKey); + return acc; + }, {} as { [key in SettingString]: SettingValueMapping[key] }); } /** diff --git a/src/type/settingTypes.ts b/src/type/settingTypes.ts index 568bc64..ff6e728 100644 --- a/src/type/settingTypes.ts +++ b/src/type/settingTypes.ts @@ -50,7 +50,7 @@ export type SettingValueMapping = { // Typsicherer Zugriff auf Settings export type SettingDefinition = { type: T; - default?: string | number | boolean; + default?: string | number | boolean | ms.StringValue; optional?: boolean; min?: T extends "number" | `number/crypt` | `number/rand` ? number : never; }; From be52a51055d456438d5c854f2763c1bdc305c274 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Tue, 29 Apr 2025 13:19:12 +0200 Subject: [PATCH 14/29] bulk setting change and image update --- .../admin/management/settingController.ts | 40 ++++++++++++++++++- src/helpers/settingsHelper.ts | 6 ++- src/routes/admin/management/setting.ts | 20 ++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/controller/admin/management/settingController.ts b/src/controller/admin/management/settingController.ts index 784ffb3..10d5dea 100644 --- a/src/controller/admin/management/settingController.ts +++ b/src/controller/admin/management/settingController.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import SettingHelper from "../../../helpers/settingsHelper"; -import { SettingString } from "../../../type/settingTypes"; +import { SettingString, SettingValueMapping } from "../../../type/settingTypes"; /** * @description get All settings @@ -45,6 +45,44 @@ export async function setSetting(req: Request, res: Response): Promise { res.sendStatus(204); } +/** + * @description set settings + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setSettings(req: Request, res: Response): Promise { + let setting = req.body as Array<{ key: K; value: SettingValueMapping[K] }>; + + for (let entry of setting) { + SettingHelper.setSetting(entry.key, entry.value); + } + + res.sendStatus(204); +} + +/** + * @description set setting + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function setImages(req: Request, res: Response): Promise { + if (req.files && !Array.isArray(req.files) && req.files.icon) { + await SettingHelper.setSetting("club.icon", "configured"); + } else { + await SettingHelper.resetSetting("club.icon"); + } + + if (req.files && !Array.isArray(req.files) && req.files.logo) { + await SettingHelper.setSetting("club.logo", "configured"); + } else { + await SettingHelper.resetSetting("club.logo"); + } + + res.sendStatus(204); +} + /** * @description reset setting * @param req {Request} Express req object diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index 554e0d9..adddcc7 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -68,11 +68,15 @@ export default abstract class SettingHelper { /** * Sets a setting + * undefined value leads to reset of key * @param key The key of the setting * @param value The value to set */ public static async setSetting(key: K, value: SettingValueMapping[K]): Promise { - if (value === undefined || value === null) return; + if (value === undefined || value === null) { + if (key != "mail.password") this.resetSetting(key); + return; + } const stringValue = String(value); diff --git a/src/routes/admin/management/setting.ts b/src/routes/admin/management/setting.ts index 8f3ef61..c284a32 100644 --- a/src/routes/admin/management/setting.ts +++ b/src/routes/admin/management/setting.ts @@ -4,8 +4,11 @@ import { getSetting, getSettings, resetSetting, + setImages, setSetting, + setSettings, } from "../../../controller/admin/management/settingController"; +import { clubImageUpload } from "../../../middleware/multer"; var router = express.Router({ mergeParams: true }); @@ -25,6 +28,23 @@ router.put( } ); +router.put( + "/multi", + PermissionHelper.passCheckMiddleware("create", "management", "setting"), + async (req: Request, res: Response) => { + await setSettings(req, res); + } +); + +router.put( + "/images", + PermissionHelper.passCheckMiddleware("create", "management", "setting"), + clubImageUpload, + async (req: Request, res: Response) => { + await setImages(req, res); + } +); + router.delete( "/:setting", PermissionHelper.passCheckMiddleware("delete", "management", "setting"), From 5b3a72820adfb6e3cf300e786b19fb854aa4e613 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Wed, 30 Apr 2025 10:43:31 +0200 Subject: [PATCH 15/29] image upload and keep if not changed --- .../admin/management/settingController.ts | 4 ++-- src/controller/publicController.ts | 14 +++++++------- src/middleware/multer.ts | 8 ++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/controller/admin/management/settingController.ts b/src/controller/admin/management/settingController.ts index 10d5dea..1a82b9a 100644 --- a/src/controller/admin/management/settingController.ts +++ b/src/controller/admin/management/settingController.ts @@ -70,13 +70,13 @@ export async function setSettings(req: Request, res: Re export async function setImages(req: Request, res: Response): Promise { if (req.files && !Array.isArray(req.files) && req.files.icon) { await SettingHelper.setSetting("club.icon", "configured"); - } else { + } else if (req.body["club.icon"] != "keep") { await SettingHelper.resetSetting("club.icon"); } if (req.files && !Array.isArray(req.files) && req.files.logo) { await SettingHelper.setSetting("club.logo", "configured"); - } else { + } else if (req.body["club.logo"] != "keep") { await SettingHelper.resetSetting("club.logo"); } diff --git a/src/controller/publicController.ts b/src/controller/publicController.ts index b608b3b..e48c008 100644 --- a/src/controller/publicController.ts +++ b/src/controller/publicController.ts @@ -130,10 +130,10 @@ export async function getApplicationLogo(req: Request, res: Response): Promise Date: Wed, 30 Apr 2025 11:38:04 +0200 Subject: [PATCH 16/29] check mail config on mail value change --- .../admin/management/settingController.ts | 24 +++++++++++-- src/helpers/settingsHelper.ts | 36 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/controller/admin/management/settingController.ts b/src/controller/admin/management/settingController.ts index 1a82b9a..643ba44 100644 --- a/src/controller/admin/management/settingController.ts +++ b/src/controller/admin/management/settingController.ts @@ -1,6 +1,8 @@ import { Request, Response } from "express"; import SettingHelper from "../../../helpers/settingsHelper"; import { SettingString, SettingValueMapping } from "../../../type/settingTypes"; +import MailHelper from "../../../helpers/mailHelper"; +import InternalException from "../../../exceptions/internalException"; /** * @description get All settings @@ -40,7 +42,15 @@ export async function setSetting(req: Request, res: Response): Promise { let setting = req.body.setting as SettingString; let value = req.body.value as string; - SettingHelper.setSetting(setting, value); + await SettingHelper.checkMail([{ key: setting, value }]).catch((err) => { + if (err == "mail") { + throw new InternalException("Mail is not valid"); + } else { + throw new InternalException("Config is not valid"); + } + }); + + await SettingHelper.setSetting(setting, value); res.sendStatus(204); } @@ -54,8 +64,16 @@ export async function setSetting(req: Request, res: Response): Promise { export async function setSettings(req: Request, res: Response): Promise { let setting = req.body as Array<{ key: K; value: SettingValueMapping[K] }>; + await SettingHelper.checkMail(setting).catch((err) => { + if (err == "mail") { + throw new InternalException("Mail is not valid"); + } else { + throw new InternalException("Config is not valid"); + } + }); + for (let entry of setting) { - SettingHelper.setSetting(entry.key, entry.value); + await SettingHelper.setSetting(entry.key, entry.value); } res.sendStatus(204); @@ -92,7 +110,7 @@ export async function setImages(req: Request, res: Response): Promise { export async function resetSetting(req: Request, res: Response): Promise { let setting = req.params.setting as SettingString; - SettingHelper.resetSetting(setting); + await SettingHelper.resetSetting(setting); res.sendStatus(204); } diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index adddcc7..15b78df 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -14,6 +14,9 @@ import { UrlConverter, } from "./convertHelper"; import cloneDeep from "lodash.clonedeep"; +import { rejects } from "assert"; +import InternalException from "../exceptions/internalException"; +import MailHelper from "./mailHelper"; export default abstract class SettingHelper { private static settings: { [key in SettingString]?: string } = {}; @@ -141,6 +144,39 @@ export default abstract class SettingHelper { } } + public static async checkMail( + setting: Array<{ key: K; value: SettingValueMapping[K] }> + ): Promise { + return new Promise(async (resolve, reject) => { + if (setting.some((t) => t.key == "mail.email" && t.value != undefined)) { + let emailValue = setting.find((t) => t.key == "mail.email").value as string; + let checkMail = await MailHelper.checkMail(emailValue); + if (!checkMail) { + return reject("mail"); + } + } + + if (setting.some((t) => t.key.startsWith("mail"))) { + let checkConfig = await MailHelper.verifyTransport({ + user: + (setting.find((t) => t.key == "mail.username").value as string) ?? + SettingHelper.getSetting("mail.username"), + password: + (setting.find((t) => t.key == "mail.password").value as string) ?? + SettingHelper.getSetting("mail.password"), + host: (setting.find((t) => t.key == "mail.host").value as string) ?? SettingHelper.getSetting("mail.host"), + port: (setting.find((t) => t.key == "mail.port").value as number) ?? SettingHelper.getSetting("mail.port"), + secure: + (setting.find((t) => t.key == "mail.secure").value as boolean) ?? SettingHelper.getSetting("mail.secure"), + }); + if (!checkConfig) { + return reject("Config is not valid"); + } + } + resolve(); + }); + } + /** * Validates a setting * @param key The key of the setting From a6229bb77c86a0e716928fbd74f0cb7cbae6ffc2 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Wed, 30 Apr 2025 12:22:38 +0200 Subject: [PATCH 17/29] enhance: provide latest inserted internal Id --- src/controller/admin/club/memberController.ts | 12 +++++++ src/data-source.ts | 2 ++ src/entity/club/member/member.ts | 15 ++++++++- .../1746006549262-memberCreatedAt.ts | 21 ++++++++++++ src/routes/admin/club/member.ts | 5 +++ src/service/club/member/memberService.ts | 33 ++++++++++++++++--- 6 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 src/migrations/1746006549262-memberCreatedAt.ts diff --git a/src/controller/admin/club/memberController.ts b/src/controller/admin/club/memberController.ts index 9ef0bf4..c0b2cd7 100644 --- a/src/controller/admin/club/memberController.ts +++ b/src/controller/admin/club/memberController.ts @@ -92,6 +92,18 @@ export async function getMembersByIds(req: Request, res: Response): Promise }); } +/** + * @description get member latest inserted InternalId + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getMemberLastInternalId(req: Request, res: Response): Promise { + let latest = await MemberService.getLatestInternalId(); + + res.send(latest); +} + /** * @description get member by id * @param req {Request} Express req object diff --git a/src/data-source.ts b/src/data-source.ts index 37f957d..2266f19 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -51,6 +51,7 @@ import { TemplatesAndProtocolSort1742549956787 } from "./migrations/174254995678 import { QueryToUUID1742922178643 } from "./migrations/1742922178643-queryToUUID"; import { NewsletterColumnType1744351418751 } from "./migrations/1744351418751-newsletterColumnType"; import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpdatedAt"; +import { MemberCreatedAt1746006549262 } from "./migrations/1746006549262-memberCreatedAt"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -111,6 +112,7 @@ const dataSource = new DataSource({ QueryToUUID1742922178643, NewsletterColumnType1744351418751, QueryUpdatedAt1744795756230, + MemberCreatedAt1746006549262, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/club/member/member.ts b/src/entity/club/member/member.ts index 1678e39..e724155 100644 --- a/src/entity/club/member/member.ts +++ b/src/entity/club/member/member.ts @@ -1,4 +1,14 @@ -import { Column, ColumnType, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryColumn } from "typeorm"; +import { + Column, + ColumnType, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + OneToOne, + PrimaryColumn, +} from "typeorm"; import { membership } from "./membership"; import { memberAwards } from "./memberAwards"; import { memberQualifications } from "./memberQualifications"; @@ -30,6 +40,9 @@ export class member { @Column() salutationId: number; + @CreateDateColumn() + createdAt: Date; + @ManyToOne(() => salutation, (salutation) => salutation.members, { nullable: false, onDelete: "RESTRICT", diff --git a/src/migrations/1746006549262-memberCreatedAt.ts b/src/migrations/1746006549262-memberCreatedAt.ts new file mode 100644 index 0000000..08f7669 --- /dev/null +++ b/src/migrations/1746006549262-memberCreatedAt.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; +import { getTypeByORM, getDefaultByORM } from "./ormHelper"; + +export class MemberCreatedAt1746006549262 implements MigrationInterface { + name = "MemberCreatedAt1746006549262"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + "member", + new TableColumn({ + name: "createdAt", + ...getTypeByORM("datetime", false, 6), + default: getDefaultByORM("currentTimestamp", 6), + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn("member", "createdAt"); + } +} diff --git a/src/routes/admin/club/member.ts b/src/routes/admin/club/member.ts index efd6e05..804b595 100644 --- a/src/routes/admin/club/member.ts +++ b/src/routes/admin/club/member.ts @@ -20,6 +20,7 @@ import { getExecutivePositionByMemberAndRecord, getExecutivePositionsByMember, getMemberById, + getMemberLastInternalId, getMemberPrintoutById, getMembersByIds, getMembershipByMemberAndRecord, @@ -43,6 +44,10 @@ router.get("/", async (req: Request, res: Response) => { await getAllMembers(req, res); }); +router.get("/last/internalId", async (req: Request, res: Response) => { + await getMemberLastInternalId(req, res); +}); + router.post("/ids", async (req: Request, res: Response) => { await getMembersByIds(req, res); }); diff --git a/src/service/club/member/memberService.ts b/src/service/club/member/memberService.ts index 3eae4e6..6480d58 100644 --- a/src/service/club/member/memberService.ts +++ b/src/service/club/member/memberService.ts @@ -1,4 +1,4 @@ -import { Brackets, Like, SelectQueryBuilder } from "typeorm"; +import { Brackets, Like, Not, SelectQueryBuilder } from "typeorm"; import { dataSource } from "../../../data-source"; import { member } from "../../../entity/club/member/member"; import { membership } from "../../../entity/club/member/membership"; @@ -31,9 +31,12 @@ export default abstract class MemberService { let searchBits = search.split(" "); if (searchBits.length < 2) { - query = query.where(`member.firstname LIKE :searchQuery OR member.lastname LIKE :searchQuery`, { - searchQuery: `%${searchBits[0]}%`, - }); + query = query.where( + `member.firstname LIKE :searchQuery OR member.lastname LIKE :searchQuery OR member.internalId LIKE :searchQuery`, + { + searchQuery: `%${searchBits[0]}%`, + } + ); } else { searchBits .flatMap((v, i) => searchBits.slice(i + 1).map((w) => [v, w])) @@ -157,6 +160,28 @@ export default abstract class MemberService { }); } + /** + * @description get latest inserted memberId + * @returns {Promise} + */ + static async getLatestInternalId(): Promise { + return await dataSource + .getRepository(member) + .createQueryBuilder("member") + .where("member.internalId IS NOT NULL") + .andWhere({ internalId: Not("") }) + .orderBy("member.createdAt", "DESC") + .addOrderBy("member.internalId", "DESC") + .limit(1) + .getOne() + .then((res) => { + return res?.internalId ?? ""; + }) + .catch((err) => { + throw new DatabaseActionException("SELECT", "memberId", err); + }); + } + /** * @description apply member joins to query * @returns {SelectQueryBuilder} From 0ea780dd519b122eaeb2423c3df901fcd812e989 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Thu, 1 May 2025 17:36:48 +0200 Subject: [PATCH 18/29] change email validation to regex only --- package-lock.json | 56 ++++++++++++++--------------------- package.json | 6 ++-- src/helpers/mailHelper.ts | 20 +++++++------ src/helpers/settingsHelper.ts | 7 +---- 4 files changed, 37 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4314bf..9004e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^16.4.5", - "email-check": "^1.1.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", @@ -42,11 +41,11 @@ "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", "typeorm": "^0.3.20", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "validator": "^13.15.0" }, "devDependencies": { "@types/cors": "^2.8.14", - "@types/email-check": "^1.1.3", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", @@ -63,6 +62,7 @@ "@types/qrcode": "~1.5.5", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", + "@types/validator": "^13.15.0", "ts-node": "10.9.2", "typescript": "^5.8.3" } @@ -808,13 +808,6 @@ "@types/node": "*" } }, - "node_modules/@types/email-check": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/email-check/-/email-check-1.1.3.tgz", - "integrity": "sha512-XgU2uxm8JjfK9e/CJg389b96XeLxJbUSCfe4hZxxwTu3XYT7A70punAWfpdppFHWPDl/qNtHC9vl3TmRHom+8w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/express": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", @@ -1106,6 +1099,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-nh7nrWhLr6CBq9ldtw0wx+z9wKnnv/uTVLA9g/3/TcOYxbpOSZE+MhKPmWqU+K0NvThjhv12uD8MuqijB0WzEA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2176,18 +2176,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/email-check": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/email-check/-/email-check-1.1.0.tgz", - "integrity": "sha512-VoqdsHtP/Ct+Dsl9nJRlvVXhcHicWjmmp2KvLbyg+WovdUXihe8EbDKC5u+3SlBQIlh8RK1qFD5A4RCgTrW9Wg==", - "license": "MIT", - "dependencies": { - "js-promisify": "1.0.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2551,6 +2539,15 @@ "node": ">= 8.0.0" } }, + "node_modules/express-validator/node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -3164,15 +3161,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/js-promisify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/js-promisify/-/js-promisify-1.0.1.tgz", - "integrity": "sha512-/IBrGxYbrmRWA+rLtHVSiX7R92NuVqc84aSWXReEjwcj7NchYf+Wy/ShAapCmMM5ev0mvD2IhWmZIDk/7f/utQ==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6032,9 +6020,9 @@ "license": "MIT" }, "node_modules/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", "license": "MIT", "engines": { "node": ">= 0.10" diff --git a/package.json b/package.json index 03e70f5..bbda6c1 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "cors": "^2.8.5", "crypto": "^1.0.1", "dotenv": "^16.4.5", - "email-check": "^1.1.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", @@ -58,11 +57,11 @@ "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", "typeorm": "^0.3.20", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "validator": "^13.15.0" }, "devDependencies": { "@types/cors": "^2.8.14", - "@types/email-check": "^1.1.3", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", "@types/jsonwebtoken": "^9.0.6", @@ -79,6 +78,7 @@ "@types/qrcode": "~1.5.5", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", + "@types/validator": "^13.15.0", "ts-node": "10.9.2", "typescript": "^5.8.3" } diff --git a/src/helpers/mailHelper.ts b/src/helpers/mailHelper.ts index b310bef..ff778b9 100644 --- a/src/helpers/mailHelper.ts +++ b/src/helpers/mailHelper.ts @@ -1,7 +1,7 @@ import { Transporter, createTransport, TransportOptions } from "nodemailer"; import { Attachment } from "nodemailer/lib/mailer"; import SettingHelper from "./settingsHelper"; -import emailCheck from "email-check"; +import validator from "validator"; export default abstract class MailHelper { private static transporter: Transporter; @@ -45,7 +45,8 @@ export default abstract class MailHelper { .then(() => { return true; }) - .catch(() => { + .catch((err) => { + console.log(err); return false; }) .finally(() => { @@ -56,13 +57,14 @@ export default abstract class MailHelper { } static async checkMail(mail: string): Promise { - return await emailCheck(mail) - .then((res) => { - return res; - }) - .catch((err) => { - return false; - }); + return validator.isEmail(mail); + // return await emailCheck(mail) + // .then((res) => { + // return res; + // }) + // .catch((err) => { + // return false; + // }); } static initialize() { diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index 15b78df..eee81b7 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -56,17 +56,12 @@ export default abstract class SettingHelper { return rawValue as unknown as SettingValueMapping[K]; } - let processedValue = rawValue; - if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { - processedValue = CodingHelper.decrypt(APPLICATION_SECRET, processedValue); - } - 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]; + return this.converters[baseType].fromString(rawValue) as unknown as SettingValueMapping[K]; } /** From 6c0ea0b11cfc017b3e5efcb4f4585009963b8020 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Thu, 1 May 2025 17:44:01 +0200 Subject: [PATCH 19/29] change convert Helper Mail to validator package --- src/helpers/convertHelper.ts | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/src/helpers/convertHelper.ts b/src/helpers/convertHelper.ts index 8857834..d797126 100644 --- a/src/helpers/convertHelper.ts +++ b/src/helpers/convertHelper.ts @@ -1,4 +1,5 @@ import ms from "ms"; +import validator from "validator"; export abstract class TypeConverter { abstract fromString(value: string): T; @@ -68,30 +69,7 @@ export abstract class EmailTypeConverter extends TypeConverter { return value; } validate(value: string): boolean { - var tester = - /^[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; - if (!value) return false; - - var emailParts = value.split("@"); - - if (emailParts.length !== 2) return false; - - var account = emailParts[0]; - var address = emailParts[1]; - - if (account.length > 64) return false; - else if (address.length > 255) return false; - - var domainParts = address.split("."); - - if ( - domainParts.some(function (part) { - return part.length > 63; - }) - ) - return false; - - return tester.test(value); + return validator.isEmail(value); } } From 7dac58d958511023161394b37d5c2c5232d6c166 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Thu, 1 May 2025 17:47:14 +0200 Subject: [PATCH 20/29] remove unused code in settings helper --- src/helpers/settingsHelper.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index eee81b7..b90cd07 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -185,27 +185,17 @@ export default abstract class SettingHelper { return; } - let processedValue = valueToCheck; - // do not encypt data here - data is only crypted towards database - // 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)) { + if (!this.converters[baseType].validate(valueToCheck)) { throw new Error(`Invalid value for ${key} of type ${baseType}`); } if (baseType === "number" && settingType.min !== undefined) { - const numValue = Number(processedValue); + const numValue = Number(valueToCheck); if (numValue < settingType.min) { throw new Error(`${key} must be at least ${settingType.min}`); } From c35b99e0c407096d4e7f9c8e5c3075d15fda29d1 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 3 May 2025 09:08:15 +0200 Subject: [PATCH 21/29] fix: migrations and discontinue sqlite --- .env.example | 5 +--- .../setting/settingCommandHandler.ts | 5 +--- src/data-source.ts | 2 ++ src/env.defaults.ts | 12 +++----- .../1745059495808-settingsFromEnv.ts | 17 ----------- .../1745059495808-settingsFromEnv_set.ts | 29 +++++++++++++++++++ 6 files changed, 37 insertions(+), 33 deletions(-) create mode 100644 src/migrations/1745059495808-settingsFromEnv_set.ts diff --git a/.env.example b/.env.example index c1fe1dc..55d9653 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -DB_TYPE = (mysql|sqlite|postgres) # default ist mysql +DB_TYPE = (mysql|postgres) # default ist mysql ## BSP für mysql DB_PORT = 3306 @@ -14,9 +14,6 @@ DB_NAME = database_name DB_USERNAME = database_username DB_PASSWORD = database_password -## BSP für sqlite -DB_HOST = filename.db - ## Dev only SERVER_PORT = portnumber diff --git a/src/command/management/setting/settingCommandHandler.ts b/src/command/management/setting/settingCommandHandler.ts index 1d50776..9890f35 100644 --- a/src/command/management/setting/settingCommandHandler.ts +++ b/src/command/management/setting/settingCommandHandler.ts @@ -1,7 +1,6 @@ import { dataSource } from "../../../data-source"; import { setting } from "../../../entity/management/setting"; import DatabaseActionException from "../../../exceptions/databaseActionException"; -import { StringHelper } from "../../../helpers/stringHelper"; import { CreateOrUpdateSettingCommand, DeleteSettingCommand } from "./settingCommand"; export default abstract class SettingCommandHandler { @@ -11,8 +10,6 @@ export default abstract class SettingCommandHandler { * @returns {Promise} */ static async create(createSetting: CreateOrUpdateSettingCommand): Promise { - const token = StringHelper.random(32); - return await dataSource .createQueryBuilder() .insert() @@ -25,7 +22,7 @@ export default abstract class SettingCommandHandler { .orUpdate(["value"], ["topic", "key"]) .execute() .then((result) => { - return token; + return createSetting.value; }) .catch((err) => { throw new DatabaseActionException("CREATE OR UPDATE", "setting", err); diff --git a/src/data-source.ts b/src/data-source.ts index d35eaa6..6d40b6c 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -54,6 +54,7 @@ import { NewsletterColumnType1744351418751 } from "./migrations/1744351418751-ne import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpdatedAt"; import { SettingsFromEnv1745059495808 } from "./migrations/1745059495808-settingsFromEnv"; import { MemberCreatedAt1746006549262 } from "./migrations/1746006549262-memberCreatedAt"; +import { UserLoginRoutine1746252454922 } from "./migrations/1746252454922-UserLoginRoutine"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -116,6 +117,7 @@ const dataSource = new DataSource({ NewsletterColumnType1744351418751, QueryUpdatedAt1744795756230, SettingsFromEnv1745059495808, + SettingsFromEnv_SET1745059495808, MemberCreatedAt1746006549262, ], migrationsRun: true, diff --git a/src/env.defaults.ts b/src/env.defaults.ts index f7bc48d..7bdcb4b 100644 --- a/src/env.defaults.ts +++ b/src/env.defaults.ts @@ -39,15 +39,11 @@ export const TRUST_PROXY = ((): Array | string | boolean | number | null })(); export function configCheck() { - if (DB_TYPE != "mysql" && DB_TYPE != "sqlite" && DB_TYPE != "postgres") - throw new Error("set valid value to DB_TYPE (mysql|sqlite|postgres)"); - if ((DB_HOST == "" || typeof DB_HOST != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_HOST"); + if (DB_TYPE != "mysql" && DB_TYPE != "postgres") throw new Error("set valid value to DB_TYPE (mysql|postgres)"); + if (DB_HOST == "" || typeof DB_HOST != "string") throw new Error("set valid value to DB_HOST"); if (DB_NAME == "" || typeof DB_NAME != "string") throw new Error("set valid value to DB_NAME"); - if ((DB_USERNAME == "" || typeof DB_USERNAME != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_USERNAME"); - if ((DB_PASSWORD == "" || typeof DB_PASSWORD != "string") && DB_TYPE != "sqlite") - throw new Error("set valid value to DB_PASSWORD"); + if (DB_USERNAME == "" || typeof DB_USERNAME != "string") throw new Error("set valid value to DB_USERNAME"); + if (DB_PASSWORD == "" || typeof DB_PASSWORD != "string") throw new Error("set valid value to DB_PASSWORD"); if (isNaN(SERVER_PORT)) throw new Error("set valid numeric value to SERVER_PORT"); diff --git a/src/migrations/1745059495808-settingsFromEnv.ts b/src/migrations/1745059495808-settingsFromEnv.ts index 6028f1d..1faee5e 100644 --- a/src/migrations/1745059495808-settingsFromEnv.ts +++ b/src/migrations/1745059495808-settingsFromEnv.ts @@ -8,23 +8,6 @@ export class SettingsFromEnv1745059495808 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable(setting_table, true, true, true); - - //transfer settings of env to database - await SettingHelper.setSetting("club.name", process.env.CLUB_NAME); - await SettingHelper.setSetting("club.website", process.env.CLUB_WEBSITE); - await SettingHelper.setSetting("session.jwt_expiration", process.env.JWT_EXPIRATION as ms.StringValue); - await SettingHelper.setSetting("session.refresh_expiration", process.env.REFRESH_EXPIRATION as ms.StringValue); - await SettingHelper.setSetting( - "session.pwa_refresh_expiration", - process.env.PWA_REFRESH_EXPIRATION as ms.StringValue - ); - await SettingHelper.setSetting("mail.username", process.env.MAIL_USERNAME); - await SettingHelper.setSetting("mail.password", process.env.MAIL_PASSWORD); - await SettingHelper.setSetting("mail.host", process.env.MAIL_HOST); - await SettingHelper.setSetting("mail.port", Number(process.env.MAIL_PORT)); - await SettingHelper.setSetting("mail.secure", Boolean(process.env.MAIL_SECURE)); - await SettingHelper.setSetting("backup.interval", Number(process.env.BACKUP_INTERVAL)); - await SettingHelper.setSetting("backup.copies", Number(process.env.BACKUP_COPIES)); } public async down(queryRunner: QueryRunner): Promise { diff --git a/src/migrations/1745059495808-settingsFromEnv_set.ts b/src/migrations/1745059495808-settingsFromEnv_set.ts new file mode 100644 index 0000000..c99c7b0 --- /dev/null +++ b/src/migrations/1745059495808-settingsFromEnv_set.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { setting_table } from "./baseSchemaTables/admin"; +import SettingHelper from "../helpers/settingsHelper"; +import ms from "ms"; + +export class SettingsFromEnv_SET1745059495808 implements MigrationInterface { + name = "SettingsFromEnv_SET1745059495808"; + + public async up(queryRunner: QueryRunner): Promise { + // transfer settings of env to database + await SettingHelper.setSetting("club.name", process.env.CLUB_NAME); + await SettingHelper.setSetting("club.website", process.env.CLUB_WEBSITE); + await SettingHelper.setSetting("session.jwt_expiration", process.env.JWT_EXPIRATION as ms.StringValue); + await SettingHelper.setSetting("session.refresh_expiration", process.env.REFRESH_EXPIRATION as ms.StringValue); + await SettingHelper.setSetting( + "session.pwa_refresh_expiration", + process.env.PWA_REFRESH_EXPIRATION as ms.StringValue + ); + await SettingHelper.setSetting("mail.username", process.env.MAIL_USERNAME); + await SettingHelper.setSetting("mail.password", process.env.MAIL_PASSWORD); + await SettingHelper.setSetting("mail.host", process.env.MAIL_HOST); + await SettingHelper.setSetting("mail.port", Number(process.env.MAIL_PORT ?? "578")); + await SettingHelper.setSetting("mail.secure", Boolean(process.env.MAIL_SECURE ?? "false")); + await SettingHelper.setSetting("backup.interval", Number(process.env.BACKUP_INTERVAL ?? "1")); + await SettingHelper.setSetting("backup.copies", Number(process.env.BACKUP_COPIES ?? "7")); + } + + public async down(queryRunner: QueryRunner): Promise {} +} From 03a5bb3592969bf2be886e26677ae5d383589665 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sat, 3 May 2025 09:09:52 +0200 Subject: [PATCH 22/29] change user model to login routine --- src/data-source.ts | 2 ++ src/entity/management/user.ts | 19 +++++++++-- src/enums/loginRoutineEnum.ts | 4 +++ src/helpers/backupHelper.ts | 2 ++ .../1746252454922-UserLoginRoutine.ts | 32 +++++++++++++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/enums/loginRoutineEnum.ts create mode 100644 src/migrations/1746252454922-UserLoginRoutine.ts diff --git a/src/data-source.ts b/src/data-source.ts index 6d40b6c..dad9827 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -55,6 +55,7 @@ import { QueryUpdatedAt1744795756230 } from "./migrations/1744795756230-QueryUpd import { SettingsFromEnv1745059495808 } from "./migrations/1745059495808-settingsFromEnv"; import { MemberCreatedAt1746006549262 } from "./migrations/1746006549262-memberCreatedAt"; import { UserLoginRoutine1746252454922 } from "./migrations/1746252454922-UserLoginRoutine"; +import { SettingsFromEnv_SET1745059495808 } from "./migrations/1745059495808-settingsFromEnv_set"; const dataSource = new DataSource({ type: DB_TYPE as any, @@ -119,6 +120,7 @@ const dataSource = new DataSource({ SettingsFromEnv1745059495808, SettingsFromEnv_SET1745059495808, MemberCreatedAt1746006549262, + UserLoginRoutine1746252454922, ], migrationsRun: true, migrationsTransactionMode: "each", diff --git a/src/entity/management/user.ts b/src/entity/management/user.ts index 94ab3a3..a833864 100644 --- a/src/entity/management/user.ts +++ b/src/entity/management/user.ts @@ -1,6 +1,7 @@ import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from "typeorm"; import { role } from "./role"; import { userPermission } from "./user_permission"; +import { LoginRoutineEnum } from "../../enums/loginRoutineEnum"; @Entity() export class user { @@ -19,11 +20,23 @@ export class user { @Column({ type: "varchar", length: 255 }) lastname: string; - @Column({ type: "varchar", length: 255 }) + @Column({ type: "text", select: false }) secret: string; - @Column({ type: "boolean", default: false }) - static: boolean; + @Column({ + type: "varchar", + length: "255", + default: LoginRoutineEnum.totp, + transformer: { + to(value: LoginRoutineEnum) { + return value.toString(); + }, + from(value: string) { + return LoginRoutineEnum[value as keyof typeof LoginRoutineEnum]; + }, + }, + }) + routine: LoginRoutineEnum; @Column({ type: "boolean", default: false }) isOwner: boolean; diff --git a/src/enums/loginRoutineEnum.ts b/src/enums/loginRoutineEnum.ts new file mode 100644 index 0000000..4d42334 --- /dev/null +++ b/src/enums/loginRoutineEnum.ts @@ -0,0 +1,4 @@ +export enum LoginRoutineEnum { + password = "password", // login with self defined password + totp = "totp", // login with totp by auth apps +} diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index 4534a30..f107881 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -7,6 +7,7 @@ import UserService from "../service/management/userService"; import DatabaseActionException from "../exceptions/databaseActionException"; import { availableTemplates } from "../type/templateTypes"; import SettingHelper from "./settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; export type BackupSection = | "member" @@ -806,6 +807,7 @@ export default abstract class BackupHelper { let roles = await this.transactionManager.getRepository("role").find(); let dataWithMappedIds = (data?.["user"] ?? []).map((u) => ({ ...u, + routine: u.routine ?? LoginRoutineEnum.totp, roles: u.roles.map((r: any) => ({ ...r, id: roles.find((role) => role.role == r.role)?.id ?? undefined, diff --git a/src/migrations/1746252454922-UserLoginRoutine.ts b/src/migrations/1746252454922-UserLoginRoutine.ts new file mode 100644 index 0000000..0da51e0 --- /dev/null +++ b/src/migrations/1746252454922-UserLoginRoutine.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; +import { getDefaultByORM, getTypeByORM } from "./ormHelper"; + +export class UserLoginRoutine1746252454922 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + let users = await queryRunner.manager.getRepository("user").find({ select: ["id", "secret"] }); + + await queryRunner.dropColumns("user", ["secret", "static"]); + + await queryRunner.addColumns("user", [ + new TableColumn({ name: "secret", ...getTypeByORM("text") }), + new TableColumn({ name: "routine", ...getTypeByORM("varchar") }), + ]); + + await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); + } + + public async down(queryRunner: QueryRunner): Promise { + let users = await queryRunner.manager.getRepository("user").find({ select: ["id", "secret"] }); + + await queryRunner.dropColumn("user", "secret"); + + await queryRunner.addColumns("user", [ + new TableColumn({ name: "secret", ...getTypeByORM("varchar") }), + new TableColumn({ name: "static", ...getTypeByORM("boolean"), default: getDefaultByORM("boolean", false) }), + ]); + + await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); + + await queryRunner.dropColumn("user", "routine"); + } +} From a476bf6823b49e30628f9413ba781fb0eb4df7f6 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Sun, 4 May 2025 19:01:06 +0200 Subject: [PATCH 23/29] migration change on default value and encrypted storage --- src/controller/authController.ts | 1 + src/controller/userController.ts | 6 ++- src/controller/webapiController.ts | 8 --- src/data-source.ts | 4 +- src/entity/management/user.ts | 8 ++- src/env.defaults.ts | 4 +- src/exceptions/databaseActionException.ts | 2 +- src/helpers/backupHelper.ts | 1 + src/helpers/codingHelper.ts | 51 +++++++++++-------- .../1746252454922-UserLoginRoutine.ts | 9 +++- src/service/management/userService.ts | 24 +++++++++ 11 files changed, 82 insertions(+), 36 deletions(-) diff --git a/src/controller/authController.ts b/src/controller/authController.ts index 9ecfa64..80492ea 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -19,6 +19,7 @@ export async function login(req: Request, res: Response): Promise { let username = req.body.username; let totp = req.body.totp; + // TODO: change to first routine and later login password/totp let { id, secret } = await UserService.getByUsername(username); let valid = speakeasy.totp.verify({ diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 8827dc9..c8001a7 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -31,7 +31,9 @@ export async function getMeById(req: Request, res: Response): Promise { export async function getMyTotp(req: Request, res: Response): Promise { const userId = req.userId; - let { secret } = await UserService.getById(userId); + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + console.log(secret); const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; @@ -57,7 +59,7 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { const userId = req.userId; let totp = req.body.totp; - let { secret } = await UserService.getById(userId); + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); let valid = speakeasy.totp.verify({ secret: secret, encoding: "base32", diff --git a/src/controller/webapiController.ts b/src/controller/webapiController.ts index e8e4206..c1153fe 100644 --- a/src/controller/webapiController.ts +++ b/src/controller/webapiController.ts @@ -1,13 +1,5 @@ import { Request, Response } from "express"; import { JWTHelper } from "../helpers/jwtHelper"; -import { JWTToken } from "../type/jwtTypes"; -import InternalException from "../exceptions/internalException"; -import RefreshCommandHandler from "../command/refreshCommandHandler"; -import { CreateRefreshCommand, DeleteRefreshCommand } from "../command/refreshCommand"; -import UserService from "../service/management/userService"; -import speakeasy from "speakeasy"; -import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; -import RefreshService from "../service/refreshService"; import WebapiService from "../service/management/webapiService"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; import WebapiCommandHandler from "../command/management/webapi/webapiCommandHandler"; diff --git a/src/data-source.ts b/src/data-source.ts index dad9827..fb60d49 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import "reflect-metadata"; import { DataSource } from "typeorm"; -import { DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_TYPE, DB_USERNAME } from "./env.defaults"; +import { configCheck, DB_HOST, DB_NAME, DB_PASSWORD, DB_PORT, DB_TYPE, DB_USERNAME } from "./env.defaults"; import { user } from "./entity/management/user"; import { refresh } from "./entity/refresh"; @@ -57,6 +57,8 @@ import { MemberCreatedAt1746006549262 } from "./migrations/1746006549262-memberC import { UserLoginRoutine1746252454922 } from "./migrations/1746252454922-UserLoginRoutine"; import { SettingsFromEnv_SET1745059495808 } from "./migrations/1745059495808-settingsFromEnv_set"; +configCheck(); + const dataSource = new DataSource({ type: DB_TYPE as any, host: DB_HOST, diff --git a/src/entity/management/user.ts b/src/entity/management/user.ts index a833864..d659acb 100644 --- a/src/entity/management/user.ts +++ b/src/entity/management/user.ts @@ -2,6 +2,8 @@ import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from import { role } from "./role"; import { userPermission } from "./user_permission"; import { LoginRoutineEnum } from "../../enums/loginRoutineEnum"; +import { CodingHelper } from "../../helpers/codingHelper"; +import { APPLICATION_SECRET } from "../../env.defaults"; @Entity() export class user { @@ -20,7 +22,11 @@ export class user { @Column({ type: "varchar", length: 255 }) lastname: string; - @Column({ type: "text", select: false }) + @Column({ + type: "text", + select: false, + transformer: CodingHelper.entityBaseCoding(APPLICATION_SECRET, ""), + }) secret: string; @Column({ diff --git a/src/env.defaults.ts b/src/env.defaults.ts index 7bdcb4b..c6df8ae 100644 --- a/src/env.defaults.ts +++ b/src/env.defaults.ts @@ -11,7 +11,7 @@ export const DB_PASSWORD = process.env.DB_PASSWORD ?? ""; export const SERVER_PORT = Number(process.env.SERVER_PORT ?? 5000); -export const APPLICATION_SECRET = process.env.APPLICATION_SECRET; +export const APPLICATION_SECRET = process.env.APPLICATION_SECRET ?? ""; export const USE_SECURITY_STRICT_LIMIT = process.env.USE_SECURITY_STRICT_LIMIT ?? "true"; export const SECURITY_STRICT_LIMIT_WINDOW = (process.env.SECURITY_STRICT_LIMIT_WINDOW ?? "15m") as ms.StringValue; @@ -45,6 +45,8 @@ export function configCheck() { if (DB_USERNAME == "" || typeof DB_USERNAME != "string") throw new Error("set valid value to DB_USERNAME"); if (DB_PASSWORD == "" || typeof DB_PASSWORD != "string") throw new Error("set valid value to DB_PASSWORD"); + if (APPLICATION_SECRET == "") throw new Error("set valid APPLICATION_SECRET"); + if (isNaN(SERVER_PORT)) throw new Error("set valid numeric value to SERVER_PORT"); if (USE_SECURITY_STRICT_LIMIT != "true" && USE_SECURITY_STRICT_LIMIT != "false") diff --git a/src/exceptions/databaseActionException.ts b/src/exceptions/databaseActionException.ts index b0b145c..eba9bb3 100644 --- a/src/exceptions/databaseActionException.ts +++ b/src/exceptions/databaseActionException.ts @@ -2,7 +2,7 @@ import CustomRequestException from "./customRequestException"; export default class DatabaseActionException extends CustomRequestException { constructor(action: string, table: string, err: any) { - let errstring = `${action} on ${table} with ${err?.code ?? "XX"} at ${err?.sqlMessage ?? "XX"}`; + let errstring = `${action} on ${table} with ${err?.code ?? "XX"} at ${err?.sqlMessage ?? err?.message ?? "XX"}`; super(500, errstring, err); } } diff --git a/src/helpers/backupHelper.ts b/src/helpers/backupHelper.ts index f107881..2f737ad 100644 --- a/src/helpers/backupHelper.ts +++ b/src/helpers/backupHelper.ts @@ -441,6 +441,7 @@ export default abstract class BackupHelper { "user.firstname", "user.lastname", "user.secret", + "user.routine", "user.isOwner", ]) .addSelect(["permissions.permission"]) diff --git a/src/helpers/codingHelper.ts b/src/helpers/codingHelper.ts index e6a79f9..354abc8 100644 --- a/src/helpers/codingHelper.ts +++ b/src/helpers/codingHelper.ts @@ -9,12 +9,13 @@ export abstract class CodingHelper { static entityBaseCoding(key: string = "", fallback: string = ""): ValueTransformer { return { from(val: string | null | undefined): string { - if (!val) return fallback; + if (!val || val == "") return fallback; try { - return CodingHelper.decrypt(key, val) || fallback; + return CodingHelper.decrypt(key, val, true); } catch (error) { console.error("Decryption error:", error); - return fallback; + if (fallback == "") return val; + else return fallback; } }, to(val: string | null | undefined): string { @@ -22,40 +23,47 @@ export abstract class CodingHelper { if (valueToEncrypt === "") return ""; try { - return CodingHelper.encrypt(key, valueToEncrypt); + return CodingHelper.encrypt(key, valueToEncrypt, true); } catch (error) { console.error("Encryption error:", error); + if (fallback == "") return val; return ""; } }, }; } - public static encrypt(phrase: string, content: string): string { + public static encrypt(phrase: string, content: string, passError = false): string { if (!content) return ""; - // Generiere zufälligen IV für jede Verschlüsselung (sicherer als statischer IV) - const iv = randomBytes(this.ivLength); - const key = scryptSync(phrase, "salt", 32); + try { + // Generiere zufälligen IV für jede Verschlüsselung (sicherer als statischer IV) + const iv = randomBytes(this.ivLength); + const key = scryptSync(phrase, "salt", 32); - const cipher = createCipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); + const cipher = createCipheriv(this.algorithm, Uint8Array.from(key), Uint8Array.from(iv)); - // Verschlüssele den Inhalt - let encrypted = cipher.update(content, "utf8", "hex"); - encrypted += cipher.final("hex"); + // Verschlüssele den Inhalt + let encrypted = cipher.update(content, "utf8", "hex"); + encrypted += cipher.final("hex"); - // Speichere das Auth-Tag für GCM (wichtig für die Entschlüsselung) - const authTag = cipher.getAuthTag(); + // Speichere das Auth-Tag für GCM (wichtig für die Entschlüsselung) + const authTag = cipher.getAuthTag(); - // Gib das Format: iv:verschlüsselter_text:authTag zurück - return Buffer.concat([ - Uint8Array.from(iv), - Uint8Array.from(Buffer.from(encrypted, "hex")), - Uint8Array.from(authTag), - ]).toString("base64"); + // Gib das Format: iv:verschlüsselter_text:authTag zurück + return Buffer.concat([ + Uint8Array.from(iv), + Uint8Array.from(Buffer.from(encrypted, "hex")), + Uint8Array.from(authTag), + ]).toString("base64"); + } catch (error) { + if (passError) throw error; + console.error("Encryption failed:", error); + return ""; + } } - public static decrypt(phrase: string, content: string): string { + public static decrypt(phrase: string, content: string, passError = false): string { if (!content) return ""; try { @@ -79,6 +87,7 @@ export abstract class CodingHelper { return decrypted; } catch (error) { + if (passError) throw error; console.error("Decryption failed:", error); return ""; } diff --git a/src/migrations/1746252454922-UserLoginRoutine.ts b/src/migrations/1746252454922-UserLoginRoutine.ts index 0da51e0..3072e37 100644 --- a/src/migrations/1746252454922-UserLoginRoutine.ts +++ b/src/migrations/1746252454922-UserLoginRoutine.ts @@ -1,5 +1,8 @@ import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; import { getDefaultByORM, getTypeByORM } from "./ormHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; +import { CodingHelper } from "../helpers/codingHelper"; +import { APPLICATION_SECRET } from "../env.defaults"; export class UserLoginRoutine1746252454922 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { @@ -9,7 +12,11 @@ export class UserLoginRoutine1746252454922 implements MigrationInterface { await queryRunner.addColumns("user", [ new TableColumn({ name: "secret", ...getTypeByORM("text") }), - new TableColumn({ name: "routine", ...getTypeByORM("varchar") }), + new TableColumn({ + name: "routine", + ...getTypeByORM("varchar"), + default: getDefaultByORM("string", LoginRoutineEnum.totp), + }), ]); await queryRunner.manager.getRepository("user").save(users.map((u) => ({ id: u.id, secret: u.secret }))); diff --git a/src/service/management/userService.ts b/src/service/management/userService.ts index 8fe4dd4..8119d5d 100644 --- a/src/service/management/userService.ts +++ b/src/service/management/userService.ts @@ -129,4 +129,28 @@ export default abstract class UserService { throw new DatabaseActionException("SELECT", "userRoles", err); }); } + + /** + * @description get secret and routine by iser + * @param userId string + * @returns {Promise} + */ + static async getUserSecretAndRoutine(userId: string): Promise { + //TODO: not working yet + return await dataSource + .getRepository(user) + .createQueryBuilder("user") + .select("user.id") + .addSelect("user.secret") + .addSelect("user.routine") + .where("user.id = :id", { id: userId }) + .getOneOrFail() + .then((res) => { + return res; + }) + .catch((err) => { + console.log(err); + throw new DatabaseActionException("SELECT", "user credentials", err); + }); + } } From be22c783725403d3e2c6668b9f9bdcea22dc7bb4 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Mon, 5 May 2025 14:21:13 +0200 Subject: [PATCH 24/29] login by password or totp --- src/controller/authController.ts | 44 +++++++++++++++++++++------ src/routes/auth.ts | 6 +++- src/service/management/userService.ts | 1 - 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/controller/authController.ts b/src/controller/authController.ts index 80492ea..ce674ab 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -8,6 +8,25 @@ import UserService from "../service/management/userService"; import speakeasy from "speakeasy"; import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException"; import RefreshService from "../service/refreshService"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; + +/** + * @description Check authentication status by token + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function kickof(req: Request, res: Response): Promise { + let username = req.body.username; + + let { routine } = await UserService.getByUsername(username).catch(() => { + throw new UnauthorizedRequestException("Username not found"); + }); + + res.json({ + routine, + }); +} /** * @description Check authentication status by token @@ -17,20 +36,25 @@ import RefreshService from "../service/refreshService"; */ export async function login(req: Request, res: Response): Promise { let username = req.body.username; - let totp = req.body.totp; + let passedSecret = req.body.secret; - // TODO: change to first routine and later login password/totp - let { id, secret } = await UserService.getByUsername(username); + let { id } = await UserService.getByUsername(username); + let { secret, routine } = await UserService.getUserSecretAndRoutine(id); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret == secret; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let accessToken = await JWTHelper.buildToken(id); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index b1200bc..c15f7cb 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,8 +1,12 @@ import express from "express"; -import { login, logout, refresh } from "../controller/authController"; +import { kickof, login, logout, refresh } from "../controller/authController"; var router = express.Router({ mergeParams: true }); +router.post("/kickof", async (req, res) => { + await kickof(req, res); +}); + router.post("/login", async (req, res) => { await login(req, res); }); diff --git a/src/service/management/userService.ts b/src/service/management/userService.ts index 8119d5d..d273315 100644 --- a/src/service/management/userService.ts +++ b/src/service/management/userService.ts @@ -136,7 +136,6 @@ export default abstract class UserService { * @returns {Promise} */ static async getUserSecretAndRoutine(userId: string): Promise { - //TODO: not working yet return await dataSource .getRepository(user) .createQueryBuilder("user") From ddb460f8d0a61067741c9821b30be7085c33f5e3 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Mon, 5 May 2025 17:43:57 +0200 Subject: [PATCH 25/29] enable switch to pw totp in account settings --- src/command/management/user/userCommand.ts | 3 + .../management/user/userCommandHandler.ts | 1 + src/controller/authController.ts | 1 + src/controller/resetController.ts | 2 + src/controller/userController.ts | 129 +++++++++++++++++- src/routes/user.ts | 33 ++++- 6 files changed, 165 insertions(+), 4 deletions(-) diff --git a/src/command/management/user/userCommand.ts b/src/command/management/user/userCommand.ts index 90f9872..66bab37 100644 --- a/src/command/management/user/userCommand.ts +++ b/src/command/management/user/userCommand.ts @@ -1,3 +1,5 @@ +import { LoginRoutineEnum } from "../../../enums/loginRoutineEnum"; + export interface CreateUserCommand { mail: string; username: string; @@ -18,6 +20,7 @@ export interface UpdateUserCommand { export interface UpdateUserSecretCommand { id: string; secret: string; + routine: LoginRoutineEnum; } export interface TransferUserOwnerCommand { diff --git a/src/command/management/user/userCommandHandler.ts b/src/command/management/user/userCommandHandler.ts index 590b2de..0f89ef5 100644 --- a/src/command/management/user/userCommandHandler.ts +++ b/src/command/management/user/userCommandHandler.ts @@ -75,6 +75,7 @@ export default abstract class UserCommandHandler { .update(user) .set({ secret: updateUser.secret, + routine: updateUser.routine, }) .where("id = :id", { id: updateUser.id }) .execute() diff --git a/src/controller/authController.ts b/src/controller/authController.ts index ce674ab..77a6717 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -50,6 +50,7 @@ export async function login(req: Request, res: Response): Promise { window: 2, }); } else { + console.log(passedSecret, secret, passedSecret == secret); valid = passedSecret == secret; } diff --git a/src/controller/resetController.ts b/src/controller/resetController.ts index 3592639..1a12b3f 100644 --- a/src/controller/resetController.ts +++ b/src/controller/resetController.ts @@ -14,6 +14,7 @@ import UserService from "../service/management/userService"; import { UpdateUserSecretCommand } from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description request totp reset @@ -101,6 +102,7 @@ export async function finishReset(req: Request, res: Response): Promise { let updateUserSecret: UpdateUserSecretCommand = { id, secret, + routine: LoginRoutineEnum.totp, }; await UserCommandHandler.updateSecret(updateUserSecret); diff --git a/src/controller/userController.ts b/src/controller/userController.ts index c8001a7..1fe92c5 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -4,10 +4,15 @@ import QRCode from "qrcode"; import InternalException from "../exceptions/internalException"; import UserService from "../service/management/userService"; import UserFactory from "../factory/admin/management/user"; -import { TransferUserOwnerCommand, UpdateUserCommand } from "../command/management/user/userCommand"; +import { + TransferUserOwnerCommand, + UpdateUserCommand, + UpdateUserSecretCommand, +} from "../command/management/user/userCommand"; import UserCommandHandler from "../command/management/user/userCommandHandler"; import ForbiddenRequestException from "../exceptions/forbiddenRequestException"; import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description get my by id @@ -22,6 +27,21 @@ export async function getMeById(req: Request, res: Response): Promise { res.json(UserFactory.mapToSingle(user)); } +/** + * @description get my routine by id + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getMyRoutine(req: Request, res: Response): Promise { + const id = req.userId; + let user = await UserService.getById(id); + + res.json({ + routine: user.routine, + }); +} + /** * @description get my totp * @param req {Request} Express req object @@ -33,8 +53,6 @@ export async function getMyTotp(req: Request, res: Response): Promise { let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); - console.log(secret); - const url = `otpauth://totp/FF Admin ${SettingHelper.getSetting("club.name")}?secret=${secret}`; QRCode.toDataURL(url) @@ -60,6 +78,11 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { let totp = req.body.totp; let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + if (routine != LoginRoutineEnum.totp) { + throw new ForbiddenRequestException("only allowed for totp login"); + } + let valid = speakeasy.totp.verify({ secret: secret, encoding: "base32", @@ -73,6 +96,106 @@ export async function verifyMyTotp(req: Request, res: Response): Promise { res.sendStatus(204); } +/** + * @description change my password + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeMyPassword(req: Request, res: Response): Promise { + const userId = req.userId; + let current = req.body.current; + let newpassword = req.body.newpassword; + + let { secret, routine } = await UserService.getUserSecretAndRoutine(userId); + + if (routine == LoginRoutineEnum.password && current != secret) { + throw new ForbiddenRequestException("passwords do not match"); + } + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: newpassword, + routine: LoginRoutineEnum.password, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + +/** + * @description get change to totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function getChangeToTOTP(req: Request, res: Response): Promise { + var secret = speakeasy.generateSecret({ length: 20, name: `FF Admin ${SettingHelper.getSetting("club.name")}` }); + + QRCode.toDataURL(secret.otpauth_url) + .then((result) => { + res.json({ + dataUrl: result, + otp: secret.base32, + }); + }) + .catch((err) => { + throw new InternalException("QRCode not created", err); + }); +} + +/** + * @description change to totp + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeToTOTP(req: Request, res: Response): Promise { + const userId = req.userId; + let otp = req.body.otp; + let totp = req.body.totp; + + let valid = speakeasy.totp.verify({ + secret: otp, + encoding: "base32", + token: totp, + window: 2, + }); + + if (!valid) { + throw new InternalException("Token not valid or expired"); + } + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: otp, + routine: LoginRoutineEnum.totp, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + +/** + * @description change to password + * @param req {Request} Express req object + * @param res {Response} Express res object + * @returns {Promise<*>} + */ +export async function changeToPW(req: Request, res: Response): Promise { + const userId = req.userId; + let newpassword = req.body.newpassword; + + let updateUser: UpdateUserSecretCommand = { + id: userId, + secret: newpassword, + routine: LoginRoutineEnum.password, + }; + await UserCommandHandler.updateSecret(updateUser); + + res.sendStatus(204); +} + /** * @description transferOwnership * @param req {Request} Express req object diff --git a/src/routes/user.ts b/src/routes/user.ts index d196e16..90ba489 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,5 +1,16 @@ import express from "express"; -import { getMeById, getMyTotp, transferOwnership, updateMe, verifyMyTotp } from "../controller/userController"; +import { + changeMyPassword, + changeToPW, + changeToTOTP, + getChangeToTOTP, + getMeById, + getMyRoutine, + getMyTotp, + transferOwnership, + updateMe, + verifyMyTotp, +} from "../controller/userController"; var router = express.Router({ mergeParams: true }); @@ -7,14 +18,34 @@ router.get("/me", async (req, res) => { await getMeById(req, res); }); +router.get("/routine", async (req, res) => { + await getMyRoutine(req, res); +}); + router.get("/totp", async (req, res) => { await getMyTotp(req, res); }); +router.get("/changeToTOTP", async (req, res) => { + await getChangeToTOTP(req, res); +}); + router.post("/verify", async (req, res) => { await verifyMyTotp(req, res); }); +router.post("/changepw", async (req, res) => { + await changeMyPassword(req, res); +}); + +router.post("/changeToTOTP", async (req, res) => { + await changeToTOTP(req, res); +}); + +router.post("/changeToPW", async (req, res) => { + await changeToPW(req, res); +}); + router.put("/transferOwner", async (req, res) => { await transferOwnership(req, res); }); From 0ea12eaafce45b14d4f3f585acad0c3f55372513 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Tue, 6 May 2025 08:37:56 +0200 Subject: [PATCH 26/29] enable password on invite or reset --- src/command/management/user/userCommand.ts | 1 + .../management/user/userCommandHandler.ts | 1 + src/controller/authController.ts | 3 ++- src/controller/inviteController.ts | 26 ++++++++++++------- src/controller/resetController.ts | 26 ++++++++++++------- src/helpers/settingsHelper.ts | 16 +++++++----- src/routes/invite.ts | 10 ++++--- src/routes/reset.ts | 10 ++++--- 8 files changed, 61 insertions(+), 32 deletions(-) diff --git a/src/command/management/user/userCommand.ts b/src/command/management/user/userCommand.ts index 66bab37..cb19989 100644 --- a/src/command/management/user/userCommand.ts +++ b/src/command/management/user/userCommand.ts @@ -7,6 +7,7 @@ export interface CreateUserCommand { lastname: string; secret: string; isOwner: boolean; + routine: LoginRoutineEnum; } export interface UpdateUserCommand { diff --git a/src/command/management/user/userCommandHandler.ts b/src/command/management/user/userCommandHandler.ts index 0f89ef5..daa5535 100644 --- a/src/command/management/user/userCommandHandler.ts +++ b/src/command/management/user/userCommandHandler.ts @@ -31,6 +31,7 @@ export default abstract class UserCommandHandler { lastname: createUser.lastname, secret: createUser.secret, isOwner: createUser.isOwner, + routine: createUser.routine, }) .execute() .then((result) => { diff --git a/src/controller/authController.ts b/src/controller/authController.ts index 77a6717..6302a3c 100644 --- a/src/controller/authController.ts +++ b/src/controller/authController.ts @@ -41,6 +41,8 @@ export async function login(req: Request, res: Response): Promise { let { id } = await UserService.getByUsername(username); let { secret, routine } = await UserService.getUserSecretAndRoutine(id); + console.log(secret, passedSecret); + let valid = false; if (routine == LoginRoutineEnum.totp) { valid = speakeasy.totp.verify({ @@ -50,7 +52,6 @@ export async function login(req: Request, res: Response): Promise { window: 2, }); } else { - console.log(passedSecret, secret, passedSecret == secret); valid = passedSecret == secret; } diff --git a/src/controller/inviteController.ts b/src/controller/inviteController.ts index a183cbc..9492e10 100644 --- a/src/controller/inviteController.ts +++ b/src/controller/inviteController.ts @@ -16,6 +16,7 @@ import UserService from "../service/management/userService"; import CustomRequestException from "../exceptions/customRequestException"; import InviteFactory from "../factory/admin/management/invite"; import SettingHelper from "../helpers/settingsHelper"; +import { LoginRoutineEnum } from "../enums/loginRoutineEnum"; /** * @description get all invites @@ -112,20 +113,26 @@ export async function verifyInvite(req: Request, res: Response): Promise { */ export async function finishInvite(req: Request, res: Response, grantAdmin: boolean = false): Promise { let mail = req.body.mail; + let routine = req.body.routine; let token = req.body.token; - let totp = req.body.totp; + let passedSecret = req.body.secret; let { secret, username, firstname, lastname } = await InviteService.getByMailAndToken(mail, token); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret != ""; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let createUser: CreateUserCommand = { @@ -133,8 +140,9 @@ export async function finishInvite(req: Request, res: Response, grantAdmin: bool firstname: firstname, lastname: lastname, mail: mail, - secret: secret, + secret: routine == LoginRoutineEnum.totp ? secret : passedSecret, isOwner: grantAdmin, + routine, }; let id = await UserCommandHandler.create(createUser); diff --git a/src/controller/resetController.ts b/src/controller/resetController.ts index 1a12b3f..7ffafe5 100644 --- a/src/controller/resetController.ts +++ b/src/controller/resetController.ts @@ -81,28 +81,34 @@ export async function verifyReset(req: Request, res: Response): Promise { */ export async function finishReset(req: Request, res: Response): Promise { let mail = req.body.mail; + let routine = req.body.routine; let token = req.body.token; - let totp = req.body.totp; + let passedSecret = req.body.secret; let { secret, username } = await ResetService.getByMailAndToken(mail, token); - let valid = speakeasy.totp.verify({ - secret: secret, - encoding: "base32", - token: totp, - window: 2, - }); + let valid = false; + if (routine == LoginRoutineEnum.totp) { + valid = speakeasy.totp.verify({ + secret: secret, + encoding: "base32", + token: passedSecret, + window: 2, + }); + } else { + valid = passedSecret != ""; + } if (!valid) { - throw new UnauthorizedRequestException("Token not valid or expired"); + throw new UnauthorizedRequestException("Credentials not valid or expired"); } let { id } = await UserService.getByUsername(username); let updateUserSecret: UpdateUserSecretCommand = { id, - secret, - routine: LoginRoutineEnum.totp, + secret: routine == LoginRoutineEnum.totp ? secret : passedSecret, + routine, }; await UserCommandHandler.updateSecret(updateUserSecret); diff --git a/src/helpers/settingsHelper.ts b/src/helpers/settingsHelper.ts index b90cd07..526e10a 100644 --- a/src/helpers/settingsHelper.ts +++ b/src/helpers/settingsHelper.ts @@ -56,12 +56,17 @@ export default abstract class SettingHelper { return rawValue as unknown as SettingValueMapping[K]; } + let processedValue = rawValue; + if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { + processedValue = CodingHelper.decrypt(APPLICATION_SECRET, processedValue); + } + const baseType = typeof settingType.type === "string" ? (settingType.type.split("/")[0] as SettingTypeAtom) : (settingType.type as SettingTypeAtom); - return this.converters[baseType].fromString(rawValue) as unknown as SettingValueMapping[K]; + return this.converters[baseType].fromString(processedValue) as unknown as SettingValueMapping[K]; } /** @@ -81,11 +86,11 @@ export default abstract class SettingHelper { const settingType = settingsType[key]; this.validateSetting(key, stringValue); - const oldValue = this.getSetting(key); - let finalValue = stringValue; + const oldValue = cloneDeep(this.settings[key]); + let newValue = stringValue; if (typeof settingType.type === "string" && settingType.type.includes("/crypt")) { - finalValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); + newValue = CodingHelper.encrypt(APPLICATION_SECRET, stringValue); } this.settings[key] = stringValue; @@ -94,10 +99,9 @@ export default abstract class SettingHelper { await SettingCommandHandler.create({ topic, key: settingKey, - value: finalValue, + value: newValue, }); - const newValue = this.getSetting(key); this.notifyListeners(key, newValue, oldValue); } diff --git a/src/routes/invite.ts b/src/routes/invite.ts index 5d8794e..ebb4ddd 100644 --- a/src/routes/invite.ts +++ b/src/routes/invite.ts @@ -8,8 +8,12 @@ router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mai await verifyInvite(req, res); }); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishInvite(req, res); -}); +router.put( + "/", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "secret", "routine "]), + async (req, res) => { + await finishInvite(req, res); + } +); export default router; diff --git a/src/routes/reset.ts b/src/routes/reset.ts index acb1516..31df6c4 100644 --- a/src/routes/reset.ts +++ b/src/routes/reset.ts @@ -12,8 +12,12 @@ router.post("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["username" await startReset(req, res); }); -router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => { - await finishReset(req, res); -}); +router.put( + "/", + ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "secret", "routine"]), + async (req, res) => { + await finishReset(req, res); + } +); export default router; From cac784474c0f423ec2b5f3c1eef55f2eac9a57bf Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Tue, 6 May 2025 09:17:55 +0200 Subject: [PATCH 27/29] update migration to work with postgre --- src/migrations/1746252454922-UserLoginRoutine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/migrations/1746252454922-UserLoginRoutine.ts b/src/migrations/1746252454922-UserLoginRoutine.ts index 3072e37..b11f9b0 100644 --- a/src/migrations/1746252454922-UserLoginRoutine.ts +++ b/src/migrations/1746252454922-UserLoginRoutine.ts @@ -11,7 +11,7 @@ export class UserLoginRoutine1746252454922 implements MigrationInterface { await queryRunner.dropColumns("user", ["secret", "static"]); await queryRunner.addColumns("user", [ - new TableColumn({ name: "secret", ...getTypeByORM("text") }), + new TableColumn({ name: "secret", ...getTypeByORM("text"), default: getDefaultByORM("string") }), new TableColumn({ name: "routine", ...getTypeByORM("varchar"), @@ -28,7 +28,7 @@ export class UserLoginRoutine1746252454922 implements MigrationInterface { await queryRunner.dropColumn("user", "secret"); await queryRunner.addColumns("user", [ - new TableColumn({ name: "secret", ...getTypeByORM("varchar") }), + new TableColumn({ name: "secret", ...getTypeByORM("varchar"), default: getDefaultByORM("string") }), new TableColumn({ name: "static", ...getTypeByORM("boolean"), default: getDefaultByORM("boolean", false) }), ]); From 72552bdd833aeff238a86ecbcbc52a1322974a96 Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Tue, 6 May 2025 09:18:22 +0200 Subject: [PATCH 28/29] enhance: update migration to work with newer model schema --- src/migrations/1742922178643-queryToUUID.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/migrations/1742922178643-queryToUUID.ts b/src/migrations/1742922178643-queryToUUID.ts index b52990e..3902fb9 100644 --- a/src/migrations/1742922178643-queryToUUID.ts +++ b/src/migrations/1742922178643-queryToUUID.ts @@ -10,8 +10,8 @@ export class QueryToUUID1742922178643 implements MigrationInterface { const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.indexOf("recipientsByQueryId") !== -1); await queryRunner.dropForeignKey("newsletter", foreignKey); - const entries = await queryRunner.manager.getRepository(query).find({ select: { title: true, query: true } }); - await queryRunner.clearTable("query"); + // const entries = await queryRunner.manager.getRepository("query").find({ select: { title: true, query: true } }); + // await queryRunner.clearTable("query"); await queryRunner.dropColumn("newsletter", "recipientsByQueryId"); await queryRunner.dropColumn("query", "id"); @@ -32,7 +32,7 @@ export class QueryToUUID1742922178643 implements MigrationInterface { }) ); - await queryRunner.manager.createQueryBuilder().insert().into("query").values(entries).execute(); + // await queryRunner.manager.getRepository("query").save(entries); await queryRunner.createForeignKey( "newsletter", From 676b7144bf80479e8b6ef3a140193f4fad318faf Mon Sep 17 00:00:00 2001 From: Julian Krauser Date: Tue, 6 May 2025 09:30:54 +0200 Subject: [PATCH 29/29] update all packages --- package-lock.json | 338 +++++++++++++++++++++++----------------------- package.json | 30 ++-- 2 files changed, 184 insertions(+), 184 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9004e16..87ee2b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,12 @@ "dependencies": { "cors": "^2.8.5", "crypto": "^1.0.1", - "dotenv": "^16.4.5", + "dotenv": "^16.5.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", "handlebars": "^4.7.8", - "helmet": "^8.0.0", + "helmet": "^8.1.0", "ics": "^3.8.1", "ip": "^2.0.1", "jsonwebtoken": "^9.0.2", @@ -25,40 +25,40 @@ "moment": "^2.30.1", "morgan": "^1.10.0", "ms": "^2.1.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "mysql": "^2.18.1", "node-schedule": "^2.1.1", - "nodemailer": "^6.10.1", + "nodemailer": "^7.0.2", "pdf-lib": "^1.17.1", - "pg": "^8.13.1", - "puppeteer": "^24.6.1", + "pg": "^8.15.6", + "puppeteer": "^24.8.0", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", "sharp": "^0.34.1", "sharp-ico": "^0.1.5", - "socket.io": "^4.7.5", + "socket.io": "^4.8.1", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", - "typeorm": "^0.3.20", + "typeorm": "^0.3.22", "uuid": "^11.1.0", "validator": "^13.15.0" }, "devDependencies": { - "@types/cors": "^2.8.14", + "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", - "@types/jsonwebtoken": "^9.0.6", + "@types/jsonwebtoken": "^9.0.9", "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", "@types/multer": "^1.4.12", - "@types/mysql": "^2.15.21", - "@types/node": "^22.14.1", - "@types/node-schedule": "^2.1.6", - "@types/nodemailer": "^6.4.14", - "@types/pg": "~8.11.12", + "@types/mysql": "^2.15.27", + "@types/node": "^22.15.12", + "@types/node-schedule": "^2.1.7", + "@types/nodemailer": "^6.4.17", + "@types/pg": "~8.12.0", "@types/qrcode": "~1.5.5", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", @@ -68,23 +68,23 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -702,9 +702,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.0.tgz", - "integrity": "sha512-HdHF4rny4JCvIcm7V1dpvpctIGqM3/Me255CB44vW7hDG1zYMmcBMjpNqZEDxdCfXGLkx5kP0+Jz5DUS+ukqtA==", + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.3.tgz", + "integrity": "sha512-iPpnFpX25gKIVsHsqVjHV+/GzW36xPgsscWkCnrrETndcdxNsXLdCrTwhkCJNR/FGWr122dJUBeyV4niz/j3TA==", "license": "Apache-2.0", "dependencies": { "debug": "^4.4.0", @@ -933,9 +933,9 @@ } }, "node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", + "version": "22.15.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.12.tgz", + "integrity": "sha512-K0fpC/ZVeb8G9rm7bH7vI0KAec4XHEhBam616nVJCV51bKzJ6oA3luG4WdKoaztxe70QaNjS/xBmcDLmr4PiGw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -962,9 +962,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.12", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.12.tgz", - "integrity": "sha512-D8qPxnq0rgpvZPYwMxAZffxvlk2mtgimLC5kos8uM7+3wPKfTESxtpD49cfB5w1UnodZL7oYnjFHT5+cB3Gw9Q==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.12.0.tgz", + "integrity": "sha512-a9Z11ecnpNPFu2iT4Qo9SSYgM2r1l4UqLIQ454zhCDRzxqOh/vsi57FFovbc64oBGPBotXw5cRhUQtJEHCb/OA==", "dev": true, "license": "MIT", "dependencies": { @@ -973,68 +973,6 @@ "pg-types": "^4.0.1" } }, - "node_modules/@types/pg/node_modules/pg-types": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", - "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", - "dev": true, - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "pg-numeric": "1.0.2", - "postgres-array": "~3.0.1", - "postgres-bytea": "~3.0.0", - "postgres-date": "~2.1.0", - "postgres-interval": "^3.0.0", - "postgres-range": "^1.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@types/pg/node_modules/postgres-array": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", - "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/pg/node_modules/postgres-bytea": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", - "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "obuf": "~1.1.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@types/pg/node_modules/postgres-date": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", - "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@types/pg/node_modules/postgres-interval": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", - "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/@types/qrcode": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", @@ -1334,9 +1272,9 @@ "optional": true }, "node_modules/bare-fs": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz", - "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.4.tgz", + "integrity": "sha512-r8+26Voz8dGX3AYpJdFb1ZPaUSM8XOLCZvy+YGpRTmwPHIxA7Z3Jov/oMPtV7hfRQbOnH8qGlLTzQAbgtdNN0Q==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1736,9 +1674,9 @@ } }, "node_modules/chromium-bidi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-3.0.0.tgz", - "integrity": "sha512-ZOGRDAhBMX1uxL2Cm2TDuhImbrsEz5A/tTcVU6RpXEWaTNUNwsHW6njUXizh51Ir6iqHbKAfhA2XK33uBcLo5A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-4.1.1.tgz", + "integrity": "sha512-biR7t4vF3YluE6RlMSk9IWk+b9U+WWyzHp+N2pL9vRTk+UXHYRTVp7jTK58ZNzMLBgoLMHY4QyJMbeuw3eKxqg==", "license": "Apache-2.0", "dependencies": { "mitt": "^3.0.1", @@ -2099,18 +2037,18 @@ } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/devtools-protocol": { - "version": "0.0.1425554", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1425554.tgz", - "integrity": "sha512-uRfxR6Nlzdzt0ihVIkV+sLztKgs7rgquY/Mhcv1YNCWDh5IZgl5mnn2aeEnW5stYTE0wwiF4RYVz8eMEpV1SEw==", + "version": "0.0.1439962", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1439962.tgz", + "integrity": "sha512-jJF48UdryzKiWhJ1bLKr7BFWUQCEIT5uCNbDLqkQJBtkFxYzILJH44WN0PDKMIlGDN7Utb8vyUY85C3w4R/t2g==", "license": "BSD-3-Clause" }, "node_modules/diff": { @@ -3859,9 +3797,9 @@ } }, "node_modules/node-abi": { - "version": "3.74.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", - "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", "license": "MIT", "dependencies": { "semver": "^7.3.5" @@ -3916,9 +3854,9 @@ } }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.2.tgz", + "integrity": "sha512-SYsisPeLFYli5Q+BCGSyHT5CVvezPmQjHgINV9KVvVLV1aktuoD4E0Np9Q3ND9I481qIHzUQzVT+Tl/Tw7Ivdg==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -4237,14 +4175,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", - "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-yvao7YI3GdmmrslNVsZgx9PfntfWrnXwtR+K/DjI0I/sTKif4Z623um+sjVZ1hk5670B+ODjvHDAckKdjmPTsg==", "license": "MIT", "dependencies": { - "pg-connection-string": "^2.7.0", - "pg-pool": "^3.8.0", - "pg-protocol": "^1.8.0", + "pg-connection-string": "^2.8.5", + "pg-pool": "^3.9.6", + "pg-protocol": "^1.9.5", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -4252,7 +4190,7 @@ "node": ">= 8.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.1.1" + "pg-cloudflare": "^1.2.5" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -4264,16 +4202,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", - "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz", + "integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", - "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.8.5.tgz", + "integrity": "sha512-Ni8FuZ8yAF+sWZzojvtLE2b03cqjO5jNULcHFfM9ZZ0/JXrgom5pBREbtnAw7oxsxJqHw9Nz/XWORUEL3/IFow==", "license": "MIT" }, "node_modules/pg-int8": { @@ -4296,21 +4234,40 @@ } }, "node_modules/pg-pool": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.8.0.tgz", - "integrity": "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.9.6.tgz", + "integrity": "sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", - "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.9.5.tgz", + "integrity": "sha512-DYTWtWpfd5FOro3UnAfwvhD8jh59r2ig8bPtc9H8Ds7MscE/9NYruUQWFAOuraRl29jwcT2kyMFQ3MxeaVjUhg==", "license": "MIT" }, "node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", @@ -4326,6 +4283,45 @@ "node": ">=4" } }, + "node_modules/pg/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pgpass": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", @@ -4351,42 +4347,46 @@ } }, "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dev": true, "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dev": true, "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, "node_modules/postgres-range": { @@ -4561,17 +4561,17 @@ } }, "node_modules/puppeteer": { - "version": "24.6.1", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.6.1.tgz", - "integrity": "sha512-/4ocGfu8LNvDbWUqJZV2VmwEWpbOdJa69y2Jivd213tV0ekAtUh/bgT1hhW63SDN/CtrEucOPwoomZ+9M+eBEg==", + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.8.0.tgz", + "integrity": "sha512-8GPlUKXvZK8ANxab75UerMar14ZnJTJpPok3XN9Nx6f7SKyabyFK39pQruMni6zfrwVBrPXp3Mo6ztwKEmXaDQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.0", - "chromium-bidi": "3.0.0", + "@puppeteer/browsers": "2.10.3", + "chromium-bidi": "4.1.1", "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1425554", - "puppeteer-core": "24.6.1", + "devtools-protocol": "0.0.1439962", + "puppeteer-core": "24.8.0", "typed-query-selector": "^2.12.0" }, "bin": { @@ -4582,15 +4582,15 @@ } }, "node_modules/puppeteer-core": { - "version": "24.6.1", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.6.1.tgz", - "integrity": "sha512-sMCxsY+OPWO2fecBrhIeCeJbWWXJ6UaN997sTid6whY0YT9XM0RnxEwLeUibluIS5/fRmuxe1efjb5RMBsky7g==", + "version": "24.8.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.8.0.tgz", + "integrity": "sha512-tDf2YKIo5kM5r0vOzT52+PTgN0bBZOA4OFgQaqYyfarrcXLLJ92wi/lSMe44hd+F+gk0gw9QsAzyRW8v6ra93w==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.10.0", - "chromium-bidi": "3.0.0", + "@puppeteer/browsers": "2.10.3", + "chromium-bidi": "4.1.1", "debug": "^4.4.0", - "devtools-protocol": "0.0.1425554", + "devtools-protocol": "0.0.1439962", "typed-query-selector": "^2.12.0", "ws": "^8.18.1" }, @@ -6116,9 +6116,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6242,9 +6242,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index bbda6c1..531d70c 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,12 @@ "dependencies": { "cors": "^2.8.5", "crypto": "^1.0.1", - "dotenv": "^16.4.5", + "dotenv": "^16.5.0", "express": "^5.1.0", "express-rate-limit": "^7.5.0", "express-validator": "^7.2.1", "handlebars": "^4.7.8", - "helmet": "^8.0.0", + "helmet": "^8.1.0", "ics": "^3.8.1", "ip": "^2.0.1", "jsonwebtoken": "^9.0.2", @@ -41,40 +41,40 @@ "moment": "^2.30.1", "morgan": "^1.10.0", "ms": "^2.1.3", - "multer": "^1.4.5-lts.1", + "multer": "^1.4.5-lts.2", "mysql": "^2.18.1", "node-schedule": "^2.1.1", - "nodemailer": "^6.10.1", + "nodemailer": "^7.0.2", "pdf-lib": "^1.17.1", - "pg": "^8.13.1", - "puppeteer": "^24.6.1", + "pg": "^8.15.6", + "puppeteer": "^24.8.0", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rss-parser": "^3.13.0", "sharp": "^0.34.1", "sharp-ico": "^0.1.5", - "socket.io": "^4.7.5", + "socket.io": "^4.8.1", "speakeasy": "^2.0.0", "sqlite3": "^5.1.7", - "typeorm": "^0.3.20", + "typeorm": "^0.3.22", "uuid": "^11.1.0", "validator": "^13.15.0" }, "devDependencies": { - "@types/cors": "^2.8.14", + "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "@types/ip": "^1.1.3", - "@types/jsonwebtoken": "^9.0.6", + "@types/jsonwebtoken": "^9.0.9", "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.uniqby": "^4.7.9", "@types/morgan": "^1.9.9", "@types/ms": "^2.1.0", "@types/multer": "^1.4.12", - "@types/mysql": "^2.15.21", - "@types/node": "^22.14.1", - "@types/node-schedule": "^2.1.6", - "@types/nodemailer": "^6.4.14", - "@types/pg": "~8.11.12", + "@types/mysql": "^2.15.27", + "@types/node": "^22.15.12", + "@types/node-schedule": "^2.1.7", + "@types/nodemailer": "^6.4.17", + "@types/pg": "~8.12.0", "@types/qrcode": "~1.5.5", "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0",