Browse Source

refactor(db): finish migrate methods off `user.go` (#7337)

Joe Chen 2 years ago
parent
commit
133b9d9044

+ 2 - 2
internal/cmd/admin.go

@@ -52,8 +52,8 @@ to make automatic initialization process more smoothly`,
 		Name:  "delete-inactive-users",
 		Usage: "Delete all inactive accounts",
 		Action: adminDashboardOperation(
-			db.DeleteInactivateUsers,
-			"All inactivate accounts have been deleted successfully",
+			func() error { return db.Users.DeleteInactivated() },
+			"All inactivated accounts have been deleted successfully",
 		),
 		Flags: []cli.Flag{
 			stringFlag("config, c", "", "Custom configuration file path"),

+ 8 - 0
internal/conf/mocks.go

@@ -37,11 +37,15 @@ func SetMockServer(t *testing.T, opts ServerOpts) {
 	})
 }
 
+var mockSSH sync.Mutex
+
 func SetMockSSH(t *testing.T, opts SSHOpts) {
+	mockSSH.Lock()
 	before := SSH
 	SSH = opts
 	t.Cleanup(func() {
 		SSH = before
+		mockSSH.Unlock()
 	})
 }
 
@@ -65,10 +69,14 @@ func SetMockUI(t *testing.T, opts UIOpts) {
 	})
 }
 
+var mockPicture sync.Mutex
+
 func SetMockPicture(t *testing.T, opts PictureOpts) {
+	mockPicture.Lock()
 	before := Picture
 	Picture = opts
 	t.Cleanup(func() {
 		Picture = before
+		mockPicture.Unlock()
 	})
 }

+ 10 - 10
internal/db/actions.go

@@ -111,16 +111,16 @@ func (db *actions) listByOrganization(ctx context.Context, orgID, actorID, after
 			Where("?", afterID <= 0).
 			Or("id < ?", afterID),
 		).
-		Where("repo_id IN (?)",
-			db.Select("repository.id").
-				Table("repository").
-				Joins("JOIN team_repo ON repository.id = team_repo.repo_id").
-				Where("team_repo.team_id IN (?)",
-					db.Select("team_id").
-						Table("team_user").
-						Where("team_user.org_id = ? AND uid = ?", orgID, actorID),
-				).
-				Or("repository.is_private = ? AND repository.is_unlisted = ?", false, false),
+		Where("repo_id IN (?)", db.
+			Select("repository.id").
+			Table("repository").
+			Joins("JOIN team_repo ON repository.id = team_repo.repo_id").
+			Where("team_repo.team_id IN (?)", db.
+				Select("team_id").
+				Table("team_user").
+				Where("team_user.org_id = ? AND uid = ?", orgID, actorID),
+			).
+			Or("repository.is_private = ? AND repository.is_unlisted = ?", false, false),
 		).
 		Limit(conf.UI.User.NewsFeedPagingNum).
 		Order("id DESC")

+ 0 - 33
internal/db/error.go

@@ -8,39 +8,6 @@ import (
 	"fmt"
 )
 
-//  ____ ___
-// |    |   \______ ___________
-// |    |   /  ___// __ \_  __ \
-// |    |  /\___ \\  ___/|  | \/
-// |______//____  >\___  >__|
-//              \/     \/
-
-type ErrUserOwnRepos struct {
-	UID int64
-}
-
-func IsErrUserOwnRepos(err error) bool {
-	_, ok := err.(ErrUserOwnRepos)
-	return ok
-}
-
-func (err ErrUserOwnRepos) Error() string {
-	return fmt.Sprintf("user still has ownership of repositories [uid: %d]", err.UID)
-}
-
-type ErrUserHasOrgs struct {
-	UID int64
-}
-
-func IsErrUserHasOrgs(err error) bool {
-	_, ok := err.(ErrUserHasOrgs)
-	return ok
-}
-
-func (err ErrUserHasOrgs) Error() string {
-	return fmt.Sprintf("user still has membership of organizations [uid: %d]", err.UID)
-}
-
 //  __      __.__ __   .__
 // /  \    /  \__|  | _|__|
 // \   \/\/   /  |  |/ /  |

+ 2 - 2
internal/db/follows.go

@@ -55,7 +55,7 @@ func (*follows) updateFollowingCount(tx *gorm.DB, userID, followID int64) error
 		).
 		Error
 	if err != nil {
-		return errors.Wrap(err, `update "num_followers"`)
+		return errors.Wrap(err, `update "user.num_followers"`)
 	}
 
 	/*
@@ -75,7 +75,7 @@ func (*follows) updateFollowingCount(tx *gorm.DB, userID, followID int64) error
 		).
 		Error
 	if err != nil {
-		return errors.Wrap(err, `update "num_following"`)
+		return errors.Wrap(err, `update "user.num_following"`)
 	}
 	return nil
 }

+ 29 - 29
internal/db/issue.go

@@ -26,36 +26,36 @@ var ErrMissingIssueNumber = errors.New("No issue number specified")
 
 // Issue represents an issue or pull request of repository.
 type Issue struct {
-	ID              int64
-	RepoID          int64       `xorm:"INDEX UNIQUE(repo_index)"`
-	Repo            *Repository `xorm:"-" json:"-"`
-	Index           int64       `xorm:"UNIQUE(repo_index)"` // Index in one repository.
-	PosterID        int64
-	Poster          *User    `xorm:"-" json:"-"`
-	Title           string   `xorm:"name"`
-	Content         string   `xorm:"TEXT"`
-	RenderedContent string   `xorm:"-" json:"-"`
-	Labels          []*Label `xorm:"-" json:"-"`
-	MilestoneID     int64
-	Milestone       *Milestone `xorm:"-" json:"-"`
+	ID              int64       `gorm:"primaryKey"`
+	RepoID          int64       `xorm:"INDEX UNIQUE(repo_index)" gorm:"index;uniqueIndex:issue_repo_index_unique;not null"`
+	Repo            *Repository `xorm:"-" json:"-" gorm:"-"`
+	Index           int64       `xorm:"UNIQUE(repo_index)" gorm:"uniqueIndex:issue_repo_index_unique;not null"` // Index in one repository.
+	PosterID        int64       `gorm:"index"`
+	Poster          *User       `xorm:"-" json:"-" gorm:"-"`
+	Title           string      `xorm:"name" gorm:"name"`
+	Content         string      `xorm:"TEXT" gorm:"type:TEXT"`
+	RenderedContent string      `xorm:"-" json:"-" gorm:"-"`
+	Labels          []*Label    `xorm:"-" json:"-" gorm:"-"`
+	MilestoneID     int64       `gorm:"index"`
+	Milestone       *Milestone  `xorm:"-" json:"-" gorm:"-"`
 	Priority        int
-	AssigneeID      int64
-	Assignee        *User `xorm:"-" json:"-"`
+	AssigneeID      int64 `gorm:"index"`
+	Assignee        *User `xorm:"-" json:"-" gorm:"-"`
 	IsClosed        bool
-	IsRead          bool         `xorm:"-" json:"-"`
+	IsRead          bool         `xorm:"-" json:"-" gorm:"-"`
 	IsPull          bool         // Indicates whether is a pull request or not.
-	PullRequest     *PullRequest `xorm:"-" json:"-"`
+	PullRequest     *PullRequest `xorm:"-" json:"-" gorm:"-"`
 	NumComments     int
 
-	Deadline     time.Time `xorm:"-" json:"-"`
+	Deadline     time.Time `xorm:"-" json:"-" gorm:"-"`
 	DeadlineUnix int64
-	Created      time.Time `xorm:"-" json:"-"`
+	Created      time.Time `xorm:"-" json:"-" gorm:"-"`
 	CreatedUnix  int64
-	Updated      time.Time `xorm:"-" json:"-"`
+	Updated      time.Time `xorm:"-" json:"-" gorm:"-"`
 	UpdatedUnix  int64
 
-	Attachments []*Attachment `xorm:"-" json:"-"`
-	Comments    []*Comment    `xorm:"-" json:"-"`
+	Attachments []*Attachment `xorm:"-" json:"-" gorm:"-"`
+	Comments    []*Comment    `xorm:"-" json:"-" gorm:"-"`
 }
 
 func (issue *Issue) BeforeInsert() {
@@ -1036,10 +1036,10 @@ func GetParticipantsByIssueID(issueID int64) ([]*User, error) {
 
 // IssueUser represents an issue-user relation.
 type IssueUser struct {
-	ID          int64
-	UID         int64 `xorm:"INDEX"` // User ID.
+	ID          int64 `gorm:"primary_key"`
+	UserID      int64 `xorm:"uid INDEX" gorm:"column:uid;index"`
 	IssueID     int64
-	RepoID      int64 `xorm:"INDEX"`
+	RepoID      int64 `xorm:"INDEX" gorm:"index"`
 	MilestoneID int64
 	IsRead      bool
 	IsAssigned  bool
@@ -1065,7 +1065,7 @@ func newIssueUsers(e *xorm.Session, repo *Repository, issue *Issue) error {
 		issueUsers = append(issueUsers, &IssueUser{
 			IssueID:    issue.ID,
 			RepoID:     repo.ID,
-			UID:        assignee.ID,
+			UserID:     assignee.ID,
 			IsPoster:   isPoster,
 			IsAssigned: assignee.ID == issue.AssigneeID,
 		})
@@ -1077,7 +1077,7 @@ func newIssueUsers(e *xorm.Session, repo *Repository, issue *Issue) error {
 		issueUsers = append(issueUsers, &IssueUser{
 			IssueID:  issue.ID,
 			RepoID:   repo.ID,
-			UID:      issue.PosterID,
+			UserID:   issue.PosterID,
 			IsPoster: true,
 		})
 	}
@@ -1107,7 +1107,7 @@ func NewIssueUsers(repo *Repository, issue *Issue) (err error) {
 func PairsContains(ius []*IssueUser, issueId, uid int64) int {
 	for i := range ius {
 		if ius[i].IssueID == issueId &&
-			ius[i].UID == uid {
+			ius[i].UserID == uid {
 			return i
 		}
 	}
@@ -1117,7 +1117,7 @@ func PairsContains(ius []*IssueUser, issueId, uid int64) int {
 // GetIssueUsers returns issue-user pairs by given repository and user.
 func GetIssueUsers(rid, uid int64, isClosed bool) ([]*IssueUser, error) {
 	ius := make([]*IssueUser, 0, 10)
-	err := x.Where("is_closed=?", isClosed).Find(&ius, &IssueUser{RepoID: rid, UID: uid})
+	err := x.Where("is_closed=?", isClosed).Find(&ius, &IssueUser{RepoID: rid, UserID: uid})
 	return ius, err
 }
 
@@ -1442,7 +1442,7 @@ func UpdateIssueUserByRead(uid, issueID int64) error {
 func updateIssueUsersByMentions(e Engine, issueID int64, uids []int64) error {
 	for _, uid := range uids {
 		iu := &IssueUser{
-			UID:     uid,
+			UserID:  uid,
 			IssueID: issueID,
 		}
 		has, err := e.Get(iu)

+ 13 - 7
internal/db/org.go

@@ -204,9 +204,20 @@ func Organizations(page, pageSize int) ([]*User, error) {
 	return orgs, x.Limit(pageSize, (page-1)*pageSize).Where("type=1").Asc("id").Find(&orgs)
 }
 
+// deleteBeans deletes all given beans, beans should contain delete conditions.
+func deleteBeans(e Engine, beans ...any) (err error) {
+	for i := range beans {
+		if _, err = e.Delete(beans[i]); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // DeleteOrganization completely and permanently deletes everything of organization.
-func DeleteOrganization(org *User) (err error) {
-	if err := DeleteUser(org); err != nil {
+func DeleteOrganization(org *User) error {
+	err := Users.DeleteByID(context.TODO(), org.ID, false)
+	if err != nil {
 		return err
 	}
 
@@ -223,11 +234,6 @@ func DeleteOrganization(org *User) (err error) {
 	); err != nil {
 		return fmt.Errorf("deleteBeans: %v", err)
 	}
-
-	if err = deleteUser(sess, org); err != nil {
-		return fmt.Errorf("deleteUser: %v", err)
-	}
-
 	return sess.Commit()
 }
 

+ 1 - 1
internal/db/org_users_test.go

@@ -47,7 +47,7 @@ func TestOrgUsers(t *testing.T) {
 func orgUsersCountByUser(t *testing.T, db *orgUsers) {
 	ctx := context.Background()
 
-	// TODO: Use OrgUsers.Join to replace SQL hack when the method is available.
+	// TODO: Use Orgs.Join to replace SQL hack when the method is available.
 	err := db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, 1, 1).Error
 	require.NoError(t, err)
 	err = db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, 2, 1).Error

+ 1 - 1
internal/db/orgs_test.go

@@ -66,7 +66,7 @@ func orgsList(t *testing.T, db *orgs) {
 	).Error
 	require.NoError(t, err)
 
-	// TODO: Use OrgUsers.Join to replace SQL hack when the method is available.
+	// TODO: Use Orgs.Join to replace SQL hack when the method is available.
 	err = db.Exec(`INSERT INTO org_user (uid, org_id, is_public) VALUES (?, ?, ?)`, alice.ID, org1.ID, false).Error
 	require.NoError(t, err)
 	err = db.Exec(`INSERT INTO org_user (uid, org_id, is_public) VALUES (?, ?, ?)`, alice.ID, org2.ID, true).Error

+ 103 - 0
internal/db/public_keys.go

@@ -0,0 +1,103 @@
+// Copyright 2023 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/pkg/errors"
+	"gorm.io/gorm"
+
+	"gogs.io/gogs/internal/conf"
+	"gogs.io/gogs/internal/osutil"
+)
+
+// PublicKeysStore is the persistent interface for public keys.
+//
+// NOTE: All methods are sorted in alphabetical order.
+type PublicKeysStore interface {
+	// RewriteAuthorizedKeys rewrites the "authorized_keys" file under the SSH root
+	// path with all public keys stored in the database.
+	RewriteAuthorizedKeys() error
+}
+
+var PublicKeys PublicKeysStore
+
+var _ PublicKeysStore = (*publicKeys)(nil)
+
+type publicKeys struct {
+	*gorm.DB
+}
+
+// NewPublicKeysStore returns a persistent interface for public keys with given
+// database connection.
+func NewPublicKeysStore(db *gorm.DB) PublicKeysStore {
+	return &publicKeys{DB: db}
+}
+
+func authorizedKeysPath() string {
+	return filepath.Join(conf.SSH.RootPath, "authorized_keys")
+}
+
+func (db *publicKeys) RewriteAuthorizedKeys() error {
+	sshOpLocker.Lock()
+	defer sshOpLocker.Unlock()
+
+	err := os.MkdirAll(conf.SSH.RootPath, os.ModePerm)
+	if err != nil {
+		return errors.Wrap(err, "create SSH root path")
+	}
+	fpath := authorizedKeysPath()
+	tempPath := fpath + ".tmp"
+	f, err := os.OpenFile(tempPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
+	if err != nil {
+		return errors.Wrap(err, "create temporary file")
+	}
+	defer func() {
+		_ = f.Close()
+		_ = os.Remove(tempPath)
+	}()
+
+	// NOTE: More recently updated keys are more likely to be used more frequently,
+	// putting them in the earlier lines could speed up the key lookup by SSHD.
+	rows, err := db.Model(&PublicKey{}).Order("updated_unix DESC").Rows()
+	if err != nil {
+		return errors.Wrap(err, "iterate public keys")
+	}
+	defer func() { _ = rows.Close() }()
+
+	for rows.Next() {
+		var key PublicKey
+		err = db.ScanRows(rows, &key)
+		if err != nil {
+			return errors.Wrap(err, "scan rows")
+		}
+
+		_, err = f.WriteString(key.AuthorizedString())
+		if err != nil {
+			return errors.Wrapf(err, "write key %d", key.ID)
+		}
+	}
+	if err = rows.Err(); err != nil {
+		return errors.Wrap(err, "check rows.Err")
+	}
+
+	err = f.Close()
+	if err != nil {
+		return errors.Wrap(err, "close temporary file")
+	}
+	if osutil.IsExist(fpath) {
+		err = os.Remove(fpath)
+		if err != nil {
+			return errors.Wrap(err, "remove")
+		}
+	}
+	err = os.Rename(tempPath, fpath)
+	if err != nil {
+		return errors.Wrap(err, "rename")
+	}
+	return nil
+}

+ 69 - 0
internal/db/public_keys_test.go

@@ -0,0 +1,69 @@
+// Copyright 2023 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package db
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"gogs.io/gogs/internal/conf"
+	"gogs.io/gogs/internal/dbtest"
+)
+
+func TestPublicKeys(t *testing.T) {
+	if testing.Short() {
+		t.Skip()
+	}
+	t.Parallel()
+
+	tables := []any{new(PublicKey)}
+	db := &publicKeys{
+		DB: dbtest.NewDB(t, "publicKeys", tables...),
+	}
+
+	for _, tc := range []struct {
+		name string
+		test func(t *testing.T, db *publicKeys)
+	}{
+		{"RewriteAuthorizedKeys", publicKeysRewriteAuthorizedKeys},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Cleanup(func() {
+				err := clearTables(t, db.DB, tables...)
+				require.NoError(t, err)
+			})
+			tc.test(t, db)
+		})
+		if t.Failed() {
+			break
+		}
+	}
+}
+
+func publicKeysRewriteAuthorizedKeys(t *testing.T, db *publicKeys) {
+	// TODO: Use PublicKeys.Add to replace SQL hack when the method is available.
+	publicKey := &PublicKey{
+		OwnerID:     1,
+		Name:        "test-key",
+		Fingerprint: "12:f8:7e:78:61:b4:bf:e2:de:24:15:96:4e:d4:72:53",
+		Content:     "test-key-content",
+	}
+	err := db.DB.Create(publicKey).Error
+	require.NoError(t, err)
+	tempSSHRootPath := filepath.Join(os.TempDir(), "publicKeysRewriteAuthorizedKeys-tempSSHRootPath")
+	conf.SetMockSSH(t, conf.SSHOpts{RootPath: tempSSHRootPath})
+	err = db.RewriteAuthorizedKeys()
+	require.NoError(t, err)
+
+	authorizedKeys, err := os.ReadFile(authorizedKeysPath())
+	require.NoError(t, err)
+	assert.Contains(t, string(authorizedKeys), fmt.Sprintf("key-%d", publicKey.ID))
+	assert.Contains(t, string(authorizedKeys), publicKey.Content)
+}

+ 8 - 13
internal/db/repo.go

@@ -1839,15 +1839,6 @@ func GetUserAndCollaborativeRepositories(userID int64) ([]*Repository, error) {
 	return append(repos, ownRepos...), nil
 }
 
-func getRepositoryCount(_ Engine, u *User) (int64, error) {
-	return x.Count(&Repository{OwnerID: u.ID})
-}
-
-// GetRepositoryCount returns the total number of repositories of user.
-func GetRepositoryCount(u *User) (int64, error) {
-	return getRepositoryCount(x, u)
-}
-
 type SearchRepoOptions struct {
 	Keyword  string
 	OwnerID  int64
@@ -2362,6 +2353,8 @@ func watchRepo(e Engine, userID, repoID int64, watch bool) (err error) {
 }
 
 // Watch or unwatch repository.
+//
+// Deprecated: Use Watches.Watch instead.
 func WatchRepo(userID, repoID int64, watch bool) (err error) {
 	return watchRepo(x, userID, repoID, watch)
 }
@@ -2441,18 +2434,20 @@ func NotifyWatchers(act *Action) error {
 //         \/           \/
 
 type Star struct {
-	ID     int64
-	UID    int64 `xorm:"UNIQUE(s)"`
-	RepoID int64 `xorm:"UNIQUE(s)"`
+	ID     int64 `gorm:"primaryKey"`
+	UserID int64 `xorm:"uid UNIQUE(s)" gorm:"column:uid;uniqueIndex:star_user_repo_unique;not null"`
+	RepoID int64 `xorm:"UNIQUE(s)" gorm:"uniqueIndex:star_user_repo_unique;not null"`
 }
 
 // Star or unstar repository.
+//
+// Deprecated: Use Stars.Star instead.
 func StarRepo(userID, repoID int64, star bool) (err error) {
 	if star {
 		if IsStaring(userID, repoID) {
 			return nil
 		}
-		if _, err = x.Insert(&Star{UID: userID, RepoID: repoID}); err != nil {
+		if _, err = x.Insert(&Star{UserID: userID, RepoID: repoID}); err != nil {
 			return err
 		} else if _, err = x.Exec("UPDATE `repository` SET num_stars = num_stars + 1 WHERE id = ?", repoID); err != nil {
 			return err

+ 4 - 4
internal/db/repo_collaboration.go

@@ -14,10 +14,10 @@ import (
 
 // Collaboration represent the relation between an individual and a repository.
 type Collaboration struct {
-	ID     int64
-	RepoID int64      `xorm:"UNIQUE(s) INDEX NOT NULL"`
-	UserID int64      `xorm:"UNIQUE(s) INDEX NOT NULL"`
-	Mode   AccessMode `xorm:"DEFAULT 2 NOT NULL"`
+	ID     int64      `gorm:"primary_key"`
+	UserID int64      `xorm:"UNIQUE(s) INDEX NOT NULL" gorm:"uniqueIndex:collaboration_user_repo_unique;index;not null"`
+	RepoID int64      `xorm:"UNIQUE(s) INDEX NOT NULL" gorm:"uniqueIndex:collaboration_user_repo_unique;index;not null"`
+	Mode   AccessMode `xorm:"DEFAULT 2 NOT NULL" gorm:"not null;default:2"`
 }
 
 func (c *Collaboration) ModeI18nKey() string {

+ 90 - 1
internal/db/repos.go

@@ -11,6 +11,7 @@ import (
 	"time"
 
 	api "github.com/gogs/go-gogs-client"
+	"github.com/pkg/errors"
 	"gorm.io/gorm"
 
 	"gogs.io/gogs/internal/errutil"
@@ -36,9 +37,14 @@ type ReposStore interface {
 	// Repositories that are owned directly by the given collaborator are not
 	// included.
 	GetByCollaboratorIDWithAccessMode(ctx context.Context, collaboratorID int64) (map[*Repository]AccessMode, error)
+	// GetByID returns the repository with given ID. It returns ErrRepoNotExist when
+	// not found.
+	GetByID(ctx context.Context, id int64) (*Repository, error)
 	// GetByName returns the repository with given owner and name. It returns
 	// ErrRepoNotExist when not found.
 	GetByName(ctx context.Context, ownerID int64, name string) (*Repository, error)
+	// Star marks the user to star the repository.
+	Star(ctx context.Context, userID, repoID int64) error
 	// Touch updates the updated time to the current time and removes the bare state
 	// of the given repository.
 	Touch(ctx context.Context, id int64) error
@@ -177,7 +183,18 @@ func (db *repos) Create(ctx context.Context, ownerID int64, opts CreateRepoOptio
 		IsFork:        opts.Fork,
 		ForkID:        opts.ForkID,
 	}
-	return repo, db.WithContext(ctx).Create(repo).Error
+	return repo, db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		err = tx.Create(repo).Error
+		if err != nil {
+			return errors.Wrap(err, "create")
+		}
+
+		err = NewWatchesStore(tx).Watch(ctx, ownerID, repo.ID)
+		if err != nil {
+			return errors.Wrap(err, "watch")
+		}
+		return nil
+	})
 }
 
 func (db *repos) GetByCollaboratorID(ctx context.Context, collaboratorID int64, limit int, orderBy string) ([]*Repository, error) {
@@ -252,6 +269,18 @@ func (ErrRepoNotExist) NotFound() bool {
 	return true
 }
 
+func (db *repos) GetByID(ctx context.Context, id int64) (*Repository, error) {
+	repo := new(Repository)
+	err := db.WithContext(ctx).Where("id = ?", id).First(repo).Error
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			return nil, ErrRepoNotExist{errutil.Args{"repoID": id}}
+		}
+		return nil, err
+	}
+	return repo, nil
+}
+
 func (db *repos) GetByName(ctx context.Context, ownerID int64, name string) (*Repository, error) {
 	repo := new(Repository)
 	err := db.WithContext(ctx).
@@ -272,6 +301,66 @@ func (db *repos) GetByName(ctx context.Context, ownerID int64, name string) (*Re
 	return repo, nil
 }
 
+func (db *repos) recountStars(tx *gorm.DB, userID, repoID int64) error {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		UPDATE repository
+		SET num_stars = (
+			SELECT COUNT(*) FROM star WHERE repo_id = @repoID
+		)
+		WHERE id = @repoID
+	*/
+	err := tx.Model(&Repository{}).
+		Where("id = ?", repoID).
+		Update(
+			"num_stars",
+			tx.Model(&Star{}).Select("COUNT(*)").Where("repo_id = ?", repoID),
+		).
+		Error
+	if err != nil {
+		return errors.Wrap(err, `update "repository.num_stars"`)
+	}
+
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		UPDATE "user"
+		SET num_stars = (
+			SELECT COUNT(*) FROM star WHERE uid = @userID
+		)
+		WHERE id = @userID
+	*/
+	err = tx.Model(&User{}).
+		Where("id = ?", userID).
+		Update(
+			"num_stars",
+			tx.Model(&Star{}).Select("COUNT(*)").Where("uid = ?", userID),
+		).
+		Error
+	if err != nil {
+		return errors.Wrap(err, `update "user.num_stars"`)
+	}
+	return nil
+}
+
+func (db *repos) Star(ctx context.Context, userID, repoID int64) error {
+	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		s := &Star{
+			UserID: userID,
+			RepoID: repoID,
+		}
+		result := tx.FirstOrCreate(s, s)
+		if result.Error != nil {
+			return errors.Wrap(result.Error, "upsert")
+		} else if result.RowsAffected <= 0 {
+			return nil // Relation already exists
+		}
+
+		return db.recountStars(tx, userID, repoID)
+	})
+}
+
 func (db *repos) Touch(ctx context.Context, id int64) error {
 	return db.WithContext(ctx).
 		Model(new(Repository)).

+ 40 - 1
internal/db/repos_test.go

@@ -85,7 +85,7 @@ func TestRepos(t *testing.T) {
 	}
 	t.Parallel()
 
-	tables := []any{new(Repository), new(Access)}
+	tables := []any{new(Repository), new(Access), new(Watch), new(User), new(EmailAddress), new(Star)}
 	db := &repos{
 		DB: dbtest.NewDB(t, "repos", tables...),
 	}
@@ -97,7 +97,9 @@ func TestRepos(t *testing.T) {
 		{"Create", reposCreate},
 		{"GetByCollaboratorID", reposGetByCollaboratorID},
 		{"GetByCollaboratorIDWithAccessMode", reposGetByCollaboratorIDWithAccessMode},
+		{"GetByID", reposGetByID},
 		{"GetByName", reposGetByName},
+		{"Star", reposStar},
 		{"Touch", reposTouch},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
@@ -154,6 +156,7 @@ func reposCreate(t *testing.T, db *repos) {
 	repo, err = db.GetByName(ctx, repo.OwnerID, repo.Name)
 	require.NoError(t, err)
 	assert.Equal(t, db.NowFunc().Format(time.RFC3339), repo.Created.UTC().Format(time.RFC3339))
+	assert.Equal(t, 1, repo.NumWatches) // The owner is watching the repo by default.
 }
 
 func reposGetByCollaboratorID(t *testing.T, db *repos) {
@@ -214,6 +217,21 @@ func reposGetByCollaboratorIDWithAccessMode(t *testing.T, db *repos) {
 	assert.Equal(t, AccessModeAdmin, accessModes[repo2.ID])
 }
 
+func reposGetByID(t *testing.T, db *repos) {
+	ctx := context.Background()
+
+	repo1, err := db.Create(ctx, 1, CreateRepoOptions{Name: "repo1"})
+	require.NoError(t, err)
+
+	got, err := db.GetByID(ctx, repo1.ID)
+	require.NoError(t, err)
+	assert.Equal(t, repo1.Name, got.Name)
+
+	_, err = db.GetByID(ctx, 404)
+	wantErr := ErrRepoNotExist{args: errutil.Args{"repoID": int64(404)}}
+	assert.Equal(t, wantErr, err)
+}
+
 func reposGetByName(t *testing.T, db *repos) {
 	ctx := context.Background()
 
@@ -232,6 +250,27 @@ func reposGetByName(t *testing.T, db *repos) {
 	assert.Equal(t, wantErr, err)
 }
 
+func reposStar(t *testing.T, db *repos) {
+	ctx := context.Background()
+
+	repo1, err := db.Create(ctx, 1, CreateRepoOptions{Name: "repo1"})
+	require.NoError(t, err)
+	usersStore := NewUsersStore(db.DB)
+	alice, err := usersStore.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	err = db.Star(ctx, alice.ID, repo1.ID)
+	require.NoError(t, err)
+
+	repo1, err = db.GetByID(ctx, repo1.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 1, repo1.NumStars)
+
+	alice, err = usersStore.GetByID(ctx, alice.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 1, alice.NumStars)
+}
+
 func reposTouch(t *testing.T, db *repos) {
 	ctx := context.Background()
 

+ 3 - 1
internal/db/ssh_key.go

@@ -517,6 +517,8 @@ func DeletePublicKey(doer *User, id int64) (err error) {
 // RewriteAuthorizedKeys removes any authorized key and rewrite all keys from database again.
 // Note: x.Iterate does not get latest data after insert/delete, so we have to call this function
 // outside any session scope independently.
+//
+// Deprecated: Use PublicKeys.RewriteAuthorizedKeys instead.
 func RewriteAuthorizedKeys() error {
 	sshOpLocker.Lock()
 	defer sshOpLocker.Unlock()
@@ -524,7 +526,7 @@ func RewriteAuthorizedKeys() error {
 	log.Trace("Doing: RewriteAuthorizedKeys")
 
 	_ = os.MkdirAll(conf.SSH.RootPath, os.ModePerm)
-	fpath := filepath.Join(conf.SSH.RootPath, "authorized_keys")
+	fpath := authorizedKeysPath()
 	tmpPath := fpath + ".tmp"
 	f, err := os.OpenFile(tmpPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
 	if err != nil {

+ 0 - 197
internal/db/user.go

@@ -1,197 +0,0 @@
-// Copyright 2014 The Gogs Authors. All rights reserved.
-// Use of this source code is governed by a MIT-style
-// license that can be found in the LICENSE file.
-
-package db
-
-import (
-	"fmt"
-	_ "image/jpeg"
-	"os"
-	"time"
-
-	"xorm.io/xorm"
-
-	"gogs.io/gogs/internal/repoutil"
-	"gogs.io/gogs/internal/userutil"
-)
-
-// TODO(unknwon): Delete me once refactoring is done.
-func (u *User) BeforeInsert() {
-	u.CreatedUnix = time.Now().Unix()
-	u.UpdatedUnix = u.CreatedUnix
-}
-
-// TODO(unknwon): Delete me once refactoring is done.
-func (u *User) AfterSet(colName string, _ xorm.Cell) {
-	switch colName {
-	case "created_unix":
-		u.Created = time.Unix(u.CreatedUnix, 0).Local()
-	case "updated_unix":
-		u.Updated = time.Unix(u.UpdatedUnix, 0).Local()
-	}
-}
-
-// deleteBeans deletes all given beans, beans should contain delete conditions.
-func deleteBeans(e Engine, beans ...any) (err error) {
-	for i := range beans {
-		if _, err = e.Delete(beans[i]); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// FIXME: need some kind of mechanism to record failure. HINT: system notice
-func deleteUser(e *xorm.Session, u *User) error {
-	// Note: A user owns any repository or belongs to any organization
-	//	cannot perform delete operation.
-
-	// Check ownership of repository.
-	count, err := getRepositoryCount(e, u)
-	if err != nil {
-		return fmt.Errorf("GetRepositoryCount: %v", err)
-	} else if count > 0 {
-		return ErrUserOwnRepos{UID: u.ID}
-	}
-
-	// Check membership of organization.
-	count, err = u.getOrganizationCount(e)
-	if err != nil {
-		return fmt.Errorf("GetOrganizationCount: %v", err)
-	} else if count > 0 {
-		return ErrUserHasOrgs{UID: u.ID}
-	}
-
-	// ***** START: Watch *****
-	watches := make([]*Watch, 0, 10)
-	if err = e.Find(&watches, &Watch{UserID: u.ID}); err != nil {
-		return fmt.Errorf("get all watches: %v", err)
-	}
-	for i := range watches {
-		if _, err = e.Exec("UPDATE `repository` SET num_watches=num_watches-1 WHERE id=?", watches[i].RepoID); err != nil {
-			return fmt.Errorf("decrease repository watch number[%d]: %v", watches[i].RepoID, err)
-		}
-	}
-	// ***** END: Watch *****
-
-	// ***** START: Star *****
-	stars := make([]*Star, 0, 10)
-	if err = e.Find(&stars, &Star{UID: u.ID}); err != nil {
-		return fmt.Errorf("get all stars: %v", err)
-	}
-	for i := range stars {
-		if _, err = e.Exec("UPDATE `repository` SET num_stars=num_stars-1 WHERE id=?", stars[i].RepoID); err != nil {
-			return fmt.Errorf("decrease repository star number[%d]: %v", stars[i].RepoID, err)
-		}
-	}
-	// ***** END: Star *****
-
-	// ***** START: Follow *****
-	followers := make([]*Follow, 0, 10)
-	if err = e.Find(&followers, &Follow{UserID: u.ID}); err != nil {
-		return fmt.Errorf("get all followers: %v", err)
-	}
-	for i := range followers {
-		if _, err = e.Exec("UPDATE `user` SET num_followers=num_followers-1 WHERE id=?", followers[i].UserID); err != nil {
-			return fmt.Errorf("decrease user follower number[%d]: %v", followers[i].UserID, err)
-		}
-	}
-	// ***** END: Follow *****
-
-	if err = deleteBeans(e,
-		&AccessToken{UserID: u.ID},
-		&Collaboration{UserID: u.ID},
-		&Access{UserID: u.ID},
-		&Watch{UserID: u.ID},
-		&Star{UID: u.ID},
-		&Follow{FollowID: u.ID},
-		&Action{UserID: u.ID},
-		&IssueUser{UID: u.ID},
-		&EmailAddress{UserID: u.ID},
-	); err != nil {
-		return fmt.Errorf("deleteBeans: %v", err)
-	}
-
-	// ***** START: PublicKey *****
-	keys := make([]*PublicKey, 0, 10)
-	if err = e.Find(&keys, &PublicKey{OwnerID: u.ID}); err != nil {
-		return fmt.Errorf("get all public keys: %v", err)
-	}
-
-	keyIDs := make([]int64, len(keys))
-	for i := range keys {
-		keyIDs[i] = keys[i].ID
-	}
-	if err = deletePublicKeys(e, keyIDs...); err != nil {
-		return fmt.Errorf("deletePublicKeys: %v", err)
-	}
-	// ***** END: PublicKey *****
-
-	// Clear assignee.
-	if _, err = e.Exec("UPDATE `issue` SET assignee_id=0 WHERE assignee_id=?", u.ID); err != nil {
-		return fmt.Errorf("clear assignee: %v", err)
-	}
-
-	if _, err = e.ID(u.ID).Delete(new(User)); err != nil {
-		return fmt.Errorf("Delete: %v", err)
-	}
-
-	// FIXME: system notice
-	// Note: There are something just cannot be roll back,
-	//	so just keep error logs of those operations.
-
-	_ = os.RemoveAll(repoutil.UserPath(u.Name))
-	_ = os.Remove(userutil.CustomAvatarPath(u.ID))
-
-	return nil
-}
-
-// Deprecated: Use OrgsUsers.CountByUser instead.
-//
-// TODO(unknwon): Delete me once no more call sites in this file.
-func (u *User) getOrganizationCount(e Engine) (int64, error) {
-	return e.Where("uid=?", u.ID).Count(new(OrgUser))
-}
-
-// DeleteUser completely and permanently deletes everything of a user,
-// but issues/comments/pulls will be kept and shown as someone has been deleted.
-func DeleteUser(u *User) (err error) {
-	sess := x.NewSession()
-	defer sess.Close()
-	if err = sess.Begin(); err != nil {
-		return err
-	}
-
-	if err = deleteUser(sess, u); err != nil {
-		// Note: don't wrapper error here.
-		return err
-	}
-
-	if err = sess.Commit(); err != nil {
-		return err
-	}
-
-	return RewriteAuthorizedKeys()
-}
-
-// DeleteInactivateUsers deletes all inactivate users and email addresses.
-func DeleteInactivateUsers() (err error) {
-	users := make([]*User, 0, 10)
-	if err = x.Where("is_active = ?", false).Find(&users); err != nil {
-		return fmt.Errorf("get all inactive users: %v", err)
-	}
-	// FIXME: should only update authorized_keys file once after all deletions.
-	for _, u := range users {
-		if err = DeleteUser(u); err != nil {
-			// Ignore users that were set inactive by admin.
-			if IsErrUserOwnRepos(err) || IsErrUserHasOrgs(err) {
-				continue
-			}
-			return err
-		}
-	}
-
-	_, err = x.Where("is_activated = ?", false).Delete(new(EmailAddress))
-	return err
-}

+ 227 - 0
internal/db/users.go

@@ -6,6 +6,7 @@ package db
 
 import (
 	"context"
+	"database/sql"
 	"fmt"
 	"os"
 	"strings"
@@ -61,6 +62,14 @@ type UsersStore interface {
 	// DeleteCustomAvatar deletes the current user custom avatar and falls back to
 	// use look up avatar by email.
 	DeleteCustomAvatar(ctx context.Context, userID int64) error
+	// DeleteByID deletes the given user and all their resources. It returns
+	// ErrUserOwnRepos when the user still has repository ownership, or returns
+	// ErrUserHasOrgs when the user still has organization membership. It is more
+	// performant to skip rewriting the "authorized_keys" file for individual
+	// deletion in a batch operation.
+	DeleteByID(ctx context.Context, userID int64, skipRewriteAuthorizedKeys bool) error
+	// DeleteInactivated deletes all inactivated users.
+	DeleteInactivated() 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)
@@ -423,6 +432,224 @@ func (db *users) DeleteCustomAvatar(ctx context.Context, userID int64) error {
 		Error
 }
 
+type ErrUserOwnRepos struct {
+	args errutil.Args
+}
+
+// IsErrUserOwnRepos returns true if the underlying error has the type
+// ErrUserOwnRepos.
+func IsErrUserOwnRepos(err error) bool {
+	_, ok := errors.Cause(err).(ErrUserOwnRepos)
+	return ok
+}
+
+func (err ErrUserOwnRepos) Error() string {
+	return fmt.Sprintf("user still has repository ownership: %v", err.args)
+}
+
+type ErrUserHasOrgs struct {
+	args errutil.Args
+}
+
+// IsErrUserHasOrgs returns true if the underlying error has the type
+// ErrUserHasOrgs.
+func IsErrUserHasOrgs(err error) bool {
+	_, ok := errors.Cause(err).(ErrUserHasOrgs)
+	return ok
+}
+
+func (err ErrUserHasOrgs) Error() string {
+	return fmt.Sprintf("user still has organization membership: %v", err.args)
+}
+
+func (db *users) DeleteByID(ctx context.Context, userID int64, skipRewriteAuthorizedKeys bool) error {
+	user, err := db.GetByID(ctx, userID)
+	if err != nil {
+		if IsErrUserNotExist(err) {
+			return nil
+		}
+		return errors.Wrap(err, "get user")
+	}
+
+	// Double check the user is not a direct owner of any repository and not a
+	// member of any organization.
+	var count int64
+	err = db.WithContext(ctx).Model(&Repository{}).Where("owner_id = ?", userID).Count(&count).Error
+	if err != nil {
+		return errors.Wrap(err, "count repositories")
+	} else if count > 0 {
+		return ErrUserOwnRepos{args: errutil.Args{"userID": userID}}
+	}
+
+	err = db.WithContext(ctx).Model(&OrgUser{}).Where("uid = ?", userID).Count(&count).Error
+	if err != nil {
+		return errors.Wrap(err, "count organization membership")
+	} else if count > 0 {
+		return ErrUserHasOrgs{args: errutil.Args{"userID": userID}}
+	}
+
+	needsRewriteAuthorizedKeys := false
+	err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		/*
+			Equivalent SQL for PostgreSQL:
+
+			UPDATE repository
+			SET num_watches = num_watches - 1
+			WHERE id IN (
+				SELECT repo_id FROM watch WHERE user_id = @userID
+			)
+		*/
+		err = tx.Table("repository").
+			Where("id IN (?)", tx.
+				Select("repo_id").
+				Table("watch").
+				Where("user_id = ?", userID),
+			).
+			UpdateColumn("num_watches", gorm.Expr("num_watches - 1")).
+			Error
+		if err != nil {
+			return errors.Wrap(err, `decrease "repository.num_watches"`)
+		}
+
+		/*
+			Equivalent SQL for PostgreSQL:
+
+			UPDATE repository
+			SET num_stars = num_stars - 1
+			WHERE id IN (
+				SELECT repo_id FROM star WHERE uid = @userID
+			)
+		*/
+		err = tx.Table("repository").
+			Where("id IN (?)", tx.
+				Select("repo_id").
+				Table("star").
+				Where("uid = ?", userID),
+			).
+			UpdateColumn("num_stars", gorm.Expr("num_stars - 1")).
+			Error
+		if err != nil {
+			return errors.Wrap(err, `decrease "repository.num_stars"`)
+		}
+
+		/*
+			Equivalent SQL for PostgreSQL:
+
+			UPDATE user
+			SET num_followers = num_followers - 1
+			WHERE id IN (
+				SELECT follow_id FROM follow WHERE user_id = @userID
+			)
+		*/
+		err = tx.Table("user").
+			Where("id IN (?)", tx.
+				Select("follow_id").
+				Table("follow").
+				Where("user_id = ?", userID),
+			).
+			UpdateColumn("num_followers", gorm.Expr("num_followers - 1")).
+			Error
+		if err != nil {
+			return errors.Wrap(err, `decrease "user.num_followers"`)
+		}
+
+		/*
+			Equivalent SQL for PostgreSQL:
+
+			UPDATE user
+			SET num_following = num_following - 1
+			WHERE id IN (
+				SELECT user_id FROM follow WHERE follow_id = @userID
+			)
+		*/
+		err = tx.Table("user").
+			Where("id IN (?)", tx.
+				Select("user_id").
+				Table("follow").
+				Where("follow_id = ?", userID),
+			).
+			UpdateColumn("num_following", gorm.Expr("num_following - 1")).
+			Error
+		if err != nil {
+			return errors.Wrap(err, `decrease "user.num_following"`)
+		}
+
+		if !skipRewriteAuthorizedKeys {
+			// We need to rewrite "authorized_keys" file if the user owns any public keys.
+			needsRewriteAuthorizedKeys = tx.Where("owner_id = ?", userID).First(&PublicKey{}).Error != gorm.ErrRecordNotFound
+		}
+
+		err = tx.Model(&Issue{}).Where("assignee_id = ?", userID).Update("assignee_id", 0).Error
+		if err != nil {
+			return errors.Wrap(err, "clear assignees")
+		}
+
+		for _, t := range []struct {
+			table any
+			where string
+		}{
+			{&Watch{}, "user_id = @userID"},
+			{&Star{}, "uid = @userID"},
+			{&Follow{}, "user_id = @userID OR follow_id = @userID"},
+			{&PublicKey{}, "owner_id = @userID"},
+
+			{&AccessToken{}, "uid = @userID"},
+			{&Collaboration{}, "user_id = @userID"},
+			{&Access{}, "user_id = @userID"},
+			{&Action{}, "user_id = @userID"},
+			{&IssueUser{}, "uid = @userID"},
+			{&EmailAddress{}, "uid = @userID"},
+			{&User{}, "id = @userID"},
+		} {
+			err = tx.Where(t.where, sql.Named("userID", userID)).Delete(t.table).Error
+			if err != nil {
+				return errors.Wrapf(err, "clean up table %T", t.table)
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	_ = os.RemoveAll(repoutil.UserPath(user.Name))
+	_ = os.Remove(userutil.CustomAvatarPath(userID))
+
+	if needsRewriteAuthorizedKeys {
+		err = NewPublicKeysStore(db.DB).RewriteAuthorizedKeys()
+		if err != nil {
+			return errors.Wrap(err, `rewrite "authorized_keys" file`)
+		}
+	}
+	return nil
+}
+
+// NOTE: We do not take context.Context here because this operation in practice
+// could much longer than the general request timeout (e.g. one minute).
+func (db *users) DeleteInactivated() error {
+	var userIDs []int64
+	err := db.Model(&User{}).Where("is_active = ?", false).Pluck("id", &userIDs).Error
+	if err != nil {
+		return errors.Wrap(err, "get inactivated user IDs")
+	}
+
+	for _, userID := range userIDs {
+		err = db.DeleteByID(context.Background(), userID, true)
+		if err != nil {
+			// Skip users that may had set to inactivated by admins.
+			if IsErrUserOwnRepos(err) || IsErrUserHasOrgs(err) {
+				continue
+			}
+			return errors.Wrapf(err, "delete user with ID %d", userID)
+		}
+	}
+	err = NewPublicKeysStore(db.DB).RewriteAuthorizedKeys()
+	if err != nil {
+		return errors.Wrap(err, `rewrite "authorized_keys" file`)
+	}
+	return nil
+}
+
 var _ errutil.NotFound = (*ErrUserNotExist)(nil)
 
 type ErrUserNotExist struct {

+ 267 - 1
internal/db/users_test.go

@@ -82,7 +82,11 @@ func TestUsers(t *testing.T) {
 	}
 	t.Parallel()
 
-	tables := []any{new(User), new(EmailAddress), new(Repository), new(Follow), new(PullRequest), new(PublicKey)}
+	tables := []any{
+		new(User), new(EmailAddress), new(Repository), new(Follow), new(PullRequest), new(PublicKey), new(OrgUser),
+		new(Watch), new(Star), new(Issue), new(AccessToken), new(Collaboration), new(Action), new(IssueUser),
+		new(Access),
+	}
 	db := &users{
 		DB: dbtest.NewDB(t, "users", tables...),
 	}
@@ -96,6 +100,8 @@ func TestUsers(t *testing.T) {
 		{"Count", usersCount},
 		{"Create", usersCreate},
 		{"DeleteCustomAvatar", usersDeleteCustomAvatar},
+		{"DeleteByID", usersDeleteByID},
+		{"DeleteInactivated", usersDeleteInactivated},
 		{"GetByEmail", usersGetByEmail},
 		{"GetByID", usersGetByID},
 		{"GetByUsername", usersGetByUsername},
@@ -463,6 +469,266 @@ func usersDeleteCustomAvatar(t *testing.T, db *users) {
 	assert.False(t, alice.UseCustomAvatar)
 }
 
+func usersDeleteByID(t *testing.T, db *users) {
+	ctx := context.Background()
+	reposStore := NewReposStore(db.DB)
+
+	t.Run("user still has repository ownership", func(t *testing.T) {
+		alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+		require.NoError(t, err)
+
+		_, err = reposStore.Create(ctx, alice.ID, CreateRepoOptions{Name: "repo1"})
+		require.NoError(t, err)
+
+		err = db.DeleteByID(ctx, alice.ID, false)
+		wantErr := ErrUserOwnRepos{errutil.Args{"userID": alice.ID}}
+		assert.Equal(t, wantErr, err)
+	})
+
+	t.Run("user still has organization membership", func(t *testing.T) {
+		bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+		require.NoError(t, err)
+
+		// TODO: Use Orgs.Create to replace SQL hack when the method is available.
+		org1, err := db.Create(ctx, "org1", "[email protected]", CreateUserOptions{})
+		require.NoError(t, err)
+		err = db.Exec(
+			dbutil.Quote("UPDATE %s SET type = ? WHERE id IN (?)", "user"),
+			UserTypeOrganization, org1.ID,
+		).Error
+		require.NoError(t, err)
+
+		// TODO: Use Orgs.Join to replace SQL hack when the method is available.
+		err = db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, bob.ID, org1.ID).Error
+		require.NoError(t, err)
+
+		err = db.DeleteByID(ctx, bob.ID, false)
+		wantErr := ErrUserHasOrgs{errutil.Args{"userID": bob.ID}}
+		assert.Equal(t, wantErr, err)
+	})
+
+	cindy, err := db.Create(ctx, "cindy", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	frank, err := db.Create(ctx, "frank", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	repo2, err := reposStore.Create(ctx, cindy.ID, CreateRepoOptions{Name: "repo2"})
+	require.NoError(t, err)
+
+	testUser, err := db.Create(ctx, "testUser", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	// Mock watches, stars and follows
+	err = NewWatchesStore(db.DB).Watch(ctx, testUser.ID, repo2.ID)
+	require.NoError(t, err)
+	err = reposStore.Star(ctx, testUser.ID, repo2.ID)
+	require.NoError(t, err)
+	followsStore := NewFollowsStore(db.DB)
+	err = followsStore.Follow(ctx, testUser.ID, cindy.ID)
+	require.NoError(t, err)
+	err = followsStore.Follow(ctx, frank.ID, testUser.ID)
+	require.NoError(t, err)
+
+	// Mock "authorized_keys" file
+	// TODO: Use PublicKeys.Add to replace SQL hack when the method is available.
+	publicKey := &PublicKey{
+		OwnerID:     testUser.ID,
+		Name:        "test-key",
+		Fingerprint: "12:f8:7e:78:61:b4:bf:e2:de:24:15:96:4e:d4:72:53",
+		Content:     "test-key-content",
+	}
+	err = db.DB.Create(publicKey).Error
+	require.NoError(t, err)
+	tempSSHRootPath := filepath.Join(os.TempDir(), "usersDeleteByID-tempSSHRootPath")
+	conf.SetMockSSH(t, conf.SSHOpts{RootPath: tempSSHRootPath})
+	err = NewPublicKeysStore(db.DB).RewriteAuthorizedKeys()
+	require.NoError(t, err)
+
+	// Mock issue assignee
+	// TODO: Use Issues.Assign to replace SQL hack when the method is available.
+	issue := &Issue{
+		RepoID:     repo2.ID,
+		Index:      1,
+		PosterID:   cindy.ID,
+		Title:      "test-issue",
+		AssigneeID: testUser.ID,
+	}
+	err = db.DB.Create(issue).Error
+	require.NoError(t, err)
+
+	// Mock random entries in related tables
+	for _, table := range []any{
+		&AccessToken{UserID: testUser.ID},
+		&Collaboration{UserID: testUser.ID},
+		&Access{UserID: testUser.ID},
+		&Action{UserID: testUser.ID},
+		&IssueUser{UserID: testUser.ID},
+		&EmailAddress{UserID: testUser.ID},
+	} {
+		err = db.DB.Create(table).Error
+		require.NoError(t, err, "table for %T", table)
+	}
+
+	// Mock user directory
+	tempRepositoryRoot := filepath.Join(os.TempDir(), "usersDeleteByID-tempRepositoryRoot")
+	conf.SetMockRepository(t, conf.RepositoryOpts{Root: tempRepositoryRoot})
+	tempUserPath := repoutil.UserPath(testUser.Name)
+	err = os.MkdirAll(tempUserPath, os.ModePerm)
+	require.NoError(t, err)
+
+	// Mock user custom avatar
+	tempPictureAvatarUploadPath := filepath.Join(os.TempDir(), "usersDeleteByID-tempPictureAvatarUploadPath")
+	conf.SetMockPicture(t, conf.PictureOpts{AvatarUploadPath: tempPictureAvatarUploadPath})
+	err = os.MkdirAll(tempPictureAvatarUploadPath, os.ModePerm)
+	require.NoError(t, err)
+	tempCustomAvatarPath := userutil.CustomAvatarPath(testUser.ID)
+	err = os.WriteFile(tempCustomAvatarPath, []byte("test"), 0600)
+	require.NoError(t, err)
+
+	// Verify mock data
+	repo2, err = reposStore.GetByID(ctx, repo2.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 2, repo2.NumWatches) // The owner is watching the repo by default.
+	assert.Equal(t, 1, repo2.NumStars)
+
+	cindy, err = db.GetByID(ctx, cindy.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 1, cindy.NumFollowers)
+	frank, err = db.GetByID(ctx, frank.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 1, frank.NumFollowing)
+
+	authorizedKeys, err := os.ReadFile(authorizedKeysPath())
+	require.NoError(t, err)
+	assert.Contains(t, string(authorizedKeys), fmt.Sprintf("key-%d", publicKey.ID))
+	assert.Contains(t, string(authorizedKeys), publicKey.Content)
+
+	// TODO: Use Issues.GetByID to replace SQL hack when the method is available.
+	err = db.DB.First(issue, issue.ID).Error
+	require.NoError(t, err)
+	assert.Equal(t, testUser.ID, issue.AssigneeID)
+
+	relatedTables := []any{
+		&Watch{UserID: testUser.ID},
+		&Star{UserID: testUser.ID},
+		&Follow{UserID: testUser.ID},
+		&PublicKey{OwnerID: testUser.ID},
+		&AccessToken{UserID: testUser.ID},
+		&Collaboration{UserID: testUser.ID},
+		&Access{UserID: testUser.ID},
+		&Action{UserID: testUser.ID},
+		&IssueUser{UserID: testUser.ID},
+		&EmailAddress{UserID: testUser.ID},
+	}
+	for _, table := range relatedTables {
+		var count int64
+		err = db.DB.Model(table).Where(table).Count(&count).Error
+		require.NoError(t, err, "table for %T", table)
+		assert.NotZero(t, count, "table for %T", table)
+	}
+
+	assert.True(t, osutil.IsExist(tempUserPath))
+	assert.True(t, osutil.IsExist(tempCustomAvatarPath))
+
+	// Pull the trigger
+	err = db.DeleteByID(ctx, testUser.ID, false)
+	require.NoError(t, err)
+
+	// Verify after-the-fact data
+	repo2, err = reposStore.GetByID(ctx, repo2.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 1, repo2.NumWatches) // The owner is watching the repo by default.
+	assert.Equal(t, 0, repo2.NumStars)
+
+	cindy, err = db.GetByID(ctx, cindy.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 0, cindy.NumFollowers)
+	frank, err = db.GetByID(ctx, frank.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 0, frank.NumFollowing)
+
+	authorizedKeys, err = os.ReadFile(authorizedKeysPath())
+	require.NoError(t, err)
+	assert.Empty(t, authorizedKeys)
+
+	// TODO: Use Issues.GetByID to replace SQL hack when the method is available.
+	err = db.DB.First(issue, issue.ID).Error
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), issue.AssigneeID)
+
+	for _, table := range []any{
+		&Watch{UserID: testUser.ID},
+		&Star{UserID: testUser.ID},
+		&Follow{UserID: testUser.ID},
+		&PublicKey{OwnerID: testUser.ID},
+		&AccessToken{UserID: testUser.ID},
+		&Collaboration{UserID: testUser.ID},
+		&Access{UserID: testUser.ID},
+		&Action{UserID: testUser.ID},
+		&IssueUser{UserID: testUser.ID},
+		&EmailAddress{UserID: testUser.ID},
+	} {
+		var count int64
+		err = db.DB.Model(table).Where(table).Count(&count).Error
+		require.NoError(t, err, "table for %T", table)
+		assert.Equal(t, int64(0), count, "table for %T", table)
+	}
+
+	assert.False(t, osutil.IsExist(tempUserPath))
+	assert.False(t, osutil.IsExist(tempCustomAvatarPath))
+
+	_, err = db.GetByID(ctx, testUser.ID)
+	wantErr := ErrUserNotExist{errutil.Args{"userID": testUser.ID}}
+	assert.Equal(t, wantErr, err)
+}
+
+func usersDeleteInactivated(t *testing.T, db *users) {
+	ctx := context.Background()
+
+	// User with repository ownership should be skipped
+	alice, err := db.Create(ctx, "alice", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	reposStore := NewReposStore(db.DB)
+	_, err = reposStore.Create(ctx, alice.ID, CreateRepoOptions{Name: "repo1"})
+	require.NoError(t, err)
+
+	// User with organization membership should be skipped
+	bob, err := db.Create(ctx, "bob", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	// TODO: Use Orgs.Create to replace SQL hack when the method is available.
+	org1, err := db.Create(ctx, "org1", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+	err = db.Exec(
+		dbutil.Quote("UPDATE %s SET type = ? WHERE id IN (?)", "user"),
+		UserTypeOrganization, org1.ID,
+	).Error
+	require.NoError(t, err)
+	// TODO: Use Orgs.Join to replace SQL hack when the method is available.
+	err = db.Exec(`INSERT INTO org_user (uid, org_id) VALUES (?, ?)`, bob.ID, org1.ID).Error
+	require.NoError(t, err)
+
+	// User activated state should be skipped
+	_, err = db.Create(ctx, "cindy", "[email protected]", CreateUserOptions{Activated: true})
+	require.NoError(t, err)
+
+	// User meant to be deleted
+	david, err := db.Create(ctx, "david", "[email protected]", CreateUserOptions{})
+	require.NoError(t, err)
+
+	tempSSHRootPath := filepath.Join(os.TempDir(), "usersDeleteInactivated-tempSSHRootPath")
+	conf.SetMockSSH(t, conf.SSHOpts{RootPath: tempSSHRootPath})
+
+	err = db.DeleteInactivated()
+	require.NoError(t, err)
+
+	_, err = db.GetByID(ctx, david.ID)
+	wantErr := ErrUserNotExist{errutil.Args{"userID": david.ID}}
+	assert.Equal(t, wantErr, err)
+
+	users, err := db.List(ctx, 1, 10)
+	require.NoError(t, err)
+	require.Len(t, users, 3)
+}
+
 func usersGetByEmail(t *testing.T, db *users) {
 	ctx := context.Background()
 

+ 39 - 0
internal/db/watches.go

@@ -7,6 +7,7 @@ package db
 import (
 	"context"
 
+	"github.com/pkg/errors"
 	"gorm.io/gorm"
 )
 
@@ -16,6 +17,8 @@ import (
 type WatchesStore interface {
 	// ListByRepo returns all watches of the given repository.
 	ListByRepo(ctx context.Context, repoID int64) ([]*Watch, error)
+	// Watch marks the user to watch the repository.
+	Watch(ctx context.Context, userID, repoID int64) error
 }
 
 var Watches WatchesStore
@@ -36,3 +39,39 @@ func (db *watches) ListByRepo(ctx context.Context, repoID int64) ([]*Watch, erro
 	var watches []*Watch
 	return watches, db.WithContext(ctx).Where("repo_id = ?", repoID).Find(&watches).Error
 }
+
+func (db *watches) updateWatchingCount(tx *gorm.DB, repoID int64) error {
+	/*
+		Equivalent SQL for PostgreSQL:
+
+		UPDATE repository
+		SET num_watches = (
+			SELECT COUNT(*) FROM watch WHERE repo_id = @repoID
+		)
+		WHERE id = @repoID
+	*/
+	return tx.Model(&Repository{}).
+		Where("id = ?", repoID).
+		Update(
+			"num_watches",
+			tx.Model(&Watch{}).Select("COUNT(*)").Where("repo_id = ?", repoID),
+		).
+		Error
+}
+
+func (db *watches) Watch(ctx context.Context, userID, repoID int64) error {
+	return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+		w := &Watch{
+			UserID: userID,
+			RepoID: repoID,
+		}
+		result := tx.FirstOrCreate(w, w)
+		if result.Error != nil {
+			return errors.Wrap(result.Error, "upsert")
+		} else if result.RowsAffected <= 0 {
+			return nil // Relation already exists
+		}
+
+		return db.updateWatchingCount(tx, repoID)
+	})
+}

+ 44 - 3
internal/db/watches_test.go

@@ -5,8 +5,10 @@
 package db
 
 import (
+	"context"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
 	"gogs.io/gogs/internal/dbtest"
@@ -18,7 +20,7 @@ func TestWatches(t *testing.T) {
 	}
 	t.Parallel()
 
-	tables := []any{new(Watch)}
+	tables := []any{new(Watch), new(Repository)}
 	db := &watches{
 		DB: dbtest.NewDB(t, "watches", tables...),
 	}
@@ -28,6 +30,7 @@ func TestWatches(t *testing.T) {
 		test func(t *testing.T, db *watches)
 	}{
 		{"ListByRepo", watchesListByRepo},
+		{"Watch", watchesWatch},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			t.Cleanup(func() {
@@ -42,6 +45,44 @@ func TestWatches(t *testing.T) {
 	}
 }
 
-func watchesListByRepo(_ *testing.T, _ *watches) {
-	// TODO: Add tests once WatchRepo is migrated to GORM.
+func watchesListByRepo(t *testing.T, db *watches) {
+	ctx := context.Background()
+
+	err := db.Watch(ctx, 1, 1)
+	require.NoError(t, err)
+	err = db.Watch(ctx, 2, 1)
+	require.NoError(t, err)
+	err = db.Watch(ctx, 2, 2)
+	require.NoError(t, err)
+
+	got, err := db.ListByRepo(ctx, 1)
+	require.NoError(t, err)
+	for _, w := range got {
+		w.ID = 0
+	}
+
+	want := []*Watch{
+		{UserID: 1, RepoID: 1},
+		{UserID: 2, RepoID: 1},
+	}
+	assert.Equal(t, want, got)
+}
+
+func watchesWatch(t *testing.T, db *watches) {
+	ctx := context.Background()
+
+	reposStore := NewReposStore(db.DB)
+	repo1, err := reposStore.Create(ctx, 1, CreateRepoOptions{Name: "repo1"})
+	require.NoError(t, err)
+
+	err = db.Watch(ctx, 2, repo1.ID)
+	require.NoError(t, err)
+
+	// It is OK to watch multiple times and just be noop.
+	err = db.Watch(ctx, 2, repo1.ID)
+	require.NoError(t, err)
+
+	repo1, err = reposStore.GetByID(ctx, repo1.ID)
+	require.NoError(t, err)
+	assert.Equal(t, 2, repo1.NumWatches) // The owner is watching the repo by default.
 }

+ 1 - 1
internal/route/admin/admin.go

@@ -145,7 +145,7 @@ func Operation(c *context.Context) {
 	switch AdminOperation(c.QueryInt("op")) {
 	case CleanInactivateUser:
 		success = c.Tr("admin.dashboard.delete_inactivate_accounts_success")
-		err = db.DeleteInactivateUsers()
+		err = db.Users.DeleteInactivated()
 	case CleanRepoArchives:
 		success = c.Tr("admin.dashboard.delete_repo_archives_success")
 		err = db.DeleteRepositoryArchives()

+ 1 - 1
internal/route/admin/users.go

@@ -226,7 +226,7 @@ func DeleteUser(c *context.Context) {
 		return
 	}
 
-	if err = db.DeleteUser(u); err != nil {
+	if err = db.Users.DeleteByID(c.Req.Context(), u.ID, false); err != nil {
 		switch {
 		case db.IsErrUserOwnRepos(err):
 			c.Flash.Error(c.Tr("admin.users.still_own_repo"))

+ 1 - 1
internal/route/api/v1/admin/user.go

@@ -129,7 +129,7 @@ func DeleteUser(c *context.APIContext) {
 		return
 	}
 
-	if err := db.DeleteUser(u); err != nil {
+	if err := db.Users.DeleteByID(c.Req.Context(), u.ID, false); err != nil {
 		if db.IsErrUserOwnRepos(err) ||
 			db.IsErrUserHasOrgs(err) {
 			c.ErrorStatus(http.StatusUnprocessableEntity, err)

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

@@ -1502,9 +1502,15 @@ type MockReposStore struct {
 	// function object controlling the behavior of the method
 	// GetByCollaboratorIDWithAccessMode.
 	GetByCollaboratorIDWithAccessModeFunc *ReposStoreGetByCollaboratorIDWithAccessModeFunc
+	// GetByIDFunc is an instance of a mock function object controlling the
+	// behavior of the method GetByID.
+	GetByIDFunc *ReposStoreGetByIDFunc
 	// GetByNameFunc is an instance of a mock function object controlling
 	// the behavior of the method GetByName.
 	GetByNameFunc *ReposStoreGetByNameFunc
+	// StarFunc is an instance of a mock function object controlling the
+	// behavior of the method Star.
+	StarFunc *ReposStoreStarFunc
 	// TouchFunc is an instance of a mock function object controlling the
 	// behavior of the method Touch.
 	TouchFunc *ReposStoreTouchFunc
@@ -1529,11 +1535,21 @@ func NewMockReposStore() *MockReposStore {
 				return
 			},
 		},
+		GetByIDFunc: &ReposStoreGetByIDFunc{
+			defaultHook: func(context.Context, int64) (r0 *db.Repository, r1 error) {
+				return
+			},
+		},
 		GetByNameFunc: &ReposStoreGetByNameFunc{
 			defaultHook: func(context.Context, int64, string) (r0 *db.Repository, r1 error) {
 				return
 			},
 		},
+		StarFunc: &ReposStoreStarFunc{
+			defaultHook: func(context.Context, int64, int64) (r0 error) {
+				return
+			},
+		},
 		TouchFunc: &ReposStoreTouchFunc{
 			defaultHook: func(context.Context, int64) (r0 error) {
 				return
@@ -1561,11 +1577,21 @@ func NewStrictMockReposStore() *MockReposStore {
 				panic("unexpected invocation of MockReposStore.GetByCollaboratorIDWithAccessMode")
 			},
 		},
+		GetByIDFunc: &ReposStoreGetByIDFunc{
+			defaultHook: func(context.Context, int64) (*db.Repository, error) {
+				panic("unexpected invocation of MockReposStore.GetByID")
+			},
+		},
 		GetByNameFunc: &ReposStoreGetByNameFunc{
 			defaultHook: func(context.Context, int64, string) (*db.Repository, error) {
 				panic("unexpected invocation of MockReposStore.GetByName")
 			},
 		},
+		StarFunc: &ReposStoreStarFunc{
+			defaultHook: func(context.Context, int64, int64) error {
+				panic("unexpected invocation of MockReposStore.Star")
+			},
+		},
 		TouchFunc: &ReposStoreTouchFunc{
 			defaultHook: func(context.Context, int64) error {
 				panic("unexpected invocation of MockReposStore.Touch")
@@ -1587,9 +1613,15 @@ func NewMockReposStoreFrom(i db.ReposStore) *MockReposStore {
 		GetByCollaboratorIDWithAccessModeFunc: &ReposStoreGetByCollaboratorIDWithAccessModeFunc{
 			defaultHook: i.GetByCollaboratorIDWithAccessMode,
 		},
+		GetByIDFunc: &ReposStoreGetByIDFunc{
+			defaultHook: i.GetByID,
+		},
 		GetByNameFunc: &ReposStoreGetByNameFunc{
 			defaultHook: i.GetByName,
 		},
+		StarFunc: &ReposStoreStarFunc{
+			defaultHook: i.Star,
+		},
 		TouchFunc: &ReposStoreTouchFunc{
 			defaultHook: i.Touch,
 		},
@@ -1934,6 +1966,114 @@ func (c ReposStoreGetByCollaboratorIDWithAccessModeFuncCall) Results() []interfa
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// ReposStoreGetByIDFunc describes the behavior when the GetByID method of
+// the parent MockReposStore instance is invoked.
+type ReposStoreGetByIDFunc struct {
+	defaultHook func(context.Context, int64) (*db.Repository, error)
+	hooks       []func(context.Context, int64) (*db.Repository, error)
+	history     []ReposStoreGetByIDFuncCall
+	mutex       sync.Mutex
+}
+
+// GetByID delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockReposStore) GetByID(v0 context.Context, v1 int64) (*db.Repository, error) {
+	r0, r1 := m.GetByIDFunc.nextHook()(v0, v1)
+	m.GetByIDFunc.appendCall(ReposStoreGetByIDFuncCall{v0, v1, r0, r1})
+	return r0, r1
+}
+
+// SetDefaultHook sets function that is called when the GetByID method of
+// the parent MockReposStore instance is invoked and the hook queue is
+// empty.
+func (f *ReposStoreGetByIDFunc) SetDefaultHook(hook func(context.Context, int64) (*db.Repository, error)) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// GetByID method of the parent MockReposStore 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 *ReposStoreGetByIDFunc) PushHook(hook func(context.Context, int64) (*db.Repository, 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 *ReposStoreGetByIDFunc) SetDefaultReturn(r0 *db.Repository, r1 error) {
+	f.SetDefaultHook(func(context.Context, int64) (*db.Repository, error) {
+		return r0, r1
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *ReposStoreGetByIDFunc) PushReturn(r0 *db.Repository, r1 error) {
+	f.PushHook(func(context.Context, int64) (*db.Repository, error) {
+		return r0, r1
+	})
+}
+
+func (f *ReposStoreGetByIDFunc) nextHook() func(context.Context, int64) (*db.Repository, 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 *ReposStoreGetByIDFunc) appendCall(r0 ReposStoreGetByIDFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of ReposStoreGetByIDFuncCall objects
+// describing the invocations of this function.
+func (f *ReposStoreGetByIDFunc) History() []ReposStoreGetByIDFuncCall {
+	f.mutex.Lock()
+	history := make([]ReposStoreGetByIDFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// ReposStoreGetByIDFuncCall is an object that describes an invocation of
+// method GetByID on an instance of MockReposStore.
+type ReposStoreGetByIDFuncCall 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 *db.Repository
+	// Result1 is the value of the 2nd result returned from this method
+	// invocation.
+	Result1 error
+}
+
+// Args returns an interface slice containing the arguments of this
+// invocation.
+func (c ReposStoreGetByIDFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c ReposStoreGetByIDFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0, c.Result1}
+}
+
 // ReposStoreGetByNameFunc describes the behavior when the GetByName method
 // of the parent MockReposStore instance is invoked.
 type ReposStoreGetByNameFunc struct {
@@ -2045,6 +2185,113 @@ func (c ReposStoreGetByNameFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// ReposStoreStarFunc describes the behavior when the Star method of the
+// parent MockReposStore instance is invoked.
+type ReposStoreStarFunc struct {
+	defaultHook func(context.Context, int64, int64) error
+	hooks       []func(context.Context, int64, int64) error
+	history     []ReposStoreStarFuncCall
+	mutex       sync.Mutex
+}
+
+// Star delegates to the next hook function in the queue and stores the
+// parameter and result values of this invocation.
+func (m *MockReposStore) Star(v0 context.Context, v1 int64, v2 int64) error {
+	r0 := m.StarFunc.nextHook()(v0, v1, v2)
+	m.StarFunc.appendCall(ReposStoreStarFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the Star method of the
+// parent MockReposStore instance is invoked and the hook queue is empty.
+func (f *ReposStoreStarFunc) SetDefaultHook(hook func(context.Context, int64, int64) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// Star method of the parent MockReposStore 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 *ReposStoreStarFunc) PushHook(hook func(context.Context, int64, 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 *ReposStoreStarFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *ReposStoreStarFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, int64) error {
+		return r0
+	})
+}
+
+func (f *ReposStoreStarFunc) nextHook() func(context.Context, int64, 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 *ReposStoreStarFunc) appendCall(r0 ReposStoreStarFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of ReposStoreStarFuncCall objects describing
+// the invocations of this function.
+func (f *ReposStoreStarFunc) History() []ReposStoreStarFuncCall {
+	f.mutex.Lock()
+	history := make([]ReposStoreStarFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// ReposStoreStarFuncCall is an object that describes an invocation of
+// method Star on an instance of MockReposStore.
+type ReposStoreStarFuncCall 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 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 ReposStoreStarFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c ReposStoreStarFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // ReposStoreTouchFunc describes the behavior when the Touch method of the
 // parent MockReposStore instance is invoked.
 type ReposStoreTouchFunc struct {
@@ -2565,9 +2812,15 @@ type MockUsersStore struct {
 	// CreateFunc is an instance of a mock function object controlling the
 	// behavior of the method Create.
 	CreateFunc *UsersStoreCreateFunc
+	// DeleteByIDFunc is an instance of a mock function object controlling
+	// the behavior of the method DeleteByID.
+	DeleteByIDFunc *UsersStoreDeleteByIDFunc
 	// DeleteCustomAvatarFunc is an instance of a mock function object
 	// controlling the behavior of the method DeleteCustomAvatar.
 	DeleteCustomAvatarFunc *UsersStoreDeleteCustomAvatarFunc
+	// DeleteInactivatedFunc is an instance of a mock function object
+	// controlling the behavior of the method DeleteInactivated.
+	DeleteInactivatedFunc *UsersStoreDeleteInactivatedFunc
 	// GetByEmailFunc is an instance of a mock function object controlling
 	// the behavior of the method GetByEmail.
 	GetByEmailFunc *UsersStoreGetByEmailFunc
@@ -2634,11 +2887,21 @@ func NewMockUsersStore() *MockUsersStore {
 				return
 			},
 		},
+		DeleteByIDFunc: &UsersStoreDeleteByIDFunc{
+			defaultHook: func(context.Context, int64, bool) (r0 error) {
+				return
+			},
+		},
 		DeleteCustomAvatarFunc: &UsersStoreDeleteCustomAvatarFunc{
 			defaultHook: func(context.Context, int64) (r0 error) {
 				return
 			},
 		},
+		DeleteInactivatedFunc: &UsersStoreDeleteInactivatedFunc{
+			defaultHook: func() (r0 error) {
+				return
+			},
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: func(context.Context, string) (r0 *db.User, r1 error) {
 				return
@@ -2731,11 +2994,21 @@ func NewStrictMockUsersStore() *MockUsersStore {
 				panic("unexpected invocation of MockUsersStore.Create")
 			},
 		},
+		DeleteByIDFunc: &UsersStoreDeleteByIDFunc{
+			defaultHook: func(context.Context, int64, bool) error {
+				panic("unexpected invocation of MockUsersStore.DeleteByID")
+			},
+		},
 		DeleteCustomAvatarFunc: &UsersStoreDeleteCustomAvatarFunc{
 			defaultHook: func(context.Context, int64) error {
 				panic("unexpected invocation of MockUsersStore.DeleteCustomAvatar")
 			},
 		},
+		DeleteInactivatedFunc: &UsersStoreDeleteInactivatedFunc{
+			defaultHook: func() error {
+				panic("unexpected invocation of MockUsersStore.DeleteInactivated")
+			},
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: func(context.Context, string) (*db.User, error) {
 				panic("unexpected invocation of MockUsersStore.GetByEmail")
@@ -2820,9 +3093,15 @@ func NewMockUsersStoreFrom(i db.UsersStore) *MockUsersStore {
 		CreateFunc: &UsersStoreCreateFunc{
 			defaultHook: i.Create,
 		},
+		DeleteByIDFunc: &UsersStoreDeleteByIDFunc{
+			defaultHook: i.DeleteByID,
+		},
 		DeleteCustomAvatarFunc: &UsersStoreDeleteCustomAvatarFunc{
 			defaultHook: i.DeleteCustomAvatar,
 		},
+		DeleteInactivatedFunc: &UsersStoreDeleteInactivatedFunc{
+			defaultHook: i.DeleteInactivated,
+		},
 		GetByEmailFunc: &UsersStoreGetByEmailFunc{
 			defaultHook: i.GetByEmail,
 		},
@@ -3301,6 +3580,114 @@ func (c UsersStoreCreateFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0, c.Result1}
 }
 
+// UsersStoreDeleteByIDFunc describes the behavior when the DeleteByID
+// method of the parent MockUsersStore instance is invoked.
+type UsersStoreDeleteByIDFunc struct {
+	defaultHook func(context.Context, int64, bool) error
+	hooks       []func(context.Context, int64, bool) error
+	history     []UsersStoreDeleteByIDFuncCall
+	mutex       sync.Mutex
+}
+
+// DeleteByID delegates to the next hook function in the queue and stores
+// the parameter and result values of this invocation.
+func (m *MockUsersStore) DeleteByID(v0 context.Context, v1 int64, v2 bool) error {
+	r0 := m.DeleteByIDFunc.nextHook()(v0, v1, v2)
+	m.DeleteByIDFunc.appendCall(UsersStoreDeleteByIDFuncCall{v0, v1, v2, r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the DeleteByID method of
+// the parent MockUsersStore instance is invoked and the hook queue is
+// empty.
+func (f *UsersStoreDeleteByIDFunc) SetDefaultHook(hook func(context.Context, int64, bool) error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// DeleteByID 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 *UsersStoreDeleteByIDFunc) PushHook(hook func(context.Context, int64, bool) 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 *UsersStoreDeleteByIDFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func(context.Context, int64, bool) error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreDeleteByIDFunc) PushReturn(r0 error) {
+	f.PushHook(func(context.Context, int64, bool) error {
+		return r0
+	})
+}
+
+func (f *UsersStoreDeleteByIDFunc) nextHook() func(context.Context, int64, bool) 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 *UsersStoreDeleteByIDFunc) appendCall(r0 UsersStoreDeleteByIDFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreDeleteByIDFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreDeleteByIDFunc) History() []UsersStoreDeleteByIDFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreDeleteByIDFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreDeleteByIDFuncCall is an object that describes an invocation of
+// method DeleteByID on an instance of MockUsersStore.
+type UsersStoreDeleteByIDFuncCall 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 bool
+	// 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 UsersStoreDeleteByIDFuncCall) Args() []interface{} {
+	return []interface{}{c.Arg0, c.Arg1, c.Arg2}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreDeleteByIDFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreDeleteCustomAvatarFunc describes the behavior when the
 // DeleteCustomAvatar method of the parent MockUsersStore instance is
 // invoked.
@@ -3407,6 +3794,106 @@ func (c UsersStoreDeleteCustomAvatarFuncCall) Results() []interface{} {
 	return []interface{}{c.Result0}
 }
 
+// UsersStoreDeleteInactivatedFunc describes the behavior when the
+// DeleteInactivated method of the parent MockUsersStore instance is
+// invoked.
+type UsersStoreDeleteInactivatedFunc struct {
+	defaultHook func() error
+	hooks       []func() error
+	history     []UsersStoreDeleteInactivatedFuncCall
+	mutex       sync.Mutex
+}
+
+// DeleteInactivated delegates to the next hook function in the queue and
+// stores the parameter and result values of this invocation.
+func (m *MockUsersStore) DeleteInactivated() error {
+	r0 := m.DeleteInactivatedFunc.nextHook()()
+	m.DeleteInactivatedFunc.appendCall(UsersStoreDeleteInactivatedFuncCall{r0})
+	return r0
+}
+
+// SetDefaultHook sets function that is called when the DeleteInactivated
+// method of the parent MockUsersStore instance is invoked and the hook
+// queue is empty.
+func (f *UsersStoreDeleteInactivatedFunc) SetDefaultHook(hook func() error) {
+	f.defaultHook = hook
+}
+
+// PushHook adds a function to the end of hook queue. Each invocation of the
+// DeleteInactivated 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 *UsersStoreDeleteInactivatedFunc) PushHook(hook func() 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 *UsersStoreDeleteInactivatedFunc) SetDefaultReturn(r0 error) {
+	f.SetDefaultHook(func() error {
+		return r0
+	})
+}
+
+// PushReturn calls PushHook with a function that returns the given values.
+func (f *UsersStoreDeleteInactivatedFunc) PushReturn(r0 error) {
+	f.PushHook(func() error {
+		return r0
+	})
+}
+
+func (f *UsersStoreDeleteInactivatedFunc) nextHook() func() 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 *UsersStoreDeleteInactivatedFunc) appendCall(r0 UsersStoreDeleteInactivatedFuncCall) {
+	f.mutex.Lock()
+	f.history = append(f.history, r0)
+	f.mutex.Unlock()
+}
+
+// History returns a sequence of UsersStoreDeleteInactivatedFuncCall objects
+// describing the invocations of this function.
+func (f *UsersStoreDeleteInactivatedFunc) History() []UsersStoreDeleteInactivatedFuncCall {
+	f.mutex.Lock()
+	history := make([]UsersStoreDeleteInactivatedFuncCall, len(f.history))
+	copy(history, f.history)
+	f.mutex.Unlock()
+
+	return history
+}
+
+// UsersStoreDeleteInactivatedFuncCall is an object that describes an
+// invocation of method DeleteInactivated on an instance of MockUsersStore.
+type UsersStoreDeleteInactivatedFuncCall struct {
+	// 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 UsersStoreDeleteInactivatedFuncCall) Args() []interface{} {
+	return []interface{}{}
+}
+
+// Results returns an interface slice containing the results of this
+// invocation.
+func (c UsersStoreDeleteInactivatedFuncCall) Results() []interface{} {
+	return []interface{}{c.Result0}
+}
+
 // UsersStoreGetByEmailFunc describes the behavior when the GetByEmail
 // method of the parent MockUsersStore instance is invoked.
 type UsersStoreGetByEmailFunc struct {

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

@@ -649,7 +649,7 @@ func SettingsDelete(c *context.Context) {
 			return
 		}
 
-		if err := db.DeleteUser(c.User); err != nil {
+		if err := db.Users.DeleteByID(c.Req.Context(), c.User.ID, false); err != nil {
 			switch {
 			case db.IsErrUserOwnRepos(err):
 				c.Flash.Error(c.Tr("form.still_own_repo"))