Skip to content

Commit

Permalink
Merge pull request #437 from sbondCo/user-management
Browse files Browse the repository at this point in the history
User management
  • Loading branch information
IRHM authored Mar 25, 2024
2 parents ac1ac76 + 06ddda1 commit 564788a
Show file tree
Hide file tree
Showing 13 changed files with 542 additions and 130 deletions.
2 changes: 2 additions & 0 deletions server/arr.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type RadarrTestResponse struct {
LanguageProfiles []arr.LanguageProfile `json:"languageProfiles"`
}

// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
sonarr := arr.New(arr.SONARR, &p.Host, &p.Key)
qps, err := sonarr.GetQualityProfiles()
Expand All @@ -76,6 +77,7 @@ func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
return SonarrTestResponse{QualityProfiles: qps, RootFolders: rfs, LanguageProfiles: lps}, nil
}

// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
func testRadarr(p ArrTestParams) (RadarrTestResponse, error) {
radarr := arr.New(arr.RADARR, &p.Host, &p.Key)
qps, err := radarr.GetQualityProfiles()
Expand Down
15 changes: 15 additions & 0 deletions server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,21 @@ func AdminRequired() gin.HandlerFunc {
}
}

// Specific perm only middleware (use after AuthRequired with extra info!)
func PermRequired(perm int) gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetUint("userId")
perms := c.GetInt("userPermissions")
if hasPermission(perms, perm) {
slog.Debug("PermRequired: User has permission to access perm only route", "user_id", userId, "required_perm", perm)
c.Next()
return
}
slog.Info("PermRequired: User denied permission to access perm only route", "user_id", userId, "required_perm", perm)
c.AbortWithStatus(401)
}
}

func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
if !Config.SIGNUP_ENABLED {
slog.Warn("Register called, but signing up is disabled.")
Expand Down
17 changes: 7 additions & 10 deletions server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,13 @@ func getEnabledFeatures(userPerms int) ServerFeatures {
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
f.Games = true
}
// https://github.com/sbondCo/Watcharr/issues/211
// Currently requesting permissions have not been setup, only admins for now.
if !hasPermission(userPerms, PERM_ADMIN) {
return f
}
if len(Config.SONARR) > 0 {
f.Sonarr = true
}
if len(Config.RADARR) > 0 {
f.Radarr = true
if hasPermission(userPerms, PERM_REQUEST_CONTENT) {
if len(Config.SONARR) > 0 {
f.Sonarr = true
}
if len(Config.RADARR) > 0 {
f.Radarr = true
}
}
return f
}
Expand Down
68 changes: 52 additions & 16 deletions server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,38 @@ func (b *BaseRouter) addServerRoutes() {
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
c.JSON(http.StatusOK, getServerStats(b.db))
}))

// Get all server users (for manage users page)
server.GET("/users", func(c *gin.Context) {
resp, err := getAllUsers(b.db)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, resp)
})

// Edit a user (for manage users page)
server.POST("/users/:id", func(c *gin.Context) {
userId, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
slog.Error("/users/:id failed to parse id as a uint", "error", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to parse id"})
return
}
var ur UpdateUserRequest
err = c.ShouldBindJSON(&ur)
if err == nil {
err := manageUser(b.db, uint(userId), ur)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
}

func (b *BaseRouter) addFeatureRoutes() {
Expand All @@ -973,10 +1005,12 @@ func (b *BaseRouter) addFeatureRoutes() {
}

func (b *BaseRouter) addSonarrRoutes() {
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db), AdminRequired())
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db))

// Routes are manually given AdminRequired or PermRequired middleware.

// Test configuration
s.POST("/test", func(c *gin.Context) {
s.POST("/test", AdminRequired(), func(c *gin.Context) {
var ur ArrTestParams
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand All @@ -992,7 +1026,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})

// Used to get config for specific server (quality profile, root folder, etc)
s.GET("/config/:name", func(c *gin.Context) {
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
server, err := getSonarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
Expand All @@ -1007,7 +1041,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})

// Add sonarr server into config
s.POST("/add", func(c *gin.Context) {
s.POST("/add", AdminRequired(), func(c *gin.Context) {
var ur SonarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand All @@ -1023,7 +1057,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})

// Edit sonarr servers config
s.POST("/edit", func(c *gin.Context) {
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
var ur SonarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand All @@ -1039,7 +1073,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})

// Remove sonarr server
s.POST("/rm/:name", func(c *gin.Context) {
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
err := rmSonarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
Expand All @@ -1049,13 +1083,13 @@ func (b *BaseRouter) addSonarrRoutes() {
})

// Get safe config for all sonarr servers
s.GET("", func(c *gin.Context) {
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
response := getSonarrsSafe()
c.JSON(http.StatusOK, response)
})

// Request a show
s.POST("/request", func(c *gin.Context) {
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
var ur arr.SonarrRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand All @@ -1079,10 +1113,12 @@ func (b *BaseRouter) addSonarrRoutes() {
}

func (b *BaseRouter) addRadarrRoutes() {
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db), AdminRequired())
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db))

// Routes are manually given AdminRequired or PermRequired middleware.

// Test configuration
s.POST("/test", func(c *gin.Context) {
s.POST("/test", AdminRequired(), func(c *gin.Context) {
var ur ArrTestParams
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand All @@ -1098,7 +1134,7 @@ func (b *BaseRouter) addRadarrRoutes() {
})

// Get config for specific server
s.GET("/config/:name", func(c *gin.Context) {
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
server, err := getRadarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
Expand All @@ -1112,7 +1148,7 @@ func (b *BaseRouter) addRadarrRoutes() {
c.JSON(http.StatusOK, resp)
})

s.POST("/add", func(c *gin.Context) {
s.POST("/add", AdminRequired(), func(c *gin.Context) {
var ur RadarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand All @@ -1127,7 +1163,7 @@ func (b *BaseRouter) addRadarrRoutes() {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})

s.POST("/edit", func(c *gin.Context) {
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
var ur RadarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand All @@ -1142,7 +1178,7 @@ func (b *BaseRouter) addRadarrRoutes() {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})

s.POST("/rm/:name", func(c *gin.Context) {
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
err := rmRadarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
Expand All @@ -1151,12 +1187,12 @@ func (b *BaseRouter) addRadarrRoutes() {
c.Status(http.StatusOK)
})

s.GET("", func(c *gin.Context) {
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
response := getRadarrsSafe()
c.JSON(http.StatusOK, response)
})

s.POST("/request", func(c *gin.Context) {
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
var ur arr.RadarrRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
Expand Down
58 changes: 58 additions & 0 deletions server/user_manage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"errors"
"log/slog"
"time"

"gorm.io/gorm"
)

// User details wanted for management views.
type ManagedUser struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Username string `json:"username"`
Type UserType `json:"type"`
Permissions int `json:"permissions"`
Private bool `json:"private"`
}

type UpdateUserRequest struct {
Permissions *int `json:"permissions"`
}

func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
users := []ManagedUser{}
if res := db.Model(&User{}).Find(&users); res.Error != nil {
slog.Error("getAllUsers: Failed to fetch users from database", "error", res.Error)
return []ManagedUser{}, errors.New("failed to fetch users from database")
}
return users, nil
}

// Update a user. For management views, for admin to update another user.
func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
// Error now if no userId or any UpdateUserRequest property was provided.
if userId == 0 || (ur.Permissions == nil) {
slog.Error("manageUser: invalid arguments", "user_id", userId)
return errors.New("invalid arguments, ensure a valid userId and at least one property has been provided for updating")
}
toUpdate := map[string]interface{}{}
if ur.Permissions != nil {
if *ur.Permissions == 0 {
// If removing all perms, set to default of 1 (PERM_NONE).
// Will avoid confusion and possibly bugs later on, though I doubt
// we'd ever be (directly) checking a user to ensure they have no perms.
toUpdate["permissions"] = PERM_NONE
} else {
toUpdate["permissions"] = *ur.Permissions
}
}
if res := db.Model(&User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
slog.Error("manageUser: failed to update user in database", "user_id", userId, "error", res.Error)
return errors.New("failed to update user in database")
}
slog.Debug("manageUser: A user has been updated", "user_id", userId)
return nil
}
93 changes: 93 additions & 0 deletions src/norm.scss
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,96 @@
}
}
}

:global(table) {
margin-top: 20px;
width: 100%;
border-spacing: 0px;
border: 1px solid $accent-color;
border-radius: 10px;
font-size: 16px;

th {
padding: 12px 15px;
text-align: left;
transition: padding 100ms ease;

&:first-of-type {
border-top-left-radius: 10px;
}

&:last-of-type {
border-top-right-radius: 10px;
}

&.loading-col {
width: 28px;
padding: 0;
}
}

tr {
th {
background-color: $accent-color;
}

&:last-child {
td:first-of-type {
border-bottom-left-radius: 10px;
}

td:last-of-type {
border-bottom-right-radius: 10px;
}
}

&:nth-child(odd) td {
background-color: $accent-color;
}
}

td {
padding: 5px;

&.icon-cell {
padding-right: 3px;

& > div {
display: flex;
padding-left: 4px;
}
}

input {
background: transparent;
color: $text-color;
border: 0;
font-size: 16px;
padding: 0;
padding: 7px 10px;
transition: padding 100ms ease;

&[type="number"] {
appearance: textfield;

&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}

button {
&.delete {
display: flex;
justify-content: center;
color: $placeholder-color;

&:hover {
color: $error;
}
}
}
}
}
Loading

0 comments on commit 564788a

Please sign in to comment.