From 6675e5dcfe1790edff548d934d1afa56d8bc5343 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:41:23 +0100 Subject: [PATCH] docs: update architecture docs and diagrams for recent changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE.md: - Directory structure: add middleware/requireAuth.js and favicon assets - §4.1: remove CORS from middleware list - §4.2: all proxy routes now auth-required via requireAuth; add middleware description - §6: cookie payload corrected (no token); document secure+sameSite - §7: add emby:users cache key (60s TTL) - §8: Download Object table: userTag → allTags/matchedUserTag/tagBadges - §9 POST /login: document cookie security attributes - §10: add Tag Badge Rendering section; remove hardcoded line count Diagrams: - class-server.puml: add requireAuth middleware module; update dashboard.js methods (extractAllTags, extractUserTag w/ username, buildTagBadges, getEmbyUsers); add TagBadge value class; add auth relationships for all proxy routes - class-data.puml: Download Object userTag → allTags/matchedUserTag/ tagBadges; add TagBadge class; remove token from Session Cookie - seq-auth.puml: cookie payload no longer contains token; add secure/sameSite note - component.puml: remove CORS component; add requireAuth; consolidate Emby connection to show tag badge + user-summary usage - activity-matching.puml: update to extractAllTags/extractUserTag (with username); showAll uses hasAnyTag; tagBadges built from embyUserMap; add Emby user fetch step; update legend - seq-dashboard.puml: add emby:users cache lookup / Emby fetch for showAll; update matching groups to show tag classification; add tag badge rendering note on renderDownloads() --- docs/ARCHITECTURE.md | 43 ++++++++++++++++++-------- docs/diagrams/activity-matching.puml | 46 ++++++++++++++++++++++------ docs/diagrams/class-data.puml | 15 +++++++-- docs/diagrams/class-server.puml | 26 +++++++++++++++- docs/diagrams/component.puml | 11 +++++-- docs/diagrams/seq-auth.puml | 2 +- docs/diagrams/seq-dashboard.puml | 27 ++++++++++++---- 7 files changed, 135 insertions(+), 35 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e35a2c4..8aca5c6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -107,6 +107,8 @@ sofarr/ │ │ ├── sabnzbd.js # Proxy routes to SABnzbd API │ │ ├── sonarr.js # Proxy routes to Sonarr API │ │ └── radarr.js # Proxy routes to Radarr API +│ ├── middleware/ +│ │ └── requireAuth.js # httpOnly cookie auth middleware │ └── utils/ │ ├── cache.js # MemoryCache class (Map + TTL + stats) │ ├── config.js # Multi-instance service configuration parser @@ -117,6 +119,9 @@ sofarr/ │ ├── index.html # HTML shell: splash, login, dashboard │ ├── app.js # All frontend logic (auth, rendering, status) │ ├── style.css # Themes, layout, responsive design +│ ├── favicon.ico # Multi-size favicon (16/32/48px) +│ ├── favicon-32.png # 32px PNG favicon +│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA) │ └── images/ # Logo / splash screen assets ├── Dockerfile # Production container image ├── docker-compose.yaml # Example compose deployment @@ -135,7 +140,7 @@ Responsibilities: - Load environment variables via `dotenv` - Configure structured logging with level filtering (`LOG_LEVEL`) - Redirect `console.*` to both stdout and `server.log` -- Mount Express middleware (CORS, cookie-parser, JSON, static files) +- Mount Express middleware (cookie-parser, JSON, static files) - Mount route modules under `/api/*` - Start the background poller @@ -143,12 +148,14 @@ Responsibilities: | Module | Mount Point | Auth Required | Purpose | |--------|------------|---------------|---------| -| `auth.js` | `/api/auth` | No | Login, session check, logout | -| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status | -| `emby.js` | `/api/emby` | No | Proxy to Emby API | -| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API | -| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API | -| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API | +| `auth.js` | `/api/auth` | No (public) | Login, session check, logout | +| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status | +| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API | +| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API | +| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API | +| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API | + +`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed. > **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache. @@ -208,7 +215,7 @@ When a user requests `/api/dashboard/user-downloads`: 1. User submits credentials via the login form 2. Backend calls Emby `POST /Users/authenticatebyname` 3. On success, fetches full user profile to determine admin status -4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin, token }` +4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie 5. Cookie expires after 24 hours 6. All subsequent dashboard requests read this cookie for identity @@ -253,6 +260,7 @@ Users are matched to downloads via tags in Sonarr/Radarr: | `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history | | `poll:radarr-tags` | `[{id, label}]` | Radarr tag API | | `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents | +| `emby:users` | `Map` | Full Emby user list (60s TTL) | ### TTL Strategy @@ -314,7 +322,9 @@ Each matched download produces an object with: | `eta` | string | Estimated time remaining | | `seriesName` / `movieName` | string | Friendly media title | | `episodeInfo` / `movieInfo` | object | Full *arr queue/history record | -| `userTag` | string | Matched user tag | +| `allTags` | string[] | All resolved tag labels on the series/movie | +| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` | +| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list | | `importIssues` | string[] / null | Import warning/error messages | | `downloadPath` | string / null | (Admin) Download client path | | `targetPath` | string / null | (Admin) *arr target path | @@ -346,7 +356,7 @@ Authenticate a user via Emby. { "success": false, "error": "Invalid username or password" } ``` -**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL). +**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored. --- @@ -447,7 +457,7 @@ Admin-only per-user download counts (fetches live from APIs, not cached). ## 10. Frontend Architecture -The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js` (754 lines), styled by `style.css`, and structured by `index.html`. +The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js`, styled by `style.css`, and structured by `index.html`. ### UI States @@ -473,7 +483,7 @@ The frontend is a **vanilla JavaScript SPA** with no build step. All logic resid | `handleLogin()` | Authenticate, fade login → splash → dashboard | | `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render | | `renderDownloads()` | Diff-based card rendering (create/update/remove) | -| `createDownloadCard()` | Build DOM for a single download card | +| `createDownloadCard()` | Build DOM for a single download card; renders tag badges | | `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) | | `toggleStatusPanel()` | Show/hide admin status panel | | `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) | @@ -489,6 +499,15 @@ Three CSS themes via `data-theme` attribute on ``: Theme selection persists in `localStorage`. +### Tag Badge Rendering + +Download cards render tag badges in the card header: + +- **Normal user view**: a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`). +- **Admin `showAll` view**: all tags on the download are rendered using `tagBadges[]`: + - Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost) + - Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost) + ### Auto-Refresh The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking. diff --git a/docs/diagrams/activity-matching.puml b/docs/diagrams/activity-matching.puml index c74b956..b9d7718 100644 --- a/docs/diagrams/activity-matching.puml +++ b/docs/diagrams/activity-matching.puml @@ -22,6 +22,12 @@ end note :Build **sonarrTagMap** (tagId → label) Build **radarrTagMap** (tagId → label); +if (showAll?) then (yes) + :Fetch full Emby user list + Build **embyUserMap** (lowerName → displayName) + [cached 60s]; +endif + :Initialise **userDownloads** = []; partition "Process SABnzbd Queue Slots" { @@ -32,13 +38,20 @@ partition "Process SABnzbd Queue Slots" { if (Title matches Sonarr **queue** record?) then (yes) :series = seriesMap.get(match.seriesId)\n|| match.series; if (series exists?) then (yes) - :userTag = extractUserTag(series.tags, sonarrTagMap); - if (showAll OR tagMatchesUser?) then (yes) + :allTags = extractAllTags(series.tags, sonarrTagMap) +matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username); + if (showAll AND hasAnyTag?) then (yes) :Build download object (type=series) Add coverArt, status, progress, speed, eta + Add allTags, matchedUserTag + Add tagBadges = buildTagBadges(allTags, embyUserMap) Add importIssues if any Add admin fields (paths, arrLink); :Push to **userDownloads**; + elseif (NOT showAll AND matchedUserTag?) then (yes) + :Build download object (type=series) + Add matchedUserTag; + :Push to **userDownloads**; endif endif endif @@ -46,13 +59,19 @@ partition "Process SABnzbd Queue Slots" { if (Title matches Radarr **queue** record?) then (yes) :movie = moviesMap.get(match.movieId)\n|| match.movie; if (movie exists?) then (yes) - :userTag = extractUserTag(movie.tags, radarrTagMap); - if (showAll OR tagMatchesUser?) then (yes) + :allTags = extractAllTags(movie.tags, radarrTagMap) +matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username); + if (showAll AND hasAnyTag?) then (yes) :Build download object (type=movie) Add coverArt, status, progress, speed, eta + Add allTags, matchedUserTag, tagBadges Add importIssues if any Add admin fields (paths, arrLink); :Push to **userDownloads**; + elseif (NOT showAll AND matchedUserTag?) then (yes) + :Build download object (type=movie) + Add matchedUserTag; + :Push to **userDownloads**; endif endif endif @@ -67,16 +86,20 @@ partition "Process SABnzbd History Slots" { if (Title matches Sonarr **history** record?) then (yes) :series = seriesMap.get(match.seriesId)\n|| match.series; if (series found?) then (yes) - :Check user tag, build download\n(type=series, with completedAt); - :Push to **userDownloads** if tag matches; + :extractAllTags + extractUserTag(username) +Build download (type=series, completedAt) +Add allTags, matchedUserTag, tagBadges if showAll; + :Push to **userDownloads** if showAll+anyTag or matchedUserTag; endif endif if (Title matches Radarr **history** record?) then (yes) :movie = moviesMap.get(match.movieId)\n|| match.movie; if (movie found?) then (yes) - :Check user tag, build download\n(type=movie, with completedAt); - :Push to **userDownloads** if tag matches; + :extractAllTags + extractUserTag(username) +Build download (type=movie, completedAt) +Add allTags, matchedUserTag, tagBadges if showAll; + :Push to **userDownloads** if showAll+anyTag or matchedUserTag; endif endif endwhile (no) @@ -119,10 +142,15 @@ legend right (bidirectional substring, case-insensitive): ""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)"" - **Tag Matching Logic**: + **Tag Matching Logic** (tagMatchesUser): 1. Exact: tag.toLowerCase() === username 2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username) (handles Ombi-mangled email-style usernames) + + **extractAllTags**: returns all resolved tag labels + **extractUserTag**: returns the ONE label matching current user + **buildTagBadges**: classifies each tag against full Emby user + list → { label, matchedUser: displayName | null } end legend @enduml diff --git a/docs/diagrams/class-data.puml b/docs/diagrams/class-data.puml index ed92cf4..e3631d1 100644 --- a/docs/diagrams/class-data.puml +++ b/docs/diagrams/class-data.puml @@ -153,7 +153,9 @@ package "sofarr Internal Models" { + movieName : string | null + episodeInfo : object | null + movieInfo : object | null - + userTag : string + + allTags : string[] + + matchedUserTag : string | null + + tagBadges : TagBadge[] | undefined + importIssues : string[] | null + downloadPath : string | null + targetPath : string | null @@ -170,6 +172,11 @@ package "sofarr Internal Models" { + completedAt : string } + class "TagBadge" as tagbadge <> { + + label : string + + matchedUser : string | null + } + class "API Response\n/user-downloads" as apir { + user : string + isAdmin : boolean @@ -201,7 +208,7 @@ package "sofarr Internal Models" { + id : string + name : string + isAdmin : boolean - + token : string + ' Note: Emby AccessToken intentionally excluded } apir *-- dl @@ -215,7 +222,9 @@ sabh ..> dl : matched &\ntransformed qbt ..> dl : mapTorrentToDownload() ss ..> dl : coverArt, seriesName,\npath, tags rm ..> dl : coverArt, movieName,\npath, tags -tag ..> dl : userTag resolution +tag ..> dl : allTags / matchedUserTag eu ..> cookie : login creates +eu ..> tagbadge : buildTagBadges() +dl *-- tagbadge : tagBadges[] @enduml diff --git a/docs/diagrams/class-server.puml b/docs/diagrams/class-server.puml index 7e7042b..fb9719a 100644 --- a/docs/diagrams/class-server.puml +++ b/docs/diagrams/class-server.puml @@ -33,7 +33,10 @@ package "server/routes" { + GET /status -- - getCoverArt(item) : string|null - - extractUserTag(tags, tagMap) : string|null + - extractAllTags(tags, tagMap) : string[] + - extractUserTag(tags, tagMap, username) : string|null + - buildTagBadges(allTags, embyUserMap) : TagBadge[] + - getEmbyUsers() : Promise - sanitizeTagLabel(input) : string - tagMatchesUser(tag, username) : boolean - getImportIssues(record) : string[]|null @@ -69,6 +72,16 @@ package "server/routes" { } } +package "server/middleware" { + class "requireAuth.js" as requireauth <> { + + requireAuth(req, res, next) : void + -- + Reads emby_user cookie + Attaches parsed user to req.user + Returns 401 if absent/invalid + } +} + package "server/utils" { class "MemoryCache" as cache { - store : Map @@ -158,6 +171,11 @@ package "server/utils" { + logToFile(message) : void } + class "TagBadge" as tb <> { + + label : string + + matchedUser : string | null + } + class "ClientInfo" as ci <> { + user : string + refreshRateMs : number @@ -172,6 +190,12 @@ ep --> emby_r ep --> sab_r ep --> sonarr_r ep --> radarr_r + +dashboard --> requireauth : uses +emby_r --> requireauth : uses +sab_r --> requireauth : uses +sonarr_r --> requireauth : uses +radarr_r --> requireauth : uses ep --> poller : startPoller() dashboard --> cache : read/write diff --git a/docs/diagrams/component.puml b/docs/diagrams/component.puml index 61f5191..7567ad4 100644 --- a/docs/diagrams/component.puml +++ b/docs/diagrams/component.puml @@ -16,10 +16,10 @@ package "Browser" as browser { package "Express Server" as server { package "Middleware" { - [CORS] as cors [cookie-parser] as cp [express.json] as ej [express.static] as es + [requireAuth.js] as requireauth } package "Routes" as routes { @@ -41,7 +41,6 @@ package "Express Server" as server { [index.js\nEntry Point] as entry - entry --> cors entry --> cp entry --> ej entry --> es @@ -51,6 +50,12 @@ package "Express Server" as server { entry --> sab_route entry --> sonarr_route entry --> radarr_route + + emby_route --> requireauth + sab_route --> requireauth + sonarr_route --> requireauth + radarr_route --> requireauth + dashboard --> requireauth entry --> poller : startPoller() dashboard --> cache : read poll:* keys @@ -76,7 +81,7 @@ cloud "External Services" as external { } auth --> emby : authenticate\nuser profile -dashboard ..> emby : /user-summary\n(live fetch) +dashboard --> emby : GET /Users\n(user-summary + tag badge classification) emby_route --> emby sab_route --> sab sonarr_route --> sonarr diff --git a/docs/diagrams/seq-auth.puml b/docs/diagrams/seq-auth.puml index f81c644..e559c95 100644 --- a/docs/diagrams/seq-auth.puml +++ b/docs/diagrams/seq-auth.puml @@ -37,7 +37,7 @@ alt Valid credentials auth -> emby : GET /Users/{userId} emby --> auth : { Name, Policy: { IsAdministrator } } deactivate emby - auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL) + auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored auth --> browser : { success: true, user: { name, isAdmin } } browser -> browser : fadeOutLogin() browser -> browser : showSplash() diff --git a/docs/diagrams/seq-dashboard.puml b/docs/diagrams/seq-dashboard.puml index 98864dc..34f1772 100644 --- a/docs/diagrams/seq-dashboard.puml +++ b/docs/diagrams/seq-dashboard.puml @@ -50,19 +50,28 @@ dashboard -> dashboard : Build seriesMap from\nSonarr queue records dashboard -> dashboard : Build moviesMap from\nRadarr queue records dashboard -> dashboard : Build tag maps\n(id → label) +alt showAll=true + dashboard -> cache : get('emby:users') + alt cache miss + dashboard -> ext : GET /Users (Emby) + ext --> dashboard : [{ Name, ... }] + dashboard -> cache : set('emby:users', map, 60s) + end + dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName) +end + group SABnzbd Queue Matching loop each queue slot dashboard -> dashboard : Match title vs Sonarr queue dashboard -> dashboard : Match title vs Radarr queue - dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username + dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap) end end group SABnzbd History Matching loop each history slot - dashboard -> dashboard : Match title vs Sonarr history - dashboard -> dashboard : Match title vs Radarr history - dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter + dashboard -> dashboard : Match title vs Sonarr/Radarr history + dashboard -> dashboard : Same tag extraction + inclusion logic end end @@ -72,14 +81,20 @@ group qBittorrent Matching dashboard -> dashboard : 2. Match vs Radarr queue dashboard -> dashboard : 3. Match vs Sonarr history dashboard -> dashboard : 4. Match vs Radarr history - dashboard -> dashboard : mapTorrentToDownload()\n→ enrich with series/movie info + dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges end end dashboard --> browser : { user, isAdmin,\ndownloads: [...] } deactivate dashboard -browser -> browser : renderDownloads()\n(diff-based update) +browser -> browser : renderDownloads() (diff-based) +note right + createDownloadCard() renders tag badges: + - Normal: accent badge for matchedUserTag + - showAll: amber badges (unmatched tags) + accent badges (matched → show Emby displayName) +end note deactivate browser @enduml