Skip to content

Manage User Badges in the UI #31262

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

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 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
183 changes: 175 additions & 8 deletions models/user/badge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -70,8 +114,24 @@ 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 {
isExist, err := IsBadgeUserExist(ctx, u.ID, badge.ID)
if err != nil {
return err
} else if isExist {
return ErrBadgeAlreadyExist{}
}

return AddUserBadges(ctx, u, []*Badge{badge})
}

Expand All @@ -80,11 +140,11 @@ func AddUserBadges(ctx context.Context, u *User, badges []*Badge) error {
return db.WithTx(ctx, func(ctx context.Context) error {
for _, badge := range badges {
// hydrate badge and check if it exists
has, err := db.GetEngine(ctx).Where("slug=?", badge.Slug).Get(badge)
has, err := db.GetEngine(ctx).Where("id=?", badge.ID).Get(badge)
if err != nil {
return err
} else if !has {
return fmt.Errorf("badge with slug %s doesn't exist", badge.Slug)
return ErrBadgeNotExist{ID: badge.ID}
}
if err := db.Insert(ctx, &UserBadge{
BadgeID: badge.ID,
Expand All @@ -106,10 +166,7 @@ func RemoveUserBadge(ctx context.Context, u *User, badge *Badge) error {
func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error {
return db.WithTx(ctx, func(ctx context.Context) error {
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 {
if _, err := db.GetEngine(ctx).Delete(&UserBadge{BadgeID: badge.ID, UserID: u.ID}); err != nil {
return err
}
}
Expand All @@ -122,3 +179,113 @@ 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)})
}

// IsBadgeUserExist checks if given badge id, uid exist,
func IsBadgeUserExist(ctx context.Context, uid, bid int64) (bool, error) {
return db.GetEngine(ctx).
Get(&UserBadge{UserID: uid, BadgeID: bid})
}

// 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)
}
41 changes: 41 additions & 0 deletions models/user/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: %d]", err.Slug, err.ID)
}

// Unwrap unwraps this error as a ErrNotExist error
func (err ErrBadgeNotExist) Unwrap() error {
return util.ErrNotExist
}
38 changes: 38 additions & 0 deletions modules/validation/binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +40,8 @@ func AddBindingRules() {
addGlobOrRegexPatternRule()
addUsernamePatternRule()
addValidGroupTeamMapRule()
addValidImageURLBindingRule()
addSlugPatternRule()
}

func addGitRefNameBindingRule() {
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions modules/validation/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package validation
import (
"net"
"net/url"
"path/filepath"
"regexp"
"strings"

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Loading
Loading