Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb68bddedb | |||
| 81d0aa82f2 | |||
| 7d7304637c | |||
| d87ad9f1c7 | |||
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 | |||
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e | |||
| 88bfa52eba | |||
| 95e301ef56 | |||
| 3c6791658c | |||
| 9c7dcf55b0 | |||
| afc940aba7 | |||
| 5488969387 | |||
| ec9b1c6d94 | |||
| 3f8970ea99 | |||
| 9491948ec9 | |||
| d4ee3b8ef7 | |||
| 64c872423f | |||
| 86c67bcf29 | |||
| 9548eb41f5 | |||
| d1db3118f0 | |||
| f8aa90011e | |||
| 82b3824658 | |||
| 49e3261b59 | |||
| 2934becf32 | |||
| 6ff660b8af | |||
| 6ac0a8421e | |||
| a021ceba47 | |||
| f8c7e35f31 | |||
| de71580756 | |||
| 2943afdbaf | |||
| 1d571b066d | |||
| db809f2fb3 |
@@ -23,17 +23,34 @@ jobs:
|
|||||||
if [[ "$BRANCH" == develop* ]]; then
|
if [[ "$BRANCH" == develop* ]]; then
|
||||||
# Sanitise branch name for tag: replace slashes with dashes
|
# Sanitise branch name for tag: replace slashes with dashes
|
||||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||||
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
|
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
||||||
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||||
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building develop image tags: ${TAGS}"
|
||||||
else
|
else
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
RELEASE_NAME=${BRANCH#release/}
|
||||||
|
|
||||||
|
# Primary registry tags
|
||||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||||
|
|
||||||
|
# Gitea package registry tags
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||||
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
echo "Building release image tags: ${TAGS}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Log into Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.i3omb.com
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
+2
-1
@@ -11,4 +11,5 @@ data/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.agents/
|
.agents/
|
||||||
.windsurf/
|
.windsurf/
|
||||||
|
scratch/
|
||||||
+53
-4
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
|
|||||||
Both endpoints share identical processing logic:
|
Both endpoints share identical processing logic:
|
||||||
|
|
||||||
```
|
```
|
||||||
Sonarr/Radarr
|
Sonarr/Radarr/Ombi
|
||||||
POST /api/webhook/sonarr
|
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",
|
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
|
||||||
"date": "2026-05-19T10:00:00.000Z", … }
|
"date": "2026-05-19T10:00:00.000Z", … }
|
||||||
│
|
│
|
||||||
@@ -404,6 +404,7 @@ Sonarr/Radarr
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
validateWebhookSecret() ──fail──► 401 Unauthorized
|
validateWebhookSecret() ──fail──► 401 Unauthorized
|
||||||
|
(Checks header or query param)
|
||||||
│ ok
|
│ ok
|
||||||
▼
|
▼
|
||||||
validatePayload() ──fail──► 400 Bad Request
|
validatePayload() ──fail──► 400 Bad Request
|
||||||
@@ -994,6 +995,42 @@ For AI agents and automated tooling, every endpoint includes:
|
|||||||
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
||||||
- Runs the coverage test suite on every push
|
- Runs the coverage test suite on every push
|
||||||
|
|
||||||
|
### 7.5 Real-Time Debug Log Streaming Subsystem
|
||||||
|
|
||||||
|
sofarr provides a togglable, real-time log capturing and streaming engine allowing developer-administrators to view server standard output stream activity and browser console log activity for easy debugging in production.
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Browser ["Browser (SPA)"]
|
||||||
|
console["console.log/warn/error"] --> queue["logQueue (batched)"]
|
||||||
|
queue --> |POST /api/debug/client-logs| ingestionRoute["POST router handler"]
|
||||||
|
end
|
||||||
|
|
||||||
|
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)"]
|
||||||
|
|
||||||
|
ingestionRoute --> clientBuffer["clientLogBuffer (rolling 1000 lines)"]
|
||||||
|
ingestionRoute --> |emit client-log| clientSse["GET /api/debug/client-logs (SSE)"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### In-Process Interceptor (Stdout & Stderr)
|
||||||
|
To guarantee 100% logging completeness without requiring complex and insecure external Docker daemon sockets, the server hooks directly into standard output stream writers `process.stdout.write` and `process.stderr.write` during the process boot cycle.
|
||||||
|
- Custom accumulators process streams, strip ANSI terminal colors (colors are stripped using a standard regex matching all terminal escape codes), and split incoming chunks into structured lines.
|
||||||
|
- Historical lines are appended to a rolling in-memory array `logBuffer` capped at 1,000 entries.
|
||||||
|
- Real-time logging events are broadcasted via a central `EventEmitter` allowing connected SSE stream clients to receive updates instantly.
|
||||||
|
|
||||||
|
#### Client Console Log Capture
|
||||||
|
To assist developers in troubleshooting client-side runtime errors without access to developer consoles (e.g., in embedded WebViews, smart TVs, or custom mobile browser instances), the SPA implements an automatic logging interceptor.
|
||||||
|
- If `/api/debug/status` indicates log streaming is active, the bootstrap process replaces global `console.log`, `console.warn`, and `console.error` methods.
|
||||||
|
- Logs are added to an in-memory queue and flushed in batches using a stateless `POST /api/debug/client-logs` request every 2,000ms (or when the queue size reaches 20 items) to prevent browser thread blocking.
|
||||||
|
- A synchronous cleanup check is registered via standard `beforeunload` to flush any remaining logs using `navigator.sendBeacon()` or `keepalive: true` fetch on page refresh or navigate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Directory Structure
|
## 8. Directory Structure
|
||||||
@@ -1188,7 +1225,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| Concern | Mechanism |
|
| Concern | Mechanism |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
| **Secret validation** | Every webhook request must carry `X-Sofarr-Webhook-Secret` matching `SOFARR_WEBHOOK_SECRET`. Absent or wrong secret → `401`. Webhook endpoints function outside the CSRF middleware (they are not browser-initiated). |
|
||||||
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). |
|
| **Rate limiting** | Dedicated `webhookLimiter`: 60 req/min per IP (stricter than the general 300 req/15 min limiter). Bypassed in testing/dev via `SKIP_RATE_LIMIT=1`. |
|
||||||
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
| **Payload validation** | `validatePayload()` enforces: JSON object body, `eventType` as a non-empty string ≤ 64 chars, `eventType` in the allowlist, `instanceName` as string if present. Rejects with `400` on any violation. |
|
||||||
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
| **Replay protection** | `isReplay()` caches a composite key `{eventType}:{instanceName}:{date}` for 5 minutes. Duplicate events within that window are acknowledged with `200 { received: true, duplicate: true }` and not processed. |
|
||||||
|
|
||||||
@@ -1196,13 +1233,25 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
|
|
||||||
| Concern | Mechanism |
|
| Concern | Mechanism |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| **Rate limiting** | 300 req/15 min general (all API routes); 10 failed attempts/15 min login limiter; 60 req/1 min webhook limiter. |
|
| **Rate limiting** | General API limiter (300 req/15 min on `/api/*` prefix) exempts `/api/dashboard/cover-art` requests; Login limiter (10 attempts/15 min) employs `skipSuccessfulRequests: true` to count failed attempts only; Webhook limiter runs 60 req/1 min on `/api/webhook/*` endpoints; Root `/health` and `/ready` probes are entirely exempt. All limiters bypassable in testing via `SKIP_RATE_LIMIT=1` or `createApp({ skipRateLimits: true })`. |
|
||||||
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
| **Secret leakage** | `sanitizeError()` (`server/utils/sanitizeError.js`) redacts secrets from error messages and logs: URL query-param secrets (`apikey=`, `token=`), HTTP auth headers (`Authorization:`, `X-Emby-Authorization:`), Bearer tokens, and basic-auth credentials in URLs. |
|
||||||
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
| **HTTP headers** | Helmet v7: CSP with per-request nonce (`crypto.randomBytes(16)` for inline styles/scripts), HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`. |
|
||||||
| **Body size** | `express.json` body limit: 64 KB. |
|
| **Body size** | `express.json` body limit: 64 KB. |
|
||||||
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
|
| **Authorisation matrix** | Regular users see only their own downloads. Admins can view all users, see paths and *arr links, and blocklist any download. Non-admins can only blocklist when import issues exist or (for qBittorrent) the torrent is >1 h old with <100% availability. |
|
||||||
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
|
| **Container security** | Docker image runs as the non-root `node` user (UID 1000). `/app/data` is owned by `node`. |
|
||||||
|
|
||||||
|
### 10.4 Debug Log Streaming Security
|
||||||
|
|
||||||
|
Access to the debug log stream endpoints (`/api/debug/server-logs` and `/api/debug/client-logs`) is secured via a strict multi-layered policy:
|
||||||
|
|
||||||
|
| Layer | Mechanism |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Feature lock toggle** | Strictly disabled by default; only enableable when environment variable `ENABLE_LOG_STREAM=true` is set. |
|
||||||
|
| **Subnet filtering** | If environment variable `LOG_ALLOW_SUBNETS` is configured (using standard comma-separated CIDR notation), incoming client IPs (`req.ip`) are parsed and validated via the `ipaddr.js` library. Unmatched subnets receive a `403 Forbidden` response immediately. |
|
||||||
|
| **Fast-path webhook bypass** | A valid webhook secret bypasses the active Emby authentication layer when provided on the `X-Webhook-Secret` header. |
|
||||||
|
| **Active Emby session** | Validates that an active `emby_user` session cookie is present and that the authenticated Emby user is an administrator. |
|
||||||
|
| **Emby basic auth fallback** | Allows Basic Authentication headers by performing an on-demand asynchronous credentials check against Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to assert active policy administrator status. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Technology Stack
|
## 11. Technology Stack
|
||||||
|
|||||||
+143
@@ -4,6 +4,149 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
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).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
|
||||||
|
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
|
||||||
|
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.12] - 2026-05-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Real-Time Server-Side Log Streaming (`GET /api/debug/server-logs`)** — Introduced an in-process output stream capturer that intercepts standard output (`stdout`) and standard error (`stderr`), strips terminal ANSI escape codes, maintains a rolling 1000-line buffer, and streams live operational logs via Server-Sent Events (SSE). Resolves Gitea Issue [#45](https://git.i3omb.com/Gandalf/sofarr/issues/45).
|
||||||
|
- **Real-Time Client-Side Console Log Stream (`GET & POST /api/debug/client-logs`)** — Added a browser-side console override in the frontend SPA that intercepts `console.log`, `console.warn`, and `console.error` calls. Logs are queued and posted to `/api/debug/client-logs` in optimized batches every 2000ms (or when the queue reaches 20 items), which are then buffered and streamed over SSE to debugging developers. Resolves Gitea Issue [#46](https://git.i3omb.com/Gandalf/sofarr/issues/46).
|
||||||
|
- **Subnet IP Filtering (`LOG_ALLOW_SUBNETS`)** — Implemented an optional CIDR-based subnet validation check using `ipaddr.js` to restrict access to debug logs endpoints to specific internal IP ranges (e.g. `127.0.0.1/32,192.168.1.0/24`). Works natively with reverse proxies when `TRUST_PROXY` is enabled.
|
||||||
|
- **Multi-Layered Security Bypass & Auth** — Debug endpoints are guarded globally by `ENABLE_LOG_STREAM=true` (off by default) and require Emby admin sessions, standard HTTP Basic Auth fallback checks against Emby, or high-priority bypass via header `X-Webhook-Secret`.
|
||||||
|
- **Swagger UI Specification Coverage** — Fully documented all four new endpoints, headers, and schemas in `server/openapi.yaml`, with automated verification integrated inside `swagger-coverage.test.js`.
|
||||||
|
- **Architectural & Developer Guides** — Updated `ARCHITECTURE.md` (with system overview, diagrams, stdout/stderr hooks, and threat mitigations) and `README.md` (deploy instructions and querying parameters).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.11] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID).
|
||||||
|
- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved.
|
||||||
|
- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.10] - 2026-05-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.9] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook PascalCase payload parsing (Re-release)** — Correctly packaged and released the Ombi webhook parsing logic to handle PascalCase property names (e.g., `NotificationType`, `RequestId`) sent by C#/.NET applications. This ensures standard Ombi webhook integrations function correctly. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.8] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook PascalCase payload parsing** — Updated the Ombi webhook parsing logic to correctly handle payloads formatted with PascalCase property names (e.g. `NotificationType`, `RequestId`), which are often sent by C#/.NET applications. This ensures that new requests generated via Ombi's standard webhook integrations are correctly processed and displayed in the frontend dashboard. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.7] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.6] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
|
||||||
|
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.5] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.4] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook registration in production** — Fixed a bug where `/api/ombi/webhook/enable` and other `/api/ombi/*` endpoints returned `404 (Not Found)` in production. The core Express app factory (`server/app.js`) registered the router, but the production server entry point (`server/index.js`) duplicated Express setup instead of utilizing the factory, omitting the `ombiRoutes` registration entirely. Re-registered the router in `server/index.js`, resolves Gitea Issue [#40](https://git.i3omb.com/Gandalf/sofarr/issues/40).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.7.3] - 2026-05-23
|
## [1.7.3] - 2026-05-23
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ PORT=3001 # Server port
|
|||||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||||
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||||
# Set to 0 or "off" to disable (on-demand mode)
|
# Set to 0 or "off" to disable (on-demand mode)
|
||||||
|
|
||||||
|
# Debug Log Streaming Subsystem
|
||||||
|
ENABLE_LOG_STREAM=false # Set to true to enable logs debugging routes
|
||||||
|
LOG_ALLOW_SUBNETS=127.0.0.1/32 # Comma-separated allowed CIDR blocks (e.g. 127.0.0.1/32,192.168.1.0/24)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Webhooks & Smart Polling
|
### Webhooks & Smart Polling
|
||||||
@@ -441,6 +445,12 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
|
|||||||
### History
|
### History
|
||||||
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||||
|
|
||||||
|
### Debug Logs (requires ENABLE_LOG_STREAM=true)
|
||||||
|
- `GET /api/debug/status` — Get runtime log stream configurations (public)
|
||||||
|
- `GET /api/debug/server-logs` — **SSE stream**: push server `stdout`/`stderr` in real-time (requires auth & subnet check)
|
||||||
|
- `GET /api/debug/client-logs` — **SSE stream**: push ingested frontend console logs in real-time (requires auth & subnet check)
|
||||||
|
- `POST /api/debug/client-logs` — Ingest batched frontend console logs (requires auth & subnet check)
|
||||||
|
|
||||||
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`)
|
||||||
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
- `POST /api/webhook/sonarr` — receive Sonarr webhook events
|
||||||
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
- `POST /api/webhook/radarr` — receive Radarr webhook events
|
||||||
|
|||||||
+8
-7
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 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 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/*` |
|
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
||||||
@@ -162,12 +162,13 @@ server {
|
|||||||
|
|
||||||
## Rate Limits
|
## Rate Limits
|
||||||
|
|
||||||
| Endpoint | Limit |
|
| Endpoint | Limit | Details & Exemptions |
|
||||||
|----------|-------|
|
|----------|-------|----------------------|
|
||||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
| `POST /api/auth/login` | 10 attempts per 15 min per IP | **Only failed attempts count** (`skipSuccessfulRequests: true`). Successful requests are not counted. |
|
||||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
| All `/api/*` routes | 300 requests per 15 min per IP | General rate limiting. **Exempts `/api/dashboard/cover-art` requests** to avoid page layout image loading exhaustion. |
|
||||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
| `POST /api/webhook/*` | 60 requests per 1 min per IP | Webhook-specific limiter, stricter than general. |
|
||||||
| `GET /api/swagger` | No rate limit (public documentation) |
|
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
|
||||||
|
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
|
|||||||
arrInstanceUrl: download.arrInstanceUrl,
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
arrInstanceKey: download.arrInstanceKey,
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
arrContentId: download.arrContentId,
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentIds: download.arrContentIds,
|
||||||
|
arrSeriesId: download.arrSeriesId,
|
||||||
arrContentType: download.arrContentType
|
arrContentType: download.arrContentType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import { initThemeSwitcher } from './ui/theme.js';
|
|||||||
import { initTabs, goHome } from './ui/tabs.js';
|
import { initTabs, goHome } from './ui/tabs.js';
|
||||||
import { handleShowAllToggle } from './sse.js';
|
import { handleShowAllToggle } from './sse.js';
|
||||||
import { loadAppVersion } from './api.js';
|
import { loadAppVersion } from './api.js';
|
||||||
|
import { initClientLogCapture } from './utils/clientLogCapture.js';
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initialize client console log capturing early
|
||||||
|
initClientLogCapture();
|
||||||
|
|
||||||
// Login form
|
// Login form
|
||||||
const loginForm = document.getElementById('login-form');
|
const loginForm = document.getElementById('login-form');
|
||||||
if (loginForm) {
|
if (loginForm) {
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ export async function handleBlocklistSearchClick(btn, download) {
|
|||||||
arrInstanceUrl: download.arrInstanceUrl,
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
arrInstanceKey: download.arrInstanceKey,
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
arrContentId: download.arrContentId,
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentIds: download.arrContentIds,
|
||||||
|
arrSeriesId: download.arrSeriesId,
|
||||||
arrContentType: download.arrContentType,
|
arrContentType: download.arrContentType,
|
||||||
isAdmin: state.isAdmin,
|
isAdmin: state.isAdmin,
|
||||||
canBlocklist: download.canBlocklist
|
canBlocklist: download.canBlocklist
|
||||||
|
|||||||
@@ -118,18 +118,22 @@ export function renderStatusPanel(data, panel) {
|
|||||||
const wh = data.webhooks;
|
const wh = data.webhooks;
|
||||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||||
|
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
|
||||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||||
|
const ombiEvents = wh.ombi?.eventsReceived || 0;
|
||||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||||
|
const ombiPolls = wh.ombi?.pollsSkipped || 0;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="status-card">
|
<div class="status-card">
|
||||||
<div class="status-card-title">Webhooks</div>
|
<div class="status-card-title">Webhooks</div>
|
||||||
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
<div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
|
||||||
|
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
const logQueue = [];
|
||||||
|
const MAX_QUEUE_SIZE = 20;
|
||||||
|
const FLUSH_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
// Original console functions
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
let isSending = false;
|
||||||
|
let isInitialized = false;
|
||||||
|
let flushInterval = null;
|
||||||
|
|
||||||
|
function formatArgs(args) {
|
||||||
|
return args.map(arg => {
|
||||||
|
if (arg === null) return 'null';
|
||||||
|
if (arg === undefined) return 'undefined';
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return `${arg.name}: ${arg.message}\n${arg.stack || ''}`;
|
||||||
|
}
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(level, args) {
|
||||||
|
const formattedMsg = formatArgs(args);
|
||||||
|
|
||||||
|
// Still write to the developer console!
|
||||||
|
if (level === 'info') originalLog.apply(console, args);
|
||||||
|
else if (level === 'warn') originalWarn.apply(console, args);
|
||||||
|
else if (level === 'error') originalError.apply(console, args);
|
||||||
|
|
||||||
|
// Guard against infinite loop during logs dispatching
|
||||||
|
if (isSending) return;
|
||||||
|
|
||||||
|
logQueue.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
message: formattedMsg
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush immediately if queue is full
|
||||||
|
if (logQueue.length >= MAX_QUEUE_SIZE) {
|
||||||
|
flushQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushQueue() {
|
||||||
|
if (logQueue.length === 0 || isSending) return;
|
||||||
|
|
||||||
|
isSending = true;
|
||||||
|
const batch = [...logQueue];
|
||||||
|
logQueue.length = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/debug/client-logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(batch),
|
||||||
|
// keepalive allows request to survive page unload
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
originalError.call(console, '[clientLogCapture] Ingestion server returned error status:', response.status);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
originalError.call(console, '[clientLogCapture] Ingestion post request failed:', err.message);
|
||||||
|
} finally {
|
||||||
|
isSending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform a fast/unblocked payload flush using sendBeacon on page unload
|
||||||
|
function flushOnUnload() {
|
||||||
|
if (logQueue.length === 0) return;
|
||||||
|
|
||||||
|
const batch = [...logQueue];
|
||||||
|
logQueue.length = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
|
||||||
|
navigator.sendBeacon('/api/debug/client-logs', blob);
|
||||||
|
} catch (err) {
|
||||||
|
// sendBeacon failure, fallback to synchronous fetch with keepalive if available
|
||||||
|
try {
|
||||||
|
fetch('/api/debug/client-logs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(batch),
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initClientLogCapture() {
|
||||||
|
if (isInitialized) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check if the server toggle for logging is active
|
||||||
|
const response = await fetch('/api/debug/status');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.enabled === true) {
|
||||||
|
// 2. Override global console methods
|
||||||
|
console.log = (...args) => enqueue('info', args);
|
||||||
|
console.warn = (...args) => enqueue('warn', args);
|
||||||
|
console.error = (...args) => enqueue('error', args);
|
||||||
|
|
||||||
|
// 3. Set interval for batch updates
|
||||||
|
flushInterval = setInterval(flushQueue, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// 4. Setup beforeunload listener for clean flushing
|
||||||
|
window.addEventListener('beforeunload', flushOnUnload);
|
||||||
|
|
||||||
|
isInitialized = true;
|
||||||
|
console.log('[clientLogCapture] Browser console logging interceptor initialized successfully.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
originalError.call(console, '[clientLogCapture] Check failed to start interceptor:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.3",
|
"version": "1.7.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.3",
|
"version": "1.7.19",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.3",
|
"version": "1.7.19",
|
||||||
"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",
|
"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",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+19
-1
@@ -1888,6 +1888,23 @@ body {
|
|||||||
|
|
||||||
/* ===== Mobile ===== */
|
/* ===== Mobile ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.main-tabs {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-container {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-meta {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -2234,6 +2251,7 @@ body {
|
|||||||
.requests-list {
|
.requests-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-card {
|
.request-card {
|
||||||
@@ -2245,6 +2263,7 @@ body {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-card:hover {
|
.request-card:hover {
|
||||||
@@ -2273,7 +2292,6 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
|||||||
const swaggerJsdoc = require('swagger-jsdoc');
|
const swaggerJsdoc = require('swagger-jsdoc');
|
||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||||
const sonarrRoutes = require('./routes/sonarr');
|
const sonarrRoutes = require('./routes/sonarr');
|
||||||
@@ -26,6 +27,7 @@ const historyRoutes = require('./routes/history');
|
|||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const webhookRoutes = require('./routes/webhook');
|
const webhookRoutes = require('./routes/webhook');
|
||||||
const ombiRoutes = require('./routes/ombi');
|
const ombiRoutes = require('./routes/ombi');
|
||||||
|
const debugRoutes = require('./routes/debug');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
function createApp({ skipRateLimits = false } = {}) {
|
function createApp({ skipRateLimits = false } = {}) {
|
||||||
@@ -96,6 +98,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||||
message: { error: 'Too many requests, please try again later' }
|
message: { error: 'Too many requests, please try again later' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,13 +129,17 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* type: number
|
* type: number
|
||||||
* description: Server uptime in seconds
|
* description: Server uptime in seconds
|
||||||
* example: 3600.5
|
* example: 3600.5
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* description: sofarr version
|
||||||
|
* example: "1.7.19"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
* source: curl http://localhost:3001/health
|
* source: curl http://localhost:3001/health
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime() });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,6 +219,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api', apiLimiter);
|
app.use('/api', apiLimiter);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/webhook', webhookRoutes);
|
app.use('/api/webhook', webhookRoutes);
|
||||||
|
app.use('/api/debug', debugRoutes);
|
||||||
|
|
||||||
// CSRF protection for all state-changing API requests below
|
// CSRF protection for all state-changing API requests below
|
||||||
app.use('/api', verifyCsrf);
|
app.use('/api', verifyCsrf);
|
||||||
|
|||||||
@@ -87,10 +87,11 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh cached data from Ombi API
|
* Refresh cached data from Ombi API
|
||||||
|
* @param {boolean} force - Whether to force a refresh regardless of TTL
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async refreshCache() {
|
async refreshCache(force = false) {
|
||||||
if (!this.isCacheExpired()) {
|
if (!force && !this.isCacheExpired()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,19 +142,21 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all movie requests
|
* Get all movie requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Array>} Array of movie request objects
|
* @returns {Promise<Array>} Array of movie request objects
|
||||||
*/
|
*/
|
||||||
async getMovieRequests() {
|
async getMovieRequests(force = false) {
|
||||||
await this.refreshCache();
|
await this.refreshCache(force);
|
||||||
return this.cache.movieRequests;
|
return this.cache.movieRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all TV requests
|
* Get all TV requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Array>} Array of TV request objects
|
* @returns {Promise<Array>} Array of TV request objects
|
||||||
*/
|
*/
|
||||||
async getTvRequests() {
|
async getTvRequests(force = false) {
|
||||||
await this.refreshCache();
|
await this.refreshCache(force);
|
||||||
return this.cache.tvRequests;
|
return this.cache.tvRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -13,6 +13,8 @@ const swaggerJsdoc = require('swagger-jsdoc');
|
|||||||
const YAML = require('yamljs');
|
const YAML = require('yamljs');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
require('./utils/loadSecrets')();
|
require('./utils/loadSecrets')();
|
||||||
|
const logCapture = require('./utils/logCapture');
|
||||||
|
logCapture.init();
|
||||||
const { version } = require('../package.json');
|
const { version } = require('../package.json');
|
||||||
|
|
||||||
// Setup logging with levels
|
// Setup logging with levels
|
||||||
@@ -89,6 +91,8 @@ const statusRoutes = require('./routes/status');
|
|||||||
const historyRoutes = require('./routes/history');
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const webhookRoutes = require('./routes/webhook');
|
const webhookRoutes = require('./routes/webhook');
|
||||||
|
const ombiRoutes = require('./routes/ombi');
|
||||||
|
const debugRoutes = require('./routes/debug');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
const { validateInstanceUrl } = require('./utils/config');
|
const { validateInstanceUrl } = require('./utils/config');
|
||||||
@@ -205,6 +209,7 @@ const apiLimiter = rateLimit({
|
|||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
max: 300, // 300 requests per IP per window (generous for polling)
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||||
message: { error: 'Too many requests, please try again later' }
|
message: { error: 'Too many requests, please try again later' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.6.0"
|
* example: "1.7.19"
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
@@ -365,6 +370,7 @@ function serveIndex(req, res) {
|
|||||||
app.use('/api', apiLimiter);
|
app.use('/api', apiLimiter);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/webhook', webhookRoutes);
|
app.use('/api/webhook', webhookRoutes);
|
||||||
|
app.use('/api/debug', debugRoutes);
|
||||||
|
|
||||||
// All routes below this point require CSRF validation on mutating methods
|
// All routes below this point require CSRF validation on mutating methods
|
||||||
app.use('/api', verifyCsrf);
|
app.use('/api', verifyCsrf);
|
||||||
@@ -372,6 +378,7 @@ app.use('/api/sabnzbd', sabnzbdRoutes);
|
|||||||
app.use('/api/sonarr', sonarrRoutes);
|
app.use('/api/sonarr', sonarrRoutes);
|
||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
|
app.use('/api/ombi', ombiRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
app.use('/api/status', statusRoutes);
|
app.use('/api/status', statusRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const axios = require('axios');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const ipaddr = require('ipaddr.js');
|
||||||
|
|
||||||
|
function getEmbyUrl() {
|
||||||
|
return process.env.EMBY_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIpAllowed(clientIp, allowedSubnetsStr) {
|
||||||
|
if (!allowedSubnetsStr) return true;
|
||||||
|
try {
|
||||||
|
const clientIpParsed = ipaddr.parse(clientIp);
|
||||||
|
const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
for (const subnet of subnets) {
|
||||||
|
let rangeStr = subnet;
|
||||||
|
let bits = null;
|
||||||
|
if (subnet.includes('/')) {
|
||||||
|
const parts = subnet.split('/');
|
||||||
|
rangeStr = parts[0];
|
||||||
|
bits = parseInt(parts[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeIpParsed = ipaddr.parse(rangeStr);
|
||||||
|
|
||||||
|
if (bits === null) {
|
||||||
|
// Exact IP match
|
||||||
|
if (clientIpParsed.toString() === rangeIpParsed.toString()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Handle IPv4 mapped IPv6 address case
|
||||||
|
if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||||
|
if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match with subnet bits
|
||||||
|
if (clientIpParsed.kind() === rangeIpParsed.kind()) {
|
||||||
|
if (clientIpParsed.match(rangeIpParsed, bits)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
|
||||||
|
// Handle IPv4 mapped IPv6 address case matching IPv4 range
|
||||||
|
if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logStreamAuth(req, res, next) {
|
||||||
|
// 1. Subnet IP Filtering (First Priority)
|
||||||
|
const allowedSubnets = process.env.LOG_ALLOW_SUBNETS;
|
||||||
|
if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) {
|
||||||
|
console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`);
|
||||||
|
return res.status(403).json({ error: `Access denied from IP: ${req.ip}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Webhook Secret Bypass (High Priority)
|
||||||
|
const secretHeader = req.headers['x-webhook-secret'];
|
||||||
|
const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
if (configuredSecret && secretHeader === configuredSecret) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Session Cookie
|
||||||
|
const signed = !!process.env.COOKIE_SECRET;
|
||||||
|
const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||||
|
if (rawCookie && rawCookie !== false) {
|
||||||
|
try {
|
||||||
|
const u = JSON.parse(rawCookie);
|
||||||
|
if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) {
|
||||||
|
req.user = u;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore JSON parse errors, fallback to basic auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Basic Authentication Fallback
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Basic ')) {
|
||||||
|
try {
|
||||||
|
const credentialsBase64 = authHeader.substring(6);
|
||||||
|
const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8');
|
||||||
|
const colonIdx = credentialsStr.indexOf(':');
|
||||||
|
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const username = credentialsStr.substring(0, colonIdx).trim();
|
||||||
|
const password = credentialsStr.substring(colonIdx + 1);
|
||||||
|
|
||||||
|
if (username && password) {
|
||||||
|
const embyUrl = getEmbyUrl();
|
||||||
|
if (!embyUrl) {
|
||||||
|
console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured');
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||||
|
return res.status(401).json({ error: 'Authentication service unavailable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate with Emby using stable DeviceId derived from username
|
||||||
|
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`);
|
||||||
|
const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, {
|
||||||
|
Username: username,
|
||||||
|
Pw: password
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const authData = authResponse.data;
|
||||||
|
const userId = authData.User.Id || authData.User.id;
|
||||||
|
|
||||||
|
// Fetch detailed profile to verify administrator status
|
||||||
|
const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, {
|
||||||
|
headers: {
|
||||||
|
'X-MediaBrowser-Token': authData.AccessToken
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = userResponse.data;
|
||||||
|
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`);
|
||||||
|
req.user = { id: user.Id, name: user.Name, isAdmin: true };
|
||||||
|
return next();
|
||||||
|
} else {
|
||||||
|
console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[logStreamAuth] Emby authentication error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Unauthorized / Access Denied
|
||||||
|
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = logStreamAuth;
|
||||||
+232
-8
@@ -12,13 +12,17 @@ info:
|
|||||||
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
- General API: 300 requests per 15 minutes per IP
|
To protect the system from resource exhaustion, rate limiters are enforced at different levels:
|
||||||
- Login: 10 failed attempts per 15 minutes per IP
|
- **General API Limiter**: Enforces a limit of **300 requests per 15 minutes** per IP across all `/api/*` endpoints.
|
||||||
- Webhooks: 60 requests per minute per IP
|
- *Exemption:* Requests starting with `/api/dashboard/cover-art` are completely exempted from this limit to avoid normal dashboard image browsing triggering blocks.
|
||||||
|
- **Login Rate Limiter**: Enforces a strict limit of **10 attempts per 15 minutes** per IP on `POST /api/auth/login`.
|
||||||
|
- *Exemption:* This limiter only tracks and counts *failed* login attempts (`skipSuccessfulRequests: true`). Successful logins do not count towards the lockout threshold.
|
||||||
|
- **Webhook Limiter**: Enforces a limit of **60 requests per minute** per IP on stateful webhook receiver endpoints (`/api/webhook/*`).
|
||||||
|
- **Health and Readiness Probes**: The public `/health` and `/ready` endpoints are mounted at the root directory level rather than under `/api/*` and are completely exempt from both rate limiting and authentication controls.
|
||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.6.0
|
version: 1.7.19
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
@@ -172,6 +176,27 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
description: Tooltip text for Ombi icon ("Request" or "Search")
|
description: Tooltip text for Ombi icon ("Request" or "Search")
|
||||||
example: "Request"
|
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:
|
DashboardPayload:
|
||||||
type: object
|
type: object
|
||||||
@@ -276,7 +301,6 @@ components:
|
|||||||
- arrQueueId
|
- arrQueueId
|
||||||
- arrType
|
- arrType
|
||||||
- arrInstanceUrl
|
- arrInstanceUrl
|
||||||
- arrContentId
|
|
||||||
- arrContentType
|
- arrContentType
|
||||||
properties:
|
properties:
|
||||||
arrQueueId:
|
arrQueueId:
|
||||||
@@ -301,6 +325,16 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
description: episodeId (Sonarr) or movieId (Radarr)
|
description: episodeId (Sonarr) or movieId (Radarr)
|
||||||
example: 456
|
example: 456
|
||||||
|
arrContentIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
description: Array of episodeIds for multi-episode packs (Sonarr)
|
||||||
|
example: [456, 457]
|
||||||
|
arrSeriesId:
|
||||||
|
type: integer
|
||||||
|
description: seriesId for fallback automatic series search (Sonarr)
|
||||||
|
example: 789
|
||||||
arrContentType:
|
arrContentType:
|
||||||
type: string
|
type: string
|
||||||
enum: [episode, movie]
|
enum: [episode, movie]
|
||||||
@@ -782,8 +816,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Sonarr 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: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -819,8 +860,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Radarr 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: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -856,8 +904,15 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Webhook]
|
tags: [Webhook]
|
||||||
summary: Ombi 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: []
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: secret
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -1743,3 +1798,172 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/api/debug/status:
|
||||||
|
get:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Check if log streaming is enabled
|
||||||
|
description: Returns whether the log streaming feature is enabled at runtime. No authentication required.
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Feature status returned successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
|
||||||
|
/api/debug/server-logs:
|
||||||
|
get:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Stream server logs in real-time
|
||||||
|
description: |
|
||||||
|
Streams server-side standard output (stdout/stderr) logs via Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
**Security Policies:**
|
||||||
|
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||||
|
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||||
|
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||||
|
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||||
|
security:
|
||||||
|
- CookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: X-Webhook-Secret
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Fast-track webhook secret bypass token
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Event stream established
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/api/debug/client-logs:
|
||||||
|
get:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Stream client console logs in real-time
|
||||||
|
description: |
|
||||||
|
Streams client-side console logs via Server-Sent Events (SSE).
|
||||||
|
|
||||||
|
**Security Policies:**
|
||||||
|
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||||
|
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||||
|
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||||
|
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||||
|
security:
|
||||||
|
- CookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: X-Webhook-Secret
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Fast-track webhook secret bypass token
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Event stream established
|
||||||
|
content:
|
||||||
|
text/event-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
post:
|
||||||
|
tags: [Debug]
|
||||||
|
summary: Ingest client console logs
|
||||||
|
description: |
|
||||||
|
Ingests a batch of client-side console logs into the server-side rolling clientLogBuffer.
|
||||||
|
|
||||||
|
**Security Policies:**
|
||||||
|
- **Subnet IP Filtering**: IP must match subnet ranges configured in `LOG_ALLOW_SUBNETS` (if set).
|
||||||
|
- **Bypass Header**: Direct bypass if `X-Webhook-Secret` header matches the configured `SOFARR_WEBHOOK_SECRET`.
|
||||||
|
- **Session Auth**: Emby session cookie `emby_user` where user is an administrator.
|
||||||
|
- **Basic Auth Fallback**: `Authorization` header containing credentials of a valid Emby administrator.
|
||||||
|
security:
|
||||||
|
- CookieAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: X-Webhook-Secret
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Fast-track webhook secret bypass token
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [level, message]
|
||||||
|
properties:
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
level:
|
||||||
|
type: string
|
||||||
|
enum: [info, warn, error]
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Logs ingested successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
'400':
|
||||||
|
description: Invalid JSON body
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (feature disabled or IP not in subnet allowlist)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
|||||||
+64
-16
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
|
|||||||
function buildMetadataMaps(snapshot) {
|
function buildMetadataMaps(snapshot) {
|
||||||
const seriesMap = new Map();
|
const seriesMap = new Map();
|
||||||
for (const r of snapshot.sonarrQueue.data.records) {
|
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) {
|
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();
|
const moviesMap = new Map();
|
||||||
for (const r of snapshot.radarrQueue.data.records) {
|
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) {
|
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 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]));
|
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
||||||
@@ -676,27 +702,43 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentIds, arrSeriesId, arrContentType } = req.body;
|
||||||
|
|
||||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
|
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
|
||||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||||
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the download to verify permission
|
// Look up the queue record directly from the *arr cache.
|
||||||
const allDownloads = await downloadClientRegistry.getAllDownloads();
|
// downloadClientRegistry.getAllDownloads() returns raw download-client data
|
||||||
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
|
// (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) {
|
if (!queueRecord) {
|
||||||
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
|
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
|
||||||
return res.status(403).json({ error: 'Download not found or permission denied' });
|
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
|
// 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 });
|
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' });
|
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
|
||||||
}
|
}
|
||||||
@@ -724,8 +766,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
// Step 2: Trigger a new automatic search
|
// Step 2: Trigger a new automatic search
|
||||||
let commandBody;
|
let commandBody;
|
||||||
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
||||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
if (arrContentId) {
|
||||||
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||||
|
} else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) {
|
||||||
|
commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) };
|
||||||
|
} else if (arrSeriesId) {
|
||||||
|
commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) };
|
||||||
|
}
|
||||||
|
} else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) {
|
||||||
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +785,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
const { pollAllServices } = require('../utils/poller');
|
const { pollAllServices } = require('../utils/poller');
|
||||||
pollAllServices().catch(() => {});
|
pollAllServices().catch(() => {});
|
||||||
|
|
||||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId || 'none'} by ${user.name}`);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const logStreamAuth = require('../middleware/logStreamAuth');
|
||||||
|
const {
|
||||||
|
logEmitter,
|
||||||
|
logBuffer,
|
||||||
|
clientLogBuffer,
|
||||||
|
ingestClientLogs
|
||||||
|
} = require('../utils/logCapture');
|
||||||
|
|
||||||
|
// Public status check (no auth, no 403 block, returns standard config state)
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global toggle check
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (process.env.ENABLE_LOG_STREAM !== 'true') {
|
||||||
|
return res.status(403).json({ error: 'Log streaming feature is disabled' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enforce subnet and authentication validations on all debug routes
|
||||||
|
router.use(logStreamAuth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/debug/server-logs
|
||||||
|
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
|
||||||
|
*/
|
||||||
|
router.get('/server-logs', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send historical server logs buffer first
|
||||||
|
for (const line of logBuffer) {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gracefully close for integration testing
|
||||||
|
if (req.query.testClose === 'true') {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendLog = (line) => {
|
||||||
|
try {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[debugRoutes] Error sending server log line:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logEmitter.on('server-log', sendLog);
|
||||||
|
|
||||||
|
// 25s heartbeat comment to prevent proxy timeouts
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(': heartbeat\n\n');
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
logEmitter.off('server-log', sendLog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/debug/client-logs
|
||||||
|
* Exposes a real-time SSE stream of ingested client-side console logs.
|
||||||
|
*/
|
||||||
|
router.get('/client-logs', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send historical client logs buffer first
|
||||||
|
for (const line of clientLogBuffer) {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gracefully close for integration testing
|
||||||
|
if (req.query.testClose === 'true') {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendClientLog = (line) => {
|
||||||
|
try {
|
||||||
|
res.write(`data: ${line}\n\n`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[debugRoutes] Error sending client log line:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logEmitter.on('client-log', sendClientLog);
|
||||||
|
|
||||||
|
// 25s heartbeat comment to prevent proxy timeouts
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(': heartbeat\n\n');
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
logEmitter.off('client-log', sendClientLog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/debug/client-logs
|
||||||
|
* Receives batches of frontend console logs to store in buffer and emit.
|
||||||
|
*/
|
||||||
|
router.post('/client-logs', (req, res) => {
|
||||||
|
const logs = req.body;
|
||||||
|
if (!Array.isArray(logs)) {
|
||||||
|
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ingestClientLogs(logs);
|
||||||
|
return res.status(200).json({ success: true, count: logs.length });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[debugRoutes] Ingestion failed:', err.message);
|
||||||
|
return res.status(500).json({ error: 'Internal server error during ingestion' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+20
-2
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
// initialize() is idempotent - cheap no-op if already initialized
|
// initialize() is idempotent - cheap no-op if already initialized
|
||||||
await arrRetrieverRegistry.initialize();
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
// Filter by user if not admin or if showAll is false
|
// Filter by user if not admin or if showAll is false
|
||||||
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
||||||
@@ -221,13 +221,31 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||||
|
|
||||||
// Call Ombi API to register webhook
|
// Call Ombi API to register webhook
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// Get existing settings to retrieve the database ID
|
||||||
|
const currentRes = await axios.get(
|
||||||
|
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'ApiKey': ombiInst.apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch(err => {
|
||||||
|
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
|
||||||
|
return { data: {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentConfig = currentRes.data || {};
|
||||||
|
const settingsId = currentConfig.id || 0;
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||||
{
|
{
|
||||||
|
id: settingsId,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
webhookUrl: webhookUrl,
|
webhookUrl: webhookUrl,
|
||||||
applicationToken: ombiInst.apiKey
|
applicationToken: ombiInst.apiKey
|
||||||
|
|||||||
+12
-4
@@ -4,9 +4,9 @@ const router = express.Router();
|
|||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
||||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||||
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
@@ -121,6 +121,7 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
// Check webhook configuration for each service
|
// Check webhook configuration for each service
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
||||||
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
||||||
@@ -128,15 +129,21 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
const radarrWebhookConfigured = radarrInstances.length > 0
|
const radarrWebhookConfigured = radarrInstances.length > 0
|
||||||
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
||||||
: false;
|
: false;
|
||||||
|
const ombiWebhookConfigured = ombiInstances.length > 0
|
||||||
|
? await checkOmbiWebhookConfigured(ombiInstances[0])
|
||||||
|
: false;
|
||||||
|
|
||||||
// Find Sonarr and Radarr metrics from instances
|
// Find Sonarr, Radarr, and Ombi metrics from instances
|
||||||
const sonarrMetrics = {};
|
const sonarrMetrics = {};
|
||||||
const radarrMetrics = {};
|
const radarrMetrics = {};
|
||||||
|
const ombiMetrics = {};
|
||||||
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
||||||
if (url.includes('sonarr')) {
|
if (url.includes('sonarr')) {
|
||||||
sonarrMetrics[url] = metrics;
|
sonarrMetrics[url] = metrics;
|
||||||
} else if (url.includes('radarr')) {
|
} else if (url.includes('radarr')) {
|
||||||
radarrMetrics[url] = metrics;
|
radarrMetrics[url] = metrics;
|
||||||
|
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
|
||||||
|
ombiMetrics[url] = metrics;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +163,8 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
cache: cacheStats,
|
cache: cacheStats,
|
||||||
webhooks: {
|
webhooks: {
|
||||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
|
||||||
|
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+43
-17
@@ -87,7 +87,7 @@ const VALID_EVENT_TYPES = new Set([
|
|||||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
||||||
// Ombi notification types
|
// Ombi notification types
|
||||||
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||||
@@ -135,6 +135,7 @@ const HISTORY_EVENTS = new Set([
|
|||||||
|
|
||||||
// Ombi event types — all Ombi events refresh the requests cache
|
// Ombi event types — all Ombi events refresh the requests cache
|
||||||
const OMBI_EVENTS = new Set([
|
const OMBI_EVENTS = new Set([
|
||||||
|
'NewRequest',
|
||||||
'RequestAvailable',
|
'RequestAvailable',
|
||||||
'RequestApproved',
|
'RequestApproved',
|
||||||
'RequestDeclined',
|
'RequestDeclined',
|
||||||
@@ -143,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
|
* @param {Object} req - Express request object
|
||||||
* @returns {boolean} True if secret is valid, false otherwise
|
* @returns {boolean} True if secret is valid, false otherwise
|
||||||
*/
|
*/
|
||||||
function validateWebhookSecret(req) {
|
function validateWebhookSecret(req) {
|
||||||
const expectedSecret = getWebhookSecret();
|
const expectedSecret = getWebhookSecret();
|
||||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
|
||||||
|
|
||||||
if (!expectedSecret) {
|
if (!expectedSecret) {
|
||||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||||
@@ -157,7 +158,7 @@ function validateWebhookSecret(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!providedSecret) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +259,9 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const ombiInstances = getOmbiInstances();
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
if (affectsOmbi) {
|
if (affectsOmbi) {
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||||
}
|
}
|
||||||
@@ -306,13 +309,13 @@ function validatePayload(body) {
|
|||||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* 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).
|
* No cookie authentication required (webhooks come from Sonarr, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **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)
|
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -339,6 +342,13 @@ function validatePayload(body) {
|
|||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -453,13 +463,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* 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).
|
* No cookie authentication required (webhooks come from Radarr, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **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)
|
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -486,6 +496,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -600,13 +617,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
||||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
* 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).
|
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
||||||
*
|
*
|
||||||
* **Rate Limiting:** 60 requests per minute per IP.
|
* **Rate Limiting:** 60 requests per minute per IP.
|
||||||
*
|
*
|
||||||
* **Validation:**
|
* **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)
|
* - Payload validation (must be JSON object with notificationType, requestId)
|
||||||
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
||||||
* - Replay protection: rejects duplicate events within 5-minute window
|
* - Replay protection: rejects duplicate events within 5-minute window
|
||||||
@@ -624,11 +641,17 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
||||||
*
|
*
|
||||||
* **x-integration-notes:** Configure Ombi webhook:
|
* **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
|
* - Method: POST
|
||||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
|
||||||
* - Application Token: OMBI_API_KEY
|
* - Application Token: OMBI_API_KEY
|
||||||
* security: []
|
* security: []
|
||||||
|
* parameters:
|
||||||
|
* - name: secret
|
||||||
|
* in: query
|
||||||
|
* required: false
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
@@ -716,9 +739,12 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Unauthorized' });
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ombi uses notificationType instead of eventType
|
// Ombi uses notificationType instead of eventType. Support PascalCase for .NET apps
|
||||||
const { notificationType, requestId, requestedUser, applicationUrl } = req.body;
|
const notificationType = req.body.notificationType || req.body.NotificationType;
|
||||||
const eventType = notificationType || req.body.eventType;
|
const requestId = req.body.requestId || req.body.RequestId;
|
||||||
|
const applicationUrl = req.body.applicationUrl || req.body.ApplicationUrl;
|
||||||
|
|
||||||
|
const eventType = notificationType || req.body.eventType || req.body.EventType;
|
||||||
|
|
||||||
// Extract username from requestedUser (handles both object and string formats)
|
// Extract username from requestedUser (handles both object and string formats)
|
||||||
const username = extractRequestedUser(req.body);
|
const username = extractRequestedUser(req.body);
|
||||||
@@ -731,7 +757,7 @@ router.post('/ombi', webhookLimiter, (req, res) => {
|
|||||||
// Use applicationUrl as instance identifier for replay protection
|
// Use applicationUrl as instance identifier for replay protection
|
||||||
const instanceName = applicationUrl || 'ombi';
|
const instanceName = applicationUrl || 'ombi';
|
||||||
// Use requestId + eventType + current time as replay key
|
// Use requestId + eventType + current time as replay key
|
||||||
const eventDate = req.body.requestedDate || new Date().toISOString();
|
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
|
||||||
|
|
||||||
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
|
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) {
|
||||||
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
|
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
|
||||||
|
|||||||
@@ -209,10 +209,15 @@ async function matchSabSlots(slots, context) {
|
|||||||
dlObj.arrType = 'sonarr';
|
dlObj.arrType = 'sonarr';
|
||||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||||
|
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
||||||
|
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
||||||
dlObj.arrContentType = 'episode';
|
dlObj.arrContentType = 'episode';
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = series.path || null;
|
dlObj.targetPath = series.path || null;
|
||||||
|
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||||
|
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -267,6 +272,9 @@ async function matchSabSlots(slots, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = movie.path || null;
|
dlObj.targetPath = movie.path || null;
|
||||||
|
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||||
|
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -451,10 +459,15 @@ async function matchTorrents(torrents, context) {
|
|||||||
download.arrType = 'sonarr';
|
download.arrType = 'sonarr';
|
||||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||||
download.arrContentId = sonarrMatch.episodeId || null;
|
download.arrContentId = sonarrMatch.episodeId || null;
|
||||||
|
download.arrContentIds = sonarrMatch.episodeIds || null;
|
||||||
|
download.arrSeriesId = sonarrMatch.seriesId || null;
|
||||||
download.arrContentType = 'episode';
|
download.arrContentType = 'episode';
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = series.path || null;
|
download.targetPath = series.path || null;
|
||||||
|
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||||
|
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -501,6 +514,9 @@ async function matchTorrents(torrents, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = movie.path || null;
|
download.targetPath = movie.path || null;
|
||||||
|
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||||
|
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,26 @@ function aggregateMetrics(metricsMap, configured) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Sofarr webhook is configured in an Ombi instance.
|
||||||
|
* @param {Object} instance - The Ombi instance config
|
||||||
|
* @returns {Promise<boolean>} true if webhook is configured
|
||||||
|
*/
|
||||||
|
async function checkOmbiWebhookConfigured(instance) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${instance.url}/api/v1/Settings/notifications/webhook`, {
|
||||||
|
headers: { 'ApiKey': instance.apiKey },
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
return !!(response.data && response.data.enabled);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[WebhookStatus] Failed to check Ombi webhook config: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
checkWebhookConfigured,
|
checkWebhookConfigured,
|
||||||
|
checkOmbiWebhookConfigured,
|
||||||
aggregateMetrics
|
aggregateMetrics
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all Ombi requests
|
* Get all Ombi requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||||
*/
|
*/
|
||||||
async getOmbiRequests() {
|
async getOmbiRequests(force = false) {
|
||||||
const ombiRetrievers = this.getOmbiRetrievers();
|
const ombiRetrievers = this.getOmbiRetrievers();
|
||||||
if (ombiRetrievers.length === 0) {
|
if (ombiRetrievers.length === 0) {
|
||||||
return { movie: [], tv: [] };
|
return { movie: [], tv: [] };
|
||||||
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
|
|||||||
// Use the first Ombi retriever (single instance expected)
|
// Use the first Ombi retriever (single instance expected)
|
||||||
const retriever = ombiRetrievers[0];
|
const retriever = ombiRetrievers[0];
|
||||||
try {
|
try {
|
||||||
const movieRequests = await retriever.getMovieRequests();
|
const movieRequests = await retriever.getMovieRequests(force);
|
||||||
const tvRequests = await retriever.getTvRequests();
|
const tvRequests = await retriever.getTvRequests(false);
|
||||||
return { movie: movieRequests, tv: tvRequests };
|
return { movie: movieRequests, tv: tvRequests };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||||
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Ombi requests grouped by type
|
* Get Ombi requests grouped by type
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||||
*/
|
*/
|
||||||
async getOmbiRequestsByType() {
|
async getOmbiRequestsByType(force = false) {
|
||||||
return await this.getOmbiRequests();
|
return await this.getOmbiRequests(force);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
class LogEmitter extends EventEmitter {}
|
||||||
|
const logEmitter = new LogEmitter();
|
||||||
|
|
||||||
|
const logBuffer = [];
|
||||||
|
const clientLogBuffer = [];
|
||||||
|
const MAX_BUFFER_SIZE = 1000;
|
||||||
|
|
||||||
|
// ANSI escape code regular expression for stripping terminal colors
|
||||||
|
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||||
|
|
||||||
|
function stripAnsi(str) {
|
||||||
|
return typeof str === 'string' ? str.replace(ansiRegex, '') : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep track of original stdout/stderr write functions
|
||||||
|
const originalStdoutWrite = process.stdout.write;
|
||||||
|
const originalStderrWrite = process.stderr.write;
|
||||||
|
|
||||||
|
// Buffer to accumulate partial lines from stdout and stderr
|
||||||
|
let stdoutLineBuffer = '';
|
||||||
|
let stderrLineBuffer = '';
|
||||||
|
|
||||||
|
function processStreamData(data, encoding, callback, streamName, lineAccumulator) {
|
||||||
|
let str = '';
|
||||||
|
if (Buffer.isBuffer(data)) {
|
||||||
|
str = data.toString(encoding || 'utf8');
|
||||||
|
} else if (typeof data === 'string') {
|
||||||
|
str = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate writing to the original stream first
|
||||||
|
callback.call(this, data, encoding);
|
||||||
|
|
||||||
|
// Append new data to the accumulator
|
||||||
|
const accumulated = lineAccumulator.buffer + str;
|
||||||
|
const lines = accumulated.split(/\r?\n/);
|
||||||
|
|
||||||
|
// The last element is either empty (if str ended with \n) or a partial line
|
||||||
|
lineAccumulator.buffer = lines.pop();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const cleanLine = stripAnsi(line);
|
||||||
|
if (!cleanLine) continue;
|
||||||
|
|
||||||
|
// Prepend timestamp if not present (format: [ISO] Message)
|
||||||
|
const timestampedLine = cleanLine.startsWith('[')
|
||||||
|
? cleanLine
|
||||||
|
: `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${cleanLine}`;
|
||||||
|
|
||||||
|
logBuffer.push(timestampedLine);
|
||||||
|
if (logBuffer.length > MAX_BUFFER_SIZE) {
|
||||||
|
logBuffer.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
logEmitter.emit('server-log', timestampedLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulator objects to allow updating string buffers by reference
|
||||||
|
const stdoutAccumulator = { buffer: '' };
|
||||||
|
const stderrAccumulator = { buffer: '' };
|
||||||
|
|
||||||
|
let isHooked = false;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (isHooked) return;
|
||||||
|
|
||||||
|
// Intercept stdout
|
||||||
|
process.stdout.write = function(data, encoding, callback) {
|
||||||
|
processStreamData.call(
|
||||||
|
process.stdout,
|
||||||
|
data,
|
||||||
|
encoding,
|
||||||
|
originalStdoutWrite,
|
||||||
|
'stdout',
|
||||||
|
stdoutAccumulator
|
||||||
|
);
|
||||||
|
if (typeof callback === 'function') callback();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intercept stderr
|
||||||
|
process.stderr.write = function(data, encoding, callback) {
|
||||||
|
processStreamData.call(
|
||||||
|
process.stderr,
|
||||||
|
data,
|
||||||
|
encoding,
|
||||||
|
originalStderrWrite,
|
||||||
|
'stderr',
|
||||||
|
stderrAccumulator
|
||||||
|
);
|
||||||
|
if (typeof callback === 'function') callback();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
isHooked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingests a list of client-side logs into the rolling clientLogBuffer.
|
||||||
|
* Each client log is expected to have structure: { timestamp, level, message }
|
||||||
|
*/
|
||||||
|
function ingestClientLogs(logs) {
|
||||||
|
if (!Array.isArray(logs)) return;
|
||||||
|
|
||||||
|
for (const log of logs) {
|
||||||
|
const timestamp = log.timestamp || new Date().toISOString();
|
||||||
|
const level = (log.level || 'info').toUpperCase();
|
||||||
|
const msg = typeof log.message === 'string' ? log.message : JSON.stringify(log.message);
|
||||||
|
|
||||||
|
const formattedLog = `[${timestamp}] [CLIENT] [${level}] ${stripAnsi(msg)}`;
|
||||||
|
clientLogBuffer.push(formattedLog);
|
||||||
|
|
||||||
|
if (clientLogBuffer.length > MAX_BUFFER_SIZE) {
|
||||||
|
clientLogBuffer.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
logEmitter.emit('client-log', formattedLog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init,
|
||||||
|
logEmitter,
|
||||||
|
logBuffer,
|
||||||
|
clientLogBuffer,
|
||||||
|
ingestClientLogs
|
||||||
|
};
|
||||||
@@ -15,17 +15,19 @@
|
|||||||
function extractRequestedUser(request) {
|
function extractRequestedUser(request) {
|
||||||
if (!request) return '';
|
if (!request) return '';
|
||||||
|
|
||||||
|
const requestedUser = request.requestedUser || request.RequestedUser;
|
||||||
|
|
||||||
// Handle object format: OmbiStore.Entities.OmbiUser
|
// Handle object format: OmbiStore.Entities.OmbiUser
|
||||||
if (request.requestedUser && typeof request.requestedUser === 'object') {
|
if (requestedUser && typeof requestedUser === 'object') {
|
||||||
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
// Priority: alias > userAlias > userName > normalizedUserName > requestedByAlias
|
||||||
return request.requestedUser.alias ||
|
return requestedUser.alias || requestedUser.Alias ||
|
||||||
request.requestedUser.userAlias ||
|
requestedUser.userAlias || requestedUser.UserAlias ||
|
||||||
request.requestedUser.userName ||
|
requestedUser.userName || requestedUser.UserName ||
|
||||||
request.requestedUser.normalizedUserName ||
|
requestedUser.normalizedUserName || requestedUser.NormalizedUserName ||
|
||||||
request.requestedByAlias || '';
|
request.requestedByAlias || request.RequestedByAlias || '';
|
||||||
}
|
}
|
||||||
// Handle string format (fallback for compatibility)
|
// Handle string format (fallback for compatibility)
|
||||||
return request.requestedUser || request.requestedByAlias || '';
|
return requestedUser || request.requestedByAlias || request.RequestedByAlias || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterRequestsByUser(requests, username, showAll) {
|
function filterRequestsByUser(requests, username, showAll) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const {
|
|||||||
getRadarrInstances,
|
getRadarrInstances,
|
||||||
getOmbiInstances
|
getOmbiInstances
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/utils/clientLogCapture.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { initClientLogCapture } from '../../../client/src/utils/clientLogCapture.js';
|
||||||
|
|
||||||
|
describe('clientLogCapture', () => {
|
||||||
|
let fetchMock;
|
||||||
|
let originalConsoleLog;
|
||||||
|
let originalConsoleWarn;
|
||||||
|
let originalConsoleError;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// Preserve original console methods
|
||||||
|
originalConsoleLog = console.log;
|
||||||
|
originalConsoleWarn = console.warn;
|
||||||
|
originalConsoleError = console.error;
|
||||||
|
|
||||||
|
// Reset console methods to standard ones
|
||||||
|
console.log = vi.fn();
|
||||||
|
console.warn = vi.fn();
|
||||||
|
console.error = vi.fn();
|
||||||
|
|
||||||
|
// Mock window fetch
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
global.window.fetch = fetchMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
|
// Restore original console methods
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.warn = originalConsoleWarn;
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits early and does not intercept console if status returns disabled', async () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ enabled: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
await initClientLogCapture();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||||
|
|
||||||
|
// Console calls should NOT be queued or overridden (i.e. they should just run vi.fn mocks)
|
||||||
|
console.log('Test message');
|
||||||
|
expect(console.log).toHaveBeenCalledWith('Test message');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalledWith('/api/debug/client-logs', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hooks console and flushes logs periodically when status returns enabled', async () => {
|
||||||
|
fetchMock.mockImplementation((url, options) => {
|
||||||
|
if (url === '/api/debug/status') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ enabled: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url === '/api/debug/client-logs') {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await initClientLogCapture();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/debug/status');
|
||||||
|
|
||||||
|
// Trigger console logs
|
||||||
|
console.log('Booting app', { config: 'loaded' });
|
||||||
|
console.warn('Deprecated api call');
|
||||||
|
console.error('Failed request', new Error('timeout'));
|
||||||
|
|
||||||
|
// Move timers forward to trigger flush interval (2000ms)
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/debug/client-logs', expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lastCall = fetchMock.mock.calls.find(call => call[0] === '/api/debug/client-logs');
|
||||||
|
expect(lastCall).toBeDefined();
|
||||||
|
|
||||||
|
const loggedEntries = JSON.parse(lastCall[1].body);
|
||||||
|
expect(loggedEntries).toHaveLength(4); // Includes the interceptor boot message + 3 logs
|
||||||
|
|
||||||
|
expect(loggedEntries[1].level).toBe('info');
|
||||||
|
expect(loggedEntries[1].message).toContain('Booting app {"config":"loaded"}');
|
||||||
|
|
||||||
|
expect(loggedEntries[2].level).toBe('warn');
|
||||||
|
expect(loggedEntries[2].message).toContain('Deprecated api call');
|
||||||
|
|
||||||
|
expect(loggedEntries[3].level).toBe('error');
|
||||||
|
expect(loggedEntries[3].message).toContain('Failed request');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -225,7 +225,7 @@ function invalidatePollCache() {
|
|||||||
'poll:sab-queue', 'poll:sab-history',
|
'poll:sab-queue', 'poll:sab-history',
|
||||||
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
||||||
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-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);
|
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.arrQueueId).toBe(1002);
|
||||||
expect(dl.arrType).toBe('sonarr');
|
expect(dl.arrType).toBe('sonarr');
|
||||||
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||||
|
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
|
||||||
expect(dl.downloadPath).toBeDefined();
|
expect(dl.downloadPath).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -749,12 +750,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
|
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok'
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.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' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/permission denied/i);
|
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 app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
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)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.post('/api/dashboard/blocklist-search')
|
||||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
.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' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/download not found/i);
|
expect(res.body.error).toMatch(/download not found/i);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
|
// Seed cache: queue record with import issues — qualifies non-admin for blocklist
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'warning',
|
||||||
|
statusMessages: [{ messages: ['Import error 1'] }]
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
// Mock Sonarr DELETE and command endpoints
|
// Mock Sonarr DELETE and command endpoints
|
||||||
nock(SONARR_BASE)
|
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' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 when required fields are missing', async () => {
|
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 app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.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' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Radarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(RADARR_BASE)
|
nock(RADARR_BASE)
|
||||||
.delete('/api/v3/queue/2001')
|
.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' });
|
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 502 when Sonarr DELETE request fails', async () => {
|
it('returns 502 when Sonarr DELETE request fails', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -916,7 +905,90 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.set('X-CSRF-Token', csrf)
|
.set('X-CSRF-Token', csrf)
|
||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(502);
|
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);
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
|
||||||
|
describe('Debug Logs API Integration', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret-xyz';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
||||||
|
delete process.env.ENABLE_LOG_STREAM;
|
||||||
|
delete process.env.LOG_ALLOW_SUBNETS;
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/debug/status', () => {
|
||||||
|
it('returns enabled: false when ENABLE_LOG_STREAM is not true', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns enabled: true when ENABLE_LOG_STREAM is true', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/status');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Global toggle checking', () => {
|
||||||
|
it('returns 403 Forbidden on server logs GET when feature is disabled', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/server-logs');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 Forbidden on client logs GET when feature is disabled', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/client-logs');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 Forbidden on client logs POST when feature is disabled', async () => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'false';
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).post('/api/debug/client-logs').send([]);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/disabled/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Subnet CIDR validation', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
process.env.LOG_ALLOW_SUBNETS = '127.0.0.1/32,192.168.1.0/24';
|
||||||
|
process.env.TRUST_PROXY = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 Forbidden if client IP is not in subnet allowlist', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs')
|
||||||
|
.set('X-Forwarded-For', '10.0.0.50');
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/Access denied from IP/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bypasses subnet check and hits auth validation if client IP is allowed', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// In subnet allowlist but missing credentials -> returns 401 instead of 403!
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs')
|
||||||
|
.set('X-Forwarded-For', '192.168.1.150');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.LOG_ALLOW_SUBNETS;
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Authentication and Bypass policies', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 Unauthorized when all auth options are missing', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/debug/server-logs');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.headers['www-authenticate']).toContain('Basic realm=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows access via X-Webhook-Secret header bypass', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// X-Webhook-Secret bypass avoids Emby login entirely (returns 200 SSE stream)
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs?testClose=true')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows access via Basic Authentication with valid Emby administrator credentials', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
// Mock Emby login
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(200, {
|
||||||
|
AccessToken: 'admin-emby-tok',
|
||||||
|
User: { Id: 'admin-user-id', Name: 'embyadmin' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Emby profile fetch verifying IsAdministrator is true
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/admin-user-id')
|
||||||
|
.reply(200, {
|
||||||
|
Id: 'admin-user-id',
|
||||||
|
Name: 'embyadmin',
|
||||||
|
Policy: { IsAdministrator: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs?testClose=true')
|
||||||
|
.auth('embyadmin', 'password123');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies access via Basic Authentication if user is not an administrator', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(200, {
|
||||||
|
AccessToken: 'user-emby-tok',
|
||||||
|
User: { Id: 'regular-user-id', Name: 'embyuser' }
|
||||||
|
});
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/regular-user-id')
|
||||||
|
.reply(200, {
|
||||||
|
Id: 'regular-user-id',
|
||||||
|
Name: 'embyuser',
|
||||||
|
Policy: { IsAdministrator: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/debug/server-logs')
|
||||||
|
.auth('embyuser', 'password123');
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Client logs ingestion and streaming', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.ENABLE_LOG_STREAM = 'true';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 Bad Request on client logs POST if body is not an array', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/debug/client-logs')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||||
|
.send({ message: 'not an array' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ingests client logs array and streams them over client logs GET SSE', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|
||||||
|
// Ingest client logs
|
||||||
|
const postRes = await request(app)
|
||||||
|
.post('/api/debug/client-logs')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz')
|
||||||
|
.send([
|
||||||
|
{ timestamp: new Date().toISOString(), level: 'info', message: 'Hello from client' }
|
||||||
|
]);
|
||||||
|
expect(postRes.status).toBe(200);
|
||||||
|
expect(postRes.body.count).toBe(1);
|
||||||
|
|
||||||
|
// Verify log streams successfully via GET
|
||||||
|
const getRes = await request(app)
|
||||||
|
.get('/api/debug/client-logs?testClose=true')
|
||||||
|
.set('X-Webhook-Secret', 'test-webhook-secret-xyz');
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
expect(getRes.headers['content-type']).toContain('text/event-stream');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -850,7 +850,15 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
|
|
||||||
it('enables webhook successfully', async () => {
|
it('enables webhook successfully', async () => {
|
||||||
nock(OMBI_BASE)
|
nock(OMBI_BASE)
|
||||||
.post('/api/v1/Settings/notifications/webhook')
|
.get('/api/v1/Settings/notifications/webhook')
|
||||||
|
.reply(200, { id: 42, enabled: false, webhookUrl: null, applicationToken: null });
|
||||||
|
nock(OMBI_BASE)
|
||||||
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
|
id: 42,
|
||||||
|
enabled: true,
|
||||||
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
|
applicationToken: 'test-ombi-key'
|
||||||
|
})
|
||||||
.reply(200, { success: true });
|
.reply(200, { success: true });
|
||||||
|
|
||||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
@@ -862,11 +870,38 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.success).toBe(true);
|
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');
|
expect(res.body.applicationToken).toBe('test-ombi-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enables webhook successfully even if GET settings fails', async () => {
|
||||||
|
nock(OMBI_BASE)
|
||||||
|
.get('/api/v1/Settings/notifications/webhook')
|
||||||
|
.reply(500, { error: 'Failed to fetch settings' });
|
||||||
|
nock(OMBI_BASE)
|
||||||
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
|
id: 0,
|
||||||
|
enabled: true,
|
||||||
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
|
applicationToken: 'test-ombi-key'
|
||||||
|
})
|
||||||
|
.reply(200, { success: true });
|
||||||
|
|
||||||
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/ombi/webhook/enable')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.set('X-CSRF-Token', csrfToken)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('handles Ombi API errors gracefully', async () => {
|
it('handles Ombi API errors gracefully', async () => {
|
||||||
|
nock(OMBI_BASE)
|
||||||
|
.get('/api/v1/Settings/notifications/webhook')
|
||||||
|
.reply(200, { id: 42 });
|
||||||
nock(OMBI_BASE)
|
nock(OMBI_BASE)
|
||||||
.post('/api/v1/Settings/notifications/webhook')
|
.post('/api/v1/Settings/notifications/webhook')
|
||||||
.reply(500, { error: 'Internal server error' });
|
.reply(500, { error: 'Internal server error' });
|
||||||
|
|||||||
@@ -196,6 +196,18 @@ describe('Swagger Coverage', () => {
|
|||||||
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
expect(paths['/api/ombi/webhook/test'].post).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have Debug logging endpoints documented', () => {
|
||||||
|
const paths = openapiSpec.paths;
|
||||||
|
|
||||||
|
expect(paths['/api/debug/status']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/status'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/server-logs']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/server-logs'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs']).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs'].get).toBeDefined();
|
||||||
|
expect(paths['/api/debug/client-logs'].post).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 200 for Swagger UI endpoint', async () => {
|
it('should return 200 for Swagger UI endpoint', async () => {
|
||||||
const response = await request(app).get('/api/swagger').redirects(1);
|
const response = await request(app).get('/api/swagger').redirects(1);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ function makeApp() {
|
|||||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||||
]);
|
]);
|
||||||
|
process.env.OMBI_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' }
|
||||||
|
]);
|
||||||
return createApp({ skipRateLimits: true });
|
return createApp({ skipRateLimits: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +116,10 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
// Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
|
||||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
|
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -125,6 +129,7 @@ afterEach(() => {
|
|||||||
delete process.env.SOFARR_BASE_URL;
|
delete process.env.SOFARR_BASE_URL;
|
||||||
delete process.env.SONARR_INSTANCES;
|
delete process.env.SONARR_INSTANCES;
|
||||||
delete process.env.RADARR_INSTANCES;
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
delete process.env.OMBI_INSTANCES;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -151,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
|
|||||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||||
expect(res.status).toBe(401);
|
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', () => {
|
describe('POST /api/webhook/radarr — secret validation', () => {
|
||||||
@@ -166,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
|
|||||||
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||||
expect(res.status).toBe(401);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -518,3 +558,190 @@ describe('GET /api/webhook/config', () => {
|
|||||||
expect(res.body.missing).toHaveLength(2);
|
expect(res.body.missing).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ombi webhook receiver
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('POST /api/webhook/ombi', () => {
|
||||||
|
function postOmbi(app, payload, secret = VALID_SECRET) {
|
||||||
|
const req = request(app).post('/api/webhook/ombi').send(payload);
|
||||||
|
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, null);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, 'wrong-secret');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
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 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when notificationType is unknown', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await postOmbi(app, { notificationType: 'UnknownNotification', requestId: 1 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid NewRequest event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
// Nock requests endpoint since processWebhookEvent will fetch requests
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test')
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 123,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'New Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:30:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
expect(res.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid RequestAvailable event', 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 payload = {
|
||||||
|
notificationType: 'RequestAvailable',
|
||||||
|
requestId: 124,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'Available Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Available',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:31:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns duplicate: true for a replay of the same event', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
nock('https://ombi.test').persist()
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
nock('https://ombi.test').persist()
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
notificationType: 'NewRequest',
|
||||||
|
requestId: 125,
|
||||||
|
requestedUser: 'gordon',
|
||||||
|
title: 'New Movie',
|
||||||
|
type: 'Movie',
|
||||||
|
requestStatus: 'Pending',
|
||||||
|
applicationUrl: 'https://ombi.test',
|
||||||
|
requestedDate: '2026-05-23T20:32:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
// First request
|
||||||
|
const res1 = await postOmbi(app, payload);
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
expect(res1.body.duplicate).toBeUndefined();
|
||||||
|
|
||||||
|
// Replay
|
||||||
|
const res2 = await postOmbi(app, payload);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(res2.body.duplicate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 { received: true } for a valid NewRequest event with PascalCase payload', 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 payload = {
|
||||||
|
NotificationType: 'NewRequest',
|
||||||
|
RequestId: 126,
|
||||||
|
RequestedUser: { UserName: 'gordon_pascal' },
|
||||||
|
Title: 'Pascal Movie',
|
||||||
|
Type: 'Movie',
|
||||||
|
RequestStatus: 'Pending',
|
||||||
|
ApplicationUrl: 'https://ombi.test',
|
||||||
|
RequestedDate: '2026-05-23T20:33:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await postOmbi(app, payload);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.received).toBe(true);
|
||||||
|
expect(res.body.duplicate).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
|
|||||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should refresh if cache is not expired but force is true', async () => {
|
||||||
|
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||||
|
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||||
|
const mockTvShows = [];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies1);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
|
||||||
|
// First refresh
|
||||||
|
await retriever.refreshCache();
|
||||||
|
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||||
|
|
||||||
|
// Set up new mocks for second refresh without advancing time
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies2);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
// Second refresh with force=true should make API calls
|
||||||
|
await retriever.refreshCache(true);
|
||||||
|
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('should build movie map with TMDB and IMDB IDs', async () => {
|
it('should build movie map with TMDB and IMDB IDs', async () => {
|
||||||
const mockMovies = [
|
const mockMovies = [
|
||||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
||||||
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(mockMovies);
|
expect(result).toEqual(mockMovies);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should force refresh and return movie requests even when cache is not expired if force is true', async () => {
|
||||||
|
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||||
|
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||||
|
const mockTvShows = [];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies1);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
await retriever.refreshCache();
|
||||||
|
|
||||||
|
// Set up new mocks for second fetch
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies2);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const result = await retriever.getMovieRequests(true);
|
||||||
|
expect(result).toEqual(mockMovies2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTvRequests', () => {
|
describe('getTvRequests', () => {
|
||||||
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(mockTvShows);
|
expect(result).toEqual(mockTvShows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should force refresh and return TV requests even when cache is not expired if force is true', async () => {
|
||||||
|
const mockMovies = [];
|
||||||
|
const mockTvShows1 = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||||
|
const mockTvShows2 = [{ id: 1, title: 'Show 1' }, { id: 2, title: 'Show 2', theTvDbId: '22222' }];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows1);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
await retriever.refreshCache();
|
||||||
|
|
||||||
|
// Set up new mocks for second fetch
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows2);
|
||||||
|
|
||||||
|
const result = await retriever.getTvRequests(true);
|
||||||
|
expect(result).toEqual(mockTvShows2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findMovieRequest', () => {
|
describe('findMovieRequest', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user