Phase 2: Subsonic REST API framework (auth, response formatting, error handling, routing) plus user permission model
This commit is contained in:
+24
-2
@@ -18,6 +18,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ type App struct {
|
|||||||
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,6 +63,12 @@ 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}
|
||||||
|
|
||||||
|
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{
|
webApp := &web.Web{
|
||||||
DB: db,
|
DB: db,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -69,10 +77,20 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
|||||||
Middleware: mw,
|
Middleware: mw,
|
||||||
Scanner: sc,
|
Scanner: sc,
|
||||||
CoverArt: covers,
|
CoverArt: covers,
|
||||||
|
EncryptionKey: encryptionKey,
|
||||||
Log: log,
|
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 {
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -8,6 +8,23 @@ 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
|
||||||
@@ -16,10 +33,19 @@ type User struct {
|
|||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
Disabled bool
|
Disabled bool
|
||||||
ExternalAuth bool
|
ExternalAuth bool
|
||||||
|
Permissions Permissions
|
||||||
|
SubsonicAuthSecret sql.NullString // AES-GCM encrypted password, for Subsonic token auth; see internal/subsonic
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastLoginAt sql.NullTime
|
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")
|
||||||
|
|
||||||
func CountUsers(db *sql.DB) (int, error) {
|
func CountUsers(db *sql.DB) (int, error) {
|
||||||
@@ -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,32 +128,49 @@ 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() {
|
||||||
|
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 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
|
||||||
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
|
return nil, err
|
||||||
}
|
}
|
||||||
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 {
|
||||||
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
|
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, &u)
|
return &u, nil
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
|
func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
|
||||||
@@ -121,6 +178,18 @@ func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ 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{},
|
||||||
@@ -40,6 +45,7 @@ func newTestWeb(t *testing.T) (*Web, *sql.DB) {
|
|||||||
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(), ""),
|
||||||
|
EncryptionKey: key,
|
||||||
Log: log,
|
Log: log,
|
||||||
}
|
}
|
||||||
return h, db
|
return h, db
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Web struct {
|
|||||||
Middleware *auth.Middleware
|
Middleware *auth.Middleware
|
||||||
Scanner *scanner.Scanner
|
Scanner *scanner.Scanner
|
||||||
CoverArt *coverart.Store
|
CoverArt *coverart.Store
|
||||||
|
EncryptionKey []byte // for auth.SetUserPassword; see internal/auth/subsonic_secret.go
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user