Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 72 additions & 36 deletions api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

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.

PasswordStrength int
UserChangeNotifier *UserChangeNotifier
Registration bool
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
			}
		}
	})
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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"))
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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

Expand All @@ -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"))
}
Expand Down
15 changes: 2 additions & 13 deletions api/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestUserSuite(t *testing.T) {
type UserSuite struct {
suite.Suite
db *testdb.Database
a *UserAPI
a *UserAPI[*testdb.Database]
ctx *gin.Context
recorder *httptest.ResponseRecorder
notifiedAdd bool
Expand All @@ -49,7 +49,7 @@ func (s *UserSuite) BeforeTest(suiteName, testName string) {
s.notifiedAdd = true
return nil
})
s.a = &UserAPI{DB: s.db, UserChangeNotifier: s.notifier}
s.a = &UserAPI[*testdb.Database]{DB: s.db, UserChangeNotifier: s.notifier}
}

func (s *UserSuite) AfterTest(suiteName, testName string) {
Expand Down Expand Up @@ -350,17 +350,6 @@ func (s *UserSuite) Test_UpdateUserByID_InvalidID() {
assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_UpdateUserByID_EmptyPassword_Expect400() {
s.loginAdmin()

s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}

s.ctx.Request = httptest.NewRequest("POST", "/user/1", strings.NewReader(`{"name": "admin", "pass": "", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.UpdateUserByID(s.ctx)
assert.Equal(s.T(), 400, s.recorder.Code)
}

func (s *UserSuite) Test_UpdateUserByID_TooLongPassword_Expect400() {
s.loginAdmin()

Expand Down
12 changes: 11 additions & 1 deletion database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,14 +172,24 @@ func createDirectoryIfSqlite(dialect, connection string) {

// GormDatabase is a wrapper for the gorm framework.
type GormDatabase struct {
DB *gorm.DB
DB *gorm.DB
Nested bool
}

// Close closes the gorm database connection.
func (d *GormDatabase) Close() {
if d.Nested {
return
}
sqldb, err := d.DB.DB()
if err != nil {
return
}
sqldb.Close()
}

func (d *GormDatabase) Txn(fn func(txdb *GormDatabase) error) error {
return d.DB.Transaction(func(tx *gorm.DB) error {
return fn(&GormDatabase{DB: tx, Nested: true})
}, &sql.TxOptions{Isolation: sql.LevelSerializable})
}
18 changes: 7 additions & 11 deletions database/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,19 @@ func (d *GormDatabase) GetUserByID(id uint) (*model.User, error) {
return nil, err
}

// CountUser returns the user count which satisfies the given condition.
func (d *GormDatabase) CountUser(condition ...any) (int64, error) {
c := int64(-1)
// GetUsers returns the users which satisfy the given condition.
func (d *GormDatabase) GetUsers(condition ...any) ([]*model.User, error) {
users := make([]*model.User, 0)
handle := d.DB.Model(new(model.User))
if len(condition) == 1 {
handle = handle.Where(condition[0])
} else if len(condition) > 1 {
handle = handle.Where(condition[0], condition[1:]...)
}
err := handle.Count(&c).Error
return c, err
}

// GetUsers returns all users.
func (d *GormDatabase) GetUsers() ([]*model.User, error) {
var users []*model.User
err := d.DB.Find(&users).Error
err := handle.Find(&users).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return users, err
}

Expand Down
13 changes: 7 additions & 6 deletions database/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ func (s *DatabaseSuite) TestUser() {
require.NoError(s.T(), err)
assert.NotNil(s.T(), jmattheis, "on bootup the first user should be automatically created")

adminCount, err := s.db.CountUser("admin = ?", true)
admins, err := s.db.GetUsers("admin = ?", true)
require.NoError(s.T(), err)
assert.Equal(s.T(), int64(1), adminCount, "there is initially one admin")
assert.Len(s.T(), admins, 1)
assert.True(s.T(), admins[0].Admin, "the admin user should be an admin")

users, err := s.db.GetUsers()
require.NoError(s.T(), err)
Expand All @@ -31,9 +32,9 @@ func (s *DatabaseSuite) TestUser() {
nicories := &model.User{Name: "nicories", Pass: []byte{1, 2, 3, 4}, Admin: false}
s.db.CreateUser(nicories)
assert.NotEqual(s.T(), 0, nicories.ID, "on create user a new id should be assigned")
userCount, err := s.db.CountUser()
users, err = s.db.GetUsers()
require.NoError(s.T(), err)
assert.Equal(s.T(), int64(2), userCount, "two users should exist")
assert.Len(s.T(), users, 2, "two users should exist")

user, err = s.db.GetUserByName("nicories")
require.NoError(s.T(), err)
Expand All @@ -58,9 +59,9 @@ func (s *DatabaseSuite) TestUser() {
require.NoError(s.T(), err)
assert.Len(s.T(), users, 2)

adminCount, err = s.db.CountUser(&model.User{Admin: true})
admins, err = s.db.GetUsers(&model.User{Admin: true})
require.NoError(s.T(), err)
assert.Equal(s.T(), int64(2), adminCount, "two admins exist")
assert.Len(s.T(), admins, 2, "two admins exist")

require.NoError(s.T(), s.db.DeleteUserByID(tom.ID))
users, err = s.db.GetUsers()
Expand Down
2 changes: 1 addition & 1 deletion plugin/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import (

// The Database interface for encapsulating database access.
type Database interface {
GetUsers() ([]*model.User, error)
GetUsers(condition ...any) ([]*model.User, error)
GetPluginConfByUserAndPath(userid uint, path string) (*model.PluginConf, error)
CreatePluginConf(p *model.PluginConf) error
GetPluginConfByApplicationID(appid uint) (*model.PluginConf, error)
Expand Down
2 changes: 1 addition & 1 deletion router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
}
sessionHandler := api.SessionAPI{DB: db, NotifyDeleted: streamHandler.NotifyDeletedClient, SecureCookie: conf.Server.SecureCookie, LocalAuthEnabled: conf.LocalAuthEnabled}
userChangeNotifier := new(api.UserChangeNotifier)
userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration}
userHandler := api.UserAPI[*database.GormDatabase]{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration}

pluginManager, err := plugin.NewManager(db, conf.PluginsDir, g.Group("/plugin/:id/custom/"), streamHandler)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions test/testdb/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ type Database struct {
t *testing.T
}

func (d *Database) Txn(fn func(txdb *Database) error) error {
return d.GormDatabase.Txn(func(txdb *database.GormDatabase) error {
return fn(&Database{GormDatabase: txdb, t: d.t})
})
}

// AppClientBuilder has helper methods to create applications and clients.
type AppClientBuilder struct {
userID uint
Expand Down
Loading