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/metadata"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/scanner"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/subsonic"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/web"
|
||||
)
|
||||
|
||||
@@ -27,6 +28,7 @@ type App struct {
|
||||
Log *slog.Logger
|
||||
Scanner *scanner.Scanner
|
||||
Web *web.Web
|
||||
Subsonic *subsonic.Server
|
||||
}
|
||||
|
||||
// Open initializes the database and every subsystem, and imports
|
||||
@@ -61,6 +63,12 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
||||
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
|
||||
csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure}
|
||||
|
||||
encryptionKey, err := auth.EncryptionKey(db, cfg.SessionSecret)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("deriving auth encryption key: %w", err)
|
||||
}
|
||||
|
||||
webApp := &web.Web{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
@@ -69,10 +77,20 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) {
|
||||
Middleware: mw,
|
||||
Scanner: sc,
|
||||
CoverArt: covers,
|
||||
EncryptionKey: encryptionKey,
|
||||
Log: log,
|
||||
}
|
||||
|
||||
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp}, nil
|
||||
subsonicSrv := &subsonic.Server{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
EncryptionKey: encryptionKey,
|
||||
Scanner: sc,
|
||||
CoverArt: covers,
|
||||
Log: log,
|
||||
}
|
||||
|
||||
return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp, Subsonic: subsonicSrv}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
@@ -126,9 +144,13 @@ func (a *App) Run(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/rest/", a.Subsonic.Router())
|
||||
mux.Handle("/", a.Web.NewRouter())
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: a.Cfg.ListenAddr,
|
||||
Handler: a.Web.NewRouter(),
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// Permissions mirrors the Subsonic REST API's per-user role model.
|
||||
// AdminRole is represented separately by User.IsAdmin (an admin
|
||||
// implicitly has every permission below regardless of these flags).
|
||||
type Permissions struct {
|
||||
Settings bool
|
||||
Stream bool
|
||||
Download bool
|
||||
Upload bool
|
||||
Playlist bool
|
||||
CoverArt bool
|
||||
Comment bool
|
||||
Podcast bool
|
||||
Share bool
|
||||
Jukebox bool
|
||||
VideoConversion bool
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
@@ -16,10 +33,19 @@ type User struct {
|
||||
IsAdmin bool
|
||||
Disabled bool
|
||||
ExternalAuth bool
|
||||
Permissions Permissions
|
||||
SubsonicAuthSecret sql.NullString // AES-GCM encrypted password, for Subsonic token auth; see internal/subsonic
|
||||
CreatedAt time.Time
|
||||
LastLoginAt sql.NullTime
|
||||
}
|
||||
|
||||
// Allows reports whether the user may perform the action gated by
|
||||
// permission p — admins can do everything regardless of individual
|
||||
// flags.
|
||||
func (u *User) Allows(p bool) bool {
|
||||
return u.IsAdmin || p
|
||||
}
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
func CountUsers(db *sql.DB) (int, error) {
|
||||
@@ -28,8 +54,14 @@ func CountUsers(db *sql.DB) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
const userColumns = `id, username, email, password_hash, is_admin, disabled, external_auth,
|
||||
role_settings, role_stream, role_download, role_upload, role_playlist, role_cover_art,
|
||||
role_comment, role_podcast, role_share, role_jukebox, role_video_conversion,
|
||||
subsonic_auth_secret, created_at, last_login_at`
|
||||
|
||||
// CreateUser inserts a new user. passwordHash may be empty for
|
||||
// external-auth-only accounts.
|
||||
// external-auth-only accounts. New non-admin users get the default
|
||||
// permission set (stream/download/playlist/coverArt/comment/share).
|
||||
func CreateUser(db *sql.DB, username, email, passwordHash string, isAdmin bool) (*User, error) {
|
||||
id := idgen.New("us")
|
||||
var hash sql.NullString
|
||||
@@ -56,19 +88,22 @@ func CreateExternalUser(db *sql.DB, username string) (*User, error) {
|
||||
}
|
||||
|
||||
func GetUserByID(db *sql.DB, id string) (*User, error) {
|
||||
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE id = ?`, id))
|
||||
return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||
return scanUser(db.QueryRow(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users WHERE username = ?`, username))
|
||||
return scanUser(db.QueryRow(`SELECT `+userColumns+` FROM users WHERE username = ?`, username))
|
||||
}
|
||||
|
||||
func scanUser(row *sql.Row) (*User, error) {
|
||||
var u User
|
||||
var isAdmin, disabled, external int
|
||||
var pSettings, pStream, pDownload, pUpload, pPlaylist, pCoverArt, pComment, pPodcast, pShare, pJukebox, pVideoConv int
|
||||
var createdAt string
|
||||
var lastLogin sql.NullString
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin)
|
||||
err := row.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external,
|
||||
&pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv,
|
||||
&u.SubsonicAuthSecret, &createdAt, &lastLogin)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -78,6 +113,11 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
u.IsAdmin = isAdmin != 0
|
||||
u.Disabled = disabled != 0
|
||||
u.ExternalAuth = external != 0
|
||||
u.Permissions = Permissions{
|
||||
Settings: pSettings != 0, Stream: pStream != 0, Download: pDownload != 0, Upload: pUpload != 0,
|
||||
Playlist: pPlaylist != 0, CoverArt: pCoverArt != 0, Comment: pComment != 0, Podcast: pPodcast != 0,
|
||||
Share: pShare != 0, Jukebox: pJukebox != 0, VideoConversion: pVideoConv != 0,
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(createdAt)
|
||||
if lastLogin.Valid {
|
||||
if t, err := parseTime(lastLogin.String); err == nil {
|
||||
@@ -88,32 +128,49 @@ func scanUser(row *sql.Row) (*User, error) {
|
||||
}
|
||||
|
||||
func ListUsers(db *sql.DB) ([]*User, error) {
|
||||
rows, err := db.Query(`SELECT id, username, email, password_hash, is_admin, disabled, external_auth, created_at, last_login_at FROM users ORDER BY username COLLATE NOCASE`)
|
||||
rows, err := db.Query(`SELECT ` + userColumns + ` FROM users ORDER BY username COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*User
|
||||
for rows.Next() {
|
||||
u, err := scanUserRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanUserRow(rows *sql.Rows) (*User, error) {
|
||||
var u User
|
||||
var isAdmin, disabled, external int
|
||||
var pSettings, pStream, pDownload, pUpload, pPlaylist, pCoverArt, pComment, pPodcast, pShare, pJukebox, pVideoConv int
|
||||
var createdAt string
|
||||
var lastLogin sql.NullString
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, &createdAt, &lastLogin); err != nil {
|
||||
err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external,
|
||||
&pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv,
|
||||
&u.SubsonicAuthSecret, &createdAt, &lastLogin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.IsAdmin = isAdmin != 0
|
||||
u.Disabled = disabled != 0
|
||||
u.ExternalAuth = external != 0
|
||||
u.Permissions = Permissions{
|
||||
Settings: pSettings != 0, Stream: pStream != 0, Download: pDownload != 0, Upload: pUpload != 0,
|
||||
Playlist: pPlaylist != 0, CoverArt: pCoverArt != 0, Comment: pComment != 0, Podcast: pPodcast != 0,
|
||||
Share: pShare != 0, Jukebox: pJukebox != 0, VideoConversion: pVideoConv != 0,
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(createdAt)
|
||||
if lastLogin.Valid {
|
||||
if t, err := parseTime(lastLogin.String); err == nil {
|
||||
u.LastLoginAt = sql.NullTime{Time: t, Valid: true}
|
||||
}
|
||||
}
|
||||
out = append(out, &u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
|
||||
@@ -121,6 +178,18 @@ func SetUserPassword(db *sql.DB, userID, passwordHash string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUserSubsonicAuthSecret stores (or clears, with encrypted="") the
|
||||
// AES-GCM encrypted copy of the user's current plaintext password, used
|
||||
// only to verify legacy Subsonic token authentication.
|
||||
func SetUserSubsonicAuthSecret(db *sql.DB, userID, encrypted string) error {
|
||||
var v any
|
||||
if encrypted != "" {
|
||||
v = encrypted
|
||||
}
|
||||
_, err := db.Exec(`UPDATE users SET subsonic_auth_secret = ? WHERE id = ?`, v, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func SetUserDisabled(db *sql.DB, userID string, disabled bool) error {
|
||||
_, err := db.Exec(`UPDATE users SET disabled = ? WHERE id = ?`, boolInt(disabled), userID)
|
||||
return err
|
||||
@@ -131,6 +200,18 @@ func SetUserAdmin(db *sql.DB, userID string, isAdmin bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetUserPermissions updates the full Subsonic permission set for a user.
|
||||
func SetUserPermissions(db *sql.DB, userID string, p Permissions) error {
|
||||
_, err := db.Exec(`UPDATE users SET
|
||||
role_settings=?, role_stream=?, role_download=?, role_upload=?, role_playlist=?,
|
||||
role_cover_art=?, role_comment=?, role_podcast=?, role_share=?, role_jukebox=?, role_video_conversion=?
|
||||
WHERE id=?`,
|
||||
boolInt(p.Settings), boolInt(p.Stream), boolInt(p.Download), boolInt(p.Upload), boolInt(p.Playlist),
|
||||
boolInt(p.CoverArt), boolInt(p.Comment), boolInt(p.Podcast), boolInt(p.Share), boolInt(p.Jukebox), boolInt(p.VideoConversion),
|
||||
userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteUser(db *sql.DB, userID string) error {
|
||||
_, err := db.Exec(`DELETE FROM users WHERE id = ?`, userID)
|
||||
return err
|
||||
@@ -141,6 +222,47 @@ func TouchLastLogin(db *sql.DB, userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Per-user music folder restrictions ---
|
||||
|
||||
// AllowedMusicFolderIDs returns the music folder IDs a user is
|
||||
// restricted to, or nil if the user has no restriction (sees every
|
||||
// folder — the default and common case).
|
||||
func AllowedMusicFolderIDs(db *sql.DB, userID string) ([]string, error) {
|
||||
rows, err := db.Query(`SELECT music_folder_id FROM user_music_folders WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetAllowedMusicFolders replaces a user's folder restriction list. An
|
||||
// empty ids slice removes all restrictions (unrestricted access).
|
||||
func SetAllowedMusicFolders(db *sql.DB, userID string, ids []string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(`DELETE FROM user_music_folders WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := tx.Exec(`INSERT INTO user_music_folders (user_id, music_folder_id) VALUES (?, ?)`, userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
|
||||
@@ -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)
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(password)
|
||||
user, err := database.CreateUser(h.DB, username, "", "", isAdmin)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := database.CreateUser(h.DB, username, "", hash, isAdmin); err != nil {
|
||||
if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, password); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,12 +137,7 @@ func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user, err := database.CreateUser(h.DB, username, "", hash, true)
|
||||
user, err := database.CreateUser(h.DB, username, "", "", true)
|
||||
if err != nil {
|
||||
render(w, "setup", PageData{
|
||||
Title: "Setup", CSRFToken: h.CSRF.Token(w, r),
|
||||
@@ -151,6 +146,10 @@ func (h *Web) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, password); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
folder, err := database.CreateMusicFolder(h.DB, folderName, folderPath, "music")
|
||||
if err != nil {
|
||||
|
||||
@@ -32,6 +32,11 @@ func newTestWeb(t *testing.T) (*Web, *sql.DB) {
|
||||
extauth := &auth.ExtAuth{DB: db, Config: config.ExtAuthConfig{}}
|
||||
mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth}
|
||||
|
||||
key, err := auth.EncryptionKey(db, "test-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptionKey: %v", err)
|
||||
}
|
||||
|
||||
h := &Web{
|
||||
DB: db,
|
||||
Config: config.Config{},
|
||||
@@ -40,6 +45,7 @@ func newTestWeb(t *testing.T) (*Web, *sql.DB) {
|
||||
Middleware: mw,
|
||||
Scanner: scanner.New(db, &metadata.Prober{}, log),
|
||||
CoverArt: coverart.New(t.TempDir(), ""),
|
||||
EncryptionKey: key,
|
||||
Log: log,
|
||||
}
|
||||
return h, db
|
||||
|
||||
@@ -52,12 +52,7 @@ func (h *Web) handlePasswordChange(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderSettings(w, r, "", "New password must be at least 8 characters.", "danger")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := database.SetUserPassword(h.DB, user.ID, hash); err != nil {
|
||||
if err := auth.SetUserPassword(h.DB, h.EncryptionKey, user.ID, newPassword); err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Web struct {
|
||||
Middleware *auth.Middleware
|
||||
Scanner *scanner.Scanner
|
||||
CoverArt *coverart.Store
|
||||
EncryptionKey []byte // for auth.SetUserPassword; see internal/auth/subsonic_secret.go
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
|
||||
@@ -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