feat(webhook): implement Phase 1 webhook receiver for Sonarr and Radarr
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m15s
CI / Security audit (push) Successful in 1m44s
CI / Tests & coverage (push) Successful in 1m53s

- Added POST /api/webhook/sonarr and POST /api/webhook/radarr endpoints
- Implemented webhook secret validation via SOFARR_WEBHOOK_SECRET environment variable
- Added logging for all incoming webhook events using existing logToFile utility
- Returns HTTP 200 immediately to prevent webhook retries
- Mounted webhook routes before CSRF middleware (called by external services)
- Non-breaking: no changes to polling, caching, SSE, or any existing behavior
- Lays groundwork for Phase 2 (cache + SSE integration) without implementing it yet
This commit is contained in:
2026-05-19 15:15:53 +01:00
parent 934f5e3fd5
commit 99ddb05dbe
4 changed files with 106 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const verifyCsrf = require('./middleware/verifyCsrf');
function createApp({ skipRateLimits = false } = {}) {
@@ -94,6 +95,7 @@ function createApp({ skipRateLimits = false } = {}) {
// API routes
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
// CSRF protection for all state-changing API requests below
app.use('/api', verifyCsrf);

89
server/routes/webhook.js Normal file
View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const { logToFile } = require('../utils/logger');
const { getWebhookSecret } = require('../utils/config');
const router = express.Router();
/**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
* @param {Object} req - Express request object
* @returns {boolean} True if secret is valid, false otherwise
*/
function validateWebhookSecret(req) {
const expectedSecret = getWebhookSecret();
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
if (!expectedSecret) {
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
return false;
}
if (!providedSecret) {
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
return false;
}
if (providedSecret !== expectedSecret) {
logToFile('[Webhook] WARNING: Invalid webhook secret provided');
return false;
}
return true;
}
/**
* POST /api/webhook/sonarr
* Receives webhook events from Sonarr instances.
* Validates the secret, logs the event, and returns 200 immediately.
*
* Phase 1: Receiver only - no cache or SSE integration yet.
* Future phases will integrate with PALDRA registry for event-driven updates.
*/
router.post('/sonarr', (req, res) => {
if (!validateWebhookSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const { eventType, instanceName } = req.body || {};
logToFile(`[Webhook] Sonarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
// Phase 1: Log and respond immediately
// Future phases will push to cache and trigger SSE
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Sonarr error: ${error.message}`);
res.status(200).json({ received: true }); // Still return 200 to avoid webhook retries
}
});
/**
* POST /api/webhook/radarr
* Receives webhook events from Radarr instances.
* Validates the secret, logs the event, and returns 200 immediately.
*
* Phase 1: Receiver only - no cache or SSE integration yet.
* Future phases will integrate with PALDRA registry for event-driven updates.
*/
router.post('/radarr', (req, res) => {
if (!validateWebhookSecret(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
try {
const { eventType, instanceName } = req.body || {};
logToFile(`[Webhook] Radarr event received - Type: ${eventType || 'unknown'}, Instance: ${instanceName || 'unknown'}`);
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
// Phase 1: Log and respond immediately
// Future phases will push to cache and trigger SSE
res.status(200).json({ received: true });
} catch (error) {
logToFile(`[Webhook] Radarr error: ${error.message}`);
res.status(200).json({ received: true }); // Still return 200 to avoid webhook retries
}
});
module.exports = router;

View File

@@ -114,6 +114,10 @@ function getRtorrentInstances() {
);
}
function getWebhookSecret() {
return process.env.SOFARR_WEBHOOK_SECRET || '';
}
module.exports = {
getSABnzbdInstances,
getSonarrInstances,
@@ -121,6 +125,7 @@ module.exports = {
getQbittorrentInstances,
getTransmissionInstances,
getRtorrentInstances,
getWebhookSecret,
parseInstances,
validateInstanceUrl
};