diff --git a/internal/app/app.go b/internal/app/app.go index 4d62666..2a17142 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,15 +18,17 @@ 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" ) type App struct { - Cfg config.Config - DB *sql.DB - Log *slog.Logger - Scanner *scanner.Scanner - Web *web.Web + Cfg config.Config + DB *sql.DB + Log *slog.Logger + Scanner *scanner.Scanner + Web *web.Web + Subsonic *subsonic.Server } // Open initializes the database and every subsystem, and imports @@ -61,18 +63,34 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { mw := &auth.Middleware{Sessions: sessions, ExtAuth: extauth} csrf := &auth.CSRF{CookieSecure: cfg.CookieSecure} - webApp := &web.Web{ - DB: db, - Config: cfg, - Sessions: sessions, - CSRF: csrf, - Middleware: mw, - Scanner: sc, - CoverArt: covers, - Log: log, + encryptionKey, err := auth.EncryptionKey(db, cfg.SessionSecret) + if err != nil { + db.Close() + return nil, fmt.Errorf("deriving auth encryption key: %w", err) } - return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp}, nil + webApp := &web.Web{ + DB: db, + Config: cfg, + Sessions: sessions, + CSRF: csrf, + Middleware: mw, + Scanner: sc, + CoverArt: covers, + EncryptionKey: encryptionKey, + Log: log, + } + + subsonicSrv := &subsonic.Server{ + DB: db, + Config: cfg, + EncryptionKey: encryptionKey, + Scanner: sc, + CoverArt: covers, + Log: log, + } + + return &App{Cfg: cfg, DB: db, Log: log, Scanner: sc, Web: webApp, Subsonic: subsonicSrv}, nil } func (a *App) Close() error { @@ -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) diff --git a/internal/auth/subsonic_secret.go b/internal/auth/subsonic_secret.go new file mode 100644 index 0000000..3d135eb --- /dev/null +++ b/internal/auth/subsonic_secret.go @@ -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 +} diff --git a/internal/auth/userpassword.go b/internal/auth/userpassword.go new file mode 100644 index 0000000..e8c9dd8 --- /dev/null +++ b/internal/auth/userpassword.go @@ -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) +} diff --git a/internal/database/users.go b/internal/database/users.go index 86c2509..f2ec538 100644 --- a/internal/database/users.go +++ b/internal/database/users.go @@ -8,16 +8,42 @@ 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 - Email string - PasswordHash sql.NullString - IsAdmin bool - Disabled bool - ExternalAuth bool - CreatedAt time.Time - LastLoginAt sql.NullTime + ID string + Username string + Email string + PasswordHash sql.NullString + 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") @@ -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,39 +128,68 @@ 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() { - var u User - var isAdmin, disabled, external 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 { + u, err := scanUserRow(rows) + if err != nil { return nil, err } - u.IsAdmin = isAdmin != 0 - u.Disabled = disabled != 0 - u.ExternalAuth = external != 0 - u.CreatedAt, _ = parseTime(createdAt) - if lastLogin.Valid { - if t, err := parseTime(lastLogin.String); err == nil { - u.LastLoginAt = sql.NullTime{Time: t, Valid: true} - } - } - out = append(out, &u) + 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 + err := rows.Scan(&u.ID, &u.Username, &u.Email, &u.PasswordHash, &isAdmin, &disabled, &external, + &pSettings, &pStream, &pDownload, &pUpload, &pPlaylist, &pCoverArt, &pComment, &pPodcast, &pShare, &pJukebox, &pVideoConv, + &u.SubsonicAuthSecret, &createdAt, &lastLogin) + if err != nil { + return nil, err + } + u.IsAdmin = isAdmin != 0 + u.Disabled = disabled != 0 + u.ExternalAuth = external != 0 + u.Permissions = Permissions{ + Settings: pSettings != 0, Stream: pStream != 0, Download: pDownload != 0, Upload: pUpload != 0, + Playlist: pPlaylist != 0, CoverArt: pCoverArt != 0, Comment: pComment != 0, Podcast: pPodcast != 0, + Share: pShare != 0, Jukebox: pJukebox != 0, VideoConversion: pVideoConv != 0, + } + u.CreatedAt, _ = parseTime(createdAt) + if lastLogin.Valid { + if t, err := parseTime(lastLogin.String); err == nil { + u.LastLoginAt = sql.NullTime{Time: t, Valid: true} + } + } + return &u, nil +} + func SetUserPassword(db *sql.DB, userID, passwordHash string) error { _, err := db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID) 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 diff --git a/internal/subsonic/auth.go b/internal/subsonic/auth.go new file mode 100644 index 0000000..1bb5782 --- /dev/null +++ b/internal/subsonic/auth.go @@ -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= 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 +} diff --git a/internal/subsonic/errors.go b/internal/subsonic/errors.go new file mode 100644 index 0000000..8c6a03c --- /dev/null +++ b/internal/subsonic/errors.go @@ -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") +) diff --git a/internal/subsonic/model.go b/internal/subsonic/model.go new file mode 100644 index 0000000..594052d --- /dev/null +++ b/internal/subsonic/model.go @@ -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"` +} diff --git a/internal/subsonic/model_browsing.go b/internal/subsonic/model_browsing.go new file mode 100644 index 0000000..e0100ac --- /dev/null +++ b/internal/subsonic/model_browsing.go @@ -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"` +} diff --git a/internal/subsonic/model_lists.go b/internal/subsonic/model_lists.go new file mode 100644 index 0000000..cb4f792 --- /dev/null +++ b/internal/subsonic/model_lists.go @@ -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"` +} diff --git a/internal/subsonic/model_media.go b/internal/subsonic/model_media.go new file mode 100644 index 0000000..34b0d5c --- /dev/null +++ b/internal/subsonic/model_media.go @@ -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"` +} diff --git a/internal/subsonic/model_misc.go b/internal/subsonic/model_misc.go new file mode 100644 index 0000000..bb5ea07 --- /dev/null +++ b/internal/subsonic/model_misc.go @@ -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"` +} diff --git a/internal/subsonic/model_playlists.go b/internal/subsonic/model_playlists.go new file mode 100644 index 0000000..d9d835c --- /dev/null +++ b/internal/subsonic/model_playlists.go @@ -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"` +} diff --git a/internal/subsonic/model_users.go b/internal/subsonic/model_users.go new file mode 100644 index 0000000..1344573 --- /dev/null +++ b/internal/subsonic/model_users.go @@ -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"` +} diff --git a/internal/subsonic/params.go b/internal/subsonic/params.go new file mode 100644 index 0000000..500ef9e --- /dev/null +++ b/internal/subsonic/params.go @@ -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 +} diff --git a/internal/subsonic/response.go b/internal/subsonic/response.go new file mode 100644 index 0000000..d437a2b --- /dev/null +++ b/internal/subsonic/response.go @@ -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 +} diff --git a/internal/subsonic/router.go b/internal/subsonic/router.go new file mode 100644 index 0000000..c595f5b --- /dev/null +++ b/internal/subsonic/router.go @@ -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) +} diff --git a/internal/subsonic/router_test.go b/internal/subsonic/router_test.go new file mode 100644 index 0000000..412195e --- /dev/null +++ b/internal/subsonic/router_test.go @@ -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"]) + } +} diff --git a/internal/subsonic/server.go b/internal/subsonic/server.go new file mode 100644 index 0000000..9fd284e --- /dev/null +++ b/internal/subsonic/server.go @@ -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) +} diff --git a/internal/subsonic/system.go b/internal/subsonic/system.go new file mode 100644 index 0000000..b71d7ed --- /dev/null +++ b/internal/subsonic/system.go @@ -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 +} diff --git a/internal/subsonic/testutil_test.go b/internal/subsonic/testutil_test.go new file mode 100644 index 0000000..1f60c9b --- /dev/null +++ b/internal/subsonic/testutil_test.go @@ -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 +} diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go index b447d50..17216c6 100644 --- a/internal/web/admin_handlers.go +++ b/internal/web/admin_handlers.go @@ -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 } diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index 716ce3c..24530e9 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -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 { diff --git a/internal/web/router_test.go b/internal/web/router_test.go index 42bce56..cfa96ae 100644 --- a/internal/web/router_test.go +++ b/internal/web/router_test.go @@ -32,15 +32,21 @@ 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{}, - Sessions: sessions, - CSRF: &auth.CSRF{CookieSecure: false}, - Middleware: mw, - Scanner: scanner.New(db, &metadata.Prober{}, log), - CoverArt: coverart.New(t.TempDir(), ""), - Log: log, + DB: db, + Config: config.Config{}, + Sessions: sessions, + CSRF: &auth.CSRF{CookieSecure: false}, + Middleware: mw, + Scanner: scanner.New(db, &metadata.Prober{}, log), + CoverArt: coverart.New(t.TempDir(), ""), + EncryptionKey: key, + Log: log, } return h, db } diff --git a/internal/web/settings_handlers.go b/internal/web/settings_handlers.go index 89abbe1..8f038ea 100644 --- a/internal/web/settings_handlers.go +++ b/internal/web/settings_handlers.go @@ -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 } diff --git a/internal/web/web.go b/internal/web/web.go index c72ac95..6b5e829 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -14,14 +14,15 @@ import ( ) type Web struct { - DB *sql.DB - Config config.Config - Sessions *auth.Sessions - CSRF *auth.CSRF - Middleware *auth.Middleware - Scanner *scanner.Scanner - CoverArt *coverart.Store - Log *slog.Logger + DB *sql.DB + Config config.Config + Sessions *auth.Sessions + CSRF *auth.CSRF + Middleware *auth.Middleware + Scanner *scanner.Scanner + CoverArt *coverart.Store + EncryptionKey []byte // for auth.SetUserPassword; see internal/auth/subsonic_secret.go + Log *slog.Logger } // page renders a template with the standard envelope (current user, CSRF diff --git a/migrations/0002_subsonic.sql b/migrations/0002_subsonic.sql new file mode 100644 index 0000000..150d0ab --- /dev/null +++ b/migrations/0002_subsonic.sql @@ -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) +);