diff --git a/client/src/Features/UI/uiSlice.js b/client/src/Features/UI/uiSlice.js index 45ba267b0..2f31c350b 100644 --- a/client/src/Features/UI/uiSlice.js +++ b/client/src/Features/UI/uiSlice.js @@ -54,6 +54,9 @@ const uiSlice = createSlice({ state.showURL = action.payload; }, setGreeting(state, action) { + if (!state.greeting) { + state.greeting = { index: 0, lastUpdate: null }; + } state.greeting.index = action.payload.index; state.greeting.lastUpdate = action.payload.lastUpdate; }, diff --git a/client/src/Hooks/useNotifications.js b/client/src/Hooks/useNotifications.js index 7feebd44c..43bbd43a5 100644 --- a/client/src/Hooks/useNotifications.js +++ b/client/src/Hooks/useNotifications.js @@ -3,7 +3,11 @@ import { createToast } from "../Utils/toastUtils"; import { networkService } from "../main"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { NOTIFICATION_TYPES } from "../Pages/Notifications/utils"; +import { + NOTIFICATION_TYPES, + NTFY_AUTH_METHODS, + NTFY_PRIORITIES, +} from "../Pages/Notifications/utils"; const useCreateNotification = () => { const navigate = useNavigate(); @@ -103,6 +107,18 @@ const useGetNotificationById = (id, setNotification) => { address: notification?.address, notificationName: notification?.notificationName, type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?._id, + // ntfy-specific fields + ntfyAuthMethod: + NTFY_AUTH_METHODS.find( + (method) => method.value === notification?.ntfyAuthMethod + )?._id || NTFY_AUTH_METHODS[0]._id, + ntfyUsername: notification?.ntfyUsername || "", + ntfyPassword: notification?.ntfyPassword || "", + ntfyBearerToken: notification?.ntfyBearerToken || "", + ntfyPriority: + NTFY_PRIORITIES.find( + (priority) => priority.value === notification?.ntfyPriority + )?._id || NTFY_PRIORITIES[2]._id, }; setNotification(notificationData); diff --git a/client/src/Pages/Notifications/create/index.jsx b/client/src/Pages/Notifications/create/index.jsx index 6d8afe665..d5aa84e02 100644 --- a/client/src/Pages/Notifications/create/index.jsx +++ b/client/src/Pages/Notifications/create/index.jsx @@ -29,6 +29,8 @@ import { DESCRIPTION_MAP, LABEL_MAP, PLACEHOLDER_MAP, + NTFY_AUTH_METHODS, + NTFY_PRIORITIES, } from "../utils"; // Setup @@ -57,6 +59,12 @@ const CreateNotifications = () => { notificationName: "", address: "", type: NOTIFICATION_TYPES[0]._id, + // ntfy-specific fields + ntfyAuthMethod: NTFY_AUTH_METHODS[0]._id, // "None" option _id + ntfyUsername: "", + ntfyPassword: "", + ntfyBearerToken: "", + ntfyPriority: NTFY_PRIORITIES[2]._id, // "Default" option _id (3) }); const [errors, setErrors] = useState({}); const { t } = useTranslation(); @@ -69,6 +77,16 @@ const CreateNotifications = () => { return NOTIFICATION_TYPES.find((type) => type._id === typeId)?.value || "email"; }; + const getNtfyAuthMethodValue = (authMethodId) => { + return ( + NTFY_AUTH_METHODS.find((method) => method._id === authMethodId)?.value || "none" + ); + }; + + const getNtfyPriorityValue = (priorityId) => { + return NTFY_PRIORITIES.find((priority) => priority._id === priorityId)?.value || 3; + }; + const extractError = (error, field) => error?.details.find((d) => d.path.includes(field))?.message; @@ -78,6 +96,9 @@ const CreateNotifications = () => { const form = { ...notification, type: getNotificationTypeValue(notification.type), + // Convert ntfy dropdown IDs to actual values + ntfyAuthMethod: getNtfyAuthMethodValue(notification.ntfyAuthMethod), + ntfyPriority: getNtfyPriorityValue(notification.ntfyPriority), }; let error = null; @@ -107,6 +128,9 @@ const CreateNotifications = () => { let newNotification = { ...rawNotification, type: getNotificationTypeValue(rawNotification.type), + // Convert ntfy dropdown IDs to actual values for validation + ntfyAuthMethod: getNtfyAuthMethodValue(rawNotification.ntfyAuthMethod), + ntfyPriority: getNtfyPriorityValue(rawNotification.ntfyPriority), }; const { error } = notificationValidation.validate(newNotification, { @@ -117,6 +141,12 @@ const CreateNotifications = () => { if (name === "type") { validationError["type"] = extractError(error, "type"); validationError["address"] = extractError(error, "address"); + } else if (name === "ntfyAuthMethod") { + // When auth method changes, update all ntfy field errors + validationError["ntfyAuthMethod"] = extractError(error, "ntfyAuthMethod"); + validationError["ntfyUsername"] = extractError(error, "ntfyUsername"); + validationError["ntfyPassword"] = extractError(error, "ntfyPassword"); + validationError["ntfyBearerToken"] = extractError(error, "ntfyBearerToken"); } else { validationError[name] = extractError(error, name); } @@ -129,6 +159,9 @@ const CreateNotifications = () => { const form = { ...notification, type: getNotificationTypeValue(notification.type), + // Convert ntfy dropdown IDs to actual values + ntfyAuthMethod: getNtfyAuthMethodValue(notification.ntfyAuthMethod), + ntfyPriority: getNtfyPriorityValue(notification.ntfyPriority), }; let error = null; @@ -232,6 +265,76 @@ const CreateNotifications = () => { error={Boolean(errors.address)} helperText={errors["address"]} /> + + {/* ntfy-specific fields */} + {type === "ntfy" && ( + <> + + + )} diff --git a/client/src/Pages/Notifications/utils.js b/client/src/Pages/Notifications/utils.js index 0813f6868..06b3d47fa 100644 --- a/client/src/Pages/Notifications/utils.js +++ b/client/src/Pages/Notifications/utils.js @@ -4,6 +4,7 @@ export const NOTIFICATION_TYPES = [ { _id: 3, name: "PagerDuty", value: "pager_duty" }, { _id: 4, name: "Webhook", value: "webhook" }, { _id: 5, name: "Discord", value: "discord" }, + { _id: 6, name: "ntfy", value: "ntfy" }, ]; export const TITLE_MAP = { @@ -12,6 +13,7 @@ export const TITLE_MAP = { pager_duty: "createNotifications.pagerdutySettings.title", webhook: "createNotifications.webhookSettings.title", discord: "createNotifications.discordSettings.title", + ntfy: "createNotifications.ntfySettings.title", }; export const DESCRIPTION_MAP = { @@ -20,6 +22,7 @@ export const DESCRIPTION_MAP = { pager_duty: "createNotifications.pagerdutySettings.description", webhook: "createNotifications.webhookSettings.description", discord: "createNotifications.discordSettings.description", + ntfy: "createNotifications.ntfySettings.description", }; export const LABEL_MAP = { @@ -28,6 +31,7 @@ export const LABEL_MAP = { pager_duty: "createNotifications.pagerdutySettings.integrationKeyLabel", webhook: "createNotifications.webhookSettings.webhookLabel", discord: "createNotifications.discordSettings.webhookLabel", + ntfy: "createNotifications.ntfySettings.urlLabel", }; export const PLACEHOLDER_MAP = { @@ -36,4 +40,20 @@ export const PLACEHOLDER_MAP = { pager_duty: "createNotifications.pagerdutySettings.integrationKeyPlaceholder", webhook: "createNotifications.webhookSettings.webhookPlaceholder", discord: "createNotifications.discordSettings.webhookPlaceholder", + ntfy: "createNotifications.ntfySettings.urlPlaceholder", }; + +// ntfy-specific constants +export const NTFY_AUTH_METHODS = [ + { _id: 1, name: "None", value: "none" }, + { _id: 2, name: "Username/Password", value: "username_password" }, + { _id: 3, name: "Bearer Token", value: "bearer_token" }, +]; + +export const NTFY_PRIORITIES = [ + { _id: 1, name: "Min", value: 1 }, + { _id: 2, name: "Low", value: 2 }, + { _id: 3, name: "Default", value: 3 }, + { _id: 4, name: "High", value: 4 }, + { _id: 5, name: "Urgent", value: 5 }, +]; diff --git a/client/src/Utils/greeting.jsx b/client/src/Utils/greeting.jsx index d2b7fa033..87ed76711 100644 --- a/client/src/Utils/greeting.jsx +++ b/client/src/Utils/greeting.jsx @@ -135,9 +135,9 @@ const Greeting = ({ type = "" }) => { const theme = useTheme(); const dispatch = useDispatch(); const { t } = useTranslation(); - const { firstName } = useSelector((state) => state.auth.user); - const index = useSelector((state) => state.ui.greeting.index); - const lastUpdate = useSelector((state) => state.ui.greeting.lastUpdate); + const { firstName } = useSelector((state) => state.auth.user || {}); + const index = useSelector((state) => state.ui.greeting?.index ?? 0); + const lastUpdate = useSelector((state) => state.ui.greeting?.lastUpdate ?? null); const now = new Date(); const hour = now.getHours(); @@ -153,7 +153,8 @@ const Greeting = ({ type = "" }) => { let greetingArray = hour < 6 ? early : hour < 12 ? morning : hour < 18 ? afternoon : evening; - const { prepend, append, emoji } = greetingArray[index]; + const safeIndex = index >= 0 && index < greetingArray.length ? index : 0; + const { prepend, append, emoji } = greetingArray[safeIndex]; return ( diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index 26e390ff4..6f9dceade 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -446,12 +446,13 @@ const notificationValidation = joi.object({ type: joi .string() - .valid("email", "webhook", "slack", "discord", "pager_duty") + .valid("email", "webhook", "slack", "discord", "pager_duty", "ntfy") .required() .messages({ "string.empty": "Notification type is required", "any.required": "Notification type is required", - "any.only": "Notification type must be email, webhook, or pager_duty", + "any.only": + "Notification type must be email, webhook, slack, discord, pager_duty, or ntfy", }), address: joi.when("type", { @@ -483,8 +484,74 @@ const notificationValidation = joi.object({ "string.uri": "Please enter a valid Webhook URL", }), }, + { + is: "ntfy", + then: joi.string().uri().required().messages({ + "string.empty": "ntfy URL cannot be empty", + "any.required": "ntfy URL is required", + "string.uri": "Please enter a valid ntfy URL", + }), + }, ], }), + + // ntfy-specific fields + ntfyAuthMethod: joi.when("type", { + is: "ntfy", + then: joi.string().valid("none", "username_password", "bearer_token").default("none"), + otherwise: joi.forbidden(), + }), + + ntfyUsername: joi.when("type", { + is: "ntfy", + then: joi.when("ntfyAuthMethod", { + is: "username_password", + then: joi.string().required().messages({ + "string.empty": + "Username cannot be empty when using username/password authentication", + "any.required": "Username is required for username/password authentication", + }), + otherwise: joi.string().optional().allow(""), + }), + otherwise: joi.forbidden(), + }), + + ntfyPassword: joi.when("type", { + is: "ntfy", + then: joi.when("ntfyAuthMethod", { + is: "username_password", + then: joi.string().required().messages({ + "string.empty": + "Password cannot be empty when using username/password authentication", + "any.required": "Password is required for username/password authentication", + }), + otherwise: joi.string().optional().allow(""), + }), + otherwise: joi.forbidden(), + }), + + ntfyBearerToken: joi.when("type", { + is: "ntfy", + then: joi.when("ntfyAuthMethod", { + is: "bearer_token", + then: joi.string().required().messages({ + "string.empty": + "Bearer token cannot be empty when using bearer token authentication", + "any.required": "Bearer token is required for bearer token authentication", + }), + otherwise: joi.string().optional().allow(""), + }), + otherwise: joi.forbidden(), + }), + + ntfyPriority: joi.when("type", { + is: "ntfy", + then: joi.number().min(1).max(5).default(3).messages({ + "number.min": "Priority must be between 1 and 5", + "number.max": "Priority must be between 1 and 5", + }), + otherwise: joi.forbidden(), + }), }); const editUserValidation = joi.object({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 991f57d72..b8045150f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -690,6 +690,20 @@ "webhookLabel": "Webhook URL", "webhookPlaceholder": "https://your-server.com/webhook" }, + "ntfySettings": { + "title": "ntfy", + "description": "Send push notifications via ntfy (self-hosted or ntfy.sh)", + "urlLabel": "ntfy Topic URL", + "urlPlaceholder": "https://ntfy.sh/my-topic", + "authMethodLabel": "Authentication Method", + "usernameLabel": "Username", + "usernamePlaceholder": "Enter username", + "passwordLabel": "Password", + "passwordPlaceholder": "Enter password", + "bearerTokenLabel": "Bearer Token", + "bearerTokenPlaceholder": "Enter bearer token", + "priorityLabel": "Priority" + }, "testNotification": "Test notification", "dialogDeleteTitle": "Are you sure you want to delete this notification?", "dialogDeleteConfirm": "Delete" diff --git a/server/src/db/models/Notification.js b/server/src/db/models/Notification.js index a6a3c7c09..88de5029a 100755 --- a/server/src/db/models/Notification.js +++ b/server/src/db/models/Notification.js @@ -16,7 +16,7 @@ const NotificationSchema = mongoose.Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty"], + enum: ["email", "slack", "discord", "webhook", "pager_duty", "ntfy"], }, notificationName: { type: String, @@ -28,6 +28,27 @@ const NotificationSchema = mongoose.Schema( phone: { type: String, }, + // ntfy-specific fields + ntfyAuthMethod: { + type: String, + enum: ["none", "username_password", "bearer_token"], + default: "none", + }, + ntfyUsername: { + type: String, + }, + ntfyPassword: { + type: String, // Will be encrypted + }, + ntfyBearerToken: { + type: String, // Will be encrypted + }, + ntfyPriority: { + type: Number, + min: 1, + max: 5, + default: 3, + }, }, { timestamps: true, diff --git a/server/src/service/infrastructure/networkService.js b/server/src/service/infrastructure/networkService.js index 72a117e74..9b08a8554 100644 --- a/server/src/service/infrastructure/networkService.js +++ b/server/src/service/infrastructure/networkService.js @@ -443,6 +443,52 @@ class NetworkService { throw error; } } + + async requestNtfy(url, message, title, notification) { + try { + // Build headers + const headers = { + Title: title, + Priority: notification.ntfyPriority?.toString() || "3", + Tags: "checkmate,monitoring", + "Content-Type": "text/plain", + }; + + // Add authentication headers based on method + if (notification.ntfyAuthMethod === "username_password" && notification.ntfyUsername && notification.ntfyPassword) { + const auth = Buffer.from(`${notification.ntfyUsername}:${notification.ntfyPassword}`).toString("base64"); + headers["Authorization"] = `Basic ${auth}`; + } else if (notification.ntfyAuthMethod === "bearer_token" && notification.ntfyBearerToken) { + headers["Authorization"] = `Bearer ${notification.ntfyBearerToken}`; + } + + // Send the notification + const response = await this.axios.post(url, message, { headers }); + + return { + type: "ntfy", + status: true, + code: response.status, + message: "Successfully sent ntfy notification", + payload: response.data, + }; + } catch (error) { + this.logger.warn({ + message: error.message, + service: this.SERVICE_NAME, + method: "requestNtfy", + url: url, + }); + + return { + type: "ntfy", + status: false, + code: error.response?.status || this.NETWORK_ERROR, + message: `Failed to send ntfy notification: ${error.message}`, + payload: error.response?.data, + }; + } + } } export default NetworkService; diff --git a/server/src/service/infrastructure/notificationService.js b/server/src/service/infrastructure/notificationService.js index 8c34519f3..b7ab220ab 100644 --- a/server/src/service/infrastructure/notificationService.js +++ b/server/src/service/infrastructure/notificationService.js @@ -44,14 +44,23 @@ class NotificationService { return response; } + if (type === "ntfy") { + const response = await this.networkService.requestNtfy(address, content, subject, notification); + return response.status; + } }; async handleNotifications(networkResponse) { const { monitor, statusChanged, prevStatus } = networkResponse; const { type } = monitor; - if (type !== "hardware" && statusChanged === false) return false; + + if (type !== "hardware" && statusChanged === false) { + return false; + } // if prevStatus is undefined, monitor is resuming, we're done - if (type !== "hardware" && prevStatus === undefined) return false; + if (type !== "hardware" && prevStatus === undefined) { + return false; + } const notificationIDs = networkResponse.monitor?.notifications ?? []; if (notificationIDs.length === 0) return false; diff --git a/server/src/validation/joi.js b/server/src/validation/joi.js index fa8dccde2..1633151a2 100755 --- a/server/src/validation/joi.js +++ b/server/src/validation/joi.js @@ -573,10 +573,10 @@ const createNotificationBodyValidation = joi.object({ "any.required": "Notification name is required", }), - type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty").required().messages({ + type: joi.string().valid("email", "webhook", "slack", "discord", "pager_duty", "ntfy").required().messages({ "string.empty": "Notification type is required", "any.required": "Notification type is required", - "any.only": "Notification type must be email, webhook, or pager_duty", + "any.only": "Notification type must be email, webhook, slack, discord, pager_duty, or ntfy", }), address: joi.when("type", { @@ -604,8 +604,71 @@ const createNotificationBodyValidation = joi.object({ "string.uri": "Please enter a valid Webhook URL", }), }, + { + is: "ntfy", + then: joi.string().uri().required().messages({ + "string.empty": "ntfy URL cannot be empty", + "any.required": "ntfy URL is required", + "string.uri": "Please enter a valid ntfy URL", + }), + }, ], }), + + // ntfy-specific fields + ntfyAuthMethod: joi.when("type", { + is: "ntfy", + then: joi.string().valid("none", "username_password", "bearer_token").default("none"), + otherwise: joi.forbidden(), + }), + + ntfyUsername: joi.when("type", { + is: "ntfy", + then: joi.when("ntfyAuthMethod", { + is: "username_password", + then: joi.string().required().messages({ + "string.empty": "Username cannot be empty when using username/password authentication", + "any.required": "Username is required for username/password authentication", + }), + otherwise: joi.string().optional().allow(""), + }), + otherwise: joi.forbidden(), + }), + + ntfyPassword: joi.when("type", { + is: "ntfy", + then: joi.when("ntfyAuthMethod", { + is: "username_password", + then: joi.string().required().messages({ + "string.empty": "Password cannot be empty when using username/password authentication", + "any.required": "Password is required for username/password authentication", + }), + otherwise: joi.string().optional().allow(""), + }), + otherwise: joi.forbidden(), + }), + + ntfyBearerToken: joi.when("type", { + is: "ntfy", + then: joi.when("ntfyAuthMethod", { + is: "bearer_token", + then: joi.string().required().messages({ + "string.empty": "Bearer token cannot be empty when using bearer token authentication", + "any.required": "Bearer token is required for bearer token authentication", + }), + otherwise: joi.string().optional().allow(""), + }), + otherwise: joi.forbidden(), + }), + + ntfyPriority: joi.when("type", { + is: "ntfy", + then: joi.number().min(1).max(5).default(3).messages({ + "number.min": "Priority must be between 1 and 5", + "number.max": "Priority must be between 1 and 5", + }), + otherwise: joi.forbidden(), + }), }); //****************************************