Compare commits

..

7 Commits

Author SHA1 Message Date
gronod fbc071da09 merge branch 'develop' into 'main' - Release v1.7.17
Build and Push Docker Image / build (push) Successful in 46s
Create Release / release (push) Successful in 48s
CI / Swagger Validation & Coverage (push) Successful in 2m45s
CI / Security audit (push) Successful in 2m9s
CI / Tests & coverage (push) Failing after 32s
2026-05-24 22:49:16 +01:00
gronod 7690d959b3 fix: blocklist-search lookup against queue cache instead of downloadClientRegistry
CI / Security audit (push) Successful in 1m52s
Docs Check / Markdown lint (push) Successful in 1m37s
Build and Push Docker Image / build (push) Successful in 2m2s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
CI / Swagger Validation & Coverage (push) Successful in 3m17s
Docs Check / Mermaid diagram parse check (push) Successful in 3m31s
CI / Tests & coverage (push) Successful in 4m5s
Fixes the root cause of the regression from v1.7.16. The v1.7.16 fix
correctly cast arrQueueId to String, but the lookup was performed
against downloadClientRegistry.getAllDownloads() which returns raw
download client data (qBittorrent, SABnzbd, etc.) that never has
arrQueueId populated.

The fix now looks up the queue record directly from the Sonarr/Radarr
queue cache where record.id is the numeric queue ID, using String()
casting on both sides to handle the DOM-dataset (string) vs API
response (number) type difference.

Resolves Gitea Issue #48
Closes #48
2026-05-24 22:48:17 +01:00
gronod 1ba9d15954 merge branch 'develop' into 'main' - Release v1.7.16
Create Release / release (push) Successful in 21s
Build and Push Docker Image / build (push) Successful in 2m11s
CI / Security audit (push) Successful in 1m57s
CI / Tests & coverage (push) Successful in 2m29s
CI / Swagger Validation & Coverage (push) Successful in 2m6s
2026-05-24 22:13:28 +01:00
gronod 83c9d4d164 fix: blocklist-search queue ID type mismatch and bump version to 1.7.16
Build and Push Docker Image / build (push) Successful in 2m14s
Docs Check / Markdown lint (push) Successful in 2m29s
CI / Security audit (push) Successful in 2m56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m4s
CI / Swagger Validation & Coverage (push) Successful in 3m52s
Docs Check / Mermaid diagram parse check (push) Successful in 4m8s
CI / Tests & coverage (push) Successful in 4m38s
- Cast arrQueueId to String in both sides of the download lookup comparison
  in /api/dashboard/blocklist-search to resolve false-negative match failure
  caused by DOM dataset string vs Radarr/Sonarr API number type mismatch
- Add regression integration test for string-vs-number arrQueueId matching
- Bump version to 1.7.16, update CHANGELOG.md, openapi.yaml, and JSDoc examples

Resolves #48
2026-05-24 22:12:34 +01:00
gronod f2c01903fa fix: resolve Mermaid syntax parse error in ARCHITECTURE.md
Create Release / release (push) Successful in 44s
Docs Check / Markdown lint (push) Successful in 55s
Build and Push Docker Image / build (push) Successful in 57s
Docs Check / Mermaid diagram parse check (push) Successful in 2m39s
CI / Tests & coverage (push) Successful in 2m3s
CI / Security audit (push) Successful in 2m47s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
2026-05-24 21:30:51 +01:00
gronod 8b6ef0f64f merge branch 'develop' into 'main' - Release v1.7.15
Build and Push Docker Image / build (push) Successful in 1m43s
Create Release / release (push) Successful in 39s
CI / Security audit (push) Successful in 1m49s
CI / Swagger Validation & Coverage (push) Successful in 2m0s
CI / Tests & coverage (push) Successful in 2m51s
2026-05-24 21:25:55 +01:00
gronod 7b9c895888 fix: support query parameter-based secret validation fallback to fix Ombi webhooks (#47)
Build and Push Docker Image / build (push) Successful in 2m4s
Docs Check / Markdown lint (push) Successful in 2m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m3s
CI / Security audit (push) Successful in 3m42s
Docs Check / Mermaid diagram parse check (push) Failing after 3m58s
CI / Tests & coverage (push) Successful in 4m21s
CI / Swagger Validation & Coverage (push) Successful in 4m33s
2026-05-24 21:25:38 +01:00
27 changed files with 493 additions and 87 deletions
+8 -7
View File
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
Both endpoints share identical processing logic:
```
Sonarr/Radarr
Sonarr/Radarr/Ombi
POST /api/webhook/sonarr
Headers: X-Sofarr-Webhook-Secret: <secret>
Headers: X-Sofarr-Webhook-Secret: <secret> OR URL parameter: ?secret=<secret>
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
"date": "2026-05-19T10:00:00.000Z", … }
@@ -404,6 +404,7 @@ Sonarr/Radarr
validateWebhookSecret() ──fail──► 401 Unauthorized
(Checks header or query param)
│ ok
validatePayload() ──fail──► 400 Bad Request
@@ -1002,19 +1003,19 @@ sofarr provides a togglable, real-time log capturing and streaming engine allowi
```mermaid
flowchart LR
subgraph Browser (SPA)
subgraph Browser ["Browser (SPA)"]
console["console.log/warn/error"] --> queue["logQueue (batched)"]
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
end
subgraph Node.js (Server)
subgraph Server ["Node.js (Server)"]
stdout["process.stdout.write"] --> capture["processStreamData()"]
stderr["process.stderr.write"] --> capture
capture --> |stripAnsi()| serverBuffer["logBuffer (rolling 1000 lines)"]
capture --> |emit('server-log')| serverSse["GET /api/debug/server-logs (SSE)"]
capture --> |stripAnsi| serverBuffer["logBuffer (rolling 1000 lines)"]
capture --> |emit server-log| serverSse["GET /api/debug/server-logs (SSE)"]
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
ingestionRoute --> |emit('client-log')| clientSse["GET /api/debug/client-logs (SSE)"]
ingestionRoute --> |emit client-log| clientSse["GET /api/debug/client-logs (SSE)"]
end
```
+24
View File
@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.17] - 2026-05-24
### Fixed
- **Blocklist-Search Persistent Failure (Regression from v1.7.16)** — Identified and corrected the true root cause of the blocklist-and-search feature being non-functional. The v1.7.16 fix correctly cast both sides of the queue ID comparison to `String`, but the lookup was performed against `downloadClientRegistry.getAllDownloads()`, which returns **raw download-client data** (qBittorrent, SABnzbd, etc.) that never has `arrQueueId` populated — that field is only assigned by `DownloadMatcher.js` during the SSE build phase from the *arr cache. For qBittorrent torrents specifically, `QBittorrentClient.normalizeDownload()` does not set `arrQueueId` at all, so the lookup always returned `undefined` and the request was rejected with `403`. The permission check in `POST /api/dashboard/blocklist-search` now looks up the queue record directly from the Sonarr/Radarr queue cache (`poll:sonarr-queue` / `poll:radarr-queue`) where `record.id` is the numeric queue ID, using `String()` casting on both sides to handle the DOM-dataset (string) vs API response (number) type difference. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48) (regression).
---
## [1.7.16] - 2026-05-24
### Fixed
- **Blocklist-Search Queue ID Type Mismatch** — Resolved a bug where the "Blocklist and search" action consistently returned `403 Download not found or permission denied` for all users. The server-side download lookup in `server/routes/dashboard.js` used strict equality (`===`) to compare `arrQueueId` values, but the value sent from the SPA client (read from a DOM `data-*` attribute) is always a `string`, while the value populated from the Radarr/Sonarr queue API is a `number`. Both sides are now cast to `String` before comparison, resolving the false-negative match failure. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48).
---
## [1.7.15] - 2026-05-24
### Fixed
- **Ombi Webhook Authentication Failures** — Resolved a critical issue where Ombi webhook notifications failed to authenticate because Ombi's built-in notification agent does not support custom HTTP headers (such as `X-Sofarr-Webhook-Secret`). Added a query parameter authentication fallback (`?secret=`) to all `/api/webhook/*` endpoints (Sonarr, Radarr, and Ombi) and configured Ombi webhook registration to automatically append this secret query parameter. Resolves Gitea Issue [#47](https://git.i3omb.com/Gandalf/sofarr/issues/47).
---
## [1.7.14] - 2026-05-24
### Fixed
+1 -1
View File
@@ -40,7 +40,7 @@ users via Emby. The primary threat surface when exposed to the public internet:
| Privilege escalation (container) | Non-root user (UID 1000), `no-new-privileges`, all caps dropped |
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header or `secret` query parameter; 401 on mismatch |
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sofarr",
"version": "1.7.14",
"version": "1.7.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "1.7.14",
"version": "1.7.17",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sofarr",
"version": "1.7.14",
"version": "1.7.17",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js",
"scripts": {
+21
View File
@@ -0,0 +1,21 @@
Problem:
The "Blocklist & Search" button on download cards fails with a "400 Bad Request (Missing required fields)" when clicked on any television release in Sonarr that represents a full season package or a multi-episode release.
Root Cause:
1. In `server/services/DownloadMatcher.js`, when a download is matched with a Sonarr queue record, `arrContentId` is populated with `sonarrMatch.episodeId || null`.
2. However, for multi-episode packs or full season grabs in Sonarr v3, the `episodeId` field is missing from the queue record payload (since the release is associated with multiple episodes). Instead, Sonarr provides an `episodeIds` array. As a result, `arrContentId` is normalized to `null`.
3. When the user clicks the "Blocklist & Search" button in the UI, the frontend calls the `POST /api/dashboard/blocklist-search` endpoint. The request body includes `arrContentId: null`.
4. The backend route validator in `server/routes/dashboard.js` strictly requires all fields including `arrContentId` to be truthy:
```javascript
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
return res.status(400).json({ error: 'Missing required fields' });
}
```
Because `arrContentId` is `null`, this check fails and returns `400 Missing required fields`, completely blocking the blocklist operation (even though queue removal itself does not require an episode ID).
5. Furthermore, the search trigger logic in `dashboard.js` only handles single episode searches via `{ name: 'EpisodeSearch', episodeIds: [arrContentId] }` and has no logic to handle `episodeIds` arrays or fallback searches (such as `SeriesSearch` or `SeasonSearch`).
Proposed Fix:
1. **Relax Backend Validation**: Allow `arrContentId` to be optional or null for `sonarr` queue records to ensure the deletion and blocklist steps can still execute.
2. **Robust Search Triggers**:
- If `episodeId` is missing but `episodeIds` array is available on the matched record, pass the array of IDs to the frontend/backend.
- Modify the `dashboard.js` re-search block to support `EpisodeSearch` with multiple IDs, or fall back to triggering a `SeriesSearch` command using the `seriesId` if no specific episode IDs are resolved.
+16
View File
@@ -0,0 +1,16 @@
Title:
FEATURE: Client-side console log capturing and streaming API endpoint with dual-authentication
Problem / Requirement:
To aid in frontend troubleshooting, developers need a way to capture and gather client-side console logs (`console.log`, `console.warn`, `console.error`) and make them accessible over a real-time log stream endpoint. This helps debug frontend issues (such as SSE failures, CSP violations, and state synchronization issues) in environments without direct access to browser devtools.
Success Criteria:
1. Client-Side Interceptor: Intercept standard browser console methods at SPA startup and place captured logs into an in-memory queue.
2. Batched Log Transmission (Selected Option A): Periodic HTTP POST batch queries to `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize browser thread and network overhead.
3. Server storage and SSE log streaming:
- Save incoming logs into a separate rolling 1000-line buffer `clientLogBuffer`.
- Expose `GET /api/debug/client-logs/stream` to stream client-side logs in real-time via SSE.
4. Security & Configuration:
- Enableable only when the environment variable `ENABLE_LOG_STREAM=true` is set.
- Enforce exact same dual-auth rules (Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass) on both client logs endpoints.
5. API Documentation: Documented in `server/openapi.yaml`.
+12
View File
@@ -0,0 +1,12 @@
Amended the plan to add client-side console log capturing and streaming options:
### Proposed Client Logging Design:
- **Client-Side Capture (Frontend Interception)**: Hook into standard browser console methods (`console.log`, `console.warn`, `console.error`) at client-side startup.
- **Client-to-Server Transmission**:
- **Option A (Recommended)**: Store captured logs in a local memory queue, and periodically perform a batched `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize network overhead.
- **Option B (WebSocket Channel)**: Stream logs instantly via persistent WebSockets, which adds structural and connection management complexity.
- **Server Storage & SSE Streaming**:
- Store incoming client logs in a separate rolling 1000-line buffer `clientLogBuffer`.
- Expose `GET /api/debug/client-logs/stream` (under the exact same dual-auth/webhook-secret constraints) to stream client-side logs in real-time via SSE to debugging tools.
The `implementation_plan.md` artifact has been successfully updated with these options.
+32
View File
@@ -0,0 +1,32 @@
const axios = require('axios');
const fs = require('fs');
const secret = '63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a';
const serverLogsUrl = 'https://sofarr.i3omb.com/api/debug/server-logs?testClose=true';
const clientLogsUrl = 'https://sofarr.i3omb.com/api/debug/client-logs?testClose=true';
async function fetchLogs(url, filename) {
console.log(`Fetching logs from ${url}...`);
try {
const response = await axios.get(url, {
headers: {
'x-webhook-secret': secret
}
});
fs.writeFileSync(filename, response.data);
console.log(`Logs saved to ${filename} (${response.data.length} bytes).`);
} catch (err) {
console.error(`Failed to fetch from ${url}:`, err.message);
if (err.response) {
console.error(`Status: ${err.response.status}`);
console.error(`Body:`, JSON.stringify(err.response.data));
}
}
}
async function run() {
await fetchLogs(serverLogsUrl, 'scratch/remote_server.log');
await fetchLogs(clientLogsUrl, 'scratch/remote_client.log');
}
run();
+26
View File
@@ -0,0 +1,26 @@
## Regression: Fix in v1.7.16 was insufficient — issue persists in production
### Updated Root Cause Analysis
Post-release investigation of live server debug logs on `sofarr.i3omb.com` confirms the blocklist feature is **still failing** after v1.7.16. The server logs still show:
```
[Blocklist] Download not found: { arrQueueId: 439913856, arrType: 'radarr' }
```
The v1.7.16 fix cast both sides of the comparison to `String`, which was the correct approach — but it was applied to the **wrong data source**.
The permission check at line 693 of `dashboard.js` calls:
```js
const allDownloads = await downloadClientRegistry.getAllDownloads();
const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType);
```
`downloadClientRegistry.getAllDownloads()` fetches **raw download client data** directly from qBittorrent, SABnzbd, etc. — these are unmatched objects with no Sonarr/Radarr queue metadata. The `arrQueueId` field is only populated during `DownloadMatcher.js` processing (which runs during the SSE/dashboard build from the *arr cache). Because qBittorrent's `normalizeDownload()` never sets `arrQueueId`, the lookup **always returns `undefined`** for any qBittorrent torrent, regardless of type casting.
### Correct Fix
The permission check should validate against the **Sonarr/Radarr queue cache records** directly (where `id` is the queue record ID), rather than against raw download client data. The fix will replace the `downloadClientRegistry.getAllDownloads()` lookup with a direct cache lookup of `poll:sonarr-queue` / `poll:radarr-queue` records, matching by `String(record.id) === String(arrQueueId)`.
This will be released in v1.7.17.
+46
View File
@@ -0,0 +1,46 @@
## Summary
The "Blocklist and search" feature is broken for all users. Clicking the blocklist button on a download (e.g. the film "Project Hail Mary", `arrQueueId: 905000340`, `arrType: radarr`) consistently returns a `403 Download not found or permission denied` error.
## Root Cause
The server-side lookup in `server/routes/dashboard.js` uses strict equality (`===`) to find the matching download:
```js
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
```
- `d.arrQueueId` is populated from the Radarr/Sonarr queue API response as a **number** (e.g. `905000340`).
- `arrQueueId` from `req.body` originates from the client SPA via a DOM `dataset` attribute, which is always a **string** (e.g. `"905000340"`).
- Due to the type mismatch, `905000340 === "905000340"` evaluates to `false`, so the lookup always fails and returns `403`.
## Evidence
Server log (live environment, `2026-05-24`):
```
[Blocklist] Download not found: { arrQueueId: 905000340, arrType: 'radarr' }
```
Client log confirms user clicked blocklist at `21:01:19`, `21:01:32`, and `21:02:35`.
## Steps to Reproduce
1. Open the dashboard on a Radarr or Sonarr download with a pending queue entry.
2. Click the "Blocklist and search" button.
3. The action silently fails; the download is not removed and no re-search is triggered.
4. Server logs show `[Blocklist] Download not found`.
## Proposed Fix
Cast both sides of the comparison to `String` before comparing:
```js
const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType);
```
This fix will be released in version `1.7.16`.
## Severity
**High** — The blocklist-and-search feature is completely non-functional for all users. There is no workaround within the UI.
+19
View File
@@ -0,0 +1,19 @@
Title:
FEATURE: Log streaming debug endpoint with dual-authentication and togglable runtime configuration
Problem / Requirement:
Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access.
Success Criteria:
1. **Lightweight Log Streaming**: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory.
2. **Dual-Authentication**:
- Accepts existing session cookie (`emby_user`) with administrative credentials.
- Accepts standard HTTP Basic Authentication (`Authorization: Basic <base64>`) using Emby administrator username/password credentials.
3. **Runtime Configuration Toggle**: Enableable using a runtime environment variable `ENABLE_LOG_STREAM=true` (defaulting to `false`/disabled). When disabled, returns a `403 Forbidden` response.
4. **API Spec Documentation**: Documented in `server/openapi.yaml` under the `/api/debug/logs` endpoint, including the query format and response schemas.
Proposed Implementation:
1. **Log Interceptor**: Implement a global stdout/stderr hook in `server/index.js` or in a new `server/utils/logCapture.js` to collect a rolling buffer of 1000 log lines and expose a Node `EventEmitter` to push new logs to active subscribers.
2. **Authentication Middleware**: Create `server/middleware/logStreamAuth.js` which verifies active sessions or fallback Basic Auth headers by calling Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to verify the user is a valid administrator.
3. **Route Definition**: Define `server/routes/debug.js` to register `GET /api/debug/logs` backing the SSE stream, enforce the `ENABLE_LOG_STREAM === 'true'` check, and execute `logStreamAuth` checks.
4. **OpenAPI Spec Integration**: Define `/api/debug/logs` schemas, parameters, security schemes, and basic auth descriptions inside `server/openapi.yaml`.
+21
View File
@@ -0,0 +1,21 @@
### Bug Description
Ombi webhooks are currently failing to authenticate. In `server/routes/webhook.js`, all `/api/webhook/*` endpoints (sonarr, radarr, and ombi) require the custom `X-Sofarr-Webhook-Secret` HTTP header to be present and match the configured `SOFARR_WEBHOOK_SECRET`.
However, Ombi's built-in Webhook notification agent does not support adding custom HTTP headers to its outgoing webhook notification requests. This makes it impossible for Ombi to successfully authenticate using the current header-only validation mechanism.
### Root Cause
In `server/routes/webhook.js`, `validateWebhookSecret(req)` only inspects `req.get('X-Sofarr-Webhook-Secret')`:
```javascript
function validateWebhookSecret(req) {
const expectedSecret = getWebhookSecret();
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
...
}
```
Since Ombi sends standard JSON payloads to a configured URL without custom headers, it cannot supply this header, resulting in a `401 Unauthorized` response.
### Proposed Remediation
1. **Fallback Authentication Method**: Update `validateWebhookSecret(req)` in `server/routes/webhook.js` to look for the secret in either the `X-Sofarr-Webhook-Secret` header OR as a `secret` query parameter (`req.query.secret`).
2. **Registration Update**: Update the `/webhook/enable` route in `server/routes/ombi.js` to automatically append `?secret=${webhookSecret}` to the registered `webhookUrl` sent to Ombi.
3. **OpenAPI Spec & JSDoc Updates**: Document the query-parameter fallback authentication option in `server/openapi.yaml` and the `@openapi` JSDoc comments in `server/routes/webhook.js`.
4. **Integration Testing**: Add new integration tests in `tests/integration/webhook.test.js` to assert that authentication via query parameters succeeds, and that invalid query parameters are rejected.
+6
View File
@@ -0,0 +1,6 @@
Amended the plan to include a high-priority bypass using the `X-Webhook-Secret` request header:
1. **Webhook Secret Bypass**: If the request contains the `X-Webhook-Secret` header, we verify if it matches the configured `SOFARR_WEBHOOK_SECRET` environment variable.
2. **Access Granted**: If matching, the request is immediately authorized, completely bypassing session and Emby Basic Auth checks. This is ideal for curl scripts, server-to-server monitoring, or external debugging logs captures.
I have updated the `implementation_plan.md` artifact to reflect this amendment.
+15
View File
@@ -0,0 +1,15 @@
I have investigated the blocklist & search failure reported in this issue and created a technical remediation plan:
### Root Cause
For television grabs representing a full-season pack or multi-episode package in Sonarr, the `episodeId` property is absent (instead, it has an `episodeIds` array). This maps to a `null` value for `arrContentId` on the client download card. The `/api/dashboard/blocklist-search` route strictly requires all fields including `arrContentId` to be truthy, returning `400 Bad Request: Missing required fields` and completely blocking the queue blocklist/removal action.
### Remediation Plan
1. **Enrich Backend Match Data**: Expose `arrContentIds` (`sonarrMatch.episodeIds`) and `arrSeriesId` (`sonarrMatch.seriesId`) from `DownloadMatcher.js` to the normalized download card object.
2. **Relax API Route Validation**: Remove `arrContentId` from the mandatory request parameters check in `server/routes/dashboard.js`.
3. **Enhance Search Commands**:
- If a single `arrContentId` is provided, trigger `EpisodeSearch` for that single ID.
- If an `arrContentIds` array is provided, trigger `EpisodeSearch` with that list of IDs.
- If no specific episode IDs can be resolved but `arrSeriesId` is provided, fall back to triggering a series-wide `SeriesSearch`.
4. **Update Frontend & Documentation**: Update the client payload, update the OpenAPI spec, and add integration tests covering single/multi/fallback searches.
Upon approval, I will execute this plan, merge to `main`, close this ticket referencing the resolving commit, and cut a new point release (v1.7.11).
+14
View File
@@ -0,0 +1,14 @@
Title:
FEATURE: Togglable server-side (Docker) log streaming debug endpoint with dual-authentication
Problem / Requirement:
Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access.
Success Criteria:
1. Lightweight Log Streaming: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory.
2. Dual-Authentication with Webhook Secret Bypass:
- Accepts existing session cookie (emby_user) with administrative credentials.
- Accepts standard HTTP Basic Authentication (Authorization: Basic <base64>) using Emby administrator username/password credentials.
- Accepts X-Webhook-Secret header matching the SOFARR_WEBHOOK_SECRET environment variable for programmatic bypass.
3. Runtime Configuration Toggle: Enableable using a runtime environment variable ENABLE_LOG_STREAM=true (defaulting to false/disabled). When disabled, returns a 403 Forbidden response.
4. API Spec Documentation: Documented in server/openapi.yaml under the /api/debug/logs endpoint, including the query format and response schemas.
+9
View File
@@ -0,0 +1,9 @@
Release v1.7.16
Remediate the blocklist-search queue ID type mismatch. The "Blocklist and
search" action was returning 403 for all users because the arrQueueId
comparison used strict equality between a string (from the SPA DOM dataset)
and a number (from the Radarr/Sonarr API). Both values are now cast to
String before comparison.
See CHANGELOG.md for full details.
+1
View File
@@ -0,0 +1 @@
63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a
+1 -1
View File
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version:
* type: string
* description: sofarr version
* example: "1.7.14"
* example: "1.7.16"
* x-code-samples:
* - lang: curl
* label: cURL
+1 -1
View File
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
* version:
* type: string
* description: sofarr version
* example: "1.7.14"
* example: "1.7.16"
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version });
+25 -4
View File
@@ -22,7 +22,7 @@ info:
## SSE Streaming
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
version: 1.7.14
version: 1.7.17
contact:
name: sofarr
license:
@@ -795,8 +795,15 @@ paths:
post:
tags: [Webhook]
summary: Sonarr webhook
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header.
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
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:
@@ -832,8 +839,15 @@ paths:
post:
tags: [Webhook]
summary: Radarr webhook
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header.
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
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:
@@ -869,8 +883,15 @@ paths:
post:
tags: [Webhook]
summary: Ombi webhook
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header.
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
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:
+22 -6
View File
@@ -686,17 +686,33 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
}
// Look up the download to verify permission
const allDownloads = await downloadClientRegistry.getAllDownloads();
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
// Look up the queue record directly from the *arr cache.
// downloadClientRegistry.getAllDownloads() returns raw download-client data
// (qBittorrent, SABnzbd, etc.) which never has arrQueueId set — that field
// is only populated later by DownloadMatcher during the SSE build phase.
// Instead, we verify permission by finding the record in the Sonarr/Radarr
// queue cache where record.id is the numeric queue ID.
// Cast both sides to String to handle the DOM dataset → string vs API → number mismatch.
const queueCacheKey = arrType === 'sonarr' ? 'poll:sonarr-queue' : 'poll:radarr-queue';
const queueData = cache.get(queueCacheKey) || { records: [] };
const queueRecord = (queueData.records || []).find(r => r.id != null && String(r.id) === String(arrQueueId));
if (!download) {
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
if (!queueRecord) {
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
return res.status(403).json({ error: 'Download not found or permission denied' });
}
// Build a minimal download-like object for canBlocklist eligibility check.
// Includes importIssues so non-admins can blocklist stalled/import-pending items.
const importIssues = require('../services/DownloadAssembler').getImportIssues(queueRecord);
const downloadForCheck = {
importIssues: importIssues || [],
arrQueueId: queueRecord.id,
arrType
};
// Check if user can blocklist this download
if (!canBlocklist(download, user.isAdmin)) {
if (!canBlocklist(downloadForCheck, user.isAdmin)) {
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
}
+1 -1
View File
@@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
}
const ombiInst = ombiInstances[0];
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
// Call Ombi API to register webhook
const axios = require('axios');
+31 -11
View File
@@ -144,13 +144,13 @@ const OMBI_EVENTS = new Set([
]);
/**
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
* 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');
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
if (!expectedSecret) {
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
@@ -158,7 +158,7 @@ function validateWebhookSecret(req) {
}
if (!providedSecret) {
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter');
return false;
}
@@ -309,13 +309,13 @@ function validatePayload(body) {
* 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`.
* **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
* - 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
@@ -342,6 +342,13 @@ function validatePayload(body) {
* - 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:
@@ -456,13 +463,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
* 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`.
* **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
* - 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
@@ -489,6 +496,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
* - 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:
@@ -603,13 +617,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
* 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`.
* **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
* - 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
@@ -627,11 +641,17 @@ router.post('/radarr', webhookLimiter, (req, res) => {
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
*
* **x-integration-notes:** Configure Ombi webhook:
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi`
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
* - Method: POST
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
* - 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:
+66 -49
View File
@@ -225,7 +225,7 @@ function invalidatePollCache() {
'poll:sab-queue', 'poll:sab-history',
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
'poll:qbittorrent'
'poll:qbittorrent', 'poll:ombi-requests'
];
for (const k of keys) cache.invalidate(k);
}
@@ -749,12 +749,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok'
}] }, CACHE_TTL);
const res = await request(app)
.post('/api/dashboard/blocklist-search')
@@ -763,18 +765,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission denied/i);
mockGetAllDownloads.mockRestore();
});
it('returns 403 for non-admin when download not found in active downloads', async () => {
it('returns 403 for non-admin when download not found in arr queue cache', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return empty array (download not found)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]);
// Cache is already seeded empty by beforeEach; no queue record with id=1 exists
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
@@ -782,19 +780,21 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/download not found/i);
mockGetAllDownloads.mockRestore();
});
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
]);
// Seed cache: queue record with import issues qualifies non-admin for blocklist
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['Import error 1'] }]
}] }, CACHE_TTL);
// Mock Sonarr DELETE and command endpoints
nock(SONARR_BASE)
@@ -812,7 +812,6 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('returns 400 when required fields are missing', async () => {
@@ -843,11 +842,8 @@ describe('POST /api/dashboard/blocklist-search', () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -864,18 +860,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('calls Radarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
]);
// Seed the Radarr queue cache so the permission lookup finds the record
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
nock(RADARR_BASE)
.delete('/api/v3/queue/2001')
@@ -892,18 +884,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('returns 502 when Sonarr DELETE request fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -916,17 +904,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(502);
mockGetAllDownloads.mockRestore();
});
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -943,17 +928,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -970,7 +952,42 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('matches download correctly when arrQueueId is sent as a string but stored as a number in queue cache (type mismatch regression)', async () => {
// Regression test for issue #48 (v2): arrQueueId from the SPA DOM dataset is always
// a string, but the queue record id from the Radarr/Sonarr API cache is a number.
// Without String() casting the === comparison fails and returns 403.
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed Radarr queue with a numeric id (as Radarr API returns it)
cache.set('poll:radarr-queue', { records: [{
id: 9050001,
title: 'Project.Hail.Mary.2026.2160p',
movieId: 77,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: RADARR_BASE,
_instanceKey: 'rk'
}] }, CACHE_TTL);
nock(RADARR_BASE)
.delete('/api/v3/queue/9050001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(RADARR_BASE)
.post('/api/v3/command')
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
// arrQueueId sent as a STRING from the client (as the SPA DOM dataset does)
.send({ arrQueueId: '9050001', arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 77, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
+3 -3
View File
@@ -856,7 +856,7 @@ describe('POST /api/ombi/webhook/enable', () => {
.post('/api/v1/Settings/notifications/webhook', {
id: 42,
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
applicationToken: 'test-ombi-key'
})
.reply(200, { success: true });
@@ -870,7 +870,7 @@ describe('POST /api/ombi/webhook/enable', () => {
.expect(200);
expect(res.body.success).toBe(true);
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi`);
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`);
expect(res.body.applicationToken).toBe('test-ombi-key');
});
@@ -882,7 +882,7 @@ describe('POST /api/ombi/webhook/enable', () => {
.post('/api/v1/Settings/notifications/webhook', {
id: 0,
enabled: true,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
applicationToken: 'test-ombi-key'
})
.reply(200, { success: true });
+69
View File
@@ -156,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
const res = await postSonarr(app, SONARR_GRAB, 'anything');
expect(res.status).toBe(401);
});
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/sonarr?secret=${VALID_SECRET}`)
.send(SONARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/sonarr?secret=wrong-query-secret')
.send(SONARR_GRAB);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
});
describe('POST /api/webhook/radarr — secret validation', () => {
@@ -171,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
expect(res.status).toBe(401);
});
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/radarr?secret=${VALID_SECRET}`)
.send(RADARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/radarr?secret=wrong-query-secret')
.send(RADARR_GRAB);
expect(res.status).toBe(401);
});
});
// ---------------------------------------------------------------------------
@@ -548,6 +583,40 @@ describe('POST /api/webhook/ombi', () => {
expect(res.body.error).toBe('Unauthorized');
});
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
nock('https://ombi.test')
.get('/api/v1/Request/movie')
.reply(200, []);
nock('https://ombi.test')
.get('/api/v1/Request/tv')
.reply(200, []);
const res = await request(app)
.post(`/api/webhook/ombi?secret=${VALID_SECRET}`)
.send({
notificationType: 'NewRequest',
requestId: 127,
requestedUser: 'gordon',
title: 'Query Movie',
type: 'Movie',
requestStatus: 'Pending',
applicationUrl: 'https://ombi.test',
requestedDate: '2026-05-23T20:40:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/ombi?secret=wrong-query-secret')
.send({ notificationType: 'NewRequest', requestId: 1 });
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
it('returns 400 when notificationType is missing or invalid', async () => {
const app = makeApp();
const res = await postOmbi(app, { requestId: 1 });