Files
sofarr/server/routes/webhook.js
T
gronod 9862c0555c
Build and Push Docker Image / build (push) Successful in 47s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m24s
CI / Security audit (push) Successful in 1m47s
CI / Swagger Validation & Coverage (push) Successful in 2m11s
CI / Tests & coverage (push) Successful in 2m27s
Handle Ombi API object-format requestedUser field
The Ombi API returns requestedUser as an OmbiUser object instead of a string.
Add extractRequestedUser helper to extract username from various fields
(alias, userAlias, userName, normalizedUserName) with fallback to legacy string format.
Update client and server routes to use the helper for consistent username extraction.
2026-05-21 21:56:36 +01:00

709 lines
27 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 } = require('../utils/config');
const cache = require('../utils/cache');
const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
const { extractRequestedUser } = require('../utils/ombiHelpers');
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',
// Ombi notification types
'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) {
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'
]);
// Ombi event types — all Ombi events refresh the requests cache
const OMBI_EVENTS = new Set([
'RequestAvailable',
'RequestApproved',
'RequestDeclined',
'RequestPending',
'RequestProcessing'
]);
/**
* 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', 'radarr', or 'ombi'
* @param {string} eventType - the eventType from the webhook payload
*/
async function processWebhookEvent(serviceType, eventType) {
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: 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))`);
}
} else if (serviceType === 'ombi') {
const ombiInstances = getOmbiInstances();
if (affectsOmbi) {
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
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 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 });
}
});
/**
* @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 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
* - 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`
* - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - Application Token: OMBI_API_KEY
* security: []
* 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
const { notificationType, requestId, requestedUser, applicationUrl } = req.body;
const eventType = notificationType || 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 || 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).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;