ARCHITECTURE.md: - Node version: 18+ → 22 (Alpine) - Tech stack: add helmet, express-rate-limit, cookie-parser, testing tools - Directory structure: add server/app.js, verifyCsrf.js, tokenStore.js, sanitizeError.js, tests/, docs/, .gitea/workflows/, vitest.config.js - §4.1: document app.js factory (createApp) vs index.js entry point; CSP nonce, rate limiters, CSRF middleware, trust proxy - §4.2: add CSRF Required column; document verifyCsrf; fix auth note - §4.3: add tokenStore.js and sanitizeError.js descriptions - §6 Auth flow: add rememberMe, rate limiter, stable DeviceId, server-side token store, CSRF token issuance, correct cookie TTL (session/30d not 24h) - §9 API: add csrfToken to login response, rememberMe field, 400/429 codes; add GET /api/auth/csrf endpoint; fix /me response; fix /logout CSRF note - §11 Config: add DATA_DIR, COOKIE_SECRET, TRUST_PROXY, NODE_ENV; split into Core / Emby / Service Instances / Tuning sections - §12 Deployment: update Dockerfile description to multi-stage node:22-alpine; add COOKIE_SECRET, TRUST_PROXY, named volume to compose example; add security hardening checklist; add CI/CD table diagrams/seq-auth.puml: - Add TokenStore participant - Add rememberMe, CSRF token issuance, stable DeviceId note - Add login rate limiter note - Add GET /csrf refresh flow - Add server-side token revocation on logout diagrams/class-server.puml: - Add app.js createApp() factory class - Add verifyCsrf middleware class - Add TokenStore and SanitizeError utility classes - Update auth.js routes (add GET /csrf) - Fix relationships: entry → appfn → routes diagrams/component.puml: - Add app.js factory component - Add helmet, express-rate-limit components - Add verifyCsrf middleware component - Add tokenStore.js and sanitizeError.js utility components - Fix wiring: entry → createApp() → mounts routes Dockerfile: - Fix stale comments referencing better-sqlite3 and SQLite server/routes/auth.js: - Fix stale comment: SQLite-backed → JSON file-backed
276 lines
6.6 KiB
Plaintext
276 lines
6.6 KiB
Plaintext
@startuml class-server
|
|
!theme plain
|
|
title sofarr — Server Class / Module Diagram
|
|
|
|
package "server/index.js" as entry {
|
|
class "EntryPoint" as ep <<module>> {
|
|
- LOG_LEVELS : Object
|
|
- currentLevel : number
|
|
- logFile : WriteStream
|
|
+ shouldLog(level) : boolean
|
|
--
|
|
Logging setup, app.listen(),
|
|
static files, startPoller()
|
|
}
|
|
}
|
|
|
|
package "server/app.js" as appfactory {
|
|
class "createApp(options?)" as appfn <<factory>> {
|
|
+ createApp(skipRateLimits?) : Express
|
|
--
|
|
Mounts helmet (CSP nonce),
|
|
rate limiters, cookie-parser,
|
|
auth routes (pre-CSRF),
|
|
verifyCsrf, all other routes,
|
|
/health, /ready, error handler
|
|
}
|
|
}
|
|
|
|
package "server/routes" {
|
|
class "auth.js" as auth <<router>> {
|
|
+ POST /login (rate-limited)
|
|
+ GET /me
|
|
+ GET /csrf
|
|
+ POST /logout
|
|
--
|
|
Authenticates via Emby API
|
|
Issues emby_user + csrf_token cookies
|
|
Stores/revokes Emby tokens server-side
|
|
}
|
|
|
|
class "dashboard.js" as dashboard <<router>> {
|
|
- activeClients : Map<string, ClientInfo>
|
|
- CLIENT_STALE_MS : 30000
|
|
--
|
|
+ GET /user-downloads
|
|
+ GET /user-summary
|
|
+ GET /status
|
|
--
|
|
- getCoverArt(item) : string|null
|
|
- extractAllTags(tags, tagMap) : string[]
|
|
- extractUserTag(tags, tagMap, username) : string|null
|
|
- buildTagBadges(allTags, embyUserMap) : TagBadge[]
|
|
- getEmbyUsers() : Promise<Map>
|
|
- sanitizeTagLabel(input) : string
|
|
- tagMatchesUser(tag, username) : boolean
|
|
- getImportIssues(record) : string[]|null
|
|
- getSonarrLink(series) : string|null
|
|
- getRadarrLink(movie) : string|null
|
|
- getActiveClients() : ClientInfo[]
|
|
}
|
|
|
|
class "emby.js" as emby_r <<router>> {
|
|
+ GET /sessions
|
|
+ GET /users/:id
|
|
+ GET /users
|
|
+ GET /session/:sessionId/user
|
|
}
|
|
|
|
class "sabnzbd.js" as sab_r <<router>> {
|
|
+ GET /queue
|
|
+ GET /history
|
|
}
|
|
|
|
class "sonarr.js" as sonarr_r <<router>> {
|
|
+ GET /queue
|
|
+ GET /history
|
|
+ GET /series/:id
|
|
+ GET /series
|
|
}
|
|
|
|
class "radarr.js" as radarr_r <<router>> {
|
|
+ GET /queue
|
|
+ GET /history
|
|
+ GET /movies/:id
|
|
+ GET /movies
|
|
}
|
|
}
|
|
|
|
package "server/middleware" {
|
|
class "requireAuth.js" as requireauth <<middleware>> {
|
|
+ requireAuth(req, res, next) : void
|
|
--
|
|
Reads emby_user cookie (signed if COOKIE_SECRET)
|
|
Validates schema: id, name, isAdmin
|
|
Attaches user to req.user
|
|
Returns 401 if absent/tampered/invalid
|
|
}
|
|
|
|
class "verifyCsrf.js" as verifycsrf <<middleware>> {
|
|
+ verifyCsrf(req, res, next) : void
|
|
--
|
|
Exempt: GET, HEAD, OPTIONS
|
|
Compares csrf_token cookie
|
|
vs X-CSRF-Token header
|
|
using crypto.timingSafeEqual
|
|
Returns 403 on mismatch/missing
|
|
}
|
|
}
|
|
|
|
package "server/utils" {
|
|
class "MemoryCache" as cache {
|
|
- store : Map<string, CacheEntry>
|
|
+ get(key) : any|null
|
|
+ set(key, value, ttlMs) : void
|
|
+ invalidate(key) : void
|
|
+ clear() : void
|
|
+ getStats() : CacheStats
|
|
}
|
|
|
|
class "CacheEntry" as ce <<value>> {
|
|
+ value : any
|
|
+ expiresAt : number
|
|
}
|
|
|
|
class "CacheStats" as cs <<value>> {
|
|
+ entryCount : number
|
|
+ totalSizeBytes : number
|
|
+ entries : CacheEntryStats[]
|
|
}
|
|
|
|
class "Poller" as poller <<module>> {
|
|
- POLL_INTERVAL : number
|
|
- POLLING_ENABLED : boolean
|
|
- polling : boolean
|
|
- lastPollTimings : PollTimings|null
|
|
- intervalHandle : number|null
|
|
--
|
|
+ startPoller() : void
|
|
+ stopPoller() : void
|
|
+ pollAllServices() : Promise<void>
|
|
+ getLastPollTimings() : PollTimings|null
|
|
--
|
|
- timed(label, fn) : TimedResult
|
|
}
|
|
|
|
class "PollTimings" as pt <<value>> {
|
|
+ totalMs : number
|
|
+ timestamp : string (ISO)
|
|
+ tasks : { label, ms }[]
|
|
}
|
|
|
|
class "Config" as config <<module>> {
|
|
+ getSABnzbdInstances() : Instance[]
|
|
+ getSonarrInstances() : Instance[]
|
|
+ getRadarrInstances() : Instance[]
|
|
+ getQbittorrentInstances() : Instance[]
|
|
--
|
|
- parseInstances(envVar, ...) : Instance[]
|
|
}
|
|
|
|
class "Instance" as inst <<value>> {
|
|
+ id : string
|
|
+ name : string
|
|
+ url : string
|
|
+ apiKey : string
|
|
+ username? : string
|
|
+ password? : string
|
|
}
|
|
|
|
class "QBittorrentClient" as qbt {
|
|
- id : string
|
|
- name : string
|
|
- url : string
|
|
- username : string
|
|
- password : string
|
|
- authCookie : string|null
|
|
--
|
|
+ login() : Promise<boolean>
|
|
+ makeRequest(endpoint, config) : Promise<Response>
|
|
+ getTorrents() : Promise<Torrent[]>
|
|
}
|
|
|
|
class "qbittorrent.js" as qbt_mod <<module>> {
|
|
- persistedClients : QBittorrentClient[]|null
|
|
--
|
|
+ getTorrents() : Promise<Torrent[]>
|
|
+ getClients() : QBittorrentClient[]
|
|
+ mapTorrentToDownload(torrent) : Download
|
|
+ formatBytes(bytes) : string
|
|
+ formatSpeed(bps) : string
|
|
+ formatEta(seconds) : string
|
|
}
|
|
|
|
class "Logger" as logger <<module>> {
|
|
- logFile : WriteStream
|
|
+ logToFile(message) : void
|
|
}
|
|
|
|
class "TokenStore" as tokenstore <<module>> {
|
|
- store : Object (in-memory)
|
|
- STORE_PATH : string (DATA_DIR/tokens.json)
|
|
- TOKEN_TTL_MS : 31 days
|
|
--
|
|
+ storeToken(userId, accessToken) : void
|
|
+ getToken(userId) : {accessToken}|null
|
|
+ clearToken(userId) : void
|
|
--
|
|
Atomic write (.tmp → rename)
|
|
Pruned on startup + hourly
|
|
}
|
|
|
|
class "SanitizeError" as sanitize <<module>> {
|
|
+ sanitizeError(err) : string
|
|
--
|
|
Redacts: query-param secrets,
|
|
auth headers, bearer tokens,
|
|
basic-auth URLs
|
|
}
|
|
|
|
class "TagBadge" as tb <<value>> {
|
|
+ label : string
|
|
+ matchedUser : string | null
|
|
}
|
|
|
|
class "ClientInfo" as ci <<value>> {
|
|
+ user : string
|
|
+ refreshRateMs : number
|
|
+ lastSeen : number (timestamp)
|
|
}
|
|
}
|
|
|
|
' Relationships
|
|
ep --> appfn : createApp()
|
|
ep --> poller : startPoller()
|
|
|
|
appfn --> auth : /api/auth (pre-CSRF)
|
|
appfn --> verifycsrf : /api (all routes below)
|
|
appfn --> dashboard
|
|
appfn --> emby_r
|
|
appfn --> sab_r
|
|
appfn --> sonarr_r
|
|
appfn --> radarr_r
|
|
|
|
dashboard --> requireauth : uses
|
|
emby_r --> requireauth : uses
|
|
sab_r --> requireauth : uses
|
|
sonarr_r --> requireauth : uses
|
|
radarr_r --> requireauth : uses
|
|
|
|
auth --> tokenstore : storeToken / getToken / clearToken
|
|
|
|
dashboard --> cache : read/write
|
|
dashboard --> poller : pollAllServices()
|
|
dashboard --> qbt_mod : mapTorrentToDownload()
|
|
dashboard --> config
|
|
|
|
poller --> cache : set poll:* keys
|
|
poller --> config : get instances
|
|
poller --> qbt_mod : getTorrents()
|
|
|
|
qbt_mod --> config : getQbittorrentInstances()
|
|
qbt_mod *-- qbt : creates
|
|
qbt --> logger
|
|
|
|
cache *-- ce : stores
|
|
cache ..> cs : returns from getStats()
|
|
poller ..> pt : stores/returns
|
|
dashboard *-- ci : stores in activeClients
|
|
|
|
config ..> inst : returns
|
|
|
|
auth ..> sanitize : sanitizeError on catch
|
|
dashboard ..> sanitize : sanitizeError on catch
|
|
|
|
@enduml
|