Subsonic: user management endpoints (getUser, getUsers, createUser, updateUser, deleteUser, changePassword) with full permission model mapping

This commit is contained in:
2026-08-23 00:22:44 -05:00
parent d92a25b141
commit 8d93bea384
5 changed files with 452 additions and 0 deletions
@@ -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
+284
View File
@@ -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
}
+155
View File
@@ -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)
}
}