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", });