From c3a41272a3c6750cfe952e6b721c0a7a74cd1ca2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Sun, 1 Mar 2026 17:35:00 +0000 Subject: [PATCH 01/11] validation refactoring --- server/package-lock.json | 26 +- server/package.json | 3 +- server/src/controllers/authController.ts | 35 +- server/src/controllers/checkController.ts | 2 +- server/src/controllers/geoCheckController.ts | 2 +- server/src/controllers/inviteController.ts | 8 +- .../maintenanceWindowController.ts | 2 +- server/src/controllers/monitorController.ts | 2 +- .../src/controllers/notificationController.ts | 2 +- server/src/controllers/settingsController.ts | 5 +- .../src/controllers/statusPageController.ts | 7 +- .../src/validation/announcementValidation.ts | 21 + server/src/validation/authValidation.ts | 50 ++ server/src/validation/checkValidation.ts | 56 ++ server/src/validation/index.ts | 20 + server/src/validation/joi.ts | 822 ------------------ .../validation/maintenanceWindowValidation.ts | 53 ++ server/src/validation/monitorValidation.ts | 127 +++ .../src/validation/notificationValidation.ts | 78 ++ server/src/validation/settingsValidation.ts | 34 + server/src/validation/shared.ts | 39 + server/src/validation/statusPageValidation.ts | 68 ++ server/src/validation/userValidation.ts | 52 ++ 23 files changed, 662 insertions(+), 852 deletions(-) create mode 100644 server/src/validation/announcementValidation.ts create mode 100644 server/src/validation/authValidation.ts create mode 100644 server/src/validation/checkValidation.ts create mode 100644 server/src/validation/index.ts delete mode 100755 server/src/validation/joi.ts create mode 100644 server/src/validation/maintenanceWindowValidation.ts create mode 100644 server/src/validation/monitorValidation.ts create mode 100644 server/src/validation/notificationValidation.ts create mode 100644 server/src/validation/settingsValidation.ts create mode 100644 server/src/validation/shared.ts create mode 100644 server/src/validation/statusPageValidation.ts create mode 100644 server/src/validation/userValidation.ts diff --git a/server/package-lock.json b/server/package-lock.json index 30b610df5..131a3708e 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -43,7 +43,8 @@ "ssl-checker": "2.0.10", "super-simple-scheduler": "1.4.5", "swagger-ui-express": "5.0.1", - "winston": "^3.13.0" + "winston": "^3.13.0", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -876,6 +877,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1505,6 +1507,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1549,6 +1552,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4376,6 +4380,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -4536,6 +4541,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5022,6 +5028,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5600,6 +5607,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6542,6 +6550,7 @@ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz", "integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==", "license": "MIT", + "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.9", "lilconfig": "^3.1.3" @@ -7314,6 +7323,7 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7675,6 +7685,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -9323,6 +9334,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12212,6 +12224,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14436,6 +14449,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14598,6 +14612,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15402,6 +15417,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/server/package.json b/server/package.json index 9e005bca4..3abadb75f 100755 --- a/server/package.json +++ b/server/package.json @@ -58,7 +58,8 @@ "ssl-checker": "2.0.10", "super-simple-scheduler": "1.4.5", "swagger-ui-express": "5.0.1", - "winston": "^3.13.0" + "winston": "^3.13.0", + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 3fa551808..8add39087 100755 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -6,17 +6,20 @@ import type { UserRole } from "@/types/user.js"; import { registrationBodyValidation, loginValidation, - editUserBodyValidation, - createUserBodyValidation, recoveryValidation, recoveryTokenBodyValidation, newPasswordValidation, +} from "@/validation/authValidation.js"; + +import { + editUserBodyValidation, + createUserBodyValidation, getUserByIdParamValidation, editUserByIdParamValidation, editUserByIdBodyValidation, editSuperadminUserByIdBodyValidation, editUserPasswordByIdBodyValidation, -} from "@/validation/joi.js"; +} from "@/validation/userValidation.js"; const SERVICE_NAME = "authController"; @@ -40,7 +43,7 @@ class AuthController { if (newUser?.email) { newUser.email = newUser.email.toLowerCase(); } - await registrationBodyValidation.validateAsync(newUser); + registrationBodyValidation.parse(newUser); const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file); res.status(200).json({ @@ -59,7 +62,7 @@ class AuthController { if (userData?.email) { userData.email = userData.email.toLowerCase(); } - await createUserBodyValidation.validateAsync(userData); + createUserBodyValidation.parse(userData); const teamId = requireTeamId(req.user?.teamId); const actorRoles = requireUserRoles(req.user?.role) as UserRole[]; @@ -79,7 +82,7 @@ class AuthController { if (req.body?.email) { req.body.email = req.body.email?.toLowerCase(); } - await loginValidation.validateAsync(req.body); + loginValidation.parse(req.body); const { user, token } = await this.userService.loginUser(req.body.email, req.body.password); return res.status(200).json({ @@ -97,7 +100,7 @@ class AuthController { editUser = async (req: Request, res: Response, next: NextFunction) => { try { - await editUserBodyValidation.validateAsync(req.body); + editUserBodyValidation.parse(req.body); const updatedUser = await this.userService.editUser(req.body, req.file, req.user); res.status(200).json({ @@ -125,7 +128,7 @@ class AuthController { requestRecovery = async (req: Request, res: Response, next: NextFunction) => { try { - await recoveryValidation.validateAsync(req.body); + recoveryValidation.parse(req.body); const email = req?.body?.email; const msgId = await this.userService.requestRecovery(email); return res.status(200).json({ @@ -140,7 +143,7 @@ class AuthController { validateRecovery = async (req: Request, res: Response, next: NextFunction) => { try { - await recoveryTokenBodyValidation.validateAsync(req.body); + recoveryTokenBodyValidation.parse(req.body); await this.userService.validateRecovery(req.body.recoveryToken); return res.status(200).json({ success: true, @@ -153,7 +156,7 @@ class AuthController { resetPassword = async (req: Request, res: Response, next: NextFunction) => { try { - await newPasswordValidation.validateAsync(req.body); + newPasswordValidation.parse(req.body); const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken); return res.status(200).json({ success: true, @@ -192,7 +195,7 @@ class AuthController { getUserById = async (req: Request, res: Response, next: NextFunction) => { try { - await getUserByIdParamValidation.validateAsync(req.params); + getUserByIdParamValidation.parse(req.params); const userId = req?.params?.userId; const roles = req?.user?.role; @@ -223,12 +226,12 @@ class AuthController { const userId = req.params.userId as string; const user = { ...req.body }; - await editUserByIdParamValidation.validateAsync(req.params); + editUserByIdParamValidation.parse(req.params); // If this is superadmin self edit, allow "superadmin" role if (userId === req.user?.id) { - await editSuperadminUserByIdBodyValidation.validateAsync(req.body); + editSuperadminUserByIdBodyValidation.parse(req.body); } else { - await editUserByIdBodyValidation.validateAsync(req.body); + editUserByIdBodyValidation.parse(req.body); } await this.userService.editUserById(userId, user); @@ -246,8 +249,8 @@ class AuthController { } const userId = req.params.userId as string; - await editUserByIdParamValidation.validateAsync(req.params); - await editUserPasswordByIdBodyValidation.validateAsync(req.body); + editUserByIdParamValidation.parse(req.params); + editUserPasswordByIdBodyValidation.parse(req.body); const updatedPassword = req.body.password; await this.userService.setPasswordByUserId(userId, updatedPassword); return res.status(200).json({ success: true, msg: "Password reset successfully" }); diff --git a/server/src/controllers/checkController.ts b/server/src/controllers/checkController.ts index 0c085f6d3..39c4db02a 100755 --- a/server/src/controllers/checkController.ts +++ b/server/src/controllers/checkController.ts @@ -10,7 +10,7 @@ import { ackCheckBodyValidation, ackAllChecksParamValidation, ackAllChecksBodyValidation, -} from "@/validation/joi.js"; +} from "@/validation/checkValidation.js"; const SERVICE_NAME = "checkController"; diff --git a/server/src/controllers/geoCheckController.ts b/server/src/controllers/geoCheckController.ts index ec9d489f1..056599000 100644 --- a/server/src/controllers/geoCheckController.ts +++ b/server/src/controllers/geoCheckController.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/joi.js"; +import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js"; import type { IGeoChecksService } from "@/service/business/geoChecksService.js"; const SERVICE_NAME = "geoCheckController"; diff --git a/server/src/controllers/inviteController.ts b/server/src/controllers/inviteController.ts index bba04fbbd..b17d32e98 100755 --- a/server/src/controllers/inviteController.ts +++ b/server/src/controllers/inviteController.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/joi.js"; +import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js"; import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js"; const SERVICE_NAME = "inviteController"; @@ -20,7 +20,7 @@ class InviteController { const userRoles = requireUserRoles(req.user?.role); const invite = req.body; invite.teamId = teamId; - await inviteBodyValidation.validateAsync(invite); + inviteBodyValidation.parse(invite); const inviteToken = await this.inviteService.getInviteToken({ invite, teamId, userRoles }); return res.status(200).json({ success: true, @@ -39,7 +39,7 @@ class InviteController { const inviteRequest = req.body; inviteRequest.teamId = teamId; - await inviteBodyValidation.validateAsync(inviteRequest); + inviteBodyValidation.parse(inviteRequest); const inviteToken = await this.inviteService.sendInviteEmail({ invite: inviteRequest, @@ -58,7 +58,7 @@ class InviteController { verifyInviteToken = async (req: Request, res: Response, next: NextFunction) => { try { - await inviteVerificationBodyValidation.validateAsync(req.body); + inviteVerificationBodyValidation.parse(req.body); const invite = await this.inviteService.verifyInviteToken({ inviteToken: req?.body?.token }); return res.status(200).json({ success: true, diff --git a/server/src/controllers/maintenanceWindowController.ts b/server/src/controllers/maintenanceWindowController.ts index 187e479cc..d4c99a549 100644 --- a/server/src/controllers/maintenanceWindowController.ts +++ b/server/src/controllers/maintenanceWindowController.ts @@ -7,7 +7,7 @@ import { getMaintenanceWindowsByMonitorIdParamValidation, getMaintenanceWindowsByTeamIdQueryValidation, deleteMaintenanceWindowByIdParamValidation, -} from "@/validation/joi.js"; +} from "@/validation/maintenanceWindowValidation.js"; import { requireTeamId } from "@/controllers/controllerUtils.js"; const SERVICE_NAME = "maintenanceWindowController"; diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index a11e3fc65..4dba18abc 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -12,7 +12,7 @@ import { getCertificateParamValidation, getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, -} from "@/validation/joi.js"; +} from "@/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import { fetchMonitorCertificate, diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index f32f80ddc..77e332cad 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { Notification } from "@/types/index.js"; -import { createNotificationBodyValidation } from "@/validation/joi.js"; +import { createNotificationBodyValidation } from "@/validation/notificationValidation.js"; import { AppError } from "@/utils/AppError.js"; import { IMonitorsRepository } from "@/repositories/index.js"; import { INotificationsService } from "@/service/index.js"; diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 3cbfd3f6d..354f34858 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from "express"; -import { sendTestEmailBodyValidation, updateAppSettingsBodyValidation } from "@/validation/joi.js"; +import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js"; +import { sendTestEmailBodyValidation } from "@/validation/announcementValidation.js"; import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "SettingsController"; @@ -68,7 +69,7 @@ class SettingsController { sendTestEmail = async (req: Request, res: Response, next: NextFunction) => { try { - await sendTestEmailBodyValidation.validateAsync(req.body); + sendTestEmailBodyValidation.parse(req.body); const { to, diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index b936ff5ad..ccffff7a9 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -1,6 +1,11 @@ import { Request, Response, NextFunction } from "express"; -import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation, imageValidation } from "@/validation/joi.js"; +import { + createStatusPageBodyValidation, + getStatusPageParamValidation, + getStatusPageQueryValidation, + imageValidation, +} from "@/validation/statusPageValidation.js"; import { AppError } from "@/utils/AppError.js"; import { requireTeamId, requireUserId } from "@/controllers/controllerUtils.js"; import { IStatusPageService } from "@/service/business/statusPageService.js"; diff --git a/server/src/validation/announcementValidation.ts b/server/src/validation/announcementValidation.ts new file mode 100644 index 000000000..5f3514709 --- /dev/null +++ b/server/src/validation/announcementValidation.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +//**************************************** +// Announcement Validations +//**************************************** + +export const sendTestEmailBodyValidation = z.object({ + to: z.string().min(1, "To field is required"), + systemEmailHost: z.string().optional(), + systemEmailPort: z.number().optional(), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + systemEmailAddress: z.string().optional(), + systemEmailPassword: z.string().optional(), + systemEmailUser: z.string().optional(), + systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(), + systemEmailIgnoreTLS: z.boolean().optional(), + systemEmailRequireTLS: z.boolean().optional(), + systemEmailRejectUnauthorized: z.boolean().optional(), + systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(), +}); diff --git a/server/src/validation/authValidation.ts b/server/src/validation/authValidation.ts new file mode 100644 index 000000000..045868c40 --- /dev/null +++ b/server/src/validation/authValidation.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { passwordPattern, nameValidation, lowercaseEmailValidation } from "./shared.js"; + +//**************************************** +// Auth Validations +//**************************************** + +export const loginValidation = z.object({ + email: z.email("Must be a valid email address").transform((val) => val.toLowerCase()), + password: z.string().min(1, "Password is required"), +}); + +export const registrationBodyValidation = z.object({ + firstName: nameValidation, + lastName: nameValidation, + email: lowercaseEmailValidation, + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + profileImage: z.any().optional(), + inviteToken: z.string().optional().default(""), +}); + +export const recoveryValidation = z.object({ + email: z.email("Must be a valid email address"), +}); + +export const recoveryTokenBodyValidation = z.object({ + recoveryToken: z.string().min(1, "Recovery token is required"), +}); + +export const newPasswordValidation = z.object({ + recoveryToken: z.string().min(1, "Recovery token is required"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + confirm: z.string().optional(), +}); + +export const inviteBodyValidation = z.object({ + email: z.email("Must be a valid email address"), + role: z.array(z.string()).min(1, "At least one role is required"), + teamId: z.string().min(1, "Team ID is required"), +}); + +export const inviteVerificationBodyValidation = z.object({ + token: z.string().min(1, "Token is required"), +}); diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts new file mode 100644 index 000000000..b88415876 --- /dev/null +++ b/server/src/validation/checkValidation.ts @@ -0,0 +1,56 @@ +import joi from "joi"; +import { GeoContinents } from "@/types/geoCheck.js"; + +//**************************************** +// Check Validations +//**************************************** + +export const ackCheckBodyValidation = joi.object({ + ack: joi.boolean(), +}); + +export const ackAllChecksParamValidation = joi.object({ + monitorId: joi.string().optional(), + path: joi.string().valid("monitor", "team").required(), +}); + +export const ackAllChecksBodyValidation = joi.object({ + ack: joi.boolean(), +}); + +export const getChecksParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +export const getChecksQueryValidation = joi.object({ + type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"), + sortOrder: joi.string().valid("asc", "desc"), + limit: joi.number(), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), + filter: joi.string().valid("all", "up", "down", "resolve"), + ack: joi.boolean(), + page: joi.number(), + rowsPerPage: joi.number(), + status: joi.boolean(), + continent: joi.alternatives().try(joi.string().valid(...GeoContinents), joi.array().items(joi.string().valid(...GeoContinents))), +}); + +export const getTeamChecksQueryValidation = joi.object({ + sortOrder: joi.string().valid("asc", "desc"), + limit: joi.number(), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), + filter: joi.string().valid("all", "up", "down", "resolve"), + ack: joi.boolean(), + page: joi.number(), + rowsPerPage: joi.number(), +}); + +export const deleteChecksParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +export const deleteChecksByTeamIdParamValidation = joi.object({}); + +export const updateChecksTTLBodyValidation = joi.object({ + ttl: joi.number().required(), +}); diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts new file mode 100644 index 000000000..cd04ae2d2 --- /dev/null +++ b/server/src/validation/index.ts @@ -0,0 +1,20 @@ +/** + * Central validation exports + * + * This file re-exports all validation schemas from their respective modules. + * Import from here for convenience: import { loginValidation } from "@/validation"; + */ + +// Shared utilities +export * from "./shared.js"; + +// Domain-specific validations +export * from "./authValidation.js"; +export * from "./monitorValidation.js"; +export * from "./checkValidation.js"; +export * from "./maintenanceWindowValidation.js"; +export * from "./settingsValidation.js"; +export * from "./statusPageValidation.js"; +export * from "./notificationValidation.js"; +export * from "./announcementValidation.js"; +export * from "./userValidation.js"; diff --git a/server/src/validation/joi.ts b/server/src/validation/joi.ts deleted file mode 100755 index 415f3732c..000000000 --- a/server/src/validation/joi.ts +++ /dev/null @@ -1,822 +0,0 @@ -import joi, { type CustomHelpers } from "joi"; -import { type UserRole, UserRoles } from "@/types/user.js"; -import { GeoContinents } from "@/types/geoCheck.js"; - -//**************************************** -// Custom Validators -//**************************************** - -const roleValidatior = (role: UserRole[]) => (value: UserRole[], helpers: CustomHelpers) => { - const hasRole = role.some((role: UserRole) => value.includes(role)); - if (!hasRole) { - return helpers.error("any.invalid", { message: `You do not have the required authorization. Required roles: ${role.join(", ")}` }); - } - return value; -}; - -//**************************************** -// Auth -//**************************************** - -const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/; - -const loginValidation = joi.object({ - email: joi.string().email().required().lowercase(), - password: joi.string().required(), -}); -const nameValidation = joi - .string() - .trim() - .max(50) - .pattern(/^(?=.*[\p{L}\p{Sc}])[\p{L}\p{Sc}\s'\-().]+$/u) - .messages({ - "string.empty": "Name is required", - "string.max": "Name must be less than 50 characters", - "string.pattern.base": - "Names must contain at least 1 letter and may only include letters, currency symbols, spaces, apostrophes, hyphens (-), periods (.), and parentheses ().", - }); - -const registrationBodyValidation = joi.object({ - firstName: nameValidation.required(), - lastName: nameValidation.required(), - email: joi - .string() - .email() - .required() - .custom((value, helpers) => { - const lowercasedValue = value.toLowerCase(); - if (value !== lowercasedValue) { - return helpers.message({ custom: "Email must be in lowercase" }); - } - return lowercasedValue; - }), - password: joi.string().min(8).required().pattern(passwordPattern), - profileImage: joi.any(), - inviteToken: joi.string().allow("").optional(), -}); - -const editUserBodyValidation = joi.object({ - firstName: nameValidation.optional(), - lastName: nameValidation.optional(), - profileImage: joi.any(), - newPassword: joi.string().min(8).pattern(passwordPattern), - password: joi.string().min(8).pattern(passwordPattern), - deleteProfileImage: joi.alternatives().try(joi.boolean(), joi.string().valid("true", "false")), -}); - -const recoveryValidation = joi.object({ - email: joi - .string() - .email({ tlds: { allow: false } }) - .required(), -}); - -const recoveryTokenBodyValidation = joi.object({ - recoveryToken: joi.string().required(), -}); - -const newPasswordValidation = joi.object({ - recoveryToken: joi.string().required(), - password: joi.string().min(8).required().pattern(passwordPattern), - confirm: joi.string(), -}); - -const deleteUserParamValidation = joi.object({ - email: joi.string().email().required(), -}); - -const inviteBodyValidation = joi.object({ - email: joi.string().trim().email().required().messages({ - "string.empty": "Email is required", - "string.email": "Must be a valid email address", - }), - role: joi.array().required(), - teamId: joi.string().required(), -}); - -const inviteVerificationBodyValidation = joi.object({ - token: joi.string().required(), -}); - -//**************************************** -// Monitors -//**************************************** - -const getMonitorByIdParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const getMonitorByIdQueryValidation = joi.object({ - status: joi.boolean(), - sortOrder: joi.string().valid("asc", "desc"), - limit: joi.number(), - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - numToDisplay: joi.number(), - normalize: joi.boolean(), - continent: joi.string().valid(...GeoContinents), -}); - -const getMonitorsByTeamIdParamValidation = joi.object({}); - -const getMonitorsByTeamIdQueryValidation = joi.object({ - type: joi - .alternatives() - .try( - joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), - joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) - ), - filter: joi.string().allow("", null), -}); - -const getMonitorsWithChecksQueryValidation = joi.object({ - limit: joi.number().integer().min(1).max(100).optional(), - page: joi.number().integer().min(0).optional(), - rowsPerPage: joi.number().integer().min(1).max(100).optional(), - filter: joi.string().allow("", null).optional(), - field: joi.string().optional(), - order: joi.string().valid("asc", "desc").optional(), - type: joi - .alternatives() - .try( - joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), - joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) - ) - .optional(), - explain: joi.boolean().optional(), -}); - -const getCertificateParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const createMonitorBodyValidation = joi.object({ - _id: joi.string(), - name: joi.string().required(), - description: joi.string().allow(null, ""), - type: joi.string().required(), - statusWindowSize: joi.number().min(1).max(20).default(5), - statusWindowThreshold: joi.number().min(1).max(100).default(60), - url: joi.string().required(), - ignoreTlsErrors: joi.boolean().default(false), - useAdvancedMatching: joi.boolean().default(false), - port: joi.number(), - isActive: joi.boolean(), - interval: joi.number(), - cpuAlertThreshold: joi.number(), - memoryAlertThreshold: joi.number(), - diskAlertThreshold: joi.number(), - tempAlertThreshold: joi.number(), - notifications: joi.array().items(joi.string()), - secret: joi.string(), - jsonPath: joi.string().allow(""), - expectedValue: joi.string().allow(""), - matchMethod: joi.string().allow(null, ""), - gameId: joi.string().allow(""), - grpcServiceName: joi.string().allow("").default(""), - selectedDisks: joi.array().items(joi.string()).optional(), - group: joi.string().max(50).trim().allow(null, "").optional(), - geoCheckEnabled: joi.boolean().optional(), - geoCheckLocations: joi - .array() - .items(joi.string().valid(...GeoContinents)) - .optional(), - geoCheckInterval: joi.number().min(300000).optional(), -}); - -const createMonitorsBodyValidation = joi.array().items( - createMonitorBodyValidation.keys({ - userId: joi.string().required(), - teamId: joi.string().required(), - }) -); - -const editMonitorBodyValidation = joi - .object({ - name: joi.string(), - statusWindowSize: joi.number().min(1).max(20).default(5), - statusWindowThreshold: joi.number().min(1).max(100).default(60), - description: joi.string().allow(null, ""), - interval: joi.number(), - notifications: joi.array().items(joi.string()), - secret: joi.string(), - ignoreTlsErrors: joi.boolean(), - useAdvancedMatching: joi.boolean(), - jsonPath: joi.string().allow(""), - expectedValue: joi.string().allow(""), - matchMethod: joi.string().allow(null, ""), - port: joi.number().min(1).max(65535), - cpuAlertThreshold: joi.number(), - memoryAlertThreshold: joi.number(), - diskAlertThreshold: joi.number(), - tempAlertThreshold: joi.number(), - gameId: joi.string().allow(""), - grpcServiceName: joi.string().allow(""), - selectedDisks: joi.array().items(joi.string()).optional(), - group: joi.string().max(50).trim().allow(null, "").optional(), - geoCheckEnabled: joi.boolean().optional(), - geoCheckLocations: joi - .array() - .items(joi.string().valid(...GeoContinents)) - .optional(), - geoCheckInterval: joi.number().min(300000).optional(), - }) - .options({ stripUnknown: true }); - -const pauseMonitorParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const getMonitorURLByQueryValidation = joi.object({ - monitorURL: joi.string().uri().required(), -}); - -const getHardwareDetailsByIdParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const getHardwareDetailsByIdQueryValidation = joi.object({ - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), -}); - -//**************************************** -// Alerts -//**************************************** - -const createAlertParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const createAlertBodyValidation = joi.object({ - checkId: joi.string().required(), - monitorId: joi.string().required(), - userId: joi.string().required(), - status: joi.boolean(), - message: joi.string(), - notifiedStatus: joi.boolean(), - acknowledgeStatus: joi.boolean(), -}); - -const getAlertsByUserIdParamValidation = joi.object({ - userId: joi.string().required(), -}); - -const getAlertsByMonitorIdParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const getAlertByIdParamValidation = joi.object({ - alertId: joi.string().required(), -}); - -const editAlertParamValidation = joi.object({ - alertId: joi.string().required(), -}); - -const editAlertBodyValidation = joi.object({ - status: joi.boolean(), - message: joi.string(), - notifiedStatus: joi.boolean(), - acknowledgeStatus: joi.boolean(), -}); - -const deleteAlertParamValidation = joi.object({ - alertId: joi.string().required(), -}); - -//**************************************** -// Checks -//**************************************** - -const createCheckParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const createCheckBodyValidation = joi.object({ - monitorId: joi.string().required(), - status: joi.boolean().required(), - responseTime: joi.number().required(), - statusCode: joi.number().required(), - message: joi.string().required(), -}); - -const ackCheckBodyValidation = joi.object({ - ack: joi.boolean(), -}); - -const ackAllChecksParamValidation = joi.object({ - monitorId: joi.string().optional(), - path: joi.string().valid("monitor", "team").required(), -}); - -const ackAllChecksBodyValidation = joi.object({ - ack: joi.boolean(), -}); - -const getChecksParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const getChecksQueryValidation = joi.object({ - type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"), - sortOrder: joi.string().valid("asc", "desc"), - limit: joi.number(), - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "up", "down", "resolve"), - ack: joi.boolean(), - page: joi.number(), - rowsPerPage: joi.number(), - status: joi.boolean(), - continent: joi.alternatives().try(joi.string().valid(...GeoContinents), joi.array().items(joi.string().valid(...GeoContinents))), -}); - -const getTeamChecksQueryValidation = joi.object({ - sortOrder: joi.string().valid("asc", "desc"), - limit: joi.number(), - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "up", "down", "resolve"), - ack: joi.boolean(), - page: joi.number(), - rowsPerPage: joi.number(), -}); - -const deleteChecksParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const deleteChecksByTeamIdParamValidation = joi.object({}); - -const updateChecksTTLBodyValidation = joi.object({ - ttl: joi.number().required(), -}); - -//**************************************** -// PageSpeedCheckValidation -//**************************************** - -const getPageSpeedCheckParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -//Validation schema for the monitorId parameter -const createPageSpeedCheckParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -//Validation schema for the monitorId body -const createPageSpeedCheckBodyValidation = joi.object({ - url: joi.string().required(), -}); - -const deletePageSpeedCheckParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -//**************************************** -// MaintenanceWindowValidation -//**************************************** - -const createMaintenanceWindowBodyValidation = joi.object({ - monitors: joi.array().items(joi.string()).required(), - name: joi.string().required(), - active: joi.boolean(), - duration: joi.number().required(), - durationUnit: joi.string().valid("seconds", "minutes", "hours", "days").required(), - start: joi.date().required(), - end: joi.date().required(), - repeat: joi.number().required(), - expiry: joi.date(), -}); - -const getMaintenanceWindowByIdParamValidation = joi.object({ - id: joi.string().required(), -}); - -const getMaintenanceWindowsByTeamIdQueryValidation = joi.object({ - active: joi.boolean(), - page: joi.number(), - rowsPerPage: joi.number(), - field: joi.string(), - order: joi.string().valid("asc", "desc"), -}); - -const getMaintenanceWindowsByMonitorIdParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -const deleteMaintenanceWindowByIdParamValidation = joi.object({ - id: joi.string().required(), -}); - -const editMaintenanceWindowByIdParamValidation = joi.object({ - id: joi.string().required(), -}); - -const editMaintenanceByIdWindowBodyValidation = joi.object({ - active: joi.boolean(), - name: joi.string(), - repeat: joi.number(), - start: joi.date(), - end: joi.date(), - expiry: joi.date(), - monitors: joi.array(), - duration: joi.number(), - durationUnit: joi.string().valid("seconds", "minutes", "hours", "days"), -}); - -//**************************************** -// SettingsValidation -//**************************************** -const updateAppSettingsBodyValidation = joi.object({ - checkTTL: joi.number().allow(""), - pagespeedApiKey: joi.string().allow(""), - language: joi.string().allow(""), - timezone: joi.string().allow(""), - showURL: joi.bool().optional(), - systemEmailHost: joi.string().allow(""), - systemEmailPort: joi.number().allow(""), - systemEmailAddress: joi.string().allow(""), - systemEmailPassword: joi.string().allow(""), - systemEmailUser: joi.string().allow(""), - systemEmailConnectionHost: joi.string().allow(""), - systemEmailTLSServername: joi.string().allow(""), - systemEmailSecure: joi.boolean(), - systemEmailPool: joi.boolean(), - systemEmailIgnoreTLS: joi.boolean(), - systemEmailRequireTLS: joi.boolean(), - systemEmailRejectUnauthorized: joi.boolean(), - - globalThresholds: joi - .object({ - cpu: joi.number().min(1).max(100).allow(""), - memory: joi.number().min(1).max(100).allow(""), - disk: joi.number().min(1).max(100).allow(""), - temperature: joi.number().min(1).max(150).allow(""), - }) - .optional(), -}); - -//**************************************** -// Status Page Validation -//**************************************** - -const getStatusPageParamValidation = joi.object({ - url: joi.string().required(), -}); - -const getStatusPageQueryValidation = joi.object({ - type: joi.string().valid("uptime").required(), - timeFrame: joi.number().optional(), -}); - -const createStatusPageBodyValidation = joi.object({ - type: joi.string().valid("uptime").required(), - companyName: joi.string().required(), - url: joi - .string() - .pattern(/^[a-zA-Z0-9_-]+$/) // Only allow alphanumeric, underscore, and hyphen - .required() - .messages({ - "string.pattern.base": "URL can only contain letters, numbers, underscores, and hyphens", - }), - timezone: joi.string().optional(), - color: joi.string().optional(), - monitors: joi - .array() - .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) - .required() - .messages({ - "string.pattern.base": "Must be a valid monitor ID", - "array.base": "Monitors must be an array", - "array.empty": "At least one monitor is required", - "any.required": "Monitors are required", - }), - subMonitors: joi - .array() - .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) - .optional(), - deleteSubmonitors: joi.boolean().optional(), - isPublished: joi.boolean(), - showCharts: joi.boolean().optional(), - showUptimePercentage: joi.boolean(), - showAdminLoginLink: joi.boolean().optional(), - removeLogo: joi.string().valid("true", "false").optional(), -}); - -const imageValidation = joi - .object({ - fieldname: joi.string().required(), - originalname: joi.string().required(), - encoding: joi.string().required(), - mimetype: joi.string().valid("image/jpeg", "image/png", "image/jpg").required().messages({ - "string.valid": "File must be a valid image (jpeg, jpg, or png)", - }), - size: joi.number().max(3145728).required().messages({ - "number.max": "File size must be less than 3MB", - }), - buffer: joi.binary().required(), - destination: joi.string(), - filename: joi.string(), - path: joi.string(), - }) - .messages({ - "any.required": "Image file is required", - }); - -const webhookConfigValidation = joi - .object({ - webhookUrl: joi - .string() - .uri() - .when("$platform", { - switch: [ - { - is: "telegram", - then: joi.optional(), - }, - { - is: "discord", - then: joi.required().messages({ - "string.empty": "Discord webhook URL is required", - "string.uri": "Discord webhook URL must be a valid URL", - "any.required": "Discord webhook URL is required", - }), - }, - { - is: "slack", - then: joi.required().messages({ - "string.empty": "Slack webhook URL is required", - "string.uri": "Slack webhook URL must be a valid URL", - "any.required": "Slack webhook URL is required", - }), - }, - ], - }), - botToken: joi.string().when("$platform", { - is: "telegram", - then: joi.required().messages({ - "string.empty": "Telegram bot token is required", - "any.required": "Telegram bot token is required", - }), - otherwise: joi.optional(), - }), - chatId: joi.string().when("$platform", { - is: "telegram", - then: joi.required().messages({ - "string.empty": "Telegram chat ID is required", - "any.required": "Telegram chat ID is required", - }), - otherwise: joi.optional(), - }), - }) - .required(); - -const triggerNotificationBodyValidation = joi.object({ - monitorId: joi.string().required().messages({ - "string.empty": "Monitor ID is required", - "any.required": "Monitor ID is required", - }), - type: joi.string().valid("webhook").required().messages({ - "string.empty": "Notification type is required", - "any.required": "Notification type is required", - "any.only": "Notification type must be webhook", - }), - platform: joi.string().valid("telegram", "discord", "slack").required().messages({ - "string.empty": "Platform type is required", - "any.required": "Platform type is required", - "any.only": "Platform must be telegram, discord, or slack", - }), - config: webhookConfigValidation.required().messages({ - "any.required": "Webhook configuration is required", - }), -}); - -const createNotificationBodyValidation = joi.object({ - notificationName: joi.string().required().messages({ - "string.empty": "Notification name is required", - "any.required": "Notification name is required", - }), - - type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty", "matrix").required().messages({ - "string.empty": "Notification type is required", - "any.required": "Notification type is required", - "any.only": "Notification type must be email, webhook, slack, discord, pager_duty, or matrix", - }), - - address: joi.when("type", { - switch: [ - { - is: "email", - then: joi.string().email().required().messages({ - "string.empty": "E-mail address cannot be empty", - "any.required": "E-mail address is required", - "string.email": "Please enter a valid e-mail address", - }), - }, - { - is: "pager_duty", - then: joi.string().required().messages({ - "string.empty": "PagerDuty integration key cannot be empty", - "any.required": "PagerDuty integration key is required", - }), - }, - { - is: joi.string().valid("webhook", "slack", "discord"), - then: joi.string().uri().required().messages({ - "string.empty": "Webhook URL cannot be empty", - "any.required": "Webhook URL is required", - "string.uri": "Please enter a valid Webhook URL", - }), - }, - { - is: "matrix", - then: joi.string().allow("").optional(), - }, - ], - }), - - homeserverUrl: joi.when("type", { - is: "matrix", - then: joi.string().uri().required().messages({ - "string.empty": "Homeserver URL cannot be empty", - "any.required": "Homeserver URL is required", - "string.uri": "Please enter a valid Homeserver URL", - }), - otherwise: joi.string().allow("").optional(), - }), - - roomId: joi.when("type", { - is: "matrix", - then: joi.string().required().messages({ - "string.empty": "Room ID cannot be empty", - "any.required": "Room ID is required", - }), - otherwise: joi.string().allow("").optional(), - }), - - accessToken: joi.when("type", { - is: "matrix", - then: joi.string().required().messages({ - "string.empty": "Access Token cannot be empty", - "any.required": "Access Token is required", - }), - otherwise: joi.string().allow("").optional(), - }), -}); - -//**************************************** -// Announcetment Page Validation -//**************************************** - -const createAnnouncementValidation = joi.object({ - title: joi.string().required().messages({ - "string.empty": "Title cannot be empty", - "any.required": "Title is required", - }), - message: joi.string().required().messages({ - "string.empty": "Message cannot be empty", - "any.required": "Message is required", - }), - userId: joi.string().required(), -}); - -const sendTestEmailBodyValidation = joi.object({ - to: joi.string().required(), - systemEmailHost: joi.string(), - systemEmailPort: joi.number(), - systemEmailSecure: joi.boolean(), - systemEmailPool: joi.boolean(), - systemEmailAddress: joi.string(), - systemEmailPassword: joi.string(), - systemEmailUser: joi.string(), - systemEmailConnectionHost: joi.string().allow("").optional(), - systemEmailIgnoreTLS: joi.boolean(), - systemEmailRequireTLS: joi.boolean(), - systemEmailRejectUnauthorized: joi.boolean(), - systemEmailTLSServername: joi.string().allow("").optional(), -}); - -const getUserByIdParamValidation = joi.object({ - userId: joi.string().required(), -}); - -const editUserByIdParamValidation = joi.object({ - userId: joi.string().required(), -}); - -const editUserByIdBodyValidation = joi.object({ - firstName: nameValidation.required(), - lastName: nameValidation.required(), - role: joi - .array() - .items(joi.string().valid(...UserRoles)) - .min(1) - .required(), -}); - -const editSuperadminUserByIdBodyValidation = joi.object({ - firstName: nameValidation.required(), - lastName: nameValidation.required(), - role: joi - .array() - .items(joi.string().valid(...UserRoles)) - .min(1) - .required(), -}); - -const editUserPasswordByIdBodyValidation = joi.object({ - password: joi.string().min(8).required().pattern(passwordPattern), -}); - -const createUserBodyValidation = joi.object({ - firstName: nameValidation.required(), - lastName: nameValidation.required(), - email: joi - .string() - .email() - .required() - .custom((value, helpers) => { - const lowercasedValue = value.toLowerCase(); - if (value !== lowercasedValue) { - return helpers.message({ custom: "Email must be in lowercase" }); - } - return lowercasedValue; - }), - password: joi.string().min(8).required().pattern(passwordPattern), - role: joi - .array() - .items(joi.string().valid(...UserRoles)) - .min(1) - .required(), -}); - -export { - roleValidatior, - loginValidation, - registrationBodyValidation, - recoveryValidation, - recoveryTokenBodyValidation, - newPasswordValidation, - inviteBodyValidation, - inviteVerificationBodyValidation, - createMonitorBodyValidation, - createMonitorsBodyValidation, - getMonitorByIdParamValidation, - getMonitorByIdQueryValidation, - getMonitorsByTeamIdParamValidation, - getMonitorsByTeamIdQueryValidation, - getMonitorsWithChecksQueryValidation, - getHardwareDetailsByIdParamValidation, - getHardwareDetailsByIdQueryValidation, - getCertificateParamValidation, - editMonitorBodyValidation, - pauseMonitorParamValidation, - getMonitorURLByQueryValidation, - editUserBodyValidation, - createAlertParamValidation, - createAlertBodyValidation, - getAlertsByUserIdParamValidation, - getAlertsByMonitorIdParamValidation, - getAlertByIdParamValidation, - editAlertParamValidation, - editAlertBodyValidation, - deleteAlertParamValidation, - createCheckParamValidation, - createCheckBodyValidation, - getChecksParamValidation, - getChecksQueryValidation, - getTeamChecksQueryValidation, - ackCheckBodyValidation, - ackAllChecksParamValidation, - ackAllChecksBodyValidation, - deleteChecksParamValidation, - deleteChecksByTeamIdParamValidation, - updateChecksTTLBodyValidation, - deleteUserParamValidation, - getPageSpeedCheckParamValidation, - createPageSpeedCheckParamValidation, - deletePageSpeedCheckParamValidation, - createPageSpeedCheckBodyValidation, - createMaintenanceWindowBodyValidation, - getMaintenanceWindowByIdParamValidation, - getMaintenanceWindowsByTeamIdQueryValidation, - getMaintenanceWindowsByMonitorIdParamValidation, - deleteMaintenanceWindowByIdParamValidation, - editMaintenanceWindowByIdParamValidation, - editMaintenanceByIdWindowBodyValidation, - updateAppSettingsBodyValidation, - createStatusPageBodyValidation, - getStatusPageParamValidation, - getStatusPageQueryValidation, - imageValidation, - triggerNotificationBodyValidation, - createNotificationBodyValidation, - webhookConfigValidation, - createAnnouncementValidation, - sendTestEmailBodyValidation, - getUserByIdParamValidation, - editUserByIdParamValidation, - editUserByIdBodyValidation, - editSuperadminUserByIdBodyValidation, - editUserPasswordByIdBodyValidation, - createUserBodyValidation, -}; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts new file mode 100644 index 000000000..ac3e32966 --- /dev/null +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -0,0 +1,53 @@ +import joi from "joi"; + +//**************************************** +// Maintenance Window Validations +//**************************************** + +export const createMaintenanceWindowBodyValidation = joi.object({ + monitors: joi.array().items(joi.string()).required(), + name: joi.string().required(), + active: joi.boolean(), + duration: joi.number().required(), + durationUnit: joi.string().valid("seconds", "minutes", "hours", "days").required(), + start: joi.date().required(), + end: joi.date().required(), + repeat: joi.number().required(), + expiry: joi.date(), +}); + +export const getMaintenanceWindowByIdParamValidation = joi.object({ + id: joi.string().required(), +}); + +export const getMaintenanceWindowsByTeamIdQueryValidation = joi.object({ + active: joi.boolean(), + page: joi.number(), + rowsPerPage: joi.number(), + field: joi.string(), + order: joi.string().valid("asc", "desc"), +}); + +export const getMaintenanceWindowsByMonitorIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +export const deleteMaintenanceWindowByIdParamValidation = joi.object({ + id: joi.string().required(), +}); + +export const editMaintenanceWindowByIdParamValidation = joi.object({ + id: joi.string().required(), +}); + +export const editMaintenanceByIdWindowBodyValidation = joi.object({ + active: joi.boolean(), + name: joi.string(), + repeat: joi.number(), + start: joi.date(), + end: joi.date(), + expiry: joi.date(), + monitors: joi.array(), + duration: joi.number(), + durationUnit: joi.string().valid("seconds", "minutes", "hours", "days"), +}); diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts new file mode 100644 index 000000000..37c669ca3 --- /dev/null +++ b/server/src/validation/monitorValidation.ts @@ -0,0 +1,127 @@ +import joi from "joi"; +import { GeoContinents } from "@/types/geoCheck.js"; + +export const getMonitorByIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +export const getMonitorByIdQueryValidation = joi.object({ + status: joi.boolean(), + sortOrder: joi.string().valid("asc", "desc"), + limit: joi.number(), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), + numToDisplay: joi.number(), + normalize: joi.boolean(), + continent: joi.string().valid(...GeoContinents), +}); + +export const getMonitorsByTeamIdParamValidation = joi.object({}); + +export const getMonitorsByTeamIdQueryValidation = joi.object({ + type: joi + .alternatives() + .try( + joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), + joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) + ), + filter: joi.string().allow("", null), +}); + +export const getMonitorsWithChecksQueryValidation = joi.object({ + limit: joi.number().integer().min(1).max(100).optional(), + page: joi.number().integer().min(0).optional(), + rowsPerPage: joi.number().integer().min(1).max(100).optional(), + filter: joi.string().allow("", null).optional(), + field: joi.string().optional(), + order: joi.string().valid("asc", "desc").optional(), + type: joi + .alternatives() + .try( + joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), + joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) + ) + .optional(), + explain: joi.boolean().optional(), +}); + +export const getCertificateParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +export const createMonitorBodyValidation = joi.object({ + _id: joi.string(), + name: joi.string().required(), + description: joi.string().allow(null, ""), + type: joi.string().required(), + statusWindowSize: joi.number().min(1).max(20).default(5), + statusWindowThreshold: joi.number().min(1).max(100).default(60), + url: joi.string().required(), + ignoreTlsErrors: joi.boolean().default(false), + useAdvancedMatching: joi.boolean().default(false), + port: joi.number(), + isActive: joi.boolean(), + interval: joi.number(), + cpuAlertThreshold: joi.number(), + memoryAlertThreshold: joi.number(), + diskAlertThreshold: joi.number(), + tempAlertThreshold: joi.number(), + notifications: joi.array().items(joi.string()), + secret: joi.string(), + jsonPath: joi.string().allow(""), + expectedValue: joi.string().allow(""), + matchMethod: joi.string().allow(null, ""), + gameId: joi.string().allow(""), + grpcServiceName: joi.string().allow("").default(""), + selectedDisks: joi.array().items(joi.string()).optional(), + group: joi.string().max(50).trim().allow(null, "").optional(), + geoCheckEnabled: joi.boolean().optional(), + geoCheckLocations: joi + .array() + .items(joi.string().valid(...GeoContinents)) + .optional(), + geoCheckInterval: joi.number().min(300000).optional(), +}); + +export const editMonitorBodyValidation = joi + .object({ + name: joi.string(), + statusWindowSize: joi.number().min(1).max(20).default(5), + statusWindowThreshold: joi.number().min(1).max(100).default(60), + description: joi.string().allow(null, ""), + interval: joi.number(), + notifications: joi.array().items(joi.string()), + secret: joi.string(), + ignoreTlsErrors: joi.boolean(), + useAdvancedMatching: joi.boolean(), + jsonPath: joi.string().allow(""), + expectedValue: joi.string().allow(""), + matchMethod: joi.string().allow(null, ""), + port: joi.number().min(1).max(65535), + cpuAlertThreshold: joi.number(), + memoryAlertThreshold: joi.number(), + diskAlertThreshold: joi.number(), + tempAlertThreshold: joi.number(), + gameId: joi.string().allow(""), + grpcServiceName: joi.string().allow(""), + selectedDisks: joi.array().items(joi.string()).optional(), + group: joi.string().max(50).trim().allow(null, "").optional(), + geoCheckEnabled: joi.boolean().optional(), + geoCheckLocations: joi + .array() + .items(joi.string().valid(...GeoContinents)) + .optional(), + geoCheckInterval: joi.number().min(300000).optional(), + }) + .options({ stripUnknown: true }); + +export const pauseMonitorParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +export const getHardwareDetailsByIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +export const getHardwareDetailsByIdQueryValidation = joi.object({ + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), +}); diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts new file mode 100644 index 000000000..73e6750f8 --- /dev/null +++ b/server/src/validation/notificationValidation.ts @@ -0,0 +1,78 @@ +import joi from "joi"; + +//**************************************** +// Notification Validations +//**************************************** + +export const createNotificationBodyValidation = joi.object({ + notificationName: joi.string().required().messages({ + "string.empty": "Notification name is required", + "any.required": "Notification name is required", + }), + + type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty", "matrix").required().messages({ + "string.empty": "Notification type is required", + "any.required": "Notification type is required", + "any.only": "Notification type must be email, webhook, slack, discord, pager_duty, or matrix", + }), + + address: joi.when("type", { + switch: [ + { + is: "email", + then: joi.string().email().required().messages({ + "string.empty": "E-mail address cannot be empty", + "any.required": "E-mail address is required", + "string.email": "Please enter a valid e-mail address", + }), + }, + { + is: "pager_duty", + then: joi.string().required().messages({ + "string.empty": "PagerDuty integration key cannot be empty", + "any.required": "PagerDuty integration key is required", + }), + }, + { + is: joi.string().valid("webhook", "slack", "discord"), + then: joi.string().uri().required().messages({ + "string.empty": "Webhook URL cannot be empty", + "any.required": "Webhook URL is required", + "string.uri": "Please enter a valid Webhook URL", + }), + }, + { + is: "matrix", + then: joi.string().allow("").optional(), + }, + ], + }), + + homeserverUrl: joi.when("type", { + is: "matrix", + then: joi.string().uri().required().messages({ + "string.empty": "Homeserver URL cannot be empty", + "any.required": "Homeserver URL is required", + "string.uri": "Please enter a valid Homeserver URL", + }), + otherwise: joi.string().allow("").optional(), + }), + + roomId: joi.when("type", { + is: "matrix", + then: joi.string().required().messages({ + "string.empty": "Room ID cannot be empty", + "any.required": "Room ID is required", + }), + otherwise: joi.string().allow("").optional(), + }), + + accessToken: joi.when("type", { + is: "matrix", + then: joi.string().required().messages({ + "string.empty": "Access Token cannot be empty", + "any.required": "Access Token is required", + }), + otherwise: joi.string().allow("").optional(), + }), +}); diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts new file mode 100644 index 000000000..6321f13f0 --- /dev/null +++ b/server/src/validation/settingsValidation.ts @@ -0,0 +1,34 @@ +import joi from "joi"; + +//**************************************** +// Settings Validations +//**************************************** + +export const updateAppSettingsBodyValidation = joi.object({ + checkTTL: joi.number().allow(""), + pagespeedApiKey: joi.string().allow(""), + language: joi.string().allow(""), + timezone: joi.string().allow(""), + showURL: joi.bool().optional(), + systemEmailHost: joi.string().allow(""), + systemEmailPort: joi.number().allow(""), + systemEmailAddress: joi.string().allow(""), + systemEmailPassword: joi.string().allow(""), + systemEmailUser: joi.string().allow(""), + systemEmailConnectionHost: joi.string().allow(""), + systemEmailTLSServername: joi.string().allow(""), + systemEmailSecure: joi.boolean(), + systemEmailPool: joi.boolean(), + systemEmailIgnoreTLS: joi.boolean(), + systemEmailRequireTLS: joi.boolean(), + systemEmailRejectUnauthorized: joi.boolean(), + + globalThresholds: joi + .object({ + cpu: joi.number().min(1).max(100).allow(""), + memory: joi.number().min(1).max(100).allow(""), + disk: joi.number().min(1).max(100).allow(""), + temperature: joi.number().min(1).max(150).allow(""), + }) + .optional(), +}); diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts new file mode 100644 index 000000000..ce01705ea --- /dev/null +++ b/server/src/validation/shared.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import { type UserRole } from "@/types/user.js"; + +/** + * Password pattern: requires at least one lowercase, uppercase, number, and special character + */ +export const passwordPattern = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/; + +/** + * Reusable name validation schema + */ +export const nameValidation = z + .string() + .trim() + .max(50, "Name must be less than 50 characters") + .regex( + /^(?=.*[\p{L}\p{Sc}])[\p{L}\p{Sc}\s'\-().]+$/u, + "Names must contain at least 1 letter and may only include letters, currency symbols, spaces, apostrophes, hyphens (-), periods (.), and parentheses ()." + ); + +/** + * Reusable email validation with lowercase enforcement + */ +export const lowercaseEmailValidation = z + .email() + .transform((val) => val.toLowerCase()) + .refine((val) => val === val.toLowerCase(), { + message: "Email must be in lowercase", + }); + +/** + * Custom validator for role-based authorization + */ +export const roleValidator = (allowedRoles: UserRole[]) => { + return z.array(z.custom()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), { + message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`, + }); +}; diff --git a/server/src/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts new file mode 100644 index 000000000..bca638fc4 --- /dev/null +++ b/server/src/validation/statusPageValidation.ts @@ -0,0 +1,68 @@ +import joi from "joi"; + +//**************************************** +// Status Page Validations +//**************************************** + +export const getStatusPageParamValidation = joi.object({ + url: joi.string().required(), +}); + +export const getStatusPageQueryValidation = joi.object({ + type: joi.string().valid("uptime").required(), + timeFrame: joi.number().optional(), +}); + +export const createStatusPageBodyValidation = joi.object({ + type: joi.string().valid("uptime").required(), + companyName: joi.string().required(), + url: joi + .string() + .pattern(/^[a-zA-Z0-9_-]+$/) + .required() + .messages({ + "string.pattern.base": "URL can only contain letters, numbers, underscores, and hyphens", + }), + timezone: joi.string().optional(), + color: joi.string().optional(), + monitors: joi + .array() + .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) + .required() + .messages({ + "string.pattern.base": "Must be a valid monitor ID", + "array.base": "Monitors must be an array", + "array.empty": "At least one monitor is required", + "any.required": "Monitors are required", + }), + subMonitors: joi + .array() + .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) + .optional(), + deleteSubmonitors: joi.boolean().optional(), + isPublished: joi.boolean(), + showCharts: joi.boolean().optional(), + showUptimePercentage: joi.boolean(), + showAdminLoginLink: joi.boolean().optional(), + removeLogo: joi.string().valid("true", "false").optional(), +}); + +export const imageValidation = joi + .object({ + fieldname: joi.string().required(), + originalname: joi.string().required(), + encoding: joi.string().required(), + mimetype: joi.string().valid("image/jpeg", "image/png", "image/jpg").required().messages({ + "string.valid": "File must be a valid image (jpeg, jpg, or png)", + }), + size: joi.number().max(3145728).required().messages({ + "number.max": "File size must be less than 3MB", + }), + buffer: joi.binary().required(), + destination: joi.string(), + filename: joi.string(), + path: joi.string(), + }) + .messages({ + "any.required": "Image file is required", + }); diff --git a/server/src/validation/userValidation.ts b/server/src/validation/userValidation.ts new file mode 100644 index 000000000..9e11c8c57 --- /dev/null +++ b/server/src/validation/userValidation.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { UserRoles } from "@/types/user.js"; +import { nameValidation, lowercaseEmailValidation, passwordPattern } from "./shared.js"; + +//**************************************** +// User Validations +//**************************************** + +export const getUserByIdParamValidation = z.object({ + userId: z.string().min(1, "User ID is required"), +}); + +export const editUserByIdParamValidation = z.object({ + userId: z.string().min(1, "User ID is required"), +}); + +export const editUserByIdBodyValidation = z.object({ + firstName: nameValidation, + lastName: nameValidation, + role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), +}); + +export const editSuperadminUserByIdBodyValidation = z.object({ + firstName: nameValidation, + lastName: nameValidation, + role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), +}); + +export const editUserPasswordByIdBodyValidation = z.object({ + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), +}); + +export const createUserBodyValidation = z.object({ + firstName: nameValidation, + lastName: nameValidation, + email: lowercaseEmailValidation, + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(passwordPattern, "Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character"), + role: z.array(z.enum(UserRoles)).min(1, "At least one role is required"), +}); + +export const editUserBodyValidation = z.object({ + firstName: nameValidation.optional(), + lastName: nameValidation.optional(), + email: lowercaseEmailValidation.optional(), + profilePicture: z.string().optional(), +}); From a5d3c95145184895fc5360f379b26f70a63e73e0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 17:29:48 +0000 Subject: [PATCH 02/11] validation refactoring --- server/src/controllers/checkController.ts | 15 ++--- server/src/controllers/geoCheckController.ts | 4 +- server/src/controllers/settingsController.ts | 2 +- .../src/validation/announcementValidation.ts | 21 ------ server/src/validation/checkValidation.ts | 67 ++++++++----------- server/src/validation/index.ts | 1 - .../src/validation/notificationValidation.ts | 17 +++++ 7 files changed, 53 insertions(+), 74 deletions(-) delete mode 100644 server/src/validation/announcementValidation.ts diff --git a/server/src/controllers/checkController.ts b/server/src/controllers/checkController.ts index 39c4db02a..ec6722bd3 100755 --- a/server/src/controllers/checkController.ts +++ b/server/src/controllers/checkController.ts @@ -7,9 +7,6 @@ import { deleteChecksParamValidation, deleteChecksByTeamIdParamValidation, updateChecksTTLBodyValidation, - ackCheckBodyValidation, - ackAllChecksParamValidation, - ackAllChecksBodyValidation, } from "@/validation/checkValidation.js"; const SERVICE_NAME = "checkController"; @@ -28,8 +25,8 @@ class CheckController { getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => { try { - await getChecksParamValidation.validateAsync(req.params); - await getChecksQueryValidation.validateAsync(req.query); + getChecksParamValidation.parse(req.params); + getChecksQueryValidation.parse(req.query); const result = await this.checkService.getChecksByMonitor({ monitorId: req?.params?.monitorId, @@ -49,7 +46,7 @@ class CheckController { getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => { try { - await getTeamChecksQueryValidation.validateAsync(req.query); + getTeamChecksQueryValidation.parse(req.query); const checkData = await this.checkService.getChecksByTeam({ teamId: req?.user?.teamId, query: req?.query, @@ -80,7 +77,7 @@ class CheckController { deleteChecks = async (req: Request, res: Response, next: NextFunction) => { try { - await deleteChecksParamValidation.validateAsync(req.params); + deleteChecksParamValidation.parse(req.params); const deletedCount = await this.checkService.deleteChecks({ monitorId: req.params.monitorId as string, @@ -99,7 +96,7 @@ class CheckController { deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - await deleteChecksByTeamIdParamValidation.validateAsync(req.params); + deleteChecksByTeamIdParamValidation.parse(req.params); const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId }); @@ -115,7 +112,7 @@ class CheckController { updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => { try { - await updateChecksTTLBodyValidation.validateAsync(req.body); + updateChecksTTLBodyValidation.parse(req.body); await this.checkService.updateChecksTTL({ teamId: req?.user?.teamId, diff --git a/server/src/controllers/geoCheckController.ts b/server/src/controllers/geoCheckController.ts index 056599000..cfa7fe056 100644 --- a/server/src/controllers/geoCheckController.ts +++ b/server/src/controllers/geoCheckController.ts @@ -18,8 +18,8 @@ class GeoCheckController { getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => { try { - await getChecksParamValidation.validateAsync(req.params); - await getChecksQueryValidation.validateAsync(req.query); + getChecksParamValidation.parse(req.params); + getChecksQueryValidation.parse(req.query); const result = await this.geoChecksService.getGeoChecksByMonitor({ monitorId: req?.params?.monitorId as string, diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 354f34858..69443fd6a 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js"; -import { sendTestEmailBodyValidation } from "@/validation/announcementValidation.js"; +import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js"; import { AppError } from "@/utils/AppError.js"; const SERVICE_NAME = "SettingsController"; diff --git a/server/src/validation/announcementValidation.ts b/server/src/validation/announcementValidation.ts deleted file mode 100644 index 5f3514709..000000000 --- a/server/src/validation/announcementValidation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod"; - -//**************************************** -// Announcement Validations -//**************************************** - -export const sendTestEmailBodyValidation = z.object({ - to: z.string().min(1, "To field is required"), - systemEmailHost: z.string().optional(), - systemEmailPort: z.number().optional(), - systemEmailSecure: z.boolean().optional(), - systemEmailPool: z.boolean().optional(), - systemEmailAddress: z.string().optional(), - systemEmailPassword: z.string().optional(), - systemEmailUser: z.string().optional(), - systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(), - systemEmailIgnoreTLS: z.boolean().optional(), - systemEmailRequireTLS: z.boolean().optional(), - systemEmailRejectUnauthorized: z.boolean().optional(), - systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(), -}); diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index b88415876..011507692 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -1,56 +1,43 @@ -import joi from "joi"; +import { z } from "zod"; import { GeoContinents } from "@/types/geoCheck.js"; //**************************************** // Check Validations //**************************************** -export const ackCheckBodyValidation = joi.object({ - ack: joi.boolean(), +export const getChecksParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), }); -export const ackAllChecksParamValidation = joi.object({ - monitorId: joi.string().optional(), - path: joi.string().valid("monitor", "team").required(), +export const getChecksQueryValidation = z.object({ + type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"]).optional(), + sortOrder: z.enum(["asc", "desc"]).optional(), + limit: z.number().optional(), + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), + filter: z.enum(["all", "up", "down", "resolve"]).optional(), + ack: z.boolean().optional(), + page: z.number().optional(), + rowsPerPage: z.number().optional(), + status: z.boolean().optional(), + continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(), }); -export const ackAllChecksBodyValidation = joi.object({ - ack: joi.boolean(), +export const getTeamChecksQueryValidation = z.object({ + sortOrder: z.enum(["asc", "desc"]).optional(), + limit: z.number().optional(), + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), + filter: z.enum(["all", "up", "down", "resolve"]).optional(), + ack: z.boolean().optional(), + page: z.number().optional(), + rowsPerPage: z.number().optional(), }); -export const getChecksParamValidation = joi.object({ - monitorId: joi.string().required(), +export const deleteChecksParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), }); -export const getChecksQueryValidation = joi.object({ - type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"), - sortOrder: joi.string().valid("asc", "desc"), - limit: joi.number(), - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "up", "down", "resolve"), - ack: joi.boolean(), - page: joi.number(), - rowsPerPage: joi.number(), - status: joi.boolean(), - continent: joi.alternatives().try(joi.string().valid(...GeoContinents), joi.array().items(joi.string().valid(...GeoContinents))), -}); +export const deleteChecksByTeamIdParamValidation = z.object({}); -export const getTeamChecksQueryValidation = joi.object({ - sortOrder: joi.string().valid("asc", "desc"), - limit: joi.number(), - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - filter: joi.string().valid("all", "up", "down", "resolve"), - ack: joi.boolean(), - page: joi.number(), - rowsPerPage: joi.number(), -}); - -export const deleteChecksParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -export const deleteChecksByTeamIdParamValidation = joi.object({}); - -export const updateChecksTTLBodyValidation = joi.object({ - ttl: joi.number().required(), +export const updateChecksTTLBodyValidation = z.object({ + ttl: z.number().min(1, "TTL is required"), }); diff --git a/server/src/validation/index.ts b/server/src/validation/index.ts index cd04ae2d2..4d7140d01 100644 --- a/server/src/validation/index.ts +++ b/server/src/validation/index.ts @@ -16,5 +16,4 @@ export * from "./maintenanceWindowValidation.js"; export * from "./settingsValidation.js"; export * from "./statusPageValidation.js"; export * from "./notificationValidation.js"; -export * from "./announcementValidation.js"; export * from "./userValidation.js"; diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 73e6750f8..43eae5575 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -1,4 +1,5 @@ import joi from "joi"; +import { z } from "zod"; //**************************************** // Notification Validations @@ -76,3 +77,19 @@ export const createNotificationBodyValidation = joi.object({ otherwise: joi.string().allow("").optional(), }), }); + +export const sendTestEmailBodyValidation = z.object({ + to: z.string().min(1, "To field is required"), + systemEmailHost: z.string().optional(), + systemEmailPort: z.number().optional(), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + systemEmailAddress: z.string().optional(), + systemEmailPassword: z.string().optional(), + systemEmailUser: z.string().optional(), + systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(), + systemEmailIgnoreTLS: z.boolean().optional(), + systemEmailRequireTLS: z.boolean().optional(), + systemEmailRejectUnauthorized: z.boolean().optional(), + systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(), +}); From b997c43bd5e3040d6ae8d6207a2bec235484b954 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 17:46:33 +0000 Subject: [PATCH 03/11] check, monitor, maintenance --- .../maintenanceWindowController.ts | 14 +- server/src/controllers/monitorController.ts | 36 +-- server/src/validation/checkValidation.ts | 31 ++- .../validation/maintenanceWindowValidation.ts | 70 +++--- server/src/validation/monitorValidation.ts | 205 +++++++++--------- 5 files changed, 181 insertions(+), 175 deletions(-) diff --git a/server/src/controllers/maintenanceWindowController.ts b/server/src/controllers/maintenanceWindowController.ts index d4c99a549..ed16911bf 100644 --- a/server/src/controllers/maintenanceWindowController.ts +++ b/server/src/controllers/maintenanceWindowController.ts @@ -25,7 +25,7 @@ class MaintenanceWindowController { createMaintenanceWindows = async (req: Request, res: Response, next: NextFunction) => { try { - await createMaintenanceWindowBodyValidation.validateAsync(req.body); + createMaintenanceWindowBodyValidation.parse(req.body); const teamId = requireTeamId(req?.user?.teamId); await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body }); @@ -40,7 +40,7 @@ class MaintenanceWindowController { }; getMaintenanceWindowById = async (req: Request, res: Response, next: NextFunction) => { try { - await getMaintenanceWindowByIdParamValidation.validateAsync(req.params); + getMaintenanceWindowByIdParamValidation.parse(req.params); const teamId = requireTeamId(req.user?.teamId); @@ -58,7 +58,7 @@ class MaintenanceWindowController { getMaintenanceWindowsByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query); + getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query); const teamId = requireTeamId(req?.user?.teamId); @@ -76,7 +76,7 @@ class MaintenanceWindowController { getMaintenanceWindowsByMonitorId = async (req: Request, res: Response, next: NextFunction) => { try { - await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params); + getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params); const teamId = requireTeamId(req?.user?.teamId); @@ -96,7 +96,7 @@ class MaintenanceWindowController { }; deleteMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => { try { - await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params); + deleteMaintenanceWindowByIdParamValidation.parse(req.params); const teamId = requireTeamId(req?.user?.teamId); @@ -113,8 +113,8 @@ class MaintenanceWindowController { editMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => { try { - await editMaintenanceWindowByIdParamValidation.validateAsync(req.params); - await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body); + editMaintenanceWindowByIdParamValidation.parse(req.params); + editMaintenanceByIdWindowBodyValidation.parse(req.body); const teamId = requireTeamId(req.user?.teamId); diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index 4dba18abc..1d222e6e2 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -45,7 +45,7 @@ class MonitorController { getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => { try { - await getCertificateParamValidation.validateAsync(req.params); + getCertificateParamValidation.parse(req.params); const teamId = requireTeamId(req?.user?.teamId); const monitorId = requireString(req.params?.monitorId, "Monitor ID"); const monitor = await this.monitorService.getMonitorById({ teamId, monitorId }); @@ -88,8 +88,8 @@ class MonitorController { getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => { try { - await getHardwareDetailsByIdParamValidation.validateAsync(req.params); - await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); + getHardwareDetailsByIdParamValidation.parse(req.params); + getHardwareDetailsByIdQueryValidation.parse(req.query); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent"; @@ -112,8 +112,8 @@ class MonitorController { }; getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => { try { - await getHardwareDetailsByIdParamValidation.validateAsync(req.params); - await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); + getHardwareDetailsByIdParamValidation.parse(req.params); + getHardwareDetailsByIdQueryValidation.parse(req.query); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const dateRange = requireString(req?.query?.dateRange, "dateRange"); @@ -137,8 +137,8 @@ class MonitorController { getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => { try { - await getMonitorByIdParamValidation.validateAsync(req.params); - await getMonitorByIdQueryValidation.validateAsync(req.query); + getMonitorByIdParamValidation.parse(req.params); + getMonitorByIdQueryValidation.parse(req.query); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const dateRange = requireString(req?.query?.dateRange, "dateRange"); @@ -169,8 +169,8 @@ class MonitorController { getMonitorById = async (req: Request, res: Response, next: NextFunction) => { try { - await getMonitorByIdParamValidation.validateAsync(req.params); - await getMonitorByIdQueryValidation.validateAsync(req.query); + getMonitorByIdParamValidation.parse(req.params); + getMonitorByIdQueryValidation.parse(req.query); const teamId = requireTeamId(req?.user?.teamId); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); @@ -189,7 +189,7 @@ class MonitorController { createMonitor = async (req: Request, res: Response, next: NextFunction) => { try { - await createMonitorBodyValidation.validateAsync(req.body); + createMonitorBodyValidation.parse(req.body); const userId = requireString(req?.user?.id, "User ID"); const teamId = requireTeamId(req?.user?.teamId); @@ -231,7 +231,7 @@ class MonitorController { deleteMonitor = async (req: Request, res: Response, next: NextFunction) => { try { - await getMonitorByIdParamValidation.validateAsync(req.params); + getMonitorByIdParamValidation.parse(req.params); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const teamId = requireTeamId(req?.user?.teamId); @@ -264,8 +264,8 @@ class MonitorController { editMonitor = async (req: Request, res: Response, next: NextFunction) => { try { - await getMonitorByIdParamValidation.validateAsync(req.params); - await editMonitorBodyValidation.validateAsync(req.body); + getMonitorByIdParamValidation.parse(req.params); + editMonitorBodyValidation.parse(req.body); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const teamId = requireTeamId(req?.user?.teamId); @@ -283,7 +283,7 @@ class MonitorController { pauseMonitor = async (req: Request, res: Response, next: NextFunction) => { try { - await pauseMonitorParamValidation.validateAsync(req.params); + pauseMonitorParamValidation.parse(req.params); const monitorId = requireString(req?.params?.monitorId, "Monitor ID"); const teamId = requireTeamId(req?.user?.teamId); @@ -336,8 +336,8 @@ class MonitorController { getMonitorsByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - await getMonitorsByTeamIdParamValidation.validateAsync(req.params); - await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); + getMonitorsByTeamIdParamValidation.parse(req.params); + getMonitorsByTeamIdQueryValidation.parse(req.query); const teamId = requireTeamId(req?.user?.teamId); const type = parseMonitorTypeFilter(req.query?.type); @@ -357,8 +357,8 @@ class MonitorController { getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => { try { - await getMonitorsByTeamIdParamValidation.validateAsync(req.params); - await getMonitorsWithChecksQueryValidation.validateAsync(req.query); + getMonitorsByTeamIdParamValidation.parse(req.params); + getMonitorsWithChecksQueryValidation.parse(req.query); const explain = optionalBoolean(req?.query?.explain, "explain"); const limit = optionalNumber(req?.query?.limit, "limit"); const page = optionalNumber(req?.query?.page, "page"); diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index 011507692..6c098bec7 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -5,6 +5,19 @@ import { GeoContinents } from "@/types/geoCheck.js"; // Check Validations //**************************************** +export const ackCheckBodyValidation = z.object({ + ack: z.coerce.boolean().optional(), +}); + +export const ackAllChecksParamValidation = z.object({ + monitorId: z.string().optional(), + path: z.enum(["monitor", "team"]), +}); + +export const ackAllChecksBodyValidation = z.object({ + ack: z.coerce.boolean().optional(), +}); + export const getChecksParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); @@ -12,24 +25,24 @@ export const getChecksParamValidation = z.object({ export const getChecksQueryValidation = z.object({ type: z.enum(["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc"]).optional(), sortOrder: z.enum(["asc", "desc"]).optional(), - limit: z.number().optional(), + limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), filter: z.enum(["all", "up", "down", "resolve"]).optional(), - ack: z.boolean().optional(), - page: z.number().optional(), - rowsPerPage: z.number().optional(), - status: z.boolean().optional(), + ack: z.coerce.boolean().optional(), + page: z.coerce.number().optional(), + rowsPerPage: z.coerce.number().optional(), + status: z.coerce.boolean().optional(), continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(), }); export const getTeamChecksQueryValidation = z.object({ sortOrder: z.enum(["asc", "desc"]).optional(), - limit: z.number().optional(), + limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), filter: z.enum(["all", "up", "down", "resolve"]).optional(), - ack: z.boolean().optional(), - page: z.number().optional(), - rowsPerPage: z.number().optional(), + ack: z.coerce.boolean().optional(), + page: z.coerce.number().optional(), + rowsPerPage: z.coerce.number().optional(), }); export const deleteChecksParamValidation = z.object({ diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index ac3e32966..c1e65c733 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -1,53 +1,53 @@ -import joi from "joi"; +import { z } from "zod"; //**************************************** // Maintenance Window Validations //**************************************** -export const createMaintenanceWindowBodyValidation = joi.object({ - monitors: joi.array().items(joi.string()).required(), - name: joi.string().required(), - active: joi.boolean(), - duration: joi.number().required(), - durationUnit: joi.string().valid("seconds", "minutes", "hours", "days").required(), - start: joi.date().required(), - end: joi.date().required(), - repeat: joi.number().required(), - expiry: joi.date(), +export const createMaintenanceWindowBodyValidation = z.object({ + monitors: z.array(z.string()).min(1, "At least one monitor is required"), + name: z.string().min(1, "Name is required"), + active: z.boolean().optional(), + duration: z.number().min(1, "Duration is required"), + durationUnit: z.enum(["seconds", "minutes", "hours", "days"]), + start: z.coerce.date(), + end: z.coerce.date(), + repeat: z.number().min(0, "Repeat must be a non-negative number"), + expiry: z.coerce.date().optional(), }); -export const getMaintenanceWindowByIdParamValidation = joi.object({ - id: joi.string().required(), +export const getMaintenanceWindowByIdParamValidation = z.object({ + id: z.string().min(1, "ID is required"), }); -export const getMaintenanceWindowsByTeamIdQueryValidation = joi.object({ - active: joi.boolean(), - page: joi.number(), - rowsPerPage: joi.number(), - field: joi.string(), - order: joi.string().valid("asc", "desc"), +export const getMaintenanceWindowsByTeamIdQueryValidation = z.object({ + active: z.coerce.boolean().optional(), + page: z.coerce.number().optional(), + rowsPerPage: z.coerce.number().optional(), + field: z.string().optional(), + order: z.enum(["asc", "desc"]).optional(), }); -export const getMaintenanceWindowsByMonitorIdParamValidation = joi.object({ - monitorId: joi.string().required(), +export const getMaintenanceWindowsByMonitorIdParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), }); -export const deleteMaintenanceWindowByIdParamValidation = joi.object({ - id: joi.string().required(), +export const deleteMaintenanceWindowByIdParamValidation = z.object({ + id: z.string().min(1, "ID is required"), }); -export const editMaintenanceWindowByIdParamValidation = joi.object({ - id: joi.string().required(), +export const editMaintenanceWindowByIdParamValidation = z.object({ + id: z.string().min(1, "ID is required"), }); -export const editMaintenanceByIdWindowBodyValidation = joi.object({ - active: joi.boolean(), - name: joi.string(), - repeat: joi.number(), - start: joi.date(), - end: joi.date(), - expiry: joi.date(), - monitors: joi.array(), - duration: joi.number(), - durationUnit: joi.string().valid("seconds", "minutes", "hours", "days"), +export const editMaintenanceByIdWindowBodyValidation = z.object({ + active: z.boolean().optional(), + name: z.string().optional(), + repeat: z.number().optional(), + start: z.coerce.date().optional(), + end: z.coerce.date().optional(), + expiry: z.coerce.date().optional(), + monitors: z.array(z.unknown()).optional(), + duration: z.number().optional(), + durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(), }); diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 37c669ca3..0effa94a9 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,127 +1,120 @@ -import joi from "joi"; +import { z } from "zod"; import { GeoContinents } from "@/types/geoCheck.js"; -export const getMonitorByIdParamValidation = joi.object({ - monitorId: joi.string().required(), +export const getMonitorByIdParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), }); -export const getMonitorByIdQueryValidation = joi.object({ - status: joi.boolean(), - sortOrder: joi.string().valid("asc", "desc"), - limit: joi.number(), - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), - numToDisplay: joi.number(), - normalize: joi.boolean(), - continent: joi.string().valid(...GeoContinents), +export const getMonitorByIdQueryValidation = z.object({ + status: z.coerce.boolean().optional(), + sortOrder: z.enum(["asc", "desc"]).optional(), + limit: z.coerce.number().optional(), + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), + numToDisplay: z.coerce.number().optional(), + normalize: z.coerce.boolean().optional(), + continent: z.enum(GeoContinents).optional(), }); -export const getMonitorsByTeamIdParamValidation = joi.object({}); +export const getMonitorsByTeamIdParamValidation = z.object({}); -export const getMonitorsByTeamIdQueryValidation = joi.object({ - type: joi - .alternatives() - .try( - joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), - joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) - ), - filter: joi.string().allow("", null), -}); - -export const getMonitorsWithChecksQueryValidation = joi.object({ - limit: joi.number().integer().min(1).max(100).optional(), - page: joi.number().integer().min(0).optional(), - rowsPerPage: joi.number().integer().min(1).max(100).optional(), - filter: joi.string().allow("", null).optional(), - field: joi.string().optional(), - order: joi.string().valid("asc", "desc").optional(), - type: joi - .alternatives() - .try( - joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"), - joi.array().items(joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc")) - ) +export const getMonitorsByTeamIdQueryValidation = z.object({ + type: z + .union([ + z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]), + z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])), + ]) .optional(), - explain: joi.boolean().optional(), + filter: z.union([z.string(), z.literal(""), z.null()]).optional(), }); -export const getCertificateParamValidation = joi.object({ - monitorId: joi.string().required(), -}); - -export const createMonitorBodyValidation = joi.object({ - _id: joi.string(), - name: joi.string().required(), - description: joi.string().allow(null, ""), - type: joi.string().required(), - statusWindowSize: joi.number().min(1).max(20).default(5), - statusWindowThreshold: joi.number().min(1).max(100).default(60), - url: joi.string().required(), - ignoreTlsErrors: joi.boolean().default(false), - useAdvancedMatching: joi.boolean().default(false), - port: joi.number(), - isActive: joi.boolean(), - interval: joi.number(), - cpuAlertThreshold: joi.number(), - memoryAlertThreshold: joi.number(), - diskAlertThreshold: joi.number(), - tempAlertThreshold: joi.number(), - notifications: joi.array().items(joi.string()), - secret: joi.string(), - jsonPath: joi.string().allow(""), - expectedValue: joi.string().allow(""), - matchMethod: joi.string().allow(null, ""), - gameId: joi.string().allow(""), - grpcServiceName: joi.string().allow("").default(""), - selectedDisks: joi.array().items(joi.string()).optional(), - group: joi.string().max(50).trim().allow(null, "").optional(), - geoCheckEnabled: joi.boolean().optional(), - geoCheckLocations: joi - .array() - .items(joi.string().valid(...GeoContinents)) +export const getMonitorsWithChecksQueryValidation = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional(), + page: z.coerce.number().int().min(0).optional(), + rowsPerPage: z.coerce.number().int().min(1).max(100).optional(), + filter: z.union([z.string(), z.literal(""), z.null()]).optional(), + field: z.string().optional(), + order: z.enum(["asc", "desc"]).optional(), + type: z + .union([ + z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"]), + z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])), + ]) .optional(), - geoCheckInterval: joi.number().min(300000).optional(), + explain: z.coerce.boolean().optional(), }); -export const editMonitorBodyValidation = joi +export const getCertificateParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), +}); + +export const createMonitorBodyValidation = z.object({ + _id: z.string().optional(), + name: z.string().min(1, "Name is required"), + description: z.union([z.string(), z.null(), z.literal("")]).optional(), + type: z.string().min(1, "Type is required"), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + url: z.string().min(1, "URL is required"), + ignoreTlsErrors: z.boolean().default(false), + useAdvancedMatching: z.boolean().default(false), + port: z.number().optional(), + isActive: z.boolean().optional(), + interval: z.number().optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + notifications: z.array(z.string()).optional(), + secret: z.string().optional(), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.string(), z.null(), z.literal("")]).optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).default(""), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), +}); + +export const editMonitorBodyValidation = z .object({ - name: joi.string(), - statusWindowSize: joi.number().min(1).max(20).default(5), - statusWindowThreshold: joi.number().min(1).max(100).default(60), - description: joi.string().allow(null, ""), - interval: joi.number(), - notifications: joi.array().items(joi.string()), - secret: joi.string(), - ignoreTlsErrors: joi.boolean(), - useAdvancedMatching: joi.boolean(), - jsonPath: joi.string().allow(""), - expectedValue: joi.string().allow(""), - matchMethod: joi.string().allow(null, ""), - port: joi.number().min(1).max(65535), - cpuAlertThreshold: joi.number(), - memoryAlertThreshold: joi.number(), - diskAlertThreshold: joi.number(), - tempAlertThreshold: joi.number(), - gameId: joi.string().allow(""), - grpcServiceName: joi.string().allow(""), - selectedDisks: joi.array().items(joi.string()).optional(), - group: joi.string().max(50).trim().allow(null, "").optional(), - geoCheckEnabled: joi.boolean().optional(), - geoCheckLocations: joi - .array() - .items(joi.string().valid(...GeoContinents)) - .optional(), - geoCheckInterval: joi.number().min(300000).optional(), + name: z.string().optional(), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + description: z.union([z.string(), z.null(), z.literal("")]).optional(), + interval: z.number().optional(), + notifications: z.array(z.string()).optional(), + secret: z.string().optional(), + ignoreTlsErrors: z.boolean().optional(), + useAdvancedMatching: z.boolean().optional(), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.string(), z.null(), z.literal("")]).optional(), + port: z.number().min(1).max(65535).optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).optional(), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), }) - .options({ stripUnknown: true }); + .strict(); -export const pauseMonitorParamValidation = joi.object({ - monitorId: joi.string().required(), +export const pauseMonitorParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), }); -export const getHardwareDetailsByIdParamValidation = joi.object({ - monitorId: joi.string().required(), +export const getHardwareDetailsByIdParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), }); -export const getHardwareDetailsByIdQueryValidation = joi.object({ - dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), +export const getHardwareDetailsByIdQueryValidation = z.object({ + dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), }); From f1466df56cdb5af248fbeed99a2fcd9b3b9d0813 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 17:54:52 +0000 Subject: [PATCH 04/11] update monitor validation --- server/src/validation/monitorValidation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 0effa94a9..ede1ada13 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -81,6 +81,8 @@ export const createMonitorBodyValidation = z.object({ export const editMonitorBodyValidation = z .object({ name: z.string().optional(), + type: z.string().optional(), + url: z.string().optional(), statusWindowSize: z.number().min(1).max(20).default(5), statusWindowThreshold: z.number().min(1).max(100).default(60), description: z.union([z.string(), z.null(), z.literal("")]).optional(), @@ -105,7 +107,7 @@ export const editMonitorBodyValidation = z geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), }) - .strict(); + .passthrough(); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), From c1e03a45b023cc501a2ce18ed1a1159918abe049 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 18:03:10 +0000 Subject: [PATCH 05/11] settings --- server/src/controllers/settingsController.ts | 2 +- server/src/validation/settingsValidation.ts | 64 +++++++++++--------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 69443fd6a..0dc0e5b07 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -56,7 +56,7 @@ class SettingsController { }; updateAppSettings = async (req: Request, res: Response, next: NextFunction) => { - await updateAppSettingsBodyValidation.validateAsync(req.body); + updateAppSettingsBodyValidation.parse(req.body); const updatedSettings = await this.settingsService.updateDbSettings(req.body); const returnSettings = this.buildAppSettings(updatedSettings); diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index 6321f13f0..045ee33e8 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -1,34 +1,42 @@ -import joi from "joi"; +import { z } from "zod"; //**************************************** // Settings Validations //**************************************** -export const updateAppSettingsBodyValidation = joi.object({ - checkTTL: joi.number().allow(""), - pagespeedApiKey: joi.string().allow(""), - language: joi.string().allow(""), - timezone: joi.string().allow(""), - showURL: joi.bool().optional(), - systemEmailHost: joi.string().allow(""), - systemEmailPort: joi.number().allow(""), - systemEmailAddress: joi.string().allow(""), - systemEmailPassword: joi.string().allow(""), - systemEmailUser: joi.string().allow(""), - systemEmailConnectionHost: joi.string().allow(""), - systemEmailTLSServername: joi.string().allow(""), - systemEmailSecure: joi.boolean(), - systemEmailPool: joi.boolean(), - systemEmailIgnoreTLS: joi.boolean(), - systemEmailRequireTLS: joi.boolean(), - systemEmailRejectUnauthorized: joi.boolean(), +export const updateAppSettingsBodyValidation = z + .object({ + // Number or empty string fields + checkTTL: z.union([z.number(), z.literal("")]).optional(), + systemEmailPort: z.union([z.number(), z.literal("")]).optional(), - globalThresholds: joi - .object({ - cpu: joi.number().min(1).max(100).allow(""), - memory: joi.number().min(1).max(100).allow(""), - disk: joi.number().min(1).max(100).allow(""), - temperature: joi.number().min(1).max(150).allow(""), - }) - .optional(), -}); + // String or empty string fields + pagespeedApiKey: z.union([z.string(), z.literal("")]).optional(), + language: z.union([z.string(), z.literal("")]).optional(), + timezone: z.union([z.string(), z.literal("")]).optional(), + systemEmailHost: z.union([z.string(), z.literal("")]).optional(), + systemEmailAddress: z.union([z.string(), z.literal("")]).optional(), + systemEmailPassword: z.union([z.string(), z.literal("")]).optional(), + systemEmailUser: z.union([z.string(), z.literal("")]).optional(), + systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(), + systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(), + + // Boolean fields (optional by default in Joi) + showURL: z.boolean().optional(), + systemEmailSecure: z.boolean().optional(), + systemEmailPool: z.boolean().optional(), + systemEmailIgnoreTLS: z.boolean().optional(), + systemEmailRequireTLS: z.boolean().optional(), + systemEmailRejectUnauthorized: z.boolean().optional(), + + // Nested object + globalThresholds: z + .object({ + cpu: z.union([z.number().min(1).max(100), z.literal("")]).optional(), + memory: z.union([z.number().min(1).max(100), z.literal("")]).optional(), + disk: z.union([z.number().min(1).max(100), z.literal("")]).optional(), + temperature: z.union([z.number().min(1).max(150), z.literal("")]).optional(), + }) + .optional(), + }) + .strip(); // Joi by default strips unknown keys, match that behavior with .strip() From 64242322a2a3b192da198f897381fa4e4c8b4a3e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 18:46:21 +0000 Subject: [PATCH 06/11] add try/catch block --- server/src/controllers/settingsController.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/server/src/controllers/settingsController.ts b/server/src/controllers/settingsController.ts index 0dc0e5b07..d3caa0d8f 100644 --- a/server/src/controllers/settingsController.ts +++ b/server/src/controllers/settingsController.ts @@ -56,15 +56,19 @@ class SettingsController { }; updateAppSettings = async (req: Request, res: Response, next: NextFunction) => { - updateAppSettingsBodyValidation.parse(req.body); + try { + updateAppSettingsBodyValidation.parse(req.body); - const updatedSettings = await this.settingsService.updateDbSettings(req.body); - const returnSettings = this.buildAppSettings(updatedSettings); - return res.status(200).json({ - success: true, - msg: "App settings updated successfully", - data: returnSettings, - }); + const updatedSettings = await this.settingsService.updateDbSettings(req.body); + const returnSettings = this.buildAppSettings(updatedSettings); + return res.status(200).json({ + success: true, + msg: "App settings updated successfully", + data: returnSettings, + }); + } catch (error) { + next(error); + } }; sendTestEmail = async (req: Request, res: Response, next: NextFunction) => { From 8236b020349217af8983ffca38933fb28c9b6460 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 19:06:32 +0000 Subject: [PATCH 07/11] remove comments --- server/src/validation/settingsValidation.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/src/validation/settingsValidation.ts b/server/src/validation/settingsValidation.ts index 045ee33e8..ee2a2ee87 100644 --- a/server/src/validation/settingsValidation.ts +++ b/server/src/validation/settingsValidation.ts @@ -6,11 +6,9 @@ import { z } from "zod"; export const updateAppSettingsBodyValidation = z .object({ - // Number or empty string fields checkTTL: z.union([z.number(), z.literal("")]).optional(), systemEmailPort: z.union([z.number(), z.literal("")]).optional(), - // String or empty string fields pagespeedApiKey: z.union([z.string(), z.literal("")]).optional(), language: z.union([z.string(), z.literal("")]).optional(), timezone: z.union([z.string(), z.literal("")]).optional(), @@ -21,7 +19,6 @@ export const updateAppSettingsBodyValidation = z systemEmailConnectionHost: z.union([z.string(), z.literal("")]).optional(), systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(), - // Boolean fields (optional by default in Joi) showURL: z.boolean().optional(), systemEmailSecure: z.boolean().optional(), systemEmailPool: z.boolean().optional(), @@ -29,7 +26,6 @@ export const updateAppSettingsBodyValidation = z systemEmailRequireTLS: z.boolean().optional(), systemEmailRejectUnauthorized: z.boolean().optional(), - // Nested object globalThresholds: z .object({ cpu: z.union([z.number().min(1).max(100), z.literal("")]).optional(), @@ -39,4 +35,4 @@ export const updateAppSettingsBodyValidation = z }) .optional(), }) - .strip(); // Joi by default strips unknown keys, match that behavior with .strip() + .strip(); From 16cf23baf1824e69a7ef9fe2acc1547fe0936118 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 19:25:34 +0000 Subject: [PATCH 08/11] status page validation --- .../src/controllers/statusPageController.ts | 16 ++-- server/src/validation/checkValidation.ts | 20 +--- .../validation/maintenanceWindowValidation.ts | 3 +- server/src/validation/monitorValidation.ts | 7 +- server/src/validation/shared.ts | 10 ++ server/src/validation/statusPageValidation.ts | 95 ++++++++----------- 6 files changed, 70 insertions(+), 81 deletions(-) diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts index ccffff7a9..fa6cd3288 100644 --- a/server/src/controllers/statusPageController.ts +++ b/server/src/controllers/statusPageController.ts @@ -33,8 +33,10 @@ class StatusPageController { createStatusPage = async (req: Request, res: Response, next: NextFunction) => { try { - await createStatusPageBodyValidation.validateAsync(req.body); - await imageValidation.validateAsync(req.file); + createStatusPageBodyValidation.parse(req.body); + if (req.file) { + imageValidation.parse(req.file); + } const teamId = requireTeamId(req?.user?.teamId); const userId = requireUserId(req?.user?.id); @@ -52,8 +54,10 @@ class StatusPageController { updateStatusPage = async (req: Request, res: Response, next: NextFunction) => { try { - await createStatusPageBodyValidation.validateAsync(req.body); - await imageValidation.validateAsync(req.file); + createStatusPageBodyValidation.parse(req.body); + if (req.file) { + imageValidation.parse(req.file); + } const teamId = requireTeamId(req?.user?.teamId); const statusPageId = req.params.id as string; if (!statusPageId) { @@ -75,8 +79,8 @@ class StatusPageController { getStatusPageByUrl = async (req: Request, res: Response, next: NextFunction) => { try { - await getStatusPageParamValidation.validateAsync(req.params); - await getStatusPageQueryValidation.validateAsync(req.query); + getStatusPageParamValidation.parse(req.params); + getStatusPageQueryValidation.parse(req.query); if (!req.params.url) { throw new AppError({ message: "Status page URL is required", status: 400 }); diff --git a/server/src/validation/checkValidation.ts b/server/src/validation/checkValidation.ts index 6c098bec7..547281d68 100644 --- a/server/src/validation/checkValidation.ts +++ b/server/src/validation/checkValidation.ts @@ -1,23 +1,11 @@ import { z } from "zod"; +import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; //**************************************** // Check Validations //**************************************** -export const ackCheckBodyValidation = z.object({ - ack: z.coerce.boolean().optional(), -}); - -export const ackAllChecksParamValidation = z.object({ - monitorId: z.string().optional(), - path: z.enum(["monitor", "team"]), -}); - -export const ackAllChecksBodyValidation = z.object({ - ack: z.coerce.boolean().optional(), -}); - export const getChecksParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), }); @@ -28,10 +16,10 @@ export const getChecksQueryValidation = z.object({ limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), filter: z.enum(["all", "up", "down", "resolve"]).optional(), - ack: z.coerce.boolean().optional(), + ack: booleanCoercion.optional(), page: z.coerce.number().optional(), rowsPerPage: z.coerce.number().optional(), - status: z.coerce.boolean().optional(), + status: booleanCoercion.optional(), continent: z.union([z.enum(GeoContinents), z.array(z.enum(GeoContinents))]).optional(), }); @@ -40,7 +28,7 @@ export const getTeamChecksQueryValidation = z.object({ limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), filter: z.enum(["all", "up", "down", "resolve"]).optional(), - ack: z.coerce.boolean().optional(), + ack: booleanCoercion.optional(), page: z.coerce.number().optional(), rowsPerPage: z.coerce.number().optional(), }); diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index c1e65c733..e09ac2989 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { booleanCoercion } from "./shared.js"; //**************************************** // Maintenance Window Validations @@ -21,7 +22,7 @@ export const getMaintenanceWindowByIdParamValidation = z.object({ }); export const getMaintenanceWindowsByTeamIdQueryValidation = z.object({ - active: z.coerce.boolean().optional(), + active: booleanCoercion.optional(), page: z.coerce.number().optional(), rowsPerPage: z.coerce.number().optional(), field: z.string().optional(), diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index ede1ada13..50004e088 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { booleanCoercion } from "./shared.js"; import { GeoContinents } from "@/types/geoCheck.js"; export const getMonitorByIdParamValidation = z.object({ @@ -6,12 +7,12 @@ export const getMonitorByIdParamValidation = z.object({ }); export const getMonitorByIdQueryValidation = z.object({ - status: z.coerce.boolean().optional(), + status: booleanCoercion.optional(), sortOrder: z.enum(["asc", "desc"]).optional(), limit: z.coerce.number().optional(), dateRange: z.enum(["recent", "hour", "day", "week", "month", "all"]).optional(), numToDisplay: z.coerce.number().optional(), - normalize: z.coerce.boolean().optional(), + normalize: booleanCoercion.optional(), continent: z.enum(GeoContinents).optional(), }); @@ -40,7 +41,7 @@ export const getMonitorsWithChecksQueryValidation = z.object({ z.array(z.enum(["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc"])), ]) .optional(), - explain: z.coerce.boolean().optional(), + explain: booleanCoercion.optional(), }); export const getCertificateParamValidation = z.object({ diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index ce01705ea..0265fd290 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -29,6 +29,16 @@ export const lowercaseEmailValidation = z message: "Email must be in lowercase", }); +/** + * Helper to properly coerce boolean from strings (handles "true"/"false" strings) + * Needed for query parameters and form data where Express sends strings + */ +export const booleanCoercion = z.preprocess((val) => { + if (val === "true" || val === true) return true; + if (val === "false" || val === false) return false; + return val; // Let Zod validation handle invalid values +}, z.boolean()); + /** * Custom validator for role-based authorization */ diff --git a/server/src/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts index bca638fc4..b4b5e270c 100644 --- a/server/src/validation/statusPageValidation.ts +++ b/server/src/validation/statusPageValidation.ts @@ -1,68 +1,53 @@ -import joi from "joi"; +import { z } from "zod"; +import { booleanCoercion } from "./shared.js"; //**************************************** // Status Page Validations //**************************************** -export const getStatusPageParamValidation = joi.object({ - url: joi.string().required(), +export const getStatusPageParamValidation = z.object({ + url: z.string().min(1, "URL is required"), }); -export const getStatusPageQueryValidation = joi.object({ - type: joi.string().valid("uptime").required(), - timeFrame: joi.number().optional(), +export const getStatusPageQueryValidation = z.object({ + type: z.literal("uptime"), + timeFrame: z.coerce.number().optional(), }); -export const createStatusPageBodyValidation = joi.object({ - type: joi.string().valid("uptime").required(), - companyName: joi.string().required(), - url: joi - .string() - .pattern(/^[a-zA-Z0-9_-]+$/) - .required() - .messages({ - "string.pattern.base": "URL can only contain letters, numbers, underscores, and hyphens", - }), - timezone: joi.string().optional(), - color: joi.string().optional(), - monitors: joi - .array() - .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) - .required() - .messages({ - "string.pattern.base": "Must be a valid monitor ID", - "array.base": "Monitors must be an array", - "array.empty": "At least one monitor is required", - "any.required": "Monitors are required", - }), - subMonitors: joi - .array() - .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) - .optional(), - deleteSubmonitors: joi.boolean().optional(), - isPublished: joi.boolean(), - showCharts: joi.boolean().optional(), - showUptimePercentage: joi.boolean(), - showAdminLoginLink: joi.boolean().optional(), - removeLogo: joi.string().valid("true", "false").optional(), -}); - -export const imageValidation = joi +export const createStatusPageBodyValidation = z .object({ - fieldname: joi.string().required(), - originalname: joi.string().required(), - encoding: joi.string().required(), - mimetype: joi.string().valid("image/jpeg", "image/png", "image/jpg").required().messages({ - "string.valid": "File must be a valid image (jpeg, jpg, or png)", + type: z.literal("uptime"), + companyName: z.string().min(1, "Company name is required"), + url: z.string().regex(/^[a-zA-Z0-9_-]+$/, { + message: "URL can only contain letters, numbers, underscores, and hyphens", }), - size: joi.number().max(3145728).required().messages({ - "number.max": "File size must be less than 3MB", - }), - buffer: joi.binary().required(), - destination: joi.string(), - filename: joi.string(), - path: joi.string(), + timezone: z.string().optional(), + color: z.string().optional(), + monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"), + subMonitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/)).optional(), + deleteSubmonitors: z.boolean().optional(), + isPublished: booleanCoercion, + showCharts: booleanCoercion.optional(), + showUptimePercentage: booleanCoercion, + showAdminLoginLink: booleanCoercion.optional(), + removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), }) - .messages({ - "any.required": "Image file is required", + .strip(); + +export const imageValidation = z + .object({ + fieldname: z.string().min(1, "Field name is required"), + originalname: z.string().min(1, "Original name is required"), + encoding: z.string().min(1, "Encoding is required"), + mimetype: z.enum(["image/jpeg", "image/png", "image/jpg"], { + message: "File must be a valid image (jpeg, jpg, or png)", + }), + size: z.number().max(3145728, "File size must be less than 3MB"), + buffer: z.instanceof(Buffer, { message: "Buffer is required" }), + destination: z.string().optional(), + filename: z.string().optional(), + path: z.string().optional(), + }) + .refine((data) => data.buffer, { + message: "Image file is required", }); From 75a7e144cd081275ef7c7782cacef50d48304acc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 19:26:20 +0000 Subject: [PATCH 09/11] remove comments --- server/src/validation/shared.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 0265fd290..778330dfc 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -1,15 +1,9 @@ import { z } from "zod"; import { type UserRole } from "@/types/user.js"; -/** - * Password pattern: requires at least one lowercase, uppercase, number, and special character - */ export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.~`|\\/]+$/; -/** - * Reusable name validation schema - */ export const nameValidation = z .string() .trim() @@ -19,9 +13,6 @@ export const nameValidation = z "Names must contain at least 1 letter and may only include letters, currency symbols, spaces, apostrophes, hyphens (-), periods (.), and parentheses ()." ); -/** - * Reusable email validation with lowercase enforcement - */ export const lowercaseEmailValidation = z .email() .transform((val) => val.toLowerCase()) @@ -29,19 +20,12 @@ export const lowercaseEmailValidation = z message: "Email must be in lowercase", }); -/** - * Helper to properly coerce boolean from strings (handles "true"/"false" strings) - * Needed for query parameters and form data where Express sends strings - */ export const booleanCoercion = z.preprocess((val) => { if (val === "true" || val === true) return true; if (val === "false" || val === false) return false; return val; // Let Zod validation handle invalid values }, z.boolean()); -/** - * Custom validator for role-based authorization - */ export const roleValidator = (allowedRoles: UserRole[]) => { return z.array(z.custom()).refine((userRoles) => allowedRoles.some((role) => userRoles.includes(role)), { message: `You do not have the required authorization. Required roles: ${allowedRoles.join(", ")}`, From 9fd006cfff7966efa04d5c83d9ceaf47bef56041 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 19:42:47 +0000 Subject: [PATCH 10/11] remove joi --- server/package-lock.json | 50 -------- server/package.json | 1 - .../src/controllers/notificationController.ts | 8 +- .../MongoNotificationsRepository.ts | 1 - .../src/validation/notificationValidation.ts | 117 ++++++++---------- 5 files changed, 52 insertions(+), 125 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 131a3708e..42c2388dc 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -30,7 +30,6 @@ "ioredis": "^5.4.2", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", - "joi": "^17.13.1", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", "mailersend": "^2.2.0", @@ -2231,21 +2230,6 @@ "node": ">=6" } }, - "node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/topo": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", - "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3460,27 +3444,6 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, - "node_modules/@sideway/address": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", - "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "license": "BSD-3-Clause" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "license": "BSD-3-Clause" - }, "node_modules/@sinclair/typebox": { "version": "0.34.47", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz", @@ -9931,19 +9894,6 @@ "node": ">= 0.6.0" } }, - "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^9.3.0", - "@hapi/topo": "^5.1.0", - "@sideway/address": "^4.1.5", - "@sideway/formula": "^3.0.1", - "@sideway/pinpoint": "^2.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/server/package.json b/server/package.json index 3abadb75f..ee30cccee 100755 --- a/server/package.json +++ b/server/package.json @@ -45,7 +45,6 @@ "ioredis": "^5.4.2", "isomorphic-dompurify": "^2.26.0", "jmespath": "^0.16.0", - "joi": "^17.13.1", "jsdom": "^26.1.0", "jsonwebtoken": "9.0.2", "mailersend": "^2.2.0", diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 77e332cad..0cee3726e 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -42,9 +42,7 @@ class NotificationController { createNotification = async (req: Request, res: Response, next: NextFunction) => { try { - await createNotificationBodyValidation.validateAsync(req.body, { - abortEarly: false, - }); + createNotificationBodyValidation.parse(req.body); const body = req.body; @@ -134,9 +132,7 @@ class NotificationController { editNotification = async (req: Request, res: Response, next: NextFunction) => { try { - await createNotificationBodyValidation.validateAsync(req.body, { - abortEarly: false, - }); + createNotificationBodyValidation.parse(req.body); const teamId = requireTeamId(req.user?.teamId); const notificationId = req.params.id as string; diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 730072d8c..3c4225b50 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -3,7 +3,6 @@ import { NotificationModel, type NotificationDocument } from "@/db/models/index. import { INotificationsRepository } from "@/repositories/index.js"; import type { Notification } from "@/types/index.js"; import { AppError } from "@/utils/AppError.js"; -import { not } from "joi"; class MongoNotificationsRepository implements INotificationsRepository { private mapDocuments = (documents: NotificationDocument[]): Notification[] => { diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 43eae5575..d974d873f 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -1,82 +1,65 @@ -import joi from "joi"; import { z } from "zod"; //**************************************** // Notification Validations //**************************************** -export const createNotificationBodyValidation = joi.object({ - notificationName: joi.string().required().messages({ - "string.empty": "Notification name is required", - "any.required": "Notification name is required", +export const createNotificationBodyValidation = z.discriminatedUnion("type", [ + // Email notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("email"), + address: z.email("Please enter a valid e-mail address"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), - - type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty", "matrix").required().messages({ - "string.empty": "Notification type is required", - "any.required": "Notification type is required", - "any.only": "Notification type must be email, webhook, slack, discord, pager_duty, or matrix", + // Webhook notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("webhook"), + address: z.string().url("Please enter a valid Webhook URL"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), - - address: joi.when("type", { - switch: [ - { - is: "email", - then: joi.string().email().required().messages({ - "string.empty": "E-mail address cannot be empty", - "any.required": "E-mail address is required", - "string.email": "Please enter a valid e-mail address", - }), - }, - { - is: "pager_duty", - then: joi.string().required().messages({ - "string.empty": "PagerDuty integration key cannot be empty", - "any.required": "PagerDuty integration key is required", - }), - }, - { - is: joi.string().valid("webhook", "slack", "discord"), - then: joi.string().uri().required().messages({ - "string.empty": "Webhook URL cannot be empty", - "any.required": "Webhook URL is required", - "string.uri": "Please enter a valid Webhook URL", - }), - }, - { - is: "matrix", - then: joi.string().allow("").optional(), - }, - ], + // Slack notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("slack"), + address: z.string().url("Please enter a valid Webhook URL"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), - - homeserverUrl: joi.when("type", { - is: "matrix", - then: joi.string().uri().required().messages({ - "string.empty": "Homeserver URL cannot be empty", - "any.required": "Homeserver URL is required", - "string.uri": "Please enter a valid Homeserver URL", - }), - otherwise: joi.string().allow("").optional(), + // Discord notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("discord"), + address: z.string().url("Please enter a valid Webhook URL"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), - - roomId: joi.when("type", { - is: "matrix", - then: joi.string().required().messages({ - "string.empty": "Room ID cannot be empty", - "any.required": "Room ID is required", - }), - otherwise: joi.string().allow("").optional(), + // PagerDuty notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("pager_duty"), + address: z.string().min(1, "PagerDuty integration key is required"), + homeserverUrl: z.union([z.string(), z.literal("")]).optional(), + roomId: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), }), - - accessToken: joi.when("type", { - is: "matrix", - then: joi.string().required().messages({ - "string.empty": "Access Token cannot be empty", - "any.required": "Access Token is required", - }), - otherwise: joi.string().allow("").optional(), + // Matrix notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("matrix"), + address: z.union([z.string(), z.literal("")]).optional(), + homeserverUrl: z.string().url("Please enter a valid Homeserver URL"), + roomId: z.string().min(1, "Room ID is required"), + accessToken: z.string().min(1, "Access Token is required"), }), -}); +]); export const sendTestEmailBodyValidation = z.object({ to: z.string().min(1, "To field is required"), From a1858e8302a56de4aa0f092ed742845ab3749199 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 2 Mar 2026 21:35:08 +0000 Subject: [PATCH 11/11] remove passthrough, remove redudnant refine --- server/src/validation/monitorValidation.ts | 58 +++++++++++----------- server/src/validation/shared.ts | 7 +-- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index 50004e088..4694143ac 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -79,36 +79,34 @@ export const createMonitorBodyValidation = z.object({ geoCheckInterval: z.number().min(300000).optional(), }); -export const editMonitorBodyValidation = z - .object({ - name: z.string().optional(), - type: z.string().optional(), - url: z.string().optional(), - statusWindowSize: z.number().min(1).max(20).default(5), - statusWindowThreshold: z.number().min(1).max(100).default(60), - description: z.union([z.string(), z.null(), z.literal("")]).optional(), - interval: z.number().optional(), - notifications: z.array(z.string()).optional(), - secret: z.string().optional(), - ignoreTlsErrors: z.boolean().optional(), - useAdvancedMatching: z.boolean().optional(), - jsonPath: z.union([z.string(), z.literal("")]).optional(), - expectedValue: z.union([z.string(), z.literal("")]).optional(), - matchMethod: z.union([z.string(), z.null(), z.literal("")]).optional(), - port: z.number().min(1).max(65535).optional(), - cpuAlertThreshold: z.number().optional(), - memoryAlertThreshold: z.number().optional(), - diskAlertThreshold: z.number().optional(), - tempAlertThreshold: z.number().optional(), - gameId: z.union([z.string(), z.literal("")]).optional(), - grpcServiceName: z.union([z.string(), z.literal("")]).optional(), - selectedDisks: z.array(z.string()).optional(), - group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), - geoCheckEnabled: z.boolean().optional(), - geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), - geoCheckInterval: z.number().min(300000).optional(), - }) - .passthrough(); +export const editMonitorBodyValidation = z.object({ + name: z.string().optional(), + type: z.string().optional(), + url: z.string().optional(), + statusWindowSize: z.number().min(1).max(20).default(5), + statusWindowThreshold: z.number().min(1).max(100).default(60), + description: z.union([z.string(), z.null(), z.literal("")]).optional(), + interval: z.number().optional(), + notifications: z.array(z.string()).optional(), + secret: z.string().optional(), + ignoreTlsErrors: z.boolean().optional(), + useAdvancedMatching: z.boolean().optional(), + jsonPath: z.union([z.string(), z.literal("")]).optional(), + expectedValue: z.union([z.string(), z.literal("")]).optional(), + matchMethod: z.union([z.string(), z.null(), z.literal("")]).optional(), + port: z.number().min(1).max(65535).optional(), + cpuAlertThreshold: z.number().optional(), + memoryAlertThreshold: z.number().optional(), + diskAlertThreshold: z.number().optional(), + tempAlertThreshold: z.number().optional(), + gameId: z.union([z.string(), z.literal("")]).optional(), + grpcServiceName: z.union([z.string(), z.literal("")]).optional(), + selectedDisks: z.array(z.string()).optional(), + group: z.union([z.string().max(50).trim(), z.null(), z.literal("")]).optional(), + geoCheckEnabled: z.boolean().optional(), + geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), + geoCheckInterval: z.number().min(300000).optional(), +}); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), diff --git a/server/src/validation/shared.ts b/server/src/validation/shared.ts index 778330dfc..28fd13c82 100644 --- a/server/src/validation/shared.ts +++ b/server/src/validation/shared.ts @@ -13,12 +13,7 @@ export const nameValidation = z "Names must contain at least 1 letter and may only include letters, currency symbols, spaces, apostrophes, hyphens (-), periods (.), and parentheses ()." ); -export const lowercaseEmailValidation = z - .email() - .transform((val) => val.toLowerCase()) - .refine((val) => val === val.toLowerCase(), { - message: "Email must be in lowercase", - }); +export const lowercaseEmailValidation = z.email().transform((val) => val.toLowerCase()); export const booleanCoercion = z.preprocess((val) => { if (val === "true" || val === true) return true;