diff --git a/models/fixtures/badge.yml b/models/fixtures/badge.yml new file mode 100644 index 0000000000000..438cd0ca5d4fe --- /dev/null +++ b/models/fixtures/badge.yml @@ -0,0 +1,5 @@ +- + id: 1 + slug: badge1 + description: just a test badge + image_url: badge1.png diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 4f899453b5f57..6542bd1c81431 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -384,8 +384,9 @@ func prepareMigrationTasks() []*migration { newMigration(319, "Add ExclusiveOrder to Label table", v1_24.AddExclusiveOrderColumnToLabelTable), newMigration(320, "Migrate two_factor_policy to login_source table", v1_24.MigrateSkipTwoFactor), - // Gitea 1.24.0 ends at database version 321 + // Gitea 1.24.0 ends at database version 321 (database version 322) newMigration(321, "Use LONGTEXT for some columns and fix review_state.updated_files column", v1_25.UseLongTextInSomeColumnsAndFixBugs), + newMigration(322, "Add unique constraint for user badge", v1_25.AddUniqueIndexForUserBadge), } return preparedMigrations } diff --git a/models/migrations/v1_25/v322.go b/models/migrations/v1_25/v322.go new file mode 100644 index 0000000000000..0494239520d6b --- /dev/null +++ b/models/migrations/v1_25/v322.go @@ -0,0 +1,62 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "fmt" + + "xorm.io/xorm" + "xorm.io/xorm/schemas" +) + +type UserBadge struct { //revive:disable-line:exported + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices +} + +// AddUniqueIndexForUserBadge adds a compound unique indexes for user badge table +// and it replaces an old index on user_id +func AddUniqueIndexForUserBadge(x *xorm.Engine) error { + // remove possible duplicated records in table user_badge + type result struct { + UserID int64 + BadgeID int64 + Cnt int + } + var results []result + if err := x.Select("user_id, badge_id, count(*) as cnt"). + Table("user_badge"). + GroupBy("user_id, badge_id"). + Having("count(*) > 1"). + Find(&results); err != nil { + return err + } + for _, r := range results { + if x.Dialect().URI().DBType == schemas.MSSQL { + if _, err := x.Exec(fmt.Sprintf("delete from user_badge where id in (SELECT top %d id FROM user_badge WHERE user_id = ? and badge_id = ?)", r.Cnt-1), r.UserID, r.BadgeID); err != nil { + return err + } + } else { + var ids []int64 + if err := x.SQL("SELECT id FROM user_badge WHERE user_id = ? and badge_id = ? limit ?", r.UserID, r.BadgeID, r.Cnt-1).Find(&ids); err != nil { + return err + } + if _, err := x.Table("user_badge").In("id", ids).Delete(); err != nil { + return err + } + } + } + + return x.Sync(new(UserBadge)) +} diff --git a/models/migrations/v1_25/v322_test.go b/models/migrations/v1_25/v322_test.go new file mode 100644 index 0000000000000..db090b77a4991 --- /dev/null +++ b/models/migrations/v1_25/v322_test.go @@ -0,0 +1,85 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_25 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" +) + +type UserBadgeBefore struct { + ID int64 `xorm:"pk autoincr"` + BadgeID int64 + UserID int64 `xorm:"INDEX"` +} + +func (UserBadgeBefore) TableName() string { + return "user_badge" +} + +func Test_AddUniqueIndexForUserBadge(t *testing.T) { + x, deferable := base.PrepareTestEnv(t, 0, new(UserBadgeBefore)) + defer deferable() + if x == nil || t.Failed() { + return + } + + testData := []*UserBadgeBefore{ + {UserID: 1, BadgeID: 1}, + {UserID: 1, BadgeID: 1}, // duplicate + {UserID: 2, BadgeID: 1}, + {UserID: 1, BadgeID: 2}, + {UserID: 3, BadgeID: 3}, + {UserID: 3, BadgeID: 3}, // duplicate + } + + for _, data := range testData { + _, err := x.Insert(data) + assert.NoError(t, err) + } + + // check that we have duplicates + count, err := x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(2), count) + + totalCount, err := x.Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(6), totalCount) + + // run the migration + if err := AddUniqueIndexForUserBadge(x); err != nil { + assert.NoError(t, err) + return + } + + // verify the duplicates were removed + count, err = x.Where("user_id = ? AND badge_id = ?", 1, 1).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) + + count, err = x.Where("user_id = ? AND badge_id = ?", 3, 3).Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(1), count) + + // check total count + totalCount, err = x.Count(&UserBadgeBefore{}) + assert.NoError(t, err) + assert.Equal(t, int64(4), totalCount) + + // fail to insert a duplicate + _, err = x.Insert(&UserBadge{UserID: 1, BadgeID: 1}) + assert.Error(t, err) + + // succeed adding a non-duplicate + _, err = x.Insert(&UserBadge{UserID: 4, BadgeID: 1}) + assert.NoError(t, err) +} diff --git a/models/user/badge.go b/models/user/badge.go index e475ceb74894d..f3204d6514e15 100644 --- a/models/user/badge.go +++ b/models/user/badge.go @@ -6,8 +6,13 @@ package user import ( "context" "fmt" + "strings" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" + "xorm.io/xorm/schemas" ) // Badge represents a user badge @@ -22,7 +27,60 @@ type Badge struct { type UserBadge struct { //nolint:revive // export stutter ID int64 `xorm:"pk autoincr"` BadgeID int64 - UserID int64 `xorm:"INDEX"` + UserID int64 +} + +// TableIndices implements xorm's TableIndices interface +func (n *UserBadge) TableIndices() []*schemas.Index { + indices := make([]*schemas.Index, 0, 1) + ubUnique := schemas.NewIndex("unique_user_badge", schemas.UniqueType) + ubUnique.AddColumn("user_id", "badge_id") + indices = append(indices, ubUnique) + return indices +} + +// ErrBadgeAlreadyExist represents a "badge already exists" error. +type ErrBadgeAlreadyExist struct { + Slug string +} + +// IsErrBadgeAlreadyExist checks if an error is a ErrBadgeAlreadyExist. +func IsErrBadgeAlreadyExist(err error) bool { + _, ok := err.(ErrBadgeAlreadyExist) + return ok +} + +func (err ErrBadgeAlreadyExist) Error() string { + return fmt.Sprintf("badge already exists [slug: %s]", err.Slug) +} + +// Unwrap unwraps this error as a ErrExist error +func (err ErrBadgeAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrBadgeNotExist represents a "BadgeNotExist" kind of error. +type ErrBadgeNotExist struct { + Slug string + ID int64 +} + +func (err ErrBadgeNotExist) Error() string { + if err.ID > 0 { + return fmt.Sprintf("badge does not exist [id: %d]", err.ID) + } + return fmt.Sprintf("badge does not exist [slug: %s]", err.Slug) +} + +// IsErrBadgeNotExist checks if an error is a ErrBadgeNotExist. +func IsErrBadgeNotExist(err error) bool { + _, ok := err.(ErrBadgeNotExist) + return ok +} + +// Unwrap unwraps this error as a ErrNotExist error +func (err ErrBadgeNotExist) Unwrap() error { + return util.ErrNotExist } func init() { @@ -42,13 +100,37 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) { return badges, count, err } +// GetBadgeUsersOptions contains options for getting users with a specific badge +type GetBadgeUsersOptions struct { + db.ListOptions + BadgeSlug string +} + +// GetBadgeUsers returns the users that have a specific badge with pagination support. +func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) { + sess := db.GetEngine(ctx). + Select("`user`.*"). + Join("INNER", "user_badge", "`user_badge`.user_id=user.id"). + Join("INNER", "badge", "`user_badge`.badge_id=badge.id"). + Where("badge.slug=?", opts.BadgeSlug) + + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, opts) + } + + users := make([]*User, 0, opts.PageSize) + count, err := sess.FindAndCount(&users) + return users, count, err +} + // CreateBadge creates a new badge. func CreateBadge(ctx context.Context, badge *Badge) error { + // this will fail if the badge already exists due to the UNIQUE constraint _, err := db.GetEngine(ctx).Insert(badge) return err } -// GetBadge returns a badge +// GetBadge returns a specific badge func GetBadge(ctx context.Context, slug string) (*Badge, error) { badge := new(Badge) has, err := db.GetEngine(ctx).Where("slug=?", slug).Get(badge) @@ -60,14 +142,26 @@ func GetBadge(ctx context.Context, slug string) (*Badge, error) { // UpdateBadge updates a badge based on its slug. func UpdateBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Update(badge) + _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Cols("description", "image_url").Update(badge) return err } -// DeleteBadge deletes a badge. +// DeleteBadge deletes a badge and all associated user_badge entries. func DeleteBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge) - return err + return db.WithTx(ctx, func(ctx context.Context) error { + // First delete all user_badge entries for this badge + if _, err := db.GetEngine(ctx). + Where("badge_id = (SELECT id FROM badge WHERE slug = ?)", badge.Slug). + Delete(&UserBadge{}); err != nil { + return err + } + + // Then delete the badge itself + if _, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Delete(badge); err != nil { + return err + } + return nil + }) } // AddUserBadge adds a badge to a user. @@ -84,7 +178,7 @@ func AddUserBadges(ctx context.Context, u *User, badges []*Badge) error { if err != nil { return err } else if !has { - return fmt.Errorf("badge with slug %s doesn't exist", badge.Slug) + return ErrBadgeNotExist{Slug: badge.Slug} } if err := db.Insert(ctx, &UserBadge{ BadgeID: badge.ID, @@ -102,16 +196,26 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error { return RemoveUserBadges(ctx, u, []*Badge{badge}) } -// RemoveUserBadges removes badges from a user. +// RemoveUserBadges removes specific badges from a user. func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error { return db.WithTx(ctx, func(ctx context.Context) error { + badgeSlugs := make([]string, 0, len(badges)) for _, badge := range badges { - if _, err := db.GetEngine(ctx). - Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). - Where("`user_badge`.user_id=? AND `badge`.slug=?", u.ID, badge.Slug). - Delete(&UserBadge{}); err != nil { - return err - } + badgeSlugs = append(badgeSlugs, badge.Slug) + } + var userBadges []UserBadge + if err := db.GetEngine(ctx).Table("user_badge"). + Join("INNER", "badge", "badge.id = `user_badge`.badge_id"). + Where("`user_badge`.user_id = ?", u.ID).In("`badge`.slug", badgeSlugs). + Find(&userBadges); err != nil { + return err + } + userBadgeIDs := make([]int64, 0, len(userBadges)) + for _, ub := range userBadges { + userBadgeIDs = append(userBadgeIDs, ub.ID) + } + if _, err := db.GetEngine(ctx).Table("user_badge").In("id", userBadgeIDs).Delete(); err != nil { + return err } return nil }) @@ -122,3 +226,55 @@ func RemoveAllUserBadges(ctx context.Context, u *User) error { _, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{}) return err } + +// SearchBadgeOptions represents the options when fdin badges +type SearchBadgeOptions struct { + db.ListOptions + + Keyword string + Slug string + ID int64 + OrderBy db.SearchOrderBy + Actor *User // The user doing the search +} + +func (opts *SearchBadgeOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.Keyword != "" { + lowerKeyword := strings.ToLower(opts.Keyword) + keywordCond := builder.Or( + builder.Like{"badge.slug", lowerKeyword}, + builder.Like{"badge.description", lowerKeyword}, + ) + cond = cond.And(keywordCond) + } + + if opts.ID > 0 { + cond = cond.And(builder.Eq{"badge.id": opts.ID}) + } + + if len(opts.Slug) > 0 { + cond = cond.And(builder.Eq{"badge.slug": opts.Slug}) + } + + return cond +} + +// SearchBadges returns badges based on the provided SearchBadgeOptions options +func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) ([]*Badge, int64, error) { + return db.FindAndCount[Badge](ctx, opts) +} + +// GetBadgeByID returns a specific badge by ID +func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) { + badge := new(Badge) + has, err := db.GetEngine(ctx).ID(id).Get(badge) + if err != nil { + return nil, err + } + if !has { + return nil, ErrBadgeNotExist{ID: id} + } + return badge, nil +} diff --git a/models/user/badge_test.go b/models/user/badge_test.go new file mode 100644 index 0000000000000..3d53cb39c4e95 --- /dev/null +++ b/models/user/badge_test.go @@ -0,0 +1,89 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestGetBadgeUsers(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // Create a test badge + badge := &user_model.Badge{ + Slug: "test-badge", + Description: "Test Badge", + ImageURL: "test.png", + } + assert.NoError(t, user_model.CreateBadge(db.DefaultContext, badge)) + + // Create test users and assign badges + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.NoError(t, user_model.AddUserBadge(db.DefaultContext, user1, badge)) + assert.NoError(t, user_model.AddUserBadge(db.DefaultContext, user2, badge)) + + // Test getting users with pagination + opts := &user_model.GetBadgeUsersOptions{ + BadgeSlug: badge.Slug, + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 1, + }, + } + + users, count, err := user_model.GetBadgeUsers(db.DefaultContext, opts) + assert.NoError(t, err) + assert.EqualValues(t, 2, count) + assert.Len(t, users, 1) + + // Test second page + opts.Page = 2 + users, count, err = user_model.GetBadgeUsers(db.DefaultContext, opts) + assert.NoError(t, err) + assert.EqualValues(t, 2, count) + assert.Len(t, users, 1) + + // Test with non-existent badge + opts.BadgeSlug = "non-existent" + users, count, err = user_model.GetBadgeUsers(db.DefaultContext, opts) + assert.NoError(t, err) + assert.EqualValues(t, 0, count) + assert.Empty(t, users) +} + +func TestAddAndRemoveUserBadges(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + badge1 := unittest.AssertExistsAndLoadBean(t, &user_model.Badge{ID: 1}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + // Add a badge to user and verify that it is returned in the list + assert.NoError(t, user_model.AddUserBadge(db.DefaultContext, user1, badge1)) + badges, count, err := user_model.GetUserBadges(db.DefaultContext, user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Confirm that it is impossible to duplicate the same badge + assert.Error(t, user_model.AddUserBadge(db.DefaultContext, user1, badge1)) + + // Nothing happened to the existing badge + badges, count, err = user_model.GetUserBadges(db.DefaultContext, user1) + assert.Equal(t, int64(1), count) + assert.Equal(t, badge1.Slug, badges[0].Slug) + assert.NoError(t, err) + + // Remove a badge from user and verify that it is no longer in the list + assert.NoError(t, user_model.RemoveUserBadge(db.DefaultContext, user1, badge1)) + _, count, err = user_model.GetUserBadges(db.DefaultContext, user1) + assert.Equal(t, int64(0), count) + assert.NoError(t, err) +} diff --git a/modules/validation/binding.go b/modules/validation/binding.go index 75190e31e185b..74d82aee45ff1 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -27,6 +27,8 @@ const ( ErrUsername = "UsernameError" // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" + // ErrInvalidSlug is returned when a slug is invalid + ErrInvalidSlug = "InvalidSlug" ) // AddBindingRules adds additional binding rules @@ -40,6 +42,7 @@ func AddBindingRules() { addGlobOrRegexPatternRule() addUsernamePatternRule() addValidGroupTeamMapRule() + addSlugPatternRule() } func addGitRefNameBindingRule() { @@ -123,6 +126,22 @@ func addValidSiteURLBindingRule() { }) } +func addSlugPatternRule() { + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return rule == "Slug" + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if !IsValidSlug(str) { + errs.Add([]string{name}, ErrInvalidSlug, "invalid slug") + return false, errs + } + return true, errs + }, + }) +} + func addGlobPatternRule() { binding.AddRule(&binding.Rule{ IsMatch: func(rule string) bool { diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index ba383ba195db1..cf8a1537af15f 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -132,3 +132,7 @@ func IsValidUsername(name string) bool { vars := globalVars() return vars.validUsernamePattern.MatchString(name) && !vars.invalidUsernamePattern.MatchString(name) } + +func IsValidSlug(slug string) bool { + return IsValidUsername(slug) +} diff --git a/modules/web/middleware/binding.go b/modules/web/middleware/binding.go index ee4eca976e367..2c62d4a5c15d2 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -138,6 +138,8 @@ func Validate(errs binding.Errors, data map[string]any, f Form, l translation.Lo data["ErrorMsg"] = trName + l.TrString("form.username_error") case validation.ErrInvalidGroupTeamMap: data["ErrorMsg"] = trName + l.TrString("form.invalid_group_team_map_error", errs[0].Message) + case validation.ErrInvalidSlug: + data["ErrorMsg"] = l.TrString("form.invalid_slug_error") default: msg := errs[0].Classification if msg != "" && errs[0].Message != "" { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index d7e73a0cfbb08..a7ea446064fa3 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -180,6 +180,7 @@ exact = Exact exact_tooltip = Include only results that match the exact search term repo_kind = Search repos… user_kind = Search users… +badge_kind = Search badges… org_kind = Search orgs… team_kind = Search teams… code_kind = Search code… @@ -574,6 +575,7 @@ PayloadUrl = Payload URL TeamName = Team name AuthName = Authorization name AdminEmail = Admin email +ImageURL = Image URL NewBranchName = New branch name CommitSummary = Commit summary @@ -603,12 +605,15 @@ unknown_error = Unknown error: captcha_incorrect = The CAPTCHA code is incorrect. password_not_match = The passwords do not match. lang_select_error = Select a language from the list. +invalid_image_url_error = `Please provide a valid image URL.` +invalid_slug_error = `Please provide a valid slug.` username_been_taken = The username is already taken. username_change_not_local_user = Non-local users are not allowed to change their username. change_username_disabled = Changing username is disabled. change_full_name_disabled = Changing full name is disabled. username_has_not_been_changed = Username has not been changed +slug_been_taken = The slug is already taken. repo_name_been_taken = The repository name is already used. repository_force_private = Force Private is enabled: private repositories cannot be made public. repository_files_already_exist = Files already exist for this repository. Contact the system administrator. @@ -2963,6 +2968,7 @@ dashboard = Dashboard self_check = Self Check identity_access = Identity & Access users = User Accounts +badges = Badges organizations = Organizations assets = Code Assets repositories = Repositories @@ -3142,6 +3148,30 @@ emails.delete_desc = Are you sure you want to delete this email address? emails.deletion_success = The email address has been deleted. emails.delete_primary_email_error = You cannot delete the primary email address. +badges.badges_manage_panel = Badge Management +badges.details = Badge Details +badges.new_badge = Create New Badge +badges.slug = Slug +badges.description = Description +badges.image_url = Image URL +badges.slug.must_fill = Slug must be filled. +badges.new_success = The badge "%s" has been created. +badges.update_success = The badge has been updated. +badges.deletion_success = The badge has been deleted. +badges.edit_badge = Edit Badge +badges.update_badge = Update Badge +badges.delete_badge = Delete Badge +badges.delete_badge_desc = Are you sure you want to permanently delete this badge? +badges.users_with_badge = Users with Badge (%s) +badges.add_user = Add User +badges.remove_user = Remove User +badges.delete_user_desc = Are you sure you want to remove this badge from the user? +badges.not_found = Badge not found! +badges.user_add_success = User has been added to the badge. +badges.user_remove_success = User has been removed from the badge. +badges.manage_users = Manage Users + + orgs.org_manage_panel = Organization Management orgs.name = Name orgs.teams = Teams diff --git a/routers/web/admin/badges.go b/routers/web/admin/badges.go new file mode 100644 index 0000000000000..c026887edd87c --- /dev/null +++ b/routers/web/admin/badges.go @@ -0,0 +1,333 @@ +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/web/explore" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplBadges templates.TplName = "admin/badge/list" + tplBadgeNew templates.TplName = "admin/badge/new" + tplBadgeView templates.TplName = "admin/badge/view" + tplBadgeEdit templates.TplName = "admin/badge/edit" + tplBadgeUsers templates.TplName = "admin/badge/users" +) + +// BadgeSearchDefaultAdminSort is the default sort type for admin view +const BadgeSearchDefaultAdminSort = "oldest" + +// Badges show all the badges +func Badges(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges") + ctx.Data["PageIsAdminBadges"] = true + + sortType := ctx.FormString("sort") + if sortType == "" { + sortType = BadgeSearchDefaultAdminSort + ctx.SetFormString("sort", sortType) + } + ctx.PageData["adminBadgeListSearchForm"] = map[string]any{ + "SortType": sortType, + } + + explore.RenderBadgeSearch(ctx, &user_model.SearchBadgeOptions{ + Actor: ctx.Doer, + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + }, + }, tplBadges) +} + +// NewBadge render adding a new badge +func NewBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge") + ctx.Data["PageIsAdminBadges"] = true + + ctx.HTML(http.StatusOK, tplBadgeNew) +} + +// NewBadgePost response for adding a new badge +func NewBadgePost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm) + ctx.Data["Title"] = ctx.Tr("admin.badges.new_badge") + ctx.Data["PageIsAdminBadges"] = true + + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplBadgeNew) + return + } + + b := &user_model.Badge{ + Slug: form.Slug, + Description: form.Description, + ImageURL: form.ImageURL, + } + + if len(form.Slug) < 1 { + ctx.Data["Err_Slug"] = true + ctx.RenderWithErr(ctx.Tr("admin.badges.slug.must_fill"), tplBadgeNew, &form) + return + } + + if len(form.Description) < 1 { + ctx.Data["Err_Description"] = true + ctx.RenderWithErr(ctx.Tr("admin.badges.description.must_fill"), tplBadgeNew, &form) + return + } + + if err := user_model.CreateBadge(ctx, b); err != nil { + switch { + default: + ctx.ServerError("CreateBadge", err) + } + return + } + + log.Trace("Badge created by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.new_success", b.Slug)) + ctx.Redirect(setting.AppSubURL + "/-/admin/badges/" + url.PathEscape(b.Slug)) +} + +func prepareBadgeInfo(ctx *context.Context) *user_model.Badge { + b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug")) + if err != nil { + if user_model.IsErrBadgeNotExist(err) { + ctx.Redirect(setting.AppSubURL + "/-/admin/badges") + } else { + ctx.ServerError("GetBadge", err) + } + return nil + } + ctx.Data["Badge"] = b + + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + }, + BadgeSlug: b.Slug, + } + users, count, err := user_model.GetBadgeUsers(ctx, opts) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Redirect(setting.AppSubURL + "/-/admin/badges") + } else { + ctx.ServerError("GetBadgeUsers", err) + } + return nil + } + ctx.Data["Users"] = users + ctx.Data["UsersTotal"] = int(count) + + return b +} + +func ViewBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.details") + ctx.Data["PageIsAdminBadges"] = true + + prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplBadgeView) +} + +// EditBadge show editing badge page +func EditBadge(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badges") + ctx.Data["PageIsAdminBadges"] = true + prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + ctx.HTML(http.StatusOK, tplBadgeEdit) +} + +// EditBadgePost response for editing badge +func EditBadgePost(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.edit_badges") + ctx.Data["PageIsAdminBadges"] = true + b := prepareBadgeInfo(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*forms.AdminCreateBadgeForm) + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplBadgeEdit) + return + } + + if form.Slug != "" { + if err := user_service.UpdateBadge(ctx, ctx.Data["Badge"].(*user_model.Badge)); err != nil { + switch { + default: + ctx.ServerError("UpdateBadge", err) + } + return + } + } + + b.ImageURL = form.ImageURL + b.Description = form.Description + + if err := user_model.UpdateBadge(ctx, ctx.Data["Badge"].(*user_model.Badge)); err != nil { + ctx.ServerError("UpdateBadge", err) + return + } + + log.Trace("Badge updated by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.update_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/badges/" + url.PathEscape(ctx.PathParam("badge_slug"))) +} + +// DeleteBadge response for deleting a badge +func DeleteBadge(ctx *context.Context) { + b, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug")) + if err != nil { + ctx.ServerError("GetBadge", err) + return + } + + if err = user_service.DeleteBadge(ctx, b); err != nil { + ctx.ServerError("DeleteBadge", err) + return + } + + log.Trace("Badge deleted by admin (%s): %s", ctx.Doer.Name, b.Slug) + + ctx.Flash.Success(ctx.Tr("admin.badges.deletion_success")) + ctx.Redirect(setting.AppSubURL + "/-/admin/badges") +} + +func BadgeUsers(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.badges.users_with_badge", ctx.PathParam("badge_slug")) + ctx.Data["PageIsAdminBadges"] = true + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + badge := &user_model.Badge{Slug: ctx.PathParam("badge_slug")} + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.UserPagingNum, + }, + BadgeSlug: badge.Slug, + } + users, count, err := user_model.GetBadgeUsers(ctx, opts) + if err != nil { + ctx.ServerError("GetBadgeUsers", err) + return + } + + ctx.Data["Users"] = users + ctx.Data["Total"] = count + ctx.Data["Page"] = context.NewPagination(int(count), setting.UI.Admin.UserPagingNum, page, 5) + + ctx.HTML(http.StatusOK, tplBadgeUsers) +} + +// BadgeUsersPost response for actions for user badges +func BadgeUsersPost(ctx *context.Context) { + name := strings.ToLower(ctx.FormString("user")) + + u, err := user_model.GetUserByName(ctx, name) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + + if err = user_model.AddUserBadge(ctx, u, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err != nil { + if user_model.IsErrBadgeNotExist(err) { + ctx.Flash.Error(ctx.Tr("admin.badges.not_found")) + } else { + ctx.ServerError("AddUserBadge", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("admin.badges.user_add_success")) + ctx.Redirect(setting.AppSubURL + ctx.Req.URL.EscapedPath()) +} + +// DeleteBadgeUser delete a badge from a user +func DeleteBadgeUser(ctx *context.Context) { + user, err := user_model.GetUserByID(ctx, ctx.FormInt64("id")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Flash.Error(ctx.Tr("form.user_not_exist")) + } else { + ctx.ServerError("GetUserByName", err) + return + } + } + if err := user_model.RemoveUserBadge(ctx, user, &user_model.Badge{Slug: ctx.PathParam("badge_slug")}); err == nil { + ctx.Flash.Success(ctx.Tr("admin.badges.user_remove_success")) + } else { + ctx.ServerError("RemoveUserBadge", err) + } + + ctx.JSONRedirect(fmt.Sprintf("%s/-/admin/badges/%s/users", setting.AppSubURL, ctx.PathParam("badge_slug"))) +} + +// ViewBadgeUsers render badge's users page +func ViewBadgeUsers(ctx *context.Context) { + badge, err := user_model.GetBadge(ctx, ctx.PathParam("badge_slug")) + if err != nil { + ctx.ServerError("GetBadge", err) + return + } + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: setting.UI.Admin.UserPagingNum, + }, + BadgeSlug: badge.Slug, + } + users, count, err := user_model.GetBadgeUsers(ctx, opts) + if err != nil { + ctx.ServerError("GetBadgeUsers", err) + return + } + + ctx.Data["Title"] = badge.Description + ctx.Data["Badge"] = badge + ctx.Data["Users"] = users + ctx.Data["Total"] = count + ctx.Data["Pages"] = context.NewPagination(int(count), setting.UI.Admin.UserPagingNum, page, 5) + ctx.HTML(http.StatusOK, tplBadgeUsers) +} diff --git a/routers/web/explore/badge.go b/routers/web/explore/badge.go new file mode 100644 index 0000000000000..1aecd0ce72265 --- /dev/null +++ b/routers/web/explore/badge.go @@ -0,0 +1,75 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName templates.TplName) { + // Sitemap index for sitemap paths + opts.Page = int(ctx.PathParamInt64("idx")) + if opts.Page <= 1 { + opts.Page = ctx.FormInt("page") + } + if opts.Page <= 1 { + opts.Page = 1 + } + + var ( + badges []*user_model.Badge + count int64 + err error + orderBy db.SearchOrderBy + ) + + // we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns + + sortOrder := ctx.FormString("sort") + if sortOrder == "" { + sortOrder = setting.UI.ExploreDefaultSort + } + ctx.Data["SortType"] = sortOrder + + switch sortOrder { + case "newest": + orderBy = "`badge`.id DESC" + case "oldest": + orderBy = "`badge`.id ASC" + case "reversealphabetically": + orderBy = "`badge`.slug DESC" + case "alphabetically": + orderBy = "`badge`.slug ASC" + default: + // in case the sortType is not valid, we set it to recent update + ctx.Data["SortType"] = "oldest" + orderBy = "`badge`.id ASC" + } + + opts.Keyword = ctx.FormTrim("q") + opts.OrderBy = orderBy + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + badges, count, err = user_model.SearchBadges(ctx, opts) + if err != nil { + ctx.ServerError("SearchBadges", err) + return + } + } + + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Badges"] = badges + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplName) +} diff --git a/routers/web/web.go b/routers/web/web.go index 09be0c39045e0..2f98ce6ea01cb 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -754,6 +754,16 @@ func registerWebRoutes(m *web.Router) { m.Post("/{userid}/avatar/delete", admin.DeleteAvatar) }) + m.Group("/badges", func() { + m.Get("", admin.Badges) + m.Combo("/new").Get(admin.NewBadge).Post(web.Bind(forms.AdminCreateBadgeForm{}), admin.NewBadgePost) + m.Get("/{badge_slug}", admin.ViewBadge) + m.Combo("/{badge_slug}/edit").Get(admin.EditBadge).Post(web.Bind(forms.AdminCreateBadgeForm{}), admin.EditBadgePost) + m.Post("/{badge_slug}/delete", admin.DeleteBadge) + m.Combo("/{badge_slug}/users").Get(admin.BadgeUsers).Post(admin.BadgeUsersPost) + m.Post("/{badge_slug}/users/delete", admin.DeleteBadgeUser) + }) + m.Group("/emails", func() { m.Get("", admin.Emails) m.Post("/activate", admin.ActivateEmail) diff --git a/services/forms/admin.go b/services/forms/admin.go index 81276f8f46f9c..cad784f6863b6 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -25,6 +25,19 @@ type AdminCreateUserForm struct { Visibility structs.VisibleType } +// AdminCreateBadgeForm form for admin to create badge +type AdminCreateBadgeForm struct { + Slug string `binding:"Required;Slug"` + Description string `binding:"Required"` + ImageURL string `binding:"ValidImageUrl"` +} + +// Validate validates form fields +func (f *AdminCreateBadgeForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { + ctx := context.GetValidateContext(req) + return middleware.Validate(errs, ctx.Data, f, ctx.Locale) +} + // Validate validates form fields func (f *AdminCreateUserForm) Validate(req *http.Request, errs binding.Errors) binding.Errors { ctx := context.GetValidateContext(req) diff --git a/services/user/badge.go b/services/user/badge.go new file mode 100644 index 0000000000000..b5ed7f6d353b6 --- /dev/null +++ b/services/user/badge.go @@ -0,0 +1,41 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" +) + +// UpdateBadgeDescription changes the description and/or image of a badge +func UpdateBadge(ctx context.Context, b *user_model.Badge) error { + return db.WithTx(ctx, func(ctx context.Context) error { + return user_model.UpdateBadge(ctx, b) + }) +} + +// DeleteBadge remove record of badge in the database +func DeleteBadge(ctx context.Context, b *user_model.Badge) error { + return db.WithTx(ctx, func(ctx context.Context) error { + if err := user_model.DeleteBadge(ctx, b); err != nil { + return fmt.Errorf("DeleteBadge: %w", err) + } + return nil + }) +} + +// GetBadgeUsers returns the users that have a specific badge +func GetBadgeUsers(ctx context.Context, badge *user_model.Badge, page, pageSize int) ([]*user_model.User, int64, error) { + opts := &user_model.GetBadgeUsersOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: pageSize, + }, + BadgeSlug: badge.Slug, + } + return user_model.GetBadgeUsers(ctx, opts) +} diff --git a/templates/admin/badge/edit.tmpl b/templates/admin/badge/edit.tmpl new file mode 100644 index 0000000000000..277ce9baec877 --- /dev/null +++ b/templates/admin/badge/edit.tmpl @@ -0,0 +1,47 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.edit_badge"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+
+ + + +{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/list.tmpl b/templates/admin/badge/list.tmpl new file mode 100644 index 0000000000000..6313fa825a9cd --- /dev/null +++ b/templates/admin/badge/list.tmpl @@ -0,0 +1,67 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}} +
+

+ {{ctx.Locale.Tr "admin.badges.badges_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}}) + +

+
+
+ + + + + {{template "shared/search/combo" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.badge_kind")}} +
+
+
+ + + + + + + + + + + {{range .Badges}} + + + + + + + {{end}} + +
ID{{SortArrow "oldest" "newest" .SortType false}} + {{ctx.Locale.Tr "admin.badges.slug"}} + {{SortArrow "alphabetically" "reversealphabeically" $.SortType true}} + {{ctx.Locale.Tr "admin.badges.description"}}
{{.ID}} + {{.Slug}} + {{.Description}} + +
+
+ + {{template "base/paginate" .}} +
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/new.tmpl b/templates/admin/badge/new.tmpl new file mode 100644 index 0000000000000..3231c37cf8235 --- /dev/null +++ b/templates/admin/badge/new.tmpl @@ -0,0 +1,29 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin new badge")}} +
+

+ {{ctx.Locale.Tr "admin.badges.new_badge"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+
+{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/users.tmpl b/templates/admin/badge/users.tmpl new file mode 100644 index 0000000000000..a40a2d0d8b286 --- /dev/null +++ b/templates/admin/badge/users.tmpl @@ -0,0 +1,41 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin badge")}} +
+

+ {{.Title}} +

+ {{if .Users}} +
+
+ {{range .Users}} +
+ +
+
+ {{template "shared/user/name" .}} +
+
+ +
+ {{end}} +
+
+ {{end}} + {{template "base/paginate" .}} +
+
+ {{.CsrfTokenHtml}} + + +
+
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/badge/view.tmpl b/templates/admin/badge/view.tmpl new file mode 100644 index 0000000000000..1f4a3e11c4197 --- /dev/null +++ b/templates/admin/badge/view.tmpl @@ -0,0 +1,44 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin view user")}} + +
+
+
+

+ {{.Title}} + +

+
+
+
+ {{if .Image}} +
+ {{.Badge.Description}} +
+ {{end}} +
+
+ {{.Badge.Slug}} +
+
+ {{.Badge.Description}} +
+
+
+
+
+
+
+

+ {{ctx.Locale.Tr "explore.users"}} ({{.UsersTotal}}) + +

+
+ {{template "explore/user_list" .}} +
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 72584ec799cc3..ce3048ed9fa4d 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -13,7 +13,7 @@ -
+
{{ctx.Locale.Tr "admin.identity_access"}}