Subsonic: user management endpoints (getUser, getUsers, createUser, updateUser, deleteUser, changePassword) with full permission model mapping
This commit is contained in:
@@ -280,3 +280,9 @@ func nullStringVal(s sql.NullString) any {
|
|||||||
func parseTime(s string) (time.Time, error) {
|
func parseTime(s string) (time.Time, error) {
|
||||||
return time.Parse("2006-01-02T15:04:05.999999999Z", s)
|
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
|
||||||
|
}
|
||||||
|
|||||||
+7
@@ -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
|
||||||
BIN
Binary file not shown.
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user