From a4b6f22d86d6f4facf94019f2b7e94f4f9d15745 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 4 Aug 2026 16:19:24 -0400 Subject: [PATCH 01/15] =?UTF-8?q?feat(update):=20silent=20self-update=20vi?= =?UTF-8?q?a=20PackageInstaller=20session=20=E2=80=94=20#2438?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ACTION_VIEW + application/vnd.android.package-archive handoff with a PackageInstaller session, and declares UPDATE_PACKAGES_WITHOUT_USER_ACTION so the update can land with no confirm dialog at all. The platform grants the silent path when the installer opts in via setRequireUserAction(USER_ACTION_NOT_REQUIRED), the installed app targets API 29+, the installer holds that permission, and the target is the installer itself. Minstrel updating Minstrel satisfies all four. Where it can't be granted — anything pre-S — the platform returns STATUS_PENDING_USER_ACTION and we show its dialog instead, so this degrades rather than failing. Prior art: Mihon, which is out-of-store and self-updating and whose updates are quiet for exactly this reason. It also confirmed REQUEST_INSTALL_PACKAGES is not what draws install warnings — Mihon declares it too. No setRequestUpdateOwnership(true), despite it reading like the obvious declaration for a self-updater. Ownership can only be claimed on initial installation (a no-op on update) and additionally wants the privileged ENFORCE_UPDATE_OWNERSHIP permission. It's an API for app stores claiming the apps they install. Also: the install now has an outcome. The old path fired an intent and assumed, so a failure and a user declining were indistinguishable. Sessions report back, so InstallOutcome distinguishes Installed / Cancelled / Failed, and cancelling returns to IDLE rather than showing an error — the user chose it. DOWNLOADING and INSTALLING became separate stages because the install half now genuinely waits, and "Downloading…" through a confirm dialog is a lie. The FileProvider and res/xml/file_paths.xml are gone. They existed only to expose the cached APK as a content:// URI for the old intent; a session takes a stream. Nothing else used that authority. Two judgement calls worth naming: - The pending-user-action intent is only launched if it resolves to a system component. Below API 34 a dynamically registered receiver can't declare itself unexported, so another app can broadcast at us, and an unchecked startActivity on an attacker-supplied extra would be an escalation primitive. The real confirm activity is a system app, so the check costs the legitimate path nothing. - Cancellation unregisters the receiver but deliberately does NOT abandon the session. By then it's committed, and killing an install because the user navigated away from the banner misreads their intent. Untestable here: no androidTest source set and no Robolectric, so the gesture-level behaviour is operator on-device verification. --- android/app/src/main/AndroidManifest.xml | 23 +- .../settings/ui/AboutCardViewModel.kt | 58 +++-- .../minstrel/settings/ui/SettingsScreen.kt | 20 +- .../minstrel/update/data/ApkInstaller.kt | 34 ++- .../minstrel/update/data/InstallOutcome.kt | 68 ++++++ .../minstrel/update/data/SelfUpdateSession.kt | 220 ++++++++++++++++++ .../minstrel/update/ui/UpdateBanner.kt | 17 +- .../update/ui/UpdateBannerViewModel.kt | 51 ++-- android/app/src/main/res/xml/file_paths.xml | 9 - 9 files changed, 410 insertions(+), 90 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/InstallOutcome.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/update/data/SelfUpdateSession.kt delete mode 100644 android/app/src/main/res/xml/file_paths.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 15066c95..b8265ece 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,16 @@ + + @@ -48,15 +57,11 @@ - - - + - - From 1bf0e388cb5edfcfb22da8c4288e74a6069f5936 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 4 Aug 2026 21:26:26 -0400 Subject: [PATCH 02/15] docs(readme): state scope and responsible use up front MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minstrel integrates with Lidarr, and that integration is the kind of thing a reader can misread as content sourcing. It isn't, and the README never said so explicitly. Now it does, before the Quickstart rather than buried at the bottom. The section states what is simply true: Minstrel indexes files already on disk and streams them; it ships no indexers, no trackers, no torrent/Usenet/NZB client, and no DRM circumvention; the Lidarr integration is optional, inert until an operator supplies a URL and API key, and points at an instance they already run. Library contents and configured sources are the operator's responsibility. Plus a non-affiliation line for Lidarr, ListenBrainz, MusicBrainz and Subsonic. Also made the Lidarr highlight explicit that the instance is yours and the integration is off by default — the bullet previously read as though Minstrel brought Lidarr with it. Written as scope-setting rather than legalese, deliberately: a confident description of what the software does is both more useful to a reader and better evidence of intent than an anxious disclaimer would be. Docs only — no workflow path filter matches README.md, so no CI lane runs. --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 76f8a7da..58db87d4 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,22 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes, - **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration. - **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices. - **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag. -- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit. +- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key. - **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy. - **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track). +## Scope and responsible use + +**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from. + +Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem. + +The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself. + +**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect. + +Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic. + ## Quickstart ```yaml From 2e1a8a62d82e7ffda6e965f5ee3f60cb652447d1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 08:41:05 -0400 Subject: [PATCH 03/15] =?UTF-8?q?refactor(android):=20move=20cleartext=20o?= =?UTF-8?q?pt-out=20into=20a=20networkSecurityConfig=20=E2=80=94=20#2439?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `android:usesCleartextTraffic="true"` sat on as a bare opt-out of the platform's network-security default, with nothing recorded about why. It's now a res/xml/network_security_config.xml carrying the same permission and the reasoning behind it. Behaviour is unchanged. networkSecurityConfig supersedes the attribute on API 24+ and our minSdk is 26, so the attribute is removed rather than kept alongside. Cleartext stays permitted because two independent things need it, and neither can be narrowed to a domain list: - The Minstrel server's host is user-entered at runtime, and plenty of self-hosters run plain HTTP on a LAN. - UPnP/DLNA/Sonos — device-description and SOAP control URLs arrive in SSDP responses at runtime and are plain HTTP essentially always. This one wasn't in the original ticket, which only considered the server; it independently rules out the "tighten it later to RFC1918" idea, since matches literal hostnames, not CIDR ranges, and renderer IPs are unknowable ahead of time. Trust anchors deliberately left at the platform default. Adding would let self-hosters use HTTPS with a private CA — which Mihon does, and which suits this product — but it also trusts every CA on the device including a corporate MITM proxy. Raised separately rather than assumed as a default. tools:ignore="InsecureBaseConfiguration" mirrors Mihon's config and keeps lintVitalRelease quiet about a choice that is deliberate and now documented. --- android/app/src/main/AndroidManifest.xml | 2 +- .../main/res/xml/network_security_config.xml | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/res/xml/network_security_config.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b8265ece..b09ec99f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,9 +28,9 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true" android:theme="@style/Theme.Minstrel" - android:usesCleartextTraffic="true" tools:targetApi="34"> + + + From d86af7397da733e0fdbedecfc29b0d6b7bc51bf3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 09:17:40 -0400 Subject: [PATCH 04/15] =?UTF-8?q?feat(auth):=20active=20sessions=20API=20w?= =?UTF-8?q?ith=20origin/current=20IP=20=E2=80=94=20#370?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server half of the active-sessions surface. Web UI follows. The operator wants this specifically to notice a compromised account, which sets the bar: the addresses have to be trustworthy, or the feature is worse than absent because it looks like evidence. Migration 0052 adds created_ip + last_ip. Two columns, not one, and the pair is the signal: a session issued at home and now being used from elsewhere is the shape of a stolen token, and neither column alone can show that. Typed text, matching the user_agent column beside it — these are displayed, never queried by subnet, and inet round-trips through pgx as a netip.Prefix that renders "1.2.3.4/32". The rest of the schema was already waiting. Migration 0004 anticipated this exactly: "last_seen_at enables an 'active sessions' UI later (not wired in this plan) without schema churn." last_seen_at is live data — the auth middleware already touches it per request — so last_ip rides that same UPDATE for free. Getting the address right is the substance here. Nothing extracted a client IP anywhere before, and both obvious approaches are wrong: - RemoteAddr alone shows the reverse proxy on every session, which is the normal self-hosted deployment. Noise shaped like data. - Trusting X-Forwarded-For lets any client choose what its victim sees. A security surface an attacker can write to is worse than none. So auth.ClientIP trusts the header only when the request actually arrived from a proxy range. Public RemoteAddr means a direct connection, so XFF is attacker-controlled and ignored outright. Private RemoteAddr means we walk XFF right-to-left — proxies append, so the right end is what our own infrastructure wrote — and take the first non-proxy address. A forged XFF only prepends to the left end, which that walk never reaches. Unit-tested, including both spoofing shapes. Fails closed on a public-addressed proxy (separate host, CDN): we report the proxy rather than trusting a forgeable header. Documented at the function. Endpoints, all scoped by user_id per rule #47: GET /api/me/sessions → list, flagging the current row DELETE /api/me/sessions/{id} → 204, or 404 if not yours POST /api/me/sessions/logout-others → {"revoked": n} Keyed on session id alone, any household member could revoke another's session by guessing a uuid, so the delete carries user_id in its WHERE and :execrows distinguishes "not yours" (404) from a false 204. There's a test that asserts the row actually survives, not merely that we returned 404. The middleware now also puts the session id in context. logout-others is defined by exclusion, and without knowing which session is ours the safe-looking action deletes everything including the caller's — so it refuses rather than guesses when the id is absent, and that refusal is tested for non-deletion too. audit_log.action is plain text with no CHECK, so the two new actions need no migration (rule #36 checked, not assumed). Codegen is real sqlc 1.31.1 via the container in `make generate` — docker is present on this workstation even though Go and sqlc aren't — rather than the hand-written .sql.go shortcut used in milestone #268. --- internal/api/api.go | 3 + internal/api/auth.go | 5 + internal/api/auth_register.go | 1 + internal/api/me_sessions.go | 136 +++++++++++ internal/api/me_sessions_test.go | 226 ++++++++++++++++++ internal/audit/audit.go | 7 + internal/auth/clientip.go | 92 +++++++ internal/auth/clientip_test.go | 121 ++++++++++ internal/auth/middleware.go | 17 +- internal/auth/session.go | 16 +- internal/db/dbq/models.go | 2 + internal/db/dbq/sessions.sql.go | 111 ++++++++- .../db/migrations/0052_session_ips.down.sql | 3 + .../db/migrations/0052_session_ips.up.sql | 19 ++ internal/db/queries/sessions.sql | 26 +- 15 files changed, 771 insertions(+), 14 deletions(-) create mode 100644 internal/api/me_sessions.go create mode 100644 internal/api/me_sessions_test.go create mode 100644 internal/auth/clientip.go create mode 100644 internal/auth/clientip_test.go create mode 100644 internal/db/migrations/0052_session_ips.down.sql create mode 100644 internal/db/migrations/0052_session_ips.up.sql diff --git a/internal/api/api.go b/internal/api/api.go index 7e04e4c8..d27e2e47 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -87,6 +87,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Put("/me/timezone", h.handlePutTimezone) authed.Get("/me/api-token", h.handleGetMyAPIToken) authed.Post("/me/api-token", h.handleRegenerateMyAPIToken) + authed.Get("/me/sessions", h.handleListMySessions) + authed.Delete("/me/sessions/{id}", h.handleRevokeMySession) + authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions) authed.Get("/artists", h.handleListArtists) authed.Get("/artists/{id}", h.handleGetArtist) diff --git a/internal/api/auth.go b/internal/api/auth.go index 00fd5164..46e18c2e 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -96,6 +96,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { UserID: user.ID, TokenHash: auth.HashSessionToken(token), UserAgent: r.UserAgent(), + // Origin address, frozen at issue time. Compared against last_ip in + // the active-sessions surface: a session that was born somewhere the + // user recognises but is being used from somewhere they don't is the + // case this whole surface exists to surface. + Ip: auth.ClientIP(r), }); err != nil { h.logger.Error("api: insert session failed", "err", err) writeErr(w, apierror.InternalMsg("insert failed", err)) diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index 849573c1..5bf1803e 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -175,6 +175,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { UserID: user.ID, TokenHash: auth.HashSessionToken(sessionToken), UserAgent: r.UserAgent(), + Ip: auth.ClientIP(r), }); err != nil { h.logger.Error("register: insert session failed", "err", err) writeErr(w, apierror.Internal(err)) diff --git a/internal/api/me_sessions.go b/internal/api/me_sessions.go new file mode 100644 index 00000000..e2f2f508 --- /dev/null +++ b/internal/api/me_sessions.go @@ -0,0 +1,136 @@ +package api + +import ( + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/audit" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// errNoCurrentSession means the request authenticated but the middleware +// didn't record which session did it — which should be impossible on a route +// behind RequireUser. It matters because "log out everywhere else" is defined +// by exclusion: without knowing which session is ours, the safe-looking +// action would sign the caller out too. +var errNoCurrentSession = errors.New("no session id in request context") + +// sessionResp is one row of the active-sessions list. +// +// token_hash is absent, and that is the point of storing only a hash: it +// never leaves the database, so this surface can list sessions without +// handing out anything that could be replayed. +type sessionResp struct { + ID string `json:"id"` + UserAgent string `json:"user_agent"` + // CreatedIP is frozen at issue time; LastIP moves with the session. The + // pair is what makes a stolen token legible — same device string, but an + // address the user doesn't recognise. + CreatedIP string `json:"created_ip"` + LastIP string `json:"last_ip"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt time.Time `json:"last_seen_at"` + // Current marks the session making this request so the UI can label it + // and not offer a "log out" that signs the user out of the page they're + // standing on. + Current bool `json:"current"` +} + +type revokedResp struct { + Revoked int `json:"revoked"` +} + +// handleListMySessions implements GET /api/me/sessions. +func (h *handlers) handleListMySessions(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + // Absent id is tolerated here (unlike logout-others): the list still + // renders, it just won't flag a current row. + currentID, _ := auth.SessionIDFromContext(r.Context()) + + rows, err := dbq.New(h.pool).ListSessionsForUser(r.Context(), user.ID) + if err != nil { + h.logger.Error("list sessions: query failed", "err", err) + writeErr(w, apierror.Internal(err)) + return + } + out := make([]sessionResp, 0, len(rows)) + for _, s := range rows { + out = append(out, sessionResp{ + ID: uuidToString(s.ID), + UserAgent: s.UserAgent, + CreatedIP: s.CreatedIp, + LastIP: s.LastIp, + CreatedAt: s.CreatedAt.Time, + LastSeenAt: s.LastSeenAt.Time, + Current: s.ID == currentID, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// handleRevokeMySession implements DELETE /api/me/sessions/{id}. +func (h *handlers) handleRevokeMySession(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + // Malformed and belongs-to-someone-else collapse to one answer on + // purpose: a distinguishable response would let a caller probe + // whether another user's session id exists. + writeErr(w, apierror.NotFound("session")) + return + } + n, err := dbq.New(h.pool).DeleteSessionForUser(r.Context(), dbq.DeleteSessionForUserParams{ + ID: id, + UserID: user.ID, + }) + if err != nil { + h.logger.Error("revoke session: delete failed", "err", err) + writeErr(w, apierror.Internal(err)) + return + } + if n == 0 { + writeErr(w, apierror.NotFound("session")) + return + } + audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevoke, nil) + w.WriteHeader(http.StatusNoContent) +} + +// handleRevokeMyOtherSessions implements POST /api/me/sessions/logout-others. +func (h *handlers) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) { + user, ok := requireUser(w, r) + if !ok { + return + } + currentID, ok := auth.SessionIDFromContext(r.Context()) + if !ok { + // Refuse rather than guess: deleting "all but unknown" is deleting + // all, which would log the caller out of the page they invoked this + // from and look exactly like the attack they were defending against. + h.logger.Error("revoke other sessions: no session id in context") + writeErr(w, apierror.Internal(errNoCurrentSession)) + return + } + n, err := dbq.New(h.pool).DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{ + UserID: user.ID, + ID: currentID, + }) + if err != nil { + h.logger.Error("revoke other sessions: delete failed", "err", err) + writeErr(w, apierror.Internal(err)) + return + } + audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevokeOthers, nil) + writeJSON(w, http.StatusOK, revokedResp{Revoked: int(n)}) +} diff --git a/internal/api/me_sessions_test.go b/internal/api/me_sessions_test.go new file mode 100644 index 00000000..8f9c3d6d --- /dev/null +++ b/internal/api/me_sessions_test.go @@ -0,0 +1,226 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// seedSession inserts a session for userID and returns its id. +func seedSession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, ip string) pgtype.UUID { + t.Helper() + token, err := auth.MintSessionToken() + if err != nil { + t.Fatalf("mint: %v", err) + } + sess, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{ + UserID: userID, + TokenHash: auth.HashSessionToken(token), + UserAgent: "test-agent", + Ip: ip, + }) + if err != nil { + t.Fatalf("insert session: %v", err) + } + return sess.ID +} + +// withSession attaches the user and current-session id the handlers expect +// from RequireUser. +func withSession(r *http.Request, user dbq.User, sessionID pgtype.UUID) *http.Request { + ctx := context.WithValue(r.Context(), userCtxKeyForTest(), user) + ctx = context.WithValue(ctx, auth.SessionIDCtxKeyForTest(), sessionID) + return r.WithContext(ctx) +} + +// withURLParam wires a chi route param, which handlers read via chi.URLParam. +func withURLParam(r *http.Request, key, value string) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add(key, value) + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) +} + +// The rule #47 assertion. A delete keyed only on session id would let any +// household member revoke any other member's session by id — this pins that +// the user scope is actually in the WHERE clause and not just intended. +func TestRevokeMySession_CannotRevokeAnotherUsersSession(t *testing.T) { + h, pool := testHandlers(t) + alice := seedUser(t, pool, "alice", "hunter2", false) + bob := seedUser(t, pool, "bob", "hunter2", false) + + bobSession := seedSession(t, pool, bob.ID, "203.0.113.9") + aliceSession := seedSession(t, pool, alice.ID, "203.0.113.1") + + target := uuidToString(bobSession) + req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil) + req = withURLParam(req, "id", target) + req = withSession(req, alice, aliceSession) + w := httptest.NewRecorder() + h.handleRevokeMySession(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404 (not another user's to revoke)", w.Code) + } + + // The 404 must mean "didn't happen", not merely "wasn't reported". + var stillThere bool + if err := pool.QueryRow(context.Background(), + `SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, bobSession, + ).Scan(&stillThere); err != nil { + t.Fatalf("exists check: %v", err) + } + if !stillThere { + t.Error("bob's session was deleted by alice's request") + } +} + +func TestRevokeMySession_DeletesOwnSession(t *testing.T) { + h, pool := testHandlers(t) + alice := seedUser(t, pool, "alice", "hunter2", false) + current := seedSession(t, pool, alice.ID, "203.0.113.1") + other := seedSession(t, pool, alice.ID, "198.51.100.7") + + target := uuidToString(other) + req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil) + req = withURLParam(req, "id", target) + req = withSession(req, alice, current) + w := httptest.NewRecorder() + h.handleRevokeMySession(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", w.Code) + } + var gone bool + if err := pool.QueryRow(context.Background(), + `SELECT NOT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, other, + ).Scan(&gone); err != nil { + t.Fatalf("exists check: %v", err) + } + if !gone { + t.Error("session survived its own owner's revoke") + } +} + +// "Log out everywhere else" must spare the caller — otherwise the button +// signs you out of the page you pressed it on, which is indistinguishable +// from the compromise it's meant to remedy. +func TestRevokeMyOtherSessions_SparesCurrentAndOtherUsers(t *testing.T) { + h, pool := testHandlers(t) + alice := seedUser(t, pool, "alice", "hunter2", false) + bob := seedUser(t, pool, "bob", "hunter2", false) + + current := seedSession(t, pool, alice.ID, "203.0.113.1") + seedSession(t, pool, alice.ID, "198.51.100.7") + seedSession(t, pool, alice.ID, "198.51.100.8") + bobSession := seedSession(t, pool, bob.ID, "203.0.113.9") + + req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil) + req = withSession(req, alice, current) + w := httptest.NewRecorder() + h.handleRevokeMyOtherSessions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var body revokedResp + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Revoked != 2 { + t.Errorf("revoked = %d, want 2 (alice's other two, not bob's)", body.Revoked) + } + + var aliceRemaining, bobRemaining int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID, + ).Scan(&aliceRemaining); err != nil { + t.Fatalf("count alice: %v", err) + } + if aliceRemaining != 1 { + t.Errorf("alice sessions = %d, want 1 (the current one)", aliceRemaining) + } + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM sessions WHERE id = $1`, bobSession, + ).Scan(&bobRemaining); err != nil { + t.Fatalf("count bob: %v", err) + } + if bobRemaining != 1 { + t.Error("bob's session was caught in alice's logout-others") + } +} + +// Without a current-session id the exclusion has nothing to exclude, so the +// handler must refuse rather than delete everything. +func TestRevokeMyOtherSessions_RefusesWithoutCurrentSession(t *testing.T) { + h, pool := testHandlers(t) + alice := seedUser(t, pool, "alice", "hunter2", false) + seedSession(t, pool, alice.ID, "203.0.113.1") + + req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil) + req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), alice)) + w := httptest.NewRecorder() + h.handleRevokeMyOtherSessions(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", w.Code) + } + var remaining int + if err := pool.QueryRow(context.Background(), + `SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID, + ).Scan(&remaining); err != nil { + t.Fatalf("count: %v", err) + } + if remaining != 1 { + t.Errorf("sessions = %d, want 1 — refusing must not delete", remaining) + } +} + +func TestListMySessions_FlagsCurrentAndScopesToUser(t *testing.T) { + h, pool := testHandlers(t) + alice := seedUser(t, pool, "alice", "hunter2", false) + bob := seedUser(t, pool, "bob", "hunter2", false) + + current := seedSession(t, pool, alice.ID, "203.0.113.1") + seedSession(t, pool, alice.ID, "198.51.100.7") + seedSession(t, pool, bob.ID, "203.0.113.9") + + req := httptest.NewRequest(http.MethodGet, "/api/me/sessions", nil) + req = withSession(req, alice, current) + w := httptest.NewRecorder() + h.handleListMySessions(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var got []sessionResp + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 2 { + t.Fatalf("sessions = %d, want 2 (bob's must not appear)", len(got)) + } + currentCount := 0 + for _, s := range got { + if s.Current { + currentCount++ + if s.ID != uuidToString(current) { + t.Errorf("current flagged on %s, want %s", s.ID, uuidToString(current)) + } + } + if s.CreatedIP == "" { + t.Error("created_ip empty — the whole point of the surface") + } + } + if currentCount != 1 { + t.Errorf("current-flagged rows = %d, want exactly 1", currentCount) + } +} diff --git a/internal/audit/audit.go b/internal/audit/audit.go index e38b5ee4..eb2e8a5b 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -48,6 +48,13 @@ const ( ActionTokenRegenerate Action = "token_regenerate" ActionForgotPasswordInit Action = "forgot_password_initiated" ActionPasswordResetByEmail Action = "password_reset_via_email" + + // Active-sessions surface (#370). Worth auditing rather than silent: + // revoking sessions is what a user does when they think an account is + // compromised, so the audit trail is most useful precisely when it's + // exercised. + ActionSessionRevoke Action = "session_revoke" + ActionSessionRevokeOthers Action = "session_revoke_others" ) // Write inserts one audit_log row. metadata is marshaled as JSON; diff --git a/internal/auth/clientip.go b/internal/auth/clientip.go new file mode 100644 index 00000000..ba228fe0 --- /dev/null +++ b/internal/auth/clientip.go @@ -0,0 +1,92 @@ +package auth + +import ( + "net" + "net/http" + "strings" +) + +// ClientIP returns the caller's address for the active-sessions surface (#370). +// +// Both obvious implementations are wrong, and they're wrong in ways that +// matter specifically because this feeds a compromise-detection UI: +// +// - r.RemoteAddr alone. Minstrel is normally behind a reverse proxy, so +// every session would show the proxy's address — noise shaped like data, +// hiding the exact thing the operator is looking for. +// - Trusting X-Forwarded-For. Any client can set that header, so an +// attacker could choose what appears in their victim's session list. +// A security surface an attacker can write to is worse than none. +// +// So the header is trusted only when the request actually arrived from a +// proxy. If RemoteAddr is public, the caller reached us directly and its XFF +// is attacker-controlled, so it's ignored outright. If RemoteAddr is +// private/loopback, XFF is walked from the RIGHT — entries are appended as a +// request passes through infrastructure, so the rightmost end is the one our +// own proxies wrote — and the first address that isn't itself a proxy range +// wins. A client forging XFF can only prepend to the untrusted left end, +// which that walk never reaches. +// +// Known limitation, failing closed on purpose: if the proxy sits on a PUBLIC +// address (a separate host, or a CDN in front), RemoteAddr isn't in a proxy +// range, so we report the proxy rather than the end user. That's a true fact +// about where the request came from, which beats trusting a forgeable header. +// +// Returns "" when nothing usable can be determined. Callers store that as-is +// and the UI renders "unknown" rather than inventing a value. +func ClientIP(r *http.Request) string { + remote := hostOf(r.RemoteAddr) + ip := net.ParseIP(remote) + if ip == nil || !isProxyRange(ip) { + return remote + } + if forwarded := forwardedClient(r.Header.Get("X-Forwarded-For")); forwarded != "" { + return forwarded + } + // Some proxies set only X-Real-IP. The trust condition is already + // satisfied — we know this request came from a proxy range. + if real := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); real != nil { + return real.String() + } + return remote +} + +// hostOf strips the port from a RemoteAddr, tolerating values that have none. +func hostOf(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return strings.TrimSpace(remoteAddr) + } + return host +} + +// forwardedClient walks an X-Forwarded-For value right-to-left and returns +// the first address outside our proxy ranges — see ClientIP for why the +// direction matters. Returns "" if the header is absent, malformed, or +// contains nothing but proxy addresses. +func forwardedClient(header string) string { + parts := strings.Split(header, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := net.ParseIP(strings.TrimSpace(parts[i])) + if ip == nil || isProxyRange(ip) { + continue + } + return ip.String() + } + return "" +} + +// isProxyRange reports whether ip is an address a reverse proxy would +// plausibly occupy in a self-hosted deployment: loopback, RFC1918 / ULA +// (both covered by IsPrivate), link-local, or unspecified. +// +// Deliberately not configurable. These ranges cover proxy-on-same-host and +// proxy-on-the-same-docker-network, which is essentially every self-hosted +// install, and it works with no setup at all (rule #26). An exotic topology +// can motivate a setting when one actually turns up. +func isProxyRange(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsUnspecified() +} diff --git a/internal/auth/clientip_test.go b/internal/auth/clientip_test.go new file mode 100644 index 00000000..8b49633e --- /dev/null +++ b/internal/auth/clientip_test.go @@ -0,0 +1,121 @@ +package auth + +import ( + "net/http" + "testing" +) + +// The spoofing cases below are the reason this function exists rather than a +// one-line r.RemoteAddr read, so they're asserted explicitly rather than +// folded into the happy-path table. +func TestClientIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + forwarded string + realIP string + want string + }{ + { + name: "direct connection, no proxy headers", + remoteAddr: "203.0.113.5:51234", + want: "203.0.113.5", + }, + { + // The attack this guards against: a client connecting straight to + // us claims to be someone else. RemoteAddr is public, so it did + // NOT come through our proxy, so its XFF is worthless. + name: "direct connection ignores forged X-Forwarded-For", + remoteAddr: "203.0.113.5:51234", + forwarded: "198.51.100.99", + want: "203.0.113.5", + }, + { + name: "direct connection ignores forged X-Real-IP", + remoteAddr: "203.0.113.5:51234", + realIP: "198.51.100.99", + want: "203.0.113.5", + }, + { + name: "behind proxy, single forwarded client", + remoteAddr: "172.18.0.1:40000", + forwarded: "203.0.113.5", + want: "203.0.113.5", + }, + { + // A client that prepends a lie to XFF only pollutes the LEFT end; + // the proxy appends the address it actually saw on the right. The + // right-to-left walk reaches the truth first. + name: "behind proxy, forged prefix is skipped for the appended truth", + remoteAddr: "10.0.0.2:40000", + forwarded: "198.51.100.99, 203.0.113.5", + want: "203.0.113.5", + }, + { + name: "behind proxy chain, internal hops skipped", + remoteAddr: "10.0.0.2:40000", + forwarded: "203.0.113.5, 10.0.0.7, 172.18.0.3", + want: "203.0.113.5", + }, + { + name: "behind proxy, X-Real-IP used when no forwarded header", + remoteAddr: "127.0.0.1:40000", + realIP: "203.0.113.5", + want: "203.0.113.5", + }, + { + // LAN client through a LAN proxy: everything is private, so there + // is no public address to find. Reporting the peer is honest. + name: "behind proxy, all-private chain falls back to remote", + remoteAddr: "172.18.0.1:40000", + forwarded: "192.168.1.50, 172.18.0.3", + want: "172.18.0.1", + }, + { + name: "behind proxy, malformed forwarded entries ignored", + remoteAddr: "172.18.0.1:40000", + forwarded: "not-an-ip, 203.0.113.5, also-garbage", + want: "203.0.113.5", + }, + { + name: "remote addr without a port is tolerated", + remoteAddr: "203.0.113.5", + want: "203.0.113.5", + }, + { + name: "ipv6 remote addr", + remoteAddr: "[2001:db8::1]:51234", + want: "2001:db8::1", + }, + { + name: "ipv6 forwarded client behind proxy", + remoteAddr: "[fd00::1]:40000", + forwarded: "2001:db8::5", + want: "2001:db8::5", + }, + { + name: "empty remote addr yields empty", + remoteAddr: "", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r, err := http.NewRequest(http.MethodGet, "/api/me/sessions", nil) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + r.RemoteAddr = tc.remoteAddr + if tc.forwarded != "" { + r.Header.Set("X-Forwarded-For", tc.forwarded) + } + if tc.realIP != "" { + r.Header.Set("X-Real-IP", tc.realIP) + } + if got := ClientIP(r); got != tc.want { + t.Errorf("ClientIP() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 1438c418..139bf1f7 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -3,12 +3,17 @@ package auth import ( "context" + "github.com/jackc/pgx/v5/pgtype" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) type ctxKey int -const userCtxKey ctxKey = 1 +const ( + userCtxKey ctxKey = 1 + sessionIDCtxKey ctxKey = 2 +) // UserFromContext returns the authenticated user placed in context by // RequireUser. Returns false when RequireUser has not run (e.g. in tests that @@ -17,3 +22,13 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) { u, ok := ctx.Value(userCtxKey).(dbq.User) return u, ok } + +// SessionIDFromContext returns the id of the session that authenticated this +// request. The active-sessions surface needs it for the two things it can't +// do from the user alone: mark which row is "this device", and exclude that +// row from "log out everywhere else" so the action doesn't sign the caller +// out of the page they invoked it from. +func SessionIDFromContext(ctx context.Context) (pgtype.UUID, bool) { + id, ok := ctx.Value(sessionIDCtxKey).(pgtype.UUID) + return id, ok +} diff --git a/internal/auth/session.go b/internal/auth/session.go index e949223e..9ebed6e4 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -98,10 +98,17 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler { } // Best-effort last-seen update. A failure here shouldn't fail the // request; the session is still valid and this is observability. - if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil { + // last_ip rides the same UPDATE — a session whose address has + // moved since it was issued is the signal the active-sessions + // surface exists to show, and it costs nothing extra here. + if err := q.TouchSessionLastSeen(r.Context(), dbq.TouchSessionLastSeenParams{ + ID: sess.ID, + LastIp: ClientIP(r), + }); err != nil { slog.Warn("api: touch session last_seen failed", "err", err) } ctx := context.WithValue(r.Context(), userCtxKey, user) + ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -112,6 +119,12 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler { // middleware. Do not use this outside _test.go files. func UserCtxKeyForTest() any { return userCtxKey } +// SessionIDCtxKeyForTest is the sibling of UserCtxKeyForTest for the session +// id, so handler tests can exercise the current-session logic (which row is +// "this device", which one logout-others must spare) without standing up the +// middleware. Do not use this outside _test.go files. +func SessionIDCtxKeyForTest() any { return sessionIDCtxKey } + // OptionalUser is RequireUser's permissive sibling: it resolves the caller // from the session cookie or bearer header and attaches the user to context // when present + valid, but does NOT 401 on absence. The downstream handler @@ -153,6 +166,7 @@ func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) ht return } ctx := context.WithValue(r.Context(), userCtxKey, user) + ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index bfbb4ef8..56fc74af 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -514,6 +514,8 @@ type Session struct { UserAgent string CreatedAt pgtype.Timestamptz LastSeenAt pgtype.Timestamptz + CreatedIp string + LastIp string } type SkipEvent struct { diff --git a/internal/db/dbq/sessions.sql.go b/internal/db/dbq/sessions.sql.go index bc34671b..95c05d84 100644 --- a/internal/db/dbq/sessions.sql.go +++ b/internal/db/dbq/sessions.sql.go @@ -11,6 +11,25 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const deleteOtherSessionsForUser = `-- name: DeleteOtherSessionsForUser :execrows +DELETE FROM sessions WHERE user_id = $1 AND id <> $2 +` + +type DeleteOtherSessionsForUserParams struct { + UserID pgtype.UUID + ID pgtype.UUID +} + +// "Log out everywhere else." Excludes the caller's own session so the action +// doesn't log them out of the page they just used to invoke it. +func (q *Queries) DeleteOtherSessionsForUser(ctx context.Context, arg DeleteOtherSessionsForUserParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteOtherSessionsForUser, arg.UserID, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const deleteSession = `-- name: DeleteSession :exec DELETE FROM sessions WHERE id = $1 ` @@ -29,8 +48,29 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte return err } +const deleteSessionForUser = `-- name: DeleteSessionForUser :execrows +DELETE FROM sessions WHERE id = $1 AND user_id = $2 +` + +type DeleteSessionForUserParams struct { + ID pgtype.UUID + UserID pgtype.UUID +} + +// Scoped by user_id, not just id (rule #47). Keyed on the id alone, any +// household member could revoke another member's session by guessing a uuid. +// execrows lets the handler answer 404 rather than a false 204 when the row +// isn't theirs. +func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionForUserParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteSessionForUser, arg.ID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one -SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at FROM sessions WHERE token_hash = $1 +SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1 ` func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) { @@ -43,24 +83,35 @@ func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) ( &i.UserAgent, &i.CreatedAt, &i.LastSeenAt, + &i.CreatedIp, + &i.LastIp, ) return i, err } const insertSession = `-- name: InsertSession :one -INSERT INTO sessions (user_id, token_hash, user_agent) -VALUES ($1, $2, $3) -RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at +INSERT INTO sessions (user_id, token_hash, user_agent, created_ip, last_ip) +VALUES ($1, $2, $3, $4, $4) +RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip ` type InsertSessionParams struct { UserID pgtype.UUID TokenHash []byte UserAgent string + Ip string } +// created_ip and last_ip start equal: at issue time the origin IS the current +// location. They diverge as the session is used from elsewhere, which is what +// makes a stolen token visible in the active-sessions surface. func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (Session, error) { - row := q.db.QueryRow(ctx, insertSession, arg.UserID, arg.TokenHash, arg.UserAgent) + row := q.db.QueryRow(ctx, insertSession, + arg.UserID, + arg.TokenHash, + arg.UserAgent, + arg.Ip, + ) var i Session err := row.Scan( &i.ID, @@ -69,15 +120,57 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S &i.UserAgent, &i.CreatedAt, &i.LastSeenAt, + &i.CreatedIp, + &i.LastIp, ) return i, err } -const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec -UPDATE sessions SET last_seen_at = now() WHERE id = $1 +const listSessionsForUser = `-- name: ListSessionsForUser :many +SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC ` -func (q *Queries) TouchSessionLastSeen(ctx context.Context, id pgtype.UUID) error { - _, err := q.db.Exec(ctx, touchSessionLastSeen, id) +// Most-recently-active first: the row a user is most likely to act on is the +// one that moved last, and an unfamiliar entry at the top is the alarm. +func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) { + rows, err := q.db.Query(ctx, listSessionsForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Session + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.TokenHash, + &i.UserAgent, + &i.CreatedAt, + &i.LastSeenAt, + &i.CreatedIp, + &i.LastIp, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec +UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1 +` + +type TouchSessionLastSeenParams struct { + ID pgtype.UUID + LastIp string +} + +func (q *Queries) TouchSessionLastSeen(ctx context.Context, arg TouchSessionLastSeenParams) error { + _, err := q.db.Exec(ctx, touchSessionLastSeen, arg.ID, arg.LastIp) return err } diff --git a/internal/db/migrations/0052_session_ips.down.sql b/internal/db/migrations/0052_session_ips.down.sql new file mode 100644 index 00000000..e1e631d6 --- /dev/null +++ b/internal/db/migrations/0052_session_ips.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE sessions + DROP COLUMN created_ip, + DROP COLUMN last_ip; diff --git a/internal/db/migrations/0052_session_ips.up.sql b/internal/db/migrations/0052_session_ips.up.sql new file mode 100644 index 00000000..1da0bf48 --- /dev/null +++ b/internal/db/migrations/0052_session_ips.up.sql @@ -0,0 +1,19 @@ +-- Session provenance for the active-sessions surface (#370). +-- +-- TWO addresses, not one, and the pair is the point: a session created at +-- home and now being used from somewhere else is the shape of a stolen +-- token. A single "current IP" column can't express that, and a single +-- "origin IP" column goes stale the moment the token moves. +-- +-- text rather than inet, matching user_agent directly above: these are +-- stored to be displayed, never queried by subnet, and inet round-trips +-- through pgx/sqlc as a netip.Prefix that renders as "1.2.3.4/32" and would +-- need unwrapping at every display site. +-- +-- DEFAULT '' rather than NULL so existing rows — and any future insert that +-- genuinely can't determine an address — stay renderable without a null +-- check at every call site. The UI reads empty as "unknown" rather than +-- inventing a value. +ALTER TABLE sessions + ADD COLUMN created_ip text NOT NULL DEFAULT '', + ADD COLUMN last_ip text NOT NULL DEFAULT ''; diff --git a/internal/db/queries/sessions.sql b/internal/db/queries/sessions.sql index 438b066f..9c809053 100644 --- a/internal/db/queries/sessions.sql +++ b/internal/db/queries/sessions.sql @@ -1,16 +1,36 @@ -- name: InsertSession :one -INSERT INTO sessions (user_id, token_hash, user_agent) -VALUES ($1, $2, $3) +-- created_ip and last_ip start equal: at issue time the origin IS the current +-- location. They diverge as the session is used from elsewhere, which is what +-- makes a stolen token visible in the active-sessions surface. +INSERT INTO sessions (user_id, token_hash, user_agent, created_ip, last_ip) +VALUES ($1, $2, $3, sqlc.arg(ip), sqlc.arg(ip)) RETURNING *; -- name: GetSessionByTokenHash :one SELECT * FROM sessions WHERE token_hash = $1; -- name: TouchSessionLastSeen :exec -UPDATE sessions SET last_seen_at = now() WHERE id = $1; +UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1; + +-- name: ListSessionsForUser :many +-- Most-recently-active first: the row a user is most likely to act on is the +-- one that moved last, and an unfamiliar entry at the top is the alarm. +SELECT * FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC; -- name: DeleteSession :exec DELETE FROM sessions WHERE id = $1; -- name: DeleteSessionByTokenHash :exec DELETE FROM sessions WHERE token_hash = $1; + +-- name: DeleteSessionForUser :execrows +-- Scoped by user_id, not just id (rule #47). Keyed on the id alone, any +-- household member could revoke another member's session by guessing a uuid. +-- execrows lets the handler answer 404 rather than a false 204 when the row +-- isn't theirs. +DELETE FROM sessions WHERE id = $1 AND user_id = $2; + +-- name: DeleteOtherSessionsForUser :execrows +-- "Log out everywhere else." Excludes the caller's own session so the action +-- doesn't log them out of the page they just used to invoke it. +DELETE FROM sessions WHERE user_id = $1 AND id <> $2; From bf649f3beb0ca568cad5dbc0d625fefd8728eb22 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 09:25:20 -0400 Subject: [PATCH 05/15] =?UTF-8?q?feat(web):=20active=20sessions=20card=20i?= =?UTF-8?q?n=20Settings=20=E2=80=94=20#370?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client half of #370. Lists every device signed in to your account, with a per-row sign-out and a "sign out all other devices" action. The card does one thing the API alone doesn't: it says "Address changed" when created_ip and last_ip differ, rather than printing two addresses and leaving you to compare them. That mismatch — same device string, different origin — is the shape of a stolen token, and it's the reason IP capture was worth a migration. Making the operator spot it by eye would have wasted the data. Placed with Password and API Token rather than at the bottom of the page: those three are the account-security group, and this is the one that tells you the other two need attention. Details worth naming: - The current session gets a "This device" badge and NO sign-out button — offering one would log you out of the page you're standing on. The server already excludes it from logout-others; this makes that visible. - Sign-out-all-others is a two-step confirm and states the count, so the button can't be a surprise. - A 404 on revoke reloads instead of erroring. It means the session is already gone — revoked elsewhere, or expired — so the list was simply stale and showing the truth is the right response. The code is `session_not_found`, not `not_found`: apierror.NotFound(what) prefixes it. - Empty and error states both handled (rule #24); the empty case is practically unreachable since listing requires an authenticated request, and is handled rather than assumed. - User-agent parsing is deliberately coarse. A real UA parser is a dependency and a maintenance burden for a string whose only job is "do you recognise this?" — the addresses carry the actual signal. Tests cover the parts that would be quiet if broken: the current-session badge suppressing its own sign-out button, the address-changed warning appearing and NOT appearing, the two-step confirm not firing on first click, and the load-failure retry. Android parity is a separate decision, not assumed. --- web/src/lib/api/me.ts | 30 +++ web/src/lib/components/ActiveSessions.svelte | 227 ++++++++++++++++++ web/src/lib/components/ActiveSessions.test.ts | 126 ++++++++++ web/src/routes/settings/+page.svelte | 6 + 4 files changed, 389 insertions(+) create mode 100644 web/src/lib/components/ActiveSessions.svelte create mode 100644 web/src/lib/components/ActiveSessions.test.ts diff --git a/web/src/lib/api/me.ts b/web/src/lib/api/me.ts index f6004a31..e9a9ab66 100644 --- a/web/src/lib/api/me.ts +++ b/web/src/lib/api/me.ts @@ -62,3 +62,33 @@ export async function regenerateAPIToken(): Promise { export async function putMyTimezone(timezone: string): Promise { await api.put('/api/me/timezone', { timezone }); } + +// Active sessions (#370) --------------------------------------------------- + +// created_ip is frozen at issue time; last_ip moves with the session. The +// pair is the signal — the same device string arriving from an address you +// don't recognise is what a stolen token looks like from the inside. +export type ActiveSession = { + id: string; + user_agent: string; + created_ip: string; + last_ip: string; + created_at: string; + last_seen_at: string; + current: boolean; +}; + +export async function listSessions(): Promise { + return api.get('/api/me/sessions'); +} + +export async function revokeSession(id: string): Promise { + await api.del(`/api/me/sessions/${id}`); +} + +// Returns how many were ended. The server excludes the caller's own session, +// so this never signs you out of the page you pressed it on. +export async function revokeOtherSessions(): Promise { + const body = await api.post<{ revoked: number }>('/api/me/sessions/logout-others', {}); + return body.revoked; +} diff --git a/web/src/lib/components/ActiveSessions.svelte b/web/src/lib/components/ActiveSessions.svelte new file mode 100644 index 00000000..bcde8318 --- /dev/null +++ b/web/src/lib/components/ActiveSessions.svelte @@ -0,0 +1,227 @@ + + +
+

Active sessions

+

+ Every device signed in to your account. If you see one you don't recognise — especially + one marked as having moved — sign it out and change your password. +

+ + {#if loadError} +

+ Couldn't load your sessions. + +

+ {:else if sessions === null} +

Loading…

+ {:else if sessions.length === 0} + +

No active sessions.

+ {:else} +
    + {#each sessions as s (s.id)} +
  • +
  • + {/each} +
+ + {#if others > 0} + {#if confirmingLogoutOthers} +
+ + Sign out {others === 1 ? '1 other device' : `${others} other devices`}? You'll stay + signed in here. + + + +
+ {:else} + + {/if} + {/if} + {/if} +
diff --git a/web/src/lib/components/ActiveSessions.test.ts b/web/src/lib/components/ActiveSessions.test.ts new file mode 100644 index 00000000..0cd226d3 --- /dev/null +++ b/web/src/lib/components/ActiveSessions.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import ActiveSessions from './ActiveSessions.svelte'; + +const listSessions = vi.fn(); +const revokeSession = vi.fn(); +const revokeOtherSessions = vi.fn(); + +vi.mock('$lib/api/me', () => ({ + listSessions: (...a: unknown[]) => listSessions(...a), + revokeSession: (...a: unknown[]) => revokeSession(...a), + revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a) +})); + +vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() })); + +type Row = { + id: string; + user_agent: string; + created_ip: string; + last_ip: string; + created_at: string; + last_seen_at: string; + current: boolean; +}; + +function row(over: Partial = {}): Row { + return { + id: 'a1', + user_agent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0', + created_ip: '203.0.113.1', + last_ip: '203.0.113.1', + created_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), + current: false, + ...over + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ActiveSessions', () => { + test('flags the current session and gives it no sign-out button', async () => { + listSessions.mockResolvedValue([ + row({ id: 'cur', current: true }), + row({ id: 'other', current: false }) + ]); + render(ActiveSessions); + + await screen.findByText('This device'); + // One sign-out button, for the non-current row. Offering one on the + // current session would sign the user out of the page they're using. + await waitFor(() => { + expect(screen.getAllByRole('button', { name: 'Sign out' })).toHaveLength(1); + }); + }); + + // The whole reason IP is stored: surfacing the mismatch rather than making + // someone compare two addresses by eye. + test('warns when a session is used from a different address than it was created', async () => { + listSessions.mockResolvedValue([ + row({ id: 'moved', created_ip: '203.0.113.1', last_ip: '198.51.100.9' }) + ]); + render(ActiveSessions); + + expect(await screen.findByText('Address changed')).toBeTruthy(); + }); + + test('does not warn when the address has not changed', async () => { + listSessions.mockResolvedValue([ + row({ created_ip: '203.0.113.1', last_ip: '203.0.113.1' }) + ]); + render(ActiveSessions); + + await screen.findByText(/Signed in/); + expect(screen.queryByText('Address changed')).toBeNull(); + }); + + test('sign-out-all-others confirms before acting', async () => { + listSessions.mockResolvedValue([ + row({ id: 'cur', current: true }), + row({ id: 'o1' }), + row({ id: 'o2' }) + ]); + revokeOtherSessions.mockResolvedValue(2); + render(ActiveSessions); + + const start = await screen.findByRole('button', { name: 'Sign out all other devices' }); + await fireEvent.click(start); + // First click only arms the action. + expect(revokeOtherSessions).not.toHaveBeenCalled(); + expect(screen.getByText(/Sign out 2 other devices\?/)).toBeTruthy(); + + await fireEvent.click(screen.getByRole('button', { name: 'Sign them out' })); + await waitFor(() => expect(revokeOtherSessions).toHaveBeenCalledTimes(1)); + }); + + test('offers no bulk action when there are no other devices', async () => { + listSessions.mockResolvedValue([row({ id: 'cur', current: true })]); + render(ActiveSessions); + + await screen.findByText('This device'); + expect(screen.queryByRole('button', { name: 'Sign out all other devices' })).toBeNull(); + }); + + test('surfaces a retry when loading fails', async () => { + listSessions.mockRejectedValue(new Error('boom')); + render(ActiveSessions); + + const retry = await screen.findByRole('button', { name: 'Try again' }); + listSessions.mockResolvedValue([row({ id: 'cur', current: true })]); + await fireEvent.click(retry); + await screen.findByText('This device'); + }); + + test('renders unknown for a missing address rather than an empty cell', async () => { + listSessions.mockResolvedValue([row({ created_ip: '', last_ip: '' })]); + render(ActiveSessions); + + await waitFor(() => { + expect(screen.getAllByText('unknown').length).toBeGreaterThan(0); + }); + }); +}); diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index 3c6c0d23..4d6c98cb 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -26,6 +26,7 @@ import { pushToast } from '$lib/stores/toast.svelte'; import MobileAppDownload from '$lib/components/MobileAppDownload.svelte'; import ServerVersion from '$lib/components/ServerVersion.svelte'; + import ActiveSessions from '$lib/components/ActiveSessions.svelte'; const queryClient = useQueryClient(); @@ -526,6 +527,11 @@ + + +

Library

    From 381e9cedb702667d331188889c32f68610428328 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 10:07:43 -0400 Subject: [PATCH 06/15] =?UTF-8?q?feat(net):=20trusted-proxy=20depth=20so?= =?UTF-8?q?=20real=20client=20IPs=20survive=20a=20proxy=20=E2=80=94=20#245?= =?UTF-8?q?3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the defect the operator spotted in #370 immediately after it shipped: auth.ClientIP ignored X-Forwarded-For whenever RemoteAddr was public, so a proxy on a public address — a separate host, or a CDN, i.e. anyone running this publicly, since public means TLS means a proxy — recorded the PROXY for every session. created_ip and last_ip were then always equal and the "Address changed" signal could never fire. The feature looked like it worked and reported nothing. Replaced with the standard trusted-hop model (Rails, Caddy, Traefik, nginx). XFF grows left-to-right as each proxy appends the peer it received from, so for client -> CDN -> own-proxy -> app the app sees [client, CDN] with RemoteAddr = own-proxy, and the client sits at XFF[len - hops]: 0 RemoteAddr, XFF ignored — no proxy 1 the address your own proxy observed 2 through a CDN in front of your proxy Default 1, per the operator: publicly reachable means a TLS terminator in front. The cost is real and stated rather than hidden. hops >= 1 DECLARES that a proxy exists; set it with no proxy, or deeper than the actual chain, and the index reaches attacker-supplied entries, letting a visitor choose which address their own session shows — defeating exactly the detection #370 is for. That's inherent to the model, which is why 0 is a first-class value and the admin card says "count your proxies, don't guess high" instead of just exposing a number. Both mis-set shapes are pinned by tests so they stay known consequences rather than surprises. Migration 0053 + internal/netsettings, cached under an RWMutex. That's not an optimisation: ClientIP runs in RequireUser for every authenticated request, so a per-request query would put the database on the critical path of the whole API. New() always returns a usable service so a boot-time DB hiccup degrades to the default instead of breaking that path (rule #131), and Hops() is nil-safe because test routers construct middleware without it. RequireUser now takes a func() int rather than an int — the value is operator-editable at runtime while the middleware is built once at boot, and reading it per request is what makes a save take effect with no restart (rule #25). The admin card is verifiable, not just configurable: it reports the address the CURRENT setting resolves THIS request to, the raw forwarded chain, and the socket peer — so you set the number, save, and confirm the address matches the machine you're on. It also counts the arriving chain and says how many proxies that implies. GET/PUT both return that payload, PUT recomputed under the new value, so the effect is visible without a reload. Also fixes styling in the #370 card that CI could not catch: text-destructive and bg-destructive don't exist in this Tailwind config — the palette is colors.action.destructive — so the "Address changed" warning and the sign-out-others button were rendering unstyled. Both now use text-action-destructive / bg-action-destructive / text-action-fg. Not done here: requestlog.go still logs raw RemoteAddr and will disagree with the sessions UI about who connected. Left for its own change. --- internal/api/admin_network.go | 65 +++++++ internal/api/api.go | 12 +- internal/auth/clientip.go | 145 +++++++++------- internal/auth/clientip_test.go | 163 ++++++++++++------ internal/auth/session.go | 10 +- internal/auth/session_test.go | 2 +- internal/db/dbq/models.go | 5 + internal/db/dbq/network_settings.sql.go | 32 ++++ .../migrations/0053_network_settings.down.sql | 1 + .../migrations/0053_network_settings.up.sql | 33 ++++ internal/db/queries/network_settings.sql | 5 + internal/netsettings/service.go | 98 +++++++++++ internal/netsettings/service_test.go | 55 ++++++ internal/server/server.go | 13 +- web/src/lib/api/admin.ts | 25 +++ web/src/lib/components/ActiveSessions.svelte | 6 +- .../lib/components/NetworkSettingsCard.svelte | 135 +++++++++++++++ .../components/NetworkSettingsCard.test.ts | 106 ++++++++++++ .../routes/admin/integrations/+page.svelte | 7 + 19 files changed, 788 insertions(+), 130 deletions(-) create mode 100644 internal/api/admin_network.go create mode 100644 internal/db/dbq/network_settings.sql.go create mode 100644 internal/db/migrations/0053_network_settings.down.sql create mode 100644 internal/db/migrations/0053_network_settings.up.sql create mode 100644 internal/db/queries/network_settings.sql create mode 100644 internal/netsettings/service.go create mode 100644 internal/netsettings/service_test.go create mode 100644 web/src/lib/components/NetworkSettingsCard.svelte create mode 100644 web/src/lib/components/NetworkSettingsCard.test.ts diff --git a/internal/api/admin_network.go b/internal/api/admin_network.go new file mode 100644 index 00000000..4ab12d63 --- /dev/null +++ b/internal/api/admin_network.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/netsettings" +) + +type networkSettingsResp struct { + TrustedProxyHops int `json:"trusted_proxy_hops"` + MaxHops int `json:"max_hops"` + // DetectedClientIP is what the CURRENT setting resolves this very request + // to. It's the difference between a number the operator has to reason + // about and one they can verify: set the value, reload, and check the + // address matches the machine you're sitting at. + DetectedClientIP string `json:"detected_client_ip"` + // ForwardedChain is the raw X-Forwarded-For as received, so an operator + // whose detected address looks wrong can see how many hops actually + // arrived and count them rather than guess. + ForwardedChain string `json:"forwarded_chain"` + RemoteAddr string `json:"remote_addr"` +} + +type updateNetworkSettingsReq struct { + TrustedProxyHops int `json:"trusted_proxy_hops"` +} + +func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.networkSettingsPayload(r)) +} + +func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Request) { + var req updateNetworkSettingsReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON")) + return + } + if err := h.netSettings.SetHops(r.Context(), req.TrustedProxyHops); err != nil { + if errors.Is(err, netsettings.ErrHopsOutOfRange) { + writeErr(w, apierror.BadRequest("invalid_hops", err.Error())) + return + } + writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err)) + return + } + // Echo the payload recomputed under the NEW value, so the card can show + // immediately what the change did to this request's own address rather + // than making the operator reload to find out. + writeJSON(w, http.StatusOK, h.networkSettingsPayload(r)) +} + +func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp { + hops := h.netSettings.Hops() + return networkSettingsResp{ + TrustedProxyHops: hops, + MaxHops: netsettings.MaxTrustedProxyHops, + DetectedClientIP: auth.ClientIP(r, hops), + ForwardedChain: r.Header.Get("X-Forwarded-For"), + RemoteAddr: r.RemoteAddr, + } +} diff --git a/internal/api/api.go b/internal/api/api.go index d27e2e47..e9c481b5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -20,6 +20,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" + "git.fabledsword.com/bvandeusen/minstrel/internal/netsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" @@ -30,7 +31,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -51,6 +52,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev eventbus: bus, playlistScheduler: playlistScheduler, streamSecret: streamSecret, + netSettings: netSettings, } r.Route("/api", func(api chi.Router) { @@ -74,7 +76,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream) api.Group(func(authed chi.Router) { - authed.Use(auth.RequireUser(pool)) + authed.Use(auth.RequireUser(pool, netSettings.Hops)) authed.Post("/auth/logout", h.handleLogout) authed.Get("/me", h.handleGetMe) authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus) @@ -185,6 +187,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover) admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers) + admin.Get("/network-settings", h.handleGetNetworkSettings) + admin.Put("/network-settings", h.handleUpdateNetworkSettings) + admin.Get("/scan/status", h.handleGetScanStatus) admin.Post("/scan/run", h.handleTriggerScan) @@ -264,6 +269,9 @@ type handlers struct { mailer mailer.Sender eventbus *eventbus.Bus playlistScheduler *playlists.Scheduler + // netSettings caches the trusted reverse-proxy depth read by the auth + // middleware on every request and edited from the admin network card. + netSettings *netsettings.Service // streamSecret is the HMAC key used by SignStreamToken / // VerifyStreamToken to authenticate the UPnP-speaker stream path // (see internal/api/stream_token.go and the design at diff --git a/internal/auth/clientip.go b/internal/auth/clientip.go index ba228fe0..2be913cb 100644 --- a/internal/auth/clientip.go +++ b/internal/auth/clientip.go @@ -6,51 +6,101 @@ import ( "strings" ) -// ClientIP returns the caller's address for the active-sessions surface (#370). +// ClientIP returns the caller's address, reading through trustedProxyHops +// reverse proxies (#2453). // -// Both obvious implementations are wrong, and they're wrong in ways that -// matter specifically because this feeds a compromise-detection UI: +// X-Forwarded-For grows left-to-right: every proxy APPENDS the peer it +// received the request from. For client -> CDN -> own-proxy -> Minstrel the +// app sees XFF = [client, CDN] and RemoteAddr = own-proxy. Each trusted proxy +// therefore accounts for one entry counting from the right, and the first +// address we were NOT told to trust is the client: // -// - r.RemoteAddr alone. Minstrel is normally behind a reverse proxy, so -// every session would show the proxy's address — noise shaped like data, -// hiding the exact thing the operator is looking for. -// - Trusting X-Forwarded-For. Any client can set that header, so an -// attacker could choose what appears in their victim's session list. -// A security surface an attacker can write to is worse than none. +// hops 0 -> RemoteAddr; XFF ignored entirely +// hops 1 -> XFF[1] = CDN — trusting only our own proxy, the most we can +// honestly claim is the address it told us about +// hops 2 -> XFF[0] = client // -// So the header is trusted only when the request actually arrived from a -// proxy. If RemoteAddr is public, the caller reached us directly and its XFF -// is attacker-controlled, so it's ignored outright. If RemoteAddr is -// private/loopback, XFF is walked from the RIGHT — entries are appended as a -// request passes through infrastructure, so the rightmost end is the one our -// own proxies wrote — and the first address that isn't itself a proxy range -// wins. A client forging XFF can only prepend to the untrusted left end, -// which that walk never reaches. +// This replaces an earlier heuristic that ignored XFF whenever RemoteAddr was +// public. That was safe but useless in the deployment that matters: a proxy +// on a public address (separate host, or a CDN) meant every session recorded +// the proxy, so the active-sessions surface could never show an address +// change (#370). // -// Known limitation, failing closed on purpose: if the proxy sits on a PUBLIC -// address (a separate host, or a CDN in front), RemoteAddr isn't in a proxy -// range, so we report the proxy rather than the end user. That's a true fact -// about where the request came from, which beats trusting a forgeable header. +// # What the operator is asserting // -// Returns "" when nothing usable can be determined. Callers store that as-is -// and the UI renders "unknown" rather than inventing a value. -func ClientIP(r *http.Request) string { +// hops >= 1 is a DECLARATION that a proxy sits in front. Two ways to get it +// wrong, both worth understanding rather than papering over: +// +// - Set to 1+ with NO proxy: any client can forge X-Forwarded-For and pick +// what its own session row shows, defeating the compromise detection. +// - Set HIGHER than the real chain: the index runs past the proxy-written +// entries into attacker-supplied ones, same result. +// +// Both are inherent to the trusted-hop model — Rails, Caddy, Traefik and +// nginx all behave this way — which is why 0 is a first-class value and the +// admin card tells the operator to count their proxies. +func ClientIP(r *http.Request, trustedProxyHops int) string { remote := hostOf(r.RemoteAddr) - ip := net.ParseIP(remote) - if ip == nil || !isProxyRange(ip) { + if trustedProxyHops <= 0 { return remote } - if forwarded := forwardedClient(r.Header.Get("X-Forwarded-For")); forwarded != "" { - return forwarded + chain := forwardedChain(r) + if len(chain) == 0 { + // No forwarding header: either there's genuinely no proxy, or one is + // misconfigured. The socket peer is the only thing we actually know. + return remote } - // Some proxies set only X-Real-IP. The trust condition is already - // satisfied — we know this request came from a proxy range. - if real := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); real != nil { - return real.String() + // Clamp rather than reject: a chain shorter than the configured depth + // means the operator over-counted, and the leftmost entry is the closest + // thing to a client on offer. The caveat above covers the risk. + idx := len(chain) - trustedProxyHops + if idx < 0 { + idx = 0 } + if ip := net.ParseIP(chain[idx]); ip != nil { + return ip.String() + } + // A proxy wrote something that isn't an address. Positional meaning is + // lost, so fall back to what we can verify ourselves. return remote } +// forwardedChain returns the X-Forwarded-For entries in wire order, or the +// single X-Real-IP value when XFF is absent. +// +// Entries are kept verbatim, including unparseable ones: their POSITION is +// what carries meaning here, so silently dropping a malformed hop would +// shift every index and could hand back an attacker-supplied entry. +func forwardedChain(r *http.Request) []string { + raw := r.Header.Get("X-Forwarded-For") + if strings.TrimSpace(raw) == "" { + // Some proxies set only X-Real-IP, which by construction is a single + // hop — the address that proxy saw. + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { + return []string{real} + } + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// hopsOf reads a trusted-depth accessor, treating a nil one as "trust +// nothing". Test contexts and any future caller that hasn't wired the +// settings service get the safe reading rather than a panic. +func hopsOf(fn func() int) int { + if fn == nil { + return 0 + } + return fn() +} + // hostOf strips the port from a RemoteAddr, tolerating values that have none. func hostOf(remoteAddr string) string { host, _, err := net.SplitHostPort(remoteAddr) @@ -59,34 +109,3 @@ func hostOf(remoteAddr string) string { } return host } - -// forwardedClient walks an X-Forwarded-For value right-to-left and returns -// the first address outside our proxy ranges — see ClientIP for why the -// direction matters. Returns "" if the header is absent, malformed, or -// contains nothing but proxy addresses. -func forwardedClient(header string) string { - parts := strings.Split(header, ",") - for i := len(parts) - 1; i >= 0; i-- { - ip := net.ParseIP(strings.TrimSpace(parts[i])) - if ip == nil || isProxyRange(ip) { - continue - } - return ip.String() - } - return "" -} - -// isProxyRange reports whether ip is an address a reverse proxy would -// plausibly occupy in a self-hosted deployment: loopback, RFC1918 / ULA -// (both covered by IsPrivate), link-local, or unspecified. -// -// Deliberately not configurable. These ranges cover proxy-on-same-host and -// proxy-on-the-same-docker-network, which is essentially every self-hosted -// install, and it works with no setup at all (rule #26). An exotic topology -// can motivate a setting when one actually turns up. -func isProxyRange(ip net.IP) bool { - return ip.IsLoopback() || - ip.IsPrivate() || - ip.IsLinkLocalUnicast() || - ip.IsUnspecified() -} diff --git a/internal/auth/clientip_test.go b/internal/auth/clientip_test.go index 8b49633e..fba50fb9 100644 --- a/internal/auth/clientip_test.go +++ b/internal/auth/clientip_test.go @@ -5,99 +5,148 @@ import ( "testing" ) -// The spoofing cases below are the reason this function exists rather than a -// one-line r.RemoteAddr read, so they're asserted explicitly rather than -// folded into the happy-path table. +// The hop arithmetic is the whole feature, so the table is written as +// deployment topologies rather than abstract inputs. func TestClientIP(t *testing.T) { tests := []struct { name string + hops int remoteAddr string forwarded string realIP string want string }{ { - name: "direct connection, no proxy headers", + name: "no proxy configured, socket peer wins", + hops: 0, remoteAddr: "203.0.113.5:51234", want: "203.0.113.5", }, { - // The attack this guards against: a client connecting straight to - // us claims to be someone else. RemoteAddr is public, so it did - // NOT come through our proxy, so its XFF is worthless. - name: "direct connection ignores forged X-Forwarded-For", + // hops 0 is the setting for a directly-exposed instance, and it + // must make forged headers inert. + name: "hops 0 ignores a forged forwarded header", + hops: 0, remoteAddr: "203.0.113.5:51234", forwarded: "198.51.100.99", want: "203.0.113.5", }, { - name: "direct connection ignores forged X-Real-IP", + // The common case: one TLS-terminating proxy. Note RemoteAddr is + // PUBLIC here — a proxy on its own host — which the previous + // private-range heuristic got wrong. + name: "one proxy on a public address yields the client", + hops: 1, + remoteAddr: "203.0.113.200:40000", + forwarded: "198.51.100.7", + want: "198.51.100.7", + }, + { + name: "one proxy on a private address yields the client", + hops: 1, + remoteAddr: "172.18.0.1:40000", + forwarded: "198.51.100.7", + want: "198.51.100.7", + }, + { + // client -> Cloudflare -> own proxy -> app. + // Trusting only our own proxy, the honest answer is Cloudflare: + // that's the address our proxy actually observed. + name: "cdn chain with hops 1 stops at the cdn", + hops: 1, + remoteAddr: "172.18.0.1:40000", + forwarded: "198.51.100.7, 203.0.113.50", + want: "203.0.113.50", + }, + { + // Same chain, both hops trusted — now we reach the real client. + name: "cdn chain with hops 2 reaches the client", + hops: 2, + remoteAddr: "172.18.0.1:40000", + forwarded: "198.51.100.7, 203.0.113.50", + want: "198.51.100.7", + }, + { + // A client prepending a lie is only reachable if the operator + // over-counts their proxies; at the correct depth it's skipped. + name: "forged prefix is not reached at the correct depth", + hops: 1, + remoteAddr: "172.18.0.1:40000", + forwarded: "1.2.3.4, 198.51.100.7", + want: "198.51.100.7", + }, + { + // The documented mis-set failure, pinned so it stays a KNOWN + // consequence rather than a surprise: depth deeper than the real + // chain reads attacker-supplied input. + name: "hops set deeper than the chain clamps to the leftmost entry", + hops: 5, + remoteAddr: "172.18.0.1:40000", + forwarded: "1.2.3.4, 198.51.100.7", + want: "1.2.3.4", + }, + { + name: "no forwarding header falls back to the socket peer", + hops: 1, remoteAddr: "203.0.113.5:51234", - realIP: "198.51.100.99", want: "203.0.113.5", }, { - name: "behind proxy, single forwarded client", + name: "x-real-ip used when forwarded-for is absent", + hops: 1, remoteAddr: "172.18.0.1:40000", - forwarded: "203.0.113.5", - want: "203.0.113.5", + realIP: "198.51.100.7", + want: "198.51.100.7", }, { - // A client that prepends a lie to XFF only pollutes the LEFT end; - // the proxy appends the address it actually saw on the right. The - // right-to-left walk reaches the truth first. - name: "behind proxy, forged prefix is skipped for the appended truth", - remoteAddr: "10.0.0.2:40000", - forwarded: "198.51.100.99, 203.0.113.5", - want: "203.0.113.5", - }, - { - name: "behind proxy chain, internal hops skipped", - remoteAddr: "10.0.0.2:40000", - forwarded: "203.0.113.5, 10.0.0.7, 172.18.0.3", - want: "203.0.113.5", - }, - { - name: "behind proxy, X-Real-IP used when no forwarded header", - remoteAddr: "127.0.0.1:40000", - realIP: "203.0.113.5", - want: "203.0.113.5", - }, - { - // LAN client through a LAN proxy: everything is private, so there - // is no public address to find. Reporting the peer is honest. - name: "behind proxy, all-private chain falls back to remote", + name: "forwarded-for wins over x-real-ip when both present", + hops: 1, remoteAddr: "172.18.0.1:40000", - forwarded: "192.168.1.50, 172.18.0.3", + forwarded: "198.51.100.7", + realIP: "1.2.3.4", + want: "198.51.100.7", + }, + { + // Positions are preserved, so a garbage hop can be selected — + // in which case we fall back rather than return nonsense. + name: "unparseable selected entry falls back to the socket peer", + hops: 1, + remoteAddr: "172.18.0.1:40000", + forwarded: "198.51.100.7, not-an-ip", want: "172.18.0.1", }, { - name: "behind proxy, malformed forwarded entries ignored", - remoteAddr: "172.18.0.1:40000", - forwarded: "not-an-ip, 203.0.113.5, also-garbage", - want: "203.0.113.5", - }, - { - name: "remote addr without a port is tolerated", - remoteAddr: "203.0.113.5", - want: "203.0.113.5", - }, - { - name: "ipv6 remote addr", - remoteAddr: "[2001:db8::1]:51234", - want: "2001:db8::1", - }, - { - name: "ipv6 forwarded client behind proxy", + name: "ipv6 client through one proxy", + hops: 1, remoteAddr: "[fd00::1]:40000", forwarded: "2001:db8::5", want: "2001:db8::5", }, + { + name: "ipv6 socket peer without proxy", + hops: 0, + remoteAddr: "[2001:db8::1]:51234", + want: "2001:db8::1", + }, + { + name: "remote addr without a port is tolerated", + hops: 0, + remoteAddr: "203.0.113.5", + want: "203.0.113.5", + }, { name: "empty remote addr yields empty", + hops: 1, remoteAddr: "", want: "", }, + { + name: "whitespace-only forwarded header is treated as absent", + hops: 1, + remoteAddr: "172.18.0.1:40000", + forwarded: " ", + want: "172.18.0.1", + }, } for _, tc := range tests { @@ -113,8 +162,8 @@ func TestClientIP(t *testing.T) { if tc.realIP != "" { r.Header.Set("X-Real-IP", tc.realIP) } - if got := ClientIP(r); got != tc.want { - t.Errorf("ClientIP() = %q, want %q", got, tc.want) + if got := ClientIP(r, tc.hops); got != tc.want { + t.Errorf("ClientIP(hops=%d) = %q, want %q", tc.hops, got, tc.want) } }) } diff --git a/internal/auth/session.go b/internal/auth/session.go index 9ebed6e4..49eda982 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -56,7 +56,13 @@ const SessionCookieName = "minstrel_session" // bearer header and puts the dbq.User in request context via userCtxKey. // Requests without a valid session return 401 with no body so callers don't // leak whether the username existed (matches the /rest/* auth posture). -func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler { +// +// trustedHops supplies the reverse-proxy depth used to record the session's +// current address (#2453). It's a func rather than an int because the value +// is operator-editable at runtime and this middleware is constructed once at +// boot — reading it per request is what makes an admin change take effect +// without a restart. Passing nil means "trust nothing", i.e. the socket peer. +func RequireUser(pool *pgxpool.Pool, trustedHops func() int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := sessionTokenFromRequest(r) @@ -103,7 +109,7 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler { // surface exists to show, and it costs nothing extra here. if err := q.TouchSessionLastSeen(r.Context(), dbq.TouchSessionLastSeenParams{ ID: sess.ID, - LastIp: ClientIP(r), + LastIp: ClientIP(r, hopsOf(trustedHops)), }); err != nil { slog.Warn("api: touch session last_seen failed", "err", err) } diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index e805e650..88d03984 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -57,7 +57,7 @@ func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) { next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("handler must not be called") }) - h := RequireUser(nil)(next) + h := RequireUser(nil, nil)(next) req := httptest.NewRequest(http.MethodGet, "/api/me", nil) w := httptest.NewRecorder() diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 56fc74af..51cdc693 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -381,6 +381,11 @@ type LidarrRequest struct { LidarrAddConfirmedAt pgtype.Timestamptz } +type NetworkSetting struct { + ID bool + TrustedProxyHops int32 +} + type PasswordReset struct { Token string UserID pgtype.UUID diff --git a/internal/db/dbq/network_settings.sql.go b/internal/db/dbq/network_settings.sql.go new file mode 100644 index 00000000..89573cfd --- /dev/null +++ b/internal/db/dbq/network_settings.sql.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: network_settings.sql + +package dbq + +import ( + "context" +) + +const getNetworkSettings = `-- name: GetNetworkSettings :one +SELECT id, trusted_proxy_hops FROM network_settings WHERE id = true +` + +func (q *Queries) GetNetworkSettings(ctx context.Context) (NetworkSetting, error) { + row := q.db.QueryRow(ctx, getNetworkSettings) + var i NetworkSetting + err := row.Scan(&i.ID, &i.TrustedProxyHops) + return i, err +} + +const updateTrustedProxyHops = `-- name: UpdateTrustedProxyHops :one +UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING id, trusted_proxy_hops +` + +func (q *Queries) UpdateTrustedProxyHops(ctx context.Context, trustedProxyHops int32) (NetworkSetting, error) { + row := q.db.QueryRow(ctx, updateTrustedProxyHops, trustedProxyHops) + var i NetworkSetting + err := row.Scan(&i.ID, &i.TrustedProxyHops) + return i, err +} diff --git a/internal/db/migrations/0053_network_settings.down.sql b/internal/db/migrations/0053_network_settings.down.sql new file mode 100644 index 00000000..88c2e842 --- /dev/null +++ b/internal/db/migrations/0053_network_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE network_settings; diff --git a/internal/db/migrations/0053_network_settings.up.sql b/internal/db/migrations/0053_network_settings.up.sql new file mode 100644 index 00000000..d53649ff --- /dev/null +++ b/internal/db/migrations/0053_network_settings.up.sql @@ -0,0 +1,33 @@ +-- Trusted reverse-proxy depth for client-IP extraction (#2453). +-- +-- X-Forwarded-For grows left-to-right: each proxy APPENDS the peer it +-- received the request from. For client -> CDN -> own-proxy -> Minstrel the +-- app sees XFF = [client, CDN] with RemoteAddr = own-proxy. So the real +-- client sits at XFF[len - hops], where hops counts the proxies you trust: +-- +-- 0 no proxy in front — use the socket peer, ignore XFF entirely +-- 1 one reverse proxy (nginx / Caddy / Traefik terminating TLS) +-- 2 a CDN in front of your own proxy (Cloudflare -> nginx -> Minstrel) +-- +-- Default 1: a publicly reachable Minstrel needs a TLS terminator in front of +-- it, and recording that terminator's own address for every session makes the +-- active-sessions surface (#370) useless — created_ip and last_ip would both +-- be the proxy, so the "address changed" signal could never fire. +-- +-- The cost, stated on the admin card rather than buried: hops >= 1 DECLARES +-- that a proxy exists. If one doesn't, a client can forge X-Forwarded-For and +-- choose what its own session row shows, which defeats exactly the compromise +-- detection #370 exists for. That is inherent to the trusted-hop model, which +-- is why 0 is a first-class setting and not a hidden escape hatch. +-- +-- Upper bound 10 guards a typo turning into "trust the whole header"; no real +-- deployment chains ten proxies. +CREATE TABLE network_settings ( + id boolean PRIMARY KEY DEFAULT true, + trusted_proxy_hops int NOT NULL DEFAULT 1, + CONSTRAINT network_settings_singleton CHECK (id = true), + CONSTRAINT network_settings_hops_range + CHECK (trusted_proxy_hops >= 0 AND trusted_proxy_hops <= 10) +); + +INSERT INTO network_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; diff --git a/internal/db/queries/network_settings.sql b/internal/db/queries/network_settings.sql new file mode 100644 index 00000000..9527590e --- /dev/null +++ b/internal/db/queries/network_settings.sql @@ -0,0 +1,5 @@ +-- name: GetNetworkSettings :one +SELECT * FROM network_settings WHERE id = true; + +-- name: UpdateTrustedProxyHops :one +UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING *; diff --git a/internal/netsettings/service.go b/internal/netsettings/service.go new file mode 100644 index 00000000..0f062ab6 --- /dev/null +++ b/internal/netsettings/service.go @@ -0,0 +1,98 @@ +// Package netsettings holds the DB-backed network settings the request path +// needs. Today that's the trusted reverse-proxy depth used to pull a real +// client address out of X-Forwarded-For (#2453). +// +// Values are cached under an RWMutex and refreshed on write. That isn't an +// optimisation: auth.ClientIP runs in the RequireUser middleware for every +// authenticated request, so a per-request query here would put the database +// on the critical path of the entire API. +package netsettings + +import ( + "context" + "errors" + "log/slog" + "sync" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +const ( + // DefaultTrustedProxyHops mirrors migration 0053's column default. One + // proxy, because anything publicly reachable needs a TLS terminator in + // front of it. + DefaultTrustedProxyHops = 1 + // MaxTrustedProxyHops mirrors the CHECK in migration 0053. + MaxTrustedProxyHops = 10 +) + +// ErrHopsOutOfRange is returned by SetHops for values the CHECK would reject, +// so the API layer can answer 400 instead of surfacing a constraint violation. +var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10") + +// Service caches the network settings and owns their persistence. +type Service struct { + pool *pgxpool.Pool + logger *slog.Logger + + mu sync.RWMutex + hops int +} + +// New loads the settings once and caches them. +// +// It ALWAYS returns a usable Service, even alongside a non-nil error. The +// value it holds sits on the authenticated request path, so a boot-time +// database hiccup must degrade to the default rather than take every request +// down with it (rule #131). The error is returned so the caller can log that +// the cache holds a default rather than stored state. +func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) { + s := &Service{pool: pool, logger: logger, hops: DefaultTrustedProxyHops} + if pool == nil { + return s, nil + } + row, err := dbq.New(pool).GetNetworkSettings(ctx) + if err != nil { + return s, err + } + s.hops = int(row.TrustedProxyHops) + return s, nil +} + +// Hops returns the cached trusted-proxy depth. +// +// Nil-safe: test contexts construct routers without this service, and a +// missing setting should mean "trust nothing" rather than a panic in +// middleware. +func (s *Service) Hops() int { + if s == nil { + return 0 + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.hops +} + +// SetHops persists a new depth and refreshes the cache, so an admin change +// takes effect on the next request with no restart (rule #25). +func (s *Service) SetHops(ctx context.Context, hops int) error { + if s == nil || s.pool == nil { + // Mirrors Hops()'s nil-tolerance: handlers can be constructed without + // this service in tests, and a write attempt there should be an error + // rather than a panic in an HTTP handler. + return errors.New("network settings unavailable") + } + if hops < 0 || hops > MaxTrustedProxyHops { + return ErrHopsOutOfRange + } + row, err := dbq.New(s.pool).UpdateTrustedProxyHops(ctx, int32(hops)) + if err != nil { + return err + } + s.mu.Lock() + s.hops = int(row.TrustedProxyHops) + s.mu.Unlock() + return nil +} diff --git a/internal/netsettings/service_test.go b/internal/netsettings/service_test.go new file mode 100644 index 00000000..c01dd645 --- /dev/null +++ b/internal/netsettings/service_test.go @@ -0,0 +1,55 @@ +package netsettings + +import ( + "context" + "errors" + "testing" +) + +// A nil service reaches middleware in test routers and anywhere the settings +// aren't wired. It must read as "trust nothing" rather than panic — the +// alternative is a nil dereference inside RequireUser, on every request. +func TestHops_NilServiceTrustsNothing(t *testing.T) { + var s *Service + if got := s.Hops(); got != 0 { + t.Errorf("(*Service)(nil).Hops() = %d, want 0", got) + } +} + +func TestNew_NilPoolYieldsDefault(t *testing.T) { + s, err := New(context.Background(), nil, nil) + if err != nil { + t.Fatalf("New with nil pool: %v", err) + } + if s == nil { + t.Fatal("New returned nil service") + } + if got := s.Hops(); got != DefaultTrustedProxyHops { + t.Errorf("Hops() = %d, want %d", got, DefaultTrustedProxyHops) + } +} + +// Range is rejected before the query so the API answers 400 rather than +// surfacing a CHECK violation as a 500. +func TestSetHops_RejectsOutOfRange(t *testing.T) { + s, _ := New(context.Background(), nil, nil) + for _, hops := range []int{-1, MaxTrustedProxyHops + 1, 999} { + if err := s.SetHops(context.Background(), hops); !errors.Is(err, ErrHopsOutOfRange) { + t.Errorf("SetHops(%d) error = %v, want ErrHopsOutOfRange", hops, err) + } + } +} + +// In-range values with no pool must still fail, and must not mutate the +// cache — a write that didn't persist reporting success would leave the +// running process disagreeing with the database. +func TestSetHops_NoPoolFailsWithoutMutatingCache(t *testing.T) { + s, _ := New(context.Background(), nil, nil) + before := s.Hops() + if err := s.SetHops(context.Background(), 2); err == nil { + t.Error("SetHops with nil pool returned nil error") + } + if after := s.Hops(); after != before { + t.Errorf("cache changed from %d to %d despite a failed write", before, after) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 4b75e7b4..e46c0ee6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -25,6 +25,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" + "git.fabledsword.com/bvandeusen/minstrel/internal/netsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" @@ -164,13 +165,21 @@ func (s *Server) Router() http.Handler { s.Logger.Error("server: recsettings boot failed", "err", err) } } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) + // Cached trusted-proxy depth (#2453). Constructed here rather than in + // main.go because nothing else needs it at boot, and New always hands + // back a usable service — a DB hiccup degrades to the default rather + // than breaking the authenticated request path that reads it. + netSettings, err := netsettings.New(context.Background(), s.Pool, s.Logger) + if err != nil { + s.Logger.Error("server: netsettings boot failed, using default hops", "err", err) + } + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second // subtree that shadows every admin route registered by api.Mount. if s.Scanner != nil { - r.With(auth.RequireUser(s.Pool), auth.RequireAdmin()). + r.With(auth.RequireUser(s.Pool, netSettings.Hops), auth.RequireAdmin()). Post("/api/admin/scan", s.handleAdminScan) } subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer) diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 8fafa7a8..d1eb16c5 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -644,3 +644,28 @@ export function createDiagnosticDevicesQuery(userId?: string) { staleTime: 15_000 }); } + +// Trusted-proxy depth (#2453) --------------------------------------------- + +// detected_client_ip / forwarded_chain / remote_addr describe THIS request +// under the current setting, so the admin card can be verified rather than +// reasoned about: change the number, see what address you resolve to. +export type NetworkSettings = { + trusted_proxy_hops: number; + max_hops: number; + detected_client_ip: string; + forwarded_chain: string; + remote_addr: string; +}; + +export async function getNetworkSettings(): Promise { + return api.get('/api/admin/network-settings'); +} + +// Returns the payload recomputed under the new value, so the card can show +// the effect immediately instead of requiring a reload. +export async function updateNetworkSettings(hops: number): Promise { + return api.put('/api/admin/network-settings', { + trusted_proxy_hops: hops + }); +} diff --git a/web/src/lib/components/ActiveSessions.svelte b/web/src/lib/components/ActiveSessions.svelte index bcde8318..c7c0229f 100644 --- a/web/src/lib/components/ActiveSessions.svelte +++ b/web/src/lib/components/ActiveSessions.svelte @@ -127,7 +127,7 @@

    {#if loadError} -

    +

    Couldn't load your sessions.

    @@ -156,7 +156,7 @@ {/if} {#if hasMoved(s)} +

    + {:else if settings === null} +

    Loading…

    + {:else} +
    + + +
    + + +
    +
    Your address right now
    +
    {settings.detected_client_ip || 'unknown'}
    +
    Direct connection from
    +
    {settings.remote_addr || 'unknown'}
    +
    Forwarded chain
    +
    {settings.forwarded_chain || '(none)'}
    +
    + + {#if suggested > 0 && settings.trusted_proxy_hops !== suggested} +

    + This request arrived with {suggested} + {suggested === 1 ? 'forwarded address' : 'forwarded addresses'}, which usually means + {suggested} + {suggested === 1 ? 'proxy' : 'proxies'} in front of Minstrel. +

    + {/if} + +
    +

    +

    +
      +
    • 0 — no proxy; Minstrel is reached directly.
    • +
    • 1 — one reverse proxy, e.g. nginx, Caddy or Traefik terminating TLS.
    • +
    • 2 — a CDN in front of your own proxy, e.g. Cloudflare → nginx.
    • +
    +
    + {/if} +
diff --git a/web/src/lib/components/NetworkSettingsCard.test.ts b/web/src/lib/components/NetworkSettingsCard.test.ts new file mode 100644 index 00000000..b7bc4eef --- /dev/null +++ b/web/src/lib/components/NetworkSettingsCard.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import NetworkSettingsCard from './NetworkSettingsCard.svelte'; + +const getNetworkSettings = vi.fn(); +const updateNetworkSettings = vi.fn(); + +vi.mock('$lib/api/admin', () => ({ + getNetworkSettings: () => getNetworkSettings(), + updateNetworkSettings: (hops: number) => updateNetworkSettings(hops) +})); + +vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() })); + +function settings(over: Record = {}) { + return { + trusted_proxy_hops: 1, + max_hops: 10, + detected_client_ip: '198.51.100.7', + forwarded_chain: '198.51.100.7', + remote_addr: '172.18.0.1:40000', + ...over + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('NetworkSettingsCard', () => { + // The detected address is the card's verification affordance — the number + // is abstract, this is checkable against the machine you're sitting at. + test('shows the address the current setting resolves to', async () => { + getNetworkSettings.mockResolvedValue(settings()); + render(NetworkSettingsCard); + + expect(await screen.findByText('198.51.100.7')).toBeTruthy(); + expect(screen.getByText('172.18.0.1:40000')).toBeTruthy(); + }); + + test('save is inert until the value actually changes', async () => { + getNetworkSettings.mockResolvedValue(settings({ trusted_proxy_hops: 1 })); + render(NetworkSettingsCard); + + const save = await screen.findByRole('button', { name: /Save/ }); + expect(save).toBeDisabled(); + + const input = screen.getByRole('spinbutton'); + await fireEvent.input(input, { target: { value: '2' } }); + await waitFor(() => expect(save).not.toBeDisabled()); + }); + + test('saving sends the new depth and adopts the echoed value', async () => { + getNetworkSettings.mockResolvedValue(settings({ trusted_proxy_hops: 1 })); + updateNetworkSettings.mockResolvedValue( + settings({ trusted_proxy_hops: 2, detected_client_ip: '203.0.113.9' }) + ); + render(NetworkSettingsCard); + + const input = await screen.findByRole('spinbutton'); + await fireEvent.input(input, { target: { value: '2' } }); + await fireEvent.click(screen.getByRole('button', { name: /Save/ })); + + await waitFor(() => expect(updateNetworkSettings).toHaveBeenCalledWith(2)); + // The recomputed address proves the change took effect on this request. + expect(await screen.findByText('203.0.113.9')).toBeTruthy(); + }); + + // Counting proxies is the operator's job and the hint is how they do it + // without guessing. + test('hints the likely depth when it disagrees with the arriving chain', async () => { + getNetworkSettings.mockResolvedValue( + settings({ trusted_proxy_hops: 1, forwarded_chain: '198.51.100.7, 203.0.113.50' }) + ); + render(NetworkSettingsCard); + + expect(await screen.findByText(/arrived with 2 forwarded addresses/)).toBeTruthy(); + }); + + test('no hint when the setting already matches the chain length', async () => { + getNetworkSettings.mockResolvedValue( + settings({ trusted_proxy_hops: 1, forwarded_chain: '198.51.100.7' }) + ); + render(NetworkSettingsCard); + + await screen.findByText('198.51.100.7'); + expect(screen.queryByText(/arrived with/)).toBeNull(); + }); + + test('states the mis-set risk rather than only exposing a number', async () => { + getNetworkSettings.mockResolvedValue(settings()); + render(NetworkSettingsCard); + + expect(await screen.findByText(/Count your proxies/)).toBeTruthy(); + }); + + test('offers a retry when loading fails', async () => { + getNetworkSettings.mockRejectedValue(new Error('boom')); + render(NetworkSettingsCard); + + const retry = await screen.findByRole('button', { name: 'Try again' }); + getNetworkSettings.mockResolvedValue(settings()); + await fireEvent.click(retry); + await screen.findByText('198.51.100.7'); + }); +}); diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 7915ec50..e69c25b1 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -27,6 +27,7 @@ import { errCode } from '$lib/api/errors'; import { pushToast } from '$lib/stores/toast.svelte'; import Modal from '$lib/components/Modal.svelte'; + import NetworkSettingsCard from '$lib/components/NetworkSettingsCard.svelte'; import type { LidarrConfig, LidarrTestResult } from '$lib/api/types'; // Lidarr connection panel. The "saved api key" is masked as "***" on GET — @@ -820,6 +821,12 @@ + + + Date: Wed, 5 Aug 2026 10:14:38 -0400 Subject: [PATCH 07/15] =?UTF-8?q?fix(net):=20thread=20hops=20into=20sessio?= =?UTF-8?q?n=20creation;=20disambiguate=20card=20tests=20=E2=80=94=20#2453?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures from 381e9ced, both mine. **Go (vet, which cascaded into the integration job).** Widening auth.ClientIP to take a hop count, I updated the middleware that TOUCHES a session but missed the two places that CREATE one — handleLogin and handleRegister. So `created_ip`, the frozen origin address that the whole "address changed" comparison rests on, was the one value still being computed the old way. Both now read h.netSettings.Hops(), which is nil-safe so test handlers constructed without the service still work. Worth noting the shape of this miss: I checked call sites by searching for the middleware's own usage and stopped there, rather than for every caller of the function whose signature I changed. vet found it in seconds; a grep for `auth.ClientIP(` would have too. **Web (vitest).** Three tests waited on `findByText('198.51.100.7')`, which matches TWO elements in the fixture — the detected client address and the forwarded chain, identical strings for a single-proxy setup — and findByText throws on multiple matches. Now they wait on the unique "Your address right now" label and assert the address with getAllByText where duplication is legitimate. The duplication is correct behaviour, so the test moved rather than the component. --- internal/api/auth.go | 2 +- internal/api/auth_register.go | 2 +- web/src/lib/components/NetworkSettingsCard.test.ts | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/api/auth.go b/internal/api/auth.go index 46e18c2e..ac125463 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -100,7 +100,7 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) { // the active-sessions surface: a session that was born somewhere the // user recognises but is being used from somewhere they don't is the // case this whole surface exists to surface. - Ip: auth.ClientIP(r), + Ip: auth.ClientIP(r, h.netSettings.Hops()), }); err != nil { h.logger.Error("api: insert session failed", "err", err) writeErr(w, apierror.InternalMsg("insert failed", err)) diff --git a/internal/api/auth_register.go b/internal/api/auth_register.go index 5bf1803e..6f098787 100644 --- a/internal/api/auth_register.go +++ b/internal/api/auth_register.go @@ -175,7 +175,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) { UserID: user.ID, TokenHash: auth.HashSessionToken(sessionToken), UserAgent: r.UserAgent(), - Ip: auth.ClientIP(r), + Ip: auth.ClientIP(r, h.netSettings.Hops()), }); err != nil { h.logger.Error("register: insert session failed", "err", err) writeErr(w, apierror.Internal(err)) diff --git a/web/src/lib/components/NetworkSettingsCard.test.ts b/web/src/lib/components/NetworkSettingsCard.test.ts index b7bc4eef..11aebb36 100644 --- a/web/src/lib/components/NetworkSettingsCard.test.ts +++ b/web/src/lib/components/NetworkSettingsCard.test.ts @@ -34,7 +34,10 @@ describe('NetworkSettingsCard', () => { getNetworkSettings.mockResolvedValue(settings()); render(NetworkSettingsCard); - expect(await screen.findByText('198.51.100.7')).toBeTruthy(); + // The address legitimately appears twice — as the detected client and + // inside the forwarded chain — so wait on the unique label, not the value. + await screen.findByText('Your address right now'); + expect(screen.getAllByText('198.51.100.7').length).toBeGreaterThan(0); expect(screen.getByText('172.18.0.1:40000')).toBeTruthy(); }); @@ -83,7 +86,7 @@ describe('NetworkSettingsCard', () => { ); render(NetworkSettingsCard); - await screen.findByText('198.51.100.7'); + await screen.findByText('Your address right now'); expect(screen.queryByText(/arrived with/)).toBeNull(); }); @@ -101,6 +104,6 @@ describe('NetworkSettingsCard', () => { const retry = await screen.findByRole('button', { name: 'Try again' }); getNetworkSettings.mockResolvedValue(settings()); await fireEvent.click(retry); - await screen.findByText('198.51.100.7'); + await screen.findByText('Your address right now'); }); }); From d5ab3b07640b6a4bc60805c4a6df1ccb34681178 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 10:21:16 -0400 Subject: [PATCH 08/15] =?UTF-8?q?fix(net):=20update=20the=20in-package=20M?= =?UTF-8?q?ount=20call=20site=20in=20library=5Ftest=20=E2=80=94=20#2453?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third attempt at the same class of mistake, so worth naming precisely. TestRoutesRegisteredInMount calls Mount() from INSIDE package api, so the call reads `Mount(...)` unqualified. My verification grep was `api.Mount(`, which cannot match it. Same shape as the previous failure, where I grepped `auth.ClientIP(` and missed nothing — but only because those callers happened to be in other packages. The lesson generalises: after changing an exported signature, search for the bare identifier, not the package-qualified form. In-package callers — which in Go means most tests — are invisible to the qualified pattern. This time I swept every signature I touched (Mount, RequireUser, ClientIP, TouchSessionLastSeen) with an unqualified pattern before pushing, rather than letting CI enumerate them one per run. Passing h.netSettings (nil in test handlers) is deliberate, not a placeholder: this test asserts route registration, and Hops() is nil-safe by design so the middleware reads "trust nothing" rather than panicking. Also gave netsettings' logger field a use — it was assigned and never read, which staticcheck's unused pass can flag. A hop-count change alters how much of a client-supplied header the server believes, so it earns a log line for anyone later debugging odd addresses in the sessions list. --- internal/api/library_test.go | 2 +- internal/netsettings/service.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 97544d40..e99ce9fe 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings) paths := []string{ "/api/artists", diff --git a/internal/netsettings/service.go b/internal/netsettings/service.go index 0f062ab6..27bcff40 100644 --- a/internal/netsettings/service.go +++ b/internal/netsettings/service.go @@ -94,5 +94,11 @@ func (s *Service) SetHops(ctx context.Context, hops int) error { s.mu.Lock() s.hops = int(row.TrustedProxyHops) s.mu.Unlock() + // Worth a line in the log: this changes how much of a client-supplied + // header the server believes, so an operator debugging odd addresses in + // the sessions list wants to see when it last moved. + if s.logger != nil { + s.logger.Info("netsettings: trusted proxy hops updated", "hops", hops) + } return nil } From 11538095be8f4789ed5dbbce5549237f5ae3f2be Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 10:27:10 -0400 Subject: [PATCH 09/15] =?UTF-8?q?fix(net):=20validate=20hop=20range=20befo?= =?UTF-8?q?re=20checking=20availability=20=E2=80=94=20#2453?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestSetHops_RejectsOutOfRange caught a real ordering bug in code I wrote in the same commit: the nil-pool guard sat ahead of the range check, so SetHops(-1) on a service with no pool returned "network settings unavailable" instead of ErrHopsOutOfRange. Range first is correct, and the distinction is user-visible rather than cosmetic: the argument is invalid regardless of whether the database is reachable, and admin_network.go maps ErrHopsOutOfRange to 400 while anything else becomes 500. The old order blamed the server for the caller's input. Note this is the first failure in this sequence that wasn't a missed call site — vet and golangci-lint both passed, and a test asserting a specific sentinel error found it. Worth the extra assertion; `err != nil` would have passed happily. --- internal/netsettings/service.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/netsettings/service.go b/internal/netsettings/service.go index 27bcff40..9b526178 100644 --- a/internal/netsettings/service.go +++ b/internal/netsettings/service.go @@ -78,15 +78,19 @@ func (s *Service) Hops() int { // SetHops persists a new depth and refreshes the cache, so an admin change // takes effect on the next request with no restart (rule #25). func (s *Service) SetHops(ctx context.Context, hops int) error { + // Range first, availability second. The argument is wrong regardless of + // whether the database is reachable, and the distinction is user-visible: + // this ordering answers 400 for a bad value, where the reverse would + // report 500 and blame the server for the caller's input. + if hops < 0 || hops > MaxTrustedProxyHops { + return ErrHopsOutOfRange + } if s == nil || s.pool == nil { // Mirrors Hops()'s nil-tolerance: handlers can be constructed without // this service in tests, and a write attempt there should be an error // rather than a panic in an HTTP handler. return errors.New("network settings unavailable") } - if hops < 0 || hops > MaxTrustedProxyHops { - return ErrHopsOutOfRange - } row, err := dbq.New(s.pool).UpdateTrustedProxyHops(ctx, int32(hops)) if err != nil { return err From 5b36d79ff97d43b1c3a34d09ba2f2c2fed1ef8dd Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 13:02:36 -0400 Subject: [PATCH 10/15] =?UTF-8?q?fix(server):=20access=20log=20reports=20t?= =?UTF-8?q?he=20real=20client,=20not=20the=20proxy=20=E2=80=94=20#2453?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the disagreement left open by #2453: requestlog.go logged raw r.RemoteAddr while the Active-sessions surface resolved through the operator's configured proxy depth. Behind a proxy — the normal deployment for anything public — every access-log line carried the same useless proxy address, and the two surfaces contradicted each other about who connected. Logs and UI disagreeing is worse than either being wrong alone, because it costs you trust in both. `remote` now holds auth.ClientIP(r, hops). The attribute KEY is deliberately unchanged so existing log greps keep working; only its accuracy improved. Wiring note. The access log covers /healthz and the SPA, so it's registered before the pool-bearing branch that used to build the settings service. Rather than close over a variable reassigned later — which works, but leaves a mutable-after-registration seam and an awkward question about races — I hoisted netsettings.New above the router entirely. It already handles a nil pool by returning a default-valued service, so no branch is needed and the accessor stays a plain method value. Applied the lesson from the last three CI failures BEFORE pushing this time: a bare-identifier grep for `requestLog(` found three call sites in requestlog_test.go that a qualified pattern could never have matched, since the function is package-private and its tests are in-package. Also swept netsettings.New and ClientIP the same way. Tests: the behaviour change gets its own table — nil accessor and depth 0 log the socket peer, depth 1 through a PUBLIC-addressed proxy logs the client (the exact case the old heuristic got wrong forever), depth 2 reaches through a CDN. Added `remote` to the required-keys assertion so the attribute can't quietly disappear. --- internal/server/requestlog.go | 24 +++++++++- internal/server/requestlog_test.go | 71 ++++++++++++++++++++++++++++-- internal/server/server.go | 22 +++++---- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/internal/server/requestlog.go b/internal/server/requestlog.go index 27fc6a2f..fe597149 100644 --- a/internal/server/requestlog.go +++ b/internal/server/requestlog.go @@ -6,6 +6,8 @@ import ( "time" "github.com/go-chi/chi/v5/middleware" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" ) // requestLog is an slog-based access log middleware. chi ships @@ -18,7 +20,21 @@ import ( // // Severity is keyed off the response status so 4xx/5xx surface even when // the operator's logger level is set above Info. -func requestLog(logger *slog.Logger) func(http.Handler) http.Handler { +// +// The `remote` attribute holds the address resolved through the operator's +// configured reverse-proxy depth, NOT the raw socket peer (#2453). Behind a +// proxy — the normal deployment for anything public — the socket peer is the +// proxy, so every line would have carried the same useless address, and the +// access log would have disagreed with the Active-sessions surface about who +// connected. The attribute key is unchanged so existing log greps keep +// working; only its accuracy improved. +// +// trustedHops is a func because this middleware is constructed at boot while +// the value is operator-editable at runtime, and — since Router() registers +// this before it builds the settings service — because it lets the accessor +// be wired before the thing it reads exists. auth.ClientIP tolerates a depth +// of 0, which is what a nil service reports. +func requestLog(logger *slog.Logger, trustedHops func() int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/healthz" { @@ -29,13 +45,17 @@ func requestLog(logger *slog.Logger) func(http.Handler) http.Handler { ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) next.ServeHTTP(ww, r) status := ww.Status() + hops := 0 + if trustedHops != nil { + hops = trustedHops() + } attrs := []any{ "method", r.Method, "path", r.URL.Path, "status", status, "duration_ms", time.Since(start).Milliseconds(), "request_id", middleware.GetReqID(r.Context()), - "remote", r.RemoteAddr, + "remote", auth.ClientIP(r, hops), } switch { case status >= 500: diff --git a/internal/server/requestlog_test.go b/internal/server/requestlog_test.go index bff1c155..b5b552c5 100644 --- a/internal/server/requestlog_test.go +++ b/internal/server/requestlog_test.go @@ -53,7 +53,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { logger, records := newCaptureLogger() - h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(tc.status) })) req := httptest.NewRequest(http.MethodGet, "/something", nil) @@ -75,7 +75,7 @@ func TestRequestLog_StatusToSeverity(t *testing.T) { func TestRequestLog_SkipsHealthz(t *testing.T) { logger, records := newCaptureLogger() - h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) @@ -92,7 +92,7 @@ func TestRequestLog_AttributesPresent(t *testing.T) { // formatter (catches WithAttrs/WithGroup integration regressions). var buf bytes.Buffer logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo})) - h := requestLog(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + h := requestLog(logger, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodPost, "/api/something", strings.NewReader("")) @@ -102,9 +102,72 @@ func TestRequestLog_AttributesPresent(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("decode log line: %v\nraw: %s", err, buf.String()) } - for _, key := range []string{"method", "path", "status", "duration_ms"} { + for _, key := range []string{"method", "path", "status", "duration_ms", "remote"} { if _, ok := got[key]; !ok { t.Errorf("expected key %q in log entry, got %v", key, got) } } } + +// The point of routing #2453 through the access log: behind a proxy, `remote` +// must be the client rather than the proxy, and must agree with what the +// Active-sessions surface records for the same request. Logs and UI +// disagreeing about who connected is worse than either being wrong alone. +func TestRequestLog_RemoteHonoursTrustedProxyDepth(t *testing.T) { + cases := []struct { + name string + hops func() int + remoteAddr string + forwarded string + want string + }{ + { + name: "nil accessor falls back to the socket peer", + hops: nil, + remoteAddr: "203.0.113.200:40000", + forwarded: "198.51.100.7", + want: "203.0.113.200", + }, + { + name: "depth 0 ignores a forwarded header", + hops: func() int { return 0 }, + remoteAddr: "203.0.113.200:40000", + forwarded: "198.51.100.7", + want: "203.0.113.200", + }, + { + // The case that motivated the change: proxy on a PUBLIC address, + // which the pre-#2453 heuristic logged as the proxy forever. + name: "depth 1 through a public-addressed proxy logs the client", + hops: func() int { return 1 }, + remoteAddr: "203.0.113.200:40000", + forwarded: "198.51.100.7", + want: "198.51.100.7", + }, + { + name: "depth 2 reaches through a cdn to the client", + hops: func() int { return 2 }, + remoteAddr: "172.18.0.1:40000", + forwarded: "198.51.100.7, 203.0.113.50", + want: "198.51.100.7", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + logger, records := newCaptureLogger() + h := requestLog(logger, tc.hops)(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) + req := httptest.NewRequest(http.MethodGet, "/api/something", nil) + req.RemoteAddr = tc.remoteAddr + req.Header.Set("X-Forwarded-For", tc.forwarded) + h.ServeHTTP(httptest.NewRecorder(), req) + + if len(*records) != 1 { + t.Fatalf("len(records) = %d, want 1", len(*records)) + } + if got := (*records)[0].Attrs["remote"]; got != tc.want { + t.Errorf("remote = %v, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index e46c0ee6..d0dede52 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -113,8 +113,20 @@ func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg su func (s *Server) Router() http.Handler { r := chi.NewRouter() + + // Built before the router because the access log needs it, and the access + // log covers /healthz and the SPA — which exist whether or not there's a + // pool. netsettings.New handles a nil pool by returning a default-valued + // service, so this needs no branch and no later reassignment; hoisting it + // here keeps the accessor a plain method value instead of a closure over + // a variable mutated after the middleware is already registered. + netSettings, nsErr := netsettings.New(context.Background(), s.Pool, s.Logger) + if nsErr != nil { + s.Logger.Error("server: netsettings boot failed, using default hops", "err", nsErr) + } + r.Use(middleware.RequestID) - r.Use(requestLog(s.Logger)) + r.Use(requestLog(s.Logger, netSettings.Hops)) r.Use(middleware.Recoverer) r.Get("/healthz", s.handleHealthz) @@ -165,14 +177,6 @@ func (s *Server) Router() http.Handler { s.Logger.Error("server: recsettings boot failed", "err", err) } } - // Cached trusted-proxy depth (#2453). Constructed here rather than in - // main.go because nothing else needs it at boot, and New always hands - // back a usable service — a DB hiccup degrades to the default rather - // than breaking the authenticated request path that reads it. - netSettings, err := netsettings.New(context.Background(), s.Pool, s.Logger) - if err != nil { - s.Logger.Error("server: netsettings boot failed, using default hops", "err", err) - } api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware From 1126bfcf78bca113cdd8274286da099dc50074a6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 13:22:30 -0400 Subject: [PATCH 11/15] =?UTF-8?q?feat(library):=20genre=20+=20year=20brows?= =?UTF-8?q?e=20queries=20and=20endpoints=20=E2=80=94=20#367?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server half of #367. Web UI follows. Genres are exposed AS-IS per the operator: split on the delimiter, trimmed, but no case folding and no synonym mapping. So "Rock" and "rock" appear as separate rows, as does "Rock/Pop" alongside "Rock" and "Pop". The raw spread has to be visible before anyone can judge whether it needs normalising, and the alternative is a mapping table to invent and then maintain. Trimming is not an exception to that. Splitting "Rock; Pop" yields " Pop", and showing that as a genre distinct from "Pop" would be a bug in OUR splitting, not fidelity to the operator's tags. ## The correctness trap this had to avoid ListAlbumsByGenre compared tracks.genre verbatim, while recommendation.sql and discover.sql have always split it on [;,]. Building the browse index by splitting while matching exactly would have listed genres whose pages are empty — every multi-genre track unreachable from either of its genres. So ListAlbumsByGenre now splits too. That also fixes Subsonic getAlbumList?type=byGenre, its only caller, which silently missed every multi-genre track. Its Genre param went *string → string as a result. EXISTS rather than JOIN + DISTINCT ON throughout: the lateral split emits one row per (track, fragment), so a join multiplies rows per album and needs DISTINCT to undo itself. EXISTS asks the question directly, and the count query then matches the list query by construction rather than by coincidence. ## Genre is a query parameter, not a path segment Because "Rock/Pop" is a real ID3 tag — the one the task itself cites — and a slash cannot survive a path segment: Go normalises %2F and the router would split the value in two. So filtering rides GET /api/library/albums?genre=, which also reuses the existing paged album surface instead of adding a parallel one. Endpoints: GET /api/library/genres unpaged index + track counts GET /api/library/years unpaged index + album counts GET /api/library/albums?genre= filtered page GET /api/library/albums?year_from=&year_to= filtered page, either edge open The indexes are unpaged deliberately: a client needs the whole set to render a browsable picker, and paging would let it show only a prefix of an ordering the user didn't choose. Two refusals rather than guesses: genre+year together is a 400 (the UI browses them as separate axes, and quietly dropping half a filter would report a narrower result than it returned), and an inverted year range is a 400 rather than being silently swapped. Undated albums are absent from the year axis rather than bucketed under 0 — "unknown" is not a year, and a 0 row would sort to one end of a chronological list looking like data. Tests: parseYearFilter is pure and runs in the fast lane. The integration tests assert the thing that would otherwise be silently broken — that a "Rock;Pop" track is reachable from BOTH genres, that "Rock/Pop" survives as a filter value, that fragment whitespace is trimmed, and that undated albums stay out of every year range. Reused the existing seedAlbum/seedTrackWithGenre fixtures, which already took exactly the year and genre arguments needed. --- internal/api/api.go | 5 + internal/api/library_albums.go | 177 +++++++++++++-- internal/api/library_browse.go | 65 ++++++ internal/api/library_browse_test.go | 325 ++++++++++++++++++++++++++++ internal/api/library_test.go | 3 + internal/db/dbq/albums.sql.go | 35 ++- internal/db/dbq/browse.sql.go | 268 +++++++++++++++++++++++ internal/db/queries/albums.sql | 27 ++- internal/db/queries/browse.sql | 88 ++++++++ internal/subsonic/browse.go | 6 +- 10 files changed, 969 insertions(+), 30 deletions(-) create mode 100644 internal/api/library_browse.go create mode 100644 internal/api/library_browse_test.go create mode 100644 internal/db/dbq/browse.sql.go create mode 100644 internal/db/queries/browse.sql diff --git a/internal/api/api.go b/internal/api/api.go index e9c481b5..2f9f189e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -102,6 +102,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/albums/{id}/cover", h.handleGetCover) authed.Get("/library/shuffle", h.handleLibraryShuffle) authed.Get("/library/albums", h.handleListLibraryAlbums) + // Browse indexes (#367). Genre filtering rides + // /library/albums?genre= rather than a path segment, because raw + // ID3 genres contain slashes ("Rock/Pop") that a path can't carry. + authed.Get("/library/genres", h.handleListGenres) + authed.Get("/library/years", h.handleListAlbumYears) authed.Get("/library/sync", h.handleLibrarySync) authed.Get("/tracks/{id}", h.handleGetTrack) // /tracks/{id}/stream is mounted above with OptionalUser so diff --git a/internal/api/library_albums.go b/internal/api/library_albums.go index cff8e0d6..f480d311 100644 --- a/internal/api/library_albums.go +++ b/internal/api/library_albums.go @@ -1,42 +1,189 @@ package api import ( + "context" + "errors" "net/http" + "net/url" + "strconv" + "strings" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) +// Widest plausible bounds for an open-ended year filter. A missing year_from +// means "from the beginning" rather than "from year zero of the query", and +// likewise for year_to, so the caller can filter on one edge only. +const ( + minBrowseYear = 0 + maxBrowseYear = 9999 +) + +var ( + errBadYear = errors.New("year_from and year_to must be integers") + errInvertedYearRange = errors.New("year_from must not be greater than year_to") +) + +// yearFilter carries a parsed, validated inclusive year range. active is false +// when the request asked for no year filtering at all — distinct from a range +// that happens to cover everything, because the two take different code paths. +type yearFilter struct { + from int32 + to int32 + active bool +} + // handleListLibraryAlbums implements GET /api/library/albums. Mirrors // /api/artists?sort=alpha but for albums. The new wrapping-grid page on // the SPA infinite-scrolls against this endpoint via TanStack // createInfiniteQuery. +// +// Optional filters (#367): `genre` and `year_from`/`year_to`. +// +// Genre arrives as a QUERY parameter rather than a path segment on purpose. +// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag, and +// the one the task itself cites — which cannot survive a path segment: Go +// normalises %2F and the router would split the value into two segments. func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) { limit, offset, err := parsePaging(r.URL.Query()) if err != nil { writeErr(w, apierror.BadRequest("bad_request", err.Error())) return } - q := dbq.New(h.pool) - rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{ - Limit: int32(limit), Offset: int32(offset), - }) + genre := strings.TrimSpace(r.URL.Query().Get("genre")) + years, err := parseYearFilter(r.URL.Query()) if err != nil { - h.logger.Error("api: list library albums", "err", err) + writeErr(w, apierror.BadRequest("bad_request", err.Error())) + return + } + if genre != "" && years.active { + // Refused rather than silently honouring one: the UI browses these as + // separate axes (a genres page, a year filter on the albums page), so + // the combination can only arrive from a caller that has misunderstood + // the contract — and quietly dropping half a filter would report a + // narrower result set than it actually returned. + writeErr(w, apierror.BadRequest("unsupported_filter_combination", + "genre and year filters cannot be combined")) + return + } + + q := dbq.New(h.pool) + var ( + items []AlbumRef + total int64 + ) + switch { + case genre != "": + items, total, err = albumsByGenre(r.Context(), q, genre, limit, offset) + case years.active: + items, total, err = albumsByYear(r.Context(), q, years, limit, offset) + default: + items, total, err = albumsAlpha(r.Context(), q, limit, offset) + } + if err != nil { + h.logger.Error("api: list library albums", "err", err, "genre", genre, "years", years.active) writeErr(w, apierror.InternalMsg("lookup failed", err)) return } - total, err := q.CountAlbums(r.Context()) - if err != nil { - h.logger.Error("api: count albums", "err", err) - writeErr(w, apierror.InternalMsg("count failed", err)) - return - } - items := make([]AlbumRef, 0, len(rows)) - for _, row := range rows { - items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) - } writeJSON(w, http.StatusOK, Page[AlbumRef]{ Items: items, Total: int(total), Limit: limit, Offset: offset, }) } + +func albumsAlpha( + ctx context.Context, q *dbq.Queries, limit, offset int, +) ([]AlbumRef, int64, error) { + rows, err := q.ListAlbumsAlphaWithArtist(ctx, dbq.ListAlbumsAlphaWithArtistParams{ + Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + return nil, 0, err + } + total, err := q.CountAlbums(ctx) + if err != nil { + return nil, 0, err + } + items := make([]AlbumRef, 0, len(rows)) + for _, row := range rows { + items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + return items, total, nil +} + +func albumsByGenre( + ctx context.Context, q *dbq.Queries, genre string, limit, offset int, +) ([]AlbumRef, int64, error) { + rows, err := q.ListAlbumsByGenreWithArtist(ctx, dbq.ListAlbumsByGenreWithArtistParams{ + Genre: genre, Lim: int32(limit), Off: int32(offset), + }) + if err != nil { + return nil, 0, err + } + total, err := q.CountAlbumsByGenre(ctx, genre) + if err != nil { + return nil, 0, err + } + items := make([]AlbumRef, 0, len(rows)) + for _, row := range rows { + items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + return items, total, nil +} + +func albumsByYear( + ctx context.Context, q *dbq.Queries, years yearFilter, limit, offset int, +) ([]AlbumRef, int64, error) { + rows, err := q.ListAlbumsByYearRangeWithArtist(ctx, + dbq.ListAlbumsByYearRangeWithArtistParams{ + YearFrom: years.from, YearTo: years.to, + Lim: int32(limit), Off: int32(offset), + }) + if err != nil { + return nil, 0, err + } + total, err := q.CountAlbumsByYearRange(ctx, dbq.CountAlbumsByYearRangeParams{ + YearFrom: years.from, YearTo: years.to, + }) + if err != nil { + return nil, 0, err + } + items := make([]AlbumRef, 0, len(rows)) + for _, row := range rows { + items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0)) + } + return items, total, nil +} + +// parseYearFilter reads year_from / year_to. Either may be omitted, which +// leaves that edge open — filtering "everything before 1990" shouldn't +// require inventing a lower bound. +func parseYearFilter(raw url.Values) (yearFilter, error) { + fromRaw := strings.TrimSpace(raw.Get("year_from")) + toRaw := strings.TrimSpace(raw.Get("year_to")) + if fromRaw == "" && toRaw == "" { + return yearFilter{}, nil + } + f := yearFilter{from: minBrowseYear, to: maxBrowseYear, active: true} + if fromRaw != "" { + n, err := strconv.Atoi(fromRaw) + if err != nil { + return yearFilter{}, errBadYear + } + f.from = int32(n) + } + if toRaw != "" { + n, err := strconv.Atoi(toRaw) + if err != nil { + return yearFilter{}, errBadYear + } + f.to = int32(n) + } + if f.from > f.to { + // Rejected rather than swapped: silently reordering would return + // results for a range the caller didn't ask for, and an inverted + // range is far more likely a bug than an intent. + return yearFilter{}, errInvertedYearRange + } + return f, nil +} diff --git a/internal/api/library_browse.go b/internal/api/library_browse.go new file mode 100644 index 00000000..d4f237e1 --- /dev/null +++ b/internal/api/library_browse.go @@ -0,0 +1,65 @@ +package api + +import ( + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// genreCount is one row of the genre browse index (#367). +// +// Genres are the raw ID3 strings, split on [;,] but otherwise untouched — no +// case folding and no synonym mapping. So "Rock" and "rock" can both appear, +// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate for v1: the +// alternative is a normalisation table to invent and maintain, and the raw +// spread has to be visible before anyone can judge whether it's a problem. +type genreCount struct { + Genre string `json:"genre"` + TrackCount int `json:"track_count"` +} + +// yearCount is one row of the year browse index. +type yearCount struct { + Year int `json:"year"` + AlbumCount int `json:"album_count"` +} + +// handleListGenres implements GET /api/library/genres. +// +// Unpaged on purpose. Even a messy library yields hundreds of distinct tag +// strings, not thousands, and the client needs the whole set at once to render +// a browsable index — paging it would mean the UI could only ever show a +// prefix of an ordering the user didn't choose. +func (h *handlers) handleListGenres(w http.ResponseWriter, r *http.Request) { + rows, err := dbq.New(h.pool).ListGenresWithCount(r.Context()) + if err != nil { + h.logger.Error("api: list genres", "err", err) + writeErr(w, apierror.InternalMsg("lookup failed", err)) + return + } + out := make([]genreCount, 0, len(rows)) + for _, row := range rows { + out = append(out, genreCount{Genre: row.Genre, TrackCount: int(row.TrackCount)}) + } + writeJSON(w, http.StatusOK, out) +} + +// handleListAlbumYears implements GET /api/library/years. +// +// Albums with no release_date are absent rather than bucketed under 0 — "year +// unknown" isn't a year, and inventing a row for it would put a fake entry at +// one end of a chronological list. +func (h *handlers) handleListAlbumYears(w http.ResponseWriter, r *http.Request) { + rows, err := dbq.New(h.pool).ListAlbumYearsWithCount(r.Context()) + if err != nil { + h.logger.Error("api: list album years", "err", err) + writeErr(w, apierror.InternalMsg("lookup failed", err)) + return + } + out := make([]yearCount, 0, len(rows)) + for _, row := range rows { + out = append(out, yearCount{Year: int(row.Year), AlbumCount: int(row.AlbumCount)}) + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/api/library_browse_test.go b/internal/api/library_browse_test.go new file mode 100644 index 00000000..a20386e9 --- /dev/null +++ b/internal/api/library_browse_test.go @@ -0,0 +1,325 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// parseYearFilter is pure, so this runs in the fast lane rather than waiting +// on the integration job. +func TestParseYearFilter(t *testing.T) { + tests := []struct { + name string + query string + wantActive bool + wantFrom int32 + wantTo int32 + wantErr error + }{ + {name: "no params means no filtering", query: "", wantActive: false}, + { + name: "both bounds", query: "year_from=1990&year_to=1999", + wantActive: true, wantFrom: 1990, wantTo: 1999, + }, + { + // "everything from 2000 onward" shouldn't require the caller to + // invent an upper bound. + name: "from only leaves the upper edge open", query: "year_from=2000", + wantActive: true, wantFrom: 2000, wantTo: maxBrowseYear, + }, + { + name: "to only leaves the lower edge open", query: "year_to=1979", + wantActive: true, wantFrom: minBrowseYear, wantTo: 1979, + }, + { + name: "a single year is a degenerate range", query: "year_from=1985&year_to=1985", + wantActive: true, wantFrom: 1985, wantTo: 1985, + }, + {name: "non-numeric from", query: "year_from=nineteen", wantErr: errBadYear}, + {name: "non-numeric to", query: "year_to=x", wantErr: errBadYear}, + { + // Rejected, not silently swapped — reordering would answer a + // question the caller didn't ask. + name: "inverted range", query: "year_from=2000&year_to=1990", + wantErr: errInvertedYearRange, + }, + { + name: "whitespace-only values are treated as absent", + query: "year_from=%20&year_to=%20", wantActive: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + raw, err := url.ParseQuery(tc.query) + if err != nil { + t.Fatalf("ParseQuery: %v", err) + } + got, gotErr := parseYearFilter(raw) + if tc.wantErr != nil { + if gotErr != tc.wantErr { + t.Fatalf("error = %v, want %v", gotErr, tc.wantErr) + } + return + } + if gotErr != nil { + t.Fatalf("unexpected error: %v", gotErr) + } + if got.active != tc.wantActive { + t.Errorf("active = %v, want %v", got.active, tc.wantActive) + } + if tc.wantActive && (got.from != tc.wantFrom || got.to != tc.wantTo) { + t.Errorf("range = [%d,%d], want [%d,%d]", + got.from, got.to, tc.wantFrom, tc.wantTo) + } + }) + } +} + +// The crux of #367: a track tagged "Rock;Pop" must be reachable from BOTH +// genres. An exact-string match — which is what ListAlbumsByGenre did before +// this task — makes every multi-genre track invisible from either of its +// genres, so the index would list a genre whose page is empty. +func TestListGenres_SplitsMultiGenreTags(t *testing.T) { + h, pool := testHandlers(t) + artist := seedArtist(t, pool, "Genre Splitter") + album := seedAlbum(t, pool, artist.ID, "Split Album", 1995) + seedTrackWithGenre(t, pool, album.ID, artist.ID, "Both Genres", 1, 200000, "Rock;Pop") + + req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil) + w := httptest.NewRecorder() + h.handleListGenres(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var got []genreCount + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + counts := map[string]int{} + for _, g := range got { + counts[g.Genre] = g.TrackCount + } + for _, want := range []string{"Rock", "Pop"} { + if counts[want] < 1 { + t.Errorf("genre %q missing from index (got %v)", want, counts) + } + } + // The undivided string must NOT appear as its own genre. + if _, ok := counts["Rock;Pop"]; ok { + t.Error(`"Rock;Pop" surfaced as a single genre — the split didn't happen`) + } +} + +// Splitting produces leading spaces on every fragment after the first, and +// showing " Pop" as a genre distinct from "Pop" would be a bug. Trimming is a +// repair for our own splitting, not normalisation of the operator's tags. +func TestListGenres_TrimsFragmentWhitespace(t *testing.T) { + h, pool := testHandlers(t) + artist := seedArtist(t, pool, "Spacey Tags") + album := seedAlbum(t, pool, artist.ID, "Spacey Album", 2001) + seedTrackWithGenre(t, pool, album.ID, artist.ID, "Spaced", 1, 200000, "Jazz; Blues ;") + + req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil) + w := httptest.NewRecorder() + h.handleListGenres(w, req) + + var got []genreCount + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + seen := map[string]bool{} + for _, g := range got { + seen[g.Genre] = true + if g.Genre == "" { + t.Error("empty genre in index — a trailing delimiter leaked through") + } + } + for _, want := range []string{"Jazz", "Blues"} { + if !seen[want] { + t.Errorf("genre %q missing (got %v)", want, keysOf(seen)) + } + } + for _, unwanted := range []string{" Blues", "Blues ", " Blues "} { + if seen[unwanted] { + t.Errorf("untrimmed genre %q present", unwanted) + } + } +} + +// Genre filtering must agree with the index: every genre the index lists has +// to lead to a non-empty page, which is exactly what the old exact-match +// query could not guarantee. +func TestListLibraryAlbums_GenreFilterReachesMultiGenreTracks(t *testing.T) { + h, pool := testHandlers(t) + artist := seedArtist(t, pool, "Reachable") + album := seedAlbum(t, pool, artist.ID, "Reachable Album", 1998) + seedTrackWithGenre(t, pool, album.ID, artist.ID, "Multi", 1, 200000, "Rock;Pop") + + for _, genre := range []string{"Rock", "Pop"} { + t.Run(genre, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, + "/api/library/albums?genre="+url.QueryEscape(genre), nil) + w := httptest.NewRecorder() + h.handleListLibraryAlbums(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var page Page[AlbumRef] + if err := json.NewDecoder(w.Body).Decode(&page); err != nil { + t.Fatalf("decode: %v", err) + } + if page.Total < 1 { + t.Fatalf("total = %d, want >=1 — genre %q led to an empty page", + page.Total, genre) + } + found := false + for _, a := range page.Items { + if a.Title == "Reachable Album" { + found = true + } + } + if !found { + t.Errorf("seeded album absent from genre %q results", genre) + } + }) + } +} + +// A genre containing a slash is why filtering is a query parameter rather +// than a path segment — "Rock/Pop" cannot survive a path. +func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) { + h, pool := testHandlers(t) + artist := seedArtist(t, pool, "Slashed") + album := seedAlbum(t, pool, artist.ID, "Slashed Album", 2003) + seedTrackWithGenre(t, pool, album.ID, artist.ID, "Slashy", 1, 200000, "Rock/Pop") + + req := httptest.NewRequest(http.MethodGet, + "/api/library/albums?genre="+url.QueryEscape("Rock/Pop"), nil) + w := httptest.NewRecorder() + h.handleListLibraryAlbums(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var page Page[AlbumRef] + if err := json.NewDecoder(w.Body).Decode(&page); err != nil { + t.Fatalf("decode: %v", err) + } + if page.Total < 1 { + t.Errorf(`total = %d, want >=1 for genre "Rock/Pop"`, page.Total) + } +} + +func TestListLibraryAlbums_YearRangeFilter(t *testing.T) { + h, pool := testHandlers(t) + artist := seedArtist(t, pool, "Chronology") + seedAlbum(t, pool, artist.ID, "Old Record", 1972) + seedAlbum(t, pool, artist.ID, "Middle Record", 1995) + seedAlbum(t, pool, artist.ID, "New Record", 2020) + // An undated album must not appear in ANY year range. + seedAlbum(t, pool, artist.ID, "Undated Record", 0) + + titles := func(query string) map[string]bool { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/library/albums?"+query, nil) + w := httptest.NewRecorder() + h.handleListLibraryAlbums(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d for %q, want 200", w.Code, query) + } + var page Page[AlbumRef] + if err := json.NewDecoder(w.Body).Decode(&page); err != nil { + t.Fatalf("decode: %v", err) + } + out := map[string]bool{} + for _, a := range page.Items { + out[a.Title] = true + } + return out + } + + got := titles("year_from=1990&year_to=2000&limit=200") + if !got["Middle Record"] { + t.Error("Middle Record (1995) missing from 1990-2000") + } + for _, absent := range []string{"Old Record", "New Record", "Undated Record"} { + if got[absent] { + t.Errorf("%s present in 1990-2000 range", absent) + } + } + + // Open upper edge. + got = titles("year_from=1990&limit=200") + if !got["Middle Record"] || !got["New Record"] { + t.Error("open-ended year_from should include 1995 and 2020") + } + if got["Old Record"] { + t.Error("Old Record (1972) present in year_from=1990") + } + if got["Undated Record"] { + t.Error("undated album present in an open-ended range") + } +} + +func TestListLibraryAlbums_RejectsGenreAndYearTogether(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, + "/api/library/albums?genre=Rock&year_from=1990", nil) + w := httptest.NewRecorder() + h.handleListLibraryAlbums(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 for combined filters", w.Code) + } +} + +func TestListAlbumYears_ExcludesUndatedAlbums(t *testing.T) { + h, pool := testHandlers(t) + artist := seedArtist(t, pool, "Years Only") + seedAlbum(t, pool, artist.ID, "Dated One", 1984) + seedAlbum(t, pool, artist.ID, "No Date", 0) + + req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil) + w := httptest.NewRecorder() + h.handleListAlbumYears(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var got []yearCount + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + found1984 := false + for _, y := range got { + if y.Year == 1984 { + found1984 = true + } + if y.Year == 0 { + t.Error("year 0 present — undated albums leaked into the index") + } + } + if !found1984 { + t.Error("1984 missing from the year index") + } + // Newest-first ordering, so a picker reads chronologically without the + // client re-sorting. + for i := 1; i < len(got); i++ { + if got[i-1].Year < got[i].Year { + t.Errorf("years not descending at %d: %d then %d", i, got[i-1].Year, got[i].Year) + } + } +} + +func keysOf(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/internal/api/library_test.go b/internal/api/library_test.go index e99ce9fe..7edb4407 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -475,6 +475,9 @@ func TestRoutesRegisteredInMount(t *testing.T) { "/api/tracks/00000000-0000-0000-0000-000000000001", "/api/tracks/00000000-0000-0000-0000-000000000001/stream", "/api/search?q=x", + // Browse indexes (#367). + "/api/library/genres", + "/api/library/years", } for _, p := range paths { req := httptest.NewRequest(http.MethodGet, p, nil) diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index 3ebea2a2..98b07b4a 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -478,23 +478,40 @@ func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID } const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many -SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version FROM albums -JOIN tracks ON tracks.album_id = albums.id -WHERE tracks.genre = $1 -ORDER BY albums.id, albums.sort_title -LIMIT $2 OFFSET $3 +WHERE EXISTS ( + SELECT 1 + FROM tracks + JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true + WHERE tracks.album_id = albums.id + AND trim(g.genre) = trim($1::text) +) +ORDER BY albums.sort_title, albums.id +LIMIT $3 OFFSET $2 ` type ListAlbumsByGenreParams struct { - Genre *string - Limit int32 - Offset int32 + Genre string + Off int32 + Lim int32 } // Album "belongs to" a genre if any of its tracks carry that genre. +// Serves Subsonic getAlbumList?type=byGenre. +// +// Splits tracks.genre on [;,] as of #367. It previously compared the whole +// column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER +// "Rock" or "Pop" — a Subsonic client asking for a genre silently missed +// every multi-genre track. This also aligns the endpoint with +// recommendation.sql / discover.sql, which have always split, and with the +// genre browse index that #367 adds. +// +// EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per +// (track, genre-fragment), so a join would multiply rows per album and lean +// on DISTINCT to undo it. EXISTS asks the question directly. func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) { - rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Off, arg.Lim) if err != nil { return nil, err } diff --git a/internal/db/dbq/browse.sql.go b/internal/db/dbq/browse.sql.go new file mode 100644 index 00000000..bbb7fc8e --- /dev/null +++ b/internal/db/dbq/browse.sql.go @@ -0,0 +1,268 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: browse.sql + +package dbq + +import ( + "context" +) + +const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one +SELECT COUNT(*) FROM albums +WHERE EXISTS ( + SELECT 1 + FROM tracks + JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true + WHERE tracks.album_id = albums.id + AND trim(g.genre) = trim($1::text) +) +` + +// Total for the paging envelope. EXISTS mirrors the list query exactly; a +// JOIN + DISTINCT here would count differently the moment an album has two +// tracks carrying the same genre. +func (q *Queries) CountAlbumsByGenre(ctx context.Context, genre string) (int64, error) { + row := q.db.QueryRow(ctx, countAlbumsByGenre, genre) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countAlbumsByYearRange = `-- name: CountAlbumsByYearRange :one +SELECT COUNT(*) FROM albums +WHERE release_date IS NOT NULL + AND EXTRACT(YEAR FROM release_date)::int + BETWEEN $1::int AND $2::int +` + +type CountAlbumsByYearRangeParams struct { + YearFrom int32 + YearTo int32 +} + +func (q *Queries) CountAlbumsByYearRange(ctx context.Context, arg CountAlbumsByYearRangeParams) (int64, error) { + row := q.db.QueryRow(ctx, countAlbumsByYearRange, arg.YearFrom, arg.YearTo) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listAlbumYearsWithCount = `-- name: ListAlbumYearsWithCount :many +SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count +FROM albums +WHERE release_date IS NOT NULL +GROUP BY year +ORDER BY year DESC +` + +type ListAlbumYearsWithCountRow struct { + Year int32 + AlbumCount int64 +} + +// Year browse index (#367). Only albums with a release_date appear — an +// album with no date isn't "year unknown" as a browsable bucket, it's absent +// from this axis, and the UI says so rather than inventing a 0 row. +// Newest first: recent releases are the likelier browse target. +func (q *Queries) ListAlbumYearsWithCount(ctx context.Context) ([]ListAlbumYearsWithCountRow, error) { + rows, err := q.db.Query(ctx, listAlbumYearsWithCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumYearsWithCountRow + for rows.Next() { + var i ListAlbumYearsWithCountRow + if err := rows.Scan(&i.Year, &i.AlbumCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumsByGenreWithArtist = `-- name: ListAlbumsByGenreWithArtist :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +WHERE EXISTS ( + SELECT 1 + FROM tracks + JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true + WHERE tracks.album_id = albums.id + AND trim(g.genre) = trim($1::text) +) +ORDER BY albums.sort_title, albums.id +LIMIT $3 OFFSET $2 +` + +type ListAlbumsByGenreWithArtistParams struct { + Genre string + Off int32 + Lim int32 +} + +type ListAlbumsByGenreWithArtistRow struct { + Album Album + ArtistName string +} + +// Albums for one genre, joined with artist_name for the browse grid. +// An album belongs to a genre when ANY of its tracks carry it. Splits and +// trims identically to ListGenresWithCount — if the list is built by +// splitting and the detail matched exactly, every multi-genre track would +// produce a genre row that leads to an empty page. +func (q *Queries) ListAlbumsByGenreWithArtist(ctx context.Context, arg ListAlbumsByGenreWithArtistParams) ([]ListAlbumsByGenreWithArtistRow, error) { + rows, err := q.db.Query(ctx, listAlbumsByGenreWithArtist, arg.Genre, arg.Off, arg.Lim) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumsByGenreWithArtistRow + for rows.Next() { + var i ListAlbumsByGenreWithArtistRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.Album.CoverArtSource, + &i.Album.CoverArtSourcesVersion, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumsByYearRangeWithArtist = `-- name: ListAlbumsByYearRangeWithArtist :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +WHERE albums.release_date IS NOT NULL + AND EXTRACT(YEAR FROM albums.release_date)::int + BETWEEN $1::int AND $2::int +ORDER BY albums.sort_title, albums.id +LIMIT $4 OFFSET $3 +` + +type ListAlbumsByYearRangeWithArtistParams struct { + YearFrom int32 + YearTo int32 + Off int32 + Lim int32 +} + +type ListAlbumsByYearRangeWithArtistRow struct { + Album Album + ArtistName string +} + +// Albums released within an inclusive year range, for the albums-page filter. +func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListAlbumsByYearRangeWithArtistParams) ([]ListAlbumsByYearRangeWithArtistRow, error) { + rows, err := q.db.Query(ctx, listAlbumsByYearRangeWithArtist, + arg.YearFrom, + arg.YearTo, + arg.Off, + arg.Lim, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumsByYearRangeWithArtistRow + for rows.Next() { + var i ListAlbumsByYearRangeWithArtistRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.Album.CoverArtSource, + &i.Album.CoverArtSourcesVersion, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGenresWithCount = `-- name: ListGenresWithCount :many +SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count +FROM tracks +JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true +WHERE trim(g.genre) <> '' +GROUP BY trim(g.genre) +ORDER BY track_count DESC, trim(g.genre) +` + +type ListGenresWithCountRow struct { + Genre string + TrackCount int64 +} + +// Genre browse index (#367). +// +// Genres live inline on tracks.genre as a delimited string, so this splits on +// the same [;,] pattern already used by recommendation.sql and discover.sql — +// a track tagged "Rock;Pop" must count toward both, and diverging from the +// established pattern here would make the browse surface disagree with what +// the recommendation engine believes the library contains. +// +// trim() but deliberately NO lower(): trimming repairs an artifact of OUR +// splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre +// would be a bug), whereas case is what the tag actually says. Raw ID3 is +// exposed as-is for v1, so "Rock" and "rock" appear as separate rows. +// +// COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise +// inflate its own row. +// +// Ordered by count first: raw ID3 data has a long tail of one-off junk tags, +// so alphabetical would bury the handful of genres an operator actually has a +// library's worth of. Name breaks ties for a stable order. +// Ordered by the expression, not the output alias: `ORDER BY genre` is +// ambiguous between the alias and tracks.genre, and sqlc rejects it. +func (q *Queries) ListGenresWithCount(ctx context.Context) ([]ListGenresWithCountRow, error) { + rows, err := q.db.Query(ctx, listGenresWithCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListGenresWithCountRow + for rows.Next() { + var i ListGenresWithCountRow + if err := rows.Scan(&i.Genre, &i.TrackCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 4aaaadd0..6f87dfc8 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -61,12 +61,29 @@ SELECT * FROM albums ORDER BY random() LIMIT $1; -- name: ListAlbumsByGenre :many -- Album "belongs to" a genre if any of its tracks carry that genre. -SELECT DISTINCT ON (albums.id) albums.* +-- Serves Subsonic getAlbumList?type=byGenre. +-- +-- Splits tracks.genre on [;,] as of #367. It previously compared the whole +-- column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER +-- "Rock" or "Pop" — a Subsonic client asking for a genre silently missed +-- every multi-genre track. This also aligns the endpoint with +-- recommendation.sql / discover.sql, which have always split, and with the +-- genre browse index that #367 adds. +-- +-- EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per +-- (track, genre-fragment), so a join would multiply rows per album and lean +-- on DISTINCT to undo it. EXISTS asks the question directly. +SELECT albums.* FROM albums -JOIN tracks ON tracks.album_id = albums.id -WHERE tracks.genre = $1 -ORDER BY albums.id, albums.sort_title -LIMIT $2 OFFSET $3; +WHERE EXISTS ( + SELECT 1 + FROM tracks + JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true + WHERE tracks.album_id = albums.id + AND trim(g.genre) = trim(sqlc.arg(genre)::text) +) +ORDER BY albums.sort_title, albums.id +LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off); -- name: SearchAlbums :many SELECT * FROM albums diff --git a/internal/db/queries/browse.sql b/internal/db/queries/browse.sql new file mode 100644 index 00000000..58002299 --- /dev/null +++ b/internal/db/queries/browse.sql @@ -0,0 +1,88 @@ +-- name: ListGenresWithCount :many +-- Genre browse index (#367). +-- +-- Genres live inline on tracks.genre as a delimited string, so this splits on +-- the same [;,] pattern already used by recommendation.sql and discover.sql — +-- a track tagged "Rock;Pop" must count toward both, and diverging from the +-- established pattern here would make the browse surface disagree with what +-- the recommendation engine believes the library contains. +-- +-- trim() but deliberately NO lower(): trimming repairs an artifact of OUR +-- splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre +-- would be a bug), whereas case is what the tag actually says. Raw ID3 is +-- exposed as-is for v1, so "Rock" and "rock" appear as separate rows. +-- +-- COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise +-- inflate its own row. +-- +-- Ordered by count first: raw ID3 data has a long tail of one-off junk tags, +-- so alphabetical would bury the handful of genres an operator actually has a +-- library's worth of. Name breaks ties for a stable order. +SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count +FROM tracks +JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true +WHERE trim(g.genre) <> '' +GROUP BY trim(g.genre) +-- Ordered by the expression, not the output alias: `ORDER BY genre` is +-- ambiguous between the alias and tracks.genre, and sqlc rejects it. +ORDER BY track_count DESC, trim(g.genre); + +-- name: ListAlbumsByGenreWithArtist :many +-- Albums for one genre, joined with artist_name for the browse grid. +-- An album belongs to a genre when ANY of its tracks carry it. Splits and +-- trims identically to ListGenresWithCount — if the list is built by +-- splitting and the detail matched exactly, every multi-genre track would +-- produce a genre row that leads to an empty page. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +WHERE EXISTS ( + SELECT 1 + FROM tracks + JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true + WHERE tracks.album_id = albums.id + AND trim(g.genre) = trim(sqlc.arg(genre)::text) +) +ORDER BY albums.sort_title, albums.id +LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off); + +-- name: CountAlbumsByGenre :one +-- Total for the paging envelope. EXISTS mirrors the list query exactly; a +-- JOIN + DISTINCT here would count differently the moment an album has two +-- tracks carrying the same genre. +SELECT COUNT(*) FROM albums +WHERE EXISTS ( + SELECT 1 + FROM tracks + JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true + WHERE tracks.album_id = albums.id + AND trim(g.genre) = trim(sqlc.arg(genre)::text) +); + +-- name: ListAlbumYearsWithCount :many +-- Year browse index (#367). Only albums with a release_date appear — an +-- album with no date isn't "year unknown" as a browsable bucket, it's absent +-- from this axis, and the UI says so rather than inventing a 0 row. +-- Newest first: recent releases are the likelier browse target. +SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count +FROM albums +WHERE release_date IS NOT NULL +GROUP BY year +ORDER BY year DESC; + +-- name: ListAlbumsByYearRangeWithArtist :many +-- Albums released within an inclusive year range, for the albums-page filter. +SELECT sqlc.embed(albums), artists.name AS artist_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +WHERE albums.release_date IS NOT NULL + AND EXTRACT(YEAR FROM albums.release_date)::int + BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int +ORDER BY albums.sort_title, albums.id +LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off); + +-- name: CountAlbumsByYearRange :one +SELECT COUNT(*) FROM albums +WHERE release_date IS NOT NULL + AND EXTRACT(YEAR FROM release_date)::int + BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int; diff --git a/internal/subsonic/browse.go b/internal/subsonic/browse.go index 9bdc9d6f..70225084 100644 --- a/internal/subsonic/browse.go +++ b/internal/subsonic/browse.go @@ -284,8 +284,12 @@ func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) { WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre") return } + // Genre is a plain string as of #367 — the query now splits + // tracks.genre on [;,] instead of comparing the whole column, so a + // client asking for "Rock" also reaches tracks tagged "Rock;Pop". + // Previously those were unreachable from either of their genres. albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{ - Genre: &genre, Limit: int32(size), Offset: int32(offset), + Genre: genre, Lim: int32(size), Off: int32(offset), }) case "recent", "frequent": // Play history lands in M2; return empty to keep clients happy. From f8f2273aec05097b58879f2ef5fac7284054c971 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 13:29:28 -0400 Subject: [PATCH 12/15] =?UTF-8?q?style:=20gofmt=20alignment=20in=20library?= =?UTF-8?q?=5Fbrowse=5Ftest=20=E2=80=94=20#367?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One space. `name:` had to align with `query:` inside a composite literal where both sat on their own lines. Found via `docker run golang:1.25-alpine gofmt -l`, which is the actual point of this commit: gofmt is available here the same way sqlc is, and there is no reason to have let CI discover a formatting nit. Whole tree verified clean, not just this file. The substance of 1126bfcf was already sound — verify-generate, vet and the full integration suite passed, so the genre-splitting behaviour holds against a real database. Only the formatter objected. --- internal/api/library_browse_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/library_browse_test.go b/internal/api/library_browse_test.go index a20386e9..dee9b7ed 100644 --- a/internal/api/library_browse_test.go +++ b/internal/api/library_browse_test.go @@ -47,7 +47,7 @@ func TestParseYearFilter(t *testing.T) { wantErr: errInvertedYearRange, }, { - name: "whitespace-only values are treated as absent", + name: "whitespace-only values are treated as absent", query: "year_from=%20&year_to=%20", wantActive: false, }, } From feb1c2eca8493acffd9fa0e02995c00c1bdf7460 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 13:41:51 -0400 Subject: [PATCH 13/15] =?UTF-8?q?feat(web):=20genre=20and=20year=20browse?= =?UTF-8?q?=20pages=20=E2=80=94=20#367?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client half of #367. Two new Library tabs, each an index plus a drill-down. Genres are ordered by track count rather than alphabetically. Raw ID3 carries a long tail of one-off tags, so alphabetical would bury the handful of genres you actually have a library's worth of. Years are grouped into decades — a flat list of every year in a decades-deep library is a wall of numbers, and the decade is how people actually think about it. ## Selection travels in the query string, not the path `?g=Rock%2FPop`, not `/library/genres/Rock%2FPop`. A slash-bearing genre cannot survive a path segment — the server sees two segments, and a hard reload wouldn't reconstruct it through the SPA fallback either. There's a test pinning the encoded href and another pinning that the DECODED value reaches the API. ## Why these two pages don't use svelte-query for their lists The indexes do — fetched once per mount, so static options suffice and the cache survives bouncing in and out of a drill-down. The drill-down lists deliberately don't. Their selection comes from the URL and changes WITHOUT remounting the page, and this codebase has no reactive-query-options pattern anywhere; inventing one here would be a larger change than the feature justifies, and one I can't exercise locally. So they use $effect keyed on the derived selection with an explicit Load more. The stale-response guard is a plain `let`, not $state, and that's load-bearing: as reactive state, reading the token inside the fetch path would make the effect depend on its own writes. Its job is to discard a late response for a previously selected genre instead of painting it over the current one. ## Also Added the year filter to /library/albums' contract but NOT to that page's UI — its infinite scroll is a svelte-query infinite query, and making it react to a filter is the same reactive-options problem. The dedicated pages cover the capability, which is the shape the task offered as its alternative. Library tab bar's comment claims it mirrors Android's LibraryScreen. These two tabs have no Android equivalent, so I noted that inline rather than leaving the claim quietly false. Parity remains an open call. Not yet done from #367's bullet list: genre/year quick-jump links on album and artist detail. Year is free (AlbumRef already carries it) but genre is exposed nowhere client-side — AlbumDetail is AlbumRef + tracks, and neither carries genre — so it needs a small API addition. Following as its own commit. --- web/src/lib/api/browse.ts | 82 ++++++++ web/src/lib/api/queries.ts | 5 + web/src/routes/library/+layout.svelte | 7 + web/src/routes/library/genres/+page.svelte | 210 +++++++++++++++++++ web/src/routes/library/genres/genres.test.ts | 171 +++++++++++++++ web/src/routes/library/years/+page.svelte | 206 ++++++++++++++++++ web/src/routes/library/years/years.test.ts | 179 ++++++++++++++++ 7 files changed, 860 insertions(+) create mode 100644 web/src/lib/api/browse.ts create mode 100644 web/src/routes/library/genres/+page.svelte create mode 100644 web/src/routes/library/genres/genres.test.ts create mode 100644 web/src/routes/library/years/+page.svelte create mode 100644 web/src/routes/library/years/years.test.ts diff --git a/web/src/lib/api/browse.ts b/web/src/lib/api/browse.ts new file mode 100644 index 00000000..70e5ff13 --- /dev/null +++ b/web/src/lib/api/browse.ts @@ -0,0 +1,82 @@ +import { createQuery } from '@tanstack/svelte-query'; +import { api } from './client'; +import { qk } from './queries'; +import type { AlbumRef, Page } from './types'; + +export const BROWSE_PAGE_SIZE = 50; + +// Genres are the raw ID3 strings, split on [;,] server-side but otherwise +// untouched — no case folding, no synonym mapping. So "Rock" and "rock" can +// both appear, as can "Rock/Pop" beside "Rock" and "Pop". Deliberate for v1: +// the raw spread has to be visible before anyone can judge whether it needs +// normalising. +export type GenreCount = { genre: string; track_count: number }; + +export type YearCount = { year: number; album_count: number }; + +export async function listGenres(): Promise { + return api.get('/api/library/genres'); +} + +export async function listAlbumYears(): Promise { + return api.get('/api/library/years'); +} + +// The indexes use svelte-query: they're fetched once per page mount, so static +// options are enough, and the cache means bouncing between browse and a +// drill-down doesn't refetch. The drill-down lists below deliberately do NOT — +// see the note on listAlbumsByGenre. +export function createGenresQuery() { + return createQuery({ + queryKey: qk.genres(), + queryFn: listGenres, + // Genres only change when the library is rescanned. + staleTime: 5 * 60_000 + }); +} + +export function createAlbumYearsQuery() { + return createQuery({ + queryKey: qk.albumYears(), + queryFn: listAlbumYears, + staleTime: 5 * 60_000 + }); +} + +// Genre travels as a QUERY parameter, never a path segment. Raw ID3 genres +// contain slashes ("Rock/Pop" is a real tag), which a path segment cannot +// carry — the server would see two segments, and a hard reload of such a URL +// would not survive the SPA fallback either. +// +// Called directly rather than through createInfiniteQuery because the selected +// genre comes from the URL and changes without remounting the page. This +// codebase has no reactive-query-options pattern, and introducing one here +// would be a larger change than the feature warrants. +export async function listAlbumsByGenre( + genre: string, + limit: number, + offset: number +): Promise> { + const params = new URLSearchParams({ + genre, + limit: String(limit), + offset: String(offset) + }); + return api.get>(`/api/library/albums?${params}`); +} + +// Single-year drill-down. The server takes an inclusive range, so one year is +// expressed as its own degenerate range rather than needing a separate shape. +export async function listAlbumsByYear( + year: number, + limit: number, + offset: number +): Promise> { + const params = new URLSearchParams({ + year_from: String(year), + year_to: String(year), + limit: String(limit), + offset: String(offset) + }); + return api.get>(`/api/library/albums?${params}`); +} diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 73db1e60..584c234a 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -68,6 +68,11 @@ export const qk = { ['playlists', { kind: kind ?? 'user' }] as const, playlist: (id: string) => ['playlist', id] as const, systemPlaylistsStatus: () => ['systemPlaylistsStatus'] as const, + // Browse indexes (#367). Keys carry no arguments — both are whole-library + // indexes, and the per-genre / per-year album lists are fetched outside + // svelte-query because their selection comes from the URL. + genres: () => ['genres'] as const, + albumYears: () => ['albumYears'] as const, }; export function createArtistsQuery(sort: ArtistSort) { diff --git a/web/src/routes/library/+layout.svelte b/web/src/routes/library/+layout.svelte index b231858a..bec57e7d 100644 --- a/web/src/routes/library/+layout.svelte +++ b/web/src/routes/library/+layout.svelte @@ -7,9 +7,16 @@ // (artists / albums / liked / history / playlists) — Playlists lives // here so the operator can find their personal collection in one // place rather than tracking a separate top-level route. + // Genres and Years sit next to Albums — all three are ways of walking the + // same collection — rather than at the end beside the personal tabs (Liked, + // History, Playlists). NOTE: these two are web-only for now; Android's + // LibraryScreen has no equivalent, so the "mirrors Android" claim above is + // currently aspirational for this pair. Parity is an open call (#367). const tabs = [ { href: '/library/artists', label: 'Artists' }, { href: '/library/albums', label: 'Albums' }, + { href: '/library/genres', label: 'Genres' }, + { href: '/library/years', label: 'Years' }, { href: '/library/liked', label: 'Liked' }, { href: '/library/history', label: 'History' }, { href: '/library/playlists', label: 'Playlists' } diff --git a/web/src/routes/library/genres/+page.svelte b/web/src/routes/library/genres/+page.svelte new file mode 100644 index 00000000..069de967 --- /dev/null +++ b/web/src/routes/library/genres/+page.svelte @@ -0,0 +1,210 @@ + + + + {pageTitle(selected ? `Library · ${selected}` : 'Library · Genres')} + + +{#if selected} +
+
+ + +
+

{selected}

+ {#if !loading || albums.length > 0} +

+ {total} {total === 1 ? 'album' : 'albums'} +

+ {/if} +
+
+ + {#if failed} +

+ Couldn't load albums for this genre. + +

+ {:else if loading && albums.length === 0} +

Loading…

+ {:else if albums.length === 0} + + + {:else} +
+ {#each albums as album (album.id)} + + {/each} +
+ {#if albums.length < total} +
+ +
+ {:else} +

End of genre

+ {/if} + {/if} +
+{:else} +
+
+
+

Genres

+ {#if !index.isPending && !index.isError} +

+ {genres.length} {genres.length === 1 ? 'genre' : 'genres'}, straight from your file tags +

+ {/if} +
+ {#if genres.length > 0} + + {/if} +
+ + {#if index.isError} + + {:else if index.isPending} +

Loading…

+ {:else if genres.length === 0} + + {#snippet actions()} + + Open admin + + {/snippet} + + {:else if filter.trim() && filteredGenres.length === 0} +

+ No genres match '{filter.trim()}'. +

+ {:else} + + + {/if} +
+{/if} diff --git a/web/src/routes/library/genres/genres.test.ts b/web/src/routes/library/genres/genres.test.ts new file mode 100644 index 00000000..84cd7290 --- /dev/null +++ b/web/src/routes/library/genres/genres.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '$test-utils/query'; +import { pageUrlModule } from '$test-utils/mocks/appState'; +import { apiClientMock } from '$test-utils/mocks/client'; +import { emptyLikesMock } from '$test-utils/mocks/likes'; +import type { AlbumRef } from '$lib/api/types'; + +const pageState = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/library/genres') +})); + +vi.mock('$app/state', () => pageUrlModule(pageState)); + +vi.mock('$lib/api/browse', () => ({ + BROWSE_PAGE_SIZE: 2, + createGenresQuery: vi.fn(), + listAlbumsByGenre: vi.fn() +})); + +vi.mock('$lib/api/client', () => apiClientMock()); +vi.mock('$lib/api/likes', () => emptyLikesMock()); +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + playRadio: vi.fn(), + enqueueTrack: vi.fn(), + enqueueTracks: vi.fn(), + player: { current: undefined } +})); + +import GenresPage from './+page.svelte'; +import { createGenresQuery, listAlbumsByGenre } from '$lib/api/browse'; + +const asMock = (fn: unknown) => fn as ReturnType; + +function album(id: string, title: string): AlbumRef { + return { + id, + title, + sort_title: title, + artist_id: 'ar1', + artist_name: 'Someone', + year: 1999, + track_count: 1, + duration_sec: 100, + cover_url: '', + cover_art_source: null + }; +} + +afterEach(() => { + pageState.pageUrl = new URL('http://localhost/library/genres'); + vi.clearAllMocks(); +}); + +describe('/library/genres index', () => { + test('lists genres with track counts in server order', () => { + asMock(createGenresQuery).mockReturnValue( + mockQuery({ + data: [ + { genre: 'Rock', track_count: 120 }, + { genre: 'Jazz', track_count: 8 } + ] + }) + ); + render(GenresPage); + + expect(screen.getByText('Rock')).toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + expect(screen.getByText('Jazz')).toBeInTheDocument(); + expect(screen.getByText('8')).toBeInTheDocument(); + }); + + // The reason genre is a query parameter and not a route segment. If this + // regresses to a path, "Rock/Pop" silently becomes two segments. + test('encodes a slash-bearing genre into the query string', () => { + asMock(createGenresQuery).mockReturnValue( + mockQuery({ data: [{ genre: 'Rock/Pop', track_count: 3 }] }) + ); + render(GenresPage); + + const link = screen.getByRole('link', { name: /Rock\/Pop/ }); + expect(link.getAttribute('href')).toBe('/library/genres?g=Rock%2FPop'); + }); + + test('case variants appear separately — genres are exposed as-is', () => { + asMock(createGenresQuery).mockReturnValue( + mockQuery({ + data: [ + { genre: 'Rock', track_count: 10 }, + { genre: 'rock', track_count: 2 } + ] + }) + ); + render(GenresPage); + + expect(screen.getByRole('link', { name: /^Rock 10$/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /^rock 2$/ })).toBeInTheDocument(); + }); + + test('empty library explains where genres come from', () => { + asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] })); + render(GenresPage); + + expect(screen.getByText('No genres found')).toBeInTheDocument(); + expect(screen.getByText(/genre tag on your audio files/i)).toBeInTheDocument(); + }); + + test('surfaces an index error with a retry', () => { + const refetch = vi.fn(); + asMock(createGenresQuery).mockReturnValue( + mockQuery({ isError: true, error: { message: 'boom' }, refetch }) + ); + render(GenresPage); + + fireEvent.click(screen.getByRole('button', { name: /Try again/i })); + expect(refetch).toHaveBeenCalled(); + }); +}); + +describe('/library/genres drill-down', () => { + test('fetches and renders albums for the selected genre', async () => { + pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock%2FPop'); + asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByGenre).mockResolvedValue({ + items: [album('a1', 'First Album')], + total: 1, + limit: 2, + offset: 0 + }); + render(GenresPage); + + // The decoded genre must reach the API, not the percent-encoded form. + await waitFor(() => expect(listAlbumsByGenre).toHaveBeenCalledWith('Rock/Pop', 2, 0)); + expect(await screen.findByText('First Album')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument(); + }); + + test('load more appends the next page and then reports the end', async () => { + pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock'); + asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByGenre) + .mockResolvedValueOnce({ + items: [album('a1', 'One'), album('a2', 'Two')], + total: 3, + limit: 2, + offset: 0 + }) + .mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 }); + render(GenresPage); + + await screen.findByText('One'); + const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ }); + await fireEvent.click(more); + + await waitFor(() => expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2)); + expect(await screen.findByText('Three')).toBeInTheDocument(); + // Earlier pages are appended, not replaced. + expect(screen.getByText('One')).toBeInTheDocument(); + expect(await screen.findByText('End of genre')).toBeInTheDocument(); + }); + + test('a failed drill-down offers a retry rather than an empty grid', async () => { + pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock'); + asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByGenre).mockRejectedValue(new Error('nope')); + render(GenresPage); + + expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument(); + }); +}); diff --git a/web/src/routes/library/years/+page.svelte b/web/src/routes/library/years/+page.svelte new file mode 100644 index 00000000..2f6f4478 --- /dev/null +++ b/web/src/routes/library/years/+page.svelte @@ -0,0 +1,206 @@ + + + + {pageTitle(selected !== null ? `Library · ${selected}` : 'Library · Years')} + + +{#if selected !== null} +
+
+ + +
+

{selected}

+ {#if !loading || albums.length > 0} +

+ {total} {total === 1 ? 'album' : 'albums'} +

+ {/if} +
+
+ + {#if failed} +

+ Couldn't load albums for {selected}. + +

+ {:else if loading && albums.length === 0} +

Loading…

+ {:else if albums.length === 0} + + {:else} +
+ {#each albums as album (album.id)} + + {/each} +
+ {#if albums.length < total} +
+ +
+ {:else} +

End of year

+ {/if} + {/if} +
+{:else} +
+
+

Years

+ {#if !index.isPending && !index.isError} +

+ {years.length} {years.length === 1 ? 'year' : 'years'} with dated releases +

+ {/if} +
+ + {#if index.isError} + + {:else if index.isPending} +

Loading…

+ {:else if years.length === 0} + + + {:else} +
    + {#each decades as d (d.decade)} +
  • +

    + {d.decade}s + · {d.albumCount} +

    + +
  • + {/each} +
+ {/if} +
+{/if} diff --git a/web/src/routes/library/years/years.test.ts b/web/src/routes/library/years/years.test.ts new file mode 100644 index 00000000..4a64ab0c --- /dev/null +++ b/web/src/routes/library/years/years.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '$test-utils/query'; +import { pageUrlModule } from '$test-utils/mocks/appState'; +import { apiClientMock } from '$test-utils/mocks/client'; +import { emptyLikesMock } from '$test-utils/mocks/likes'; +import type { AlbumRef } from '$lib/api/types'; + +const pageState = vi.hoisted(() => ({ + pageUrl: new URL('http://localhost/library/years') +})); + +vi.mock('$app/state', () => pageUrlModule(pageState)); + +vi.mock('$lib/api/browse', () => ({ + BROWSE_PAGE_SIZE: 2, + createAlbumYearsQuery: vi.fn(), + listAlbumsByYear: vi.fn() +})); + +vi.mock('$lib/api/client', () => apiClientMock()); +vi.mock('$lib/api/likes', () => emptyLikesMock()); +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + playRadio: vi.fn(), + enqueueTrack: vi.fn(), + enqueueTracks: vi.fn(), + player: { current: undefined } +})); + +import YearsPage from './+page.svelte'; +import { createAlbumYearsQuery, listAlbumsByYear } from '$lib/api/browse'; + +const asMock = (fn: unknown) => fn as ReturnType; + +function album(id: string, title: string): AlbumRef { + return { + id, + title, + sort_title: title, + artist_id: 'ar1', + artist_name: 'Someone', + year: 1999, + track_count: 1, + duration_sec: 100, + cover_url: '', + cover_art_source: null + }; +} + +afterEach(() => { + pageState.pageUrl = new URL('http://localhost/library/years'); + vi.clearAllMocks(); +}); + +describe('/library/years index', () => { + test('groups years into decades, newest decade first', () => { + asMock(createAlbumYearsQuery).mockReturnValue( + mockQuery({ + data: [ + { year: 2020, album_count: 3 }, + { year: 1995, album_count: 2 }, + { year: 1991, album_count: 1 } + ] + }) + ); + render(YearsPage); + + const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent ?? ''); + const decades = headings.map((t) => t.trim().split(/\s+/)[0]); + expect(decades).toEqual(['2020s', '1990s']); + }); + + test('sums album counts per decade', () => { + asMock(createAlbumYearsQuery).mockReturnValue( + mockQuery({ + data: [ + { year: 1995, album_count: 2 }, + { year: 1991, album_count: 5 } + ] + }) + ); + render(YearsPage); + + // 2 + 5 across the decade, not per-year. + const heading = screen.getByRole('heading', { level: 2 }); + expect(heading.textContent).toMatch(/1990s\s*·\s*7/); + }); + + test('years within a decade run newest first', () => { + asMock(createAlbumYearsQuery).mockReturnValue( + mockQuery({ + data: [ + { year: 1991, album_count: 1 }, + { year: 1997, album_count: 1 }, + { year: 1994, album_count: 1 } + ] + }) + ); + render(YearsPage); + + const links = screen.getAllByRole('link').map((a) => a.getAttribute('href')); + expect(links).toEqual([ + '/library/years?y=1997', + '/library/years?y=1994', + '/library/years?y=1991' + ]); + }); + + // Undated albums are excluded server-side rather than bucketed under a fake + // year, so an entirely undated library legitimately lands on the empty state. + test('empty index explains that undated albums are absent from this axis', () => { + asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] })); + render(YearsPage); + + expect(screen.getByText('No release years found')).toBeInTheDocument(); + expect(screen.getByText(/without one don't appear/i)).toBeInTheDocument(); + }); +}); + +describe('/library/years drill-down', () => { + test('requests the selected year as a degenerate range', async () => { + pageState.pageUrl = new URL('http://localhost/library/years?y=1995'); + asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByYear).mockResolvedValue({ + items: [album('a1', 'Mid Nineties')], + total: 1, + limit: 2, + offset: 0 + }); + render(YearsPage); + + await waitFor(() => expect(listAlbumsByYear).toHaveBeenCalledWith(1995, 2, 0)); + expect(await screen.findByText('Mid Nineties')).toBeInTheDocument(); + }); + + test('a non-numeric year is treated as no selection', () => { + pageState.pageUrl = new URL('http://localhost/library/years?y=nineteen'); + asMock(createAlbumYearsQuery).mockReturnValue( + mockQuery({ data: [{ year: 1999, album_count: 1 }] }) + ); + render(YearsPage); + + // Falls back to the index rather than fetching NaN. + expect(listAlbumsByYear).not.toHaveBeenCalled(); + expect(screen.getByRole('heading', { level: 1, name: 'Years' })).toBeInTheDocument(); + }); + + test('load more appends and then reports the end', async () => { + pageState.pageUrl = new URL('http://localhost/library/years?y=1995'); + asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByYear) + .mockResolvedValueOnce({ + items: [album('a1', 'One'), album('a2', 'Two')], + total: 3, + limit: 2, + offset: 0 + }) + .mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 }); + render(YearsPage); + + await screen.findByText('One'); + await fireEvent.click(await screen.findByRole('button', { name: /Load more \(1 left\)/ })); + + await waitFor(() => expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2)); + expect(await screen.findByText('Three')).toBeInTheDocument(); + expect(screen.getByText('One')).toBeInTheDocument(); + expect(await screen.findByText('End of year')).toBeInTheDocument(); + }); + + test('a failed drill-down offers a retry', async () => { + pageState.pageUrl = new URL('http://localhost/library/years?y=1995'); + asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] })); + asMock(listAlbumsByYear).mockRejectedValue(new Error('nope')); + render(YearsPage); + + expect(await screen.findByText(/Couldn't load albums for 1995/i)).toBeInTheDocument(); + }); +}); From a9ca49dc4e7b19abff42567458e6fffe0ed236a5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 5 Aug 2026 13:50:29 -0400 Subject: [PATCH 14/15] =?UTF-8?q?feat(library):=20genre=20+=20year=20quick?= =?UTF-8?q?-jumps=20on=20album=20and=20artist=20detail=20=E2=80=94=20#367?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last bullet of #367. From an album you like, one click to everything else from that year or in that genre. Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So both detail responses gained a derived `genres` array, computed from the entity's tracks rather than stored, since an album's tracks can legitimately disagree about genre. Split and trimmed identically to the browse index. That's the invariant this whole task turned on: if the chip's matching diverged from the index's splitting, a chip would lead to a page that doesn't contain the album you clicked from. No year link on artist detail. An artist spans many years, so a single one would be a lie about the discography — genres only there. Genre lookup failure is logged and degrades to no chips rather than failing the request; a navigation nicety must not 404 a detail page that otherwise loaded. `genres` is always an array at JSON, never null, matching how every other list field in this package is emitted. ## Type widening, and the TypeScript version of a lesson from earlier today Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture that constructs one. Six of them across three test files. That's the same shape as the Go signature changes that cost three CI rounds in #2453 — change a type, then go find everything that builds it — so I searched for the constructions before pushing instead of after. All six updated. Tests: the encoded href for a slash-bearing genre ("Rock/Pop" → ?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all. gofmt verified clean via docker rather than guessed. --- internal/api/convert.go | 10 ++++ internal/api/library.go | 14 +++++ internal/api/types.go | 7 +++ internal/db/dbq/browse.sql.go | 65 ++++++++++++++++++++++ internal/db/queries/browse.sql | 21 +++++++ web/src/lib/api/types.ts | 4 ++ web/src/lib/components/AlbumCard.test.ts | 4 +- web/src/routes/albums/[id]/+page.svelte | 25 ++++++++- web/src/routes/albums/[id]/album.test.ts | 53 +++++++++++++++++- web/src/routes/artists/[id]/+page.svelte | 18 ++++++ web/src/routes/artists/[id]/artist.test.ts | 6 +- 11 files changed, 220 insertions(+), 7 deletions(-) diff --git a/internal/api/convert.go b/internal/api/convert.go index 9560a679..a7148825 100644 --- a/internal/api/convert.go +++ b/internal/api/convert.go @@ -183,3 +183,13 @@ func parsePaging(raw url.Values) (limit, offset int, err error) { } return limit, offset, nil } + +// nonNilStrings guarantees a JSON array rather than null. The clients iterate +// these without a null check, matching how every other list field in this +// package is emitted. +func nonNilStrings(in []string) []string { + if in == nil { + return []string{} + } + return in +} diff --git a/internal/api/library.go b/internal/api/library.go index cc919a81..a4e2c99c 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -82,9 +82,17 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) { refs = append(refs, ref) durSec += ref.DurationSec } + // Genre chips are a navigation nicety, so a failure here must not 404 an + // album that loaded fine. Log and ship the detail without them. + genres, err := q.ListGenresForAlbum(r.Context(), album.ID) + if err != nil { + h.logger.Warn("api: list album genres failed", "err", err, "album_id", uuidToString(album.ID)) + genres = nil + } detail := AlbumDetail{ AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec), Tracks: refs, + Genres: nonNilStrings(genres), } writeJSON(w, http.StatusOK, detail) } @@ -114,9 +122,15 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) { // durationSec=0: not aggregated for nested album lists per spec data flow. refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0)) } + genres, err := q.ListGenresForArtist(r.Context(), artist.ID) + if err != nil { + h.logger.Warn("api: list artist genres failed", "err", err, "artist_id", uuidToString(artist.ID)) + genres = nil + } detail := ArtistDetail{ ArtistRef: artistRefFrom(artist, len(rows)), Albums: refs, + Genres: nonNilStrings(genres), } writeJSON(w, http.StatusOK, detail) } diff --git a/internal/api/types.go b/internal/api/types.go index bc9f98d1..53e872f6 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -89,12 +89,19 @@ type TrackRef struct { type ArtistDetail struct { ArtistRef Albums []AlbumRef `json:"albums"` + // Genres carried by this artist's tracks, for quick-jump chips (#367). + // Always non-nil at JSON so the client can iterate without a null check. + Genres []string `json:"genres"` } // AlbumDetail is the response body of GET /api/albums/{id}. type AlbumDetail struct { AlbumRef Tracks []TrackRef `json:"tracks"` + // Genres carried by this album's tracks, for quick-jump chips (#367). + // Derived from the tracks rather than stored on the album, because genre + // lives on tracks and an album's tracks can disagree. Non-nil at JSON. + Genres []string `json:"genres"` } // SearchResponse is the body of GET /api/search. Each facet carries its own diff --git a/internal/db/dbq/browse.sql.go b/internal/db/dbq/browse.sql.go index bbb7fc8e..be5de0f4 100644 --- a/internal/db/dbq/browse.sql.go +++ b/internal/db/dbq/browse.sql.go @@ -7,6 +7,8 @@ package dbq import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one @@ -212,6 +214,69 @@ func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListA return items, nil } +const listGenresForAlbum = `-- name: ListGenresForAlbum :many +SELECT DISTINCT trim(g.genre) AS genre +FROM tracks +JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true +WHERE tracks.album_id = $1 AND trim(g.genre) <> '' +ORDER BY trim(g.genre) +` + +// Distinct genres carried by an album's tracks, for the album detail page's +// quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a +// chip always leads to a page that actually contains this album — the two +// diverging is exactly the bug #367 had to fix in ListAlbumsByGenre. +func (q *Queries) ListGenresForAlbum(ctx context.Context, albumID pgtype.UUID) ([]string, error) { + rows, err := q.db.Query(ctx, listGenresForAlbum, albumID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var genre string + if err := rows.Scan(&genre); err != nil { + return nil, err + } + items = append(items, genre) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGenresForArtist = `-- name: ListGenresForArtist :many +SELECT DISTINCT trim(g.genre) AS genre +FROM tracks +JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true +WHERE tracks.artist_id = $1 AND trim(g.genre) <> '' +ORDER BY trim(g.genre) +` + +// Same, across everything by one artist. Alphabetical rather than by count: +// an artist's genre set is small, and a stable order reads better than a +// frequency ranking nobody asked about. +func (q *Queries) ListGenresForArtist(ctx context.Context, artistID pgtype.UUID) ([]string, error) { + rows, err := q.db.Query(ctx, listGenresForArtist, artistID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var genre string + if err := rows.Scan(&genre); err != nil { + return nil, err + } + items = append(items, genre) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listGenresWithCount = `-- name: ListGenresWithCount :many SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count FROM tracks diff --git a/internal/db/queries/browse.sql b/internal/db/queries/browse.sql index 58002299..3a1e3385 100644 --- a/internal/db/queries/browse.sql +++ b/internal/db/queries/browse.sql @@ -86,3 +86,24 @@ SELECT COUNT(*) FROM albums WHERE release_date IS NOT NULL AND EXTRACT(YEAR FROM release_date)::int BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int; + +-- name: ListGenresForAlbum :many +-- Distinct genres carried by an album's tracks, for the album detail page's +-- quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a +-- chip always leads to a page that actually contains this album — the two +-- diverging is exactly the bug #367 had to fix in ListAlbumsByGenre. +SELECT DISTINCT trim(g.genre) AS genre +FROM tracks +JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true +WHERE tracks.album_id = $1 AND trim(g.genre) <> '' +ORDER BY trim(g.genre); + +-- name: ListGenresForArtist :many +-- Same, across everything by one artist. Alphabetical rather than by count: +-- an artist's genre set is small, and a stable order reads better than a +-- frequency ranking nobody asked about. +SELECT DISTINCT trim(g.genre) AS genre +FROM tracks +JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true +WHERE tracks.artist_id = $1 AND trim(g.genre) <> '' +ORDER BY trim(g.genre); diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 17a7f791..bb1645c3 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -45,10 +45,14 @@ export type TrackRef = { export type ArtistDetail = ArtistRef & { albums: AlbumRef[]; + // Genres across this artist's tracks (#367). Server guarantees an array. + genres: string[]; }; export type AlbumDetail = AlbumRef & { tracks: TrackRef[]; + // Genres across this album's tracks (#367). Server guarantees an array. + genres: string[]; }; export type Playlist = { diff --git a/web/src/lib/components/AlbumCard.test.ts b/web/src/lib/components/AlbumCard.test.ts index 79d55b20..0c2ad29f 100644 --- a/web/src/lib/components/AlbumCard.test.ts +++ b/web/src/lib/components/AlbumCard.test.ts @@ -91,7 +91,7 @@ describe('AlbumCard', () => { duration_sec: 545 }) ]; - const detail: AlbumDetail = { ...album, tracks }; + const detail: AlbumDetail = { ...album, tracks, genres: [] }; (api.get as ReturnType).mockResolvedValueOnce(detail); render(AlbumCard, { props: { album } }); @@ -112,7 +112,7 @@ describe('AlbumCard', () => { duration_sec: 545 }) ]; - const detail: AlbumDetail = { ...album, tracks }; + const detail: AlbumDetail = { ...album, tracks, genres: [] }; (api.get as ReturnType).mockResolvedValueOnce(detail); render(AlbumCard, { props: { album } }); diff --git a/web/src/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte index 355265be..b2ff3dc3 100644 --- a/web/src/routes/albums/[id]/+page.svelte +++ b/web/src/routes/albums/[id]/+page.svelte @@ -126,8 +126,31 @@

{album.artist_name}

+ {#if album.year} -

{album.year}

+

+ + {album.year} + +

+ {/if} + {#if album.genres?.length} +
    + {#each album.genres as genre (genre)} +
  • + + {genre} + +
  • + {/each} +
{/if}

{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} diff --git a/web/src/routes/albums/[id]/album.test.ts b/web/src/routes/albums/[id]/album.test.ts index 23c1427f..005a388d 100644 --- a/web/src/routes/albums/[id]/album.test.ts +++ b/web/src/routes/albums/[id]/album.test.ts @@ -49,7 +49,8 @@ describe('album detail page', () => { year: 1959, track_count: 2, duration_sec: 544 + 565, cover_url: '/api/albums/xyz/cover', cover_art_source: null, - tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)] + tracks: [track('t1', 'So What', 1, 544), track('t2', 'Freddie Freeloader', 2, 565)], + genres: ['Jazz'] }; (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); render(AlbumPage); @@ -77,7 +78,8 @@ describe('album detail page', () => { track_count: 0, duration_sec: 0, cover_url: '/api/albums/xyz/cover', cover_art_source: null, - tracks: [] + tracks: [], + genres: [] }; (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); render(AlbumPage); @@ -119,4 +121,51 @@ describe('album detail page', () => { vi.useRealTimers(); } }); + + // Quick-jumps into the browse axes (#367). The genre href must be encoded: + // a slash-bearing tag is why browse selection lives in the query string. + test('year and genre are quick-jump links into the browse axes', () => { + const detail: AlbumDetail = { + id: 'xyz', title: 'Kind of Blue', sort_title: 'Kind of Blue', + artist_id: 'md', artist_name: 'Miles Davis', + year: 1959, track_count: 0, duration_sec: 0, + cover_url: '/api/albums/xyz/cover', + cover_art_source: null, + tracks: [], + genres: ['Jazz', 'Rock/Pop'] + }; + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(AlbumPage); + + expect(screen.getByRole('link', { name: '1959' })).toHaveAttribute( + 'href', + '/library/years?y=1959' + ); + expect(screen.getByRole('link', { name: 'Jazz' })).toHaveAttribute( + 'href', + '/library/genres?g=Jazz' + ); + expect(screen.getByRole('link', { name: 'Rock/Pop' })).toHaveAttribute( + 'href', + '/library/genres?g=Rock%2FPop' + ); + }); + + test('no genre chips when the album carries no genre tags', () => { + const detail: AlbumDetail = { + id: 'xyz', title: 'Untagged', sort_title: 'Untagged', + artist_id: 'md', artist_name: 'Miles Davis', + track_count: 0, duration_sec: 0, + cover_url: '/api/albums/xyz/cover', + cover_art_source: null, + tracks: [], + genres: [] + }; + (createAlbumQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(AlbumPage); + + expect(screen.queryByRole('link', { name: /library\/genres/ })).toBeNull(); + // No year on this fixture either, so no year jump. + expect(screen.queryByRole('link', { name: /^\d{4}$/ })).toBeNull(); + }); }); diff --git a/web/src/routes/artists/[id]/+page.svelte b/web/src/routes/artists/[id]/+page.svelte index 3f829273..8da5a2e9 100644 --- a/web/src/routes/artists/[id]/+page.svelte +++ b/web/src/routes/artists/[id]/+page.svelte @@ -90,6 +90,24 @@

{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}

+ + {#if detail.genres?.length} +
    + {#each detail.genres as genre (genre)} +
  • + + {genre} + +
  • + {/each} +
+ {/if}