Browse Source

refactor(db): migrate avatar methods off `user.go` (#7206)

Joe Chen 2 years ago
parent
commit
d0a4a3401c

+ 6 - 6
internal/avatar/avatar.go

@@ -14,11 +14,11 @@ import (
 	"github.com/issue9/identicon"
 )
 
-const AVATAR_SIZE = 290
+const DefaultSize = 290
 
-// RandomImage generates and returns a random avatar image unique to input data
-// in custom size (height and width).
-func RandomImageSize(size int, data []byte) (image.Image, error) {
+// RandomImageWithSize generates and returns a random avatar image unique to
+// input data in custom size (height and width).
+func RandomImageWithSize(size int, data []byte) (image.Image, error) {
 	randExtent := len(palette.WebSafe) - 32
 	rand.Seed(time.Now().UnixNano())
 	colorIndex := rand.Intn(randExtent)
@@ -37,7 +37,7 @@ func RandomImageSize(size int, data []byte) (image.Image, error) {
 }
 
 // RandomImage generates and returns a random avatar image unique to input data
-// in default size (height and width).
+// in DefaultSize (height and width).
 func RandomImage(data []byte) (image.Image, error) {
-	return RandomImageSize(AVATAR_SIZE, data)
+	return RandomImageWithSize(DefaultSize, data)
 }

+ 2 - 5
internal/avatar/avatar_test.go

@@ -12,10 +12,7 @@ import (
 
 func Test_RandomImage(t *testing.T) {
 	_, err := RandomImage([]byte("gogs@local"))
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	_, err = RandomImageSize(0, []byte("gogs@local"))
+	assert.NoError(t, err)
+	_, err = RandomImageWithSize(0, []byte("gogs@local"))
 	assert.Error(t, err)
 }

+ 1 - 1
internal/db/repo.go

@@ -336,7 +336,7 @@ func (repo *Repository) UploadAvatar(data []byte) error {
 	}
 	defer fw.Close()
 
-	m := resize.Resize(avatar.AVATAR_SIZE, avatar.AVATAR_SIZE, img, resize.NearestNeighbor)
+	m := resize.Resize(avatar.DefaultSize, avatar.DefaultSize, img, resize.NearestNeighbor)
 	if err = png.Encode(fw, m); err != nil {
 		return fmt.Errorf("encode image: %v", err)
 	}

+ 0 - 40
internal/db/user.go

@@ -5,27 +5,22 @@
 package db
 
 import (
-	"bytes"
 	"context"
 	"encoding/hex"
 	"fmt"
-	"image"
 	_ "image/jpeg"
-	"image/png"
 	"os"
 	"path/filepath"
 	"strings"
 	"time"
 	"unicode/utf8"
 
-	"github.com/nfnt/resize"
 	"github.com/unknwon/com"
 	log "unknwon.dev/clog/v2"
 	"xorm.io/xorm"
 
 	"github.com/gogs/git-module"
 
-	"gogs.io/gogs/internal/avatar"
 	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/db/errors"
 	"gogs.io/gogs/internal/errutil"
@@ -58,41 +53,6 @@ func (u *User) AfterSet(colName string, _ xorm.Cell) {
 	}
 }
 
-// UploadAvatar saves custom avatar for user.
-// FIXME: split uploads to different subdirs in case we have massive number of users.
-func (u *User) UploadAvatar(data []byte) error {
-	img, _, err := image.Decode(bytes.NewReader(data))
-	if err != nil {
-		return fmt.Errorf("decode image: %v", err)
-	}
-
-	_ = os.MkdirAll(conf.Picture.AvatarUploadPath, os.ModePerm)
-	fw, err := os.Create(userutil.CustomAvatarPath(u.ID))
-	if err != nil {
-		return fmt.Errorf("create custom avatar directory: %v", err)
-	}
-	defer fw.Close()
-
-	m := resize.Resize(avatar.AVATAR_SIZE, avatar.AVATAR_SIZE, img, resize.NearestNeighbor)
-	if err = png.Encode(fw, m); err != nil {
-		return fmt.Errorf("encode image: %v", err)
-	}
-
-	return nil
-}
-
-// DeleteAvatar deletes the user's custom avatar.
-func (u *User) DeleteAvatar() error {
-	avatarPath := userutil.CustomAvatarPath(u.ID)
-	log.Trace("DeleteAvatar [%d]: %s", u.ID, avatarPath)
-	if err := os.Remove(avatarPath); err != nil {
-		return err
-	}
-
-	u.UseCustomAvatar = false
-	return UpdateUser(u)
-}
-
 // IsAdminOfRepo returns true if user has admin or higher access of repository.
 func (u *User) IsAdminOfRepo(repo *Repository) bool {
 	return Perms.Authorize(context.TODO(), u.ID, repo.ID, AccessModeAdmin,

+ 34 - 0
internal/db/users.go

@@ -7,6 +7,7 @@ package db
 import (
 	"context"
 	"fmt"
+	"os"
 	"strings"
 	"time"
 
@@ -45,6 +46,9 @@ type UsersStore interface {
 	// ErrUserAlreadyExist when a user with same name already exists, or
 	// ErrEmailAlreadyUsed if the email has been used by another user.
 	Create(ctx context.Context, username, email string, opts CreateUserOptions) (*User, error)
+	// DeleteCustomAvatar deletes the current user custom avatar and falls back to
+	// use look up avatar by email.
+	DeleteCustomAvatar(ctx context.Context, userID int64) error
 	// GetByEmail returns the user (not organization) with given email. It ignores
 	// records with unverified emails and returns ErrUserNotExist when not found.
 	GetByEmail(ctx context.Context, email string) (*User, error)
@@ -64,6 +68,8 @@ type UsersStore interface {
 	// Results are paginated by given page and page size, and sorted by the time of
 	// follow in descending order.
 	ListFollowings(ctx context.Context, userID int64, page, pageSize int) ([]*User, error)
+	// UseCustomAvatar uses the given avatar as the user custom avatar.
+	UseCustomAvatar(ctx context.Context, userID int64, avatar []byte) error
 }
 
 var Users UsersStore
@@ -267,6 +273,18 @@ func (db *users) Create(ctx context.Context, username, email string, opts Create
 	return user, db.WithContext(ctx).Create(user).Error
 }
 
+func (db *users) DeleteCustomAvatar(ctx context.Context, userID int64) error {
+	_ = os.Remove(userutil.CustomAvatarPath(userID))
+	return db.WithContext(ctx).
+		Model(&User{}).
+		Where("id = ?", userID).
+		Updates(map[string]interface{}{
+			"use_custom_avatar": false,
+			"updated_unix":      db.NowFunc().Unix(),
+		}).
+		Error
+}
+
 var _ errutil.NotFound = (*ErrUserNotExist)(nil)
 
 type ErrUserNotExist struct {
@@ -397,6 +415,22 @@ func (db *users) ListFollowings(ctx context.Context, userID int64, page, pageSiz
 	return users, tx.Find(&users).Error
 }
 
+func (db *users) UseCustomAvatar(ctx context.Context, userID int64, avatar []byte) error {
+	err := userutil.SaveAvatar(userID, avatar)
+	if err != nil {
+		return errors.Wrap(err, "save avatar")
+	}
+
+	return db.WithContext(ctx).
+		Model(&User{}).
+		Where("id = ?", userID).
+		Updates(map[string]interface{}{
+			"use_custom_avatar": true,
+			"updated_unix":      db.NowFunc().Unix(),
+		}).
+		Error
+}
+
 // UserType indicates the type of the user account.
 type UserType int
 

+ 67 - 0
internal/db/users_test.go

@@ -7,6 +7,7 @@ package db
 import (
 	"context"
 	"fmt"
+	"os"
 	"testing"
 	"time"
 
@@ -16,6 +17,9 @@ import (
 	"gogs.io/gogs/internal/auth"
 	"gogs.io/gogs/internal/dbtest"
 	"gogs.io/gogs/internal/errutil"
+	"gogs.io/gogs/internal/osutil"
+	"gogs.io/gogs/internal/userutil"
+	"gogs.io/gogs/public"
 )
 
 func TestUsers(t *testing.T) {
@@ -35,12 +39,14 @@ func TestUsers(t *testing.T) {
 	}{
 		{"Authenticate", usersAuthenticate},
 		{"Create", usersCreate},
+		{"DeleteCustomAvatar", usersDeleteCustomAvatar},
 		{"GetByEmail", usersGetByEmail},
 		{"GetByID", usersGetByID},
 		{"GetByUsername", usersGetByUsername},
 		{"HasForkedRepository", usersHasForkedRepository},
 		{"ListFollowers", usersListFollowers},
 		{"ListFollowings", usersListFollowings},
+		{"UseCustomAvatar", usersUseCustomAvatar},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
@@ -186,6 +192,42 @@ func usersCreate(t *testing.T, db *users) {
 	assert.Equal(t, db.NowFunc().Format(time.RFC3339), user.Updated.UTC().Format(time.RFC3339))
 }
 
+func usersDeleteCustomAvatar(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	avatar, err := public.Files.ReadFile("img/avatar_default.png")
+	require.NoError(t, err)
+
+	avatarPath := userutil.CustomAvatarPath(alice.ID)
+	_ = os.Remove(avatarPath)
+	defer func() { _ = os.Remove(avatarPath) }()
+
+	err = db.UseCustomAvatar(ctx, alice.ID, avatar)
+	require.NoError(t, err)
+
+	// Make sure avatar is saved and the user flag is updated.
+	got := osutil.IsFile(avatarPath)
+	assert.True(t, got)
+
+	alice, err = db.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.True(t, alice.UseCustomAvatar)
+
+	// Delete avatar should remove the file and revert the user flag.
+	err = db.DeleteCustomAvatar(ctx, alice.ID)
+	require.NoError(t, err)
+
+	got = osutil.IsFile(avatarPath)
+	assert.False(t, got)
+
+	alice, err = db.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.False(t, alice.UseCustomAvatar)
+}
+
 func usersGetByEmail(t *testing.T, db *users) {
 	ctx := context.Background()
 
@@ -366,3 +408,28 @@ func usersListFollowings(t *testing.T, db *users) {
 	require.Len(t, got, 1)
 	assert.Equal(t, alice.ID, got[0].ID)
 }
+
+func usersUseCustomAvatar(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	avatar, err := public.Files.ReadFile("img/avatar_default.png")
+	require.NoError(t, err)
+
+	avatarPath := userutil.CustomAvatarPath(alice.ID)
+	_ = os.Remove(avatarPath)
+	defer func() { _ = os.Remove(avatarPath) }()
+
+	err = db.UseCustomAvatar(ctx, alice.ID, avatar)
+	require.NoError(t, err)
+
+	// Make sure avatar is saved and the user flag is updated.
+	got := osutil.IsFile(avatarPath)
+	assert.True(t, got)
+
+	alice, err = db.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.True(t, alice.UseCustomAvatar)
+}

+ 246 - 0
internal/route/lfs/mocks_test.go

@@ -2299,6 +2299,9 @@ type MockUsersStore struct {
 	// CreateFunc is an instance of a mock function object controlling the
 	// behavior of the method Create.
 	CreateFunc *UsersStoreCreateFunc
+	// DeleteCustomAvatarFunc is an instance of a mock function object
+	// controlling the behavior of the method DeleteCustomAvatar.
+	DeleteCustomAvatarFunc *UsersStoreDeleteCustomAvatarFunc
 	// GetByEmailFunc is an instance of a mock function object controlling
 	// the behavior of the method GetByEmail.
 	GetByEmailFunc *UsersStoreGetByEmailFunc
@@ -2317,6 +2320,9 @@ type MockUsersStore struct {
 	// ListFollowingsFunc is an instance of a mock function object
 	// controlling the behavior of the method ListFollowings.
 	ListFollowingsFunc *UsersStoreListFollowingsFunc
+	// UseCustomAvatarFunc is an instance of a mock function object
+	// controlling the behavior of the method UseCustomAvatar.
+	UseCustomAvatarFunc *UsersStoreUseCustomAvatarFunc
 }
 
 // NewMockUsersStore creates a new mock of the UsersStore interface. All
@@ -2333,6 +2339,11 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		DeleteCustomAvatarFunc: &UsersStoreDeleteCustomAvatarFunc{
+			defaultHook: func(context.Context, int64) (r0 error) {
+				return
+			},
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: func(context.Context, string) (r0 *db.User, r1 error) {
 				return
@@ -2363,6 +2374,11 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		UseCustomAvatarFunc: &UsersStoreUseCustomAvatarFunc{
+			defaultHook: func(context.Context, int64, []byte) (r0 error) {
+				return
+			},
+		},
 	}
 }
 
@@ -2380,6 +2396,11 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.Create")
 			},
 		},
+		DeleteCustomAvatarFunc: &UsersStoreDeleteCustomAvatarFunc{
+			defaultHook: func(context.Context, int64) error {
+				panic("unexpected invocation of MockUsersStore.DeleteCustomAvatar")
+			},
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: func(context.Context, string) (*db.User, error) {
 				panic("unexpected invocation of MockUsersStore.GetByEmail")
@@ -2410,6 +2431,11 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.ListFollowings")
 			},
 		},
+		UseCustomAvatarFunc: &UsersStoreUseCustomAvatarFunc{
+			defaultHook: func(context.Context, int64, []byte) error {
+				panic("unexpected invocation of MockUsersStore.UseCustomAvatar")
+			},
+		},
 	}
 }
 
@@ -2423,6 +2449,9 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		CreateFunc: &UsersStoreCreateFunc{
 			defaultHook: i.Create,
 		},
+		DeleteCustomAvatarFunc: &UsersStoreDeleteCustomAvatarFunc{
+			defaultHook: i.DeleteCustomAvatar,
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: i.GetByEmail,
 		},
@@ -2441,6 +2470,9 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		ListFollowingsFunc: &UsersStoreListFollowingsFunc{
 			defaultHook: i.ListFollowings,
 		},
+		UseCustomAvatarFunc: &UsersStoreUseCustomAvatarFunc{
+			defaultHook: i.UseCustomAvatar,
+		},
 	}
 }
 
@@ -2671,6 +2703,112 @@ func (c UsersStoreCreateFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// UsersStoreDeleteCustomAvatarFunc describes the behavior when the
+// DeleteCustomAvatar method of the parent MockUsersStore instance is
+// invoked.
+type UsersStoreDeleteCustomAvatarFunc struct {
+	defaultHook func(context.Context, int64) error
+	hooks       []func(context.Context, int64) error
+	history     []UsersStoreDeleteCustomAvatarFuncCall
+	mutex       sync.Mutex
+}
+
+// DeleteCustomAvatar delegates to the next hook function in the queue and
+// stores the parameter and result values of this invocation.
+func (m *MockUsersStore) DeleteCustomAvatar(v0 context.Context, v1 int64) error {
+	r0 := m.DeleteCustomAvatarFunc.nextHook()(v0, v1)
+	m.DeleteCustomAvatarFunc.appendCall(UsersStoreDeleteCustomAvatarFuncCall{v0, v1, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the DeleteCustomAvatar
+// method of the parent MockUsersStore instance is invoked and the hook
+// queue is empty.
+func (f *UsersStoreDeleteCustomAvatarFunc) SetDefaultHook(hook func(context.Context, int64) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// DeleteCustomAvatar method of the parent MockUsersStore instance invokes
+// the hook at the front of the queue and discards it. After the queue is
+// empty, the default hook function is invoked for any future action.
+func (f *UsersStoreDeleteCustomAvatarFunc) PushHook(hook func(context.Context, int64) error) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreDeleteCustomAvatarFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreDeleteCustomAvatarFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreDeleteCustomAvatarFunc) nextHook() func(context.Context, int64) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreDeleteCustomAvatarFunc) appendCall(r0 UsersStoreDeleteCustomAvatarFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreDeleteCustomAvatarFuncCall
+// objects describing the invocations of this function.
+func (f *UsersStoreDeleteCustomAvatarFunc) History() []UsersStoreDeleteCustomAvatarFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreDeleteCustomAvatarFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreDeleteCustomAvatarFuncCall is an object that describes an
+// invocation of method DeleteCustomAvatar on an instance of MockUsersStore.
+type UsersStoreDeleteCustomAvatarFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreDeleteCustomAvatarFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreDeleteCustomAvatarFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreGetByEmailFunc describes the behavior when the GetByEmail
 // method of the parent MockUsersStore instance is invoked.
 type UsersStoreGetByEmailFunc struct {
@@ -3332,3 +3470,111 @@ func (c UsersStoreListFollowingsFuncCall) Args() []interface{} {
 func (c UsersStoreListFollowingsFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
+
+// UsersStoreUseCustomAvatarFunc describes the behavior when the
+// UseCustomAvatar method of the parent MockUsersStore instance is invoked.
+type UsersStoreUseCustomAvatarFunc struct {
+	defaultHook func(context.Context, int64, []byte) error
+	hooks       []func(context.Context, int64, []byte) error
+	history     []UsersStoreUseCustomAvatarFuncCall
+	mutex       sync.Mutex
+}
+
+// UseCustomAvatar delegates to the next hook function in the queue and
+// stores the parameter and result values of this invocation.
+func (m *MockUsersStore) UseCustomAvatar(v0 context.Context, v1 int64, v2 []byte) error {
+	r0 := m.UseCustomAvatarFunc.nextHook()(v0, v1, v2)
+	m.UseCustomAvatarFunc.appendCall(UsersStoreUseCustomAvatarFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the UseCustomAvatar
+// method of the parent MockUsersStore instance is invoked and the hook
+// queue is empty.
+func (f *UsersStoreUseCustomAvatarFunc) SetDefaultHook(hook func(context.Context, int64, []byte) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// UseCustomAvatar method of the parent MockUsersStore instance invokes the
+// hook at the front of the queue and discards it. After the queue is empty,
+// the default hook function is invoked for any future action.
+func (f *UsersStoreUseCustomAvatarFunc) PushHook(hook func(context.Context, int64, []byte) error) {
+	f.mutex.Lock()
+	f.hooks = append(f.hooks, hook)
+	f.mutex.Unlock()
+}
+
+// SetDefaultReturn calls SetDefaultHook with a function that returns the
+// given values.
+func (f *UsersStoreUseCustomAvatarFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, []byte) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreUseCustomAvatarFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, []byte) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreUseCustomAvatarFunc) nextHook() func(context.Context, int64, []byte) error {
+	f.mutex.Lock()
+	defer f.mutex.Unlock()
+
+	if len(f.hooks) == 0 {
+		return f.defaultHook
+	}
+
+	hook := f.hooks[0]
+	f.hooks = f.hooks[1:]
+	return hook
+}
+
+func (f *UsersStoreUseCustomAvatarFunc) appendCall(r0 UsersStoreUseCustomAvatarFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreUseCustomAvatarFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreUseCustomAvatarFunc) History() []UsersStoreUseCustomAvatarFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreUseCustomAvatarFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreUseCustomAvatarFuncCall is an object that describes an
+// invocation of method UseCustomAvatar on an instance of MockUsersStore.
+type UsersStoreUseCustomAvatarFuncCall struct {
+	// Arg0 is the value of the 1st argument passed to this method
+	// invocation.
+	Arg0 context.Context
+	// Arg1 is the value of the 2nd argument passed to this method
+	// invocation.
+	Arg1 int64
+	// Arg2 is the value of the 3rd argument passed to this method
+	// invocation.
+	Arg2 []byte
+	// Result0 is the value of the 1st result returned from this method
+	// invocation.
+	Result0 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c UsersStoreUseCustomAvatarFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreUseCustomAvatarFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}

+ 1 - 1
internal/route/org/setting.go

@@ -96,7 +96,7 @@ func SettingsAvatar(c *context.Context, f form.Avatar) {
 }
 
 func SettingsDeleteAvatar(c *context.Context) {
-	if err := c.Org.Organization.DeleteAvatar(); err != nil {
+	if err := db.Users.DeleteCustomAvatar(c.Req.Context(), c.Org.Organization.ID); err != nil {
 		c.Flash.Error(err.Error())
 	}
 

+ 16 - 23
internal/route/user/setting.go

@@ -13,9 +13,9 @@ import (
 	"io"
 	"strings"
 
+	"github.com/pkg/errors"
 	"github.com/pquerna/otp"
 	"github.com/pquerna/otp/totp"
-	"github.com/unknwon/com"
 	log "unknwon.dev/clog/v2"
 
 	"gogs.io/gogs/internal/auth"
@@ -23,7 +23,6 @@ import (
 	"gogs.io/gogs/internal/context"
 	"gogs.io/gogs/internal/cryptoutil"
 	"gogs.io/gogs/internal/db"
-	"gogs.io/gogs/internal/db/errors"
 	"gogs.io/gogs/internal/email"
 	"gogs.io/gogs/internal/form"
 	"gogs.io/gogs/internal/tool"
@@ -117,10 +116,15 @@ func SettingsPost(c *context.Context, f form.UpdateProfile) {
 
 // FIXME: limit upload size
 func UpdateAvatarSetting(c *context.Context, f form.Avatar, ctxUser *db.User) error {
-	ctxUser.UseCustomAvatar = f.Source == form.AVATAR_LOCAL
-	if len(f.Gravatar) > 0 {
+	if f.Source == form.AVATAR_BYMAIL && len(f.Gravatar) > 0 {
+		ctxUser.UseCustomAvatar = false
 		ctxUser.Avatar = cryptoutil.MD5(f.Gravatar)
 		ctxUser.AvatarEmail = f.Gravatar
+
+		if err := db.UpdateUser(ctxUser); err != nil {
+			return fmt.Errorf("update user: %v", err)
+		}
+		return nil
 	}
 
 	if f.Avatar != nil && f.Avatar.Filename != "" {
@@ -128,9 +132,7 @@ func UpdateAvatarSetting(c *context.Context, f form.Avatar, ctxUser *db.User) er
 		if err != nil {
 			return fmt.Errorf("open avatar reader: %v", err)
 		}
-		defer func() {
-			_ = r.Close()
-		}()
+		defer func() { _ = r.Close() }()
 
 		data, err := io.ReadAll(r)
 		if err != nil {
@@ -139,23 +141,13 @@ func UpdateAvatarSetting(c *context.Context, f form.Avatar, ctxUser *db.User) er
 		if !tool.IsImageFile(data) {
 			return errors.New(c.Tr("settings.uploaded_avatar_not_a_image"))
 		}
-		if err = ctxUser.UploadAvatar(data); err != nil {
-			return fmt.Errorf("upload avatar: %v", err)
-		}
-	} else {
-		// No avatar is uploaded but setting has been changed to enable,
-		// generate a random one when needed.
-		if ctxUser.UseCustomAvatar && !com.IsFile(userutil.CustomAvatarPath(ctxUser.ID)) {
-			if err := userutil.GenerateRandomAvatar(ctxUser.ID, ctxUser.Name, ctxUser.Email); err != nil {
-				log.Error("generate random avatar [%d]: %v", ctxUser.ID, err)
-			}
-		}
-	}
 
-	if err := db.UpdateUser(ctxUser); err != nil {
-		return fmt.Errorf("update user: %v", err)
+		err = db.Users.UseCustomAvatar(c.Req.Context(), ctxUser.ID, data)
+		if err != nil {
+			return errors.Wrap(err, "save avatar")
+		}
+		return nil
 	}
-
 	return nil
 }
 
@@ -176,7 +168,8 @@ func SettingsAvatarPost(c *context.Context, f form.Avatar) {
 }
 
 func SettingsDeleteAvatar(c *context.Context) {
-	if err := c.User.DeleteAvatar(); err != nil {
+	err := db.Users.DeleteCustomAvatar(c.Req.Context(), c.User.ID)
+	if err != nil {
 		c.Flash.Error(fmt.Sprintf("Failed to delete avatar: %v", err))
 	}
 

+ 29 - 0
internal/userutil/userutil.go

@@ -5,16 +5,19 @@
 package userutil
 
 import (
+	"bytes"
 	"crypto/sha256"
 	"crypto/subtle"
 	"encoding/hex"
 	"fmt"
+	"image"
 	"image/png"
 	"os"
 	"path/filepath"
 	"strconv"
 	"strings"
 
+	"github.com/nfnt/resize"
 	"github.com/pkg/errors"
 	"golang.org/x/crypto/pbkdf2"
 
@@ -81,6 +84,32 @@ func GenerateRandomAvatar(userID int64, name, email string) error {
 	return nil
 }
 
+// SaveAvatar saves the given avatar for the user.
+func SaveAvatar(userID int64, data []byte) error {
+	img, _, err := image.Decode(bytes.NewReader(data))
+	if err != nil {
+		return errors.Wrap(err, "decode image")
+	}
+
+	avatarPath := CustomAvatarPath(userID)
+	err = os.MkdirAll(filepath.Dir(avatarPath), os.ModePerm)
+	if err != nil {
+		return errors.Wrap(err, "create avatar directory")
+	}
+
+	f, err := os.Create(avatarPath)
+	if err != nil {
+		return errors.Wrap(err, "create avatar file")
+	}
+	defer func() { _ = f.Close() }()
+
+	m := resize.Resize(avatar.DefaultSize, avatar.DefaultSize, img, resize.NearestNeighbor)
+	if err = png.Encode(f, m); err != nil {
+		return errors.Wrap(err, "encode avatar image to file")
+	}
+	return nil
+}
+
 // EncodePassword encodes password using PBKDF2 SHA256 with given salt.
 func EncodePassword(password, salt string) string {
 	newPasswd := pbkdf2.Key([]byte(password), []byte(salt), 10000, 50, sha256.New)

+ 29 - 1
internal/userutil/userutil_test.go

@@ -15,6 +15,7 @@ import (
 	"gogs.io/gogs/internal/conf"
 	"gogs.io/gogs/internal/osutil"
 	"gogs.io/gogs/internal/tool"
+	"gogs.io/gogs/public"
 )
 
 func TestDashboardURLPath(t *testing.T) {
@@ -72,9 +73,36 @@ func TestGenerateRandomAvatar(t *testing.T) {
 		},
 	)
 
+	avatarPath := CustomAvatarPath(1)
+	defer func() { _ = os.Remove(avatarPath) }()
+
 	err := GenerateRandomAvatar(1, "alice", "[email protected]")
 	require.NoError(t, err)
-	got := osutil.IsFile(CustomAvatarPath(1))
+	got := osutil.IsFile(avatarPath)
+	assert.True(t, got)
+}
+
+func TestSaveAvatar(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("Skipping testing on Windows")
+		return
+	}
+
+	conf.SetMockPicture(t,
+		conf.PictureOpts{
+			AvatarUploadPath: os.TempDir(),
+		},
+	)
+
+	avatar, err := public.Files.ReadFile("img/avatar_default.png")
+	require.NoError(t, err)
+
+	avatarPath := CustomAvatarPath(1)
+	defer func() { _ = os.Remove(avatarPath) }()
+
+	err = SaveAvatar(1, avatar)
+	require.NoError(t, err)
+	got := osutil.IsFile(avatarPath)
 	assert.True(t, got)
 }
 

BIN
public/img/avatar_default.png