Subsonic: user management endpoints (getUser, getUsers, createUser, updateUser, deleteUser, changePassword) with full permission model mapping
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user