Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f6aa5b967 | |||
| 5390bbf615 | |||
| fb68bddedb | |||
| 81d0aa82f2 | |||
| 7d7304637c | |||
| d87ad9f1c7 | |||
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 | |||
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 |
+2
-1
@@ -11,4 +11,5 @@ data/
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.agents/
|
||||
.windsurf/
|
||||
.windsurf/
|
||||
scratch/
|
||||
+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,67 @@ 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.20] - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.19] - 2026-05-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
|
||||
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.13] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
+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.13",
|
||||
"version": "1.7.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.13",
|
||||
"version": "1.7.20",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.13",
|
||||
"version": "1.7.20",
|
||||
"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": {
|
||||
|
||||
+19
-1
@@ -1888,6 +1888,23 @@ body {
|
||||
|
||||
/* ===== Mobile ===== */
|
||||
@media (max-width: 768px) {
|
||||
.main-tabs {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.requests-container {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.request-meta {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -2234,6 +2251,7 @@ body {
|
||||
.requests-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.request-card {
|
||||
@@ -2245,6 +2263,7 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-card:hover {
|
||||
@@ -2273,7 +2292,6 @@ body {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.13"
|
||||
* example: "1.7.20"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
|
||||
@@ -125,6 +125,20 @@ class OmbiClient {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users from Ombi
|
||||
* @returns {Promise<Array>} Array of user objects
|
||||
*/
|
||||
async getUsers() {
|
||||
try {
|
||||
const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`);
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiClient] Get users error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OmbiClient;
|
||||
|
||||
@@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever {
|
||||
this.cache = {
|
||||
movieRequests: [],
|
||||
tvRequests: [],
|
||||
users: [],
|
||||
movieMap: new Map(), // tmdbId -> request
|
||||
tvMap: new Map(), // tvdbId -> request
|
||||
userMap: new Map(), // id -> user
|
||||
lastFetch: 0,
|
||||
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||
};
|
||||
@@ -98,20 +100,32 @@ class OmbiRetriever extends ArrRetriever {
|
||||
try {
|
||||
logToFile('[OmbiRetriever] Refreshing cache');
|
||||
|
||||
// Fetch requests in parallel
|
||||
const [movieRequests, tvRequests] = await Promise.all([
|
||||
// Fetch requests and users in parallel
|
||||
const [movieRequests, tvRequests, users] = await Promise.all([
|
||||
this.client.getMovieRequests(),
|
||||
this.client.getTvRequests()
|
||||
this.client.getTvRequests(),
|
||||
this.client.getUsers()
|
||||
]);
|
||||
|
||||
// Update cache
|
||||
this.cache.movieRequests = movieRequests;
|
||||
this.cache.tvRequests = tvRequests;
|
||||
this.cache.users = users;
|
||||
this.cache.lastFetch = Date.now();
|
||||
|
||||
// Build lookup maps
|
||||
this.cache.movieMap.clear();
|
||||
this.cache.tvMap.clear();
|
||||
this.cache.userMap.clear();
|
||||
|
||||
// Build user map (id -> user)
|
||||
if (Array.isArray(users)) {
|
||||
users.forEach(user => {
|
||||
if (user && user.id) {
|
||||
this.cache.userMap.set(user.id, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build movie map (tmdbId -> request)
|
||||
movieRequests.forEach(request => {
|
||||
@@ -133,13 +147,59 @@ class OmbiRetriever extends ArrRetriever {
|
||||
}
|
||||
});
|
||||
|
||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`);
|
||||
logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`);
|
||||
} catch (error) {
|
||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||
// Don't throw error, continue with stale cache if available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates requestedUser on a single request using the userMap cache
|
||||
* @param {Object} req - The request object
|
||||
* @returns {Object} Hydrated request object
|
||||
* @private
|
||||
*/
|
||||
_hydrateRequest(req) {
|
||||
if (!req) return req;
|
||||
|
||||
const reqUserId = req.requestedUserId || req.RequestedUserId;
|
||||
if (reqUserId && this.cache.userMap.has(reqUserId)) {
|
||||
const cachedUser = this.cache.userMap.get(reqUserId);
|
||||
|
||||
let requestedUser = req.requestedUser || req.RequestedUser;
|
||||
|
||||
// If requestedUser is not an object or is empty/null, populate it
|
||||
if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) {
|
||||
const hydratedUser = {
|
||||
id: cachedUser.id,
|
||||
userName: cachedUser.userName,
|
||||
alias: cachedUser.alias || cachedUser.Alias || '',
|
||||
userAlias: cachedUser.userAlias || cachedUser.UserAlias || '',
|
||||
normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || ''
|
||||
};
|
||||
|
||||
return {
|
||||
...req,
|
||||
requestedUser: hydratedUser,
|
||||
RequestedUser: hydratedUser
|
||||
};
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates requestedUser on a list of requests using the userMap cache
|
||||
* @param {Array} requests - Array of request objects
|
||||
* @returns {Array} Array of hydrated request objects
|
||||
* @private
|
||||
*/
|
||||
_hydrateRequests(requests) {
|
||||
if (!Array.isArray(requests)) return [];
|
||||
return requests.map(req => this._hydrateRequest(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all movie requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
@@ -147,7 +207,7 @@ class OmbiRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getMovieRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.movieRequests;
|
||||
return this._hydrateRequests(this.cache.movieRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,7 +217,7 @@ class OmbiRetriever extends ArrRetriever {
|
||||
*/
|
||||
async getTvRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.tvRequests;
|
||||
return this._hydrateRequests(this.cache.tvRequests);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,12 +231,12 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
// Try TMDB ID first
|
||||
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
||||
return this.cache.movieMap.get(tmdbId);
|
||||
return this._hydrateRequest(this.cache.movieMap.get(tmdbId));
|
||||
}
|
||||
|
||||
// Try IMDB ID as fallback
|
||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||
return this.cache.movieMap.get(imdbId);
|
||||
return this._hydrateRequest(this.cache.movieMap.get(imdbId));
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -193,12 +253,12 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
// Try TVDB ID first
|
||||
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
||||
return this.cache.tvMap.get(tvdbId);
|
||||
return this._hydrateRequest(this.cache.tvMap.get(tvdbId));
|
||||
}
|
||||
|
||||
// Try TMDB ID as fallback
|
||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||
return this.cache.tvMap.get(tmdbId);
|
||||
return this._hydrateRequest(this.cache.tvMap.get(tmdbId));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
+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.13"
|
||||
* example: "1.7.20"
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
|
||||
+46
-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.13
|
||||
version: 1.7.20
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
@@ -176,6 +176,27 @@ components:
|
||||
nullable: true
|
||||
description: Tooltip text for Ombi icon ("Request" or "Search")
|
||||
example: "Request"
|
||||
arrLink:
|
||||
type: string
|
||||
nullable: true
|
||||
format: uri
|
||||
description: Sonarr/Radarr show/movie web UI link (admin-only)
|
||||
example: "http://sonarr:8989/series/show-slug"
|
||||
downloadPath:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Save path in download client (admin-only)
|
||||
example: "/downloads/series/show-slug"
|
||||
targetPath:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Target path in library (admin-only)
|
||||
example: "/tv/show-slug"
|
||||
arrInstanceKey:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Sonarr/Radarr instance API key (admin-only)
|
||||
example: "api-key-here"
|
||||
|
||||
DashboardPayload:
|
||||
type: object
|
||||
@@ -795,8 +816,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 +860,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 +904,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:
|
||||
|
||||
+52
-10
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
|
||||
function buildMetadataMaps(snapshot) {
|
||||
const seriesMap = new Map();
|
||||
for (const r of snapshot.sonarrQueue.data.records) {
|
||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||
if (r.series && r.seriesId) {
|
||||
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||
r.series._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
}
|
||||
for (const r of snapshot.sonarrHistory.data.records) {
|
||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||
if (r.series && r.seriesId) {
|
||||
if (!r.series._instanceUrl && r._instanceUrl) {
|
||||
r.series._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
const existing = seriesMap.get(r.seriesId);
|
||||
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
|
||||
seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
}
|
||||
}
|
||||
const moviesMap = new Map();
|
||||
for (const r of snapshot.radarrQueue.data.records) {
|
||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||
if (r.movie && r.movieId) {
|
||||
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||
r.movie._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
}
|
||||
for (const r of snapshot.radarrHistory.data.records) {
|
||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||
if (r.movie && r.movieId) {
|
||||
if (!r.movie._instanceUrl && r._instanceUrl) {
|
||||
r.movie._instanceUrl = r._instanceUrl;
|
||||
}
|
||||
const existing = moviesMap.get(r.movieId);
|
||||
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
|
||||
moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
||||
@@ -686,17 +712,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:
|
||||
|
||||
@@ -215,6 +215,9 @@ async function matchSabSlots(slots, context) {
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
@@ -269,6 +272,9 @@ async function matchSabSlots(slots, context) {
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
@@ -459,6 +465,9 @@ async function matchTorrents(torrents, context) {
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
@@ -505,6 +514,9 @@ async function matchTorrents(torrents, context) {
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
getRadarrInstances,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -349,6 +349,7 @@ describe('GET /api/dashboard/user-downloads', () => {
|
||||
expect(dl.arrQueueId).toBe(1002);
|
||||
expect(dl.arrType).toBe('sonarr');
|
||||
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
|
||||
expect(dl.downloadPath).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -749,12 +750,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 +766,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 +781,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 +813,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 +843,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 +861,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 +885,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 +905,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 +929,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 +953,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 });
|
||||
|
||||
@@ -336,4 +336,47 @@ describe('OmbiClient', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsers', () => {
|
||||
it('should return user array for successful request', async () => {
|
||||
const mockUsers = [
|
||||
{ id: '1', userName: 'Gordon' },
|
||||
{ id: '2', userName: 'Alice' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(200, mockUsers);
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getUsers();
|
||||
|
||||
expect(result).toEqual(mockUsers);
|
||||
});
|
||||
|
||||
it('should return empty array on API error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getUsers();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on network error', async () => {
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.matchHeader('ApiKey', apiKey)
|
||||
.replyWithError('Network error');
|
||||
|
||||
const client = new OmbiClient(baseUrl, apiKey);
|
||||
const result = await client.getUsers();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -766,4 +766,70 @@ describe('OmbiRetriever', () => {
|
||||
expect(stats.age).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydration logic', () => {
|
||||
it('should hydrate requestedUser when missing but requestedUserId is present', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', requestedUserId: 'gordon-id', requestedUser: null }
|
||||
];
|
||||
const mockTvShows = [];
|
||||
const mockUsers = [
|
||||
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.reply(200, mockUsers);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getMovieRequests();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].requestedUser).toBeDefined();
|
||||
expect(result[0].requestedUser.userName).toBe('Gordon');
|
||||
expect(result[0].requestedUser.alias).toBe('G-Man');
|
||||
});
|
||||
|
||||
it('should not overwrite non-empty requestedUser object', async () => {
|
||||
const mockMovies = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Movie 1',
|
||||
requestedUserId: 'gordon-id',
|
||||
requestedUser: { userName: 'ExistingGordon', alias: 'ExistingG' }
|
||||
}
|
||||
];
|
||||
const mockTvShows = [];
|
||||
const mockUsers = [
|
||||
{ id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' }
|
||||
];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Identity/Users')
|
||||
.reply(200, mockUsers);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
const result = await retriever.getMovieRequests();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].requestedUser.userName).toBe('ExistingGordon');
|
||||
expect(result[0].requestedUser.alias).toBe('ExistingG');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user