From a7cc7c59753dd9649b2ec37eb9d46fe4fa8e1e1c Mon Sep 17 00:00:00 2001 From: sct Date: Tue, 12 Jan 2021 18:28:42 +0900 Subject: [PATCH] feat(notifications): Webhook Notifications (#632) --- overseerr-api.yml | 62 ++++ package.json | 2 + server/index.ts | 3 +- server/lib/notifications/agents/webhook.ts | 139 ++++++++ server/lib/settings.ts | 19 ++ .../routes/{settings.ts => settings/index.ts} | 207 +----------- server/routes/settings/notifications.ts | 265 +++++++++++++++ src/assets/bolt.svg | 1 + src/components/JSONEditor/index.tsx | 35 ++ .../NotificationsWebhook/index.tsx | 315 ++++++++++++++++++ .../Settings/SettingsNotifications.tsx | 12 + src/i18n/locale/en.json | 16 + src/pages/settings/notifications/webhook.tsx | 17 + yarn.lock | 26 ++ 14 files changed, 928 insertions(+), 191 deletions(-) create mode 100644 server/lib/notifications/agents/webhook.ts rename server/routes/{settings.ts => settings/index.ts} (67%) create mode 100644 server/routes/settings/notifications.ts create mode 100644 src/assets/bolt.svg create mode 100644 src/components/JSONEditor/index.tsx create mode 100644 src/components/Settings/Notifications/NotificationsWebhook/index.tsx create mode 100644 src/pages/settings/notifications/webhook.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 16668a10d..e5cd443ba 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -864,6 +864,22 @@ components: properties: webhookUrl: type: string + WebhookSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + jsonPayload: + type: string TelegramSettings: type: object properties: @@ -1841,6 +1857,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/webhook: + get: + summary: Return current webhook notification settings + description: Returns current webhook notification settings in JSON format + tags: + - settings + responses: + '200': + description: Returned webhook settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + post: + summary: Update webhook notification settings + description: Update current webhook notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + /settings/notifications/webhook/test: + post: + summary: Test the provided slack settings + description: Sends a test notification to the slack agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '204': + description: Test notification attempted /settings/about: get: summary: Return current about stats diff --git a/package.json b/package.json index a99c656fb..303e9fb64 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "license": "MIT", "dependencies": { "@svgr/webpack": "^5.5.0", + "ace-builds": "^1.4.12", "axios": "^0.21.1", "body-parser": "^1.19.0", "bowser": "^2.11.0", @@ -37,6 +38,7 @@ "plex-api": "^5.3.1", "pug": "^3.0.0", "react": "17.0.1", + "react-ace": "^9.2.1", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", "react-intl": "^5.10.11", diff --git a/server/index.ts b/server/index.ts index 2ab14e178..6733af87f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -21,6 +21,7 @@ import TelegramAgent from './lib/notifications/agents/telegram'; import { getAppVersion } from './utils/appVersion'; import SlackAgent from './lib/notifications/agents/slack'; import PushoverAgent from './lib/notifications/agents/pushover'; +import WebhookAgent from './lib/notifications/agents/webhook'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -51,6 +52,7 @@ app new SlackAgent(), new TelegramAgent(), new PushoverAgent(), + new WebhookAgent(), ]); // Start Jobs @@ -98,7 +100,6 @@ app }; next(); }); - server.use('/api/v1', routes); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts new file mode 100644 index 000000000..d0e502e8e --- /dev/null +++ b/server/lib/notifications/agents/webhook.ts @@ -0,0 +1,139 @@ +import axios from 'axios'; +import { get } from 'lodash'; +import { hasNotificationType, Notification } from '..'; +import { MediaStatus } from '../../../constants/media'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentWebhook } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +type KeyMapFunction = ( + payload: NotificationPayload, + type: Notification +) => string; + +const KeyMap: Record = { + notification_type: (_payload, type) => Notification[type], + subject: 'subject', + message: 'message', + image: 'image', + notifyuser_username: 'notifyUser.username', + notifyuser_email: 'notifyUser.email', + notifyuser_avatar: 'notifyUser.avatar', + media_tmdbid: 'media.tmdbId', + media_imdbid: 'media.imdbId', + media_tvdbid: 'media.tvdbId', + media_type: 'media.mediaType', + media_status: (payload) => + payload.media?.status ? MediaStatus[payload.media?.status] : '', + media_status4k: (payload) => + payload.media?.status ? MediaStatus[payload.media?.status4k] : '', +}; + +class WebhookAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentWebhook { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.webhook; + } + + private parseKeys( + finalPayload: Record, + payload: NotificationPayload, + type: Notification + ): Record { + Object.keys(finalPayload).forEach((key) => { + if (key === '{{extra}}') { + finalPayload.extra = payload.extra ?? []; + delete finalPayload[key]; + key = 'extra'; + } else if (key === '{{media}}') { + if (payload.media) { + finalPayload.media = finalPayload[key]; + } else { + finalPayload.media = null; + } + delete finalPayload[key]; + key = 'media'; + } + + if (typeof finalPayload[key] === 'string') { + Object.keys(KeyMap).forEach((keymapKey) => { + const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap]; + finalPayload[key] = (finalPayload[key] as string).replace( + `{{${keymapKey}}}`, + typeof keymapValue === 'function' + ? keymapValue(payload, type) + : get(payload, keymapValue) ?? '' + ); + }); + } else if (finalPayload[key] && typeof finalPayload[key] === 'object') { + finalPayload[key] = this.parseKeys( + finalPayload[key] as Record, + payload, + type + ); + } + }); + + return finalPayload; + } + + private buildPayload(type: Notification, payload: NotificationPayload) { + const payloadString = Buffer.from( + this.getSettings().options.jsonPayload, + 'base64' + ).toString('ascii'); + + const parsedJSON = JSON.parse(JSON.parse(payloadString)); + + return this.parseKeys(parsedJSON, payload, type); + } + + public shouldSend(type: Notification): boolean { + if ( + this.getSettings().enabled && + this.getSettings().options.webhookUrl && + hasNotificationType(type, this.getSettings().types) + ) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending webhook notification', { label: 'Notifications' }); + try { + const { webhookUrl, authHeader } = this.getSettings().options; + + if (!webhookUrl) { + return false; + } + + await axios.post(webhookUrl, this.buildPayload(type, payload), { + headers: { + Authorization: authHeader, + }, + }); + + return true; + } catch (e) { + logger.error('Error sending Webhook notification', { + label: 'Notifications', + errorMessage: e.message, + }); + return false; + } + } +} + +export default WebhookAgent; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index e4f19bde1..8d1db87be 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -106,12 +106,21 @@ export interface NotificationAgentPushover extends NotificationAgentConfig { }; } +export interface NotificationAgentWebhook extends NotificationAgentConfig { + options: { + webhookUrl: string; + jsonPayload: string; + authHeader: string; + }; +} + interface NotificationAgents { email: NotificationAgentEmail; discord: NotificationAgentDiscord; slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; pushover: NotificationAgentPushover; + webhook: NotificationAgentWebhook; } interface NotificationSettings { @@ -199,6 +208,16 @@ class Settings { sound: '', }, }, + webhook: { + enabled: false, + types: 0, + options: { + webhookUrl: '', + authHeader: '', + jsonPayload: + 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', + }, + }, }, }, }; diff --git a/server/routes/settings.ts b/server/routes/settings/index.ts similarity index 67% rename from server/routes/settings.ts rename to server/routes/settings/index.ts index 91d196e14..0b6ebaf4f 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings/index.ts @@ -5,31 +5,28 @@ import { SonarrSettings, Library, MainSettings, -} from '../lib/settings'; +} from '../../lib/settings'; import { getRepository } from 'typeorm'; -import { User } from '../entity/User'; -import PlexAPI from '../api/plexapi'; -import { jobPlexFullSync } from '../job/plexsync'; -import SonarrAPI from '../api/sonarr'; -import RadarrAPI from '../api/radarr'; -import logger from '../logger'; -import { scheduledJobs } from '../job/schedule'; -import { Permission } from '../lib/permissions'; -import { isAuthenticated } from '../middleware/auth'; +import { User } from '../../entity/User'; +import PlexAPI from '../../api/plexapi'; +import { jobPlexFullSync } from '../../job/plexsync'; +import SonarrAPI from '../../api/sonarr'; +import RadarrAPI from '../../api/radarr'; +import logger from '../../logger'; +import { scheduledJobs } from '../../job/schedule'; +import { Permission } from '../../lib/permissions'; +import { isAuthenticated } from '../../middleware/auth'; import { merge, omit } from 'lodash'; -import Media from '../entity/Media'; -import { MediaRequest } from '../entity/MediaRequest'; -import { getAppVersion } from '../utils/appVersion'; -import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces'; -import { Notification } from '../lib/notifications'; -import DiscordAgent from '../lib/notifications/agents/discord'; -import EmailAgent from '../lib/notifications/agents/email'; -import SlackAgent from '../lib/notifications/agents/slack'; -import TelegramAgent from '../lib/notifications/agents/telegram'; -import PushoverAgent from '../lib/notifications/agents/pushover'; +import Media from '../../entity/Media'; +import { MediaRequest } from '../../entity/MediaRequest'; +import { getAppVersion } from '../../utils/appVersion'; +import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces'; +import notificationRoutes from './notifications'; const settingsRoutes = Router(); +settingsRoutes.use('/notifications', notificationRoutes); + const filteredMainSettings = ( user: User, main: MainSettings @@ -437,176 +434,6 @@ settingsRoutes.get( } ); -settingsRoutes.get('/notifications/discord', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.discord); -}); - -settingsRoutes.post('/notifications/discord', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.discord = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.discord); -}); - -settingsRoutes.post('/notifications/discord/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const discordAgent = new DiscordAgent(req.body); - discordAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/slack', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.slack); -}); - -settingsRoutes.post('/notifications/slack', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.slack = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.slack); -}); - -settingsRoutes.post('/notifications/slack/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const slackAgent = new SlackAgent(req.body); - slackAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/telegram', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.telegram); -}); - -settingsRoutes.post('/notifications/telegram', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.telegram = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.telegram); -}); - -settingsRoutes.post('/notifications/telegram/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const telegramAgent = new TelegramAgent(req.body); - telegramAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/pushover', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.pushover); -}); - -settingsRoutes.post('/notifications/pushover', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.pushover = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.pushover); -}); - -settingsRoutes.post('/notifications/pushover/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const pushoverAgent = new PushoverAgent(req.body); - pushoverAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - -settingsRoutes.get('/notifications/email', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.notifications.agents.email); -}); - -settingsRoutes.post('/notifications/email', (req, res) => { - const settings = getSettings(); - - settings.notifications.agents.email = req.body; - settings.save(); - - res.status(200).json(settings.notifications.agents.email); -}); - -settingsRoutes.post('/notifications/email/test', (req, res, next) => { - if (!req.user) { - return next({ - status: 500, - message: 'User information missing from request', - }); - } - - const emailAgent = new EmailAgent(req.body); - emailAgent.send(Notification.TEST_NOTIFICATION, { - notifyUser: req.user, - subject: 'Test Notification', - message: - 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', - }); - - return res.status(204).send(); -}); - settingsRoutes.get('/about', async (req, res) => { const mediaRepository = getRepository(Media); const mediaRequestRepository = getRepository(MediaRequest); diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts new file mode 100644 index 000000000..10b0e7a51 --- /dev/null +++ b/server/routes/settings/notifications.ts @@ -0,0 +1,265 @@ +import { Router } from 'express'; +import { getSettings } from '../../lib/settings'; +import { Notification } from '../../lib/notifications'; +import DiscordAgent from '../../lib/notifications/agents/discord'; +import EmailAgent from '../../lib/notifications/agents/email'; +import SlackAgent from '../../lib/notifications/agents/slack'; +import TelegramAgent from '../../lib/notifications/agents/telegram'; +import PushoverAgent from '../../lib/notifications/agents/pushover'; +import WebhookAgent from '../../lib/notifications/agents/webhook'; + +const notificationRoutes = Router(); + +notificationRoutes.get('/discord', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.discord); +}); + +notificationRoutes.post('/discord', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.discord = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.discord); +}); + +notificationRoutes.post('/discord/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const discordAgent = new DiscordAgent(req.body); + discordAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + +notificationRoutes.get('/slack', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +notificationRoutes.post('/slack', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.slack = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +notificationRoutes.post('/slack/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const slackAgent = new SlackAgent(req.body); + slackAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + +notificationRoutes.get('/telegram', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.telegram); +}); + +notificationRoutes.post('/telegram', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.telegram = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.telegram); +}); + +notificationRoutes.post('/telegram/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const telegramAgent = new TelegramAgent(req.body); + telegramAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + +notificationRoutes.get('/pushover', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.pushover); +}); + +notificationRoutes.post('/pushover', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.pushover = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.pushover); +}); + +notificationRoutes.post('/pushover/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const pushoverAgent = new PushoverAgent(req.body); + pushoverAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + +notificationRoutes.get('/email', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.email); +}); + +notificationRoutes.post('/email', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.email = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.email); +}); + +notificationRoutes.post('/email/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const emailAgent = new EmailAgent(req.body); + emailAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + +notificationRoutes.get('/webhook', (_req, res) => { + const settings = getSettings(); + + const webhookSettings = settings.notifications.agents.webhook; + + const response: typeof webhookSettings = { + enabled: webhookSettings.enabled, + types: webhookSettings.types, + options: { + ...webhookSettings.options, + jsonPayload: JSON.parse( + Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString( + 'ascii' + ) + ), + }, + }; + + res.status(200).json(response); +}); + +notificationRoutes.post('/webhook', (req, res, next) => { + const settings = getSettings(); + try { + JSON.parse(req.body.options.jsonPayload); + + settings.notifications.agents.webhook = { + enabled: req.body.enabled, + types: req.body.types, + options: { + jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( + 'base64' + ), + webhookUrl: req.body.options.webhookUrl, + authHeader: req.body.options.authHeader, + }, + }; + settings.save(); + + res.status(200).json(settings.notifications.agents.webhook); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +notificationRoutes.post('/webhook/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + try { + JSON.parse(req.body.options.jsonPayload); + + const testBody = { + enabled: req.body.enabled, + types: req.body.types, + options: { + jsonPayload: Buffer.from(req.body.options.jsonPayload).toString( + 'base64' + ), + webhookUrl: req.body.options.webhookUrl, + authHeader: req.body.options.authHeader, + }, + }; + + const webhookAgent = new WebhookAgent(testBody); + webhookAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +export default notificationRoutes; diff --git a/src/assets/bolt.svg b/src/assets/bolt.svg new file mode 100644 index 000000000..20259b649 --- /dev/null +++ b/src/assets/bolt.svg @@ -0,0 +1 @@ + diff --git a/src/components/JSONEditor/index.tsx b/src/components/JSONEditor/index.tsx new file mode 100644 index 000000000..b1de78a75 --- /dev/null +++ b/src/components/JSONEditor/index.tsx @@ -0,0 +1,35 @@ +import React, { HTMLAttributes } from 'react'; +import AceEditor from 'react-ace'; +import 'ace-builds/src-noconflict/mode-json'; +import 'ace-builds/src-noconflict/theme-dracula'; + +interface JSONEditorProps extends HTMLAttributes { + name: string; + value: string; + onUpdate: (value: string) => void; +} + +const JSONEditor: React.FC = ({ + name, + value, + onUpdate, + onBlur, +}) => { + return ( +
+ +
+ ); +}; + +export default JSONEditor; diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx new file mode 100644 index 000000000..f618d183b --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -0,0 +1,315 @@ +import React from 'react'; +import { Field, Form, Formik } from 'formik'; +import dynamic from 'next/dynamic'; +import useSWR from 'swr'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import Button from '../../../Common/Button'; +import { defineMessages, useIntl } from 'react-intl'; +import axios from 'axios'; +import * as Yup from 'yup'; +import { useToasts } from 'react-toast-notifications'; +import NotificationTypeSelector from '../../../NotificationTypeSelector'; + +const JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false }); + +const defaultPayload = { + notification_type: '{{notification_type}}', + subject: '{{subject}}', + message: '{{message}}', + image: '{{image}}', + email: '{{notifyuser_email}}', + username: '{{notifyuser_username}}', + avatar: '{{notifyuser_avatar}}', + '{{media}}': { + media_type: '{{media_type}}', + tmdbId: '{{media_tmdbid}}', + imdbId: '{{media_imdbid}}', + tvdbId: '{{media_tvdbid}}', + status: '{{media_status}}', + status4k: '{{media_status4k}}', + }, + '{{extra}}': [], +}; + +const messages = defineMessages({ + save: 'Save Changes', + saving: 'Saving...', + agentenabled: 'Agent Enabled', + webhookUrl: 'Webhook URL', + authheader: 'Authorization Header', + validationWebhookUrlRequired: 'You must provide a webhook URL', + validationJsonPayloadRequired: 'You must provide a JSON Payload', + webhookUrlPlaceholder: 'Remote webhook URL', + webhooksettingssaved: 'Webhook notification settings saved!', + webhooksettingsfailed: 'Webhook notification settings failed to save.', + testsent: 'Test notification sent!', + test: 'Test', + notificationtypes: 'Notification Types', + resetPayload: 'Reset to Default JSON Payload', + resetPayloadSuccess: 'JSON reset to default payload.', + customJson: 'Custom JSON Payload', +}); + +const NotificationsWebhook: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/notifications/webhook' + ); + + const NotificationsWebhookSchema = Yup.object().shape({ + webhookUrl: Yup.string().required( + intl.formatMessage(messages.validationWebhookUrlRequired) + ), + jsonPayload: Yup.string() + .required(intl.formatMessage(messages.validationJsonPayloadRequired)) + .test('validate-json', 'Invalid JSON', (value) => { + try { + JSON.parse(value ?? ''); + return true; + } catch (e) { + return false; + } + }), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post('/api/v1/settings/notifications/webhook', { + enabled: values.enabled, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + jsonPayload: JSON.stringify(values.jsonPayload), + authHeader: values.authHeader, + }, + }); + addToast(intl.formatMessage(messages.webhooksettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.webhooksettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + values, + isValid, + setFieldValue, + setFieldTouched, + }) => { + const resetPayload = () => { + setFieldValue( + 'jsonPayload', + JSON.stringify(defaultPayload, undefined, ' ') + ); + addToast(intl.formatMessage(messages.resetPayloadSuccess), { + appearance: 'info', + autoDismiss: true, + }); + }; + + const testSettings = async () => { + await axios.post('/api/v1/settings/notifications/webhook/test', { + enabled: true, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + jsonPayload: JSON.stringify(values.jsonPayload), + authHeader: values.authHeader, + }, + }); + + addToast(intl.formatMessage(messages.testsent), { + appearance: 'info', + autoDismiss: true, + }); + }; + + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.webhookUrl && touched.webhookUrl && ( +
{errors.webhookUrl}
+ )} +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ setFieldValue('jsonPayload', value)} + value={values.jsonPayload} + onBlur={() => setFieldTouched('jsonPayload')} + /> +
+ {errors.jsonPayload && touched.jsonPayload && ( +
{errors.jsonPayload}
+ )} +
+ +
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.notificationtypes)} +
+
+
+
+ + setFieldValue('types', newTypes) + } + /> +
+
+
+
+
+
+
+ + + + + + +
+
+
+ ); + }} +
+ ); +}; + +export default NotificationsWebhook; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index e873ecf8f..8815b14f3 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -6,6 +6,7 @@ import DiscordLogo from '../../assets/extlogos/discord_white.svg'; import SlackLogo from '../../assets/extlogos/slack.svg'; import TelegramLogo from '../../assets/extlogos/telegram.svg'; import PushoverLogo from '../../assets/extlogos/pushover.svg'; +import Bolt from '../../assets/bolt.svg'; const messages = defineMessages({ notificationsettings: 'Notification Settings', @@ -89,6 +90,17 @@ const settingsRoutes: SettingsRoute[] = [ route: '/settings/notifications/pushover', regex: /^\/settings\/notifications\/pushover/, }, + { + text: 'Webhook', + content: ( + + + Webhook + + ), + route: '/settings/notifications/webhook', + regex: /^\/settings\/notifications\/webhook/, + }, ]; const SettingsNotifications: React.FC = ({ children }) => { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 7e9757347..c4661d777 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -162,6 +162,22 @@ "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "You must provide a webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "Webhook URL", + "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agent Enabled", + "components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header", + "components.Settings.Notifications.NotificationsWebhook.customJson": "Custom JSON Payload", + "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Notification Types", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default JSON Payload", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON reset to default payload.", + "components.Settings.Notifications.NotificationsWebhook.save": "Save Changes", + "components.Settings.Notifications.NotificationsWebhook.saving": "Saving...", + "components.Settings.Notifications.NotificationsWebhook.test": "Test", + "components.Settings.Notifications.NotificationsWebhook.testsent": "Test notification sent!", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "You must provide a JSON Payload", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "You must provide a webhook URL", + "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL", + "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "Remote webhook URL", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Webhook notification settings failed to save.", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved!", "components.Settings.Notifications.agentenabled": "Agent Enabled", "components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates", "components.Settings.Notifications.authPass": "Auth Pass", diff --git a/src/pages/settings/notifications/webhook.tsx b/src/pages/settings/notifications/webhook.tsx new file mode 100644 index 000000000..1880473b1 --- /dev/null +++ b/src/pages/settings/notifications/webhook.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import NotificationsWebhook from '../../../components/Settings/Notifications/NotificationsWebhook'; +import SettingsLayout from '../../../components/Settings/SettingsLayout'; +import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; + +const NotificationsPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/yarn.lock b/yarn.lock index 80c17d5e7..00a724f05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2552,6 +2552,11 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" +ace-builds@^1.4.12, ace-builds@^1.4.6: + version "1.4.12" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.4.12.tgz#888efa386e36f4345f40b5233fcc4fe4c588fae7" + integrity sha512-G+chJctFPiiLGvs3+/Mly3apXTcfgE45dT5yp12BcWZ1kUs+gm0qd3/fv4gsz6fVag4mM0moHVpjHDIgph6Psg== + acorn-jsx@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe" @@ -5118,6 +5123,11 @@ didyoumean@^1.2.1: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.1.tgz#e92edfdada6537d484d73c0172fd1eba0c4976ff" integrity sha1-6S7f2tplN9SE1zwBcv0eugxJdv8= +diff-match-patch@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -8412,6 +8422,11 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" @@ -11418,6 +11433,17 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-ace@^9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-9.2.1.tgz#1efaa0476c77649136def50e5c4ca30c7e546036" + integrity sha512-2arIeMER/W6/h+QGHs0YJ0pEJo5AmBOUs/R72Poa6eXSOSTpJPp/WkwD/KE7BgNy9vZ7YjlbqA+2ZcoVf6AjsQ== + dependencies: + ace-builds "^1.4.6" + diff-match-patch "^1.0.4" + lodash.get "^4.4.2" + lodash.isequal "^4.5.0" + prop-types "^15.7.2" + react-dom@17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6"