From fc4db7fa002acbb31f1fd8d20da019291d8096d8 Mon Sep 17 00:00:00 2001 From: Schrottfresser <39998368+Schrottfresser@users.noreply.github.com> Date: Tue, 6 May 2025 19:30:32 +0200 Subject: [PATCH] feat(ntfy): add native ntfy notification support (#1599) * feat(ntfy): add native ntfy notification fix #499 * feat(ntfy): update translation keys * feat(ntfy): append ntfy to cypress settings * feat(ntfy): adjust ntfy agent shouldSend * feat(ntfy): simplify ntfy post routes * feat(ntfy): refactor ntfy agent from fetch to axios * feat(ntfy): refactor ntfy frontend from fetch to axios --- cypress/config/settings.cypress.json | 8 + jellyseerr-api.yml | 72 ++++ server/index.ts | 2 + server/lib/notifications/agents/ntfy.ts | 164 ++++++++ server/lib/settings/index.ts | 22 ++ server/routes/settings/notifications.ts | 35 ++ src/assets/extlogos/ntfy.svg | 1 + .../Notifications/NotificationsNtfy/index.tsx | 366 ++++++++++++++++++ .../Settings/SettingsNotifications.tsx | 12 + src/i18n/locale/en.json | 16 + src/pages/settings/notifications/ntfy.tsx | 19 + 11 files changed, 717 insertions(+) create mode 100644 server/lib/notifications/agents/ntfy.ts create mode 100644 src/assets/extlogos/ntfy.svg create mode 100644 src/components/Settings/Notifications/NotificationsNtfy/index.tsx create mode 100644 src/pages/settings/notifications/ntfy.tsx diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 3cf2e1d98..8ff53fce4 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -142,6 +142,14 @@ "token": "", "priority": 0 } + }, + "ntfy": { + "enabled": false, + "types": 0, + "options": { + "url": "", + "topic": "" + } } } }, diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 55075b0cd..2152a5a3e 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -1399,6 +1399,32 @@ components: type: string token: type: string + NtfySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + url: + type: string + topic: + type: string + authMethodUsernamePassword: + type: boolean + username: + type: string + password: + type: string + authMethodToken: + type: boolean + token: + type: string LunaSeaSettings: type: object properties: @@ -3249,6 +3275,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/ntfy: + get: + summary: Get ntfy.sh notification settings + description: Returns current ntfy.sh notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned ntfy.sh settings + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + post: + summary: Update ntfy.sh notification settings + description: Update ntfy.sh notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + /settings/notifications/ntfy/test: + post: + summary: Test ntfy.sh settings + description: Sends a test notification to the ntfy.sh agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '204': + description: Test notification attempted /settings/notifications/slack: get: summary: Get Slack notification settings diff --git a/server/index.ts b/server/index.ts index cca0ab82e..abb98be60 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; import LunaSeaAgent from '@server/lib/notifications/agents/lunasea'; +import NtfyAgent from '@server/lib/notifications/agents/ntfy'; import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; import PushoverAgent from '@server/lib/notifications/agents/pushover'; import SlackAgent from '@server/lib/notifications/agents/slack'; @@ -103,6 +104,7 @@ app new DiscordAgent(), new EmailAgent(), new GotifyAgent(), + new NtfyAgent(), new LunaSeaAgent(), new PushbulletAgent(), new PushoverAgent(), diff --git a/server/lib/notifications/agents/ntfy.ts b/server/lib/notifications/agents/ntfy.ts new file mode 100644 index 000000000..005e9aa15 --- /dev/null +++ b/server/lib/notifications/agents/ntfy.ts @@ -0,0 +1,164 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import type { NotificationAgentNtfy } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import axios from 'axios'; +import { hasNotificationType, Notification } from '..'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; + +class NtfyAgent + extends BaseAgent + implements NotificationAgent +{ + protected getSettings(): NotificationAgentNtfy { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.ntfy; + } + + private buildPayload(type: Notification, payload: NotificationPayload) { + const { applicationUrl } = getSettings().main; + + const topic = this.getSettings().options.topic; + const priority = 3; + + const title = payload.event + ? `${payload.event} - ${payload.subject}` + : payload.subject; + let message = payload.message ?? ''; + + if (payload.request) { + message += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; + + let status = ''; + switch (type) { + case Notification.MEDIA_PENDING: + status = 'Pending Approval'; + break; + case Notification.MEDIA_APPROVED: + case Notification.MEDIA_AUTO_APPROVED: + status = 'Processing'; + break; + case Notification.MEDIA_AVAILABLE: + status = 'Available'; + break; + case Notification.MEDIA_DECLINED: + status = 'Declined'; + break; + case Notification.MEDIA_FAILED: + status = 'Failed'; + break; + } + + if (status) { + message += `\nRequest Status: ${status}`; + } + } else if (payload.comment) { + message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`; + } else if (payload.issue) { + message += `\n\nReported By: ${payload.issue.createdBy.displayName}`; + message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`; + message += `\nIssue Status: ${ + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' + }`; + } + + for (const extra of payload.extra ?? []) { + message += `\n\n**${extra.name}**\n${extra.value}`; + } + + const attach = payload.image; + + let click; + if (applicationUrl && payload.media) { + click = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; + } + + return { + topic, + priority, + title, + message, + attach, + click, + }; + } + + public shouldSend(): boolean { + const settings = this.getSettings(); + + if (settings.enabled && settings.options.url && settings.options.topic) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + const settings = this.getSettings(); + + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { + return true; + } + + logger.debug('Sending ntfy notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + }); + + try { + let authHeader; + if ( + settings.options.authMethodUsernamePassword && + settings.options.username && + settings.options.password + ) { + const encodedAuth = Buffer.from( + `${settings.options.username}:${settings.options.password}` + ).toString('base64'); + + authHeader = `Basic ${encodedAuth}`; + } else if (settings.options.authMethodToken) { + authHeader = `Bearer ${settings.options.token}`; + } + + await axios.post( + settings.options.url, + this.buildPayload(type, payload), + authHeader + ? { + headers: { + Authorization: authHeader, + }, + } + : undefined + ); + + return true; + } catch (e) { + logger.error('Error sending ntfy notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e?.response?.data, + }); + + return false; + } + } +} + +export default NtfyAgent; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 86cdf81c3..2f9b27fa5 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -259,10 +259,23 @@ export interface NotificationAgentGotify extends NotificationAgentConfig { }; } +export interface NotificationAgentNtfy extends NotificationAgentConfig { + options: { + url: string; + topic: string; + authMethodUsernamePassword?: boolean; + username?: string; + password?: string; + authMethodToken?: boolean; + token?: string; + }; +} + export enum NotificationAgentKey { DISCORD = 'discord', EMAIL = 'email', GOTIFY = 'gotify', + NTFY = 'ntfy', PUSHBULLET = 'pushbullet', PUSHOVER = 'pushover', SLACK = 'slack', @@ -275,6 +288,7 @@ interface NotificationAgents { discord: NotificationAgentDiscord; email: NotificationAgentEmail; gotify: NotificationAgentGotify; + ntfy: NotificationAgentNtfy; lunasea: NotificationAgentLunaSea; pushbullet: NotificationAgentPushbullet; pushover: NotificationAgentPushover; @@ -471,6 +485,14 @@ class Settings { priority: 0, }, }, + ntfy: { + enabled: false, + types: 0, + options: { + url: '', + topic: '', + }, + }, }, }, jobs: { diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 5b2e1715b..7d817c36d 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -5,6 +5,7 @@ import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; import LunaSeaAgent from '@server/lib/notifications/agents/lunasea'; +import NtfyAgent from '@server/lib/notifications/agents/ntfy'; import PushbulletAgent from '@server/lib/notifications/agents/pushbullet'; import PushoverAgent from '@server/lib/notifications/agents/pushover'; import SlackAgent from '@server/lib/notifications/agents/slack'; @@ -413,4 +414,38 @@ notificationRoutes.post('/gotify/test', async (req, res, next) => { } }); +notificationRoutes.get('/ntfy', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.ntfy); +}); + +notificationRoutes.post('/ntfy', async (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.ntfy = req.body; + await settings.save(); + + res.status(200).json(settings.notifications.agents.ntfy); +}); + +notificationRoutes.post('/ntfy/test', async (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information is missing from the request.', + }); + } + + const ntfyAgent = new NtfyAgent(req.body); + if (await sendTestNotification(ntfyAgent, req.user)) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send ntfy notification.', + }); + } +}); + export default notificationRoutes; diff --git a/src/assets/extlogos/ntfy.svg b/src/assets/extlogos/ntfy.svg new file mode 100644 index 000000000..dced2e155 --- /dev/null +++ b/src/assets/extlogos/ntfy.svg @@ -0,0 +1 @@ + diff --git a/src/components/Settings/Notifications/NotificationsNtfy/index.tsx b/src/components/Settings/Notifications/NotificationsNtfy/index.tsx new file mode 100644 index 000000000..30906a83e --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsNtfy/index.tsx @@ -0,0 +1,366 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; +import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; + +const messages = defineMessages( + 'components.Settings.Notifications.NotificationsNtfy', + { + agentenabled: 'Enable Agent', + url: 'Server root URL', + topic: 'Topic', + usernamePasswordAuth: 'Username + Password authentication', + username: 'Username', + password: 'Password', + tokenAuth: 'Token authentication', + token: 'Token', + ntfysettingssaved: 'Ntfy notification settings saved successfully!', + ntfysettingsfailed: 'Ntfy notification settings failed to save.', + toastNtfyTestSending: 'Sending ntfy test notification…', + toastNtfyTestSuccess: 'Ntfy test notification sent!', + toastNtfyTestFailed: 'Ntfy test notification failed to send.', + validationNtfyUrl: 'You must provide a valid URL', + validationNtfyTopic: 'You must provide a topic', + validationTypes: 'You must select at least one notification type', + } +); + +const NotificationsNtfy = () => { + const intl = useIntl(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); + const { + data, + error, + mutate: revalidate, + } = useSWR('/api/v1/settings/notifications/ntfy'); + + const NotificationsNtfySchema = Yup.object().shape({ + url: Yup.string() + .when('enabled', { + is: true, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationNtfyUrl)), + otherwise: Yup.string().nullable(), + }) + .matches( + // eslint-disable-next-line no-useless-escape + /^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i, + intl.formatMessage(messages.validationNtfyUrl) + ), + topic: Yup.string() + .when('enabled', { + is: true, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationNtfyUrl)), + otherwise: Yup.string().nullable(), + }) + .defined(intl.formatMessage(messages.validationNtfyTopic)), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post('/api/v1/settings/notifications/ntfy', { + enabled: values.enabled, + types: values.types, + options: { + url: values.url, + topic: values.topic, + authMethodUsernamePassword: values.authMethodUsernamePassword, + username: values.username, + password: values.password, + authMethodToken: values.authMethodToken, + token: values.token, + }, + }); + + addToast(intl.formatMessage(messages.ntfysettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.ntfysettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + values, + isValid, + setFieldValue, + setFieldTouched, + }) => { + const testSettings = async () => { + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastNtfyTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/ntfy/test', { + enabled: true, + types: values.types, + options: { + url: values.url, + topic: values.topic, + authMethodUsernamePassword: values.authMethodUsernamePassword, + username: values.username, + password: values.password, + authMethodToken: values.authMethodToken, + token: values.token, + }, + }); + + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastNtfyTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastNtfyTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } + }; + + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.url && + touched.url && + typeof errors.url === 'string' && ( +
{errors.url}
+ )} +
+
+
+ +
+
+ +
+ {errors.topic && + touched.topic && + typeof errors.topic === 'string' && ( +
{errors.topic}
+ )} +
+
+
+ +
+ { + setFieldValue( + 'authMethodUsernamePassword', + !values.authMethodUsernamePassword + ); + }} + /> +
+
+ {values.authMethodUsernamePassword && ( +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ )} +
+ +
+ { + setFieldValue('authMethodToken', !values.authMethodToken); + }} + /> +
+
+ {values.authMethodToken && ( +
+ +
+
+ +
+
+
+ )} + { + setFieldValue('types', newTypes); + setFieldTouched('types'); + + if (newTypes) { + setFieldValue('enabled', true); + } + }} + error={ + values.enabled && !values.types && touched.types + ? intl.formatMessage(messages.validationTypes) + : undefined + } + /> +
+
+ + + + + + +
+
+ + ); + }} +
+ ); +}; + +export default NotificationsNtfy; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 38e3f376f..bcd5e5f65 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -1,6 +1,7 @@ import DiscordLogo from '@app/assets/extlogos/discord.svg'; import GotifyLogo from '@app/assets/extlogos/gotify.svg'; import LunaSeaLogo from '@app/assets/extlogos/lunasea.svg'; +import NtfyLogo from '@app/assets/extlogos/ntfy.svg'; import PushbulletLogo from '@app/assets/extlogos/pushbullet.svg'; import PushoverLogo from '@app/assets/extlogos/pushover.svg'; import SlackLogo from '@app/assets/extlogos/slack.svg'; @@ -75,6 +76,17 @@ const SettingsNotifications = ({ children }: SettingsNotificationsProps) => { route: '/settings/notifications/gotify', regex: /^\/settings\/notifications\/gotify/, }, + { + text: 'ntfy.sh', + content: ( + + + ntfy.sh + + ), + route: '/settings/notifications/ntfy', + regex: /^\/settings\/notifications\/ntfy/, + }, { text: 'LunaSea', content: ( diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 6548c7272..d059904f4 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -624,6 +624,22 @@ "components.Settings.Notifications.NotificationsLunaSea.validationWebhookUrl": "You must provide a valid URL", "components.Settings.Notifications.NotificationsLunaSea.webhookUrl": "Webhook URL", "components.Settings.Notifications.NotificationsLunaSea.webhookUrlTip": "Your user- or device-based notification webhook URL", + "components.Settings.Notifications.NotificationsNtfy.agentenabled": "Enable Agent", + "components.Settings.Notifications.NotificationsNtfy.ntfysettingsfailed": "Ntfy notification settings failed to save.", + "components.Settings.Notifications.NotificationsNtfy.ntfysettingssaved": "Ntfy notification settings saved successfully!", + "components.Settings.Notifications.NotificationsNtfy.password": "Password", + "components.Settings.Notifications.NotificationsNtfy.toastNtfyTestFailed": "Ntfy test notification failed to send.", + "components.Settings.Notifications.NotificationsNtfy.toastNtfyTestSending": "Sending ntfy test notification…", + "components.Settings.Notifications.NotificationsNtfy.toastNtfyTestSuccess": "Ntfy test notification sent!", + "components.Settings.Notifications.NotificationsNtfy.token": "Token", + "components.Settings.Notifications.NotificationsNtfy.tokenAuth": "Token authentication", + "components.Settings.Notifications.NotificationsNtfy.topic": "Topic", + "components.Settings.Notifications.NotificationsNtfy.url": "Server root URL", + "components.Settings.Notifications.NotificationsNtfy.username": "Username", + "components.Settings.Notifications.NotificationsNtfy.usernamePasswordAuth": "Username + Password authentication", + "components.Settings.Notifications.NotificationsNtfy.validationNtfyTopic": "You must provide a topic", + "components.Settings.Notifications.NotificationsNtfy.validationNtfyUrl": "You must provide a valid URL", + "components.Settings.Notifications.NotificationsNtfy.validationTypes": "You must select at least one notification type", "components.Settings.Notifications.NotificationsPushbullet.accessToken": "Access Token", "components.Settings.Notifications.NotificationsPushbullet.accessTokenTip": "Create a token from your Account Settings", "components.Settings.Notifications.NotificationsPushbullet.agentEnabled": "Enable Agent", diff --git a/src/pages/settings/notifications/ntfy.tsx b/src/pages/settings/notifications/ntfy.tsx new file mode 100644 index 000000000..2cbfad224 --- /dev/null +++ b/src/pages/settings/notifications/ntfy.tsx @@ -0,0 +1,19 @@ +import NotificationsNtfy from '@app/components/Settings/Notifications/NotificationsNtfy'; +import SettingsLayout from '@app/components/Settings/SettingsLayout'; +import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const NotificationsPage: NextPage = () => { + useRouteGuard(Permission.ADMIN); + return ( + + + + + + ); +}; + +export default NotificationsPage;