849 lines
34 KiB
JavaScript
849 lines
34 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, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
|
const cache = require('../utils/cache');
|
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
|
const { buildArrQueueCache } = require('../utils/arrQueueHelpers');
|
|
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
|
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
|
const requireAuth = require('../middleware/requireAuth');
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/webhook/config:
|
|
* get:
|
|
* tags: [Webhook]
|
|
* summary: Get webhook configuration status
|
|
* description: |
|
|
* Returns whether the required webhook configuration (SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET)
|
|
* is properly configured. Used by the webhooks panel to determine if webhooks can be enabled.
|
|
*
|
|
* **Authentication:** Requires valid `emby_user` cookie.
|
|
* security:
|
|
* - CookieAuth: []
|
|
* responses:
|
|
* '200':
|
|
* description: Webhook configuration status
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* valid:
|
|
* type: boolean
|
|
* description: true if both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured
|
|
* example: true
|
|
* missing:
|
|
* type: array
|
|
* items:
|
|
* type: string
|
|
* description: List of missing configuration items
|
|
* example: []
|
|
* '401':
|
|
* description: Unauthorized
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
*/
|
|
router.get('/config', requireAuth, (req, res) => {
|
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
|
const webhookSecret = getWebhookSecret();
|
|
const missing = [];
|
|
|
|
if (!sofarrBaseUrl) {
|
|
missing.push('SOFARR_BASE_URL');
|
|
}
|
|
if (!webhookSecret) {
|
|
missing.push('SOFARR_WEBHOOK_SECRET');
|
|
}
|
|
|
|
res.json({
|
|
valid: missing.length === 0,
|
|
missing
|
|
});
|
|
});
|
|
|
|
// 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',
|
|
// Ombi notification types
|
|
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
|
]);
|
|
|
|
// 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, contentId) {
|
|
if (!eventDate) return false;
|
|
// Content-aware replay key: incorporates downloadId / series.id / movie.id when
|
|
// available so that distinct events sharing the same `date` (e.g. multiple
|
|
// Grab events for episodes in a season pack fired in the same second) do not
|
|
// falsely collide. Falls back to the prior shape when contentId is absent
|
|
// (e.g. Test events) so existing behaviour is preserved.
|
|
const key = `${eventType}:${instanceName || ''}:${contentId || ''}:${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'
|
|
]);
|
|
|
|
// Ombi event types — all Ombi events refresh the requests cache
|
|
const OMBI_EVENTS = new Set([
|
|
'NewRequest',
|
|
'RequestAvailable',
|
|
'RequestApproved',
|
|
'RequestDeclined',
|
|
'RequestPending',
|
|
'RequestProcessing'
|
|
]);
|
|
|
|
/**
|
|
* Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
|
|
* @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') || req.query.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 or secret query parameter');
|
|
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', 'radarr', or 'ombi'
|
|
* @param {string} eventType - the eventType from the webhook payload
|
|
*/
|
|
async function processWebhookEvent(serviceType, eventType, payload = null) {
|
|
const affectsQueue = QUEUE_EVENTS.has(eventType);
|
|
const affectsHistory = HISTORY_EVENTS.has(eventType);
|
|
const affectsOmbi = OMBI_EVENTS.has(eventType);
|
|
|
|
if (!affectsQueue && !affectsHistory && !affectsOmbi) {
|
|
logToFile(`[Webhook] Event ${eventType} does not affect queue, history, or requests, skipping refresh`);
|
|
return;
|
|
}
|
|
|
|
logToFile(`[Webhook] ${serviceType} event "${eventType}" → queue=${affectsQueue}, history=${affectsHistory}, ombi=${affectsOmbi}`);
|
|
|
|
// 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: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
|
}, 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: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
|
}, 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))`);
|
|
}
|
|
} else if (serviceType === 'ombi') {
|
|
const ombiInstances = getOmbiInstances();
|
|
|
|
if (affectsOmbi) {
|
|
const delayMs = parseInt(process.env.OMBI_WEBHOOK_REFRESH_DELAY_MS, 10);
|
|
const initialDelay = !isNaN(delayMs) ? delayMs : 2000;
|
|
logToFile(`[Webhook] Waiting initial delay of ${initialDelay}ms for Ombi webhook synchronization...`);
|
|
await new Promise(r => setTimeout(r, initialDelay));
|
|
|
|
const requestId = payload ? (payload.requestId || payload.RequestId || payload.id || payload.Id) : null;
|
|
const mediaType = payload ? (payload.type || payload.Type || '').toLowerCase() : null;
|
|
|
|
let ombiRequests = { movie: [], tv: [] };
|
|
let foundAndValid = false;
|
|
const maxRetries = 3;
|
|
const retryDelayMs = 1500;
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
if (attempt > 1) {
|
|
logToFile(`[Webhook] Ombi request not found or missing user (attempt ${attempt-1}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
|
|
await new Promise(r => setTimeout(r, retryDelayMs));
|
|
}
|
|
|
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
|
|
|
if (!requestId) {
|
|
// If no requestId was provided in payload, we can't search specifically, so just accept the fetch
|
|
foundAndValid = true;
|
|
break;
|
|
}
|
|
|
|
// Search in movie or tv lists
|
|
const targetList = (mediaType === 'tv' || mediaType === 'series') ? (ombiRequests.tv || []) : (ombiRequests.movie || []);
|
|
// Also check both if mediaType not specified
|
|
const searchList = mediaType ? targetList : [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
|
|
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
|
if (targetReq) {
|
|
const user = extractRequestedUser(targetReq);
|
|
if (user) {
|
|
logToFile(`[Webhook] Verified request ${requestId} has valid user "${user}" on attempt ${attempt}`);
|
|
foundAndValid = true;
|
|
break;
|
|
} else {
|
|
logToFile(`[Webhook] Found request ${requestId} on attempt ${attempt}, but user extraction was empty.`);
|
|
}
|
|
} else {
|
|
logToFile(`[Webhook] Request ${requestId} not found in retrieved list on attempt ${attempt}.`);
|
|
}
|
|
}
|
|
|
|
if (!foundAndValid && requestId) {
|
|
logToFile(`[Webhook] WARNING: Could not verify request ${requestId} with valid user info after ${maxRetries} retries.`);
|
|
// Try to log the raw target request if we found one
|
|
ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
|
const searchList = [...(ombiRequests.movie || []), ...(ombiRequests.tv || [])];
|
|
const targetReq = searchList.find(r => r && (r.id === requestId || r.Id === requestId));
|
|
if (targetReq) {
|
|
logToFile(`[Webhook] Raw request object where extraction failed: ${JSON.stringify(targetReq)}`);
|
|
} else {
|
|
logToFile(`[Webhook] Request ${requestId} was completely absent from Ombi requests list.`);
|
|
}
|
|
}
|
|
|
|
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
|
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
|
}
|
|
}
|
|
|
|
// 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 or `secret` query parameter 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 or `secret` query parameter
|
|
* - 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: []
|
|
* parameters:
|
|
* - name: secret
|
|
* in: query
|
|
* required: false
|
|
* schema:
|
|
* type: string
|
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
|
* 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 matchedInst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
|
const inst = matchedInst || sonarrInstances[0];
|
|
if (!matchedInst && instanceName) {
|
|
logToFile(`[Webhook] Sonarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
|
}
|
|
const resolvedInstanceName = inst ? inst.name : instanceName;
|
|
|
|
// Content-aware replay key components (Issue #62)
|
|
const contentId = req.body.downloadId || req.body.series?.id || null;
|
|
|
|
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
|
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
|
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 or `secret` query parameter 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 or `secret` query parameter
|
|
* - 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: []
|
|
* parameters:
|
|
* - name: secret
|
|
* in: query
|
|
* required: false
|
|
* schema:
|
|
* type: string
|
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
|
* 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 matchedInst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
|
const inst = matchedInst || radarrInstances[0];
|
|
if (!matchedInst && instanceName) {
|
|
logToFile(`[Webhook] Radarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
|
}
|
|
const resolvedInstanceName = inst ? inst.name : instanceName;
|
|
|
|
// Content-aware replay key components (Issue #62)
|
|
const contentId = req.body.downloadId || req.body.movie?.id || null;
|
|
|
|
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
|
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
|
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 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/webhook/ombi:
|
|
* post:
|
|
* tags: [Webhook]
|
|
* summary: Ombi webhook receiver
|
|
* description: |
|
|
* Receives webhook events from Ombi 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 or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
|
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
|
*
|
|
* **Rate Limiting:** 60 requests per minute per IP.
|
|
*
|
|
* **Validation:**
|
|
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
|
* - Payload validation (must be JSON object with notificationType, requestId)
|
|
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
|
* - Replay protection: rejects duplicate events within 5-minute window
|
|
*
|
|
* **Event Classification:**
|
|
* - OMBI_EVENTS (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing):
|
|
* Refreshes `poll:ombi-requests` cache
|
|
*
|
|
* **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 Ombi, update cache, broadcast SSE
|
|
*
|
|
* **x-integration-notes:** Configure Ombi webhook:
|
|
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
|
|
* - Method: POST
|
|
* - Application Token: OMBI_API_KEY
|
|
* security: []
|
|
* parameters:
|
|
* - name: secret
|
|
* in: query
|
|
* required: false
|
|
* schema:
|
|
* type: string
|
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* notificationType:
|
|
* type: string
|
|
* example: "RequestAvailable"
|
|
* requestId:
|
|
* type: integer
|
|
* example: 123
|
|
* requestedUser:
|
|
* type: string
|
|
* example: "username"
|
|
* title:
|
|
* type: string
|
|
* example: "Movie Title"
|
|
* type:
|
|
* type: string
|
|
* example: "Movie"
|
|
* requestStatus:
|
|
* type: string
|
|
* example: "Available"
|
|
* example:
|
|
* notificationType: "RequestAvailable"
|
|
* requestId: 123
|
|
* requestedUser: "username"
|
|
* title: "Movie Title"
|
|
* type: "Movie"
|
|
* requestStatus: "Available"
|
|
* 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 notificationType: InvalidEvent"
|
|
* x-code-samples:
|
|
* - lang: curl
|
|
* label: cURL (from Ombi)
|
|
* source: |
|
|
* curl -X POST http://sofarr:3001/api/webhook/ombi \
|
|
* -H "Content-Type: application/json" \
|
|
* -H "X-Sofarr-Webhook-Secret: your-secret-here" \
|
|
* -d '{"notificationType":"RequestAvailable","requestId":123,"requestedUser":"username","title":"Movie Title","type":"Movie","requestStatus":"Available"}'
|
|
*/
|
|
router.post('/ombi', webhookLimiter, (req, res) => {
|
|
if (!validateWebhookSecret(req)) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
|
|
// Ombi uses notificationType instead of eventType. Support PascalCase for .NET apps
|
|
const notificationType = req.body.notificationType || req.body.NotificationType;
|
|
const requestId = req.body.requestId || req.body.RequestId;
|
|
const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl;
|
|
|
|
const eventType = notificationType || req.body.eventType || req.body.EventType;
|
|
|
|
// Extract username from requestedUser (handles both object and string formats)
|
|
const username = extractRequestedUser(req.body);
|
|
|
|
if (!eventType || !OMBI_EVENTS.has(eventType)) {
|
|
logToFile(`[Webhook] Ombi payload rejected: invalid or missing notificationType`);
|
|
return res.status(400).json({ error: 'Invalid or missing notificationType' });
|
|
}
|
|
|
|
// Use applicationUrl as instance identifier for replay protection
|
|
const instanceName = applicationUrl || 'ombi';
|
|
// Use requestId + eventType + current time as replay key
|
|
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
|
|
|
|
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
|
|
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
|
|
return res.status(200).json({ received: true, duplicate: true });
|
|
}
|
|
|
|
try {
|
|
logToFile(`[Webhook] Ombi event received - Type: ${eventType}, RequestId: ${requestId}, User: ${username}`);
|
|
logToFile(`[Webhook] Ombi payload: ${JSON.stringify(req.body)}`);
|
|
|
|
// Update webhook metrics for polling optimization
|
|
const ombiInstances = getOmbiInstances();
|
|
const inst = ombiInstances[0]; // Use first Ombi instance
|
|
if (inst) {
|
|
cache.updateWebhookMetrics(inst.url);
|
|
logToFile(`[Webhook] Updated metrics for Ombi instance: ${inst.name} (${inst.url})`);
|
|
}
|
|
|
|
// Background cache refresh + SSE broadcast (fire-and-forget)
|
|
processWebhookEvent('ombi', eventType, req.body).catch(err => {
|
|
logToFile(`[Webhook] Ombi background refresh error: ${err.message}`);
|
|
});
|
|
|
|
res.status(200).json({ received: true });
|
|
} catch (error) {
|
|
logToFile(`[Webhook] Ombi error: ${error.message}`);
|
|
res.status(200).json({ received: true });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|