diff --git a/models/user/badge.go b/models/user/badge.go index 3ff3530a369..aace0655ce0 100644 --- a/models/user/badge.go +++ b/models/user/badge.go @@ -6,8 +6,14 @@ package user import ( "context" "fmt" + "net/url" + "strings" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/setting" + + "xorm.io/builder" + "xorm.io/xorm" ) // Badge represents a user badge @@ -30,6 +36,10 @@ func init() { db.RegisterModel(new(UserBadge)) } +func AdminCreateBadge(ctx context.Context, badge *Badge) error { + return CreateBadge(ctx, badge) +} + // GetUserBadges returns the user's badges. func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) { sess := db.GetEngine(ctx). @@ -42,9 +52,30 @@ func GetUserBadges(ctx context.Context, u *User) ([]*Badge, int64, error) { return badges, count, err } +// GetBadgeUsers returns the badges users. +func GetBadgeUsers(ctx context.Context, b *Badge) ([]*User, int64, error) { + sess := db.GetEngine(ctx). + Select("`user`.*"). + Join("INNER", "user_badge", "`user_badge`.user_id=user.id"). + Where("user_badge.badge_id=?", b.ID) + + users := make([]*User, 0, 8) + count, err := sess.FindAndCount(&users) + return users, count, err +} + // CreateBadge creates a new badge. func CreateBadge(ctx context.Context, badge *Badge) error { - _, err := db.GetEngine(ctx).Insert(badge) + isExist, err := IsBadgeExist(ctx, 0, badge.Slug) + + if err != nil { + return err + } else if isExist { + return ErrBadgeAlreadyExist{badge.Slug} + } + + _, err = db.GetEngine(ctx).Insert(badge) + return err } @@ -58,9 +89,22 @@ func GetBadge(ctx context.Context, slug string) (*Badge, error) { return badge, err } +// GetBadgeByID returns a badge +func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) { + badge := new(Badge) + has, err := db.GetEngine(ctx).Where("id=?", id).Get(badge) + if err != nil { + return nil, err + } else if !has { + return nil, ErrBadgeNotExist{ID: id} + } + + return badge, err +} + // 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("id=?", badge.ID).Cols("slug", "description", "image_url").Update(badge) return err } @@ -70,6 +114,15 @@ func DeleteBadge(ctx context.Context, badge *Badge) error { return err } +// DeleteUserBadgeRecord deletes a user badge record. +func DeleteUserBadgeRecord(ctx context.Context, badge *Badge) error { + userBadge := &UserBadge{ + BadgeID: badge.ID, + } + _, err := db.GetEngine(ctx).Where("badge_id=?", userBadge.BadgeID).Delete(userBadge) + return err +} + // AddUserBadge adds a badge to a user. func AddUserBadge(ctx context.Context, u *User, badge *Badge) error { return AddUserBadges(ctx, u, []*Badge{badge}) @@ -122,3 +175,107 @@ func RemoveAllUserBadges(ctx context.Context, u *User) error { _, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{}) return err } + +// HTMLURL returns the badges full link. +func (u *Badge) HTMLURL() string { + return setting.AppURL + url.PathEscape(u.Slug) +} + +// IsBadgeExist checks if given badge slug exist, +// it is used when creating/updating a badge slug +func IsBadgeExist(ctx context.Context, uid int64, slug string) (bool, error) { + if len(slug) == 0 { + return false, nil + } + return db.GetEngine(ctx). + Where("slug!=?", uid). + Get(&Badge{Slug: strings.ToLower(slug)}) +} + +// 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 + + ExtraParamStrings map[string]string +} + +func (opts *SearchBadgeOptions) ToConds() builder.Cond { + cond := builder.NewCond() + + if opts.Keyword != "" { + cond = cond.And(builder.Like{"badge.slug", opts.Keyword}) + } + + return cond +} + +func (opts *SearchBadgeOptions) ToOrders() string { + orderBy := "badge.slug" + return orderBy +} + +func (opts *SearchBadgeOptions) ToJoins() []db.JoinFunc { + return []db.JoinFunc{ + func(e db.Engine) error { + e.Join("INNER", "badge", "badge.badge_id = badge.id") + return nil + }, + } +} + +func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) (badges []*Badge, _ int64, _ error) { + sessCount := opts.toSearchQueryBase(ctx) + defer sessCount.Close() + count, err := sessCount.Count(new(Badge)) + if err != nil { + return nil, 0, fmt.Errorf("count: %w", err) + } + + if len(opts.OrderBy) == 0 { + opts.OrderBy = db.SearchOrderByID + } + + sessQuery := opts.toSearchQueryBase(ctx).OrderBy(opts.OrderBy.String()) + defer sessQuery.Close() + if opts.Page != 0 { + sessQuery = db.SetSessionPagination(sessQuery, opts) + } + + // the sql may contain JOIN, so we must only select Badge related columns + sessQuery = sessQuery.Select("`badge`.*") + badges = make([]*Badge, 0, opts.PageSize) + return badges, count, sessQuery.Find(&badges) +} + +func (opts *SearchBadgeOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { + var cond builder.Cond + cond = builder.Neq{"id": -1} + + if len(opts.Keyword) > 0 { + lowerKeyword := strings.ToLower(opts.Keyword) + keywordCond := builder.Or( + builder.Like{"slug", lowerKeyword}, + builder.Like{"description", lowerKeyword}, + builder.Like{"id", lowerKeyword}, + ) + cond = cond.And(keywordCond) + } + + if opts.ID > 0 { + cond = cond.And(builder.Eq{"id": opts.ID}) + } + + if len(opts.Slug) > 0 { + cond = cond.And(builder.Eq{"slug": opts.Slug}) + } + + e := db.GetEngine(ctx) + + return e.Where(cond) +} diff --git a/models/user/error.go b/models/user/error.go index cbf19998d10..f8e8855f1a5 100644 --- a/models/user/error.go +++ b/models/user/error.go @@ -107,3 +107,44 @@ func IsErrUserIsNotLocal(err error) bool { _, ok := err.(ErrUserIsNotLocal) return ok } + +// 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 +} + +// IsErrBadgeNotExist checks if an error is a ErrBadgeNotExist. +func IsErrBadgeNotExist(err error) bool { + _, ok := err.(ErrBadgeNotExist) + return ok +} + +func (err ErrBadgeNotExist) Error() string { + return fmt.Sprintf("badge does not exist [slug: %s | id: %i]", err.Slug, err.ID) +} + +// Unwrap unwraps this error as a ErrNotExist error +func (err ErrBadgeNotExist) Unwrap() error { + return util.ErrNotExist +} diff --git a/modules/validation/binding.go b/modules/validation/binding.go index cb0a5063e50..906cbeb1e4f 100644 --- a/modules/validation/binding.go +++ b/modules/validation/binding.go @@ -26,6 +26,8 @@ const ( ErrUsername = "UsernameError" // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid ErrInvalidGroupTeamMap = "InvalidGroupTeamMap" + ErrInvalidImageURL = "InvalidImageURL" + ErrInvalidSlug = "InvalidSlug" ) // AddBindingRules adds additional binding rules @@ -38,6 +40,8 @@ func AddBindingRules() { addGlobOrRegexPatternRule() addUsernamePatternRule() addValidGroupTeamMapRule() + addValidImageURLBindingRule() + addSlugPatternRule() } func addGitRefNameBindingRule() { @@ -94,6 +98,40 @@ func addValidSiteURLBindingRule() { }) } +func addValidImageURLBindingRule() { + // URL validation rule + binding.AddRule(&binding.Rule{ + IsMatch: func(rule string) bool { + return strings.HasPrefix(rule, "ValidImageUrl") + }, + IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) { + str := fmt.Sprintf("%v", val) + if len(str) != 0 && !IsValidImageURL(str) { + errs.Add([]string{name}, ErrInvalidImageURL, "ImageURL") + return false, errs + } + + return true, errs + }, + }) +} + +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 f6e00f3887a..9925ea0ef57 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -6,6 +6,7 @@ package validation import ( "net" "net/url" + "path/filepath" "regexp" "strings" @@ -50,6 +51,29 @@ func IsValidSiteURL(uri string) bool { return false } +// IsValidImageURL checks if URL is valid and points to an image +func IsValidImageURL(uri string) bool { + u, err := url.ParseRequestURI(uri) + if err != nil { + return false + } + + if !validPort(portOnly(u.Host)) { + return false + } + + for _, scheme := range setting.Service.ValidSiteURLSchemes { + if scheme == u.Scheme { + // Check if the path has an image file extension + ext := strings.ToLower(filepath.Ext(u.Path)) + if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".bmp" || ext == ".svg" || ext == ".webp" { + return true + } + } + } + return false +} + // IsEmailDomainListed checks whether the domain of an email address // matches a list of domains func IsEmailDomainListed(globs []glob.Glob, email string) bool { @@ -127,3 +151,7 @@ func IsValidUsername(name string) bool { // but it's easier to use positive and negative checks. return validUsernamePattern.MatchString(name) && !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 43e1bbc70e3..d086c2047d9 100644 --- a/modules/web/middleware/binding.go +++ b/modules/web/middleware/binding.go @@ -138,6 +138,10 @@ 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.ErrInvalidImageURL: + data["ErrorMsg"] = l.TrString("form.invalid_image_url_error") + 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 815cba6eeca..17f35451670 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -169,6 +169,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... @@ -544,6 +545,7 @@ PayloadUrl = Payload URL TeamName = Team name AuthName = Authorization name AdminEmail = Admin email +ImageURL = Image URL NewBranchName = New branch name CommitSummary = Commit summary @@ -573,10 +575,13 @@ 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. 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. @@ -2794,6 +2799,7 @@ dashboard = Dashboard self_check = Self Check identity_access = Identity & Access users = User Accounts +badges = Badges organizations = Organizations assets = Code Assets repositories = Repositories @@ -2968,6 +2974,22 @@ emails.duplicate_active = This email address is already active for a different u emails.change_email_header = Update Email Properties emails.change_email_text = Are you sure you want to update this 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? + + 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 00000000000..e4711605598 --- /dev/null +++ b/routers/web/admin/badges.go @@ -0,0 +1,215 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + "net/url" + "strconv" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "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 base.TplName = "admin/badge/list" + tplBadgeNew base.TplName = "admin/badge/new" + tplBadgeView base.TplName = "admin/badge/view" + tplBadgeEdit base.TplName = "admin/badge/edit" +) + +// 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.must_fill"), tplBadgeNew, &form) + return + } + + if err := user_model.AdminCreateBadge(ctx, b); err != nil { + switch { + case user_model.IsErrBadgeAlreadyExist(err): + ctx.Data["Err_Slug"] = true + ctx.RenderWithErr(ctx.Tr("form.slug_been_taken"), tplBadgeNew, &form) + 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/" + strconv.FormatInt(b.ID, 10)) +} + +func prepareBadgeInfo(ctx *context.Context) *user_model.Badge { + b, err := user_model.GetBadgeByID(ctx, ctx.ParamsInt64(":badgeid")) + if err != nil { + if user_model.IsErrBadgeNotExist(err) { + ctx.Redirect(setting.AppSubURL + "/admin/badges") + } else { + ctx.ServerError("GetBadgeByID", err) + } + return nil + } + ctx.Data["Badge"] = b + ctx.Data["Image"] = b.ImageURL != "" + + users, count, err := user_model.GetBadgeUsers(ctx, b) + 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.RenameBadge(ctx, ctx.Data["Badge"].(*user_model.Badge), form.Slug); err != nil { + switch { + case user_model.IsErrBadgeAlreadyExist(err): + ctx.Data["Err_Slug"] = true + ctx.RenderWithErr(ctx.Tr("form.slug_been_taken"), tplBadgeEdit, &form) + default: + ctx.ServerError("RenameBadge", 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.Params(":badgeid"))) +} + +// DeleteBadge response for deleting a badge +func DeleteBadge(ctx *context.Context) { + b, err := user_model.GetBadgeByID(ctx, ctx.ParamsInt64(":badgeid")) + if err != nil { + ctx.ServerError("GetBadgeByID", err) + return + } + + if err = user_service.DeleteBadge(ctx, b, true); 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") +} diff --git a/routers/web/explore/badge.go b/routers/web/explore/badge.go new file mode 100644 index 00000000000..cbc3fb1ac44 --- /dev/null +++ b/routers/web/explore/badge.go @@ -0,0 +1,97 @@ +// 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/base" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/sitemap" + "code.gitea.io/gitea/services/context" +) + +func RenderBadgeSearch(ctx *context.Context, opts *user_model.SearchBadgeOptions, tplName base.TplName) { + // Sitemap index for sitemap paths + opts.Page = int(ctx.ParamsInt64("idx")) + isSitemap := ctx.Params("idx") != "" + if opts.Page <= 1 { + opts.Page = ctx.FormInt("page") + } + if opts.Page <= 1 { + opts.Page = 1 + } + + if isSitemap { + opts.PageSize = setting.UI.SitemapPagingNum + } + + 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 + sortOrder = "alphabetically" + ctx.Data["SortType"] = "alphabetically" + orderBy = "`badge`.slug 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 + } + } + if isSitemap { + m := sitemap.NewSitemap() + for _, item := range badges { + m.Add(sitemap.URL{URL: item.HTMLURL()}) + } + ctx.Resp.Header().Set("Content-Type", "text/xml") + if _, err := m.WriteTo(ctx.Resp); err != nil { + log.Error("Failed writing sitemap: %v", 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.SetDefaultParams(ctx) + for paramKey, paramVal := range opts.ExtraParamStrings { + pager.AddParamString(paramKey, paramVal) + } + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplName) +} diff --git a/routers/web/web.go b/routers/web/web.go index 9f9a1bb0988..03aa75ef455 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -720,6 +720,14 @@ func registerRoutes(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("/{badgeid}", admin.ViewBadge) + m.Combo("/{badgeid}/edit").Get(admin.EditBadge).Post(web.Bind(forms.AdminCreateBadgeForm{}), admin.EditBadgePost) + m.Post("/{badgeid}/delete", admin.DeleteBadge) + }) + 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 81276f8f46f..57ec92ed412 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 + 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 00000000000..e8a6a65ebda --- /dev/null +++ b/services/user/badge.go @@ -0,0 +1,75 @@ +// 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" +) + +// RenameBadge changes the slug of a badge. +func RenameBadge(ctx context.Context, b *user_model.Badge, newSlug string) error { + if newSlug == b.Slug { + return nil + } + + olderSlug := b.Slug + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + isExist, err := user_model.IsBadgeExist(ctx, b.ID, newSlug) + if err != nil { + return err + } + if isExist { + return user_model.ErrBadgeAlreadyExist{ + Slug: newSlug, + } + } + + b.Slug = newSlug + if err := user_model.UpdateBadge(ctx, b); err != nil { + b.Slug = olderSlug + return err + } + if err = committer.Commit(); err != nil { + b.Slug = olderSlug + return err + } + return nil +} + +// DeleteBadge completely and permanently deletes everything of a badge +func DeleteBadge(ctx context.Context, b *user_model.Badge, purge bool) error { + if purge { + err := user_model.DeleteUserBadgeRecord(ctx, b) + if err != nil { + return err + } + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + if err := user_model.DeleteBadge(ctx, b); err != nil { + return fmt.Errorf("DeleteBadge: %w", err) + } + + if err := committer.Commit(); err != nil { + return err + } + _ = committer.Close() + + return nil +} diff --git a/templates/admin/badge/edit.tmpl b/templates/admin/badge/edit.tmpl new file mode 100644 index 00000000000..ef5bd1985ec --- /dev/null +++ b/templates/admin/badge/edit.tmpl @@ -0,0 +1,48 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin edit user")}} +
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}} | ++ | + | + | + | + | + + | +