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)
This commit is contained in:
+200
-10
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user