]> source.dussan.org Git - gitea.git/commitdiff
Repository transfer has to be confirmed, if user can not create repo for new owner...
author6543 <6543@obermui.de>
Mon, 1 Mar 2021 00:47:30 +0000 (01:47 +0100)
committerGitHub <noreply@github.com>
Mon, 1 Mar 2021 00:47:30 +0000 (01:47 +0100)
* make repo as "pending transfer" if on transfer start doer has no right to create repo in new destination

* if new pending transfer ocured, create UI & Mail notifications

32 files changed:
integrations/api_repo_test.go
models/error.go
models/fixtures/repo_transfer.yml [new file with mode: 0644]
models/issue.go
models/migrations/migrations.go
models/migrations/v174.go [new file with mode: 0644]
models/models.go
models/notification.go
models/org.go
models/org_test.go
models/repo.go
models/repo_transfer.go [new file with mode: 0644]
models/repo_transfer_test.go [new file with mode: 0644]
modules/context/repo.go
modules/convert/notification.go
modules/notification/base/notifier.go
modules/notification/base/null.go
modules/notification/mail/mail.go
modules/notification/notification.go
modules/notification/ui/ui.go
options/locale/locale_en-US.ini
routers/api/v1/repo/transfer.go
routers/repo/repo.go
routers/repo/setting.go
routers/repo/view.go
services/mailer/mail.go
services/mailer/mail_repo.go [new file with mode: 0644]
services/repository/transfer.go
templates/mail/notify/repo_transfer.tmpl [new file with mode: 0644]
templates/repo/header.tmpl
templates/repo/settings/options.tmpl
templates/user/notification/notification_div.tmpl

index b6d41fe1e0ba66ba129fd8ce192759a341c5b387..3404e050cfe88a71c799a16d6da6af7e65cfb50b 100644 (file)
@@ -444,12 +444,22 @@ func TestAPIRepoTransfer(t *testing.T) {
                teams          *[]int64
                expectedStatus int
        }{
-               {ctxUserID: 1, newOwner: "user2", teams: nil, expectedStatus: http.StatusAccepted},
-               {ctxUserID: 2, newOwner: "user1", teams: nil, expectedStatus: http.StatusAccepted},
-               {ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusForbidden},
-               {ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity},
+               // Disclaimer for test story: "user1" is an admin, "user2" is normal user and part of in owner team of org "user3"
+
+               // Transfer to a user with teams in another org should fail
                {ctxUserID: 1, newOwner: "user3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden},
+               // Transfer to a user with non-existent team IDs should fail
+               {ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity},
+               // Transfer should go through
                {ctxUserID: 1, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted},
+               // Let user transfer it back to himself
+               {ctxUserID: 2, newOwner: "user2", expectedStatus: http.StatusAccepted},
+               // And revert transfer
+               {ctxUserID: 2, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted},
+               // Cannot start transfer to an existing repo
+               {ctxUserID: 2, newOwner: "user3", teams: nil, expectedStatus: http.StatusUnprocessableEntity},
+               // Start transfer, repo is now in pending transfer mode
+               {ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusCreated},
        }
 
        defer prepareTestEnv(t)()
index fc161ed806f338b03c858fd855c23677de215735..84b7ebbfa35b08f16582b173e2d0ce6b341dde9b 100644 (file)
@@ -757,6 +757,40 @@ func (err ErrRepoNotExist) Error() string {
                err.ID, err.UID, err.OwnerName, err.Name)
 }
 
+// ErrNoPendingRepoTransfer is an error type for repositories without a pending
+// transfer request
+type ErrNoPendingRepoTransfer struct {
+       RepoID int64
+}
+
+func (e ErrNoPendingRepoTransfer) Error() string {
+       return fmt.Sprintf("repository doesn't have a pending transfer [repo_id: %d]", e.RepoID)
+}
+
+// IsErrNoPendingTransfer is an error type when a repository has no pending
+// transfers
+func IsErrNoPendingTransfer(err error) bool {
+       _, ok := err.(ErrNoPendingRepoTransfer)
+       return ok
+}
+
+// ErrRepoTransferInProgress represents the state of a repository that has an
+// ongoing transfer
+type ErrRepoTransferInProgress struct {
+       Uname string
+       Name  string
+}
+
+// IsErrRepoTransferInProgress checks if an error is a ErrRepoTransferInProgress.
+func IsErrRepoTransferInProgress(err error) bool {
+       _, ok := err.(ErrRepoTransferInProgress)
+       return ok
+}
+
+func (err ErrRepoTransferInProgress) Error() string {
+       return fmt.Sprintf("repository is already being transferred [uname: %s, name: %s]", err.Uname, err.Name)
+}
+
 // ErrRepoAlreadyExist represents a "RepoAlreadyExist" kind of error.
 type ErrRepoAlreadyExist struct {
        Uname string
diff --git a/models/fixtures/repo_transfer.yml b/models/fixtures/repo_transfer.yml
new file mode 100644 (file)
index 0000000..b841b5e
--- /dev/null
@@ -0,0 +1,7 @@
+-
+  id: 1
+  doer_id: 3
+  recipient_id: 1
+  repo_id: 3
+  created_unix: 1553610671
+  updated_unix: 1553610671
index b903e82ad7d40baf8b56f40889b53d13f9dd4986..c0bafb54e45a54a762779d921262fcfd33c0353c 100644 (file)
@@ -563,7 +563,7 @@ func (issue *Issue) ReadBy(userID int64) error {
                return err
        }
 
-       return setNotificationStatusReadIfUnread(x, userID, issue.ID)
+       return setIssueNotificationStatusReadIfUnread(x, userID, issue.ID)
 }
 
 func updateIssueCols(e Engine, issue *Issue, cols ...string) error {
index 4fc737e1bfe420858ff66e27e4aff84cdb30346f..27c159ee4adefdd5c335679bbf9c1cc2bd7ce6f8 100644 (file)
@@ -294,6 +294,8 @@ var migrations = []Migration{
        NewMigration("Add sessions table for go-chi/session", addSessionTable),
        // v173 -> v174
        NewMigration("Add time_id column to Comment", addTimeIDCommentColumn),
+       // v174 -> v175
+       NewMigration("create repo transfer table", addRepoTransfer),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v174.go b/models/migrations/v174.go
new file mode 100644 (file)
index 0000000..ce337df
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright 2021 The Gitea 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 migrations
+
+import (
+       "xorm.io/xorm"
+)
+
+func addRepoTransfer(x *xorm.Engine) error {
+       type RepoTransfer struct {
+               ID          int64 `xorm:"pk autoincr"`
+               DoerID      int64
+               RecipientID int64
+               RepoID      int64
+               TeamIDs     []int64
+               CreatedUnix int64 `xorm:"INDEX NOT NULL created"`
+               UpdatedUnix int64 `xorm:"INDEX NOT NULL updated"`
+       }
+
+       return x.Sync(new(RepoTransfer))
+}
index de50793fe78578e34fe60dbb2c9ab1c40fedaa70..ca4d1c4ad1502109227594a9730ef35bbf887206 100644 (file)
@@ -133,6 +133,7 @@ func init() {
                new(ProjectBoard),
                new(ProjectIssue),
                new(Session),
+               new(RepoTransfer),
        )
 
        gonicNames := []string{"SSL", "UID"}
index 32f3079bf4173303270e345465d9faccbc96a2c9..a9178b97defd907584ac8d85fc608f211077b460 100644 (file)
@@ -39,6 +39,8 @@ const (
        NotificationSourcePullRequest
        // NotificationSourceCommit is a notification of a commit
        NotificationSourceCommit
+       // NotificationSourceRepository is a notification for a repository
+       NotificationSourceRepository
 )
 
 // Notification represents a notification
@@ -119,6 +121,46 @@ func GetNotifications(opts FindNotificationOptions) (NotificationList, error) {
        return getNotifications(x, opts)
 }
 
+// CreateRepoTransferNotification creates  notification for the user a repository was transferred to
+func CreateRepoTransferNotification(doer, newOwner *User, repo *Repository) error {
+       sess := x.NewSession()
+       defer sess.Close()
+       if err := sess.Begin(); err != nil {
+               return err
+       }
+       var notify []*Notification
+
+       if newOwner.IsOrganization() {
+               users, err := getUsersWhoCanCreateOrgRepo(sess, newOwner.ID)
+               if err != nil || len(users) == 0 {
+                       return err
+               }
+               for i := range users {
+                       notify = append(notify, &Notification{
+                               UserID:    users[i].ID,
+                               RepoID:    repo.ID,
+                               Status:    NotificationStatusUnread,
+                               UpdatedBy: doer.ID,
+                               Source:    NotificationSourceRepository,
+                       })
+               }
+       } else {
+               notify = []*Notification{{
+                       UserID:    newOwner.ID,
+                       RepoID:    repo.ID,
+                       Status:    NotificationStatusUnread,
+                       UpdatedBy: doer.ID,
+                       Source:    NotificationSourceRepository,
+               }}
+       }
+
+       if _, err := sess.InsertMulti(notify); err != nil {
+               return err
+       }
+
+       return sess.Commit()
+}
+
 // CreateOrUpdateIssueNotifications creates an issue notification
 // for each watcher, or updates it if already exists
 // receiverID > 0 just send to reciver, else send to all watcher
@@ -363,7 +405,7 @@ func (n *Notification) loadRepo(e Engine) (err error) {
 }
 
 func (n *Notification) loadIssue(e Engine) (err error) {
-       if n.Issue == nil {
+       if n.Issue == nil && n.IssueID != 0 {
                n.Issue, err = getIssueByID(e, n.IssueID)
                if err != nil {
                        return fmt.Errorf("getIssueByID [%d]: %v", n.IssueID, err)
@@ -374,7 +416,7 @@ func (n *Notification) loadIssue(e Engine) (err error) {
 }
 
 func (n *Notification) loadComment(e Engine) (err error) {
-       if n.Comment == nil && n.CommentID > 0 {
+       if n.Comment == nil && n.CommentID != 0 {
                n.Comment, err = getCommentByID(e, n.CommentID)
                if err != nil {
                        return fmt.Errorf("GetCommentByID [%d] for issue ID [%d]: %v", n.CommentID, n.IssueID, err)
@@ -405,10 +447,18 @@ func (n *Notification) GetIssue() (*Issue, error) {
 
 // HTMLURL formats a URL-string to the notification
 func (n *Notification) HTMLURL() string {
-       if n.Comment != nil {
-               return n.Comment.HTMLURL()
+       switch n.Source {
+       case NotificationSourceIssue, NotificationSourcePullRequest:
+               if n.Comment != nil {
+                       return n.Comment.HTMLURL()
+               }
+               return n.Issue.HTMLURL()
+       case NotificationSourceCommit:
+               return n.Repository.HTMLURL() + "/commit/" + n.CommitID
+       case NotificationSourceRepository:
+               return n.Repository.HTMLURL()
        }
-       return n.Issue.HTMLURL()
+       return ""
 }
 
 // APIURL formats a URL-string to the notification
@@ -562,8 +612,10 @@ func (nl NotificationList) LoadIssues() ([]int, error) {
                if notification.Issue == nil {
                        notification.Issue = issues[notification.IssueID]
                        if notification.Issue == nil {
-                               log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID)
-                               failures = append(failures, i)
+                               if notification.IssueID != 0 {
+                                       log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID)
+                                       failures = append(failures, i)
+                               }
                                continue
                        }
                        notification.Issue.Repo = notification.Repository
@@ -683,7 +735,7 @@ func GetUIDsAndNotificationCounts(since, until timeutil.TimeStamp) ([]UserIDCoun
        return res, x.SQL(sql, since, until, NotificationStatusUnread).Find(&res)
 }
 
-func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error {
+func setIssueNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error {
        notification, err := getIssueNotification(e, userID, issueID)
        // ignore if not exists
        if err != nil {
@@ -700,6 +752,16 @@ func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error {
        return err
 }
 
+func setRepoNotificationStatusReadIfUnread(e Engine, userID, repoID int64) error {
+       _, err := e.Where(builder.Eq{
+               "user_id": userID,
+               "status":  NotificationStatusUnread,
+               "source":  NotificationSourceRepository,
+               "repo_id": repoID,
+       }).Cols("status").Update(&Notification{Status: NotificationStatusRead})
+       return err
+}
+
 // SetNotificationStatus change the notification status
 func SetNotificationStatus(notificationID int64, user *User, status NotificationStatus) error {
        notification, err := getNotificationByID(x, notificationID)
index ee867eec887f44c8d2fa32aa019925d0a263484c..437d57d0561cbf31d119f7bf4f12140bf6107d93 100644 (file)
@@ -391,6 +391,20 @@ func CanCreateOrgRepo(orgID, uid int64) (bool, error) {
                Exist(new(Team))
 }
 
+// GetUsersWhoCanCreateOrgRepo returns users which are able to create repo in organization
+func GetUsersWhoCanCreateOrgRepo(orgID int64) ([]*User, error) {
+       return getUsersWhoCanCreateOrgRepo(x, orgID)
+}
+
+func getUsersWhoCanCreateOrgRepo(e Engine, orgID int64) ([]*User, error) {
+       users := make([]*User, 0, 10)
+       return users, x.
+               Join("INNER", "`team_user`", "`team_user`.uid=`user`.id").
+               Join("INNER", "`team`", "`team`.id=`team_user`.team_id").
+               Where(builder.Eq{"team.can_create_org_repo": true}.Or(builder.Eq{"team.authorize": AccessModeOwner})).
+               And("team_user.org_id = ?", orgID).Asc("`user`.name").Find(&users)
+}
+
 func getOrgsByUserID(sess *xorm.Session, userID int64, showAll bool) ([]*User, error) {
        orgs := make([]*User, 0, 10)
        if !showAll {
index 79f8b060e70619d3582a39a5113167fb2b62d05b..45268f0f231dc00f25ea5396d1b704bbc10152da 100644 (file)
@@ -635,3 +635,21 @@ func TestHasOrgVisibleTypePrivate(t *testing.T) {
        assert.Equal(t, test2, false) // user not a part of org
        assert.Equal(t, test3, false) // logged out user
 }
+
+func TestGetUsersWhoCanCreateOrgRepo(t *testing.T) {
+       assert.NoError(t, PrepareTestDatabase())
+
+       users, err := GetUsersWhoCanCreateOrgRepo(3)
+       assert.NoError(t, err)
+       assert.Len(t, users, 2)
+       var ids []int64
+       for i := range users {
+               ids = append(ids, users[i].ID)
+       }
+       assert.ElementsMatch(t, ids, []int64{2, 28})
+
+       users, err = GetUsersWhoCanCreateOrgRepo(7)
+       assert.NoError(t, err)
+       assert.Len(t, users, 1)
+       assert.EqualValues(t, 5, users[0].ID)
+}
index 6cb6f38a48d148cb0a29d4d9e61b0d4192590fcb..2c71fc3e1eede2f43bdd1cadd7f82e42e56049cc 100644 (file)
@@ -139,8 +139,9 @@ type RepositoryStatus int
 
 // all kinds of RepositoryStatus
 const (
-       RepositoryReady         RepositoryStatus = iota // a normal repository
-       RepositoryBeingMigrated                         // repository is migrating
+       RepositoryReady           RepositoryStatus = iota // a normal repository
+       RepositoryBeingMigrated                           // repository is migrating
+       RepositoryPendingTransfer                         // repository pending in ownership transfer state
 )
 
 // TrustModelType defines the types of trust model for this repository
@@ -872,6 +873,11 @@ func (repo *Repository) DescriptionHTML() template.HTML {
        return template.HTML(markup.Sanitize(string(desc)))
 }
 
+// ReadBy sets repo to be visited by given user.
+func (repo *Repository) ReadBy(userID int64) error {
+       return setRepoNotificationStatusReadIfUnread(x, userID, repo.ID)
+}
+
 func isRepositoryExist(e Engine, u *User, repoName string) (bool, error) {
        has, err := e.Get(&Repository{
                OwnerID:   u.ID,
@@ -1189,140 +1195,6 @@ func IncrementRepoForkNum(ctx DBContext, repoID int64) error {
        return err
 }
 
-// TransferOwnership transfers all corresponding setting from old user to new one.
-func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error {
-       newOwner, err := GetUserByName(newOwnerName)
-       if err != nil {
-               return fmt.Errorf("get new owner '%s': %v", newOwnerName, err)
-       }
-
-       // Check if new owner has repository with same name.
-       has, err := IsRepositoryExist(newOwner, repo.Name)
-       if err != nil {
-               return fmt.Errorf("IsRepositoryExist: %v", err)
-       } else if has {
-               return ErrRepoAlreadyExist{newOwnerName, repo.Name}
-       }
-
-       sess := x.NewSession()
-       defer sess.Close()
-       if err = sess.Begin(); err != nil {
-               return fmt.Errorf("sess.Begin: %v", err)
-       }
-
-       oldOwner := repo.Owner
-
-       // Note: we have to set value here to make sure recalculate accesses is based on
-       // new owner.
-       repo.OwnerID = newOwner.ID
-       repo.Owner = newOwner
-       repo.OwnerName = newOwner.Name
-
-       // Update repository.
-       if _, err := sess.ID(repo.ID).Update(repo); err != nil {
-               return fmt.Errorf("update owner: %v", err)
-       }
-
-       // Remove redundant collaborators.
-       collaborators, err := repo.getCollaborators(sess, ListOptions{})
-       if err != nil {
-               return fmt.Errorf("getCollaborators: %v", err)
-       }
-
-       // Dummy object.
-       collaboration := &Collaboration{RepoID: repo.ID}
-       for _, c := range collaborators {
-               if c.ID != newOwner.ID {
-                       isMember, err := isOrganizationMember(sess, newOwner.ID, c.ID)
-                       if err != nil {
-                               return fmt.Errorf("IsOrgMember: %v", err)
-                       } else if !isMember {
-                               continue
-                       }
-               }
-               collaboration.UserID = c.ID
-               if _, err = sess.Delete(collaboration); err != nil {
-                       return fmt.Errorf("remove collaborator '%d': %v", c.ID, err)
-               }
-       }
-
-       // Remove old team-repository relations.
-       if oldOwner.IsOrganization() {
-               if err = oldOwner.removeOrgRepo(sess, repo.ID); err != nil {
-                       return fmt.Errorf("removeOrgRepo: %v", err)
-               }
-       }
-
-       if newOwner.IsOrganization() {
-               if err := newOwner.getTeams(sess); err != nil {
-                       return fmt.Errorf("GetTeams: %v", err)
-               }
-               for _, t := range newOwner.Teams {
-                       if t.IncludesAllRepositories {
-                               if err := t.addRepository(sess, repo); err != nil {
-                                       return fmt.Errorf("addRepository: %v", err)
-                               }
-                       }
-               }
-       } else if err = repo.recalculateAccesses(sess); err != nil {
-               // Organization called this in addRepository method.
-               return fmt.Errorf("recalculateAccesses: %v", err)
-       }
-
-       // Update repository count.
-       if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil {
-               return fmt.Errorf("increase new owner repository count: %v", err)
-       } else if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil {
-               return fmt.Errorf("decrease old owner repository count: %v", err)
-       }
-
-       if err = watchRepo(sess, doer.ID, repo.ID, true); err != nil {
-               return fmt.Errorf("watchRepo: %v", err)
-       }
-
-       // Remove watch for organization.
-       if oldOwner.IsOrganization() {
-               if err = watchRepo(sess, oldOwner.ID, repo.ID, false); err != nil {
-                       return fmt.Errorf("watchRepo [false]: %v", err)
-               }
-       }
-
-       // Rename remote repository to new path and delete local copy.
-       dir := UserPath(newOwner.Name)
-
-       if err := os.MkdirAll(dir, os.ModePerm); err != nil {
-               return fmt.Errorf("Failed to create dir %s: %v", dir, err)
-       }
-
-       if err = os.Rename(RepoPath(oldOwner.Name, repo.Name), RepoPath(newOwner.Name, repo.Name)); err != nil {
-               return fmt.Errorf("rename repository directory: %v", err)
-       }
-
-       // Rename remote wiki repository to new path and delete local copy.
-       wikiPath := WikiPath(oldOwner.Name, repo.Name)
-       isExist, err := util.IsExist(wikiPath)
-       if err != nil {
-               log.Error("Unable to check if %s exists. Error: %v", wikiPath, err)
-               return err
-       }
-       if isExist {
-               if err = os.Rename(wikiPath, WikiPath(newOwner.Name, repo.Name)); err != nil {
-                       return fmt.Errorf("rename repository wiki: %v", err)
-               }
-       }
-
-       // If there was previously a redirect at this location, remove it.
-       if err = deleteRepoRedirect(sess, newOwner.ID, repo.Name); err != nil {
-               return fmt.Errorf("delete repo redirect: %v", err)
-       }
-
-       if err := newRepoRedirect(sess, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil {
-               return fmt.Errorf("newRepoRedirect: %v", err)
-       }
-
-       return sess.Commit()
-}
-
 // ChangeRepositoryName changes all corresponding setting from old repository name to new one.
 func ChangeRepositoryName(doer *User, repo *Repository, newRepoName string) (err error) {
        oldRepoName := repo.Name
diff --git a/models/repo_transfer.go b/models/repo_transfer.go
new file mode 100644 (file)
index 0000000..cf1e16b
--- /dev/null
@@ -0,0 +1,335 @@
+// Copyright 2021 The Gitea 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 models
+
+import (
+       "fmt"
+       "os"
+
+       "code.gitea.io/gitea/modules/log"
+       "code.gitea.io/gitea/modules/timeutil"
+       "code.gitea.io/gitea/modules/util"
+)
+
+// RepoTransfer is used to manage repository transfers
+type RepoTransfer struct {
+       ID          int64 `xorm:"pk autoincr"`
+       DoerID      int64
+       Doer        *User `xorm:"-"`
+       RecipientID int64
+       Recipient   *User `xorm:"-"`
+       RepoID      int64
+       TeamIDs     []int64
+       Teams       []*Team `xorm:"-"`
+
+       CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"`
+       UpdatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL updated"`
+}
+
+// LoadAttributes fetches the transfer recipient from the database
+func (r *RepoTransfer) LoadAttributes() error {
+       if r.Recipient == nil {
+               u, err := GetUserByID(r.RecipientID)
+               if err != nil {
+                       return err
+               }
+
+               r.Recipient = u
+       }
+
+       if r.Recipient.IsOrganization() && len(r.TeamIDs) != len(r.Teams) {
+
+               for _, v := range r.TeamIDs {
+                       team, err := GetTeamByID(v)
+                       if err != nil {
+                               return err
+                       }
+
+                       if team.OrgID != r.Recipient.ID {
+                               return fmt.Errorf("team %d belongs not to org %d", v, r.Recipient.ID)
+                       }
+
+                       r.Teams = append(r.Teams, team)
+               }
+       }
+
+       if r.Doer == nil {
+               u, err := GetUserByID(r.DoerID)
+               if err != nil {
+                       return err
+               }
+
+               r.Doer = u
+       }
+
+       return nil
+}
+
+// CanUserAcceptTransfer checks if the user has the rights to accept/decline a repo transfer.
+// For user, it checks if it's himself
+// For organizations, it checks if the user is able to create repos
+func (r *RepoTransfer) CanUserAcceptTransfer(u *User) bool {
+       if err := r.LoadAttributes(); err != nil {
+               log.Error("LoadAttributes: %v", err)
+               return false
+       }
+
+       if !r.Recipient.IsOrganization() {
+               return r.RecipientID == u.ID
+       }
+
+       allowed, err := CanCreateOrgRepo(r.RecipientID, u.ID)
+       if err != nil {
+               log.Error("CanCreateOrgRepo: %v", err)
+               return false
+       }
+
+       return allowed
+}
+
+// GetPendingRepositoryTransfer fetches the most recent and ongoing transfer
+// process for the repository
+func GetPendingRepositoryTransfer(repo *Repository) (*RepoTransfer, error) {
+       var transfer = new(RepoTransfer)
+
+       has, err := x.Where("repo_id = ? ", repo.ID).Get(transfer)
+       if err != nil {
+               return nil, err
+       }
+
+       if !has {
+               return nil, ErrNoPendingRepoTransfer{RepoID: repo.ID}
+       }
+
+       return transfer, nil
+}
+
+func deleteRepositoryTransfer(e Engine, repoID int64) error {
+       _, err := e.Where("repo_id = ?", repoID).Delete(&RepoTransfer{})
+       return err
+}
+
+// CancelRepositoryTransfer marks the repository as ready and remove pending transfer entry,
+// thus cancel the transfer process.
+func CancelRepositoryTransfer(repo *Repository) error {
+       sess := x.NewSession()
+       defer sess.Close()
+       if err := sess.Begin(); err != nil {
+               return err
+       }
+
+       repo.Status = RepositoryReady
+       if err := updateRepositoryCols(sess, repo, "status"); err != nil {
+               return err
+       }
+
+       if err := deleteRepositoryTransfer(sess, repo.ID); err != nil {
+               return err
+       }
+
+       return sess.Commit()
+}
+
+// TestRepositoryReadyForTransfer make sure repo is ready to transfer
+func TestRepositoryReadyForTransfer(status RepositoryStatus) error {
+       switch status {
+       case RepositoryBeingMigrated:
+               return fmt.Errorf("repo is not ready, currently migrating")
+       case RepositoryPendingTransfer:
+               return ErrRepoTransferInProgress{}
+       }
+       return nil
+}
+
+// CreatePendingRepositoryTransfer transfer a repo from one owner to a new one.
+// it marks the repository transfer as "pending"
+func CreatePendingRepositoryTransfer(doer, newOwner *User, repoID int64, teams []*Team) error {
+       sess := x.NewSession()
+       defer sess.Close()
+       if err := sess.Begin(); err != nil {
+               return err
+       }
+
+       repo, err := getRepositoryByID(sess, repoID)
+       if err != nil {
+               return err
+       }
+
+       // Make sure repo is ready to transfer
+       if err := TestRepositoryReadyForTransfer(repo.Status); err != nil {
+               return err
+       }
+
+       repo.Status = RepositoryPendingTransfer
+       if err := updateRepositoryCols(sess, repo, "status"); err != nil {
+               return err
+       }
+
+       // Check if new owner has repository with same name.
+       if has, err := isRepositoryExist(sess, newOwner, repo.Name); err != nil {
+               return fmt.Errorf("IsRepositoryExist: %v", err)
+       } else if has {
+               return ErrRepoAlreadyExist{newOwner.LowerName, repo.Name}
+       }
+
+       transfer := &RepoTransfer{
+               RepoID:      repo.ID,
+               RecipientID: newOwner.ID,
+               CreatedUnix: timeutil.TimeStampNow(),
+               UpdatedUnix: timeutil.TimeStampNow(),
+               DoerID:      doer.ID,
+               TeamIDs:     make([]int64, 0, len(teams)),
+       }
+
+       for k := range teams {
+               transfer.TeamIDs = append(transfer.TeamIDs, teams[k].ID)
+       }
+
+       if _, err := sess.Insert(transfer); err != nil {
+               return err
+       }
+
+       return sess.Commit()
+}
+
+// TransferOwnership transfers all corresponding repository items from old user to new one.
+func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error {
+       sess := x.NewSession()
+       defer sess.Close()
+       if err := sess.Begin(); err != nil {
+               return fmt.Errorf("sess.Begin: %v", err)
+       }
+
+       newOwner, err := getUserByName(sess, newOwnerName)
+       if err != nil {
+               return fmt.Errorf("get new owner '%s': %v", newOwnerName, err)
+       }
+
+       // Check if new owner has repository with same name.
+       if has, err := isRepositoryExist(sess, newOwner, repo.Name); err != nil {
+               return fmt.Errorf("IsRepositoryExist: %v", err)
+       } else if has {
+               return ErrRepoAlreadyExist{newOwnerName, repo.Name}
+       }
+
+       oldOwner := repo.Owner
+
+       // Note: we have to set value here to make sure recalculate accesses is based on
+       // new owner.
+       repo.OwnerID = newOwner.ID
+       repo.Owner = newOwner
+       repo.OwnerName = newOwner.Name
+
+       // Update repository.
+       if _, err := sess.ID(repo.ID).Update(repo); err != nil {
+               return fmt.Errorf("update owner: %v", err)
+       }
+
+       // Remove redundant collaborators.
+       collaborators, err := repo.getCollaborators(sess, ListOptions{})
+       if err != nil {
+               return fmt.Errorf("getCollaborators: %v", err)
+       }
+
+       // Dummy object.
+       collaboration := &Collaboration{RepoID: repo.ID}
+       for _, c := range collaborators {
+               if c.ID != newOwner.ID {
+                       isMember, err := isOrganizationMember(sess, newOwner.ID, c.ID)
+                       if err != nil {
+                               return fmt.Errorf("IsOrgMember: %v", err)
+                       } else if !isMember {
+                               continue
+                       }
+               }
+               collaboration.UserID = c.ID
+               if _, err := sess.Delete(collaboration); err != nil {
+                       return fmt.Errorf("remove collaborator '%d': %v", c.ID, err)
+               }
+       }
+
+       // Remove old team-repository relations.
+       if oldOwner.IsOrganization() {
+               if err := oldOwner.removeOrgRepo(sess, repo.ID); err != nil {
+                       return fmt.Errorf("removeOrgRepo: %v", err)
+               }
+       }
+
+       if newOwner.IsOrganization() {
+               if err := newOwner.getTeams(sess); err != nil {
+                       return fmt.Errorf("GetTeams: %v", err)
+               }
+               for _, t := range newOwner.Teams {
+                       if t.IncludesAllRepositories {
+                               if err := t.addRepository(sess, repo); err != nil {
+                                       return fmt.Errorf("addRepository: %v", err)
+                               }
+                       }
+               }
+       } else if err := repo.recalculateAccesses(sess); err != nil {
+               // Organization called this in addRepository method.
+               return fmt.Errorf("recalculateAccesses: %v", err)
+       }
+
+       // Update repository count.
+       if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil {
+               return fmt.Errorf("increase new owner repository count: %v", err)
+       } else if _, err := sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil {
+               return fmt.Errorf("decrease old owner repository count: %v", err)
+       }
+
+       if err := watchRepo(sess, doer.ID, repo.ID, true); err != nil {
+               return fmt.Errorf("watchRepo: %v", err)
+       }
+
+       // Remove watch for organization.
+       if oldOwner.IsOrganization() {
+               if err := watchRepo(sess, oldOwner.ID, repo.ID, false); err != nil {
+                       return fmt.Errorf("watchRepo [false]: %v", err)
+               }
+       }
+
+       // Rename remote repository to new path and delete local copy.
+       dir := UserPath(newOwner.Name)
+
+       if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+               return fmt.Errorf("Failed to create dir %s: %v", dir, err)
+       }
+
+       if err := os.Rename(RepoPath(oldOwner.Name, repo.Name), RepoPath(newOwner.Name, repo.Name)); err != nil {
+               return fmt.Errorf("rename repository directory: %v", err)
+       }
+
+       // Rename remote wiki repository to new path and delete local copy.
+       wikiPath := WikiPath(oldOwner.Name, repo.Name)
+
+       if isExist, err := util.IsExist(wikiPath); err != nil {
+               log.Error("Unable to check if %s exists. Error: %v", wikiPath, err)
+               return err
+       } else if isExist {
+               if err := os.Rename(wikiPath, WikiPath(newOwner.Name, repo.Name)); err != nil {
+                       return fmt.Errorf("rename repository wiki: %v", err)
+               }
+       }
+
+       if err := deleteRepositoryTransfer(sess, repo.ID); err != nil {
+               return fmt.Errorf("deleteRepositoryTransfer: %v", err)
+       }
+       repo.Status = RepositoryReady
+       if err := updateRepositoryCols(sess, repo, "status"); err != nil {
+               return err
+       }
+
+       // If there was previously a redirect at this location, remove it.
+       if err := deleteRepoRedirect(sess, newOwner.ID, repo.Name); err != nil {
+               return fmt.Errorf("delete repo redirect: %v", err)
+       }
+
+       if err := newRepoRedirect(sess, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil {
+               return fmt.Errorf("newRepoRedirect: %v", err)
+       }
+
+       return sess.Commit()
+}
diff --git a/models/repo_transfer_test.go b/models/repo_transfer_test.go
new file mode 100644 (file)
index 0000000..55aedac
--- /dev/null
@@ -0,0 +1,54 @@
+// Copyright 2021 The Gitea 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 models
+
+import (
+       "testing"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func TestRepositoryTransfer(t *testing.T) {
+
+       assert.NoError(t, PrepareTestDatabase())
+
+       doer := AssertExistsAndLoadBean(t, &User{ID: 3}).(*User)
+       repo := AssertExistsAndLoadBean(t, &Repository{ID: 3}).(*Repository)
+
+       transfer, err := GetPendingRepositoryTransfer(repo)
+       assert.NoError(t, err)
+       assert.NotNil(t, transfer)
+
+       // Cancel transfer
+       assert.NoError(t, CancelRepositoryTransfer(repo))
+
+       transfer, err = GetPendingRepositoryTransfer(repo)
+       assert.Error(t, err)
+       assert.Nil(t, transfer)
+       assert.True(t, IsErrNoPendingTransfer(err))
+
+       user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
+
+       assert.NoError(t, CreatePendingRepositoryTransfer(doer, user2, repo.ID, nil))
+
+       transfer, err = GetPendingRepositoryTransfer(repo)
+       assert.Nil(t, err)
+       assert.NoError(t, transfer.LoadAttributes())
+       assert.Equal(t, "user2", transfer.Recipient.Name)
+
+       user6 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User)
+
+       // Only transfer can be started at any given time
+       err = CreatePendingRepositoryTransfer(doer, user6, repo.ID, nil)
+       assert.Error(t, err)
+       assert.True(t, IsErrRepoTransferInProgress(err))
+
+       // Unknown user
+       err = CreatePendingRepositoryTransfer(doer, &User{ID: 1000, LowerName: "user1000"}, repo.ID, nil)
+       assert.Error(t, err)
+
+       // Cancel transfer
+       assert.NoError(t, CancelRepositoryTransfer(repo))
+}
index bf149b8158ac9e708366ba01c410d167a50e1153..ba3cfe9bf256a42c2f9ad28ca1f5fa7048184080 100644 (file)
@@ -600,6 +600,24 @@ func RepoAssignment() func(http.Handler) http.Handler {
                        ctx.Data["CanCompareOrPull"] = canCompare
                        ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest
 
+                       if ctx.Repo.Repository.Status == models.RepositoryPendingTransfer {
+                               repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository)
+                               if err != nil {
+                                       ctx.ServerError("GetPendingRepositoryTransfer", err)
+                                       return
+                               }
+
+                               if err := repoTransfer.LoadAttributes(); err != nil {
+                                       ctx.ServerError("LoadRecipient", err)
+                                       return
+                               }
+
+                               ctx.Data["RepoTransfer"] = repoTransfer
+                               if ctx.User != nil {
+                                       ctx.Data["CanUserAcceptTransfer"] = repoTransfer.CanUserAcceptTransfer(ctx.User)
+                               }
+                       }
+
                        if ctx.Query("go-get") == "1" {
                                ctx.Data["GoGetImport"] = ComposeGoGetImport(owner.Name, repo.Name)
                                prefix := setting.AppURL + path.Join(owner.Name, repo.Name, "src", "branch", ctx.Repo.BranchName)
index fff891b15dffd052102ca84b70e191c99cc3cc0d..49abe01253246e1dae9f1eecdc8e3e7d84cd9429 100644 (file)
@@ -52,8 +52,14 @@ func ToNotificationThread(n *models.Notification) *api.NotificationThread {
                result.Subject = &api.NotificationSubject{
                        Type:  "Commit",
                        Title: n.CommitID,
+                       URL:   n.Repository.HTMLURL() + "/commit/" + n.CommitID,
+               }
+       case models.NotificationSourceRepository:
+               result.Subject = &api.NotificationSubject{
+                       Type:  "Repository",
+                       Title: n.Repository.FullName(),
+                       URL:   n.Repository.Link(),
                }
-               //unused until now
        }
 
        return result
index 5bb833d275170d4cd271eb9c793d36ec1ec9f542..8f8aa659b45d91e4de7ab661e586e5d34ba61d50 100644 (file)
@@ -57,4 +57,6 @@ type Notifier interface {
        NotifySyncPushCommits(pusher *models.User, repo *models.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits)
        NotifySyncCreateRef(doer *models.User, repo *models.Repository, refType, refFullName string)
        NotifySyncDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string)
+
+       NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository)
 }
index 2386f925cec6adefb9c71cf5ef7e0c013394e63d..e61b37a943090e6f4a25dd8f3978e116b46f2628 100644 (file)
@@ -166,3 +166,7 @@ func (*NullNotifier) NotifySyncCreateRef(doer *models.User, repo *models.Reposit
 // NotifySyncDeleteRef places a place holder function
 func (*NullNotifier) NotifySyncDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) {
 }
+
+// NotifyRepoPendingTransfer places a place holder function
+func (*NullNotifier) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) {
+}
index f984ea7661ae6d791c23c85e8bd2934b61cc15b3..f7192f5a52eff0d22d247cad80649ba88c62dba3 100644 (file)
@@ -170,3 +170,9 @@ func (m *mailNotifier) NotifyNewRelease(rel *models.Release) {
 
        mailer.MailNewRelease(rel)
 }
+
+func (m *mailNotifier) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) {
+       if err := mailer.SendRepoTransferNotifyMail(doer, newOwner, repo); err != nil {
+               log.Error("NotifyRepoPendingTransfer: %v", err)
+       }
+}
index d22d157bec18eff0f1157ad6f4955ad4207ae9fb..b574f3ccda22562f1a4629a15396c679aab76c2c 100644 (file)
@@ -290,3 +290,10 @@ func NotifySyncDeleteRef(pusher *models.User, repo *models.Repository, refType,
                notifier.NotifySyncDeleteRef(pusher, repo, refType, refFullName)
        }
 }
+
+// NotifyRepoPendingTransfer notifies creation of pending transfer to notifiers
+func NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) {
+       for _, notifier := range notifiers {
+               notifier.NotifyRepoPendingTransfer(doer, newOwner, repo)
+       }
+}
index 25ea4d91c643fef61de857781ad49f08bcaab81e..b1374f5608fd4c150211fe2e1a0e2dea8567bb97 100644 (file)
@@ -201,3 +201,9 @@ func (ns *notificationService) NotifyPullReviewRequest(doer *models.User, issue
                _ = ns.issueQueue.Push(opts)
        }
 }
+
+func (ns *notificationService) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) {
+       if err := models.CreateRepoTransferNotification(doer, newOwner, repo); err != nil {
+               log.Error("NotifyRepoPendingTransfer: %v", err)
+       }
+}
index 99f07b9050334cd90a91c5bc982e992b97aa9be3..4c4cc694be269c5fb2baafa8de80f061ef8d0d83 100644 (file)
@@ -735,6 +735,13 @@ delete_preexisting = Delete pre-existing files
 delete_preexisting_content = Delete files in %s
 delete_preexisting_success = Deleted unadopted files in %s
 
+transfer.accept = Accept Transfer
+transfer.accept_desc =  Transfer to "%s"
+transfer.reject = Reject Transfer
+transfer.reject_desc =  Cancel transfer to "%s"
+transfer.no_permission_to_accept = You do not have permission to Accept
+transfer.no_permission_to_reject = You do not have permission to Reject
+
 desc.private = Private
 desc.public = Public
 desc.private_template = Private template
@@ -1554,10 +1561,20 @@ settings.convert_fork_notices_1 = This operation will convert the fork into a re
 settings.convert_fork_confirm = Convert Repository
 settings.convert_fork_succeed = The fork has been converted into a regular repository.
 settings.transfer = Transfer Ownership
+settings.transfer.rejected = Repository transfer was rejected.
+settings.transfer.success = Repository transfer was successful.
+settings.transfer_abort = Cancel transfer
+settings.transfer_abort_invalid = You cannot cancel a non existent repository transfer.
+settings.transfer_abort_success = The repository transfer to %s was successfully cancelled.
 settings.transfer_desc = Transfer this repository to a user or to an organization for which you have administrator rights.
+settings.transfer_form_title = Enter the repository name as confirmation:
+settings.transfer_in_progress = There is currently an ongoing transfer. Please cancel it if you will like to transfer this repository to another user.
 settings.transfer_notices_1 = - You will lose access to the repository if you transfer it to an individual user.
 settings.transfer_notices_2 = - You will keep access to the repository if you transfer it to an organization that you (co-)own.
-settings.transfer_form_title = Enter the repository name as confirmation:
+settings.transfer_owner = New Owner
+settings.transfer_perform = Perform Transfer
+settings.transfer_started = This repository has been marked for transfer and awaits confirmation from "%s"
+settings.transfer_succeed = The repository has been transferred.
 settings.signing_settings = Signing Verification Settings
 settings.trust_model = Signature Trust Model
 settings.trust_model.default = Default Trust Model
@@ -1583,9 +1600,6 @@ settings.delete_notices_2 = - This operation will permanently delete the <strong
 settings.delete_notices_fork_1 = - Forks of this repository will become independent after deletion.
 settings.deletion_success = The repository has been deleted.
 settings.update_settings_success = The repository settings have been updated.
-settings.transfer_owner = New Owner
-settings.make_transfer = Perform Transfer
-settings.transfer_succeed = The repository has been transferred.
 settings.confirm_delete = Delete Repository
 settings.add_collaborator = Add Collaborator
 settings.add_collaborator_success = The collaborator has been added.
index 656ace032ed978d8bab679e838ca1640a2eeb979..2e052aa4ff23af91f2e9641a14d8d4c0068f717f 100644 (file)
@@ -96,17 +96,27 @@ func Transfer(ctx *context.APIContext) {
                }
        }
 
-       if err = repo_service.TransferOwnership(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil {
+       if err := repo_service.StartRepositoryTransfer(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil {
+               if models.IsErrRepoTransferInProgress(err) {
+                       ctx.Error(http.StatusConflict, "CreatePendingRepositoryTransfer", err)
+                       return
+               }
+
+               if models.IsErrRepoAlreadyExist(err) {
+                       ctx.Error(http.StatusUnprocessableEntity, "CreatePendingRepositoryTransfer", err)
+                       return
+               }
+
                ctx.InternalServerError(err)
                return
        }
 
-       newRepo, err := models.GetRepositoryByName(newOwner.ID, ctx.Repo.Repository.Name)
-       if err != nil {
-               ctx.InternalServerError(err)
+       if ctx.Repo.Repository.Status == models.RepositoryPendingTransfer {
+               log.Trace("Repository transfer initiated: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name)
+               ctx.JSON(http.StatusCreated, convert.ToRepo(ctx.Repo.Repository, models.AccessModeAdmin))
                return
        }
 
        log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name)
-       ctx.JSON(http.StatusAccepted, convert.ToRepo(newRepo, models.AccessModeAdmin))
+       ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, models.AccessModeAdmin))
 }
index a8cfb9ad7c9b05272719a50d3ab10462653c2c95..6fa566e7d6f43df2d894d1f21892a8e75c41133b 100644 (file)
@@ -6,6 +6,7 @@
 package repo
 
 import (
+       "errors"
        "fmt"
        "strings"
        "time"
@@ -274,6 +275,10 @@ func Action(ctx *context.Context) {
                err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, true)
        case "unstar":
                err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, false)
+       case "accept_transfer":
+               err = acceptOrRejectRepoTransfer(ctx, true)
+       case "reject_transfer":
+               err = acceptOrRejectRepoTransfer(ctx, false)
        case "desc": // FIXME: this is not used
                if !ctx.Repo.IsOwner() {
                        ctx.Error(404)
@@ -293,6 +298,36 @@ func Action(ctx *context.Context) {
        ctx.RedirectToFirst(ctx.Query("redirect_to"), ctx.Repo.RepoLink)
 }
 
+func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error {
+       repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository)
+       if err != nil {
+               return err
+       }
+
+       if err := repoTransfer.LoadAttributes(); err != nil {
+               return err
+       }
+
+       if !repoTransfer.CanUserAcceptTransfer(ctx.User) {
+               return errors.New("user does not have enough permissions")
+       }
+
+       if accept {
+               if err := repo_service.TransferOwnership(repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil {
+                       return err
+               }
+               ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success"))
+       } else {
+               if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil {
+                       return err
+               }
+               ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected"))
+       }
+
+       ctx.Redirect(ctx.Repo.Repository.HTMLURL())
+       return nil
+}
+
 // RedirectDownload return a file based on the following infos:
 func RedirectDownload(ctx *context.Context) {
        var (
index 3e22e8804e5d562174a7edf67de580c0c020dea0..b35828d7b1d2f173fc083d37aa7761d550848ca5 100644 (file)
@@ -477,18 +477,54 @@ func SettingsPost(ctx *context.Context) {
                        ctx.Repo.GitRepo.Close()
                        ctx.Repo.GitRepo = nil
                }
-               if err = repo_service.TransferOwnership(ctx.User, newOwner, repo, nil); err != nil {
+
+               if err := repo_service.StartRepositoryTransfer(ctx.User, newOwner, repo, nil); err != nil {
                        if models.IsErrRepoAlreadyExist(err) {
                                ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil)
+                       } else if models.IsErrRepoTransferInProgress(err) {
+                               ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil)
                        } else {
                                ctx.ServerError("TransferOwnership", err)
                        }
+
+                       return
+               }
+
+               log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner)
+               ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName()))
+               ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings")
+
+       case "cancel_transfer":
+               if !ctx.Repo.IsOwner() {
+                       ctx.Error(404)
+                       return
+               }
+
+               repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository)
+               if err != nil {
+                       if models.IsErrNoPendingTransfer(err) {
+                               ctx.Flash.Error("repo.settings.transfer_abort_invalid")
+                               ctx.Redirect(setting.AppSubURL + "/" + ctx.User.Name + "/" + repo.Name + "/settings")
+                       } else {
+                               ctx.ServerError("GetPendingRepositoryTransfer", err)
+                       }
+
+                       return
+               }
+
+               if err := repoTransfer.LoadAttributes(); err != nil {
+                       ctx.ServerError("LoadRecipient", err)
+                       return
+               }
+
+               if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil {
+                       ctx.ServerError("CancelRepositoryTransfer", err)
                        return
                }
 
-               log.Trace("Repository transferred: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner)
-               ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed"))
-               ctx.Redirect(setting.AppSubURL + "/" + newOwner.Name + "/" + repo.Name)
+               log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name)
+               ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name))
+               ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings")
 
        case "delete":
                if !ctx.Repo.IsOwner() {
index a5e3cbe3e432337fea3b1603ec2f0b53b632c460..39f16d183c3b1952a6dec73b44437efdad64b689 100644 (file)
@@ -586,6 +586,14 @@ func Home(ctx *context.Context) {
                        return
                }
 
+               if ctx.IsSigned {
+                       // Set repo notification-status read if unread
+                       if err := ctx.Repo.Repository.ReadBy(ctx.User.ID); err != nil {
+                               ctx.ServerError("ReadBy", err)
+                               return
+                       }
+               }
+
                var firstUnit *models.Unit
                for _, repoUnit := range ctx.Repo.Units {
                        if repoUnit.Type == models.UnitTypeCode {
index e87d34ab2952199add5e1ab8757e74d32ea34585..7d6214c742a5c7aebb7071326d4fbac39cee6628 100644 (file)
@@ -34,6 +34,8 @@ const (
 
        mailNotifyCollaborator base.TplName = "notify/collaborator"
 
+       mailRepoTransferNotify base.TplName = "notify/repo_transfer"
+
        // There's no actual limit for subject in RFC 5322
        mailMaxSubjectRunes = 256
 )
diff --git a/services/mailer/mail_repo.go b/services/mailer/mail_repo.go
new file mode 100644 (file)
index 0000000..b9d24f4
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright 2021 The Gitea 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 mailer
+
+import (
+       "bytes"
+       "fmt"
+
+       "code.gitea.io/gitea/models"
+)
+
+// SendRepoTransferNotifyMail triggers a notification e-mail when a pending repository transfer was created
+func SendRepoTransferNotifyMail(doer, newOwner *models.User, repo *models.Repository) error {
+       var (
+               emails      []string
+               destination string
+               content     bytes.Buffer
+       )
+
+       if newOwner.IsOrganization() {
+               users, err := models.GetUsersWhoCanCreateOrgRepo(newOwner.ID)
+               if err != nil {
+                       return err
+               }
+
+               for i := range users {
+                       emails = append(emails, users[i].Email)
+               }
+               destination = newOwner.DisplayName()
+       } else {
+               emails = []string{newOwner.Email}
+               destination = "you"
+       }
+
+       subject := fmt.Sprintf("%s would like to transfer \"%s\" to %s", doer.DisplayName(), repo.FullName(), destination)
+       data := map[string]interface{}{
+               "Doer":    doer,
+               "User":    repo.Owner,
+               "Repo":    repo.FullName(),
+               "Link":    repo.HTMLURL(),
+               "Subject": subject,
+
+               "Destination": destination,
+       }
+
+       if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil {
+               return err
+       }
+
+       msg := NewMessage(emails, subject, content.String())
+       msg.Info = fmt.Sprintf("UID: %d, repository pending transfer notification", newOwner.ID)
+
+       SendAsync(msg)
+       return nil
+}
index e2416cf8deb31112dda9c33c104ec54df6a293e3..ec769190bdb49ae117df908b8c265d33400c1c71 100644 (file)
@@ -70,3 +70,38 @@ func ChangeRepositoryName(doer *models.User, repo *models.Repository, newRepoNam
 
        return nil
 }
+
+// StartRepositoryTransfer transfer a repo from one owner to a new one.
+// it make repository into pending transfer state, if doer can not create repo for new owner.
+func StartRepositoryTransfer(doer, newOwner *models.User, repo *models.Repository, teams []*models.Team) error {
+       if err := models.TestRepositoryReadyForTransfer(repo.Status); err != nil {
+               return err
+       }
+
+       // Admin is always allowed to transfer || user transfer repo back to his account
+       if doer.IsAdmin || doer.ID == newOwner.ID {
+               return TransferOwnership(doer, newOwner, repo, teams)
+       }
+
+       // If new owner is an org and user can create repos he can transfer directly too
+       if newOwner.IsOrganization() {
+               allowed, err := models.CanCreateOrgRepo(newOwner.ID, doer.ID)
+               if err != nil {
+                       return err
+               }
+               if allowed {
+                       return TransferOwnership(doer, newOwner, repo, teams)
+               }
+       }
+
+       // Make repo as pending for transfer
+       repo.Status = models.RepositoryPendingTransfer
+       if err := models.CreatePendingRepositoryTransfer(doer, newOwner, repo.ID, teams); err != nil {
+               return err
+       }
+
+       // notify users who are able to accept / reject transfer
+       notification.NotifyRepoPendingTransfer(doer, newOwner, repo)
+
+       return nil
+}
diff --git a/templates/mail/notify/repo_transfer.tmpl b/templates/mail/notify/repo_transfer.tmpl
new file mode 100644 (file)
index 0000000..68ceded
--- /dev/null
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+       <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+       <title>{{.Subject}}</title>
+</head>
+
+<body>
+       <p>{{.Subject}}.
+               To accept or reject it visit <a href="{{.Link}}">{{.Repo}}</a> or just ignore it.
+       <p>
+               ---
+               <br>
+               <a href="{{.Link}}">View it on Gitea</a>.
+       </p>
+</body>
+</html>
index 31bcd5c48a8d774a7db8ab49857384fb258264d5..2f593567d5d75db9431854780ff0b0725d127f28 100644 (file)
                        </div>
                        {{if not .IsBeingCreated}}
                                <div class="repo-buttons">
+                                       {{if $.RepoTransfer}}
+                                               <form method="post" action="{{$.RepoLink}}/action/accept_transfer?redirect_to={{$.RepoLink}}">
+                                                       {{$.CsrfTokenHtml}}
+                                                       <div class="ui poping up" data-content="{{if $.CanUserAcceptTransfer}}{{$.i18n.Tr "repo.transfer.accept_desc" $.RepoTransfer.Recipient.DisplayName}}{{else}}{{$.i18n.Tr "repo.transfer.no_permission_to_accept"}}{{end}}" data-position="bottom center" data-variation="tiny">
+                                                               <button type="submit" class="ui button {{if $.CanUserAcceptTransfer}}green {{end}} ok inverted small"{{if not $.CanUserAcceptTransfer}} disabled{{end}}>
+                                                                       {{$.i18n.Tr "repo.transfer.accept"}}
+                                                               </button>
+                                                       </div>
+                                               </form>
+                                               <form method="post" action="{{$.RepoLink}}/action/reject_transfer?redirect_to={{$.RepoLink}}">
+                                                       {{$.CsrfTokenHtml}}
+                                                       <div class="ui poping up" data-content="{{if $.CanUserAcceptTransfer}}{{$.i18n.Tr "repo.transfer.reject_desc" $.RepoTransfer.Recipient.DisplayName}}{{else}}{{$.i18n.Tr "repo.transfer.no_permission_to_reject"}}{{end}}" data-position="bottom center" data-variation="tiny">
+                                                               <button type="submit" class="ui button {{if $.CanUserAcceptTransfer}}red {{end}}ok inverted small"{{if not $.CanUserAcceptTransfer}} disabled{{end}}>
+                                                                       {{$.i18n.Tr "repo.transfer.reject"}}
+                                                               </button>
+                                                       </div>
+                                               </form>
+                                       {{end}}
                                        <form method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}un{{end}}watch?redirect_to={{$.Link}}">
                                                {{$.CsrfTokenHtml}}
                                                <div class="ui labeled button{{if not $.IsSigned}} poping up{{end}}" tabindex="0"{{if not $.IsSigned}} data-content="{{$.i18n.Tr "repo.watch_guest_user" }}" data-position="top center" data-variation="tiny"{{end}}>
index e85451ac398781ca39eb1c023001a485285b9234..b69f90f9c5849f59fa81792f8d45a050d52a56b0 100644 (file)
                        {{end}}
                        <div class="item">
                                <div class="ui right">
-                                       <button class="ui basic red show-modal button" data-modal="#transfer-repo-modal">{{.i18n.Tr "repo.settings.transfer"}}</button>
+                                       {{if .RepoTransfer}}
+                                               <form class="ui form" action="{{.Link}}" method="post">
+                                                       {{.CsrfTokenHtml}}
+                                                       <input type="hidden" name="action" value="cancel_transfer">
+                                                       <button class="ui red button">{{.i18n.Tr "repo.settings.transfer_abort"}}</button>
+                                               </form>
+                                       {{ else }}
+                                               <button class="ui basic red show-modal button" data-modal="#transfer-repo-modal">{{.i18n.Tr "repo.settings.transfer"}}</button>
+                                       {{ end }}
                                </div>
                                <div>
                                        <h5>{{.i18n.Tr "repo.settings.transfer"}}</h5>
-                                       <p>{{.i18n.Tr "repo.settings.transfer_desc"}}</p>
+                                       {{if .RepoTransfer}}
+                                               <p>{{.i18n.Tr "repo.settings.transfer_started" .RepoTransfer.Recipient.DisplayName}}</p>
+                                       {{else}}
+                                               <p>{{.i18n.Tr "repo.settings.transfer_desc"}}</p>
+                                       {{end}}
                                </div>
                        </div>
 
 
                                <div class="text right actions">
                                        <div class="ui cancel button">{{.i18n.Tr "settings.cancel"}}</div>
-                                       <button class="ui red button">{{.i18n.Tr "repo.settings.make_transfer"}}</button>
+                                       <button class="ui red button">{{.i18n.Tr "repo.settings.transfer_perform"}}</button>
                                </div>
                        </form>
                </div>
index 2f25e733877d3a3df48a8b06192ec3b116cfea8b..2158dcb000f1f945151e6f0aa12112da1d076916 100644 (file)
@@ -39,6 +39,8 @@
                                 <td class="collapsing" data-href="{{.HTMLURL}}">
                                     {{if eq .Status 3}}
                                         <span class="blue">{{svg "octicon-pin"}}</span>
+                                    {{else if not $issue}}
+                                        <span class="gray">{{svg "octicon-repo"}}</span>
                                     {{else if $issue.IsPull}}
                                         {{if $issue.IsClosed}}
                                             {{if $issue.GetPullRequest.HasMerged}}
                                 </td>
                                 <td class="eleven wide" data-href="{{.HTMLURL}}">
                                     <a class="item" href="{{.HTMLURL}}">
-                                        #{{$issue.Index}} - {{$issue.Title}}
+                                        {{if $issue}}
+                                            #{{$issue.Index}} - {{$issue.Title}}
+                                        {{else}}
+                                            {{$repo.FullName}}
+                                        {{end}}
                                     </a>
                                 </td>
                                 <td data-href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}">