Compare commits
16 Commits
v1.7.9
...
release/1.7.15
| Author | SHA1 | Date | |
|---|---|---|---|
| f2c01903fa | |||
| 8b6ef0f64f | |||
| 7b9c895888 | |||
| 2b5ac2d7c5 | |||
| b5b4862e15 | |||
| 11b3296198 | |||
| 76631cd37e | |||
| 88bfa52eba | |||
| 95e301ef56 | |||
| 3c6791658c | |||
| 9c7dcf55b0 | |||
| afc940aba7 | |||
| 5488969387 | |||
| ec9b1c6d94 | |||
| 3f8970ea99 | |||
| 9491948ec9 |
+53
-4
@@ -393,9 +393,9 @@ POST /api/webhook/ombi
|
||||
Both endpoints share identical processing logic:
|
||||
|
||||
```
|
||||
Sonarr/Radarr
|
||||
Sonarr/Radarr/Ombi
|
||||
POST /api/webhook/sonarr
|
||||
Headers: X-Sofarr-Webhook-Secret: <secret>
|
||||
Headers: X-Sofarr-Webhook-Secret: <secret> OR URL parameter: ?secret=<secret>
|
||||
Body: { "eventType": "Grab", "instanceName": "Main Sonarr",
|
||||
"date": "2026-05-19T10:00:00.000Z", … }
|
||||
│
|
||||
@@ -404,6 +404,7 @@ Sonarr/Radarr
|
||||
│
|
||||
▼
|
||||
validateWebhookSecret() ──fail──► 401 Unauthorized
|
||||
(Checks header or query param)
|
||||
│ ok
|
||||
▼
|
||||
validatePayload() ──fail──► 400 Bad Request
|
||||
@@ -994,6 +995,42 @@ For AI agents and automated tooling, every endpoint includes:
|
||||
- Lints `server/openapi.yaml` with `@stoplight/spectral-cli`
|
||||
- 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
|
||||
@@ -1188,7 +1225,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
||||
| 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). |
|
||||
| **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. |
|
||||
| **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 |
|
||||
|---------|-----------|
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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`. |
|
||||
|
||||
### 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
|
||||
|
||||
@@ -4,6 +4,68 @@ All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.15] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi Webhook Authentication Failures** — Resolved a critical issue where Ombi webhook notifications failed to authenticate because Ombi's built-in notification agent does not support custom HTTP headers (such as `X-Sofarr-Webhook-Secret`). Added a query parameter authentication fallback (`?secret=`) to all `/api/webhook/*` endpoints (Sonarr, Radarr, and Ombi) and configured Ombi webhook registration to automatically append this secret query parameter. Resolves Gitea Issue [#47](https://git.i3omb.com/Gandalf/sofarr/issues/47).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.14] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Undefined Reference Error in Background Poller** — Resolved a critical runtime exception in the background scheduler loop (`server/utils/poller.js`) where `logToFile` was called on cache updates but was never imported at the top of the file, previously triggering `[Poller] Poll error: logToFile is not defined` on every interval loop.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.13] - 2026-05-24
|
||||
|
||||
### Changed
|
||||
|
||||
- **Comprehensive OpenAPI & Swagger Specification Remediation** — Bumped the API documentation version to `1.7.13` and fully documented all operational rate-limiting configurations, exemptions, and bypasses in `server/openapi.yaml` (including general cover-art exclusions, failed-only login trackers, webhook limiters, and rate-limit exempt root health probes).
|
||||
- **Aligned Health Check Endpoint Implementation** — Enhanced the express application factory `/health` endpoint to dynamically require and return the active version from `package.json`, keeping it fully aligned with the production entrypoint server logic.
|
||||
- **Synchronized Security & System Architecture Docs** — Aligned security matrices and threat mitigations in `SECURITY.md` and rate-limiting testing configurations in `ARCHITECTURE.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **Swagger API Coverage Verification Integration** — Implemented comprehensive assertions within `tests/integration/swagger-coverage.test.js` to dynamically verify that all newly added logging and debug endpoints (`/api/debug/*`) are fully represented in the active specification, raising test suite coverage to 876 passing checks.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.12] - 2026-05-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Real-Time Server-Side Log Streaming (`GET /api/debug/server-logs`)** — Introduced an in-process output stream capturer that intercepts standard output (`stdout`) and standard error (`stderr`), strips terminal ANSI escape codes, maintains a rolling 1000-line buffer, and streams live operational logs via Server-Sent Events (SSE). Resolves Gitea Issue [#45](https://git.i3omb.com/Gandalf/sofarr/issues/45).
|
||||
- **Real-Time Client-Side Console Log Stream (`GET & POST /api/debug/client-logs`)** — Added a browser-side console override in the frontend SPA that intercepts `console.log`, `console.warn`, and `console.error` calls. Logs are queued and posted to `/api/debug/client-logs` in optimized batches every 2000ms (or when the queue reaches 20 items), which are then buffered and streamed over SSE to debugging developers. Resolves Gitea Issue [#46](https://git.i3omb.com/Gandalf/sofarr/issues/46).
|
||||
- **Subnet IP Filtering (`LOG_ALLOW_SUBNETS`)** — Implemented an optional CIDR-based subnet validation check using `ipaddr.js` to restrict access to debug logs endpoints to specific internal IP ranges (e.g. `127.0.0.1/32,192.168.1.0/24`). Works natively with reverse proxies when `TRUST_PROXY` is enabled.
|
||||
- **Multi-Layered Security Bypass & Auth** — Debug endpoints are guarded globally by `ENABLE_LOG_STREAM=true` (off by default) and require Emby admin sessions, standard HTTP Basic Auth fallback checks against Emby, or high-priority bypass via header `X-Webhook-Secret`.
|
||||
- **Swagger UI Specification Coverage** — Fully documented all four new endpoints, headers, and schemas in `server/openapi.yaml`, with automated verification integrated inside `swagger-coverage.test.js`.
|
||||
- **Architectural & Developer Guides** — Updated `ARCHITECTURE.md` (with system overview, diagrams, stdout/stderr hooks, and threat mitigations) and `README.md` (deploy instructions and querying parameters).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.11] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID).
|
||||
- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved.
|
||||
- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.10] - 2026-05-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook race condition fix** — Introduced a 2000ms delay in the webhook background worker for Ombi events before fetching new requests. This resolves a race condition where Ombi triggers the webhook before its database has committed the new request, which previously caused the request to be missing from the subsequent API fetch. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
- **Ombi webhook statistics in status panel** — Integrated Ombi webhook metrics into the admin status panel. The backend now retrieves the Ombi webhook configuration status and aggregates its events received and polls skipped metrics. The frontend displays these metrics (consistently formatted as `O:<count>`) under the Webhooks status card.
|
||||
|
||||
---
|
||||
|
||||
## [1.7.9] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -227,6 +227,10 @@ PORT=3001 # Server port
|
||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||
# 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
|
||||
@@ -441,6 +445,12 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e
|
||||
### 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`)
|
||||
- `POST /api/webhook/sonarr` — receive Sonarr 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 |
|
||||
| Unbounded log growth | Size-based rotation: 10 MB cap, 3 rotated files kept |
|
||||
| Dependency vulnerabilities | `npm audit --audit-level=high` in CI on every push |
|
||||
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header; 401 on mismatch |
|
||||
| Unauthorized webhook injection | `SOFARR_WEBHOOK_SECRET` required on `X-Sofarr-Webhook-Secret` header or `secret` query parameter; 401 on mismatch |
|
||||
| Webhook payload injection | `validatePayload()` allowlists 18 known event types; rejects non-object bodies and overlong fields |
|
||||
| Webhook replay attacks | `isReplay()` tracks `(eventType, instanceName, date)` tuples for 5 minutes; duplicate events return `200 { duplicate: true }` without cache mutation |
|
||||
| Webhook flood / DoS | Dedicated rate limiter: 60 requests/min per IP on `/api/webhook/*` |
|
||||
@@ -162,12 +162,13 @@ server {
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Endpoint | Limit |
|
||||
|----------|-------|
|
||||
| `POST /api/auth/login` | 10 failed attempts per 15 min per IP |
|
||||
| All `/api/*` routes | 300 requests per 15 min per IP |
|
||||
| `POST /api/webhook/*` | 60 requests per 1 min per IP (webhook-specific limiter, stricter than general) |
|
||||
| `GET /api/swagger` | No rate limit (public documentation) |
|
||||
| Endpoint | Limit | Details & Exemptions |
|
||||
|----------|-------|----------------------|
|
||||
| `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 | 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. |
|
||||
| `/health` and `/ready` | Exempt | Root-level liveness/readiness probes bypass rate limiters completely. |
|
||||
| `GET /api/swagger` | Exempt | Public Swagger UI documentation does not enforce rate limits. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
arrContentType: download.arrContentType
|
||||
})
|
||||
});
|
||||
|
||||
@@ -11,8 +11,12 @@ import { initThemeSwitcher } from './ui/theme.js';
|
||||
import { initTabs, goHome } from './ui/tabs.js';
|
||||
import { handleShowAllToggle } from './sse.js';
|
||||
import { loadAppVersion } from './api.js';
|
||||
import { initClientLogCapture } from './utils/clientLogCapture.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize client console log capturing early
|
||||
initClientLogCapture();
|
||||
|
||||
// Login form
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
|
||||
@@ -272,6 +272,8 @@ export async function handleBlocklistSearchClick(btn, download) {
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentIds: download.arrContentIds,
|
||||
arrSeriesId: download.arrSeriesId,
|
||||
arrContentType: download.arrContentType,
|
||||
isAdmin: state.isAdmin,
|
||||
canBlocklist: download.canBlocklist
|
||||
|
||||
@@ -118,18 +118,22 @@ export function renderStatusPanel(data, panel) {
|
||||
const wh = data.webhooks;
|
||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||
const ombiEnabled = wh.ombi?.enabled ? '●' : '○';
|
||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||
const ombiEvents = wh.ombi?.eventsReceived || 0;
|
||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||
const ombiPolls = wh.ombi?.pollsSkipped || 0;
|
||||
|
||||
html += `
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Webhooks</div>
|
||||
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
||||
<div class="status-row"><span>Ombi</span><span>${ombiEnabled} ${wh.ombi?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents} O:${ombiEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls} O:${ombiPolls}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
"version": "1.7.9",
|
||||
"version": "1.7.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.9",
|
||||
"version": "1.7.15",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.9",
|
||||
"version": "1.7.15",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+8
-1
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const YAML = require('yamljs');
|
||||
const path = require('path');
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const sabnzbdRoutes = require('./routes/sabnzbd');
|
||||
const sonarrRoutes = require('./routes/sonarr');
|
||||
@@ -26,6 +27,7 @@ const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const webhookRoutes = require('./routes/webhook');
|
||||
const ombiRoutes = require('./routes/ombi');
|
||||
const debugRoutes = require('./routes/debug');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
|
||||
function createApp({ skipRateLimits = false } = {}) {
|
||||
@@ -127,13 +129,17 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
* type: number
|
||||
* description: Server uptime in seconds
|
||||
* example: 3600.5
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.14"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
* source: curl http://localhost:3001/health
|
||||
*/
|
||||
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/auth', authRoutes);
|
||||
app.use('/api/webhook', webhookRoutes);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
|
||||
// CSRF protection for all state-changing API requests below
|
||||
app.use('/api', verifyCsrf);
|
||||
|
||||
+5
-1
@@ -13,6 +13,8 @@ const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const YAML = require('yamljs');
|
||||
require('dotenv').config();
|
||||
require('./utils/loadSecrets')();
|
||||
const logCapture = require('./utils/logCapture');
|
||||
logCapture.init();
|
||||
const { version } = require('../package.json');
|
||||
|
||||
// Setup logging with levels
|
||||
@@ -90,6 +92,7 @@ const historyRoutes = require('./routes/history');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const webhookRoutes = require('./routes/webhook');
|
||||
const ombiRoutes = require('./routes/ombi');
|
||||
const debugRoutes = require('./routes/debug');
|
||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
const { validateInstanceUrl } = require('./utils/config');
|
||||
@@ -246,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.6.0"
|
||||
* example: "1.7.14"
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||
@@ -367,6 +370,7 @@ function serveIndex(req, res) {
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/webhook', webhookRoutes);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
|
||||
// All routes below this point require CSRF validation on mutating methods
|
||||
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;
|
||||
+211
-8
@@ -12,13 +12,17 @@ info:
|
||||
4. Subsequent requests must include the cookies and send the `X-CSRF-Token` header for state-changing operations (POST, PUT, PATCH, DELETE)
|
||||
|
||||
## Rate Limiting
|
||||
- General API: 300 requests per 15 minutes per IP
|
||||
- Login: 10 failed attempts per 15 minutes per IP
|
||||
- Webhooks: 60 requests per minute per IP
|
||||
To protect the system from resource exhaustion, rate limiters are enforced at different levels:
|
||||
- **General API Limiter**: Enforces a limit of **300 requests per 15 minutes** per IP across all `/api/*` endpoints.
|
||||
- *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
|
||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||
version: 1.6.0
|
||||
version: 1.7.15
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
@@ -276,7 +280,6 @@ components:
|
||||
- arrQueueId
|
||||
- arrType
|
||||
- arrInstanceUrl
|
||||
- arrContentId
|
||||
- arrContentType
|
||||
properties:
|
||||
arrQueueId:
|
||||
@@ -301,6 +304,16 @@ components:
|
||||
type: integer
|
||||
description: episodeId (Sonarr) or movieId (Radarr)
|
||||
example: 456
|
||||
arrContentIds:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
description: Array of episodeIds for multi-episode packs (Sonarr)
|
||||
example: [456, 457]
|
||||
arrSeriesId:
|
||||
type: integer
|
||||
description: seriesId for fallback automatic series search (Sonarr)
|
||||
example: 789
|
||||
arrContentType:
|
||||
type: string
|
||||
enum: [episode, movie]
|
||||
@@ -782,8 +795,15 @@ paths:
|
||||
post:
|
||||
tags: [Webhook]
|
||||
summary: Sonarr webhook
|
||||
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header.
|
||||
description: Receives webhook events from Sonarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||
security: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -819,8 +839,15 @@ paths:
|
||||
post:
|
||||
tags: [Webhook]
|
||||
summary: Radarr webhook
|
||||
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header.
|
||||
description: Receives webhook events from Radarr. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||
security: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -856,8 +883,15 @@ paths:
|
||||
post:
|
||||
tags: [Webhook]
|
||||
summary: Ombi webhook
|
||||
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header.
|
||||
description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header or secret query parameter.
|
||||
security: []
|
||||
parameters:
|
||||
- name: secret
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -1743,3 +1777,172 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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'
|
||||
|
||||
|
||||
@@ -676,10 +676,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
||||
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentIds, arrSeriesId, arrContentType } = req.body;
|
||||
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) {
|
||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
||||
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
|
||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||
@@ -724,8 +724,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
// Step 2: Trigger a new automatic search
|
||||
let commandBody;
|
||||
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
||||
if (arrContentId) {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||
} else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) {
|
||||
commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) };
|
||||
} else if (arrSeriesId) {
|
||||
commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) };
|
||||
}
|
||||
} else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) {
|
||||
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||
}
|
||||
|
||||
@@ -737,7 +743,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||
const { pollAllServices } = require('../utils/poller');
|
||||
pollAllServices().catch(() => {});
|
||||
|
||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
||||
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId || 'none'} by ${user.name}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const logStreamAuth = require('../middleware/logStreamAuth');
|
||||
const {
|
||||
logEmitter,
|
||||
logBuffer,
|
||||
clientLogBuffer,
|
||||
ingestClientLogs
|
||||
} = require('../utils/logCapture');
|
||||
|
||||
// Public status check (no auth, no 403 block, returns standard config state)
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
|
||||
});
|
||||
|
||||
// Global toggle check
|
||||
router.use((req, res, next) => {
|
||||
if (process.env.ENABLE_LOG_STREAM !== 'true') {
|
||||
return res.status(403).json({ error: 'Log streaming feature is disabled' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Enforce subnet and authentication validations on all debug routes
|
||||
router.use(logStreamAuth);
|
||||
|
||||
/**
|
||||
* GET /api/debug/server-logs
|
||||
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
|
||||
*/
|
||||
router.get('/server-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical server logs buffer first
|
||||
for (const line of logBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending server log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('server-log', sendLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('server-log', sendLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/debug/client-logs
|
||||
* Exposes a real-time SSE stream of ingested client-side console logs.
|
||||
*/
|
||||
router.get('/client-logs', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
});
|
||||
|
||||
// Send historical client logs buffer first
|
||||
for (const line of clientLogBuffer) {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
}
|
||||
|
||||
// Gracefully close for integration testing
|
||||
if (req.query.testClose === 'true') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const sendClientLog = (line) => {
|
||||
try {
|
||||
res.write(`data: ${line}\n\n`);
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Error sending client log line:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
logEmitter.on('client-log', sendClientLog);
|
||||
|
||||
// 25s heartbeat comment to prevent proxy timeouts
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(': heartbeat\n\n');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}, 25000);
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
logEmitter.off('client-log', sendClientLog);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/debug/client-logs
|
||||
* Receives batches of frontend console logs to store in buffer and emit.
|
||||
*/
|
||||
router.post('/client-logs', (req, res) => {
|
||||
const logs = req.body;
|
||||
if (!Array.isArray(logs)) {
|
||||
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
|
||||
}
|
||||
|
||||
try {
|
||||
ingestClientLogs(logs);
|
||||
return res.status(200).json({ success: true, count: logs.length });
|
||||
} catch (err) {
|
||||
console.error('[debugRoutes] Ingestion failed:', err.message);
|
||||
return res.status(500).json({ error: 'Internal server error during ingestion' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const ombiInst = ombiInstances[0];
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`;
|
||||
|
||||
// Call Ombi API to register webhook
|
||||
const axios = require('axios');
|
||||
|
||||
+12
-4
@@ -4,9 +4,9 @@ const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const cache = require('../utils/cache');
|
||||
const { getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const { getSonarrInstances, getRadarrInstances, getOmbiInstances } = require('../utils/config');
|
||||
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||
const { checkWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||
const { checkWebhookConfigured, checkOmbiWebhookConfigured, aggregateMetrics } = require('../services/WebhookStatus');
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -121,6 +121,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
// Check webhook configuration for each service
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
||||
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
||||
@@ -128,15 +129,21 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const radarrWebhookConfigured = radarrInstances.length > 0
|
||||
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
||||
: false;
|
||||
const ombiWebhookConfigured = ombiInstances.length > 0
|
||||
? await checkOmbiWebhookConfigured(ombiInstances[0])
|
||||
: false;
|
||||
|
||||
// Find Sonarr and Radarr metrics from instances
|
||||
// Find Sonarr, Radarr, and Ombi metrics from instances
|
||||
const sonarrMetrics = {};
|
||||
const radarrMetrics = {};
|
||||
const ombiMetrics = {};
|
||||
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
||||
if (url.includes('sonarr')) {
|
||||
sonarrMetrics[url] = metrics;
|
||||
} else if (url.includes('radarr')) {
|
||||
radarrMetrics[url] = metrics;
|
||||
} else if (url.includes('ombi') || (ombiInstances.length > 0 && url === ombiInstances[0].url)) {
|
||||
ombiMetrics[url] = metrics;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +163,8 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
cache: cacheStats,
|
||||
webhooks: {
|
||||
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
||||
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured),
|
||||
ombi: aggregateMetrics(ombiMetrics, ombiWebhookConfigured)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
+33
-11
@@ -144,13 +144,13 @@ const OMBI_EVENTS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header
|
||||
* Validate webhook secret from the X-Sofarr-Webhook-Secret header or secret query parameter
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {boolean} True if secret is valid, false otherwise
|
||||
*/
|
||||
function validateWebhookSecret(req) {
|
||||
const expectedSecret = getWebhookSecret();
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret');
|
||||
const providedSecret = req.get('X-Sofarr-Webhook-Secret') || req.query.secret;
|
||||
|
||||
if (!expectedSecret) {
|
||||
logToFile('[Webhook] WARNING: SOFARR_WEBHOOK_SECRET not configured, rejecting webhook');
|
||||
@@ -158,7 +158,7 @@ function validateWebhookSecret(req) {
|
||||
}
|
||||
|
||||
if (!providedSecret) {
|
||||
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header');
|
||||
logToFile('[Webhook] WARNING: Missing X-Sofarr-Webhook-Secret header or secret query parameter');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -259,6 +259,8 @@ async function processWebhookEvent(serviceType, eventType) {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
if (affectsOmbi) {
|
||||
// Add a 2000ms delay to resolve the race condition where Ombi fires the webhook before committing to DB
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||
@@ -307,13 +309,13 @@ function validatePayload(body) {
|
||||
* Receives webhook events from Sonarr instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Sonarr, not browsers).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **Validation:**
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
@@ -340,6 +342,13 @@ function validatePayload(body) {
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -454,13 +463,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
* Receives webhook events from Radarr instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Radarr, not browsers).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **Validation:**
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||
* - Payload validation (must be JSON object with eventType, instanceName, date)
|
||||
* - Event type must be in allowlist (Test, Grab, Download, DownloadFailed, etc.)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
@@ -487,6 +496,13 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Events: onGrab, onDownload, onUpgrade, onImport
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
@@ -601,13 +617,13 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
* Receives webhook events from Ombi instances. Validates the secret, logs the event,
|
||||
* refreshes cache, broadcasts SSE, and returns 200 immediately (fire-and-forget processing).
|
||||
*
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* **Authentication:** Requires `X-Sofarr-Webhook-Secret` header or `secret` query parameter matching `SOFARR_WEBHOOK_SECRET`.
|
||||
* No cookie authentication required (webhooks come from Ombi, not browsers).
|
||||
*
|
||||
* **Rate Limiting:** 60 requests per minute per IP.
|
||||
*
|
||||
* **Validation:**
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header
|
||||
* - Secret validation via `X-Sofarr-Webhook-Secret` header or `secret` query parameter
|
||||
* - Payload validation (must be JSON object with notificationType, requestId)
|
||||
* - Event type must be in allowlist (RequestAvailable, RequestApproved, RequestDeclined, RequestPending, RequestProcessing)
|
||||
* - Replay protection: rejects duplicate events within 5-minute window
|
||||
@@ -625,11 +641,17 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
||||
* 6. Background: fetch fresh data from Ombi, update cache, broadcast SSE
|
||||
*
|
||||
* **x-integration-notes:** Configure Ombi webhook:
|
||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi`
|
||||
* - URL: `{SOFARR_BASE_URL}/api/webhook/ombi?secret={SOFARR_WEBHOOK_SECRET}`
|
||||
* - Method: POST
|
||||
* - Header: `X-Sofarr-Webhook-Secret: {SOFARR_WEBHOOK_SECRET}`
|
||||
* - Application Token: OMBI_API_KEY
|
||||
* security: []
|
||||
* parameters:
|
||||
* - name: secret
|
||||
* in: query
|
||||
* required: false
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Webhook secret token (alternative to X-Sofarr-Webhook-Secret header)
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
|
||||
@@ -209,6 +209,8 @@ async function matchSabSlots(slots, context) {
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
@@ -451,6 +453,8 @@ async function matchTorrents(torrents, context) {
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
download.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
download.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
|
||||
@@ -49,7 +49,26 @@ function aggregateMetrics(metricsMap, configured) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Sofarr webhook is configured in an Ombi instance.
|
||||
* @param {Object} instance - The Ombi instance config
|
||||
* @returns {Promise<boolean>} true if webhook is configured
|
||||
*/
|
||||
async function checkOmbiWebhookConfigured(instance) {
|
||||
try {
|
||||
const response = await axios.get(`${instance.url}/api/v1/Settings/notifications/webhook`, {
|
||||
headers: { 'ApiKey': instance.apiKey },
|
||||
timeout: 5000
|
||||
});
|
||||
return !!(response.data && response.data.enabled);
|
||||
} catch (err) {
|
||||
console.log(`[WebhookStatus] Failed to check Ombi webhook config: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkWebhookConfigured,
|
||||
checkOmbiWebhookConfigured,
|
||||
aggregateMetrics
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
getOmbiInstances
|
||||
} = require('./config');
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -918,6 +918,60 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
||||
expect(res.status).toBe(502);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_BASE)
|
||||
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
|
||||
.reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
|
||||
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
||||
const app = createApp({ skipRateLimits: true });
|
||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||
|
||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
||||
]);
|
||||
|
||||
nock(SONARR_BASE)
|
||||
.delete('/api/v3/queue/1001')
|
||||
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||
.reply(200, {});
|
||||
nock(SONARR_BASE)
|
||||
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
|
||||
.reply(200, {});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/dashboard/blocklist-search')
|
||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||
.set('X-CSRF-Token', csrf)
|
||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
mockGetAllDownloads.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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', {
|
||||
id: 42,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
@@ -870,7 +870,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi`);
|
||||
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`);
|
||||
expect(res.body.applicationToken).toBe('test-ombi-key');
|
||||
});
|
||||
|
||||
@@ -882,7 +882,7 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
.post('/api/v1/Settings/notifications/webhook', {
|
||||
id: 0,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
|
||||
@@ -196,6 +196,18 @@ describe('Swagger Coverage', () => {
|
||||
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 () => {
|
||||
const response = await request(app).get('/api/swagger').redirects(1);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -156,6 +156,24 @@ describe('POST /api/webhook/sonarr — secret validation', () => {
|
||||
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post(`/api/webhook/sonarr?secret=${VALID_SECRET}`)
|
||||
.send(SONARR_GRAB);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/webhook/sonarr?secret=wrong-query-secret')
|
||||
.send(SONARR_GRAB);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webhook/radarr — secret validation', () => {
|
||||
@@ -171,6 +189,23 @@ describe('POST /api/webhook/radarr — secret validation', () => {
|
||||
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post(`/api/webhook/radarr?secret=${VALID_SECRET}`)
|
||||
.send(RADARR_GRAB);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/webhook/radarr?secret=wrong-query-secret')
|
||||
.send(RADARR_GRAB);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -548,6 +583,40 @@ describe('POST /api/webhook/ombi', () => {
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
|
||||
const app = makeApp();
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, []);
|
||||
nock('https://ombi.test')
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, []);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/webhook/ombi?secret=${VALID_SECRET}`)
|
||||
.send({
|
||||
notificationType: 'NewRequest',
|
||||
requestId: 127,
|
||||
requestedUser: 'gordon',
|
||||
title: 'Query Movie',
|
||||
type: 'Movie',
|
||||
requestStatus: 'Pending',
|
||||
applicationUrl: 'https://ombi.test',
|
||||
requestedDate: '2026-05-23T20:40:00.000Z'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.received).toBe(true);
|
||||
});
|
||||
|
||||
it('returns 401 when secret is provided as an invalid query parameter', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post('/api/webhook/ombi?secret=wrong-query-secret')
|
||||
.send({ notificationType: 'NewRequest', requestId: 1 });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
it('returns 400 when notificationType is missing or invalid', async () => {
|
||||
const app = makeApp();
|
||||
const res = await postOmbi(app, { requestId: 1 });
|
||||
|
||||
Reference in New Issue
Block a user