Compare commits
15 Commits
v1.0.0
...
release/1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c847a26d3 | |||
| 28f2aa17d8 | |||
| aa8a6a49f4 | |||
| 0ffe62e1ca | |||
| 925d0c7735 | |||
| 121c49b35b | |||
| a4004f5e7a | |||
| fd0d5cf6ec | |||
| 1f293ae70b | |||
| 352118b4af | |||
| f41d14b2a9 | |||
| 240fc0d3b6 | |||
| c3ae3a80de | |||
| 94fe0dea4d | |||
| 3c3382401c |
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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: '/'
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user