From 1f293ae70bb804db20997e77d41a622690d28821 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 09:51:04 +0100 Subject: [PATCH 1/4] ui: compact pill layout for detail items; red availability warning - Detail items (Size, Progress, Speed, ETA, Seeds, Peers, Availability, Completed) now render as inline pill badges with background + border- radius that wrap naturally on any screen width - Remove mobile @media override that forced flex-direction:column, which was causing one-per-line centred layout on small screens - Availability < 100%: value text shown in red (--danger) bold, both on card creation and on live SSE update via classList.toggle - Also ensures updateDownloadCard keeps availability-warning in sync --- public/app.js | 8 +++++--- public/style.css | 20 ++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/public/app.js b/public/app.js index ef1498c..8c12ad8 100644 --- a/public/app.js +++ b/public/app.js @@ -359,9 +359,10 @@ function updateDownloadCard(card, download) { peersEl.textContent = download.peers; } - const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value'); - if (availabilityEl && download.availability !== undefined) { - availabilityEl.textContent = `${download.availability}%`; + const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]'); + if (availabilityItem && download.availability !== undefined) { + availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`; + availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100); } } } @@ -558,6 +559,7 @@ function createDownloadCard(download) { if (download.availability !== undefined) { const availability = createDetailItem('Availability', `${download.availability}%`); + if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning'); details.appendChild(availability); } } diff --git a/public/style.css b/public/style.css index 8e8220b..ca25ed6 100644 --- a/public/style.css +++ b/public/style.css @@ -462,17 +462,26 @@ body { .download-details { display: flex; flex-wrap: wrap; - gap: 4px 14px; + gap: 4px 6px; padding-top: 6px; border-top: 1px solid var(--border); align-items: center; } .detail-item { - display: flex; + display: inline-flex; align-items: baseline; - gap: 4px; + gap: 3px; font-size: 0.78rem; + background: var(--bg-secondary, rgba(0,0,0,0.04)); + border-radius: 4px; + padding: 2px 6px; + white-space: nowrap; +} + +.detail-item.availability-warning .detail-value { + color: var(--danger, #e53e3e); + font-weight: 700; } .detail-label { @@ -1021,11 +1030,6 @@ body { width: 40px; } - .download-details { - flex-direction: column; - gap: 2px; - } - .progress-container { flex-wrap: wrap; } From fd0d5cf6ec8ba37124069162a22817d320afe990 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 09:53:55 +0100 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20progress=20bar=20not=20rendering=20?= =?UTF-8?q?=E2=80=94=20replace=20float:left=20with=20position:absolute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit float:left on .progress-segment was ignored inside the overflow:hidden position:relative .progress-bar container, so the coloured fill never appeared. Absolute positioning from top:0 left:0 with the JS-assigned width renders correctly. --- public/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/style.css b/public/style.css index ca25ed6..54ec556 100644 --- a/public/style.css +++ b/public/style.css @@ -519,13 +519,15 @@ body { } .progress-segment { + position: absolute; + top: 0; + left: 0; height: 100%; transition: width 0.3s ease; } .progress-segment.downloaded { background: linear-gradient(90deg, var(--progress-fill-start) 0%, var(--progress-fill-end) 100%); - float: left; } .progress-text { From a4004f5e7a641db98a863679c4fd2484b9b8b597 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 09:56:41 +0100 Subject: [PATCH 3/4] fix: progress bar width collapsed by pill display:inline-flex The pill redesign set display:inline-flex + white-space:nowrap on all .detail-item elements. The .progress-item (which extends .detail-item) was then shrinking the .progress-bar to zero usable width. Override pill styles on .progress-item: display:flex, no background, no padding, white-space:normal. Also give .progress-container flex:1 so it expands to fill the row. --- public/style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/public/style.css b/public/style.css index 54ec556..88b0f77 100644 --- a/public/style.css +++ b/public/style.css @@ -500,10 +500,17 @@ body { /* ===== Progress Bar (Compact) ===== */ .progress-item { flex-basis: 100%; + display: flex; + align-items: center; + background: none; + padding: 0; + white-space: normal; + border-radius: 0; } .progress-container { display: flex; + flex: 1; align-items: center; gap: 8px; } From 121c49b35b8254a816eadf57c0ca6c49224f22c0 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 10:06:43 +0100 Subject: [PATCH 4/4] docs: update ARCHITECTURE.md and README for 1.0.x fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE.md: - Cookie secure flag: NODE_ENV → TRUST_PROXY (3 locations) - upgrade-insecure-requests: document it gates on TRUST_PROXY not NODE_ENV - Docker image note: NODE_ENV=production no longer implies secure cookies - Security checklist: clarify TRUST_PROXY enables secure cookie + CSP + HSTS - dashboard.js route table: add /stream endpoint note - NODE_ENV env var table: correct description README.md: - qBittorrent availability: note red highlight when < 100% - Login side-effects: secure cookie gated on TRUST_PROXY not NODE_ENV --- README.md | 2 +- docs/ARCHITECTURE.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3154fa6..3e60666 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ sofarr polls all configured services in the background and caches the results. D ### For qBittorrent Downloads - **Seeds** - Number of seeders - **Peers** - Number of peers -- **Availability** - Percentage available in swarm +- **Availability** - Percentage available in swarm (shown in red when below 100%) ## API Endpoints diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d588c67..c1a80e9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -203,7 +203,7 @@ sofarr/ | Module | Mount Point | Auth Required | CSRF Required | Purpose | |--------|------------|:-------------:|:-------------:|--------| | `auth.js` | `/api/auth` | No | No | Login, session check, CSRF token, logout | -| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes | Aggregated download data, status, cover-art proxy | +| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Yes (except `/stream` — GET) | SSE stream, aggregated download data, status, cover-art proxy | | `emby.js` | `/api/emby` | Yes (`requireAuth`) | Yes | Proxy to Emby API | | `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Yes | Proxy to SABnzbd API | | `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Yes | Proxy to Sonarr API | @@ -293,7 +293,7 @@ For each connected user the server: 6. Sets an `httpOnly` `emby_user` cookie containing only `{ id, name, isAdmin }`: - **`rememberMe: true`** → persistent cookie, `Max-Age` 30 days - **`rememberMe: false`** → session cookie (no `Max-Age`; expires when browser closes) - - `secure` flag enabled when `NODE_ENV=production` + - `secure` flag enabled when `TRUST_PROXY` is set (i.e. a TLS-terminating reverse proxy is in front) - Signed with HMAC when `COOKIE_SECRET` is set 7. Issues a `csrf_token` cookie (`httpOnly: false` so JS can read it) containing a 32-byte random hex token 8. Returns `{ success: true, user, csrfToken }` — the SPA stores `csrfToken` in memory and sends it as `X-CSRF-Token` on all subsequent state-changing requests @@ -657,7 +657,7 @@ The status panel refreshes on a fixed 5-second timer and shows each SSE client w | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `PORT` | No | `3001` | Server listen port | -| `NODE_ENV` | No | — | Set to `production` to enable `secure` cookies and HTTPS upgrades | +| `NODE_ENV` | No | — | Set to `production` for production logging and startup validation. Does **not** control cookie `secure` flag or CSP `upgrade-insecure-requests` (both gated on `TRUST_PROXY`). | | `DATA_DIR` | No | `./data` | Directory for `tokens.json` and `server.log`. Must be writable by the server process. In Docker: `/app/data` (named volume). | | `COOKIE_SECRET` | No | — | If set, signs all session cookies with HMAC-SHA256. Strongly recommended in production. | | `TRUST_PROXY` | No | — | Express `trust proxy` setting. Set to `1` (or a hop count) when behind a reverse proxy (nginx, Traefik, etc.) so `req.ip` and `req.secure` are correct. | @@ -724,7 +724,7 @@ The production image uses a two-stage build on `node:22-alpine`: 2. **`runtime` stage** — copies `node_modules`, `server/`, `public/`, and `package.json`. Runs as the built-in non-root `node` user (UID 1000). `/app/data` is owned by `node` for writable token store and logs. Key environment variables set in the image: -- `NODE_ENV=production` — enables secure cookies and HTTPS upgrade CSP directive +- `NODE_ENV=production` — enables production startup validation and logging - `DATA_DIR=/app/data` — token store and log file location ### Docker Compose @@ -762,7 +762,7 @@ volumes: - **Set `COOKIE_SECRET`** — enables HMAC-signed cookies, preventing client-side forgery. - **Set `TRUST_PROXY=1`** when behind a reverse proxy — ensures `req.secure` is `true` so the `secure` cookie flag is enforced and HTTPS-upgrade CSP fires. - **Mount a named volume** for `DATA_DIR` — token store and log file survive container recreates. -- **Use HTTPS** — the CSP includes `upgrade-insecure-requests` in production and the HSTS header is set with a 1-year `maxAge`. +- **Use HTTPS** — set `TRUST_PROXY=1` to enable the CSP `upgrade-insecure-requests` directive, the `secure` cookie flag, and HSTS (1-year `maxAge`). - **Rate limiting** — the login endpoint is limited to 10 failed attempts per IP per 15 minutes; all API endpoints share a 300 req/15 min window. Set `TRUST_PROXY` correctly so the client IP is not the proxy's IP. ### CI / CD