Compare commits

...

15 Commits

Author SHA1 Message Date
gronod 6c847a26d3 merge: fix BOT_TOKEN secret name
Build and Push Docker Image / build (push) Successful in 31s
CI / Security audit (push) Successful in 53s
CI / Tests & coverage (push) Successful in 1m4s
CI / Security audit (pull_request) Successful in 55s
CI / Tests & coverage (pull_request) Successful in 1m4s
2026-05-17 10:12:57 +01:00
gronod 28f2aa17d8 ci: rename secret GITEA_TOKEN → BOT_TOKEN (GITEA_ prefix is reserved)
Build and Push Docker Image / build (push) Successful in 37s
CI / Security audit (push) Successful in 56s
CI / Tests & coverage (push) Successful in 1m4s
2026-05-17 10:12:51 +01:00
gronod aa8a6a49f4 merge: add render-diagrams workflow
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-17 10:10:05 +01:00
gronod 0ffe62e1ca ci: add render-diagrams workflow (.puml → .png committed back to repo)
Build and Push Docker Image / build (push) Successful in 31s
CI / Security audit (push) Successful in 1m2s
CI / Tests & coverage (push) Successful in 1m8s
2026-05-17 10:09:59 +01:00
gronod 925d0c7735 merge: develop into release/1.0.0 (doc + UI fixes)
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
2026-05-17 10:06:46 +01:00
gronod 121c49b35b docs: update ARCHITECTURE.md and README for 1.0.x fixes
Build and Push Docker Image / build (push) Successful in 22s
CI / Security audit (push) Successful in 1m0s
CI / Tests & coverage (push) Successful in 1m14s
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
2026-05-17 10:06:43 +01:00
gronod a4004f5e7a fix: progress bar width collapsed by pill display:inline-flex
Build and Push Docker Image / build (push) Successful in 29s
CI / Security audit (push) Successful in 47s
CI / Tests & coverage (push) Successful in 52s
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.
2026-05-17 09:56:41 +01:00
gronod fd0d5cf6ec fix: progress bar not rendering — replace float:left with position:absolute
Build and Push Docker Image / build (push) Successful in 29s
CI / Security audit (push) Successful in 50s
CI / Tests & coverage (push) Successful in 56s
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.
2026-05-17 09:53:55 +01:00
gronod 1f293ae70b ui: compact pill layout for detail items; red availability warning
Build and Push Docker Image / build (push) Successful in 38s
CI / Security audit (push) Successful in 56s
CI / Tests & coverage (push) Successful in 1m1s
- 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
2026-05-17 09:51:04 +01:00
gronod 352118b4af merge: cookie secure fix from release/1.0.0
Build and Push Docker Image / build (push) Successful in 26s
CI / Security audit (push) Successful in 41s
CI / Tests & coverage (push) Successful in 52s
2026-05-17 09:43:11 +01:00
gronod f41d14b2a9 fix: gate cookie secure flag on TRUST_PROXY not NODE_ENV
Build and Push Docker Image / build (push) Successful in 36s
CI / Security audit (push) Successful in 49s
CI / Tests & coverage (push) Successful in 59s
secure:true cookies are only sent by browsers over HTTPS connections.
When NODE_ENV=production (always set in the Docker container) but no
TLS proxy is in front, the browser receives the cookie on login but
refuses to send it on subsequent HTTP requests — causing every
authenticated endpoint (/stream, /status, etc.) to return 401.

The correct signal is TRUST_PROXY: it is only set when a TLS-terminating
reverse proxy is confirmed to be in front. Affects emby_user and
csrf_token cookies across login, /csrf refresh, and logout.
2026-05-17 09:42:56 +01:00
gronod 240fc0d3b6 merge: release/1.0.0 fixes into develop
Build and Push Docker Image / build (push) Successful in 28s
CI / Security audit (push) Successful in 1m2s
CI / Tests & coverage (push) Successful in 1m1s
2026-05-17 09:38:09 +01:00
gronod c3ae3a80de fix: correct upgradeInsecureRequests in index.js (the actual production config)
Build and Push Docker Image / build (push) Successful in 26s
CI / Security audit (push) Successful in 42s
CI / Tests & coverage (push) Successful in 1m5s
The previous fix was applied to server/app.js (the test factory) but
index.js has its own independent Helmet configuration which is what the
production server actually executes. Both files now gate
upgrade-insecure-requests on TRUST_PROXY instead of NODE_ENV.
2026-05-17 09:36:26 +01:00
gronod 94fe0dea4d fix: only emit upgrade-insecure-requests when TRUST_PROXY is set
Build and Push Docker Image / build (push) Successful in 31s
CI / Tests & coverage (push) Has been cancelled
CI / Security audit (push) Has been cancelled
NODE_ENV=production enabled upgrade-insecure-requests unconditionally,
which instructed browsers to upgrade HTTP subresource requests to HTTPS.
When sofarr is accessed directly over HTTP (no reverse proxy), this
silently blocks all CSS, JS, and image loads — the page renders unstyled
with no functionality.

The correct signal for 'we are behind HTTPS' is TRUST_PROXY, not
NODE_ENV. upgrade-insecure-requests is now only emitted when a
TLS-terminating reverse proxy is confirmed to be in front.
2026-05-17 09:34:52 +01:00
gronod 3c3382401c fix: remove nonce from <link> tags — breaks CSS on mobile/caching proxies
Build and Push Docker Image / build (push) Successful in 22s
CI / Security audit (push) Successful in 49s
CI / Tests & coverage (push) Successful in 1m4s
style-src 'self' already permits same-origin stylesheets without a nonce.
Injecting a nonce onto <link rel=stylesheet> causes silent CSS failure on
mobile Safari and any setup where a caching proxy serves stale HTML (the
nonce in the HTML no longer matches the per-request CSP header nonce).

Nonce injection is now limited to <script> tags only, where it is
actually required to permit the same-origin app.js.
2026-05-17 09:28:44 +01:00
8 changed files with 92 additions and 31 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Render PlantUML Diagrams
on:
push:
branches: ["main", "develop", "release/**"]
paths:
- "docs/diagrams/**.puml"
jobs:
render:
name: Render .puml → .png
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.BOT_TOKEN }}
- name: Install Java & Graphviz
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends default-jre-headless graphviz
- name: Download PlantUML jar
run: |
curl -sSL -o /usr/local/bin/plantuml.jar \
https://github.com/plantuml/plantuml/releases/download/v1.2024.6/plantuml-1.2024.6.jar
- name: Render diagrams
run: |
java -jar /usr/local/bin/plantuml.jar -tpng -o . docs/diagrams/*.puml
- name: Commit rendered PNGs
run: |
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@i3omb.com"
git add docs/diagrams/*.png
if git diff --cached --quiet; then
echo "No diagram changes to commit."
else
git commit -m "ci: render PlantUML diagrams [skip ci]"
git push
fi
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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
+5 -3
View File
@@ -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);
}
}
+22 -9
View File
@@ -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 {
@@ -491,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;
}
@@ -510,13 +526,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 {
@@ -1021,11 +1039,6 @@ body {
width: 40px;
}
.download-details {
flex-direction: column;
gap: 2px;
}
.progress-container {
flex-wrap: wrap;
}
+1 -1
View File
@@ -50,7 +50,7 @@ function createApp({ skipRateLimits = false } = {}) {
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
}
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
+7 -5
View File
@@ -137,7 +137,7 @@ app.use((req, res, next) => {
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
}
},
hsts: {
@@ -214,15 +214,17 @@ app.use(express.static(PUBLIC_DIR, {
}
}));
// Serve index.html with nonce injected into the <script> and <link> tags
// Serve index.html with CSP nonce injected into <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Inject nonce into <script> and <link rel="stylesheet"> tags
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`)
.replace(/<link([^>]*rel=["']stylesheet["'][^>]*)>/gi, `<link nonce="${nonce}"$1>`);
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
+9 -7
View File
@@ -70,13 +70,15 @@ router.post('/login', loginLimiter, async (req, res) => {
// Set authentication cookie (signed when COOKIE_SECRET is set).
// rememberMe=true → persistent cookie, expires in 30 days
// rememberMe=false → session cookie, expires when browser closes
// secure is always true — the app should sit behind HTTPS in production;
// behind a reverse proxy set TRUST_PROXY=1 so req.secure works correctly.
// secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse
// proxy is in front. Without it the app may be accessed over plain HTTP and
// secure cookies would never be sent back by the browser.
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
const signed = !!process.env.COOKIE_SECRET;
const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: secureCookie,
sameSite: 'strict',
signed,
path: '/'
@@ -91,7 +93,7 @@ router.post('/login', loginLimiter, async (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false, // intentionally readable by JS for the double-submit pattern
secure: process.env.NODE_ENV === 'production',
secure: secureCookie,
sameSite: 'strict',
path: '/'
});
@@ -142,7 +144,7 @@ router.get('/csrf', (req, res) => {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie('csrf_token', csrfToken, {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
path: '/'
});
@@ -168,14 +170,14 @@ router.post('/logout', async (req, res) => {
}
res.clearCookie('emby_user', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
signed: !!process.env.COOKIE_SECRET,
path: '/'
});
res.clearCookie('csrf_token', {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
secure: !!process.env.TRUST_PROXY,
sameSite: 'strict',
path: '/'
});