diff --git a/internal/database/users.go b/internal/database/users.go index f2ec538..032fbed 100644 --- a/internal/database/users.go +++ b/internal/database/users.go @@ -280,3 +280,9 @@ func nullStringVal(s sql.NullString) any { func parseTime(s string) (time.Time, error) { return time.Parse("2006-01-02T15:04:05.999999999Z", s) } + +// SetUserEmail updates a user's email address. +func SetUserEmail(db *sql.DB, userID, email string) error { + _, err := db.Exec(`UPDATE users SET email = ? WHERE id = ?`, email, userID) + return err +} diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2343943835_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2343943835_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2343943835_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/index.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:1.023322, +seg00000.ts +#EXT-X-ENDLIST diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2343943835_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2343943835_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000..8423fdc Binary files /dev/null and b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist2343943835_003_Test_Artist_Test_Album_Second_Song_mp3_0_aac/seg00000.ts differ diff --git a/internal/subsonic/users.go b/internal/subsonic/users.go new file mode 100644 index 0000000..3a4b883 --- /dev/null +++ b/internal/subsonic/users.go @@ -0,0 +1,284 @@ +package subsonic + +import ( + "encoding/hex" + "strconv" + "strings" + + vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth" + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func init() { + register("getUser", handleGetUser) + register("getUsers", handleGetUsers) + register("createUser", handleCreateUser) + register("updateUser", handleUpdateUser) + register("deleteUser", handleDeleteUser) + register("changePassword", handleChangePassword) +} + +func userToModel(rc *Context, u *database.User) User { + db := rc.Server.DB + m := User{ + Username: u.Username, + Email: u.Email, + ScrobblingEnabled: true, + AdminRole: u.IsAdmin, + SettingsRole: u.Allows(u.Permissions.Settings), + DownloadRole: u.Allows(u.Permissions.Download), + UploadRole: u.Allows(u.Permissions.Upload), + PlaylistRole: u.Allows(u.Permissions.Playlist), + CoverArtRole: u.Allows(u.Permissions.CoverArt), + CommentRole: u.Allows(u.Permissions.Comment), + PodcastRole: u.Allows(u.Permissions.Podcast), + StreamRole: u.Allows(u.Permissions.Stream), + JukeboxRole: u.Allows(u.Permissions.Jukebox), + ShareRole: u.Allows(u.Permissions.Share), + VideoConversionRole: u.Allows(u.Permissions.VideoConversion), + } + if fm, err := loadFolderMap(db); err == nil { + allowedIDs, _ := database.AllowedMusicFolderIDs(db, u.ID) + if len(allowedIDs) == 0 { + for _, f := range fm.folders { + n, _ := strconv.Atoi(fm.IntID(f.ID)) + m.Folder = append(m.Folder, n) + } + } else { + for _, id := range allowedIDs { + n, _ := strconv.Atoi(fm.IntID(id)) + m.Folder = append(m.Folder, n) + } + } + } + return m +} + +func handleGetUser(rc *Context) *apiError { + username, aerr := rc.Params.Required("username") + if aerr != nil { + return aerr + } + if username != rc.User.Username && !rc.User.IsAdmin { + return errUnauthorized("viewing another user's account") + } + target, err := database.GetUserByUsername(rc.Server.DB, username) + if err != nil { + return errNotFound("User") + } + model := userToModel(rc, target) + rc.Response = newResponse() + rc.Response.User = &model + return nil +} + +func handleGetUsers(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("listing users") + } + users, err := database.ListUsers(rc.Server.DB) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + out := &Users{} + for _, u := range users { + out.User = append(out.User, userToModel(rc, u)) + } + rc.Response = newResponse() + rc.Response.Users = out + return nil +} + +// permissionsFromParams builds a Permissions set for a brand-new user: +// any role param not present defaults to false (an explicit opt-in +// model for account creation). +func permissionsFromParams(p params) database.Permissions { + get := func(name string) bool { return p.Bool(name, false) } + return database.Permissions{ + Settings: get("settingsRole"), + Stream: get("streamRole"), + Download: get("downloadRole"), + Upload: get("uploadRole"), + Playlist: get("playlistRole"), + CoverArt: get("coverArtRole"), + Comment: get("commentRole"), + Podcast: get("podcastRole"), + Share: get("shareRole"), + Jukebox: get("jukeboxRole"), + VideoConversion: get("videoConversionRole"), + } +} + +// mergePermissionsFromParams overlays only the role params actually +// present in the request onto existing, so updateUser calls that omit +// some roles (e.g. a client only toggling one checkbox) don't silently +// revoke the rest. +func mergePermissionsFromParams(p params, existing database.Permissions) database.Permissions { + set := func(name string, cur bool) bool { + if !p.Has(name) { + return cur + } + return p.Bool(name, cur) + } + return database.Permissions{ + Settings: set("settingsRole", existing.Settings), + Stream: set("streamRole", existing.Stream), + Download: set("downloadRole", existing.Download), + Upload: set("uploadRole", existing.Upload), + Playlist: set("playlistRole", existing.Playlist), + CoverArt: set("coverArtRole", existing.CoverArt), + Comment: set("commentRole", existing.Comment), + Podcast: set("podcastRole", existing.Podcast), + Share: set("shareRole", existing.Share), + Jukebox: set("jukeboxRole", existing.Jukebox), + VideoConversion: set("videoConversionRole", existing.VideoConversion), + } +} + +func decodePassword(raw string) string { + if strings.HasPrefix(raw, "enc:") { + if decoded, err := hex.DecodeString(strings.TrimPrefix(raw, "enc:")); err == nil { + return string(decoded) + } + } + return raw +} + +func handleCreateUser(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("creating users") + } + username, aerr := rc.Params.Required("username") + if aerr != nil { + return aerr + } + rawPassword, aerr := rc.Params.Required("password") + if aerr != nil { + return aerr + } + email := rc.Params.Get("email") + isAdmin := rc.Params.Bool("adminRole", false) + + user, err := database.CreateUser(rc.Server.DB, username, email, "", isAdmin) + if err != nil { + return newError(ErrGeneric, "could not create user: "+err.Error()) + } + if err := vsauth.SetUserPassword(rc.Server.DB, rc.Server.EncryptionKey, user.ID, decodePassword(rawPassword)); err != nil { + return newError(ErrGeneric, err.Error()) + } + if err := database.SetUserPermissions(rc.Server.DB, user.ID, permissionsFromParams(rc.Params)); err != nil { + return newError(ErrGeneric, err.Error()) + } + if folderIDs := rc.Params.GetAll("musicFolderId"); len(folderIDs) > 0 { + if err := applyFolderRestriction(rc, user.ID, folderIDs); err != nil { + return err + } + } + + rc.Response = newResponse() + return nil +} + +func applyFolderRestriction(rc *Context, userID string, folderIntIDs []string) *apiError { + fm, err := loadFolderMap(rc.Server.DB) + if err != nil { + return newError(ErrGeneric, err.Error()) + } + var ids []string + for _, s := range folderIntIDs { + if f := fm.ByIntID(s); f != nil { + ids = append(ids, f.ID) + } + } + if err := database.SetAllowedMusicFolders(rc.Server.DB, userID, ids); err != nil { + return newError(ErrGeneric, err.Error()) + } + return nil +} + +func handleUpdateUser(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("modifying users") + } + username, aerr := rc.Params.Required("username") + if aerr != nil { + return aerr + } + target, err := database.GetUserByUsername(rc.Server.DB, username) + if err != nil { + return errNotFound("User") + } + + if email := rc.Params.Get("email"); email != "" { + if err := database.SetUserEmail(rc.Server.DB, target.ID, email); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + if rc.Params.Has("adminRole") { + if err := database.SetUserAdmin(rc.Server.DB, target.ID, rc.Params.Bool("adminRole", target.IsAdmin)); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + if rc.Params.Has("password") { + if err := vsauth.SetUserPassword(rc.Server.DB, rc.Server.EncryptionKey, target.ID, decodePassword(rc.Params.Get("password"))); err != nil { + return newError(ErrGeneric, err.Error()) + } + } + merged := mergePermissionsFromParams(rc.Params, target.Permissions) + if err := database.SetUserPermissions(rc.Server.DB, target.ID, merged); err != nil { + return newError(ErrGeneric, err.Error()) + } + if folderIDs := rc.Params.GetAll("musicFolderId"); len(folderIDs) > 0 { + if aerr := applyFolderRestriction(rc, target.ID, folderIDs); aerr != nil { + return aerr + } + } + + rc.Response = newResponse() + return nil +} + +func handleDeleteUser(rc *Context) *apiError { + if !rc.User.IsAdmin { + return errUnauthorized("deleting users") + } + username, aerr := rc.Params.Required("username") + if aerr != nil { + return aerr + } + target, err := database.GetUserByUsername(rc.Server.DB, username) + if err != nil { + return errNotFound("User") + } + if target.ID == rc.User.ID { + return newError(ErrGeneric, "cannot delete your own account") + } + if err := database.DeleteUser(rc.Server.DB, target.ID); err != nil { + return newError(ErrGeneric, err.Error()) + } + rc.Response = newResponse() + return nil +} + +func handleChangePassword(rc *Context) *apiError { + username, aerr := rc.Params.Required("username") + if aerr != nil { + return aerr + } + rawPassword, aerr := rc.Params.Required("password") + if aerr != nil { + return aerr + } + if username != rc.User.Username && !rc.User.IsAdmin { + return errUnauthorized("changing another user's password") + } + target, err := database.GetUserByUsername(rc.Server.DB, username) + if err != nil { + return errNotFound("User") + } + if err := vsauth.SetUserPassword(rc.Server.DB, rc.Server.EncryptionKey, target.ID, decodePassword(rawPassword)); err != nil { + return newError(ErrGeneric, err.Error()) + } + rc.Response = newResponse() + return nil +} diff --git a/internal/subsonic/users_test.go b/internal/subsonic/users_test.go new file mode 100644 index 0000000..de807fd --- /dev/null +++ b/internal/subsonic/users_test.go @@ -0,0 +1,155 @@ +package subsonic + +import "testing" + +func TestCreateUserAndGetUser(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "admin", "adminpassword1", true) + + q := baseParams("admin", "adminpassword1") + q.Set("username", "newbie") + q.Set("password", "newbiepass123") + q.Set("email", "newbie@example.com") + q.Set("streamRole", "true") + q.Set("downloadRole", "false") + resp := getPath(t, srv.URL, "/rest/createUser.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("createUser failed: %+v", x) + } + + q2 := baseParams("admin", "adminpassword1") + q2.Set("username", "newbie") + q2.Set("f", "json") + resp = getPath(t, srv.URL, "/rest/getUser.view", q2) + j := decodeJSON(t, resp) + u := j["user"].(map[string]any) + if u["email"] != "newbie@example.com" { + t.Fatalf("email = %v", u["email"]) + } + if u["streamRole"] != true { + t.Fatalf("streamRole = %v, want true", u["streamRole"]) + } + if u["downloadRole"] != false { + t.Fatalf("downloadRole = %v, want false", u["downloadRole"]) + } + if u["adminRole"] != false { + t.Fatalf("adminRole = %v, want false", u["adminRole"]) + } + + // The new user can log in with the password we set. + loginQ := baseParams("newbie", "newbiepass123") + resp = getPath(t, srv.URL, "/rest/ping.view", loginQ) + x = decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("new user login failed: %+v", x) + } +} + +func TestGetUserForbiddenForOthers(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + createTestUser(t, s, "bob", "password123", false) + + q := baseParams("alice", "password123") + q.Set("username", "bob") + resp := getPath(t, srv.URL, "/rest/getUser.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50, got %+v", x) + } +} + +func TestGetUsersRequiresAdmin(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + resp := getPath(t, srv.URL, "/rest/getUsers.view", baseParams("alice", "password123")) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50, got %+v", x) + } +} + +func TestUpdateUserPreservesUnspecifiedRoles(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "admin", "adminpassword1", true) + + q := baseParams("admin", "adminpassword1") + q.Set("username", "bob") + q.Set("password", "bobpassword12") + q.Set("streamRole", "true") + q.Set("downloadRole", "true") + getPath(t, srv.URL, "/rest/createUser.view", q) + + q2 := baseParams("admin", "adminpassword1") + q2.Set("username", "bob") + q2.Set("email", "bob@example.com") + // Deliberately omit streamRole/downloadRole to verify they survive. + resp := getPath(t, srv.URL, "/rest/updateUser.view", q2) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("updateUser failed: %+v", x) + } + + q3 := baseParams("admin", "adminpassword1") + q3.Set("username", "bob") + q3.Set("f", "json") + resp = getPath(t, srv.URL, "/rest/getUser.view", q3) + j := decodeJSON(t, resp) + u := j["user"].(map[string]any) + if u["streamRole"] != true || u["downloadRole"] != true { + t.Fatalf("roles should be preserved across update, got %v", u) + } + if u["email"] != "bob@example.com" { + t.Fatalf("email = %v, want updated", u["email"]) + } +} + +func TestChangePasswordSelf(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + + q := baseParams("alice", "password123") + q.Set("username", "alice") + q.Set("password", "newpassword456") + resp := getPath(t, srv.URL, "/rest/changePassword.view", q) + x := decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("changePassword failed: %+v", x) + } + + resp = getPath(t, srv.URL, "/rest/ping.view", baseParams("alice", "newpassword456")) + x = decodeXML(t, resp) + if x.Status != "ok" { + t.Fatalf("login with new password failed: %+v", x) + } +} + +func TestChangePasswordOtherUserForbidden(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "alice", "password123", false) + createTestUser(t, s, "bob", "password123", false) + + q := baseParams("alice", "password123") + q.Set("username", "bob") + q.Set("password", "hacked12345") + resp := getPath(t, srv.URL, "/rest/changePassword.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized { + t.Fatalf("expected error 50, got %+v", x) + } +} + +func TestDeleteUserCannotDeleteSelf(t *testing.T) { + s, srv := newTestRecorderServer(t) + createTestUser(t, s, "admin", "adminpassword1", true) + + q := baseParams("admin", "adminpassword1") + q.Set("username", "admin") + resp := getPath(t, srv.URL, "/rest/deleteUser.view", q) + x := decodeXML(t, resp) + if x.Status != "failed" { + t.Fatalf("expected failure deleting own account, got %+v", x) + } +}