All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
Docs Check / Markdown lint (push) Successful in 48s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 57s
CI / Security audit (push) Successful in 1m23s
CI / Tests & coverage (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 1m43s
318 lines
12 KiB
JavaScript
318 lines
12 KiB
JavaScript
// 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;
|