Phase 3: OpenSubsonic core (response envelope, error codes 42-44, getOpenSubsonicExtensions) and apiKeyAuthentication extension with tokenInfo, web API key management

This commit is contained in:
2026-08-23 08:04:01 -05:00
parent 3b4883780b
commit e38d31d384
17 changed files with 763 additions and 37 deletions
+35 -14
View File
@@ -9,18 +9,22 @@ import (
) )
type APIKey struct { type APIKey struct {
ID string ID string
UserID string UserID string
Name string Name string
KeyHash string KeyHash string
CreatedAt time.Time ClientDescription string
LastUsedAt sql.NullTime CreatedAt time.Time
RevokedAt sql.NullTime 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") 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 { if err != nil {
return nil, err 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) { 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) { 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) { func scanAPIKey(row *sql.Row) (*APIKey, error) {
var k APIKey var k APIKey
var createdAt string var createdAt string
var lastUsed, revoked sql.NullString 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -61,17 +67,32 @@ func scanAPIKey(row *sql.Row) (*APIKey, error) {
} }
func ListAPIKeysForUser(db *sql.DB, userID string) ([]*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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() 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 var out []*APIKey
for rows.Next() { for rows.Next() {
var k APIKey var k APIKey
var createdAt string var createdAt string
var lastUsed, revoked sql.NullString 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 return nil, err
} }
k.CreatedAt, _ = parseTime(createdAt) k.CreatedAt, _ = parseTime(createdAt)
+15
View File
@@ -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
}
+34 -3
View File
@@ -22,9 +22,22 @@ import (
// surface never reintroduces MD5 or plaintext as VibeSonic's own storage // surface never reintroduces MD5 or plaintext as VibeSonic's own storage
// format, it only speaks it on the wire where the protocol requires it. // 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) { func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) {
username, aerr := p.Required("u") apiKey := p.Get("apiKey")
if aerr != nil { username := p.Get("u")
return nil, aerr
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) user, err := database.GetUserByUsername(db, username)
@@ -75,6 +88,24 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *
return user, nil 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 // checkVersion rejects clients whose declared protocol version is newer
// than what this server implements. A missing or unparsable v is // than what this server implements. A missing or unparsable v is
// tolerated (treated as compatible) rather than rejected outright, to // tolerated (treated as compatible) rather than rejected outright, to
+23 -10
View File
@@ -1,16 +1,20 @@
package subsonic 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 ( const (
ErrGeneric = 0 ErrGeneric = 0
ErrMissingParameter = 10 ErrMissingParameter = 10
ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports
ErrServerMustUpgrade = 30 // server requires a newer client ErrServerMustUpgrade = 30 // server requires a newer client
ErrWrongCredentials = 40 ErrWrongCredentials = 40
ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password
ErrUnauthorized = 50 // authenticated, but not permitted to perform this action ErrAuthMechanismUnsupported = 42 // provided authentication mechanism not supported
ErrTrialExpired = 60 // VibeSonic never returns this: no license is required ErrConflictingAuth = 43 // multiple conflicting authentication mechanisms provided
ErrNotFound = 70 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 // apiError is a Subsonic protocol error carrying an HTTP-agnostic code
@@ -21,6 +25,7 @@ const (
type apiError struct { type apiError struct {
Code int Code int
Message string Message string
HelpURL string
} }
func (e *apiError) Error() string { return e.Message } 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} 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 { func errMissingParameter(name string) *apiError {
return newError(ErrMissingParameter, "Required parameter '"+name+"' is missing") 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) return newError(ErrUnauthorized, "User is not authorized for the requested "+action)
} }
const apiKeyHelpURL = "/settings#api-keys"
var ( var (
errWrongCredentials = newError(ErrWrongCredentials, "Wrong username or password") errWrongCredentials = newError(ErrWrongCredentials, "Wrong username or password")
errClientMustUpgrade = newError(ErrClientMustUpgrade, "Incompatible Subsonic REST protocol version. Client must upgrade.") 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") 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)
) )
+48
View File
@@ -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
}
+22
View File
@@ -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)
}
+50 -1
View File
@@ -28,6 +28,14 @@ type Response struct {
Status string `xml:"status,attr" json:"status"` Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"` 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"` Error *Error `xml:"error,omitempty" json:"error,omitempty"`
License *License `xml:"license,omitempty" json:"license,omitempty"` License *License `xml:"license,omitempty" json:"license,omitempty"`
@@ -72,16 +80,57 @@ type Response struct {
NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"` NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"` ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,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 { 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. // Error is the payload of a failed response.
type Error struct { type Error struct {
Code int `xml:"code,attr" json:"code"` Code int `xml:"code,attr" json:"code"`
Message string `xml:"message,attr" json:"message"` Message string `xml:"message,attr" json:"message"`
HelpURL string `xml:"helpUrl,attr,omitempty" json:"helpUrl,omitempty"`
} }
// --- System --- // --- System ---
+125
View File
@@ -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"`
}
+273
View File
@@ -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)
}
}
+1 -1
View File
@@ -54,6 +54,6 @@ func writeResponse(w http.ResponseWriter, format, callback string, resp *Respons
func errorResponse(err *apiError) *Response { func errorResponse(err *apiError) *Response {
r := newResponse() r := newResponse()
r.Status = "failed" 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 return r
} }
+44
View File
@@ -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)
}
+3
View File
@@ -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}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload))
mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete)) 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("GET /admin/jukebox", h.requireAdmin(h.handleAdminJukebox))
mux.HandleFunc("POST /admin/jukebox/start", h.requireAdmin(h.handleAdminJukeboxStart)) mux.HandleFunc("POST /admin/jukebox/start", h.requireAdmin(h.handleAdminJukeboxStart))
mux.HandleFunc("POST /admin/jukebox/stop", h.requireAdmin(h.handleAdminJukeboxStop)) mux.HandleFunc("POST /admin/jukebox/stop", h.requireAdmin(h.handleAdminJukeboxStop))
+2 -1
View File
@@ -71,12 +71,13 @@ func (h *Web) handleAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
name := r.FormValue("name") name := r.FormValue("name")
clientDescription := r.FormValue("client_description")
rawKey, err := auth.GenerateAPIKey() rawKey, err := auth.GenerateAPIKey()
if err != nil { if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return 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) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
+44
View File
@@ -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);
+30
View File
@@ -0,0 +1,30 @@
{{define "admin_api_keys"}}{{template "header" .}}
<p class="text-secondary small">All API keys across every user. Used for OpenSubsonic <code>apiKey=</code> authentication.</p>
{{if .Data}}
<table class="table vs-table table-sm">
<thead><tr><th>User</th><th>Name</th><th>Client</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead>
<tbody>
{{range .Data}}
<tr>
<td>{{.Username}}</td>
<td>{{.Key.Name}}</td>
<td class="text-secondary small">{{.Key.ClientDescription}}</td>
<td class="text-secondary small">{{fmtTime .Key.CreatedAt}}</td>
<td class="text-secondary small">{{if .Key.LastUsedAt.Valid}}{{fmtTime .Key.LastUsedAt.Time}}{{else}}never{{end}}</td>
<td>{{if .Key.RevokedAt.Valid}}<span class="text-danger">Revoked</span>{{else}}<span class="text-success">Active</span>{{end}}</td>
<td class="text-end">
{{if not .Key.RevokedAt.Valid}}
<form method="post" action="/admin/api-keys/{{.Key.ID}}/revoke">
{{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-secondary">No API keys have been created yet.</p>
{{end}}
{{template "footer" .}}{{end}}
+1
View File
@@ -82,6 +82,7 @@
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_settings"}}active{{end}}" href="/admin/settings"><i class="bi bi-sliders"></i> System Settings</a></li> <li class="nav-item"><a class="nav-link {{if eq .Active "admin_settings"}}active{{end}}" href="/admin/settings"><i class="bi bi-sliders"></i> System Settings</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_podcasts"}}active{{end}}" href="/admin/podcasts"><i class="bi bi-broadcast"></i> Podcasts</a></li> <li class="nav-item"><a class="nav-link {{if eq .Active "admin_podcasts"}}active{{end}}" href="/admin/podcasts"><i class="bi bi-broadcast"></i> Podcasts</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_jukebox"}}active{{end}}" href="/admin/jukebox"><i class="bi bi-cpu"></i> Jukebox</a></li> <li class="nav-item"><a class="nav-link {{if eq .Active "admin_jukebox"}}active{{end}}" href="/admin/jukebox"><i class="bi bi-cpu"></i> Jukebox</a></li>
<li class="nav-item"><a class="nav-link {{if eq .Active "admin_api_keys"}}active{{end}}" href="/admin/api-keys"><i class="bi bi-key"></i> API Keys</a></li>
{{end}} {{end}}
</ul> </ul>
<div class="mt-auto px-3 py-2 text-secondary small"> <div class="mt-auto px-3 py-2 text-secondary small">
+13 -7
View File
@@ -21,12 +21,13 @@
<div class="card mt-3"> <div class="card mt-3">
<div class="card-body"> <div class="card-body">
<h2 class="h6">API keys</h2> <h2 class="h6" id="api-keys">API keys</h2>
<p class="text-secondary small">Used by third-party Subsonic clients in later phases.</p> <p class="text-secondary small">Used by OpenSubsonic clients via <code>apiKey=</code> authentication, in place of your password.</p>
<form method="post" action="/settings/api-keys" class="d-flex gap-2 mb-3"> <form method="post" action="/settings/api-keys" class="row g-2 mb-3">
{{template "csrf_field" .CSRFToken}} {{template "csrf_field" .CSRFToken}}
<input type="text" name="name" class="form-control form-control-sm" placeholder="Key name"> <div class="col-5"><input type="text" name="name" class="form-control form-control-sm" placeholder="Key name" required></div>
<button type="submit" class="btn btn-outline-primary btn-sm text-nowrap">New key</button> <div class="col-5"><input type="text" name="client_description" class="form-control form-control-sm" placeholder="Client (optional)"></div>
<div class="col-2"><button type="submit" class="btn btn-outline-primary btn-sm w-100">New key</button></div>
</form> </form>
{{if .Data.NewAPIKey}} {{if .Data.NewAPIKey}}
<div class="alert alert-warning small"> <div class="alert alert-warning small">
@@ -38,13 +39,18 @@
<tbody> <tbody>
{{range .Data.APIKeys}} {{range .Data.APIKeys}}
<tr> <tr>
<td>{{.Name}}</td> <td>{{.Name}}{{if .ClientDescription}} <span class="text-secondary small">({{.ClientDescription}})</span>{{end}}</td>
<td class="text-secondary small">{{fmtTime .CreatedAt}}</td> <td class="text-secondary small">
{{fmtTime .CreatedAt}}
{{if .LastUsedAt.Valid}}· last used {{fmtTime .LastUsedAt.Time}}{{else}}· never used{{end}}
</td>
<td class="text-end"> <td class="text-end">
{{if .RevokedAt.Valid}}<span class="text-danger small">Revoked</span>{{else}}
<form method="post" action="/settings/api-keys/{{.ID}}/revoke"> <form method="post" action="/settings/api-keys/{{.ID}}/revoke">
{{template "csrf_field" $.CSRFToken}} {{template "csrf_field" $.CSRFToken}}
<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button> <button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
</form> </form>
{{end}}
</td> </td>
</tr> </tr>
{{end}} {{end}}