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
+38 -16
View File
@@ -18,15 +18,17 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/database" "git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/metadata" "git.noroutes.net/NoRoutes/vibetunes/internal/metadata"
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner" "git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
"git.noroutes.net/NoRoutes/vibetunes/internal/subsonic"
"git.noroutes.net/NoRoutes/vibetunes/internal/web" "git.noroutes.net/NoRoutes/vibetunes/internal/web"
) )
type App struct { type App struct {
Cfg config.Config Cfg config.Config
DB *sql.DB DB *sql.DB
Log *slog.Logger Log *slog.Logger
Scanner *scanner.Scanner Scanner *scanner.Scanner
Web *web.Web Web *web.Web
Subsonic *subsonic.Server
} }
// Open initializes the database and every subsystem, and imports // Open initializes the database and every subsystem, and imports
@@ -61,18 +63,34 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth} mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure} csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure}
webApp := &web.Web{ encryptionKey, err := auth.EncryptionKey(db, cfg.SessionSecret)
DB: db, if err != nil {
Config: cfg, db.Close()
Sessions: sessions, return nil, fmt.Errorf("deriving auth encryption key: %w", err)
CSRF: csrf,
Middleware: mw,
Scanner: sc,
CoverArt: covers,
Log: log,
} }
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp}, nil webApp := &web.Web{
DB: db,
Config: cfg,
Sessions: sessions,
CSRF: csrf,
Middleware: mw,
Scanner: sc,
CoverArt: covers,
EncryptionKey: encryptionKey,
Log: log,
}
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 { 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{ srv := &http.Server{
Addr: a.Cfg.ListenAddr, Addr: a.Cfg.ListenAddr,
Handler: a.Web.NewRouter(), Handler: mux,
} }
errCh := make(chan error, 1) 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)
}
+151 -29
View File
@@ -8,16 +8,42 @@ import (
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen" "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 { type User struct {
ID string ID string
Username string Username string
Email string Email string
PasswordHash sql.NullString PasswordHash sql.NullString
IsAdmin bool IsAdmin bool
Disabled bool Disabled bool
ExternalAuth bool ExternalAuth bool
CreatedAt time.Time Permissions Permissions
LastLoginAt sql.NullTime 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") var ErrNotFound = errors.New("not found")
@@ -28,8 +54,14 @@ func CountUsers(db *sql.DB) (int, error) {
return n, err 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 // 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) { func CreateUser(db *sql.DB, username, email, passwordHash string, isAdmin bool) (*User, error) {
id := idgen.New("us") id := idgen.New("us")
var hash sql.NullString 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) { 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) { 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) { func scanUser(row *sql.Row) (*User, error) {
var u User var u User
var isAdmin, disabled, external int var isAdmin, disabled, external int
var pSettings, pStream, pDownload, pUpload, pPlaylist, pCoverArt, pComment, pPodcast, pShare, pJukebox, pVideoConv int
var createdAt string var createdAt string
var lastLogin sql.NullString 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -78,6 +113,11 @@ func scanUser(row *sql.Row) (*User, error) {
u.IsAdmin = isAdmin != 0 u.IsAdmin = isAdmin != 0
u.Disabled = disabled != 0 u.Disabled = disabled != 0
u.ExternalAuth = external != 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) u.CreatedAt, _ = parseTime(createdAt)
if lastLogin.Valid { if lastLogin.Valid {
if t, err := parseTime(lastLogin.String); err == nil { if t, err := parseTime(lastLogin.String); err == nil {
@@ -88,39 +128,68 @@ func scanUser(row *sql.Row) (*User, error) {
} }
func ListUsers(db *sql.DB) ([]*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 { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var out []*User var out []*User
for rows.Next() { for rows.Next() {
var u User u, err := scanUserRow(rows)
var isAdmin, disabled, external int if err != nil {
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 {
return nil, err return nil, err
} }
u.IsAdmin = isAdmin != 0 out = append(out, u)
u.Disabled = disabled != 0
u.ExternalAuth = external != 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 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
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}
}
}
return &u, nil
}
func SetUserPassword(db *sql.DB, userID, passwordHash string) error { func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
_, err := db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID) _, err := db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
return err 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 { func SetUserDisabled(db *sql.DB, userID string, disabled bool) error {
_, err := db.Exec(`UPDATE users SET disabled = ? WHERE id = ?`, boolInt(disabled), userID) _, err := db.Exec(`UPDATE users SET disabled = ? WHERE id = ?`, boolInt(disabled), userID)
return err return err
@@ -131,6 +200,18 @@ func SetUserAdmin(db *sql.DB, userID string, isAdmin bool) error {
return err 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 { func DeleteUser(db *sql.DB, userID string) error {
_, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID) _, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID)
return err return err
@@ -141,6 +222,47 @@ func TouchLastLogin(db *sql.DB, userID string) error {
return err 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 { func boolInt(b bool) int {
if b { if b {
return 1 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) http.Redirect(w, r, "/admin/users", http.StatusFound)
return return
} }
hash, err := auth.HashPassword(password) user, err := database.CreateUser(h.DB, username, "", "", isAdmin)
if err != nil { if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
if _, err := database.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) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
+5 -6
View File
@@ -137,12 +137,7 @@ func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
return return
} }
hash, err := auth.HashPassword(password) user, err := database.CreateUser(h.DB, username, "", "", true)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := database.CreateUser(h.DB, username, "", hash, true)
if err != nil { if err != nil {
render(w, "setup", PageData{ render(w, "setup", PageData{
Title: "Setup", CSRFToken: h.CSRF.Token(w, r), Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
@@ -151,6 +146,10 @@ func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
}) })
return 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") folder, err := database.CreateMusicFolder(h.DB, folderName, folderPath, "music")
if err != nil { if err != nil {
+14 -8
View File
@@ -32,15 +32,21 @@ func newTestWeb(t *testing.T) (*Web, *sql.DB) {
extauth := &auth.ExtAuth{DB: db, Config: config.ExtAuthConfig{}} extauth := &auth.ExtAuth{DB: db, Config: config.ExtAuthConfig{}}
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth} mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
key, err := auth.EncryptionKey(db, "test-secret")
if err != nil {
t.Fatalf("EncryptionKey: %v", err)
}
h := &Web{ h := &Web{
DB: db, DB: db,
Config: config.Config{}, Config: config.Config{},
Sessions: sessions, Sessions: sessions,
CSRF: &auth.CSRF{CookieSecure: false}, CSRF: &auth.CSRF{CookieSecure: false},
Middleware: mw, Middleware: mw,
Scanner: scanner.New(db, &metadata.Prober{}, log), Scanner: scanner.New(db, &metadata.Prober{}, log),
CoverArt: coverart.New(t.TempDir(), ""), CoverArt: coverart.New(t.TempDir(), ""),
Log: log, EncryptionKey: key,
Log: log,
} }
return h, db 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") h.renderSettings(w, r, "", "New password must be at least 8 characters.", "danger")
return return
} }
hash, err := auth.HashPassword(newPassword) if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, newPassword); err != nil {
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := database.SetUserPassword(h.DB, user.ID, hash); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
+9 -8
View File
@@ -14,14 +14,15 @@ import (
) )
type Web struct { type Web struct {
DB *sql.DB DB *sql.DB
Config config.Config Config config.Config
Sessions *auth.Sessions Sessions *auth.Sessions
CSRF *auth.CSRF CSRF *auth.CSRF
Middleware *auth.Middleware Middleware *auth.Middleware
Scanner *scanner.Scanner Scanner *scanner.Scanner
CoverArt *coverart.Store CoverArt *coverart.Store
Log *slog.Logger EncryptionKey []byte // for auth.SetUserPassword; see internal/auth/subsonic_secret.go
Log *slog.Logger
} }
// page renders a template with the standard envelope (current user, CSRF // page renders a template with the standard envelope (current user, CSRF
+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)
);