feat(webhooks): add notification management API + one-click Sofarr webhook setup (Phase 3)
All checks were successful
Build and Push Docker Image / build (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m1s
CI / Security audit (push) Successful in 1m20s
CI / Tests & coverage (push) Successful in 1m35s

This commit is contained in:
2026-05-19 15:31:50 +01:00
parent 1d61ea8d83
commit e022db8ef5
4 changed files with 305 additions and 0 deletions

View File

@@ -29,6 +29,12 @@ COOKIE_SECRET=your-cookie-secret-here
# Generate with: openssl rand -hex 32
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
# Public base URL of Sofarr (for webhook configuration)
# Required for the one-click webhook setup endpoints
# Sonarr/Radarr need this URL to know where to send webhook events
# Example: https://sofarr.example.com or https://192.168.1.100:3001
SOFARR_BASE_URL=https://your-sofarr-url
# =============================================================================
# TLS / HTTPS
# =============================================================================

View File

@@ -4,6 +4,7 @@ const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
router.use(requireAuth);
@@ -56,4 +57,150 @@ router.get('/movies', async (req, res) => {
}
});
// Notification proxy routes (Phase 3)
// GET /api/radarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications - create notification
router.post('/notifications', async (req, res) => {
try {
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Radarr notification', details: sanitizeError(error) });
}
});
// PUT /api/radarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
try {
const response = await axios.put(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Radarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/radarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
try {
const response = await axios.delete(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Radarr notification', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
try {
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
}
});
// GET /api/radarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
try {
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 'POST' },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onImport: true,
onUpgrade: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${process.env.RADARR_URL}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${process.env.RADARR_URL}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
);
res.json(response.data);
}
} catch (error) {
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
module.exports = router;

View File

@@ -4,6 +4,7 @@ const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
router.use(requireAuth);
@@ -56,4 +57,150 @@ router.get('/series', async (req, res) => {
}
});
// Notification proxy routes (Phase 3)
// GET /api/sonarr/notifications - list all notifications
router.get('/notifications', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/:id - get specific notification
router.get('/notifications/:id', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications - create notification
router.post('/notifications', async (req, res) => {
try {
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to create Sonarr notification', details: sanitizeError(error) });
}
});
// PUT /api/sonarr/notifications/:id - update notification
router.put('/notifications/:id', async (req, res) => {
try {
const response = await axios.put(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to update Sonarr notification', details: sanitizeError(error) });
}
});
// DELETE /api/sonarr/notifications/:id - delete notification
router.delete('/notifications/:id', async (req, res) => {
try {
const response = await axios.delete(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to delete Sonarr notification', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/test - test notification
router.post('/notifications/test', async (req, res) => {
try {
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification/test`, req.body, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
}
});
// GET /api/sonarr/notifications/schema - get notification schema
router.get('/notifications/schema', async (req, res) => {
try {
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr notification schema', details: sanitizeError(error) });
}
});
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
router.post('/notifications/sofarr-webhook', async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
// Check if Sofarr webhook already exists
const listResponse = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
const notificationPayload = {
name: 'Sofarr',
implementation: 'Webhook',
configContract: 'WebhookSettings',
fields: [
{ name: 'url', value: webhookUrl },
{ name: 'method', value: 'POST' },
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
],
onGrab: true,
onDownload: true,
onImport: true,
onUpgrade: true,
onRename: false,
onHealthIssue: false,
onApplicationUpdate: false
};
if (existingNotification) {
// Update existing notification
const response = await axios.put(
`${process.env.SONARR_URL}/api/v3/notification/${existingNotification.id}`,
{ ...notificationPayload, id: existingNotification.id },
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
);
res.json(response.data);
} else {
// Create new notification
const response = await axios.post(
`${process.env.SONARR_URL}/api/v3/notification`,
notificationPayload,
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
);
res.json(response.data);
}
} catch (error) {
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
}
});
module.exports = router;

View File

@@ -118,6 +118,10 @@ function getWebhookSecret() {
return process.env.SOFARR_WEBHOOK_SECRET || '';
}
function getSofarrBaseUrl() {
return process.env.SOFARR_BASE_URL || '';
}
module.exports = {
getSABnzbdInstances,
getSonarrInstances,
@@ -126,6 +130,7 @@ module.exports = {
getTransmissionInstances,
getRtorrentInstances,
getWebhookSecret,
getSofarrBaseUrl,
parseInstances,
validateInstanceUrl
};