Files
sofarr/server/routes/webhook.js
T
gronod 5c0ad7cb1b docs(swagger): add JSDoc @openapi for webhook endpoints
- POST /api/webhook/sonarr: secret validation, rate-limited, replay protection
- POST /api/webhook/radarr: identical processing logic
- Document X-Sofarr-Webhook-Secret header requirement
- List all valid eventType values
- Document event classification (QUEUE vs HISTORY)
- Include replay protection window (5 minutes)
2026-05-21 12:36:43 +01:00

516 lines
20 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);
}
}
// Prune the replay cache once per minute
setInterval(pruneReplayCache, 60 * 1000).unref();
function isReplay(eventType, instanceName, eventDate) {
if (!eventDate) return false;
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 };
}
/**
* @openapi
* /api/webhook/sonarr:
* post:
* tags: [Webhook]
* summary: Sonarr webhook receiver
* description: |
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
*
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Sonarr, not browsers).
*
* **Rate Limiting:** 60 requests per minute per IP.
*
* **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header
* - Payload validation (must be JSON object with eventType, instanceName, date)
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
* - Replay protection: rejects duplicate events within 5-minute window
*
* **Event Classification:**
* - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired):
* Refreshes `poll:sonarr-queue` cache
* - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, EpisodeFileRenamed, etc.):
* Refreshes `poll:sonarr-history` cache
* - Informational events (Test, Rename, Health, etc.):
* Logged but no cache refresh
*
* **Processing Flow:**
* 1. Validate secret → 401 if invalid
* 2. Validate payload → 400 if invalid
* 3. Check replay cache → 200 with duplicate=true if replay
* 4. Update webhook metrics (enables smart polling skip)
* 5. Return 200 immediately (don't wait for background processing)
* 6. Background: fetch fresh data from Sonarr, update cache, broadcast SSE
*
* **x-integration-notes:** Configure Sonarr webhook:
* - URL: `{SOFARR_BASE_URL}/api/webhook/sonarr`
* - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Events: onGrab, onDownload, onUpgrade, onImport
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/WebhookPayload'
* example:
* eventType: "Grab"
* instanceName: "Main Sonarr"
* date: "2026-05-21T10:00:00.000Z"
* responses:
* '200':
* description: Event received and accepted
* content:
* application/json:
* schema:
* type: object
* properties:
* received:
* type: boolean
* example: true
* duplicate:
* type: boolean
* description: True if this event was already processed (replay protection)
* example: false
* examples:
* newEvent:
* received: true
* duplicate: false
* duplicateEvent:
* received: true
* duplicate: true
* '401':
* description: Invalid or missing webhook secret
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Unauthorized"
* '400':
* description: Invalid payload or unknown event type
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* examples:
* invalidPayload:
* error: "Payload must be a JSON object"
* unknownEventType:
* error: "Unknown eventType: InvalidEvent"
* x-code-samples:
* - lang: curl
* label: cURL (from Sonarr)
* source: |
* curl -X POST http://sofarr:3001/api/webhook/sonarr \
* -H "Content-Type: application/json" \
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
* -d '{"eventType":"Grab","instanceName":"Main Sonarr","date":"2026-05-21T10:00:00.000Z"}'
*/
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;
const sonarrInstances = getSonarrInstances();
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, 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: ${resolvedInstanceName || 'unknown'}`);
logToFile(`[Webhook] Sonarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
if (inst) {
cache.updateWebhookMetrics(inst.url);
logToFile(`[Webhook] Updated metrics for Sonarr instance: ${inst.name} (${inst.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 });
}
});
/**
* @openapi
* /api/webhook/radarr:
* post:
* tags: [Webhook]
* summary: Radarr webhook receiver
* description: |
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
*
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
* No cookie authentication required (webhooks come from Radarr, not browsers).
*
* **Rate Limiting:** 60 requests per minute per IP.
*
* **Validation:**
* - Secret validation via `X-Sofarr-Webhook-Secret` header
* - Payload validation (must be JSON object with eventType, instanceName, date)
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
* - Replay protection: rejects duplicate events within 5-minute window
*
* **Event Classification:**
* - QUEUE_EVENTS (Grab, Download, DownloadFailed, ManualInteractionRequired):
* Refreshes `poll:radarr-queue` cache
* - HISTORY_EVENTS (DownloadFolderImported, ImportFailed, MovieFileRenamed, etc.):
* Refreshes `poll:radarr-history` cache
* - Informational events (Test, Rename, Health, etc.):
* Logged but no cache refresh
*
* **Processing Flow:**
* 1. Validate secret → 401 if invalid
* 2. Validate payload → 400 if invalid
* 3. Check replay cache → 200 with duplicate=true if replay
* 4. Update webhook metrics (enables smart polling skip)
* 5. Return 200 immediately (don't wait for background processing)
* 6. Background: fetch fresh data from Radarr, update cache, broadcast SSE
*
* **x-integration-notes:** Configure Radarr webhook:
* - URL: `{SOFARR_BASE_URL}/api/webhook/radarr`
* - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Events: onGrab, onDownload, onUpgrade, onImport
* security: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/WebhookPayload'
* example:
* eventType: "Grab"
* instanceName: "Main Radarr"
* date: "2026-05-21T10:00:00.000Z"
* responses:
* '200':
* description: Event received and accepted
* content:
* application/json:
* schema:
* type: object
* properties:
* received:
* type: boolean
* example: true
* duplicate:
* type: boolean
* description: True if this event was already processed (replay protection)
* example: false
* examples:
* newEvent:
* received: true
* duplicate: false
* duplicateEvent:
* received: true
* duplicate: true
* '401':
* description: Invalid or missing webhook secret
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Unauthorized"
* '400':
* description: Invalid payload or unknown event type
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* examples:
* invalidPayload:
* error: "Payload must be a JSON object"
* unknownEventType:
* error: "Unknown eventType: InvalidEvent"
* x-code-samples:
* - lang: curl
* label: cURL (from Radarr)
* source: |
* curl -X POST http://sofarr:3001/api/webhook/radarr \
* -H "Content-Type: application/json" \
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
* -d '{"eventType":"Grab","instanceName":"Main Radarr","date":"2026-05-21T10:00:00.000Z"}'
*/
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;
const radarrInstances = getRadarrInstances();
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
const resolvedInstanceName = inst ? inst.name : instanceName;
if (isReplay(eventType, resolvedInstanceName, 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: ${resolvedInstanceName || 'unknown'}`);
logToFile(`[Webhook] Radarr payload: ${JSON.stringify(req.body)}`);
// Phase 5.1: update webhook metrics for polling optimization
if (inst) {
cache.updateWebhookMetrics(inst.url);
logToFile(`[Webhook] Updated metrics for Radarr instance: ${inst.name} (${inst.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;