Refactor user & avatar (#33433)

1. better GetPossibleUserByID logic
2. fix some function name & comment typos
3. do not re-generate avatar if one exists
pull/33435/head^2
wxiaoguang 1 day ago committed by GitHub
parent a89c735303
commit 4ffc54f59a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 21
      models/user/avatar.go
  2. 40
      models/user/avatar_test.go
  3. 51
      models/user/user.go
  4. 16
      models/user/user_system.go
  5. 32
      models/user/user_system_test.go
  6. 4
      models/user/user_test.go
  7. 4
      modules/storage/storage.go
  8. 25
      routers/web/user/avatar.go
  9. 2
      routers/web/user/home.go
  10. 2
      routers/web/web.go
  11. 2
      services/actions/notifier_helper.go
  12. 2
      services/mailer/mail_issue.go
  13. 4
      services/mailer/mail_release.go

@ -38,27 +38,30 @@ func GenerateRandomAvatar(ctx context.Context, u *User) error {
u.Avatar = avatars.HashEmail(seed) u.Avatar = avatars.HashEmail(seed)
// Don't share the images so that we can delete them easily _, err = storage.Avatars.Stat(u.CustomAvatarRelativePath())
if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error { if err != nil {
if err := png.Encode(w, img); err != nil { // If unable to Stat the avatar file (usually it means non-existing), then try to save a new one
log.Error("Encode: %v", err) // Don't share the images so that we can delete them easily
if err := storage.SaveFrom(storage.Avatars, u.CustomAvatarRelativePath(), func(w io.Writer) error {
if err := png.Encode(w, img); err != nil {
log.Error("Encode: %v", err)
}
return nil
}); err != nil {
return fmt.Errorf("failed to save avatar %s: %w", u.CustomAvatarRelativePath(), err)
} }
return err
}); err != nil {
return fmt.Errorf("Failed to create dir %s: %w", u.CustomAvatarRelativePath(), err)
} }
if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar").Update(u); err != nil { if _, err := db.GetEngine(ctx).ID(u.ID).Cols("avatar").Update(u); err != nil {
return err return err
} }
log.Info("New random avatar created: %d", u.ID)
return nil return nil
} }
// AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size // AvatarLinkWithSize returns a link to the user's avatar with size. size <= 0 means default size
func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string { func (u *User) AvatarLinkWithSize(ctx context.Context, size int) string {
if u.IsGhost() { if u.IsGhost() || u.IsGiteaActions() {
return avatars.DefaultAvatarLink() return avatars.DefaultAvatarLink()
} }

@ -4,13 +4,19 @@
package user package user
import ( import (
"context"
"io"
"strings"
"testing" "testing"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestUserAvatarLink(t *testing.T) { func TestUserAvatarLink(t *testing.T) {
@ -26,3 +32,37 @@ func TestUserAvatarLink(t *testing.T) {
link = u.AvatarLink(db.DefaultContext) link = u.AvatarLink(db.DefaultContext)
assert.Equal(t, "https://localhost/sub-path/avatars/avatar.png", link) assert.Equal(t, "https://localhost/sub-path/avatars/avatar.png", link)
} }
func TestUserAvatarGenerate(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
var err error
tmpDir := t.TempDir()
storage.Avatars, err = storage.NewLocalStorage(context.Background(), &setting.Storage{Path: tmpDir})
require.NoError(t, err)
u := unittest.AssertExistsAndLoadBean(t, &User{ID: 2})
// there was no avatar, generate a new one
assert.Empty(t, u.Avatar)
err = GenerateRandomAvatar(db.DefaultContext, u)
require.NoError(t, err)
assert.NotEmpty(t, u.Avatar)
// make sure the generated one exists
oldAvatarPath := u.CustomAvatarRelativePath()
_, err = storage.Avatars.Stat(u.CustomAvatarRelativePath())
require.NoError(t, err)
// and try to change its content
_, err = storage.Avatars.Save(u.CustomAvatarRelativePath(), strings.NewReader("abcd"), 4)
require.NoError(t, err)
// try to generate again
err = GenerateRandomAvatar(db.DefaultContext, u)
require.NoError(t, err)
assert.Equal(t, oldAvatarPath, u.CustomAvatarRelativePath())
f, err := storage.Avatars.Open(u.CustomAvatarRelativePath())
require.NoError(t, err)
defer f.Close()
content, _ := io.ReadAll(f)
assert.Equal(t, "abcd", string(content))
}

@ -528,6 +528,7 @@ type globalVarsStruct struct {
replaceCharsHyphenRE *regexp.Regexp replaceCharsHyphenRE *regexp.Regexp
emailToReplacer *strings.Replacer emailToReplacer *strings.Replacer
emailRegexp *regexp.Regexp emailRegexp *regexp.Regexp
systemUserNewFuncs map[int64]func() *User
} }
var globalVars = sync.OnceValue(func() *globalVarsStruct { var globalVars = sync.OnceValue(func() *globalVarsStruct {
@ -550,6 +551,11 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
";", "", ";", "",
), ),
emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"), emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"),
systemUserNewFuncs: map[int64]func() *User{
GhostUserID: NewGhostUser,
ActionsUserID: NewActionsUser,
},
} }
}) })
@ -978,30 +984,28 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, err return users, err
} }
// GetPossibleUserByID returns the user if id > 0 or return system usrs if id < 0 // GetPossibleUserByID returns the user if id > 0 or returns system user if id < 0
func GetPossibleUserByID(ctx context.Context, id int64) (*User, error) { func GetPossibleUserByID(ctx context.Context, id int64) (*User, error) {
switch id { if id < 0 {
case GhostUserID: if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok {
return NewGhostUser(), nil return newFunc(), nil
case ActionsUserID: }
return NewActionsUser(), nil return nil, ErrUserNotExist{UID: id}
case 0: } else if id == 0 {
return nil, ErrUserNotExist{} return nil, ErrUserNotExist{}
default:
return GetUserByID(ctx, id)
} }
return GetUserByID(ctx, id)
} }
// GetPossibleUserByIDs returns the users if id > 0 or return system users if id < 0 // GetPossibleUserByIDs returns the users if id > 0 or returns system users if id < 0
func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) { func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
uniqueIDs := container.SetOf(ids...) uniqueIDs := container.SetOf(ids...)
users := make([]*User, 0, len(ids)) users := make([]*User, 0, len(ids))
_ = uniqueIDs.Remove(0) _ = uniqueIDs.Remove(0)
if uniqueIDs.Remove(GhostUserID) { for systemUID, newFunc := range globalVars().systemUserNewFuncs {
users = append(users, NewGhostUser()) if uniqueIDs.Remove(systemUID) {
} users = append(users, newFunc())
if uniqueIDs.Remove(ActionsUserID) { }
users = append(users, NewActionsUser())
} }
res, err := GetUserByIDs(ctx, uniqueIDs.Values()) res, err := GetUserByIDs(ctx, uniqueIDs.Values())
if err != nil { if err != nil {
@ -1011,7 +1015,7 @@ func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
return users, nil return users, nil
} }
// GetUserByNameCtx returns user by given name. // GetUserByName returns user by given name.
func GetUserByName(ctx context.Context, name string) (*User, error) { func GetUserByName(ctx context.Context, name string) (*User, error) {
if len(name) == 0 { if len(name) == 0 {
return nil, ErrUserNotExist{Name: name} return nil, ErrUserNotExist{Name: name}
@ -1042,8 +1046,8 @@ func GetUserEmailsByNames(ctx context.Context, names []string) []string {
return mails return mails
} }
// GetMaileableUsersByIDs gets users from ids, but only if they can receive mails // GetMailableUsersByIDs gets users from ids, but only if they can receive mails
func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([]*User, error) { func GetMailableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([]*User, error) {
if len(ids) == 0 { if len(ids) == 0 {
return nil, nil return nil, nil
} }
@ -1068,17 +1072,6 @@ func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([
Find(&ous) Find(&ous)
} }
// GetUserNamesByIDs returns usernames for all resolved users from a list of Ids.
func GetUserNamesByIDs(ctx context.Context, ids []int64) ([]string, error) {
unames := make([]string, 0, len(ids))
err := db.GetEngine(ctx).In("id", ids).
Table("user").
Asc("name").
Cols("name").
Find(&unames)
return unames, err
}
// GetUserNameByID returns username for the id // GetUserNameByID returns username for the id
func GetUserNameByID(ctx context.Context, id int64) (string, error) { func GetUserNameByID(ctx context.Context, id int64) (string, error) {
var name string var name string

@ -41,6 +41,10 @@ const (
ActionsUserEmail = "teabot@gitea.io" ActionsUserEmail = "teabot@gitea.io"
) )
func IsGiteaActionsUserName(name string) bool {
return strings.EqualFold(name, ActionsUserName)
}
// NewActionsUser creates and returns a fake user for running the actions. // NewActionsUser creates and returns a fake user for running the actions.
func NewActionsUser() *User { func NewActionsUser() *User {
return &User{ return &User{
@ -58,6 +62,16 @@ func NewActionsUser() *User {
} }
} }
func (u *User) IsActions() bool { func (u *User) IsGiteaActions() bool {
return u != nil && u.ID == ActionsUserID return u != nil && u.ID == ActionsUserID
} }
func GetSystemUserByName(name string) *User {
if IsGhostUserName(name) {
return NewGhostUser()
}
if IsGiteaActionsUserName(name) {
return NewActionsUser()
}
return nil
}

@ -0,0 +1,32 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"testing"
"code.gitea.io/gitea/models/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSystemUser(t *testing.T) {
u, err := GetPossibleUserByID(db.DefaultContext, -1)
require.NoError(t, err)
assert.Equal(t, "Ghost", u.Name)
assert.Equal(t, "ghost", u.LowerName)
assert.True(t, u.IsGhost())
assert.True(t, IsGhostUserName("gHost"))
u, err = GetPossibleUserByID(db.DefaultContext, -2)
require.NoError(t, err)
assert.Equal(t, "gitea-actions", u.Name)
assert.Equal(t, "gitea-actions", u.LowerName)
assert.True(t, u.IsGiteaActions())
assert.True(t, IsGiteaActionsUserName("Gitea-actionS"))
_, err = GetPossibleUserByID(db.DefaultContext, -3)
require.Error(t, err)
}

@ -333,14 +333,14 @@ func TestGetUserIDsByNames(t *testing.T) {
func TestGetMaileableUsersByIDs(t *testing.T) { func TestGetMaileableUsersByIDs(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
results, err := user_model.GetMaileableUsersByIDs(db.DefaultContext, []int64{1, 4}, false) results, err := user_model.GetMailableUsersByIDs(db.DefaultContext, []int64{1, 4}, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, results, 1) assert.Len(t, results, 1)
if len(results) > 1 { if len(results) > 1 {
assert.Equal(t, 1, results[0].ID) assert.Equal(t, 1, results[0].ID)
} }
results, err = user_model.GetMaileableUsersByIDs(db.DefaultContext, []int64{1, 4}, true) results, err = user_model.GetMailableUsersByIDs(db.DefaultContext, []int64{1, 4}, true)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, results, 2) assert.Len(t, results, 2)
if len(results) > 2 { if len(results) > 2 {

@ -93,7 +93,7 @@ func Clean(storage ObjectStorage) error {
} }
// SaveFrom saves data to the ObjectStorage with path p from the callback // SaveFrom saves data to the ObjectStorage with path p from the callback
func SaveFrom(objStorage ObjectStorage, p string, callback func(w io.Writer) error) error { func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer) error) error {
pr, pw := io.Pipe() pr, pw := io.Pipe()
defer pr.Close() defer pr.Close()
go func() { go func() {
@ -103,7 +103,7 @@ func SaveFrom(objStorage ObjectStorage, p string, callback func(w io.Writer) err
} }
}() }()
_, err := objStorage.Save(p, pr, -1) _, err := objStorage.Save(path, pr, -1)
return err return err
} }

@ -20,27 +20,18 @@ func cacheableRedirect(ctx *context.Context, location string) {
ctx.Redirect(location) ctx.Redirect(location)
} }
// AvatarByUserName redirect browser to user avatar of requested size // AvatarByUsernameSize redirect browser to user avatar of requested size
func AvatarByUserName(ctx *context.Context) { func AvatarByUsernameSize(ctx *context.Context) {
userName := ctx.PathParam("username") username := ctx.PathParam("username")
size := int(ctx.PathParamInt64("size")) user := user_model.GetSystemUserByName(username)
if user == nil {
var user *user_model.User
if !user_model.IsGhostUserName(userName) {
var err error var err error
if user, err = user_model.GetUserByName(ctx, userName); err != nil { if user, err = user_model.GetUserByName(ctx, username); err != nil {
if user_model.IsErrUserNotExist(err) { ctx.NotFoundOrServerError("GetUserByName", user_model.IsErrUserNotExist, err)
ctx.NotFound("GetUserByName", err)
return
}
ctx.ServerError("Invalid user: "+userName, err)
return return
} }
} else {
user = user_model.NewGhostUser()
} }
cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, int(ctx.PathParamInt64("size"))))
cacheableRedirect(ctx, user.AvatarLinkWithSize(ctx, size))
} }
// AvatarByEmailHash redirects the browser to the email avatar link // AvatarByEmailHash redirects the browser to the email avatar link

@ -732,7 +732,7 @@ func UsernameSubRoute(ctx *context.Context) {
switch { switch {
case strings.HasSuffix(username, ".png"): case strings.HasSuffix(username, ".png"):
if reloadParam(".png") { if reloadParam(".png") {
AvatarByUserName(ctx) AvatarByUsernameSize(ctx)
} }
case strings.HasSuffix(username, ".keys"): case strings.HasSuffix(username, ".keys"):
if reloadParam(".keys") { if reloadParam(".keys") {

@ -681,7 +681,7 @@ func registerRoutes(m *web.Router) {
m.Get("/activate", auth.Activate) m.Get("/activate", auth.Activate)
m.Post("/activate", auth.ActivatePost) m.Post("/activate", auth.ActivatePost)
m.Any("/activate_email", auth.ActivateEmail) m.Any("/activate_email", auth.ActivateEmail)
m.Get("/avatar/{username}/{size}", user.AvatarByUserName) m.Get("/avatar/{username}/{size}", user.AvatarByUsernameSize)
m.Get("/recover_account", auth.ResetPasswd) m.Get("/recover_account", auth.ResetPasswd)
m.Post("/recover_account", auth.ResetPasswdPost) m.Post("/recover_account", auth.ResetPasswdPost)
m.Get("/forgot_password", auth.ForgotPasswd) m.Get("/forgot_password", auth.ForgotPasswd)

@ -117,7 +117,7 @@ func (input *notifyInput) Notify(ctx context.Context) {
func notify(ctx context.Context, input *notifyInput) error { func notify(ctx context.Context, input *notifyInput) error {
shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch shouldDetectSchedules := input.Event == webhook_module.HookEventPush && input.Ref.BranchName() == input.Repo.DefaultBranch
if input.Doer.IsActions() { if input.Doer.IsGiteaActions() {
// avoiding triggering cyclically, for example: // avoiding triggering cyclically, for example:
// a comment of an issue will trigger the runner to add a new comment as reply, // a comment of an issue will trigger the runner to add a new comment as reply,
// and the new comment will trigger the runner again. // and the new comment will trigger the runner again.

@ -109,7 +109,7 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_mo
} }
visited.AddMultiple(ids...) visited.AddMultiple(ids...)
unfilteredUsers, err := user_model.GetMaileableUsersByIDs(ctx, unfiltered, false) unfilteredUsers, err := user_model.GetMailableUsersByIDs(ctx, unfiltered, false)
if err != nil { if err != nil {
return err return err
} }

@ -35,9 +35,9 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
return return
} }
recipients, err := user_model.GetMaileableUsersByIDs(ctx, watcherIDList, false) recipients, err := user_model.GetMailableUsersByIDs(ctx, watcherIDList, false)
if err != nil { if err != nil {
log.Error("user_model.GetMaileableUsersByIDs: %v", err) log.Error("user_model.GetMailableUsersByIDs: %v", err)
return return
} }

Loading…
Cancel
Save