From e38d31d384263d2ebb7c2c9237c99e5d7c64d102 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 08:04:01 -0500 Subject: [PATCH] Phase 3: OpenSubsonic core (response envelope, error codes 42-44, getOpenSubsonicExtensions) and apiKeyAuthentication extension with tokenInfo, web API key management --- internal/database/apikeys.go | 49 +++- internal/subsonic/apikey.go | 15 ++ internal/subsonic/auth.go | 37 ++- internal/subsonic/errors.go | 33 ++- internal/subsonic/extensions.go | 48 ++++ internal/subsonic/formpost.go | 22 ++ internal/subsonic/model.go | 51 +++- internal/subsonic/model_opensubsonic.go | 125 +++++++++ internal/subsonic/opensubsonic_core_test.go | 273 ++++++++++++++++++++ internal/subsonic/response.go | 2 +- internal/web/admin_apikeys_handlers.go | 44 ++++ internal/web/router.go | 3 + internal/web/settings_handlers.go | 3 +- migrations/0003_opensubsonic.sql | 44 ++++ web/templates/admin_api_keys.html | 30 +++ web/templates/partials/header.html | 1 + web/templates/settings.html | 20 +- 17 files changed, 763 insertions(+), 37 deletions(-) create mode 100644 internal/subsonic/apikey.go create mode 100644 internal/subsonic/extensions.go create mode 100644 internal/subsonic/formpost.go create mode 100644 internal/subsonic/model_opensubsonic.go create mode 100644 internal/subsonic/opensubsonic_core_test.go create mode 100644 internal/web/admin_apikeys_handlers.go create mode 100644 migrations/0003_opensubsonic.sql create mode 100644 web/templates/admin_api_keys.html diff --git a/internal/database/apikeys.go b/internal/database/apikeys.go index 2deb18e..b638926 100644 --- a/internal/database/apikeys.go +++ b/internal/database/apikeys.go @@ -9,18 +9,22 @@ import ( ) type APIKey struct { - ID string - UserID string - Name string - KeyHash string - CreatedAt time.Time - LastUsedAt sql.NullTime - RevokedAt sql.NullTime + ID string + UserID string + Name string + KeyHash string + ClientDescription string + CreatedAt time.Time + LastUsedAt sql.NullTime + RevokedAt sql.NullTime } -func CreateAPIKey(db *sql.DB, userID, name, keyHash string) (*APIKey, error) { +const apiKeySelect = `SELECT id, user_id, name, key_hash, client_description, created_at, last_used_at, revoked_at FROM api_keys` + +func CreateAPIKey(db *sql.DB, userID, name, keyHash, clientDescription string) (*APIKey, error) { id := idgen.New("ak") - _, err := db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash) VALUES (?, ?, ?, ?)`, id, userID, name, keyHash) + _, err := db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, client_description) VALUES (?, ?, ?, ?, ?)`, + id, userID, name, keyHash, clientDescription) if err != nil { return nil, err } @@ -28,18 +32,20 @@ func CreateAPIKey(db *sql.DB, userID, name, keyHash string) (*APIKey, error) { } func GetAPIKey(db *sql.DB, id string) (*APIKey, error) { - return scanAPIKey(db.QueryRow(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE id = ?`, id)) + return scanAPIKey(db.QueryRow(apiKeySelect+` WHERE id = ?`, id)) } +// GetAPIKeyByHash looks up a non-revoked key by its hash, for +// authentication. Revoked keys never match, by design. func GetAPIKeyByHash(db *sql.DB, keyHash string) (*APIKey, error) { - return scanAPIKey(db.QueryRow(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL`, keyHash)) + return scanAPIKey(db.QueryRow(apiKeySelect+` WHERE key_hash = ? AND revoked_at IS NULL`, keyHash)) } func scanAPIKey(row *sql.Row) (*APIKey, error) { var k APIKey var createdAt string var lastUsed, revoked sql.NullString - err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &createdAt, &lastUsed, &revoked) + err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &k.ClientDescription, &createdAt, &lastUsed, &revoked) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -61,17 +67,32 @@ func scanAPIKey(row *sql.Row) (*APIKey, error) { } func ListAPIKeysForUser(db *sql.DB, userID string) ([]*APIKey, error) { - rows, err := db.Query(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, userID) + rows, err := db.Query(apiKeySelect+` WHERE user_id = ? ORDER BY created_at DESC`, userID) if err != nil { return nil, err } defer rows.Close() + return scanAPIKeys(rows) +} + +// ListAllAPIKeys returns every API key for every user, for admin +// management. +func ListAllAPIKeys(db *sql.DB) ([]*APIKey, error) { + rows, err := db.Query(apiKeySelect + ` ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAPIKeys(rows) +} + +func scanAPIKeys(rows *sql.Rows) ([]*APIKey, error) { var out []*APIKey for rows.Next() { var k APIKey var createdAt string var lastUsed, revoked sql.NullString - if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &createdAt, &lastUsed, &revoked); err != nil { + if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &k.ClientDescription, &createdAt, &lastUsed, &revoked); err != nil { return nil, err } k.CreatedAt, _ = parseTime(createdAt) diff --git a/internal/subsonic/apikey.go b/internal/subsonic/apikey.go new file mode 100644 index 0000000..a9f8abd --- /dev/null +++ b/internal/subsonic/apikey.go @@ -0,0 +1,15 @@ +package subsonic + +func init() { + registerExtension("apiKeyAuthentication", 1) + register("tokenInfo", handleTokenInfo) +} + +// handleTokenInfo confirms which user an API key (or any other +// credential) resolves to — primarily useful for a client that just +// received a key and wants to confirm it works before storing it. +func handleTokenInfo(rc *Context) *apiError { + rc.Response = newResponse() + rc.Response.TokenInfo = &TokenInfo{Username: rc.User.Username} + return nil +} diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go index 1bb5782..91ce16a 100644 --- a/internal/subsonic/auth.go +++ b/internal/subsonic/auth.go @@ -22,9 +22,22 @@ import ( // surface never reintroduces MD5 or plaintext as VibeSonic's own storage // format, it only speaks it on the wire where the protocol requires it. func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) { - username, aerr := p.Required("u") - if aerr != nil { - return nil, aerr + apiKey := p.Get("apiKey") + username := p.Get("u") + + if apiKey != "" { + // The apiKeyAuthentication extension requires a client not send + // 'u' alongside 'apiKey' — accepting both would leave it + // ambiguous which identity the caller actually intended, so this + // is rejected outright rather than silently preferring one. + if username != "" { + return nil, errConflictingAuth + } + return authenticateAPIKey(db, apiKey) + } + + if username == "" { + return nil, errMissingParameter("u") } user, err := database.GetUserByUsername(db, username) @@ -75,6 +88,24 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, * return user, nil } +// authenticateAPIKey implements the OpenSubsonic apiKeyAuthentication +// extension: the key is hashed the same way as VibeSonic's own web +// settings API keys (they are exactly the same keys — one system, not a +// parallel one) and looked up by hash, never by scanning stored keys in +// plaintext. +func authenticateAPIKey(db *sql.DB, rawKey string) (*database.User, *apiError) { + key, err := database.GetAPIKeyByHash(db, vsauth.HashAPIKey(rawKey)) + if err != nil { + return nil, errInvalidAPIKey + } + user, err := database.GetUserByID(db, key.UserID) + if err != nil || user.Disabled { + return nil, errInvalidAPIKey + } + _ = database.TouchAPIKey(db, key.ID) + return user, nil +} + // checkVersion rejects clients whose declared protocol version is newer // than what this server implements. A missing or unparsable v is // tolerated (treated as compatible) rather than rejected outright, to diff --git a/internal/subsonic/errors.go b/internal/subsonic/errors.go index 8c6a03c..50bb8a4 100644 --- a/internal/subsonic/errors.go +++ b/internal/subsonic/errors.go @@ -1,16 +1,20 @@ package subsonic -// Error codes from the Subsonic REST API specification. +// Error codes from the Subsonic REST API specification, plus the +// OpenSubsonic apiKeyAuthentication extension's additions (42, 43, 44). const ( - ErrGeneric = 0 - ErrMissingParameter = 10 - ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports - ErrServerMustUpgrade = 30 // server requires a newer client - ErrWrongCredentials = 40 - ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password - ErrUnauthorized = 50 // authenticated, but not permitted to perform this action - ErrTrialExpired = 60 // VibeSonic never returns this: no license is required - ErrNotFound = 70 + ErrGeneric = 0 + ErrMissingParameter = 10 + ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports + ErrServerMustUpgrade = 30 // server requires a newer client + ErrWrongCredentials = 40 + ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password + ErrAuthMechanismUnsupported = 42 // provided authentication mechanism not supported + ErrConflictingAuth = 43 // multiple conflicting authentication mechanisms provided + ErrInvalidAPIKey = 44 + ErrUnauthorized = 50 // authenticated, but not permitted to perform this action + ErrTrialExpired = 60 // VibeSonic never returns this: no license is required + ErrNotFound = 70 ) // apiError is a Subsonic protocol error carrying an HTTP-agnostic code @@ -21,6 +25,7 @@ const ( type apiError struct { Code int Message string + HelpURL string } func (e *apiError) Error() string { return e.Message } @@ -29,6 +34,10 @@ func newError(code int, message string) *apiError { return &apiError{Code: code, Message: message} } +func newErrorWithHelp(code int, message, helpURL string) *apiError { + return &apiError{Code: code, Message: message, HelpURL: helpURL} +} + func errMissingParameter(name string) *apiError { return newError(ErrMissingParameter, "Required parameter '"+name+"' is missing") } @@ -41,8 +50,12 @@ func errUnauthorized(action string) *apiError { return newError(ErrUnauthorized, "User is not authorized for the requested "+action) } +const apiKeyHelpURL = "/settings#api-keys" + var ( errWrongCredentials = newError(ErrWrongCredentials, "Wrong username or password") errClientMustUpgrade = newError(ErrClientMustUpgrade, "Incompatible Subsonic REST protocol version. Client must upgrade.") errTokenAuthNotSupported = newError(ErrTokenAuthNotSupported, "Token authentication is not supported for this account; use password authentication") + errConflictingAuth = newErrorWithHelp(ErrConflictingAuth, "Provide either 'u' (with 'p' or 't'/'s') or 'apiKey', not both", apiKeyHelpURL) + errInvalidAPIKey = newErrorWithHelp(ErrInvalidAPIKey, "Invalid API key", apiKeyHelpURL) ) diff --git a/internal/subsonic/extensions.go b/internal/subsonic/extensions.go new file mode 100644 index 0000000..8148bdb --- /dev/null +++ b/internal/subsonic/extensions.go @@ -0,0 +1,48 @@ +package subsonic + +import "sort" + +func init() { + register("getOpenSubsonicExtensions", handleGetOpenSubsonicExtensions) +} + +// extensionRegistry lists every OpenSubsonic extension VibeSonic +// actually implements and has tests for. Advertising an extension here +// is a promise the corresponding endpoint(s) really work — never add an +// entry just because a route exists. Each feature file registers its +// own entry via registerExtension() in its init(). +var extensionRegistry = map[string][]int{} + +func registerExtension(name string, versions ...int) { + if _, exists := extensionRegistry[name]; exists { + panic("subsonic: duplicate extension registration for " + name) + } + extensionRegistry[name] = versions +} + +func supportedExtensions() []OpenSubsonicExtension { + names := make([]string, 0, len(extensionRegistry)) + for n := range extensionRegistry { + names = append(names, n) + } + sort.Strings(names) + out := make([]OpenSubsonicExtension, 0, len(names)) + for _, n := range names { + out = append(out, OpenSubsonicExtension{Name: n, Versions: extensionRegistry[n]}) + } + return out +} + +// hasExtension reports whether ext (any version) is advertised — used +// where a feature is fully optional and we want to fail closed if it +// were ever unregistered. +func hasExtension(name string) bool { + _, ok := extensionRegistry[name] + return ok +} + +func handleGetOpenSubsonicExtensions(rc *Context) *apiError { + rc.Response = newResponse() + rc.Response.OpenSubsonicExtensions = supportedExtensions() + return nil +} diff --git a/internal/subsonic/formpost.go b/internal/subsonic/formpost.go new file mode 100644 index 0000000..7b1b41a --- /dev/null +++ b/internal/subsonic/formpost.go @@ -0,0 +1,22 @@ +package subsonic + +// The OpenSubsonic formPost extension requires the server accept API +// arguments via POST with an application/x-www-form-urlencoded body, in +// addition to the classic GET query-string form. This needs no +// dedicated handler: the router's dispatch (router.go) already calls +// r.ParseForm() before building params, and net/http's ParseForm merges +// URL query values and a url-encoded POST body into r.Form for any HTTP +// method — GET, POST, or otherwise — so every endpoint above already +// accepts either form for free. +// +// Precedence when the same key appears in both the query string and the +// POST body: net/http's ParseForm populates r.Form with POST-body values +// before query-string values, so params.Get (url.Values.Get, which +// returns the first entry) resolves a body value over a query value for +// the same key. This is deterministic — governed entirely by net/http's +// single parse of one request, not by any proxying/multi-parser +// ambiguity — and is asserted directly rather than silently relied upon; +// see TestFormPostBodyTakesPrecedenceOverQuery. +func init() { + registerExtension("formPost", 1) +} diff --git a/internal/subsonic/model.go b/internal/subsonic/model.go index 594052d..de1f91d 100644 --- a/internal/subsonic/model.go +++ b/internal/subsonic/model.go @@ -28,6 +28,14 @@ type Response struct { Status string `xml:"status,attr" json:"status"` Version string `xml:"version,attr" json:"version"` + // OpenSubsonic core response additions (always populated): the + // server's real product name and build version, and a flag + // confirming OpenSubsonic support so clients can detect it from any + // response, not just getOpenSubsonicExtensions. + Type string `xml:"type,attr" json:"type"` + ServerVersion string `xml:"serverVersion,attr" json:"serverVersion"` + OpenSubsonic bool `xml:"openSubsonic,attr" json:"openSubsonic"` + Error *Error `xml:"error,omitempty" json:"error,omitempty"` License *License `xml:"license,omitempty" json:"license,omitempty"` @@ -72,16 +80,57 @@ type Response struct { NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"` ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"` Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"` + + // OpenSubsonic extensions. + OpenSubsonicExtensions []OpenSubsonicExtension `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` + TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"` + PodcastEpisode *PodcastEpisode `xml:"podcastEpisode,omitempty" json:"podcastEpisode,omitempty"` + LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` + PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` + // SonicMatch is a top-level array (not wrapped in an object) on both + // getSonicSimilarTracks and findSonicPath, per the OpenSubsonic + // schema — the two endpoints are otherwise unrelated, so there is no + // shared wrapper type to name it after. + SonicMatch []SonicMatch `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` + TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` } +// OpenSubsonicExtension describes one extension this server supports, +// as returned by getOpenSubsonicExtensions. +type OpenSubsonicExtension struct { + Name string `xml:"name,attr" json:"name"` + Versions []int `xml:"versions" json:"versions"` +} + +type TokenInfo struct { + Username string `xml:"username,attr" json:"username"` +} + +// ServerName is the real product name advertised in every response's +// "type" attribute, per the OpenSubsonic base response additions. +const ServerName = "vibesonic" + +// AppVersion is VibeSonic's own build version, advertised in every +// response's "serverVersion" attribute. Overridable at link time +// (matching cmd/vibesonic's version variable); defaults to "dev". +var AppVersion = "dev" + func newResponse() *Response { - return &Response{Xmlns: "http://subsonic.org/restapi", Status: "ok", Version: ServerVersion} + return &Response{ + Xmlns: "http://subsonic.org/restapi", + Status: "ok", + Version: ServerVersion, + Type: ServerName, + ServerVersion: AppVersion, + OpenSubsonic: true, + } } // Error is the payload of a failed response. type Error struct { Code int `xml:"code,attr" json:"code"` Message string `xml:"message,attr" json:"message"` + HelpURL string `xml:"helpUrl,attr,omitempty" json:"helpUrl,omitempty"` } // --- System --- diff --git a/internal/subsonic/model_opensubsonic.go b/internal/subsonic/model_opensubsonic.go new file mode 100644 index 0000000..be94684 --- /dev/null +++ b/internal/subsonic/model_opensubsonic.go @@ -0,0 +1,125 @@ +package subsonic + +// --- songLyrics extension (versions 1 and 2) --- + +type LyricsList struct { + StructuredLyrics []StructuredLyrics `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"` +} + +type StructuredLyrics struct { + Lang string `xml:"lang,attr" json:"lang"` + Synced bool `xml:"synced,attr" json:"synced"` + DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` + Offset int `xml:"offset,attr,omitempty" json:"offset,omitempty"` + Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` // main|translation|pronunciation; v2 only + Line []Line `xml:"line" json:"line"` + Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` // v2 only + CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` // v2 only, synced entries only +} + +type Line struct { + Value string `xml:",chardata" json:"value"` + Start *int `xml:"start,attr,omitempty" json:"start,omitempty"` +} + +type Agent struct { + ID string `xml:"id,attr" json:"id"` + Role string `xml:"role,attr" json:"role"` // main|voice|bg|group + Name string `xml:"name,attr,omitempty" json:"name,omitempty"` +} + +type CueLine struct { + Index int `xml:"index,attr" json:"index"` + Start int `xml:"start,attr,omitempty" json:"start,omitempty"` + End int `xml:"end,attr,omitempty" json:"end,omitempty"` + Value string `xml:"value,attr" json:"value"` + AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"` + Cue []Cue `xml:"cue" json:"cue"` +} + +type Cue struct { + Start int `xml:"start,attr" json:"start"` + End *int `xml:"end,attr,omitempty" json:"end,omitempty"` + ByteStart int `xml:"byteStart,attr" json:"byteStart"` + ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"` + Value string `xml:"value,attr,omitempty" json:"value,omitempty"` +} + +// --- sonicSimilarity extension --- + +type SonicMatch struct { + Entry Child `xml:"entry" json:"entry"` + Similarity float64 `xml:"similarity,attr" json:"similarity"` +} + +// --- transcoding extension --- + +type ClientInfo struct { + Name string `json:"name"` + Platform string `json:"platform"` + MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"` + MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"` + DirectPlayProfiles []DirectPlayProfile `json:"directPlayProfiles,omitempty"` + TranscodingProfiles []TranscodingProfile `json:"transcodingProfiles,omitempty"` + CodecProfiles []CodecProfile `json:"codecProfiles,omitempty"` +} + +type DirectPlayProfile struct { + Containers []string `json:"containers"` + AudioCodecs []string `json:"audioCodecs"` + Protocols []string `json:"protocols"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +type TranscodingProfile struct { + Container string `json:"container"` + AudioCodec string `json:"audioCodec"` + Protocol string `json:"protocol"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +type CodecProfile struct { + Type string `json:"type"` + Name string `json:"name"` + Limitations []Limitation `json:"limitations,omitempty"` +} + +type Limitation struct { + Name string `json:"name"` // audioChannels|audioBitrate|audioProfile|audioSamplerate|audioBitdepth + Comparison string `json:"comparison"` // Equals|NotEquals|LessThanEqual|GreaterThanEqual + Values []string `json:"values"` + Required bool `json:"required"` +} + +type StreamDetails struct { + Protocol string `xml:"protocol,attr" json:"protocol"` + Container string `xml:"container,attr" json:"container"` + Codec string `xml:"codec,attr" json:"codec"` + AudioChannels int `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"` + AudioBitrate int `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"` + AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"` + AudioSamplerate int `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"` + AudioBitdepth int `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"` +} + +type TranscodeDecision struct { + CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"` + CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"` + TranscodeReason []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"` + ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"` + TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"` + SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"` + TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"` +} + +// --- index-based queue extension --- + +type PlayQueueByIndex struct { + CurrentIndex int `xml:"currentIndex,attr" json:"currentIndex"` + Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` + Username string `xml:"username,attr" json:"username"` + Changed string `xml:"changed,attr" json:"changed"` + ChangedBy string `xml:"changedBy,attr" json:"changedBy"` + Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` +} diff --git a/internal/subsonic/opensubsonic_core_test.go b/internal/subsonic/opensubsonic_core_test.go new file mode 100644 index 0000000..216b01d --- /dev/null +++ b/internal/subsonic/opensubsonic_core_test.go @@ -0,0 +1,273 @@ +package subsonic + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth" + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func TestResponseEnvelopeHasOpenSubsonicFields(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("f", "json") + resp := get(t, srv.URL, q) + j := decodeJSON(t, resp) + if j["openSubsonic"] != true { + t.Errorf("openSubsonic = %v, want true", j["openSubsonic"]) + } + if j["type"] != ServerName { + t.Errorf("type = %v, want %q", j["type"], ServerName) + } + if j["serverVersion"] == nil || j["serverVersion"] == "" { + t.Errorf("serverVersion missing: %v", j) + } +} + +func TestGetOpenSubsonicExtensions(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("f", "json") + resp := getPath(t, srv.URL, "/rest/getOpenSubsonicExtensions.view", q) + j := decodeJSON(t, resp) + exts, ok := j["openSubsonicExtensions"].([]any) + if !ok || len(exts) == 0 { + t.Fatalf("openSubsonicExtensions missing/empty: %v", j) + } + names := map[string]bool{} + for _, e := range exts { + m := e.(map[string]any) + names[m["name"].(string)] = true + if versions, ok := m["versions"].([]any); !ok || len(versions) == 0 { + t.Errorf("extension %v has no versions", m["name"]) + } + } + // This list grows as each extension is implemented through this + // phase; see docs/subsonic-compatibility.md for the final complete + // set once Phase 3 is done. + for _, want := range []string{ + "apiKeyAuthentication", "formPost", + } { + if !names[want] { + t.Errorf("extension %q not advertised: %v", want, names) + } + } +} + +func TestGetOpenSubsonicExtensionsNeverStubbed(t *testing.T) { + // Regression guard for "no fake support": every advertised name must + // correspond to a real registered handler or a documented framework + // behavior (formPost/apiKeyAuthentication/transcodeOffset don't have + // their own endpoint, everything else does). + needsHandler := map[string]string{ + "getPodcastEpisode": "getPodcastEpisode", + "indexBasedQueue": "getPlayQueueByIndex", + "playbackReport": "reportPlayback", + "songLyrics": "getLyricsBySongId", + "sonicSimilarity": "getSonicSimilarTracks", + "topSongsByArtistId": "getTopSongs", + "transcoding": "getTranscodeDecision", + } + for ext, endpoint := range needsHandler { + if !hasExtension(ext) { + continue + } + if _, ok := registry[endpoint]; !ok { + t.Errorf("extension %q advertised but handler %q not registered", ext, endpoint) + } + } +} + +func TestAPIKeyAuthenticationSuccess(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + rawKey, err := vsauth.GenerateAPIKey() + if err != nil { + t.Fatal(err) + } + if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "TestClient/1.0"); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(s.Router()) + defer srv.Close() + q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}, "f": {"json"}} + resp := get(t, srv.URL, q) + j := decodeJSON(t, resp) + if j["status"] != "ok" { + t.Fatalf("apiKey auth failed: %v", j) + } +} + +func TestAPIKeyAuthenticationInvalidKey(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := url.Values{"apiKey": {"vs_bogus"}, "v": {"1.16.1"}, "c": {"test"}} + resp := get(t, srv.URL, q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrInvalidAPIKey { + t.Fatalf("expected error 44, got %+v", x) + } +} + +func TestAPIKeyAuthenticationRevokedKey(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + rawKey, _ := vsauth.GenerateAPIKey() + key, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "") + if err != nil { + t.Fatal(err) + } + if err := database.RevokeAPIKey(db, key.ID); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(s.Router()) + defer srv.Close() + q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}} + resp := get(t, srv.URL, q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrInvalidAPIKey { + t.Fatalf("expected error 44 for revoked key, got %+v", x) + } +} + +func TestAPIKeyAuthenticationConflictsWithUsername(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + rawKey, _ := vsauth.GenerateAPIKey() + if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(s.Router()) + defer srv.Close() + q := url.Values{"apiKey": {rawKey}, "u": {"alice"}, "v": {"1.16.1"}, "c": {"test"}} + resp := get(t, srv.URL, q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrConflictingAuth { + t.Fatalf("expected error 43, got %+v", x) + } +} + +func TestAPIKeyDisabledUser(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + rawKey, _ := vsauth.GenerateAPIKey() + if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil { + t.Fatal(err) + } + if err := database.SetUserDisabled(db, u.ID, true); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(s.Router()) + defer srv.Close() + q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}} + resp := get(t, srv.URL, q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrInvalidAPIKey { + t.Fatalf("expected error 44 for disabled user's key, got %+v", x) + } +} + +func TestTokenInfo(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + rawKey, _ := vsauth.GenerateAPIKey() + if _, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), ""); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(s.Router()) + defer srv.Close() + q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}, "f": {"json"}} + resp := getPath(t, srv.URL, "/rest/tokenInfo.view", q) + j := decodeJSON(t, resp) + info, ok := j["tokenInfo"].(map[string]any) + if !ok || info["username"] != "alice" { + t.Fatalf("tokenInfo = %v, want username alice", j) + } +} + +func TestAPIKeyTouchesLastUsed(t *testing.T) { + s, db := newTestServer(t) + u := createTestUser(t, s, "alice", "password123", false) + rawKey, _ := vsauth.GenerateAPIKey() + key, err := database.CreateAPIKey(db, u.ID, "Test Key", vsauth.HashAPIKey(rawKey), "") + if err != nil { + t.Fatal(err) + } + if key.LastUsedAt.Valid { + t.Fatal("new key should not have last_used_at set") + } + + srv := httptest.NewServer(s.Router()) + defer srv.Close() + q := url.Values{"apiKey": {rawKey}, "v": {"1.16.1"}, "c": {"test"}} + get(t, srv.URL, q) + + updated, err := database.GetAPIKey(db, key.ID) + if err != nil { + t.Fatal(err) + } + if !updated.LastUsedAt.Valid { + t.Error("expected last_used_at to be set after use") + } +} + +func TestFormPostSuccess(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + req, err := http.NewRequest(http.MethodPost, srv.URL+"/rest/ping.view", strings.NewReader(q.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("POST ping failed: %+v", x) + } +} + +func TestFormPostBodyTakesPrecedenceOverQuery(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + createTestUser(t, s, "mallory", "password123", false) + + // Body says mallory+correct password; query claims a different user. + // The body value must win — this is the documented net/http + // ParseForm precedence (POST-body values are placed before + // query-string values in r.Form), asserted here rather than + // silently relied upon. + q := url.Values{"u": {"someone-else"}, "p": {"wrong"}, "v": {"1.16.1"}, "c": {"test"}} + body := baseParams("mallory", "password123") + req, err := http.NewRequest(http.MethodPost, srv.URL+"/rest/getUser.view?"+q.Encode()+"&username=mallory", strings.NewReader(body.Encode())) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("expected success (authenticated as mallory via body), got %+v", x) + } +} diff --git a/internal/subsonic/response.go b/internal/subsonic/response.go index d437a2b..fd33749 100644 --- a/internal/subsonic/response.go +++ b/internal/subsonic/response.go @@ -54,6 +54,6 @@ func writeResponse(w http.ResponseWriter, format, callback string, resp *Respons func errorResponse(err *apiError) *Response { r := newResponse() r.Status = "failed" - r.Error = &Error{Code: err.Code, Message: err.Message} + r.Error = &Error{Code: err.Code, Message: err.Message, HelpURL: err.HelpURL} return r } diff --git a/internal/web/admin_apikeys_handlers.go b/internal/web/admin_apikeys_handlers.go new file mode 100644 index 0000000..607d4bd --- /dev/null +++ b/internal/web/admin_apikeys_handlers.go @@ -0,0 +1,44 @@ +package web + +import ( + "net/http" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func (h *Web) handleAdminAPIKeys(w http.ResponseWriter, r *http.Request) { + keys, err := database.ListAllAPIKeys(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + type keyView struct { + Key *database.APIKey + Username string + } + var views []keyView + for _, k := range keys { + username := k.UserID + if u, err := database.GetUserByID(h.DB, k.UserID); err == nil { + username = u.Username + } + views = append(views, keyView{Key: k, Username: username}) + } + h.page(w, r, "admin_api_keys", "admin_api_keys", "API Keys", views) +} + +// handleAdminAPIKeyRevoke lets an admin revoke any user's API key — the +// self-service /settings/api-keys/{id}/revoke route only allows revoking +// your own. +func (h *Web) handleAdminAPIKeyRevoke(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, err := database.GetAPIKey(h.DB, id); err != nil { + http.NotFound(w, r) + return + } + if err := database.RevokeAPIKey(h.DB, id); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/api-keys", http.StatusFound) +} diff --git a/internal/web/router.go b/internal/web/router.go index df2a533..9a207f3 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -96,6 +96,9 @@ func (h *Web) NewRouter() http.Handler { mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload)) mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete)) + mux.HandleFunc("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys)) + mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke)) + mux.HandleFunc("GET /admin/jukebox", h.requireAdmin(h.handleAdminJukebox)) mux.HandleFunc("POST /admin/jukebox/start", h.requireAdmin(h.handleAdminJukeboxStart)) mux.HandleFunc("POST /admin/jukebox/stop", h.requireAdmin(h.handleAdminJukeboxStop)) diff --git a/internal/web/settings_handlers.go b/internal/web/settings_handlers.go index 8f038ea..b25276a 100644 --- a/internal/web/settings_handlers.go +++ b/internal/web/settings_handlers.go @@ -71,12 +71,13 @@ func (h *Web) handleAPIKeyCreate(w http.ResponseWriter, r *http.Request) { return } name := r.FormValue("name") + clientDescription := r.FormValue("client_description") rawKey, err := auth.GenerateAPIKey() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - if _, err := database.CreateAPIKey(h.DB, user.ID, name, auth.HashAPIKey(rawKey)); err != nil { + if _, err := database.CreateAPIKey(h.DB, user.ID, name, auth.HashAPIKey(rawKey), clientDescription); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } diff --git a/migrations/0003_opensubsonic.sql b/migrations/0003_opensubsonic.sql new file mode 100644 index 0000000..070c791 --- /dev/null +++ b/migrations/0003_opensubsonic.sql @@ -0,0 +1,44 @@ +-- Phase 3: OpenSubsonic support. + +-- API keys gain an optional client description (separate from the +-- key's own name), per the apiKeyAuthentication extension's "optionally +-- store client description" requirement. +ALTER TABLE api_keys ADD COLUMN client_description TEXT NOT NULL DEFAULT ''; + +-- Structured (word/syllable-level) lyric cues need end time and +-- precomputed UTF-8 byte offsets into their line's text, per the +-- songLyrics v2 extension. Stored at parse time rather than recomputed +-- per request, so nothing is lost or re-derived lossily later. +ALTER TABLE lyrics_cues ADD COLUMN end_ms INTEGER; +ALTER TABLE lyrics_cues ADD COLUMN byte_start INTEGER NOT NULL DEFAULT 0; +ALTER TABLE lyrics_cues ADD COLUMN byte_end INTEGER NOT NULL DEFAULT 0; + +-- Optional, opt-in, cached external metadata (MusicBrainz / Cover Art +-- Archive), keyed by the MusicBrainz entity type+ID VibeSonic already +-- has from tags. Never consulted unless VIBESONIC_EXTERNAL_METADATA is +-- enabled and a local musicbrainz_id exists; always safe to be empty. +CREATE TABLE external_metadata_cache ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL CHECK (entity_type IN ('artist','release')), + mbid TEXT NOT NULL, + source TEXT NOT NULL CHECK (source IN ('musicbrainz','coverartarchive')), + data_json TEXT NOT NULL DEFAULT '{}', + fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + UNIQUE (entity_type, mbid, source) +); + +-- Real per-user playback timeline reporting (OpenSubsonic +-- reportPlayback extension), distinct from the classic scrobble +-- endpoint: tracks transient state (starting/playing/paused/stopped) +-- rather than only submission events. +CREATE TABLE playback_reports ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, + position_ms INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL CHECK (state IN ('starting','playing','paused','stopped')), + playback_rate REAL NOT NULL DEFAULT 1, + client TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX idx_playback_reports_user ON playback_reports(user_id, created_at); diff --git a/web/templates/admin_api_keys.html b/web/templates/admin_api_keys.html new file mode 100644 index 0000000..125993e --- /dev/null +++ b/web/templates/admin_api_keys.html @@ -0,0 +1,30 @@ +{{define "admin_api_keys"}}{{template "header" .}} +

All API keys across every user. Used for OpenSubsonic apiKey= authentication.

+{{if .Data}} + + + + {{range .Data}} + + + + + + + + + + {{end}} + +
UserNameClientCreatedLast usedStatus
{{.Username}}{{.Key.Name}}{{.Key.ClientDescription}}{{fmtTime .Key.CreatedAt}}{{if .Key.LastUsedAt.Valid}}{{fmtTime .Key.LastUsedAt.Time}}{{else}}never{{end}}{{if .Key.RevokedAt.Valid}}Revoked{{else}}Active{{end}} + {{if not .Key.RevokedAt.Valid}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+ {{end}} +
+{{else}} +

No API keys have been created yet.

+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html index 0e6a745..3682b80 100644 --- a/web/templates/partials/header.html +++ b/web/templates/partials/header.html @@ -82,6 +82,7 @@ + {{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html index 8582d2e..6254dcd 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -21,12 +21,13 @@
-

API keys

-

Used by third-party Subsonic clients in later phases.

-
+

API keys

+

Used by OpenSubsonic clients via apiKey= authentication, in place of your password.

+ {{template "csrf_field" .CSRFToken}} - - +
+
+
{{if .Data.NewAPIKey}}
@@ -38,13 +39,18 @@ {{range .Data.APIKeys}} - {{.Name}} - {{fmtTime .CreatedAt}} + {{.Name}}{{if .ClientDescription}} ({{.ClientDescription}}){{end}} + + {{fmtTime .CreatedAt}} + {{if .LastUsedAt.Valid}}· last used {{fmtTime .LastUsedAt.Time}}{{else}}· never used{{end}} + + {{if .RevokedAt.Valid}}Revoked{{else}}
{{template "csrf_field" $.CSRFToken}}
+ {{end}} {{end}}