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 {
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
KeyHash string
|
||||
CreatedAt time.Time
|
||||
LastUsedAt sql.NullTime
|
||||
RevokedAt sql.NullTime
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
KeyHash string
|
||||
ClientDescription string
|
||||
CreatedAt time.Time
|
||||
LastUsedAt sql.NullTime
|
||||
RevokedAt sql.NullTime
|
||||
}
|
||||
|
||||
func CreateAPIKey(db *sql.DB, userID, name, keyHash string) (*APIKey, error) {
|
||||
const apiKeySelect = `SELECT id, user_id, name, key_hash, client_description, created_at, last_used_at, revoked_at FROM api_keys`
|
||||
|
||||
func CreateAPIKey(db *sql.DB, userID, name, keyHash, clientDescription string) (*APIKey, error) {
|
||||
id := idgen.New("ak")
|
||||
_, err := db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash) VALUES (?, ?, ?, ?)`, id, userID, name, keyHash)
|
||||
_, err := db.Exec(`INSERT INTO api_keys (id, user_id, name, key_hash, client_description) VALUES (?, ?, ?, ?, ?)`,
|
||||
id, userID, name, keyHash, clientDescription)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -28,18 +32,20 @@ func CreateAPIKey(db *sql.DB, userID, name, keyHash string) (*APIKey, error) {
|
||||
}
|
||||
|
||||
func GetAPIKey(db *sql.DB, id string) (*APIKey, error) {
|
||||
return scanAPIKey(db.QueryRow(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE id = ?`, id))
|
||||
return scanAPIKey(db.QueryRow(apiKeySelect+` WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
// GetAPIKeyByHash looks up a non-revoked key by its hash, for
|
||||
// authentication. Revoked keys never match, by design.
|
||||
func GetAPIKeyByHash(db *sql.DB, keyHash string) (*APIKey, error) {
|
||||
return scanAPIKey(db.QueryRow(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL`, keyHash))
|
||||
return scanAPIKey(db.QueryRow(apiKeySelect+` WHERE key_hash = ? AND revoked_at IS NULL`, keyHash))
|
||||
}
|
||||
|
||||
func scanAPIKey(row *sql.Row) (*APIKey, error) {
|
||||
var k APIKey
|
||||
var createdAt string
|
||||
var lastUsed, revoked sql.NullString
|
||||
err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &createdAt, &lastUsed, &revoked)
|
||||
err := row.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &k.ClientDescription, &createdAt, &lastUsed, &revoked)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -61,17 +67,32 @@ func scanAPIKey(row *sql.Row) (*APIKey, error) {
|
||||
}
|
||||
|
||||
func ListAPIKeysForUser(db *sql.DB, userID string) ([]*APIKey, error) {
|
||||
rows, err := db.Query(`SELECT id, user_id, name, key_hash, created_at, last_used_at, revoked_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
||||
rows, err := db.Query(apiKeySelect+` WHERE user_id = ? ORDER BY created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAPIKeys(rows)
|
||||
}
|
||||
|
||||
// ListAllAPIKeys returns every API key for every user, for admin
|
||||
// management.
|
||||
func ListAllAPIKeys(db *sql.DB) ([]*APIKey, error) {
|
||||
rows, err := db.Query(apiKeySelect + ` ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAPIKeys(rows)
|
||||
}
|
||||
|
||||
func scanAPIKeys(rows *sql.Rows) ([]*APIKey, error) {
|
||||
var out []*APIKey
|
||||
for rows.Next() {
|
||||
var k APIKey
|
||||
var createdAt string
|
||||
var lastUsed, revoked sql.NullString
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &createdAt, &lastUsed, &revoked); err != nil {
|
||||
if err := rows.Scan(&k.ID, &k.UserID, &k.Name, &k.KeyHash, &k.ClientDescription, &createdAt, &lastUsed, &revoked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt, _ = parseTime(createdAt)
|
||||
|
||||
@@ -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
|
||||
// format, it only speaks it on the wire where the protocol requires it.
|
||||
func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) {
|
||||
username, aerr := p.Required("u")
|
||||
if aerr != nil {
|
||||
return nil, aerr
|
||||
apiKey := p.Get("apiKey")
|
||||
username := p.Get("u")
|
||||
|
||||
if apiKey != "" {
|
||||
// The apiKeyAuthentication extension requires a client not send
|
||||
// 'u' alongside 'apiKey' — accepting both would leave it
|
||||
// ambiguous which identity the caller actually intended, so this
|
||||
// is rejected outright rather than silently preferring one.
|
||||
if username != "" {
|
||||
return nil, errConflictingAuth
|
||||
}
|
||||
return authenticateAPIKey(db, apiKey)
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return nil, errMissingParameter("u")
|
||||
}
|
||||
|
||||
user, err := database.GetUserByUsername(db, username)
|
||||
@@ -75,6 +88,24 @@ func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// authenticateAPIKey implements the OpenSubsonic apiKeyAuthentication
|
||||
// extension: the key is hashed the same way as VibeSonic's own web
|
||||
// settings API keys (they are exactly the same keys — one system, not a
|
||||
// parallel one) and looked up by hash, never by scanning stored keys in
|
||||
// plaintext.
|
||||
func authenticateAPIKey(db *sql.DB, rawKey string) (*database.User, *apiError) {
|
||||
key, err := database.GetAPIKeyByHash(db, vsauth.HashAPIKey(rawKey))
|
||||
if err != nil {
|
||||
return nil, errInvalidAPIKey
|
||||
}
|
||||
user, err := database.GetUserByID(db, key.UserID)
|
||||
if err != nil || user.Disabled {
|
||||
return nil, errInvalidAPIKey
|
||||
}
|
||||
_ = database.TouchAPIKey(db, key.ID)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// checkVersion rejects clients whose declared protocol version is newer
|
||||
// than what this server implements. A missing or unparsable v is
|
||||
// tolerated (treated as compatible) rather than rejected outright, to
|
||||
|
||||
+23
-10
@@ -1,16 +1,20 @@
|
||||
package subsonic
|
||||
|
||||
// Error codes from the Subsonic REST API specification.
|
||||
// Error codes from the Subsonic REST API specification, plus the
|
||||
// OpenSubsonic apiKeyAuthentication extension's additions (42, 43, 44).
|
||||
const (
|
||||
ErrGeneric = 0
|
||||
ErrMissingParameter = 10
|
||||
ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports
|
||||
ErrServerMustUpgrade = 30 // server requires a newer client
|
||||
ErrWrongCredentials = 40
|
||||
ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password
|
||||
ErrUnauthorized = 50 // authenticated, but not permitted to perform this action
|
||||
ErrTrialExpired = 60 // VibeSonic never returns this: no license is required
|
||||
ErrNotFound = 70
|
||||
ErrGeneric = 0
|
||||
ErrMissingParameter = 10
|
||||
ErrClientMustUpgrade = 20 // client's protocol version is newer than this server supports
|
||||
ErrServerMustUpgrade = 30 // server requires a newer client
|
||||
ErrWrongCredentials = 40
|
||||
ErrTokenAuthNotSupported = 41 // e.g. LDAP-backed accounts with no local password
|
||||
ErrAuthMechanismUnsupported = 42 // provided authentication mechanism not supported
|
||||
ErrConflictingAuth = 43 // multiple conflicting authentication mechanisms provided
|
||||
ErrInvalidAPIKey = 44
|
||||
ErrUnauthorized = 50 // authenticated, but not permitted to perform this action
|
||||
ErrTrialExpired = 60 // VibeSonic never returns this: no license is required
|
||||
ErrNotFound = 70
|
||||
)
|
||||
|
||||
// apiError is a Subsonic protocol error carrying an HTTP-agnostic code
|
||||
@@ -21,6 +25,7 @@ const (
|
||||
type apiError struct {
|
||||
Code int
|
||||
Message string
|
||||
HelpURL string
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.Message }
|
||||
@@ -29,6 +34,10 @@ func newError(code int, message string) *apiError {
|
||||
return &apiError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
func newErrorWithHelp(code int, message, helpURL string) *apiError {
|
||||
return &apiError{Code: code, Message: message, HelpURL: helpURL}
|
||||
}
|
||||
|
||||
func errMissingParameter(name string) *apiError {
|
||||
return newError(ErrMissingParameter, "Required parameter '"+name+"' is missing")
|
||||
}
|
||||
@@ -41,8 +50,12 @@ func errUnauthorized(action string) *apiError {
|
||||
return newError(ErrUnauthorized, "User is not authorized for the requested "+action)
|
||||
}
|
||||
|
||||
const apiKeyHelpURL = "/settings#api-keys"
|
||||
|
||||
var (
|
||||
errWrongCredentials = newError(ErrWrongCredentials, "Wrong username or password")
|
||||
errClientMustUpgrade = newError(ErrClientMustUpgrade, "Incompatible Subsonic REST protocol version. Client must upgrade.")
|
||||
errTokenAuthNotSupported = newError(ErrTokenAuthNotSupported, "Token authentication is not supported for this account; use password authentication")
|
||||
errConflictingAuth = newErrorWithHelp(ErrConflictingAuth, "Provide either 'u' (with 'p' or 't'/'s') or 'apiKey', not both", apiKeyHelpURL)
|
||||
errInvalidAPIKey = newErrorWithHelp(ErrInvalidAPIKey, "Invalid API key", apiKeyHelpURL)
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
Version string `xml:"version,attr" json:"version"`
|
||||
|
||||
// OpenSubsonic core response additions (always populated): the
|
||||
// server's real product name and build version, and a flag
|
||||
// confirming OpenSubsonic support so clients can detect it from any
|
||||
// response, not just getOpenSubsonicExtensions.
|
||||
Type string `xml:"type,attr" json:"type"`
|
||||
ServerVersion string `xml:"serverVersion,attr" json:"serverVersion"`
|
||||
OpenSubsonic bool `xml:"openSubsonic,attr" json:"openSubsonic"`
|
||||
|
||||
Error *Error `xml:"error,omitempty" json:"error,omitempty"`
|
||||
|
||||
License *License `xml:"license,omitempty" json:"license,omitempty"`
|
||||
@@ -72,16 +80,57 @@ type Response struct {
|
||||
NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"`
|
||||
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
|
||||
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"`
|
||||
|
||||
// OpenSubsonic extensions.
|
||||
OpenSubsonicExtensions []OpenSubsonicExtension `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"`
|
||||
PodcastEpisode *PodcastEpisode `xml:"podcastEpisode,omitempty" json:"podcastEpisode,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
// SonicMatch is a top-level array (not wrapped in an object) on both
|
||||
// getSonicSimilarTracks and findSonicPath, per the OpenSubsonic
|
||||
// schema — the two endpoints are otherwise unrelated, so there is no
|
||||
// shared wrapper type to name it after.
|
||||
SonicMatch []SonicMatch `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"`
|
||||
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
|
||||
}
|
||||
|
||||
// OpenSubsonicExtension describes one extension this server supports,
|
||||
// as returned by getOpenSubsonicExtensions.
|
||||
type OpenSubsonicExtension struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Versions []int `xml:"versions" json:"versions"`
|
||||
}
|
||||
|
||||
type TokenInfo struct {
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
}
|
||||
|
||||
// ServerName is the real product name advertised in every response's
|
||||
// "type" attribute, per the OpenSubsonic base response additions.
|
||||
const ServerName = "vibesonic"
|
||||
|
||||
// AppVersion is VibeSonic's own build version, advertised in every
|
||||
// response's "serverVersion" attribute. Overridable at link time
|
||||
// (matching cmd/vibesonic's version variable); defaults to "dev".
|
||||
var AppVersion = "dev"
|
||||
|
||||
func newResponse() *Response {
|
||||
return &Response{Xmlns: "http://subsonic.org/restapi", Status: "ok", Version: ServerVersion}
|
||||
return &Response{
|
||||
Xmlns: "http://subsonic.org/restapi",
|
||||
Status: "ok",
|
||||
Version: ServerVersion,
|
||||
Type: ServerName,
|
||||
ServerVersion: AppVersion,
|
||||
OpenSubsonic: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Error is the payload of a failed response.
|
||||
type Error struct {
|
||||
Code int `xml:"code,attr" json:"code"`
|
||||
Message string `xml:"message,attr" json:"message"`
|
||||
HelpURL string `xml:"helpUrl,attr,omitempty" json:"helpUrl,omitempty"`
|
||||
}
|
||||
|
||||
// --- System ---
|
||||
|
||||
@@ -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 {
|
||||
r := newResponse()
|
||||
r.Status = "failed"
|
||||
r.Error = &Error{Code: err.Code, Message: err.Message}
|
||||
r.Error = &Error{Code: err.Code, Message: err.Message, HelpURL: err.HelpURL}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -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}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete))
|
||||
|
||||
mux.HandleFunc("GET /admin/api-keys", h.requireAdmin(h.handleAdminAPIKeys))
|
||||
mux.HandleFunc("POST /admin/api-keys/{id}/revoke", h.requireAdmin(h.handleAdminAPIKeyRevoke))
|
||||
|
||||
mux.HandleFunc("GET /admin/jukebox", h.requireAdmin(h.handleAdminJukebox))
|
||||
mux.HandleFunc("POST /admin/jukebox/start", h.requireAdmin(h.handleAdminJukeboxStart))
|
||||
mux.HandleFunc("POST /admin/jukebox/stop", h.requireAdmin(h.handleAdminJukeboxStop))
|
||||
|
||||
@@ -71,12 +71,13 @@ func (h *Web) handleAPIKeyCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
name := r.FormValue("name")
|
||||
clientDescription := r.FormValue("client_description")
|
||||
rawKey, err := auth.GenerateAPIKey()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreateAPIKey(h.DB, user.ID, name, auth.HashAPIKey(rawKey)); err != nil {
|
||||
if _, err := database.CreateAPIKey(h.DB, user.ID, name, auth.HashAPIKey(rawKey), clientDescription); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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_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_api_keys"}}active{{end}}" href="/admin/api-keys"><i class="bi bi-key"></i> API Keys</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<div class="mt-auto px-3 py-2 text-secondary small">
|
||||
|
||||
@@ -21,12 +21,13 @@
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">API keys</h2>
|
||||
<p class="text-secondary small">Used by third-party Subsonic clients in later phases.</p>
|
||||
<form method="post" action="/settings/api-keys" class="d-flex gap-2 mb-3">
|
||||
<h2 class="h6" id="api-keys">API keys</h2>
|
||||
<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="row g-2 mb-3">
|
||||
{{template "csrf_field" .CSRFToken}}
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="Key name">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm text-nowrap">New key</button>
|
||||
<div class="col-5"><input type="text" name="name" class="form-control form-control-sm" placeholder="Key name" required></div>
|
||||
<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>
|
||||
{{if .Data.NewAPIKey}}
|
||||
<div class="alert alert-warning small">
|
||||
@@ -38,13 +39,18 @@
|
||||
<tbody>
|
||||
{{range .Data.APIKeys}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="text-secondary small">{{fmtTime .CreatedAt}}</td>
|
||||
<td>{{.Name}}{{if .ClientDescription}} <span class="text-secondary small">({{.ClientDescription}})</span>{{end}}</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">
|
||||
{{if .RevokedAt.Valid}}<span class="text-danger small">Revoked</span>{{else}}
|
||||
<form method="post" action="/settings/api-keys/{{.ID}}/revoke">
|
||||
{{template "csrf_field" $.CSRFToken}}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user