// Copyright (c) 2026 Gordon Bolton. MIT License. const express = require('express'); const rateLimit = require('express-rate-limit'); const { logToFile } = require('../utils/logger'); const { getWebhookSecret, getSonarrInstances, getRadarrInstances } = require('../utils/config'); const cache = require('../utils/cache'); const arrRetrieverRegistry = require('../utils/arrRetrievers'); const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller'); const router = express.Router(); // Dedicated rate limiter for webhook endpoints — stricter than the global API limiter. // Sonarr/Radarr send at most one event per action; 60/min per IP is generous. // In tests, SKIP_RATE_LIMIT=1 raises the ceiling to effectively unlimited. const webhookLimiter = rateLimit({ windowMs: 60 * 1000, max: process.env.SKIP_RATE_LIMIT ? Number.MAX_SAFE_INTEGER : 60, standardHeaders: true, legacyHeaders: false, message: { error: 'Too many webhook requests' } }); // Valid *arr eventType strings — used for strict input validation. const VALID_EVENT_TYPES = new Set([ 'Test', 'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired', 'DownloadFolderImported', 'ImportFailed', 'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries', 'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete', 'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored' ]); // Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys. // *arr sends a `date` field on every event; we use it as the replay key component. // TTL = 5 minutes; an event replayed after that window is considered fresh. const REPLAY_WINDOW_MS = 5 * 60 * 1000; const recentEvents = new Map(); function pruneReplayCache() { const cutoff = Date.now() - REPLAY_WINDOW_MS; for (const [key, ts] of recentEvents) { if (ts < cutoff) recentEvents.delete(key); } } function isReplay(eventType, instanceName, eventDate) { if (!eventDate) return false; pruneReplayCache(); const key = `${eventType}:${instanceName || ''}:${eventDate}`; if (recentEvents.has(key)) return true; recentEvents.set(key, Date.now()); return false; } // Cache TTL mirrors poller.js logic: 3x poll interval when active, 30s when on-demand const CACHE_TTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000; // Event classification — determines which cache keys to refresh const QUEUE_EVENTS = new Set([ 'Grab', 'Download', 'DownloadFailed', 'ManualInteractionRequired' ]); const HISTORY_EVENTS = new Set([ 'DownloadFolderImported', 'ImportFailed', 'EpisodeFileRenamed', 'MovieFileRenamed', 'EpisodeFileRenamedBySeries' ]); /** * 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; } /** * Process a webhook event by refreshing the affected cache and broadcasting SSE. * This is a fire-and-forget background task — callers must respond to the webhook * sender before awaiting this function. * * Phase 2: lightweight refresh via arrRetrieverRegistry + cache update + SSE broadcast. * * @param {string} serviceType - 'sonarr' or 'radarr' * @param {string} eventType - the eventType from the *arr webhook payload */ async function processWebhookEvent(serviceType, eventType) { const affectsQueue = QUEUE_EVENTS.has(eventType); const affectsHistory = HISTORY_EVENTS.has(eventType); if (!affectsQueue && !affectsHistory) { logToFile(`[Webhook] Event ${eventType} does not affect queue or history, skipping refresh`); return; } logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}`); // Ensure retrievers are initialized (idempotent) await arrRetrieverRegistry.initialize(); if (serviceType === 'sonarr') { const sonarrInstances = getSonarrInstances(); if (affectsQueue) { const queuesByType = await arrRetrieverRegistry.getQueuesByType(); const sonarrQueues = queuesByType.sonarr || []; cache.set('poll:sonarr-queue', { records: sonarrQueues.flatMap(q => { const inst = sonarrInstances.find(i => i.id === q.instance); const url = inst ? inst.url : null; const key = inst ? inst.apiKey : null; return (q.data.records || []).map(r => { if (r.series) r.series._instanceUrl = url; r._instanceUrl = url; r._instanceKey = key; return r; }); }) }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`); } if (affectsHistory) { const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); const sonarrHistories = historyByType.sonarr || []; cache.set('poll:sonarr-history', { records: sonarrHistories.flatMap(h => h.data.records || []) }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:sonarr-history (${sonarrHistories.length} instance(s))`); } } else if (serviceType === 'radarr') { const radarrInstances = getRadarrInstances(); if (affectsQueue) { const queuesByType = await arrRetrieverRegistry.getQueuesByType(); const radarrQueues = queuesByType.radarr || []; cache.set('poll:radarr-queue', { records: radarrQueues.flatMap(q => { const inst = radarrInstances.find(i => i.id === q.instance); const url = inst ? inst.url : null; const key = inst ? inst.apiKey : null; return (q.data.records || []).map(r => { if (r.movie) r.movie._instanceUrl = url; r._instanceUrl = url; r._instanceKey = key; return r; }); }) }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`); } if (affectsHistory) { const historyByType = await arrRetrieverRegistry.getHistoryByType({ pageSize: 10 }); const radarrHistories = historyByType.radarr || []; cache.set('poll:radarr-history', { records: radarrHistories.flatMap(h => h.data.records || []) }, CACHE_TTL); logToFile(`[Webhook] Refreshed poll:radarr-history (${radarrHistories.length} instance(s))`); } } // Broadcast to all SSE subscribers using the same mechanism poller.js uses. // pollAllServices() refreshes all data, updates every cache key, and then // iterates pollSubscribers to push fresh payloads to every open SSE connection. // If a poll is already in progress this call is a no-op, but the cache keys // above were already updated so the next broadcast (or dashboard request) // will see fresh data. logToFile('[Webhook] Triggering SSE broadcast via pollAllServices()'); await pollAllServices(); } /** * Validate and sanitize the incoming webhook payload. * Returns { valid, eventType, instanceName, eventDate } or { valid: false, reason }. */ function validatePayload(body) { if (!body || typeof body !== 'object' || Array.isArray(body)) { return { valid: false, reason: 'Payload must be a JSON object' }; } const { eventType, instanceName } = body; if (typeof eventType !== 'string' || eventType.length === 0 || eventType.length > 64) { return { valid: false, reason: 'eventType must be a non-empty string (max 64 chars)' }; } if (!VALID_EVENT_TYPES.has(eventType)) { return { valid: false, reason: `Unknown eventType: ${eventType}` }; } if (instanceName !== undefined && typeof instanceName !== 'string') { return { valid: false, reason: 'instanceName must be a string if provided' }; } const eventDate = body.date || null; return { valid: true, eventType, instanceName: instanceName || null, eventDate }; } /** * POST /api/webhook/sonarr * Receives webhook events from Sonarr instances. * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. * * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. * Phase 6: rate limiting, input validation, replay protection. */ router.post('/sonarr', webhookLimiter, (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } const validation = validatePayload(req.body); if (!validation.valid) { logToFile(`[Webhook] Sonarr payload rejected: ${validation.reason}`); return res.status(400).json({ error: validation.reason }); } const { eventType, instanceName, eventDate } = validation; if (isReplay(eventType, instanceName, eventDate)) { logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`); return res.status(200).json({ received: true, duplicate: true }); } try { logToFile(`[Webhook] Sonarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`); // Phase 5.1: update webhook metrics for polling optimization const sonarrInstances = getSonarrInstances(); const instance = sonarrInstances.find(i => i.name === instanceName); if (instance) { cache.updateWebhookMetrics(instance.url); } // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) processWebhookEvent('sonarr', eventType).catch(err => { logToFile(`[Webhook] Sonarr background refresh error: ${err.message}`); }); res.status(200).json({ received: true }); } catch (error) { logToFile(`[Webhook] Sonarr error: ${error.message}`); res.status(200).json({ received: true }); } }); /** * POST /api/webhook/radarr * Receives webhook events from Radarr instances. * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. * * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. * Phase 6: rate limiting, input validation, replay protection. */ router.post('/radarr', webhookLimiter, (req, res) => { if (!validateWebhookSecret(req)) { return res.status(401).json({ error: 'Unauthorized' }); } const validation = validatePayload(req.body); if (!validation.valid) { logToFile(`[Webhook] Radarr payload rejected: ${validation.reason}`); return res.status(400).json({ error: validation.reason }); } const { eventType, instanceName, eventDate } = validation; if (isReplay(eventType, instanceName, eventDate)) { logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`); return res.status(200).json({ received: true, duplicate: true }); } try { logToFile(`[Webhook] Radarr event received - Type: ${eventType}, Instance: ${instanceName || 'unknown'}`); logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`); // Phase 5.1: update webhook metrics for polling optimization const radarrInstances = getRadarrInstances(); const instance = radarrInstances.find(i => i.name === instanceName); if (instance) { cache.updateWebhookMetrics(instance.url); } // Phase 2: background cache refresh + SSE broadcast (fire-and-forget) processWebhookEvent('radarr', eventType).catch(err => { logToFile(`[Webhook] Radarr background refresh error: ${err.message}`); }); res.status(200).json({ received: true }); } catch (error) { logToFile(`[Webhook] Radarr error: ${error.message}`); res.status(200).json({ received: true }); } }); module.exports = router;