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(), 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 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. 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") 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 } 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 +} 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; 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; 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; 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 } 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() 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 +} 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) + } +} diff --git a/internal/subsonic/envelope.go b/internal/subsonic/envelope.go new file mode 100644 index 00000000..ffbf4255 --- /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) +} 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) + } +} 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 +) 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}, + }) +} 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) +}