Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 | |||
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 |
+8
-7
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -4,6 +4,38 @@ 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.18] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mobile overflow on Requests tab** — Request cards no longer extend off the right edge of the screen on mobile browsers. Removed `white-space: nowrap` from `.request-title` to allow text truncation with ellipsis, added `overflow-x: hidden` to `.requests-list` as a safety net, and added `@media (max-width: 768px)` rules to reduce padding and tighten gaps on mobile. Resolves Gitea Issue [#49](https://git.i3omb.com/Gandalf/sofarr/issues/49).
|
||||
|
||||
---
|
||||
|
||||
## [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
@@ -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/*` |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.14",
|
||||
"version": "1.7.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.14",
|
||||
"version": "1.7.18",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.14",
|
||||
"version": "1.7.18",
|
||||
"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": {
|
||||
|
||||
+13
-1
@@ -1888,6 +1888,18 @@ body {
|
||||
|
||||
/* ===== Mobile ===== */
|
||||
@media (max-width: 768px) {
|
||||
.requests-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -2234,6 +2246,7 @@ body {
|
||||
.requests-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
@@ -2273,7 +2286,6 @@ body {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a
|
||||
+1
-1
@@ -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
@@ -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
@@ -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.18
|
||||
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:
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user