Phase 2: Subsonic REST API framework (auth, response formatting, error handling, routing) plus user permission model

This commit is contained in:
2026-08-22 23:58:54 -05:00
parent f81c8704a5
commit b5e2bf01e2
26 changed files with 1772 additions and 75 deletions
+24 -2
View File
@@ -18,6 +18,7 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
"git.noroutes.net/NoRoutes/vibetunes/internal/subsonic"
"git.noroutes.net/NoRoutes/vibetunes/internal/web"
)
@@ -27,6 +28,7 @@ type App struct {
Log *slog.Logger
Scanner *scanner.Scanner
Web *web.Web
Subsonic *subsonic.Server
}
// Open initializes the database and every subsystem, and imports
@@ -61,6 +63,12 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure}
encryptionKey, err := auth.EncryptionKey(db, cfg.SessionSecret)
if err != nil {
db.Close()
return nil, fmt.Errorf("deriving auth encryption key: %w", err)
}
webApp := &web.Web{
DB: db,
Config: cfg,
@@ -69,10 +77,20 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
Middleware: mw,
Scanner: sc,
CoverArt: covers,
EncryptionKey: encryptionKey,
Log: log,
}
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp}, nil
subsonicSrv := &subsonic.Server{
DB: db,
Config: cfg,
EncryptionKey: encryptionKey,
Scanner: sc,
CoverArt: covers,
Log: log,
}
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp, Subsonic: subsonicSrv}, nil
}
func (a *App) Close() error {
@@ -126,9 +144,13 @@ func (a *App) Run(ctx context.Context) error {
}
}()
mux := http.NewServeMux()
mux.Handle("/rest/", a.Subsonic.Router())
mux.Handle("/", a.Web.NewRouter())
srv := &http.Server{
Addr: a.Cfg.ListenAddr,
Handler: a.Web.NewRouter(),
Handler: mux,
}
errCh := make(chan error, 1)
+97
View File
@@ -0,0 +1,97 @@
package auth
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// The legacy Subsonic protocol's token authentication scheme
// (t = md5(password + salt)) can only be verified by a server that
// knows the plaintext password. VibeSonic's native login hashes
// passwords with Argon2id, which is one-way by design and cannot answer
// that check. Every Subsonic-compatible server that supports token auth
// solves this the same way: keep a second, reversible copy of the
// password around specifically for this purpose, encrypted at rest
// (never plaintext) with a server-side key.
//
// EncryptionKey resolves that key: the operator-configured
// VIBESONIC_SESSION_SECRET if set (sha256'd to the right size), or
// otherwise a random 32-byte key generated once and persisted in the
// settings table.
const settingsKeyAuthKey = "subsonic_auth_encryption_key"
func EncryptionKey(db *sql.DB, configuredSecret string) ([]byte, error) {
if configuredSecret != "" {
sum := sha256.Sum256([]byte(configuredSecret))
return sum[:], nil
}
if existing := database.GetSetting(db, settingsKeyAuthKey, ""); existing != "" {
key, err := base64.StdEncoding.DecodeString(existing)
if err == nil && len(key) == 32 {
return key, nil
}
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
if err := database.SetSetting(db, settingsKeyAuthKey, base64.StdEncoding.EncodeToString(key)); err != nil {
return nil, err
}
return key, nil
}
// EncryptPassword returns an AES-256-GCM encrypted, base64-encoded copy
// of plaintext for storage in users.subsonic_auth_secret.
func EncryptPassword(key []byte, plaintext string) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
var ErrCannotDecrypt = errors.New("cannot decrypt stored password")
// DecryptPassword reverses EncryptPassword.
func DecryptPassword(key []byte, encoded string) (string, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", ErrCannotDecrypt
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", ErrCannotDecrypt
}
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrCannotDecrypt, err)
}
return string(plaintext), nil
}
+26
View File
@@ -0,0 +1,26 @@
package auth
import (
"database/sql"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// SetUserPassword hashes plaintext for native login and also stores the
// encrypted copy legacy Subsonic token authentication needs (see
// subsonic_secret.go), keeping both in sync. This is the only path that
// should ever change a user's password.
func SetUserPassword(db *sql.DB, encryptionKey []byte, userID, plaintext string) error {
hash, err := HashPassword(plaintext)
if err != nil {
return err
}
if err := database.SetUserPassword(db, userID, hash); err != nil {
return err
}
encrypted, err := EncryptPassword(encryptionKey, plaintext)
if err != nil {
return err
}
return database.SetUserSubsonicAuthSecret(db, userID, encrypted)
}
+131 -9
View File
@@ -8,6 +8,23 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
)
// Permissions mirrors the Subsonic REST API's per-user role model.
// AdminRole is represented separately by User.IsAdmin (an admin
// implicitly has every permission below regardless of these flags).
type Permissions struct {
Settings bool
Stream bool
Download bool
Upload bool
Playlist bool
CoverArt bool
Comment bool
Podcast bool
Share bool
Jukebox bool
VideoConversion bool
}
type User struct {
ID string
Username string
@@ -16,10 +33,19 @@ type User struct {
IsAdmin bool
Disabled bool
ExternalAuth bool
Permissions Permissions
SubsonicAuthSecret sql.NullString // AES-GCM encrypted password, for Subsonic token auth; see internal/subsonic
CreatedAt time.Time
LastLoginAt sql.NullTime
}
// Allows reports whether the user may perform the action gated by
// permission p — admins can do everything regardless of individual
// flags.
func (u *User) Allows(p bool) bool {
return u.IsAdmin || p
}
var ErrNotFound = errors.New("not found")
func CountUsers(db *sql.DB) (int, error) {
@@ -28,8 +54,14 @@ func CountUsers(db *sql.DB) (int, error) {
return n, err
}
const userColumns = `id, username, email, password_hash, is_admin, disabled, external_auth,
role_settings, role_stream, role_download, role_upload, role_playlist, role_cover_art,
role_comment, role_podcast, role_share, role_jukebox, role_video_conversion,
subsonic_auth_secret, created_at, last_login_at`
// CreateUser inserts a new user. passwordHash may be empty for
// external-auth-only accounts.
// external-auth-only accounts. New non-admin users get the default
// permission set (stream/download/playlist/coverArt/comment/share).
func CreateUser(db *sql.DB, username, email, passwordHash string, isAdmin bool) (*User, error) {
id := idgen.New("us")
var hash sql.NullString
@@ -56,19 +88,22 @@ func CreateExternalUser(db *sql.DB, username string) (*User, error) {
}
func GetUserByID(db *sql.DB, id string) (*User, error) {
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE id = ?`, id))
return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE id = ?`, id))
}
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE username = ?`, username))
return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE username = ?`, username))
}
func scanUser(row *sql.Row) (*User, error) {
var u User
var isAdmin, disabled, external int
var pSettings, pStream, pDownload, pUpload, pPlaylist, pCoverArt, pComment, pPodcast, pShare, pJukebox, pVideoConv int
var createdAt string
var lastLogin sql.NullString
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin)
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external,
&pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv,
&u.SubsonicAuthSecret, &createdAt, &lastLogin)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
@@ -78,6 +113,11 @@ func scanUser(row *sql.Row) (*User, error) {
u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0
u.ExternalAuth = external != 0
u.Permissions = Permissions{
Settings: pSettings != 0, Stream: pStream != 0, Download: pDownload != 0, Upload: pUpload != 0,
Playlist: pPlaylist != 0, CoverArt: pCoverArt != 0, Comment: pComment != 0, Podcast: pPodcast != 0,
Share: pShare != 0, Jukebox: pJukebox != 0, VideoConversion: pVideoConv != 0,
}
u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil {
@@ -88,32 +128,49 @@ func scanUser(row *sql.Row) (*User, error) {
}
func ListUsers(db *sql.DB) ([]*User, error) {
rows, err := db.Query(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users ORDER BY username COLLATE NOCASE`)
rows, err := db.Query(`SELECT ` + userColumns + ` FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*User
for rows.Next() {
u, err := scanUserRow(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func scanUserRow(rows *sql.Rows) (*User, error) {
var u User
var isAdmin, disabled, external int
var pSettings, pStream, pDownload, pUpload, pPlaylist, pCoverArt, pComment, pPodcast, pShare, pJukebox, pVideoConv int
var createdAt string
var lastLogin sql.NullString
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin); err != nil {
err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external,
&pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv,
&u.SubsonicAuthSecret, &createdAt, &lastLogin)
if err != nil {
return nil, err
}
u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0
u.ExternalAuth = external != 0
u.Permissions = Permissions{
Settings: pSettings != 0, Stream: pStream != 0, Download: pDownload != 0, Upload: pUpload != 0,
Playlist: pPlaylist != 0, CoverArt: pCoverArt != 0, Comment: pComment != 0, Podcast: pPodcast != 0,
Share: pShare != 0, Jukebox: pJukebox != 0, VideoConversion: pVideoConv != 0,
}
u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil {
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
}
}
out = append(out, &u)
}
return out, rows.Err()
return &u, nil
}
func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
@@ -121,6 +178,18 @@ func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
return err
}
// SetUserSubsonicAuthSecret stores (or clears, with encrypted="") the
// AES-GCM encrypted copy of the user's current plaintext password, used
// only to verify legacy Subsonic token authentication.
func SetUserSubsonicAuthSecret(db *sql.DB, userID, encrypted string) error {
var v any
if encrypted != "" {
v = encrypted
}
_, err := db.Exec(`UPDATE users SET subsonic_auth_secret = ? WHERE id = ?`, v, userID)
return err
}
func SetUserDisabled(db *sql.DB, userID string, disabled bool) error {
_, err := db.Exec(`UPDATE users SET disabled = ? WHERE id = ?`, boolInt(disabled), userID)
return err
@@ -131,6 +200,18 @@ func SetUserAdmin(db *sql.DB, userID string, isAdmin bool) error {
return err
}
// SetUserPermissions updates the full Subsonic permission set for a user.
func SetUserPermissions(db *sql.DB, userID string, p Permissions) error {
_, err := db.Exec(`UPDATE users SET
role_settings=?, role_stream=?, role_download=?, role_upload=?, role_playlist=?,
role_cover_art=?, role_comment=?, role_podcast=?, role_share=?, role_jukebox=?, role_video_conversion=?
WHERE id=?`,
boolInt(p.Settings), boolInt(p.Stream), boolInt(p.Download), boolInt(p.Upload), boolInt(p.Playlist),
boolInt(p.CoverArt), boolInt(p.Comment), boolInt(p.Podcast), boolInt(p.Share), boolInt(p.Jukebox), boolInt(p.VideoConversion),
userID)
return err
}
func DeleteUser(db *sql.DB, userID string) error {
_, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID)
return err
@@ -141,6 +222,47 @@ func TouchLastLogin(db *sql.DB, userID string) error {
return err
}
// --- Per-user music folder restrictions ---
// AllowedMusicFolderIDs returns the music folder IDs a user is
// restricted to, or nil if the user has no restriction (sees every
// folder — the default and common case).
func AllowedMusicFolderIDs(db *sql.DB, userID string) ([]string, error) {
rows, err := db.Query(`SELECT music_folder_id FROM user_music_folders WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// SetAllowedMusicFolders replaces a user's folder restriction list. An
// empty ids slice removes all restrictions (unrestricted access).
func SetAllowedMusicFolders(db *sql.DB, userID string, ids []string) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM user_music_folders WHERE user_id = ?`, userID); err != nil {
return err
}
for _, id := range ids {
if _, err := tx.Exec(`INSERT INTO user_music_folders (user_id, music_folder_id) VALUES (?, ?)`, userID, id); err != nil {
return err
}
}
return tx.Commit()
}
func boolInt(b bool) int {
if b {
return 1
+128
View File
@@ -0,0 +1,128 @@
package subsonic
import (
"crypto/md5"
"database/sql"
"encoding/hex"
"strconv"
"strings"
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// authenticate implements original Subsonic authentication: either
// token/salt (t = md5(password + salt), the modern recommended mode) or
// password (p=<plaintext> or p=enc:<hex>, kept for older clients).
//
// This never touches VibeSonic's Argon2id-hashed password directly for
// token auth — that hash is one-way by design. Token auth is instead
// verified against a separately maintained, AES-GCM-encrypted copy of
// the password (see internal/auth/subsonic_secret.go); the /rest/*
// 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
}
user, err := database.GetUserByUsername(db, username)
if err != nil || user.Disabled {
return nil, errWrongCredentials
}
token := p.Get("t")
salt := p.Get("s")
password := p.Get("p")
switch {
case token != "" && salt != "":
if !user.SubsonicAuthSecret.Valid {
return nil, errTokenAuthNotSupported
}
plaintext, derr := vsauth.DecryptPassword(encryptionKey, user.SubsonicAuthSecret.String)
if derr != nil {
return nil, errWrongCredentials
}
sum := md5.Sum([]byte(plaintext + salt))
expected := hex.EncodeToString(sum[:])
if !strings.EqualFold(expected, token) {
return nil, errWrongCredentials
}
case password != "":
plaintext := password
if strings.HasPrefix(password, "enc:") {
decoded, herr := hex.DecodeString(strings.TrimPrefix(password, "enc:"))
if herr != nil {
return nil, errWrongCredentials
}
plaintext = string(decoded)
}
if !user.PasswordHash.Valid {
return nil, errWrongCredentials
}
ok, verr := vsauth.VerifyPassword(plaintext, user.PasswordHash.String)
if verr != nil || !ok {
return nil, errWrongCredentials
}
default:
return nil, errMissingParameter("p or t/s")
}
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
// stay lenient with tooling that omits it — the protocol is additive, so
// there is no reason to reject an old client either.
func checkVersion(v string) *apiError {
if v == "" {
return nil
}
client, ok := parseVersion(v)
if !ok {
return nil
}
server, _ := parseVersion(ServerVersion)
if versionGreater(client, server) {
return errClientMustUpgrade
}
return nil
}
type semver struct{ major, minor, patch int }
func parseVersion(v string) (semver, bool) {
parts := strings.SplitN(v, ".", 3)
if len(parts) == 0 {
return semver{}, false
}
var s semver
var err error
s.major, err = strconv.Atoi(parts[0])
if err != nil {
return semver{}, false
}
if len(parts) > 1 {
s.minor, _ = strconv.Atoi(parts[1])
}
if len(parts) > 2 {
s.patch, _ = strconv.Atoi(parts[2])
}
return s, true
}
func versionGreater(a, b semver) bool {
if a.major != b.major {
return a.major > b.major
}
if a.minor != b.minor {
return a.minor > b.minor
}
return a.patch > b.patch
}
+48
View File
@@ -0,0 +1,48 @@
package subsonic
// Error codes from the Subsonic REST API specification.
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
)
// apiError is a Subsonic protocol error carrying an HTTP-agnostic code
// and message. Handlers return one instead of writing to the response
// directly, so the router can format it consistently in whatever
// wire format the caller asked for and it never leaks a raw Go error
// (which could expose internal paths/state) to the client.
type apiError struct {
Code int
Message string
}
func (e *apiError) Error() string { return e.Message }
func newError(code int, message string) *apiError {
return &apiError{Code: code, Message: message}
}
func errMissingParameter(name string) *apiError {
return newError(ErrMissingParameter, "Required parameter '"+name+"' is missing")
}
func errNotFound(what string) *apiError {
return newError(ErrNotFound, what+" not found")
}
func errUnauthorized(action string) *apiError {
return newError(ErrUnauthorized, "User is not authorized for the requested "+action)
}
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")
)
+114
View File
@@ -0,0 +1,114 @@
// Package subsonic implements the original Subsonic REST API (through
// version 1.16.1) on top of VibeSonic's existing database, scanner,
// streaming, and cover art packages. It does not duplicate business
// logic: every handler here is a thin adapter from Subsonic's wire
// format onto internal/database, internal/library, internal/streaming,
// and internal/coverart.
//
// One set of Go structs (this file and its model_*.go siblings) is
// shared between XML and JSON output via struct tags, so response
// shape can never drift between formats — see response.go for how they
// are serialized.
package subsonic
import "encoding/xml"
// ServerVersion is the highest Subsonic REST API version VibeSonic
// implements. Requests from a client claiming a newer protocol version
// are rejected (error code 20); older clients are accepted, since the
// protocol is additive.
const ServerVersion = "1.16.1"
// Response is the envelope every /rest/* call returns: exactly one of
// the payload fields below is set, matching the single child element
// real Subsonic servers emit inside <subsonic-response>.
type Response struct {
XMLName xml.Name `xml:"subsonic-response" json:"-"`
Xmlns string `xml:"xmlns,attr" json:"-"`
Status string `xml:"status,attr" json:"status"`
Version string `xml:"version,attr" json:"version"`
Error *Error `xml:"error,omitempty" json:"error,omitempty"`
License *License `xml:"license,omitempty" json:"license,omitempty"`
MusicFolders *MusicFolders `xml:"musicFolders,omitempty" json:"musicFolders,omitempty"`
Indexes *Indexes `xml:"indexes,omitempty" json:"indexes,omitempty"`
Directory *Directory `xml:"directory,omitempty" json:"directory,omitempty"`
Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"`
Artists *ArtistsID3 `xml:"artists,omitempty" json:"artists,omitempty"`
Artist *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"`
Album *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"`
Song *Child `xml:"song,omitempty" json:"song,omitempty"`
Videos *Videos `xml:"videos,omitempty" json:"videos,omitempty"`
VideoInfo *VideoInfo `xml:"videoInfo,omitempty" json:"videoInfo,omitempty"`
ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"`
ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"`
AlbumInfo *AlbumInfo `xml:"albumInfo,omitempty" json:"albumInfo,omitempty"`
SimilarSongs *SimilarSongs `xml:"similarSongs,omitempty" json:"similarSongs,omitempty"`
SimilarSongs2 *SimilarSongs2 `xml:"similarSongs2,omitempty" json:"similarSongs2,omitempty"`
TopSongs *TopSongs `xml:"topSongs,omitempty" json:"topSongs,omitempty"`
AlbumList *AlbumList `xml:"albumList,omitempty" json:"albumList,omitempty"`
AlbumList2 *AlbumList2 `xml:"albumList2,omitempty" json:"albumList2,omitempty"`
RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"`
SongsByGenre *Songs `xml:"songsByGenre,omitempty" json:"songsByGenre,omitempty"`
NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"`
Starred *Starred `xml:"starred,omitempty" json:"starred,omitempty"`
Starred2 *Starred2 `xml:"starred2,omitempty" json:"starred2,omitempty"`
SearchResult *SearchResult `xml:"searchResult,omitempty" json:"searchResult,omitempty"`
SearchResult2 *SearchResult2 `xml:"searchResult2,omitempty" json:"searchResult2,omitempty"`
SearchResult3 *SearchResult3 `xml:"searchResult3,omitempty" json:"searchResult3,omitempty"`
Playlists *Playlists `xml:"playlists,omitempty" json:"playlists,omitempty"`
Playlist *PlaylistWithSongs `xml:"playlist,omitempty" json:"playlist,omitempty"`
JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"`
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"`
ChatMessages *ChatMessages `xml:"chatMessages,omitempty" json:"chatMessages,omitempty"`
User *User `xml:"user,omitempty" json:"user,omitempty"`
Users *Users `xml:"users,omitempty" json:"users,omitempty"`
Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"`
PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"`
Shares *Shares `xml:"shares,omitempty" json:"shares,omitempty"`
Podcasts *Podcasts `xml:"podcasts,omitempty" json:"podcasts,omitempty"`
NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"`
ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"`
Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"`
}
func newResponse() *Response {
return &Response{Xmlns: "http://subsonic.org/restapi", Status: "ok", Version: ServerVersion}
}
// 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"`
}
// --- System ---
type License struct {
Valid bool `xml:"valid,attr" json:"valid"`
Email string `xml:"email,attr,omitempty" json:"email,omitempty"`
LicenseExpires string `xml:"licenseExpires,attr,omitempty" json:"licenseExpires,omitempty"`
}
// --- Music folders / genres ---
type MusicFolders struct {
MusicFolder []MusicFolder `xml:"musicFolder" json:"musicFolder"`
}
type MusicFolder struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
type Genres struct {
Genre []Genre `xml:"genre" json:"genre"`
}
type Genre struct {
Name string `xml:",chardata" json:"value"`
SongCount int `xml:"songCount,attr" json:"songCount"`
AlbumCount int `xml:"albumCount,attr" json:"albumCount"`
}
+188
View File
@@ -0,0 +1,188 @@
package subsonic
// --- Filesystem-style browsing: getIndexes / getMusicDirectory ---
type Indexes struct {
LastModified int64 `xml:"lastModified,attr" json:"lastModified"`
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
Shortcut []Artist `xml:"shortcut,omitempty" json:"shortcut,omitempty"`
Index []Index `xml:"index" json:"index"`
Child []Child `xml:"child,omitempty" json:"child,omitempty"` // top-level files not under any letter index
}
type Index struct {
Name string `xml:"name,attr" json:"name"`
Artist []Artist `xml:"artist" json:"artist"`
}
// Artist is the filesystem-style artist entry (a "directory" one level
// below a music folder root), distinct from the ID3 ArtistID3 below.
type Artist struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
}
type Directory struct {
ID string `xml:"id,attr" json:"id"`
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
Name string `xml:"name,attr" json:"name"`
starred // embedded for the optional "starred" attr, see model.go helper types
Child []Child `xml:"child" json:"child"`
}
// Child represents both a directory entry and a song/video — the single
// polymorphic element type Subsonic uses throughout getMusicDirectory,
// search results, playlists, and album contents.
type Child struct {
ID string `xml:"id,attr" json:"id"`
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
IsDir bool `xml:"isDir,attr" json:"isDir"`
Title string `xml:"title,attr" json:"title"`
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
Track int `xml:"track,attr,omitempty" json:"track,omitempty"`
Year int `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
TranscodedContentType string `xml:"transcodedContentType,attr,omitempty" json:"transcodedContentType,omitempty"`
TranscodedSuffix string `xml:"transcodedSuffix,attr,omitempty" json:"transcodedSuffix,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"`
Path string `xml:"path,attr,omitempty" json:"path,omitempty"`
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
UserRating int `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
DiscNumber int `xml:"discNumber,attr,omitempty" json:"discNumber,omitempty"`
Created string `xml:"created,attr,omitempty" json:"created,omitempty"`
Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"`
AlbumID string `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
ArtistID string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
}
type Songs struct {
Song []Child `xml:"song" json:"song"`
}
// starred is embedded into structs that carry an optional "starred"
// timestamp attribute (Directory, AlbumID3, ArtistID3, ...).
type starred struct {
Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"`
}
// --- ID3/tag-style browsing: getArtists / getArtist / getAlbum / getSong ---
type ArtistsID3 struct {
IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"`
Index []IndexID3 `xml:"index" json:"index"`
}
type IndexID3 struct {
Name string `xml:"name,attr" json:"name"`
Artist []ArtistID3 `xml:"artist" json:"artist"`
}
type ArtistID3 struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
AlbumCount int `xml:"albumCount,attr" json:"albumCount"`
starred
}
type ArtistWithAlbumsID3 struct {
ArtistID3
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
}
type AlbumID3 struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
ArtistID string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
SongCount int `xml:"songCount,attr" json:"songCount"`
Duration int `xml:"duration,attr" json:"duration"`
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
Created string `xml:"created,attr" json:"created"`
starred
Year int `xml:"year,attr,omitempty" json:"year,omitempty"`
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
}
type AlbumWithSongsID3 struct {
AlbumID3
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
// --- Artist/album info (last.fm-style metadata; VibeSonic has no
// external metadata provider in Phase 2, so these return locally-known
// data only: biography/images are empty, similar artists are omitted) ---
type ArtistInfoBase struct {
Biography string `xml:"biography,omitempty" json:"biography,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmURL string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageURL string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageURL string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageURL string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type ArtistInfo struct {
ArtistInfoBase
SimilarArtist []Artist `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type ArtistInfo2 struct {
ArtistInfoBase
SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"`
}
type AlbumInfo struct {
Notes string `xml:"notes,omitempty" json:"notes,omitempty"`
MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"`
LastFmURL string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"`
SmallImageURL string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"`
MediumImageURL string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"`
LargeImageURL string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"`
}
type SimilarSongs struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type SimilarSongs2 struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type TopSongs struct {
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
// --- Video ---
type Videos struct {
Video []Child `xml:"video,omitempty" json:"video,omitempty"`
}
type VideoInfo struct {
ID string `xml:"id,attr" json:"id"`
AudioTrack []AudioTrack `xml:"audioTrack,omitempty" json:"audioTrack,omitempty"`
Captions []Captions `xml:"captions,omitempty" json:"captions,omitempty"`
}
type AudioTrack struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
LanguageCode string `xml:"languageCode,attr,omitempty" json:"languageCode,omitempty"`
}
type Captions struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr,omitempty" json:"name,omitempty"`
}
+59
View File
@@ -0,0 +1,59 @@
package subsonic
// --- Album lists ---
type AlbumList struct {
Album []Child `xml:"album,omitempty" json:"album,omitempty"`
}
type AlbumList2 struct {
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
}
// --- Now playing ---
type NowPlaying struct {
Entry []NowPlayingEntry `xml:"entry,omitempty" json:"entry,omitempty"`
}
type NowPlayingEntry struct {
Child
Username string `xml:"username,attr" json:"username"`
MinutesAgo int `xml:"minutesAgo,attr" json:"minutesAgo"`
PlayerID int `xml:"playerId,attr" json:"playerId"`
PlayerName string `xml:"playerName,attr,omitempty" json:"playerName,omitempty"`
}
// --- Starred ---
type Starred struct {
Artist []Artist `xml:"artist,omitempty" json:"artist,omitempty"`
Album []Child `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type Starred2 struct {
Artist []ArtistID3 `xml:"artist,omitempty" json:"artist,omitempty"`
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
// --- Search ---
type SearchResult struct {
Offset int `xml:"offset,attr" json:"offset"`
TotalHits int `xml:"totalHits,attr" json:"totalHits"`
Match []Child `xml:"match,omitempty" json:"match,omitempty"`
}
type SearchResult2 struct {
Artist []Artist `xml:"artist,omitempty" json:"artist,omitempty"`
Album []Child `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
type SearchResult3 struct {
Artist []ArtistID3 `xml:"artist,omitempty" json:"artist,omitempty"`
Album []AlbumID3 `xml:"album,omitempty" json:"album,omitempty"`
Song []Child `xml:"song,omitempty" json:"song,omitempty"`
}
+29
View File
@@ -0,0 +1,29 @@
package subsonic
type Lyrics struct {
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Value string `xml:",chardata" json:"value,omitempty"`
}
type Bookmarks struct {
Bookmark []Bookmark `xml:"bookmark,omitempty" json:"bookmark,omitempty"`
}
type Bookmark struct {
Position int64 `xml:"position,attr" json:"position"` // milliseconds
Username string `xml:"username,attr" json:"username"`
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
Created string `xml:"created,attr" json:"created"`
Changed string `xml:"changed,attr" json:"changed"`
Entry Child `xml:"entry" json:"entry"`
}
type PlayQueue struct {
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
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"`
}
+96
View File
@@ -0,0 +1,96 @@
package subsonic
// --- Jukebox ---
type JukeboxStatus struct {
CurrentIndex int `xml:"currentIndex,attr" json:"currentIndex"`
Playing bool `xml:"playing,attr" json:"playing"`
Gain float64 `xml:"gain,attr" json:"gain"`
Position int `xml:"position,attr,omitempty" json:"position,omitempty"`
}
type JukeboxPlaylist struct {
JukeboxStatus
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
// --- Internet radio ---
type InternetRadioStations struct {
InternetRadioStation []InternetRadioStation `xml:"internetRadioStation,omitempty" json:"internetRadioStation,omitempty"`
}
type InternetRadioStation struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
StreamURL string `xml:"streamUrl,attr" json:"streamUrl"`
HomepageURL string `xml:"homepageUrl,attr,omitempty" json:"homepageUrl,omitempty"`
}
// --- Chat ---
type ChatMessages struct {
ChatMessage []ChatMessage `xml:"chatMessage,omitempty" json:"chatMessage,omitempty"`
}
type ChatMessage struct {
Username string `xml:"username,attr" json:"username"`
Time int64 `xml:"time,attr" json:"time"` // millis since epoch
Message string `xml:"message,attr" json:"message"`
}
// --- Scan status ---
type ScanStatus struct {
Scanning bool `xml:"scanning,attr" json:"scanning"`
Count int64 `xml:"count,attr,omitempty" json:"count,omitempty"`
}
// --- Shares ---
type Shares struct {
Share []Share `xml:"share,omitempty" json:"share,omitempty"`
}
type Share struct {
ID string `xml:"id,attr" json:"id"`
URL string `xml:"url,attr" json:"url"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
Username string `xml:"username,attr" json:"username"`
Created string `xml:"created,attr" json:"created"`
Expires string `xml:"expires,attr,omitempty" json:"expires,omitempty"`
LastVisited string `xml:"lastVisited,attr,omitempty" json:"lastVisited,omitempty"`
VisitCount int64 `xml:"visitCount,attr" json:"visitCount"`
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
// --- Podcasts ---
type Podcasts struct {
Channel []PodcastChannel `xml:"channel,omitempty" json:"channel,omitempty"`
}
type NewestPodcasts struct {
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
}
type PodcastChannel struct {
ID string `xml:"id,attr" json:"id"`
URL string `xml:"url,attr" json:"url"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
OriginalImageURL string `xml:"originalImageUrl,attr,omitempty" json:"originalImageUrl,omitempty"`
Status string `xml:"status,attr" json:"status"`
ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"`
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
}
type PodcastEpisode struct {
Child
StreamID string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"`
ChannelID string `xml:"channelId,attr" json:"channelId"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
Status string `xml:"status,attr" json:"status"`
PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"`
}
+23
View File
@@ -0,0 +1,23 @@
package subsonic
type Playlists struct {
Playlist []Playlist `xml:"playlist,omitempty" json:"playlist,omitempty"`
}
type Playlist struct {
ID string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
Public bool `xml:"public,attr" json:"public"`
SongCount int `xml:"songCount,attr" json:"songCount"`
Duration int `xml:"duration,attr" json:"duration"`
Created string `xml:"created,attr" json:"created"`
Changed string `xml:"changed,attr" json:"changed"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
}
type PlaylistWithSongs struct {
Playlist
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
}
+25
View File
@@ -0,0 +1,25 @@
package subsonic
type Users struct {
User []User `xml:"user" json:"user"`
}
type User struct {
Username string `xml:"username,attr" json:"username"`
Email string `xml:"email,attr,omitempty" json:"email,omitempty"`
ScrobblingEnabled bool `xml:"scrobblingEnabled,attr" json:"scrobblingEnabled"`
MaxBitRate int `xml:"maxBitRate,attr,omitempty" json:"maxBitRate,omitempty"`
AdminRole bool `xml:"adminRole,attr" json:"adminRole"`
SettingsRole bool `xml:"settingsRole,attr" json:"settingsRole"`
DownloadRole bool `xml:"downloadRole,attr" json:"downloadRole"`
UploadRole bool `xml:"uploadRole,attr" json:"uploadRole"`
PlaylistRole bool `xml:"playlistRole,attr" json:"playlistRole"`
CoverArtRole bool `xml:"coverArtRole,attr" json:"coverArtRole"`
CommentRole bool `xml:"commentRole,attr" json:"commentRole"`
PodcastRole bool `xml:"podcastRole,attr" json:"podcastRole"`
StreamRole bool `xml:"streamRole,attr" json:"streamRole"`
JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"`
ShareRole bool `xml:"shareRole,attr" json:"shareRole"`
VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"`
Folder []int `xml:"folder,omitempty" json:"folder,omitempty"`
}
+82
View File
@@ -0,0 +1,82 @@
package subsonic
import (
"net/url"
"strconv"
)
// params wraps the request's merged query+form values with typed,
// error-producing accessors, so handlers never hand-roll parameter
// parsing/validation.
type params struct {
values url.Values
}
func (p params) Get(name string) string {
return p.values.Get(name)
}
func (p params) GetAll(name string) []string {
return p.values[name]
}
func (p params) Has(name string) bool {
_, ok := p.values[name]
return ok
}
func (p params) Required(name string) (string, *apiError) {
v := p.values.Get(name)
if v == "" {
return "", errMissingParameter(name)
}
return v, nil
}
func (p params) Int(name string, def int) int {
v := p.values.Get(name)
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func (p params) RequiredInt(name string) (int, *apiError) {
v := p.values.Get(name)
if v == "" {
return 0, errMissingParameter(name)
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, errMissingParameter(name)
}
return n, nil
}
func (p params) Int64(name string, def int64) int64 {
v := p.values.Get(name)
if v == "" {
return def
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return def
}
return n
}
func (p params) Bool(name string, def bool) bool {
v := p.values.Get(name)
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
+59
View File
@@ -0,0 +1,59 @@
package subsonic
import (
"encoding/json"
"encoding/xml"
"net/http"
"regexp"
)
// jsonEnvelope is the {"subsonic-response": {...}} wrapper the JSON
// format requires around the same Response struct XML uses directly.
type jsonEnvelope struct {
Response *Response `json:"subsonic-response"`
}
// jsonpCallbackRE matches a safe JavaScript identifier (including dotted
// paths like "foo.bar", which some clients use). Anything else is
// rejected outright rather than interpolated into the response body,
// which would otherwise be a script-injection vector.
var jsonpCallbackRE = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$`)
// writeResponse serializes resp in the format requested by the client
// (f=xml, the default; f=json; f=jsonp with a validated callback) and
// writes it with the correct Content-Type. It never returns an error to
// the caller — formatting failures are logged-and-swallowed rather than
// leaking a broken partial body or a Go panic through the wire.
func writeResponse(w http.ResponseWriter, format, callback string, resp *Response) {
switch format {
case "json":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(jsonEnvelope{Response: resp})
case "jsonp":
w.Header().Set("Content-Type", "application/javascript")
body, err := json.Marshal(jsonEnvelope{Response: resp})
if err != nil {
return
}
if !jsonpCallbackRE.MatchString(callback) {
// Should already be rejected before we get here; fail safe
// by falling back to a fixed, harmless callback name rather
// than ever writing an unvalidated callback into the body.
callback = "callback"
}
_, _ = w.Write([]byte(callback + "("))
_, _ = w.Write(body)
_, _ = w.Write([]byte(")"))
default:
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
_, _ = w.Write([]byte(xml.Header))
_ = xml.NewEncoder(w).Encode(resp)
}
}
func errorResponse(err *apiError) *Response {
r := newResponse()
r.Status = "failed"
r.Error = &Error{Code: err.Code, Message: err.Message}
return r
}
+92
View File
@@ -0,0 +1,92 @@
package subsonic
import (
"net/http"
"strings"
)
type handlerFunc func(rc *Context) *apiError
// registry maps a bare endpoint name (no "/rest/" prefix, no ".view"
// suffix) to its handler. Populated by init()-time registrations in each
// endpoint group's file (system.go, browsing.go, ...) via register().
var registry = map[string]handlerFunc{}
func register(name string, fn handlerFunc) {
if _, exists := registry[name]; exists {
panic("subsonic: duplicate handler registration for " + name)
}
registry[name] = fn
}
// Router returns the /rest/* handler. All authentication, protocol
// version validation, parameter parsing, and response formatting is
// centralized here — individual endpoint handlers never touch any of it
// themselves, and never write directly to the ResponseWriter except the
// small set of binary/streaming endpoints (stream, download, hls,
// getCoverArt, getAvatar, getCaptions) which set rc.Handled.
func (s *Server) Router() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/rest/", s.dispatch)
return mux
}
func (s *Server) dispatch(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/rest/")
name = strings.TrimSuffix(name, ".view")
if name == "" {
http.NotFound(w, r)
return
}
// Subsonic clients may send parameters via query string or (for
// endpoints with large/binary bodies, or just by client preference)
// POST form encoding; ParseForm merges both into r.Form.
if err := r.ParseForm(); err != nil {
writeResponse(w, "xml", "", errorResponse(newError(ErrGeneric, "could not parse request")))
return
}
p := params{values: r.Form}
format := p.Get("f")
if format == "" {
format = "xml"
}
callback := p.Get("callback")
if format == "jsonp" && !jsonpCallbackRE.MatchString(callback) {
// A malformed/malicious callback name must never reach the
// response body; fail the request instead of sanitizing it.
writeResponse(w, "json", "", errorResponse(newError(ErrGeneric, "invalid callback parameter")))
return
}
handler, ok := registry[name]
if !ok {
writeResponse(w, format, callback, errorResponse(newError(ErrNotFound, "Unknown Subsonic API endpoint: "+name)))
return
}
if verr := checkVersion(p.Get("v")); verr != nil {
writeResponse(w, format, callback, errorResponse(verr))
return
}
user, aerr := authenticate(s.DB, s.EncryptionKey, p)
if aerr != nil {
writeResponse(w, format, callback, errorResponse(aerr))
return
}
rc := &Context{W: w, R: r, Server: s, User: user, Params: p, Format: format, Callback: callback}
if herr := handler(rc); herr != nil {
writeResponse(w, format, callback, errorResponse(herr))
return
}
if rc.Handled {
return
}
if rc.Response == nil {
rc.Response = newResponse()
}
writeResponse(w, format, callback, rc.Response)
}
+325
View File
@@ -0,0 +1,325 @@
package subsonic
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func get(t *testing.T, srv string, q url.Values) *http.Response {
t.Helper()
resp, err := http.Get(srv + "/rest/ping.view?" + q.Encode())
if err != nil {
t.Fatal(err)
}
return resp
}
func getPath(t *testing.T, srv, path string, q url.Values) *http.Response {
t.Helper()
resp, err := http.Get(srv + path + "?" + q.Encode())
if err != nil {
t.Fatal(err)
}
return resp
}
func baseParams(username, password string) url.Values {
return url.Values{
"u": {username},
"p": {password},
"v": {"1.16.1"},
"c": {"vibesonic-test"},
}
}
func tokenParams(username, salt, token string) url.Values {
return url.Values{
"u": {username},
"t": {token},
"s": {salt},
"v": {"1.16.1"},
"c": {"vibesonic-test"},
}
}
type xmlResp struct {
XMLName xml.Name `xml:"subsonic-response"`
Status string `xml:"status,attr"`
Version string `xml:"version,attr"`
Error *struct {
Code int `xml:"code,attr"`
Message string `xml:"message,attr"`
} `xml:"error"`
}
func decodeXML(t *testing.T, resp *http.Response) xmlResp {
t.Helper()
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var x xmlResp
if err := xml.Unmarshal(body, &x); err != nil {
t.Fatalf("invalid XML response: %v\nbody: %s", err, body)
}
return x
}
func decodeJSON(t *testing.T, resp *http.Response) map[string]any {
t.Helper()
defer resp.Body.Close()
var m map[string]any
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
t.Fatalf("invalid JSON response: %v", err)
}
inner, ok := m["subsonic-response"].(map[string]any)
if !ok {
t.Fatalf("response missing subsonic-response envelope: %v", m)
}
return inner
}
func TestPingSuccessXML(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := get(t, srv.URL, baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("status = %q, want ok", x.Status)
}
if x.Version != ServerVersion {
t.Fatalf("version = %q, want %q", x.Version, ServerVersion)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "xml") {
t.Errorf("Content-Type = %q, want xml", ct)
}
}
func TestPingSuccessJSON(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["status"] != "ok" {
t.Fatalf("status = %v, want ok", j["status"])
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "json") {
t.Errorf("Content-Type = %q, want json", ct)
}
}
func TestPingJSONP(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "jsonp")
q.Set("callback", "myCallback")
resp := get(t, srv.URL, q)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.HasPrefix(string(body), "myCallback(") || !strings.HasSuffix(strings.TrimSpace(string(body)), ")") {
t.Fatalf("jsonp body = %s, want wrapped in myCallback(...)", body)
}
}
func TestJSONPRejectsMaliciousCallback(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "jsonp")
q.Set("callback", "alert(1);foo")
resp := get(t, srv.URL, q)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "alert(1)") {
t.Fatalf("malicious callback was reflected into the response body: %s", body)
}
}
func TestWrongPasswordFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := get(t, srv.URL, baseParams("alice", "wrongpassword"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40, got %+v", x)
}
}
func TestUnknownUserFails(t *testing.T) {
_, srv := newTestRecorderServer(t)
resp := get(t, srv.URL, baseParams("nosuchuser", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40, got %+v", x)
}
}
func TestDisabledUserFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
u := createTestUser(t, s, "alice", "password123", false)
if err := database.SetUserDisabled(s.DB, u.ID, true); err != nil {
t.Fatal(err)
}
resp := get(t, srv.URL, baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40 for disabled user, got %+v", x)
}
}
func TestMissingUsernameFails(t *testing.T) {
_, srv := newTestRecorderServer(t)
q := url.Values{"p": {"x"}, "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 != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
func TestMissingCredentialsFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := url.Values{"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 != ErrMissingParameter {
t.Fatalf("expected error 10 with neither p nor t/s, got %+v", x)
}
}
func TestTokenAuthSuccess(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
salt := "saltysalt"
sum := md5.Sum([]byte("password123" + salt))
token := hex.EncodeToString(sum[:])
resp := get(t, srv.URL, tokenParams("alice", salt, token))
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("token auth failed: %+v", x)
}
}
func TestTokenAuthWrongToken(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := get(t, srv.URL, tokenParams("alice", "saltysalt", "0000000000000000000000000000000"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrWrongCredentials {
t.Fatalf("expected error 40, got %+v", x)
}
}
func TestEncPasswordAuth(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
encoded := "enc:" + hex.EncodeToString([]byte("password123"))
q := url.Values{"u": {"alice"}, "p": {encoded}, "v": {"1.16.1"}, "c": {"test"}}
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("enc: password auth failed: %+v", x)
}
}
func TestExternalOnlyUserCannotTokenAuth(t *testing.T) {
s, db := newTestServer(t)
if _, err := database.CreateExternalUser(db, "extuser"); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(s.Router())
defer srv.Close()
sum := md5.Sum([]byte("irrelevant" + "salt"))
resp := get(t, srv.URL, tokenParams("extuser", "salt", hex.EncodeToString(sum[:])))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrTokenAuthNotSupported {
t.Fatalf("expected error 41 for external-auth-only user, got %+v", x)
}
}
func TestClientVersionTooNewFails(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("v", "99.0.0")
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrClientMustUpgrade {
t.Fatalf("expected error 20 for too-new client version, got %+v", x)
}
}
func TestOlderClientVersionAccepted(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("v", "1.1.0")
resp := get(t, srv.URL, q)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("older client version should be accepted, got %+v", x)
}
}
func TestUnknownEndpoint(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/thisEndpointDoesNotExist.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
t.Fatalf("expected error 70 for unknown endpoint, got %+v", x)
}
}
func TestViewSuffixOptional(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/ping", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("expected /rest/ping (no .view) to work, got %+v", x)
}
}
func TestGetLicenseAlwaysValid(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/getLicense.view", q)
j := decodeJSON(t, resp)
license, ok := j["license"].(map[string]any)
if !ok {
t.Fatalf("missing license in response: %v", j)
}
if valid, _ := license["valid"].(bool); !valid {
t.Fatalf("license.valid = %v, want true", license["valid"])
}
}
+39
View File
@@ -0,0 +1,39 @@
package subsonic
import (
"database/sql"
"log/slog"
"net/http"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
)
// Server holds every dependency the Subsonic REST API needs. It mirrors
// internal/web.Web's role for the browser UI: a thin adapter, not a
// second copy of the domain logic in internal/database/internal/library/
// internal/scanner/internal/streaming/internal/coverart.
type Server struct {
DB *sql.DB
Config config.Config
EncryptionKey []byte
Scanner *scanner.Scanner
CoverArt *coverart.Store
Log *slog.Logger
}
// Context carries one request's parsed parameters, resolved user, and
// output format through a handler.
type Context struct {
W http.ResponseWriter
R *http.Request
Server *Server
User *database.User
Params params
Format string
Callback string
Response *Response // set by success handlers; ignored if Handled is true
Handled bool // true if the handler already wrote the response itself (streaming endpoints)
}
+19
View File
@@ -0,0 +1,19 @@
package subsonic
func init() {
register("ping", handlePing)
register("getLicense", handleGetLicense)
}
func handlePing(rc *Context) *apiError {
rc.Response = newResponse()
return nil
}
// handleGetLicense always reports a valid, unlimited license: VibeSonic
// is free software and never requires a commercial Subsonic license.
func handleGetLicense(rc *Context) *apiError {
rc.Response = newResponse()
rc.Response.License = &License{Valid: true}
return nil
}
+69
View File
@@ -0,0 +1,69 @@
package subsonic
import (
"database/sql"
"io"
"log/slog"
"net/http/httptest"
"path/filepath"
"testing"
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/config"
"git.noroutes.net/NoRoutes/vibetunes/internal/coverart"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
)
func newTestServer(t *testing.T) (*Server, *sql.DB) {
t.Helper()
db, err := database.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("database.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
key, err := vsauth.EncryptionKey(db, "test-secret")
if err != nil {
t.Fatalf("EncryptionKey: %v", err)
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
s := &Server{
DB: db,
Config: config.Config{},
EncryptionKey: key,
Scanner: scanner.New(db, &metadata.Prober{}, log),
CoverArt: coverart.New(t.TempDir(), ""),
Log: log,
}
return s, db
}
// createTestUser creates a user with the given password (via
// auth.SetUserPassword, so both the Argon2id hash and the encrypted
// Subsonic-token-auth secret are populated, matching production).
func createTestUser(t *testing.T, s *Server, username, password string, isAdmin bool) *database.User {
t.Helper()
u, err := database.CreateUser(s.DB, username, "", "", isAdmin)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := vsauth.SetUserPassword(s.DB, s.EncryptionKey, u.ID, password); err != nil {
t.Fatalf("SetUserPassword: %v", err)
}
u, err = database.GetUserByID(s.DB, u.ID)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
return u
}
func newTestRecorderServer(t *testing.T) (*Server, *httptest.Server) {
t.Helper()
s, _ := newTestServer(t)
srv := httptest.NewServer(s.Router())
t.Cleanup(srv.Close)
return s, srv
}
+2 -2
View File
@@ -36,12 +36,12 @@ func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/users", http.StatusFound)
return
}
hash, err := auth.HashPassword(password)
user, err := database.CreateUser(h.DB, username, "", "", isAdmin)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if _, err := database.CreateUser(h.DB, username, "", hash, isAdmin); err != nil {
if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, password); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
+5 -6
View File
@@ -137,12 +137,7 @@ func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
return
}
hash, err := auth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := database.CreateUser(h.DB, username, "", hash, true)
user, err := database.CreateUser(h.DB, username, "", "", true)
if err != nil {
render(w, "setup", PageData{
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
@@ -151,6 +146,10 @@ func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
})
return
}
if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, password); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
folder, err := database.CreateMusicFolder(h.DB, folderName, folderPath, "music")
if err != nil {
+6
View File
@@ -32,6 +32,11 @@ func newTestWeb(t *testing.T) (*Web, *sql.DB) {
extauth := &auth.ExtAuth{DB: db, Config: config.ExtAuthConfig{}}
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
key, err := auth.EncryptionKey(db, "test-secret")
if err != nil {
t.Fatalf("EncryptionKey: %v", err)
}
h := &Web{
DB: db,
Config: config.Config{},
@@ -40,6 +45,7 @@ func newTestWeb(t *testing.T) (*Web, *sql.DB) {
Middleware: mw,
Scanner: scanner.New(db, &metadata.Prober{}, log),
CoverArt: coverart.New(t.TempDir(), ""),
EncryptionKey: key,
Log: log,
}
return h, db
+1 -6
View File
@@ -52,12 +52,7 @@ func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
h.renderSettings(w, r, "", "New password must be at least 8 characters.", "danger")
return
}
hash, err := auth.HashPassword(newPassword)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := database.SetUserPassword(h.DB, user.ID, hash); err != nil {
if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, newPassword); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
+1
View File
@@ -21,6 +21,7 @@ type Web struct {
Middleware *auth.Middleware
Scanner *scanner.Scanner
CoverArt *coverart.Store
EncryptionKey []byte // for auth.SetUserPassword; see internal/auth/subsonic_secret.go
Log *slog.Logger
}
+34
View File
@@ -0,0 +1,34 @@
-- Phase 2: Subsonic REST API support.
--
-- Adds the Subsonic permission model to users, per-user music folder
-- restrictions, and a recoverable (encrypted, not plaintext) copy of the
-- password for legacy token/salt authentication (t = md5(password+salt)),
-- which is mathematically impossible to verify against a one-way Argon2id
-- hash alone.
ALTER TABLE users ADD COLUMN role_settings INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN role_stream INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN role_download INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN role_upload INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN role_playlist INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN role_cover_art INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN role_comment INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN role_podcast INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN role_share INTEGER NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN role_jukebox INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN role_video_conversion INTEGER NOT NULL DEFAULT 0;
-- Encrypted (AES-GCM, server-side key) copy of the current password,
-- refreshed whenever the plaintext is available (login, password set/
-- change). Never store the plaintext itself. NULL for accounts with no
-- local password (external-auth-only), which therefore cannot use
-- Subsonic token/password authentication until a local password is set.
ALTER TABLE users ADD COLUMN subsonic_auth_secret TEXT;
-- Per-user music folder restriction. No rows for a user means
-- unrestricted (sees every folder) -- the common case.
CREATE TABLE user_music_folders (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
music_folder_id TEXT NOT NULL REFERENCES music_folders(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, music_folder_id)
);