feat(webhook): implement Phase 1 webhook receiver for Sonarr and Radarr
All checks were successful
All checks were successful
- 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:
10
.env.sample
10
.env.sample
@@ -19,6 +19,16 @@ LOG_LEVEL=info
|
|||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
COOKIE_SECRET=your-cookie-secret-here
|
COOKIE_SECRET=your-cookie-secret-here
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WEBHOOK SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Secret for validating incoming webhooks from Sonarr and Radarr
|
||||||
|
# Required for webhook endpoints to accept requests
|
||||||
|
# Sonarr/Radarr must send this secret in the X-Sofarr-Webhook-Secret header
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# TLS / HTTPS
|
# TLS / HTTPS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const embyRoutes = require('./routes/emby');
|
|||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
const historyRoutes = require('./routes/history');
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
|
const webhookRoutes = require('./routes/webhook');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
function createApp({ skipRateLimits = false } = {}) {
|
function createApp({ skipRateLimits = false } = {}) {
|
||||||
@@ -94,6 +95,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
// API routes
|
// API routes
|
||||||
app.use('/api', apiLimiter);
|
app.use('/api', apiLimiter);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/webhook', webhookRoutes);
|
||||||
|
|
||||||
// CSRF protection for all state-changing API requests below
|
// CSRF protection for all state-changing API requests below
|
||||||
app.use('/api', verifyCsrf);
|
app.use('/api', verifyCsrf);
|
||||||
|
|||||||
89
server/routes/webhook.js
Normal file
89
server/routes/webhook.js
Normal 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;
|
||||||
@@ -114,6 +114,10 @@ function getRtorrentInstances() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getWebhookSecret() {
|
||||||
|
return process.env.SOFARR_WEBHOOK_SECRET || '';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
@@ -121,6 +125,7 @@ module.exports = {
|
|||||||
getQbittorrentInstances,
|
getQbittorrentInstances,
|
||||||
getTransmissionInstances,
|
getTransmissionInstances,
|
||||||
getRtorrentInstances,
|
getRtorrentInstances,
|
||||||
|
getWebhookSecret,
|
||||||
parseInstances,
|
parseInstances,
|
||||||
validateInstanceUrl
|
validateInstanceUrl
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user