Files
sofarr/server/routes/ombi.js
T
gronod dbf45ec31d
Build and Push Docker Image / build (push) Successful in 1m4s
Docs Check / Markdown lint (push) Successful in 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m22s
CI / Security audit (push) Successful in 2m44s
CI / Swagger Validation & Coverage (push) Successful in 2m59s
Docs Check / Mermaid diagram parse check (push) Successful in 3m11s
CI / Tests & coverage (push) Successful in 3m27s
fix: secure webhook config endpoint and validate config on Ombi enable/test
- Add requireAuth to GET /api/webhook/config to enforce authentication
- Add SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET validation to POST /api/ombi/webhook/enable and /test
- Return 400 with descriptive errors when webhook config is missing on Ombi routes
- Clean up test environment in webhook.test.js afterEach
- Add regression tests for all new validation logic
- Update CHANGELOG.md with security fixes
2026-05-22 09:50:30 +01:00

437 lines
13 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const { logToFile } = require('../utils/logger');
const cache = require('../utils/cache');
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
const requireAuth = require('../middleware/requireAuth');
const { extractRequestedUser } = require('../utils/ombiHelpers');
const router = express.Router();
/**
* @openapi
* /api/ombi/requests:
* get:
* tags: [Ombi]
* summary: Get Ombi requests
* description: |
* Returns Ombi movie and TV requests. Non-admin users only see their own requests
* (filtered by Emby user mapping), while admins see all requests.
*
* **Authentication:** Requires cookie authentication.
* security:
* - cookieAuth: []
* responses:
* '200':
* description: Ombi requests retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* user:
* type: string
* example: "username"
* isAdmin:
* type: boolean
* example: false
* requests:
* type: object
* properties:
* movie:
* type: array
* items:
* type: object
* tv:
* type: array
* items:
* type: object
* total:
* type: integer
* example: 5
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/requests', requireAuth, async (req, res) => {
try {
const user = req.user;
const isAdmin = req.isAdmin;
const username = user.name;
const showAll = isAdmin && req.query.showAll === 'true';
const arrRetrieverRegistry = require('../utils/arrRetrieverRegistry');
await arrRetrieverRegistry.initialize();
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
// Filter by user if not admin or if showAll is false
let filteredMovieRequests = ombiRequests.movie || [];
let filteredTvRequests = ombiRequests.tv || [];
if (!showAll && username) {
// Ombi uses requestedUser field to track who made the request
// Match by username (case-insensitive)
const usernameLower = username.toLowerCase();
filteredMovieRequests = filteredMovieRequests.filter(req => {
const requestedUser = extractRequestedUser(req);
return requestedUser.toLowerCase() === usernameLower;
});
filteredTvRequests = filteredTvRequests.filter(req => {
const requestedUser = extractRequestedUser(req);
return requestedUser.toLowerCase() === usernameLower;
});
}
const total = filteredMovieRequests.length + filteredTvRequests.length;
res.json({
user: username,
isAdmin,
showAll,
requests: {
movie: filteredMovieRequests,
tv: filteredTvRequests
},
total
});
} catch (error) {
logToFile(`[Ombi] Error fetching requests: ${error.message}`);
res.status(500).json({ error: 'Failed to fetch Ombi requests' });
}
});
/**
* @openapi
* /api/ombi/webhook/enable:
* post:
* tags: [Ombi]
* summary: Enable Ombi webhook
* description: |
* Registers or updates the Sofarr webhook in Ombi. Requires authentication and CSRF protection.
*
* **Authentication:** Requires cookie authentication.
* **CSRF:** Requires X-CSRF-Token header matching csrf_token cookie.
* security:
* - cookieAuth: []
* requestBody:
* required: false
* responses:
* '200':
* description: Webhook enabled successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* webhookUrl:
* type: string
* example: "https://sofarr.example.com/api/webhook/ombi"
* applicationToken:
* type: string
* example: "your-ombi-api-key"
* '400':
* description: Invalid request or missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/webhook/enable', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.status(400).json({ error: 'Ombi not configured' });
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
// Call Ombi API to register webhook
const axios = require('axios');
const response = await axios.post(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
enabled: true,
webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey
},
{
headers: {
'ApiKey': ombiInst.apiKey,
'Content-Type': 'application/json'
}
}
);
logToFile(`[Ombi] Webhook enabled: ${webhookUrl}`);
res.json({
success: true,
webhookUrl: webhookUrl,
applicationToken: ombiInst.apiKey
});
} catch (error) {
logToFile(`[Ombi] Error enabling webhook: ${error.message}`);
res.status(500).json({ error: 'Failed to enable Ombi webhook' });
}
});
/**
* @openapi
* /api/ombi/webhook/status:
* get:
* tags: [Ombi]
* summary: Get Ombi webhook status
* description: |
* Returns the current Ombi webhook configuration status and metrics.
*
* **Authentication:** Requires cookie authentication.
* security:
* - cookieAuth: []
* responses:
* '200':
* description: Webhook status retrieved successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* example: true
* webhookUrl:
* type: string
* nullable: true
* example: "https://sofarr.example.com/api/webhook/ombi"
* applicationToken:
* type: string
* nullable: true
* example: "your-ombi-api-key"
* triggers:
* type: object
* properties:
* requestAvailable:
* type: boolean
* example: true
* requestApproved:
* type: boolean
* example: true
* requestDeclined:
* type: boolean
* example: true
* requestPending:
* type: boolean
* example: true
* requestProcessing:
* type: boolean
* example: true
* stats:
* type: object
* properties:
* eventsReceived:
* type: integer
* example: 10
* pollsSkipped:
* type: integer
* example: 5
* lastWebhookTimestamp:
* type: integer
* example: 1716326400000
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/webhook/status', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
// Webhooks require SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET to be configured
if (!sofarrBaseUrl || !webhookSecret) {
return res.json({
enabled: false,
webhookUrl: null,
applicationToken: null,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
});
}
const ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.json({
enabled: false,
webhookUrl: null,
applicationToken: null,
triggers: {
requestAvailable: false,
requestApproved: false,
requestDeclined: false,
requestPending: false,
requestProcessing: false
},
stats: null
});
}
const ombiInst = ombiInstances[0];
// Call Ombi API to get webhook status
const axios = require('axios');
const response = await axios.get(
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
{
headers: {
'ApiKey': ombiInst.apiKey
}
}
);
const webhookConfig = response.data;
// Get webhook metrics from cache
const metrics = cache.getWebhookMetrics(ombiInst.url);
res.json({
enabled: webhookConfig.enabled || false,
webhookUrl: webhookConfig.webhookUrl || null,
applicationToken: webhookConfig.applicationToken || null,
triggers: {
requestAvailable: webhookConfig.enabled || false,
requestApproved: webhookConfig.enabled || false,
requestDeclined: webhookConfig.enabled || false,
requestPending: webhookConfig.enabled || false,
requestProcessing: webhookConfig.enabled || false
},
stats: metrics ? {
eventsReceived: metrics.eventCount || 0,
pollsSkipped: metrics.pollsSkipped || 0,
lastWebhookTimestamp: metrics.lastEventTimestamp || null
} : null
});
} catch (error) {
logToFile(`[Ombi] Error fetching webhook status: ${error.message}`);
res.status(500).json({ error: 'Failed to fetch Ombi webhook status' });
}
});
/**
* @openapi
* /api/ombi/webhook/test:
* post:
* tags: [Ombi]
* summary: Test Ombi webhook
* description: |
* Sends a test webhook event to the Sofarr Ombi webhook endpoint.
*
* **Authentication:** Requires cookie authentication and CSRF token.
* security:
* - cookieAuth: []
* - CsrfToken: []
* requestBody:
* required: false
* responses:
* '200':
* description: Test webhook sent successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* '400':
* description: Invalid request or missing configuration
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '401':
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/webhook/test', requireAuth, async (req, res) => {
try {
const sofarrBaseUrl = getSofarrBaseUrl();
const webhookSecret = getWebhookSecret();
if (!sofarrBaseUrl) {
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
}
if (!webhookSecret) {
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
}
const ombiInstances = getOmbiInstances();
if (ombiInstances.length === 0) {
return res.status(400).json({ error: 'Ombi not configured' });
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
// Simulate a test webhook event
const axios = require('axios');
await axios.post(webhookUrl, {
notificationType: 'RequestAvailable',
requestId: 0,
requestedUser: 'test',
title: 'Test Request',
type: 'Movie',
requestStatus: 'Pending'
}, {
headers: {
'X-Sofarr-Webhook-Secret': webhookSecret,
'Content-Type': 'application/json'
}
});
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
res.json({ success: true });
} catch (error) {
logToFile(`[Ombi] Error testing webhook: ${error.message}`);
res.status(500).json({ error: 'Failed to test Ombi webhook' });
}
});
module.exports = router;