-
-
Notifications
You must be signed in to change notification settings - Fork 873
enhance: serialize user update methods #1042
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,15 +12,17 @@ import ( | |
| "github.com/gotify/server/v3/model" | ||
| ) | ||
|
|
||
| var errCannotDeleteLastAdmin = errors.New("cannot delete last admin") | ||
|
|
||
| // The UserDatabase interface for encapsulating database access. | ||
| type UserDatabase interface { | ||
| GetUsers() ([]*model.User, error) | ||
| type UserDatabase[T UserDatabase[T]] interface { | ||
| Txn(fn func(txdb T) error) error | ||
| GetUsers(condition ...any) ([]*model.User, error) | ||
| GetUserByID(id uint) (*model.User, error) | ||
| GetUserByName(name string) (*model.User, error) | ||
| DeleteUserByID(id uint) error | ||
| UpdateUser(user *model.User) error | ||
| CreateUser(user *model.User) error | ||
| CountUser(condition ...any) (int64, error) | ||
| } | ||
|
|
||
| // UserChangeNotifier notifies listeners for user changes. | ||
|
|
@@ -58,8 +60,8 @@ func (c *UserChangeNotifier) fireUserAdded(uid uint) error { | |
| } | ||
|
|
||
| // The UserAPI provides handlers for managing users. | ||
| type UserAPI struct { | ||
| DB UserDatabase | ||
| type UserAPI[T UserDatabase[T]] struct { | ||
| DB T | ||
| PasswordStrength int | ||
| UserChangeNotifier *UserChangeNotifier | ||
| Registration bool | ||
|
|
@@ -90,7 +92,7 @@ type UserAPI struct { | |
| // description: Forbidden | ||
| // schema: | ||
| // $ref: "#/definitions/Error" | ||
| func (a *UserAPI) GetUsers(ctx *gin.Context) { | ||
| func (a *UserAPI[T]) GetUsers(ctx *gin.Context) { | ||
| users, err := a.DB.GetUsers() | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
| return | ||
|
|
@@ -126,7 +128,7 @@ func (a *UserAPI) GetUsers(ctx *gin.Context) { | |
| // description: Forbidden | ||
| // schema: | ||
| // $ref: "#/definitions/Error" | ||
| func (a *UserAPI) GetCurrentUser(ctx *gin.Context) { | ||
| func (a *UserAPI[T]) GetCurrentUser(ctx *gin.Context) { | ||
| user, err := a.DB.GetUserByID(auth.GetUserID(ctx)) | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
| return | ||
|
|
@@ -185,7 +187,7 @@ func (a *UserAPI) GetCurrentUser(ctx *gin.Context) { | |
| // description: Forbidden | ||
| // schema: | ||
| // $ref: "#/definitions/Error" | ||
| func (a *UserAPI) CreateUser(ctx *gin.Context) { | ||
| func (a *UserAPI[T]) CreateUser(ctx *gin.Context) { | ||
| user := model.CreateUserExternal{} | ||
| if err := ctx.Bind(&user); err == nil { | ||
| if err := password.ValidateNewPassword(user.Pass); err != nil { | ||
|
|
@@ -286,7 +288,7 @@ func (a *UserAPI) CreateUser(ctx *gin.Context) { | |
| // description: Not Found | ||
| // schema: | ||
| // $ref: "#/definitions/Error" | ||
| func (a *UserAPI) GetUserByID(ctx *gin.Context) { | ||
| func (a *UserAPI[T]) GetUserByID(ctx *gin.Context) { | ||
| withID(ctx, "id", func(id uint) { | ||
| user, err := a.DB.GetUserByID(id) | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
|
|
@@ -336,26 +338,41 @@ func (a *UserAPI) GetUserByID(ctx *gin.Context) { | |
| // description: Not Found | ||
| // schema: | ||
| // $ref: "#/definitions/Error" | ||
| func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { | ||
| func (a *UserAPI[T]) DeleteUserByID(ctx *gin.Context) { | ||
| withID(ctx, "id", func(id uint) { | ||
| user, err := a.DB.GetUserByID(id) | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
| return | ||
| } | ||
| if user != nil { | ||
| adminCount, err := a.DB.CountUser(&model.User{Admin: true}) | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
| return | ||
| } | ||
| if user.Admin && adminCount == 1 { | ||
| ctx.AbortWithError(400, errors.New("cannot delete last admin")) | ||
| return | ||
| } | ||
| if err := a.UserChangeNotifier.fireUserDeleted(id); err != nil { | ||
| ctx.AbortWithError(500, err) | ||
| return | ||
| for range 3 { | ||
| commitError := false | ||
| err = a.DB.Txn(func(txdb T) error { | ||
| if success := successOrAbort(ctx, 500, txdb.DeleteUserByID(id)); !success { | ||
| return err | ||
| } | ||
|
Comment on lines
+351
to
+353
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After the transaction commits/aborts there is another ctx.AbortWithError(500, err). successOrAbort already Aborts the ctx. The context should only be aborted once, as I think otherwise multiple errors are printed to the body. |
||
| anotherAdmin, err := txdb.GetUsers(&model.User{Admin: true}) | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
| return err | ||
| } | ||
| if user.Admin && len(anotherAdmin) == 0 { | ||
| ctx.AbortWithError(400, errCannotDeleteLastAdmin) | ||
| return errCannotDeleteLastAdmin | ||
| } | ||
| if success := successOrAbort(ctx, 500, a.UserChangeNotifier.fireUserDeleted(id)); !success { | ||
| return err | ||
| } | ||
| commitError = true | ||
| return nil | ||
| }) | ||
| if !commitError || err == nil { | ||
| break | ||
| } | ||
|
Comment on lines
+368
to
+370
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It took me a while to understand this case, maybe it could be done like this with explicit definition what the boolean is used for? func (a *UserAPI[T]) DeleteUserByID(ctx *gin.Context) {
withID(ctx, "id", func(id uint) {
user, err := a.DB.GetUserByID(id)
if success := successOrAbort(ctx, 500, err); !success {
return
}
if user == nil {
ctx.AbortWithError(404, errors.New("user does not exist"))
return
}
for range 3 {
retryable := true
err = a.DB.Txn(func(txdb T) error {
if err := txdb.DeleteUserByID(id); err != nil {
return err
}
anotherAdmin, err := txdb.GetUsers(&model.User{Admin: true})
if err != nil {
return err
}
if user.Admin && len(anotherAdmin) == 0 {
retryable = false
return errCannotDeleteLastAdmin
}
if err := a.UserChangeNotifier.fireUserDeleted(id); err != nil {
retryable = false
return err
}
return nil
})
if err == nil {
// user deleted successfully
ctx.Status(200)
return
}
if retryable {
continue
}
if err != nil {
status := 500
if errors.Is(err, errCannotDeleteLastAdmin) {
status = 400
}
ctx.AbortWithError(status, err)
return
}
}
})
}
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think only commit errors should be retryable (temporary serialization failures). If regular statements returned errors it means there is something wrong with the database connection or underlying data, we should just return immediately.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, then only retry then. Can it use the adjusted statements so than err == null is a separate statement and commitError == true does an explicit continue? (I think I still prefer calling it retryable) I had trouble understanding the retry condition. |
||
| if err != nil { | ||
| ctx.AbortWithError(500, err) | ||
| return | ||
| } | ||
| } | ||
| successOrAbort(ctx, 500, a.DB.DeleteUserByID(id)) | ||
| } else { | ||
| ctx.AbortWithError(404, errors.New("user does not exist")) | ||
| } | ||
|
|
@@ -395,7 +412,7 @@ func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { | |
| // description: Forbidden | ||
| // schema: | ||
| // $ref: "#/definitions/Error" | ||
| func (a *UserAPI) ChangePassword(ctx *gin.Context) { | ||
| func (a *UserAPI[T]) ChangePassword(ctx *gin.Context) { | ||
| pw := model.UserExternalPass{} | ||
| if err := ctx.Bind(&pw); err == nil { | ||
| if err := password.ValidateNewPassword(pw.Pass); err != nil { | ||
|
|
@@ -461,7 +478,7 @@ func (a *UserAPI) ChangePassword(ctx *gin.Context) { | |
| // description: Not Found | ||
| // schema: | ||
| // $ref: "#/definitions/Error" | ||
| func (a *UserAPI) UpdateUserByID(ctx *gin.Context) { | ||
| func (a *UserAPI[T]) UpdateUserByID(ctx *gin.Context) { | ||
| withID(ctx, "id", func(id uint) { | ||
| var updatedUser *model.UpdateUserExternal | ||
| if err := ctx.Bind(&updatedUser); err == nil { | ||
|
|
@@ -470,15 +487,7 @@ func (a *UserAPI) UpdateUserByID(ctx *gin.Context) { | |
| return | ||
| } | ||
| if dbUser != nil { | ||
| adminCount, err := a.DB.CountUser(&model.User{Admin: true}) | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
| return | ||
| } | ||
| if !updatedUser.Admin && dbUser.Admin && adminCount == 1 { | ||
| ctx.AbortWithError(400, errors.New("cannot delete last admin")) | ||
| return | ||
| } | ||
|
|
||
| dbUserWasAdmin := dbUser.Admin | ||
| dbUser.Name = updatedUser.Name | ||
| dbUser.Admin = updatedUser.Admin | ||
|
|
||
|
|
@@ -494,10 +503,37 @@ func (a *UserAPI) UpdateUserByID(ctx *gin.Context) { | |
| } | ||
| dbUser.Pass = pw | ||
| } | ||
| if success := successOrAbort(ctx, 500, a.DB.UpdateUser(dbUser)); !success { | ||
| return | ||
|
|
||
| for range 3 { | ||
| commitError := false | ||
|
|
||
| err = a.DB.Txn(func(txdb T) error { | ||
| if success := successOrAbort(ctx, 500, txdb.UpdateUser(dbUser)); !success { | ||
| return err | ||
| } | ||
|
|
||
| anotherAdmin, err := txdb.GetUsers(&model.User{Admin: true}) | ||
| if success := successOrAbort(ctx, 500, err); !success { | ||
| return err | ||
| } | ||
| if !updatedUser.Admin && dbUserWasAdmin && len(anotherAdmin) == 0 { | ||
| ctx.AbortWithError(400, errCannotDeleteLastAdmin) | ||
| return errCannotDeleteLastAdmin | ||
| } | ||
|
|
||
| commitError = true | ||
|
|
||
| return nil | ||
| }) | ||
|
|
||
| if !commitError || err == nil { | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if err == nil { | ||
| ctx.JSON(200, toExternalUser(dbUser)) | ||
| } | ||
| ctx.JSON(200, toExternalUser(dbUser)) | ||
| } else { | ||
| ctx.AbortWithError(404, errors.New("user does not exist")) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we use database.GormDatabase directly here? I don't think we need the interface here. I think I previously added this when the DB was mocked, but this isn't done anymore.