From 9fd3fec14918fea37368994473bd81c199501b09 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:37:11 +0000 Subject: [PATCH 01/19] feat(db): migration 0003 - users.subsonic_password opt-in column Subsonic token auth (t=md5(password+salt)) needs a reversibly stored credential; bcrypt password_hash can't serve. NULL means the user has not opted in, forcing apiKey (OpenSubsonic) instead. --- internal/db/migrations/0003_subsonic_password.up.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 internal/db/migrations/0003_subsonic_password.up.sql diff --git a/internal/db/migrations/0003_subsonic_password.up.sql b/internal/db/migrations/0003_subsonic_password.up.sql new file mode 100644 index 00000000..304e0192 --- /dev/null +++ b/internal/db/migrations/0003_subsonic_password.up.sql @@ -0,0 +1,7 @@ +-- Subsonic's token auth (t = md5(password + salt)) requires the server to +-- compute md5 over the user's plaintext password. bcrypt (password_hash) is +-- not reversible, so we store a *separate*, opt-in, plaintext credential used +-- exclusively for the /rest/* legacy auth path. NULL means the user has not +-- opted into Subsonic t/s auth and must use apiKey (OpenSubsonic) instead. + +ALTER TABLE users ADD COLUMN subsonic_password text; From b5bf0cc9d6c21680c5f3f9806ce6a824eb542eab Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:37:12 +0000 Subject: [PATCH 02/19] =?UTF-8?q?feat(db):=20migration=200003=20down=20?= =?UTF-8?q?=E2=80=94=20drop=20users.subsonic=5Fpassword?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/db/migrations/0003_subsonic_password.down.sql | 1 + 1 file changed, 1 insertion(+) create mode 100644 internal/db/migrations/0003_subsonic_password.down.sql diff --git a/internal/db/migrations/0003_subsonic_password.down.sql b/internal/db/migrations/0003_subsonic_password.down.sql new file mode 100644 index 00000000..c46bad35 --- /dev/null +++ b/internal/db/migrations/0003_subsonic_password.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS subsonic_password; From bc22ec9a91fdafa39882c894c8a1ff7d055edc7f Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:37:18 +0000 Subject: [PATCH 03/19] feat(db): add SetSubsonicPassword query Allows setting or clearing the opt-in Subsonic legacy credential. --- internal/db/queries/users.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index b3174cbe..1a9b401c 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -11,3 +11,8 @@ SELECT * FROM users WHERE api_token = $1; -- name: CountUsers :one SELECT count(*) FROM users; + +-- name: SetSubsonicPassword :exec +-- Stores (or clears with NULL) the per-user Subsonic legacy credential used +-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. +UPDATE users SET subsonic_password = $2 WHERE id = $1; From 2d153e1e9a8abb4dd75c84abf15446bb61720264 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:37:32 +0000 Subject: [PATCH 04/19] chore(sqlc): regenerate users.sql.go for subsonic_password --- internal/db/dbq/users.sql.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 21a77f01..b2aedd4c 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -7,6 +7,8 @@ package dbq import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const countUsers = `-- name: CountUsers :one @@ -23,7 +25,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { const createUser = `-- name: CreateUser :one INSERT INTO users (username, password_hash, api_token, is_admin) VALUES ($1, $2, $3, $4) -RETURNING id, username, password_hash, api_token, is_admin, created_at +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password ` type CreateUserParams struct { @@ -48,12 +50,13 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.ApiToken, &i.IsAdmin, &i.CreatedAt, + &i.SubsonicPassword, ) return i, err } const getUserByAPIToken = `-- name: GetUserByAPIToken :one -SELECT id, username, password_hash, api_token, is_admin, created_at FROM users WHERE api_token = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE api_token = $1 ` func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { @@ -66,12 +69,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, &i.ApiToken, &i.IsAdmin, &i.CreatedAt, + &i.SubsonicPassword, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, api_token, is_admin, created_at FROM users WHERE username = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -84,6 +88,23 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.ApiToken, &i.IsAdmin, &i.CreatedAt, + &i.SubsonicPassword, ) return i, err } + +const setSubsonicPassword = `-- name: SetSubsonicPassword :exec +UPDATE users SET subsonic_password = $2 WHERE id = $1 +` + +type SetSubsonicPasswordParams struct { + ID pgtype.UUID + SubsonicPassword *string +} + +// Stores (or clears with NULL) the per-user Subsonic legacy credential used +// for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. +func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error { + _, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword) + return err +} From f916cde8c857dedbde366e53c9f4b0692af34903 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:37:40 +0000 Subject: [PATCH 05/19] chore(sqlc): regenerate models.go for subsonic_password --- internal/db/dbq/models.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 8936b9ea..e6e00138 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -48,10 +48,11 @@ type Track struct { } type User struct { - ID pgtype.UUID - Username string - PasswordHash string - ApiToken string - IsAdmin bool - CreatedAt pgtype.Timestamptz + ID pgtype.UUID + Username string + PasswordHash string + ApiToken string + IsAdmin bool + CreatedAt pgtype.Timestamptz + SubsonicPassword *string } From 4a447e081dbf63e9167f1277bfc7bd913051a8a3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:37:49 +0000 Subject: [PATCH 06/19] feat(subsonic): error code constants --- internal/subsonic/errors.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 internal/subsonic/errors.go diff --git a/internal/subsonic/errors.go b/internal/subsonic/errors.go new file mode 100644 index 00000000..a7028510 --- /dev/null +++ b/internal/subsonic/errors.go @@ -0,0 +1,17 @@ +package subsonic + +// Subsonic REST error codes per http://www.subsonic.org/pages/api.jsp §3. +// ErrTokenNotSupported (41) is reused for "this user has not enabled the +// requested auth mode" — the historical meaning ("LDAP users can't use +// token auth") is the closest analogue. +const ( + ErrGeneric = 0 + ErrMissingParameter = 10 + ErrClientTooOld = 20 + ErrServerTooOld = 30 + ErrWrongCredentials = 40 + ErrTokenNotSupported = 41 + ErrNotAuthorized = 50 + ErrTrialExpired = 60 + ErrDataNotFound = 70 +) From fd408d78af8bf2909fbdf38a25324748d0a4dc61 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:38:07 +0000 Subject: [PATCH 07/19] feat(subsonic): response envelope with JSON/XML/JSONP emit Renders one wire format per ?f= parameter; JSON wraps in {"subsonic-response":...}, XML emits with xmlns, JSONP wraps in callback or falls back to JSON when callback is empty. --- internal/subsonic/envelope.go | 98 +++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 internal/subsonic/envelope.go diff --git a/internal/subsonic/envelope.go b/internal/subsonic/envelope.go new file mode 100644 index 00000000..3e2024c4 --- /dev/null +++ b/internal/subsonic/envelope.go @@ -0,0 +1,98 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" +) + +// APIVersion is the Subsonic REST version we advertise. 1.16.1 is the last +// "classic" level; OpenSubsonic extensions are surfaced via the openSubsonic +// flag and the type/serverVersion fields. +const ( + APIVersion = "1.16.1" + ServerType = "minstrel" + XMLNS = "http://subsonic.org/restapi" +) + +// ServerVersion identifies this build. Overwritten by cmd/minstrel at startup. +var ServerVersion = "dev" + +// Envelope holds the attributes present on every Subsonic response. Concrete +// response types embed it and add endpoint-specific fields with json+xml tags. +type Envelope struct { + XMLName xml.Name `json:"-" xml:"subsonic-response"` + XMLNS string `json:"-" xml:"xmlns,attr"` + Status string `json:"status" xml:"status,attr"` + Version string `json:"version" xml:"version,attr"` + Type string `json:"type" xml:"type,attr"` + ServerVersion string `json:"serverVersion" xml:"serverVersion,attr"` + OpenSubsonic bool `json:"openSubsonic" xml:"openSubsonic,attr"` + Error *Error `json:"error,omitempty" xml:"error,omitempty"` +} + +// Error is the body of a failed response. +type Error struct { + XMLName xml.Name `json:"-" xml:"error"` + Code int `json:"code" xml:"code,attr"` + Message string `json:"message" xml:"message,attr"` +} + +// NewEnvelope returns a populated header for the given status ("ok"|"failed"). +func NewEnvelope(status string) Envelope { + return Envelope{ + XMLNS: XMLNS, + Status: status, + Version: APIVersion, + Type: ServerType, + ServerVersion: ServerVersion, + OpenSubsonic: true, + } +} + +// Write renders body in the format requested by the ?f= query parameter. +// body must embed Envelope. Unknown formats fall back to JSON. +func Write(w http.ResponseWriter, r *http.Request, body any) { + switch strings.ToLower(r.URL.Query().Get("f")) { + case "xml": + writeXML(w, body) + case "jsonp": + writeJSONP(w, body, r.URL.Query().Get("callback")) + default: + writeJSON(w, body) + } +} + +// WriteFail emits an envelope-only failed response with the given error code. +func WriteFail(w http.ResponseWriter, r *http.Request, code int, message string) { + env := NewEnvelope("failed") + env.Error = &Error{Code: code, Message: message} + Write(w, r, env) +} + +func writeJSON(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"subsonic-response": body}) +} + +func writeJSONP(w http.ResponseWriter, body any, callback string) { + if callback == "" { + // Per spec the client must supply callback; fall back to plain JSON + // rather than producing invalid JavaScript. + writeJSON(w, body) + return + } + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + fmt.Fprintf(w, "%s(", callback) + _ = json.NewEncoder(w).Encode(map[string]any{"subsonic-response": body}) + _, _ = io.WriteString(w, ")") +} + +func writeXML(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + _, _ = io.WriteString(w, xml.Header) + _ = xml.NewEncoder(w).Encode(body) +} From cdf56801cd5cfbd462358e4bdb9a7579c34335cb Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:38:28 +0000 Subject: [PATCH 08/19] feat(subsonic): auth middleware (apiKey / t+s / p gated) apiKey (OpenSubsonic) is preferred. t+s uses md5(subsonic_password+salt) with a constant-time compare. p is disabled by default and gated by SubsonicConfig.AllowPlaintextPassword; enc:HEX obfuscation decoded. Auth failures write a Subsonic "failed" envelope so clients don't see HTTP 401. --- internal/subsonic/auth.go | 123 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 internal/subsonic/auth.go diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go new file mode 100644 index 00000000..b651588d --- /dev/null +++ b/internal/subsonic/auth.go @@ -0,0 +1,123 @@ +package subsonic + +import ( + "context" + "crypto/md5" + "crypto/subtle" + "encoding/hex" + "errors" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Config controls wire-format concessions the server makes for Subsonic +// clients. At-rest policy (which users have a subsonic_password set) is +// driven per-user; this struct only covers the server-wide opt-ins. +type Config struct { + AllowPlaintextPassword bool +} + +type ctxKey int + +const userCtxKey ctxKey = 1 + +// UserFromContext returns the user attached by Middleware. +func UserFromContext(ctx context.Context) (dbq.User, bool) { + u, ok := ctx.Value(userCtxKey).(dbq.User) + return u, ok +} + +// Middleware authenticates the request against users.api_token (apiKey) or +// users.subsonic_password (t/s or p). On failure it writes a Subsonic failed +// envelope using the request's f= format; downstream handlers never see an +// unauthenticated request. +func Middleware(pool *pgxpool.Pool, cfg Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, code, msg := authenticate(r, pool, cfg) + if code != 0 { + WriteFail(w, r, code, msg) + return + } + ctx := context.WithValue(r.Context(), userCtxKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func authenticate(r *http.Request, pool *pgxpool.Pool, cfg Config) (dbq.User, int, string) { + q := dbq.New(pool) + params := r.URL.Query() + + if apiKey := params.Get("apiKey"); apiKey != "" { + user, err := q.GetUserByAPIToken(r.Context(), apiKey) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.User{}, ErrWrongCredentials, "Invalid apiKey" + } + return dbq.User{}, ErrGeneric, "Auth lookup failed" + } + return user, 0, "" + } + + username := params.Get("u") + if username == "" { + return dbq.User{}, ErrMissingParameter, "Missing required parameter: u or apiKey" + } + user, err := q.GetUserByUsername(r.Context(), username) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return dbq.User{}, ErrWrongCredentials, "Wrong username or password" + } + return dbq.User{}, ErrGeneric, "Auth lookup failed" + } + + if token, salt := params.Get("t"), params.Get("s"); token != "" && salt != "" { + if user.SubsonicPassword == nil { + return dbq.User{}, ErrTokenNotSupported, "Subsonic token auth not configured; use apiKey or set a Subsonic password" + } + sum := md5.Sum([]byte(*user.SubsonicPassword + salt)) + expected := hex.EncodeToString(sum[:]) + if subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 { + return dbq.User{}, ErrWrongCredentials, "Wrong username or password" + } + return user, 0, "" + } + + if password := params.Get("p"); password != "" { + if !cfg.AllowPlaintextPassword { + return dbq.User{}, ErrTokenNotSupported, "Plaintext password auth is disabled; use apiKey or t+s" + } + if user.SubsonicPassword == nil { + return dbq.User{}, ErrTokenNotSupported, "Subsonic password not configured for this user" + } + decoded, err := decodePassword(password) + if err != nil { + return dbq.User{}, ErrWrongCredentials, "Malformed password" + } + if subtle.ConstantTimeCompare([]byte(*user.SubsonicPassword), []byte(decoded)) != 1 { + return dbq.User{}, ErrWrongCredentials, "Wrong username or password" + } + return user, 0, "" + } + + return dbq.User{}, ErrMissingParameter, "Missing required parameter: p, t+s, or apiKey" +} + +// decodePassword unwraps Subsonic's enc:HEX obfuscation. Non-enc values pass +// through unchanged so the caller can treat the result as the literal secret. +func decodePassword(p string) (string, error) { + if !strings.HasPrefix(p, "enc:") { + return p, nil + } + b, err := hex.DecodeString(p[4:]) + if err != nil { + return "", err + } + return string(b), nil +} From 2a75fc668700c818852babca4250407a2b16e89e Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:38:34 +0000 Subject: [PATCH 09/19] feat(subsonic): ping.view + getLicense.view handlers --- internal/subsonic/handlers.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 internal/subsonic/handlers.go diff --git a/internal/subsonic/handlers.go b/internal/subsonic/handlers.go new file mode 100644 index 00000000..2a210986 --- /dev/null +++ b/internal/subsonic/handlers.go @@ -0,0 +1,34 @@ +package subsonic + +import ( + "encoding/xml" + "net/http" +) + +// PingResponse is envelope-only; clients treat any ok response as success. +type PingResponse struct { + Envelope +} + +func handlePing(w http.ResponseWriter, r *http.Request) { + Write(w, r, PingResponse{Envelope: NewEnvelope("ok")}) +} + +// LicenseResponse reports the (always-valid) self-hosted license. Many +// Subsonic clients refuse to stream until this endpoint returns valid=true. +type LicenseResponse struct { + Envelope + License License `json:"license" xml:"license"` +} + +type License struct { + XMLName xml.Name `json:"-" xml:"license"` + Valid bool `json:"valid" xml:"valid,attr"` +} + +func handleGetLicense(w http.ResponseWriter, r *http.Request) { + Write(w, r, LicenseResponse{ + Envelope: NewEnvelope("ok"), + License: License{Valid: true}, + }) +} From da2bb672f004c0f285214dd6f8f1a329205083aa Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:38:44 +0000 Subject: [PATCH 10/19] feat(subsonic): mount /rest router with /ping and /getLicense Exposes each handler at /rest/name and /rest/name.view, GET+POST, for client convention compatibility. --- internal/subsonic/subsonic.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 internal/subsonic/subsonic.go diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go new file mode 100644 index 00000000..2eff4057 --- /dev/null +++ b/internal/subsonic/subsonic.go @@ -0,0 +1,34 @@ +// Package subsonic implements a Subsonic-compatible REST API surface under +// /rest. It exists for third-party Subsonic clients (Symfonium, Substreamer, +// DSub, Tempo, play:Sub, …). Minstrel's own clients use the native /api/* +// surface, which has modern auth; /rest/* retains Subsonic's legacy auth for +// compatibility only. See docs/auth.md for the security posture. +package subsonic + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Mount attaches Subsonic handlers at /rest on r. Endpoints are exposed at +// both /rest/foo and /rest/foo.view because client conventions vary. Both +// GET and POST are accepted; Subsonic's auth params live in the query string +// either way. +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) { + r.Route("/rest", func(sub chi.Router) { + sub.Use(Middleware(pool, cfg)) + register(sub, "/ping", handlePing) + register(sub, "/getLicense", handleGetLicense) + _ = logger + }) +} + +func register(r chi.Router, path string, h func(http.ResponseWriter, *http.Request)) { + r.Get(path, h) + r.Post(path, h) + r.Get(path+".view", h) + r.Post(path+".view", h) +} From 1885d197d64dd43f905a19a3ced73db9a707af74 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:39:03 +0000 Subject: [PATCH 11/19] test(subsonic): envelope JSON/XML/JSONP + WriteFail --- internal/subsonic/envelope_test.go | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 internal/subsonic/envelope_test.go diff --git a/internal/subsonic/envelope_test.go b/internal/subsonic/envelope_test.go new file mode 100644 index 00000000..89883834 --- /dev/null +++ b/internal/subsonic/envelope_test.go @@ -0,0 +1,101 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "io" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteJSONEnvelope(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/rest/ping.view", nil) + Write(rec, req, PingResponse{Envelope: NewEnvelope("ok")}) + + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("content-type = %q", ct) + } + var payload map[string]map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v — body=%s", err, rec.Body) + } + inner, ok := payload["subsonic-response"] + if !ok { + t.Fatalf("missing subsonic-response key: %s", rec.Body) + } + if inner["status"] != "ok" || inner["version"] != APIVersion || inner["type"] != ServerType { + t.Errorf("envelope fields = %+v", inner) + } + if inner["openSubsonic"] != true { + t.Errorf("openSubsonic flag missing: %+v", inner) + } +} + +func TestWriteXMLEnvelope(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/rest/getLicense.view?f=xml", nil) + Write(rec, req, LicenseResponse{ + Envelope: NewEnvelope("ok"), + License: License{Valid: true}, + }) + + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/xml") { + t.Errorf("content-type = %q", ct) + } + body, _ := io.ReadAll(rec.Body) + var decoded struct { + XMLName xml.Name `xml:"subsonic-response"` + Status string `xml:"status,attr"` + Version string `xml:"version,attr"` + License struct { + Valid bool `xml:"valid,attr"` + } `xml:"license"` + } + if err := xml.Unmarshal(body, &decoded); err != nil { + t.Fatalf("xml decode: %v — body=%s", err, body) + } + if decoded.Status != "ok" || decoded.Version != APIVersion { + t.Errorf("envelope attrs = %+v", decoded) + } + if !decoded.License.Valid { + t.Errorf("license.valid attr not parsed: %+v", decoded) + } +} + +func TestWriteFailEnvelopeJSON(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/rest/ping.view", nil) + WriteFail(rec, req, ErrWrongCredentials, "nope") + + var payload map[string]map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + inner := payload["subsonic-response"] + if inner["status"] != "failed" { + t.Errorf("status = %v, want failed", inner["status"]) + } + errMap, ok := inner["error"].(map[string]any) + if !ok { + t.Fatalf("error field missing/not object: %+v", inner) + } + if int(errMap["code"].(float64)) != ErrWrongCredentials { + t.Errorf("error.code = %v, want %d", errMap["code"], ErrWrongCredentials) + } + if errMap["message"] != "nope" { + t.Errorf("error.message = %v", errMap["message"]) + } +} + +func TestWriteJSONPWrapsCallback(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/rest/ping.view?f=jsonp&callback=cb", nil) + Write(rec, req, PingResponse{Envelope: NewEnvelope("ok")}) + + body := rec.Body.String() + if !strings.HasPrefix(body, "cb(") || !strings.HasSuffix(strings.TrimSpace(body), ")") { + t.Errorf("jsonp wrapper missing: %q", body) + } +} From 841a3d816549da80c8f60a945b0f058d4ab68297 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:39:12 +0000 Subject: [PATCH 12/19] test(subsonic): decodePassword + md5(password+salt) derivation --- internal/subsonic/auth_test.go | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 internal/subsonic/auth_test.go diff --git a/internal/subsonic/auth_test.go b/internal/subsonic/auth_test.go new file mode 100644 index 00000000..e2d0f3a1 --- /dev/null +++ b/internal/subsonic/auth_test.go @@ -0,0 +1,44 @@ +package subsonic + +import ( + "crypto/md5" + "encoding/hex" + "testing" +) + +func TestDecodePasswordEnc(t *testing.T) { + out, err := decodePassword("enc:68656c6c6f") + if err != nil { + t.Fatalf("decode: %v", err) + } + if out != "hello" { + t.Errorf("decode = %q, want hello", out) + } +} + +func TestDecodePasswordPlain(t *testing.T) { + out, err := decodePassword("hunter2") + if err != nil || out != "hunter2" { + t.Errorf("plain passthrough = %q, %v", out, err) + } +} + +func TestDecodePasswordBadHex(t *testing.T) { + if _, err := decodePassword("enc:zzzz"); err == nil { + t.Error("expected error for malformed enc: prefix") + } +} + +// TestTokenMD5Derivation documents the t = md5(password + salt) construction +// so regressions in the auth path surface as a failing checksum rather than a +// 40/wrong-credentials response that could be mistaken for bad inputs. +func TestTokenMD5Derivation(t *testing.T) { + password := "hunter2" + salt := "NaClz" + sum := md5.Sum([]byte(password + salt)) + want := hex.EncodeToString(sum[:]) + const expected = "91b894c3c9af9c16bec1b3f771e31462" + if want != expected { + t.Errorf("md5(password+salt) = %s, want %s", want, expected) + } +} From e649ad2f66029be2b373129451dbc8de2daf77c3 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:39:30 +0000 Subject: [PATCH 13/19] feat(config): subsonic.allow_plaintext_password --- internal/config/config.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index e6939c2e..e20e23c9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { Log LogConfig `yaml:"log"` Auth AuthConfig `yaml:"auth"` Library LibraryConfig `yaml:"library"` + Subsonic SubsonicConfig `yaml:"subsonic"` } type ServerConfig struct { @@ -47,6 +48,13 @@ type LibraryConfig struct { ScanOnStartup bool `yaml:"scan_on_startup"` } +// SubsonicConfig controls wire-format concessions on the /rest/* surface. +// AllowPlaintextPassword gates the `p=` parameter; the recommended posture is +// to leave it disabled and require apiKey (OpenSubsonic) or t+s. +type SubsonicConfig struct { + AllowPlaintextPassword bool `yaml:"allow_plaintext_password"` +} + func Default() Config { return Config{ Server: ServerConfig{Address: ":4533"}, @@ -98,6 +106,11 @@ func applyEnv(cfg *Config) { cfg.Library.ScanOnStartup = b } } + if v, ok := os.LookupEnv("SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD"); ok { + if b, err := strconv.ParseBool(v); err == nil { + cfg.Subsonic.AllowPlaintextPassword = b + } + } } // splitPaths splits a colon-separated path list, dropping empty entries. From f8b0d78544bdebfd3caaef655eb8a4ecb6c57f4c Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:39:48 +0000 Subject: [PATCH 14/19] test(config): subsonic env override --- internal/config/config_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 24a6eb9e..511a5bb0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -117,6 +117,17 @@ func TestLibraryEnvOverrides(t *testing.T) { } } +func TestSubsonicEnvOverride(t *testing.T) { + t.Setenv("SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD", "true") + cfg, err := Load("") + if err != nil { + t.Fatalf("Load: %v", err) + } + if !cfg.Subsonic.AllowPlaintextPassword { + t.Error("allow_plaintext_password = false, want true") + } +} + func TestLibraryYAMLLoads(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.yaml") From 5288e9d5dfae754fd98ca9a173f53680ffaed7ed Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:40:05 +0000 Subject: [PATCH 15/19] feat(server): wire subsonic.Mount under /rest --- internal/server/server.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/server/server.go b/internal/server/server.go index e4c03dcd..36fc0ce1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/library" + "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" ) // ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an @@ -21,13 +22,14 @@ type ScanTrigger interface { } type Server struct { - Logger *slog.Logger - Pool *pgxpool.Pool - Scanner ScanTrigger + Logger *slog.Logger + Pool *pgxpool.Pool + Scanner ScanTrigger + SubsonicCfg subsonic.Config } -func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger) *Server { - return &Server{Logger: logger, Pool: pool, Scanner: scanner} +func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config) *Server { + return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg} } func (s *Server) Router() http.Handler { @@ -46,6 +48,7 @@ func (s *Server) Router() http.Handler { } }) }) + subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg) } return r } From 644af30a8618b7f39a037789aa34b620ec01b6d7 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:40:10 +0000 Subject: [PATCH 16/19] test(server): update New() call for new Subsonic arg --- internal/server/server_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 76114653..bb3ae017 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -7,10 +7,12 @@ import ( "net/http" "net/http/httptest" "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" ) func TestHealthz(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}) ts := httptest.NewServer(s.Router()) defer ts.Close() From 9f325cf309c77fd8738e694d64feed0d3b407c21 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:40:24 +0000 Subject: [PATCH 17/19] feat(cmd): plumb SubsonicConfig into server.New --- cmd/minstrel/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 20be252e..df3398a9 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -17,6 +17,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" "git.fabledsword.com/bvandeusen/minstrel/internal/server" + "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" ) func main() { @@ -72,7 +73,9 @@ func run() error { }() } - srv := server.New(logger, pool, scanner) + srv := server.New(logger, pool, scanner, subsonic.Config{ + AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, + }) httpServer := &http.Server{ Addr: cfg.Server.Address, Handler: srv.Router(), From 02e867ce94be9bf1dece1be91cc43d1759ce474b Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 17:40:36 +0000 Subject: [PATCH 18/19] docs: refresh config.example.yaml with auth/library/subsonic sections --- config.example.yaml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 2b21f2a3..eb3c6bee 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -13,3 +13,26 @@ database: log: level: "info" # debug | info | warn | error format: "text" # text | json + +auth: + # One-shot admin bootstrap. Applied only when the users table is empty. + # Set username to enable; leave password blank to have the server generate + # one and log it to stderr on first start. + # Env: SMARTMUSIC_AUTH_ADMIN_USERNAME, SMARTMUSIC_AUTH_ADMIN_PASSWORD + admin_bootstrap: + username: "" + password: "" + +library: + # Filesystem roots to scan for music. Each path is walked recursively. + # Env: SMARTMUSIC_LIBRARY_SCAN_PATHS (colon-separated, PATH-style) + scan_paths: [] + # Kick off a scan on server startup. Env: SMARTMUSIC_LIBRARY_SCAN_ON_STARTUP + scan_on_startup: false + +subsonic: + # Gate for the legacy `p=` plaintext password parameter on /rest/*. + # Leave disabled unless a specific client requires it — Minstrel prefers + # apiKey (OpenSubsonic) and falls back to u+t+s token auth. + # Env: SMARTMUSIC_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD + allow_plaintext_password: false From 98818fe28c89542e09877dcca6813f41f6ed3796 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 18:07:36 +0000 Subject: [PATCH 19/19] fix(subsonic): check fmt.Fprintf return (errcheck lint) --- internal/subsonic/envelope.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/subsonic/envelope.go b/internal/subsonic/envelope.go index 3e2024c4..ffbf4255 100644 --- a/internal/subsonic/envelope.go +++ b/internal/subsonic/envelope.go @@ -86,7 +86,7 @@ func writeJSONP(w http.ResponseWriter, body any, callback string) { return } w.Header().Set("Content-Type", "application/javascript; charset=utf-8") - fmt.Fprintf(w, "%s(", callback) + _, _ = fmt.Fprintf(w, "%s(", callback) _ = json.NewEncoder(w).Encode(map[string]any{"subsonic-response": body}) _, _ = io.WriteString(w, ")") }