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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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}}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user