From 5c0ad7cb1beddc71efaffdea9c72b468b5bf24b1 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 21 May 2026 12:36:43 +0100 Subject: [PATCH] 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) --- server/routes/webhook.js | 210 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 10 deletions(-) diff --git a/server/routes/webhook.js b/server/routes/webhook.js index 0dce65f..4a99c55 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -219,12 +219,107 @@ function validatePayload(body) { } /** - * POST /api/webhook/sonarr - * Receives webhook events from Sonarr instances. - * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. + * @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). * - * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. - * Phase 6: rate limiting, input validation, replay protection. + * **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)) { @@ -271,12 +366,107 @@ router.post('/sonarr', webhookLimiter, (req, res) => { }); /** - * POST /api/webhook/radarr - * Receives webhook events from Radarr instances. - * Validates the secret, logs the event, refreshes cache, broadcasts SSE, and returns 200. + * @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). * - * Phase 2: integrated with PALDRA cache + SSE for real-time dashboard updates. - * Phase 6: rate limiting, input validation, replay protection. + * **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)) {