Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 | |||
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e | |||
| 88bfa52eba | |||
| 95e301ef56 | |||
| 3c6791658c |
+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
|
||||||
|
|||||||
@@ -4,6 +4,65 @@ 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.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
|
## [1.7.11] - 2026-05-24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.11",
|
"version": "1.7.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.11",
|
"version": "1.7.17",
|
||||||
"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.11",
|
"version": "1.7.17",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"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": {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
Problem:
|
||||||
|
The "Blocklist & Search" button on download cards fails with a "400 Bad Request (Missing required fields)" when clicked on any television release in Sonarr that represents a full season package or a multi-episode release.
|
||||||
|
|
||||||
|
Root Cause:
|
||||||
|
1. In `server/services/DownloadMatcher.js`, when a download is matched with a Sonarr queue record, `arrContentId` is populated with `sonarrMatch.episodeId || null`.
|
||||||
|
2. However, for multi-episode packs or full season grabs in Sonarr v3, the `episodeId` field is missing from the queue record payload (since the release is associated with multiple episodes). Instead, Sonarr provides an `episodeIds` array. As a result, `arrContentId` is normalized to `null`.
|
||||||
|
3. When the user clicks the "Blocklist & Search" button in the UI, the frontend calls the `POST /api/dashboard/blocklist-search` endpoint. The request body includes `arrContentId: null`.
|
||||||
|
4. The backend route validator in `server/routes/dashboard.js` strictly requires all fields including `arrContentId` to be truthy:
|
||||||
|
```javascript
|
||||||
|
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Because `arrContentId` is `null`, this check fails and returns `400 Missing required fields`, completely blocking the blocklist operation (even though queue removal itself does not require an episode ID).
|
||||||
|
5. Furthermore, the search trigger logic in `dashboard.js` only handles single episode searches via `{ name: 'EpisodeSearch', episodeIds: [arrContentId] }` and has no logic to handle `episodeIds` arrays or fallback searches (such as `SeriesSearch` or `SeasonSearch`).
|
||||||
|
|
||||||
|
Proposed Fix:
|
||||||
|
1. **Relax Backend Validation**: Allow `arrContentId` to be optional or null for `sonarr` queue records to ensure the deletion and blocklist steps can still execute.
|
||||||
|
2. **Robust Search Triggers**:
|
||||||
|
- If `episodeId` is missing but `episodeIds` array is available on the matched record, pass the array of IDs to the frontend/backend.
|
||||||
|
- Modify the `dashboard.js` re-search block to support `EpisodeSearch` with multiple IDs, or fall back to triggering a `SeriesSearch` command using the `seriesId` if no specific episode IDs are resolved.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
Title:
|
||||||
|
FEATURE: Client-side console log capturing and streaming API endpoint with dual-authentication
|
||||||
|
|
||||||
|
Problem / Requirement:
|
||||||
|
To aid in frontend troubleshooting, developers need a way to capture and gather client-side console logs (`console.log`, `console.warn`, `console.error`) and make them accessible over a real-time log stream endpoint. This helps debug frontend issues (such as SSE failures, CSP violations, and state synchronization issues) in environments without direct access to browser devtools.
|
||||||
|
|
||||||
|
Success Criteria:
|
||||||
|
1. Client-Side Interceptor: Intercept standard browser console methods at SPA startup and place captured logs into an in-memory queue.
|
||||||
|
2. Batched Log Transmission (Selected Option A): Periodic HTTP POST batch queries to `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize browser thread and network overhead.
|
||||||
|
3. Server storage and SSE log streaming:
|
||||||
|
- Save incoming logs into a separate rolling 1000-line buffer `clientLogBuffer`.
|
||||||
|
- Expose `GET /api/debug/client-logs/stream` to stream client-side logs in real-time via SSE.
|
||||||
|
4. Security & Configuration:
|
||||||
|
- Enableable only when the environment variable `ENABLE_LOG_STREAM=true` is set.
|
||||||
|
- Enforce exact same dual-auth rules (Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass) on both client logs endpoints.
|
||||||
|
5. API Documentation: Documented in `server/openapi.yaml`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Amended the plan to add client-side console log capturing and streaming options:
|
||||||
|
|
||||||
|
### Proposed Client Logging Design:
|
||||||
|
- **Client-Side Capture (Frontend Interception)**: Hook into standard browser console methods (`console.log`, `console.warn`, `console.error`) at client-side startup.
|
||||||
|
- **Client-to-Server Transmission**:
|
||||||
|
- **Option A (Recommended)**: Store captured logs in a local memory queue, and periodically perform a batched `POST /api/debug/client-logs` (every 2 seconds or when the queue hits 20 items) to minimize network overhead.
|
||||||
|
- **Option B (WebSocket Channel)**: Stream logs instantly via persistent WebSockets, which adds structural and connection management complexity.
|
||||||
|
- **Server Storage & SSE Streaming**:
|
||||||
|
- Store incoming client logs in a separate rolling 1000-line buffer `clientLogBuffer`.
|
||||||
|
- Expose `GET /api/debug/client-logs/stream` (under the exact same dual-auth/webhook-secret constraints) to stream client-side logs in real-time via SSE to debugging tools.
|
||||||
|
|
||||||
|
The `implementation_plan.md` artifact has been successfully updated with these options.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const secret = '63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a';
|
||||||
|
const serverLogsUrl = 'https://sofarr.i3omb.com/api/debug/server-logs?testClose=true';
|
||||||
|
const clientLogsUrl = 'https://sofarr.i3omb.com/api/debug/client-logs?testClose=true';
|
||||||
|
|
||||||
|
async function fetchLogs(url, filename) {
|
||||||
|
console.log(`Fetching logs from ${url}...`);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
'x-webhook-secret': secret
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fs.writeFileSync(filename, response.data);
|
||||||
|
console.log(`Logs saved to ${filename} (${response.data.length} bytes).`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch from ${url}:`, err.message);
|
||||||
|
if (err.response) {
|
||||||
|
console.error(`Status: ${err.response.status}`);
|
||||||
|
console.error(`Body:`, JSON.stringify(err.response.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await fetchLogs(serverLogsUrl, 'scratch/remote_server.log');
|
||||||
|
await fetchLogs(clientLogsUrl, 'scratch/remote_client.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Regression: Fix in v1.7.16 was insufficient — issue persists in production
|
||||||
|
|
||||||
|
### Updated Root Cause Analysis
|
||||||
|
|
||||||
|
Post-release investigation of live server debug logs on `sofarr.i3omb.com` confirms the blocklist feature is **still failing** after v1.7.16. The server logs still show:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Blocklist] Download not found: { arrQueueId: 439913856, arrType: 'radarr' }
|
||||||
|
```
|
||||||
|
|
||||||
|
The v1.7.16 fix cast both sides of the comparison to `String`, which was the correct approach — but it was applied to the **wrong data source**.
|
||||||
|
|
||||||
|
The permission check at line 693 of `dashboard.js` calls:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const allDownloads = await downloadClientRegistry.getAllDownloads();
|
||||||
|
const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType);
|
||||||
|
```
|
||||||
|
|
||||||
|
`downloadClientRegistry.getAllDownloads()` fetches **raw download client data** directly from qBittorrent, SABnzbd, etc. — these are unmatched objects with no Sonarr/Radarr queue metadata. The `arrQueueId` field is only populated during `DownloadMatcher.js` processing (which runs during the SSE/dashboard build from the *arr cache). Because qBittorrent's `normalizeDownload()` never sets `arrQueueId`, the lookup **always returns `undefined`** for any qBittorrent torrent, regardless of type casting.
|
||||||
|
|
||||||
|
### Correct Fix
|
||||||
|
|
||||||
|
The permission check should validate against the **Sonarr/Radarr queue cache records** directly (where `id` is the queue record ID), rather than against raw download client data. The fix will replace the `downloadClientRegistry.getAllDownloads()` lookup with a direct cache lookup of `poll:sonarr-queue` / `poll:radarr-queue` records, matching by `String(record.id) === String(arrQueueId)`.
|
||||||
|
|
||||||
|
This will be released in v1.7.17.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
The "Blocklist and search" feature is broken for all users. Clicking the blocklist button on a download (e.g. the film "Project Hail Mary", `arrQueueId: 905000340`, `arrType: radarr`) consistently returns a `403 Download not found or permission denied` error.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The server-side lookup in `server/routes/dashboard.js` uses strict equality (`===`) to find the matching download:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `d.arrQueueId` is populated from the Radarr/Sonarr queue API response as a **number** (e.g. `905000340`).
|
||||||
|
- `arrQueueId` from `req.body` originates from the client SPA via a DOM `dataset` attribute, which is always a **string** (e.g. `"905000340"`).
|
||||||
|
- Due to the type mismatch, `905000340 === "905000340"` evaluates to `false`, so the lookup always fails and returns `403`.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Server log (live environment, `2026-05-24`):
|
||||||
|
|
||||||
|
```
|
||||||
|
[Blocklist] Download not found: { arrQueueId: 905000340, arrType: 'radarr' }
|
||||||
|
```
|
||||||
|
|
||||||
|
Client log confirms user clicked blocklist at `21:01:19`, `21:01:32`, and `21:02:35`.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
1. Open the dashboard on a Radarr or Sonarr download with a pending queue entry.
|
||||||
|
2. Click the "Blocklist and search" button.
|
||||||
|
3. The action silently fails; the download is not removed and no re-search is triggered.
|
||||||
|
4. Server logs show `[Blocklist] Download not found`.
|
||||||
|
|
||||||
|
## Proposed Fix
|
||||||
|
|
||||||
|
Cast both sides of the comparison to `String` before comparing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const download = allDownloads.find(d => d.arrQueueId != null && String(d.arrQueueId) === String(arrQueueId) && d.arrType === arrType);
|
||||||
|
```
|
||||||
|
|
||||||
|
This fix will be released in version `1.7.16`.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
|
||||||
|
**High** — The blocklist-and-search feature is completely non-functional for all users. There is no workaround within the UI.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Title:
|
||||||
|
FEATURE: Log streaming debug endpoint with dual-authentication and togglable runtime configuration
|
||||||
|
|
||||||
|
Problem / Requirement:
|
||||||
|
Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access.
|
||||||
|
|
||||||
|
Success Criteria:
|
||||||
|
1. **Lightweight Log Streaming**: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory.
|
||||||
|
2. **Dual-Authentication**:
|
||||||
|
- Accepts existing session cookie (`emby_user`) with administrative credentials.
|
||||||
|
- Accepts standard HTTP Basic Authentication (`Authorization: Basic <base64>`) using Emby administrator username/password credentials.
|
||||||
|
3. **Runtime Configuration Toggle**: Enableable using a runtime environment variable `ENABLE_LOG_STREAM=true` (defaulting to `false`/disabled). When disabled, returns a `403 Forbidden` response.
|
||||||
|
4. **API Spec Documentation**: Documented in `server/openapi.yaml` under the `/api/debug/logs` endpoint, including the query format and response schemas.
|
||||||
|
|
||||||
|
Proposed Implementation:
|
||||||
|
1. **Log Interceptor**: Implement a global stdout/stderr hook in `server/index.js` or in a new `server/utils/logCapture.js` to collect a rolling buffer of 1000 log lines and expose a Node `EventEmitter` to push new logs to active subscribers.
|
||||||
|
2. **Authentication Middleware**: Create `server/middleware/logStreamAuth.js` which verifies active sessions or fallback Basic Auth headers by calling Emby's `/Users/authenticatebyname` and `/Users/{id}` endpoints to verify the user is a valid administrator.
|
||||||
|
3. **Route Definition**: Define `server/routes/debug.js` to register `GET /api/debug/logs` backing the SSE stream, enforce the `ENABLE_LOG_STREAM === 'true'` check, and execute `logStreamAuth` checks.
|
||||||
|
4. **OpenAPI Spec Integration**: Define `/api/debug/logs` schemas, parameters, security schemes, and basic auth descriptions inside `server/openapi.yaml`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
### Bug Description
|
||||||
|
Ombi webhooks are currently failing to authenticate. In `server/routes/webhook.js`, all `/api/webhook/*` endpoints (sonarr, radarr, and ombi) require the custom `X-Sofarr-Webhook-Secret` HTTP header to be present and match the configured `SOFARR_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
|
However, Ombi's built-in Webhook notification agent does not support adding custom HTTP headers to its outgoing webhook notification requests. This makes it impossible for Ombi to successfully authenticate using the current header-only validation mechanism.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
In `server/routes/webhook.js`, `validateWebhookSecret(req)` only inspects `req.get('X-Sofarr-Webhook-Secret')`:
|
||||||
|
```javascript
|
||||||
|
function validateWebhookSecret(req) {
|
||||||
|
const expectedSecret = getWebhookSecret();
|
||||||
|
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Since Ombi sends standard JSON payloads to a configured URL without custom headers, it cannot supply this header, resulting in a `401 Unauthorized` response.
|
||||||
|
|
||||||
|
### Proposed Remediation
|
||||||
|
1. **Fallback Authentication Method**: Update `validateWebhookSecret(req)` in `server/routes/webhook.js` to look for the secret in either the `X-Sofarr-Webhook-Secret` header OR as a `secret` query parameter (`req.query.secret`).
|
||||||
|
2. **Registration Update**: Update the `/webhook/enable` route in `server/routes/ombi.js` to automatically append `?secret=${webhookSecret}` to the registered `webhookUrl` sent to Ombi.
|
||||||
|
3. **OpenAPI Spec & JSDoc Updates**: Document the query-parameter fallback authentication option in `server/openapi.yaml` and the `@openapi` JSDoc comments in `server/routes/webhook.js`.
|
||||||
|
4. **Integration Testing**: Add new integration tests in `tests/integration/webhook.test.js` to assert that authentication via query parameters succeeds, and that invalid query parameters are rejected.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Amended the plan to include a high-priority bypass using the `X-Webhook-Secret` request header:
|
||||||
|
|
||||||
|
1. **Webhook Secret Bypass**: If the request contains the `X-Webhook-Secret` header, we verify if it matches the configured `SOFARR_WEBHOOK_SECRET` environment variable.
|
||||||
|
2. **Access Granted**: If matching, the request is immediately authorized, completely bypassing session and Emby Basic Auth checks. This is ideal for curl scripts, server-to-server monitoring, or external debugging logs captures.
|
||||||
|
|
||||||
|
I have updated the `implementation_plan.md` artifact to reflect this amendment.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
I have investigated the blocklist & search failure reported in this issue and created a technical remediation plan:
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
For television grabs representing a full-season pack or multi-episode package in Sonarr, the `episodeId` property is absent (instead, it has an `episodeIds` array). This maps to a `null` value for `arrContentId` on the client download card. The `/api/dashboard/blocklist-search` route strictly requires all fields including `arrContentId` to be truthy, returning `400 Bad Request: Missing required fields` and completely blocking the queue blocklist/removal action.
|
||||||
|
|
||||||
|
### Remediation Plan
|
||||||
|
1. **Enrich Backend Match Data**: Expose `arrContentIds` (`sonarrMatch.episodeIds`) and `arrSeriesId` (`sonarrMatch.seriesId`) from `DownloadMatcher.js` to the normalized download card object.
|
||||||
|
2. **Relax API Route Validation**: Remove `arrContentId` from the mandatory request parameters check in `server/routes/dashboard.js`.
|
||||||
|
3. **Enhance Search Commands**:
|
||||||
|
- If a single `arrContentId` is provided, trigger `EpisodeSearch` for that single ID.
|
||||||
|
- If an `arrContentIds` array is provided, trigger `EpisodeSearch` with that list of IDs.
|
||||||
|
- If no specific episode IDs can be resolved but `arrSeriesId` is provided, fall back to triggering a series-wide `SeriesSearch`.
|
||||||
|
4. **Update Frontend & Documentation**: Update the client payload, update the OpenAPI spec, and add integration tests covering single/multi/fallback searches.
|
||||||
|
|
||||||
|
Upon approval, I will execute this plan, merge to `main`, close this ticket referencing the resolving commit, and cut a new point release (v1.7.11).
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
Title:
|
||||||
|
FEATURE: Togglable server-side (Docker) log streaming debug endpoint with dual-authentication
|
||||||
|
|
||||||
|
Problem / Requirement:
|
||||||
|
Administrators and developers need a lightweight, real-time method to stream application stdout/stderr logs (which correspond exactly to Docker container logs in standard setups) directly through the API. This enables easier live debugging without requiring full Docker daemon or terminal access.
|
||||||
|
|
||||||
|
Success Criteria:
|
||||||
|
1. Lightweight Log Streaming: Streams process standard output/error (representing Docker logs) via Server-Sent Events (SSE). Keep a rolling buffer of the last 1000 lines in memory.
|
||||||
|
2. Dual-Authentication with Webhook Secret Bypass:
|
||||||
|
- Accepts existing session cookie (emby_user) with administrative credentials.
|
||||||
|
- Accepts standard HTTP Basic Authentication (Authorization: Basic <base64>) using Emby administrator username/password credentials.
|
||||||
|
- Accepts X-Webhook-Secret header matching the SOFARR_WEBHOOK_SECRET environment variable for programmatic bypass.
|
||||||
|
3. Runtime Configuration Toggle: Enableable using a runtime environment variable ENABLE_LOG_STREAM=true (defaulting to false/disabled). When disabled, returns a 403 Forbidden response.
|
||||||
|
4. API Spec Documentation: Documented in server/openapi.yaml under the /api/debug/logs endpoint, including the query format and response schemas.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Release v1.7.16
|
||||||
|
|
||||||
|
Remediate the blocklist-search queue ID type mismatch. The "Blocklist and
|
||||||
|
search" action was returning 403 for all users because the arrQueueId
|
||||||
|
comparison used strict equality between a string (from the SPA DOM dataset)
|
||||||
|
and a number (from the Radarr/Sonarr API). Both values are now cast to
|
||||||
|
String before comparison.
|
||||||
|
|
||||||
|
See CHANGELOG.md for full details.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
63f7eaf2b4e3ca7da48c003d5986afc8ba31119404d49e45102c61fc3a27329a
|
||||||
+8
-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 } = {}) {
|
||||||
@@ -127,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.16"
|
||||||
* 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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,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);
|
||||||
|
|||||||
+5
-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
|
||||||
@@ -90,6 +92,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');
|
||||||
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');
|
||||||
@@ -246,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.16"
|
||||||
*/
|
*/
|
||||||
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 });
|
||||||
@@ -367,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);
|
||||||
|
|||||||
@@ -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;
|
||||||
+201
-7
@@ -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.17
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
@@ -791,8 +795,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:
|
||||||
@@ -828,8 +839,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:
|
||||||
@@ -865,8 +883,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:
|
||||||
@@ -1752,3 +1777,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'
|
||||||
|
|
||||||
|
|||||||
@@ -686,17 +686,33 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -221,7 +221,7 @@ 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');
|
||||||
|
|||||||
+31
-11
@@ -144,13 +144,13 @@ const OMBI_EVENTS = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
* Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
|
||||||
* @param {Object} req - Express request object
|
* @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');
|
||||||
@@ -158,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,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
|
||||||
@@ -342,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:
|
||||||
@@ -456,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
|
||||||
@@ -489,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:
|
||||||
@@ -603,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
|
||||||
@@ -627,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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -749,12 +749,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 +765,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 +780,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 +812,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 +842,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 +860,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 +884,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,17 +904,14 @@ 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 () => {
|
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', 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);
|
||||||
|
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -943,17 +928,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, 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('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', 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);
|
||||||
|
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -970,7 +952,42 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], 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('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -856,7 +856,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.post('/api/v1/Settings/notifications/webhook', {
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
id: 42,
|
id: 42,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
applicationToken: 'test-ombi-key'
|
applicationToken: 'test-ombi-key'
|
||||||
})
|
})
|
||||||
.reply(200, { success: true });
|
.reply(200, { success: true });
|
||||||
@@ -870,7 +870,7 @@ 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');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -882,7 +882,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
|||||||
.post('/api/v1/Settings/notifications/webhook', {
|
.post('/api/v1/Settings/notifications/webhook', {
|
||||||
id: 0,
|
id: 0,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||||
applicationToken: 'test-ombi-key'
|
applicationToken: 'test-ombi-key'
|
||||||
})
|
})
|
||||||
.reply(200, { success: true });
|
.reply(200, { success: true });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -156,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', () => {
|
||||||
@@ -171,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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -548,6 +583,40 @@ describe('POST /api/webhook/ombi', () => {
|
|||||||
expect(res.body.error).toBe('Unauthorized');
|
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 () => {
|
it('returns 400 when notificationType is missing or invalid', async () => {
|
||||||
const app = makeApp();
|
const app = makeApp();
|
||||||
const res = await postOmbi(app, { requestId: 1 });
|
const res = await postOmbi(app, { requestId: 1 });
|
||||||
|
|||||||
Reference in New Issue
Block a user