diff --git a/.env.sample b/.env.sample index 66f84c3..dd41483 100644 --- a/.env.sample +++ b/.env.sample @@ -19,6 +19,16 @@ LOG_LEVEL=info # Generate with: openssl rand -hex 32 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 # ============================================================================= diff --git a/server/app.js b/server/app.js index da9d319..8a67fb7 100644 --- a/server/app.js +++ b/server/app.js @@ -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); diff --git a/server/routes/webhook.js b/server/routes/webhook.js new file mode 100644 index 0000000..74a00cc --- /dev/null +++ b/server/routes/webhook.js @@ -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; diff --git a/server/utils/config.js b/server/utils/config.js index 35e1b71..1d95e80 100644 --- a/server/utils/config.js +++ b/server/utils/config.js @@ -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 };