diff --git a/go.sum b/go.sum index 552be8a7d0da5..bd54d51f31ae7 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,7 @@ github.com/RoaringBitmap/roaring v0.4.21 h1:WJ/zIlNX4wQZ9x8Ey33O1UaD9TCTakYsdLFS github.com/RoaringBitmap/roaring v0.4.21/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/Unknwon/com v0.0.0-20190321035513-0fed4efef755 h1:1B7wb36fHLSwZfHg6ngZhhtIEHQjiC5H4p7qQGBEffg= github.com/Unknwon/com v0.0.0-20190321035513-0fed4efef755/go.mod h1:voKvFVpXBJxdIPeqjoJuLK+UVcRlo/JLjeToGxPYu68= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= diff --git a/integrations/api_repo_test.go b/integrations/api_repo_test.go index ff59fac5cbdf7..2b886e8e08194 100644 --- a/integrations/api_repo_test.go +++ b/integrations/api_repo_test.go @@ -400,12 +400,17 @@ func TestAPIRepoTransfer(t *testing.T) { teams *[]int64 expectedStatus int }{ + // 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: "user2", teams: nil, expectedStatus: http.StatusAccepted}, - {ctxUserID: 2, newOwner: "user1", teams: nil, expectedStatus: http.StatusAccepted}, + // Transfer already started.. Cannot start transfer to another + // user again + {ctxUserID: 1, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusConflict}, + // User does not have access to repo {ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusForbidden}, - {ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity}, - {ctxUserID: 1, newOwner: "user3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden}, - {ctxUserID: 1, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted}, } defer prepareTestEnv(t)() diff --git a/models/error.go b/models/error.go index f54df3733047f..64fa7bc857c03 100644 --- a/models/error.go +++ b/models/error.go @@ -705,6 +705,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 index 0000000000000..5d8a5bd79c069 --- /dev/null +++ b/models/fixtures/repo_transfer.yml @@ -0,0 +1,8 @@ +- + id: 1 + user_id: 3 + recipient_id: 1 + status: 1 + repo_id: 3 + created_unix: 1553610671 + updated_unix: 1553610671 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 847cd75d521f6..4a0289b2985f2 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -204,6 +204,8 @@ var migrations = []Migration{ NewMigration("Refix merge base for merged pull requests", refixMergeBase), // v135 -> 136 NewMigration("Add OrgID column to Labels table", addOrgIDLabelColumn), + // V136 -> 137 + NewMigration("create repo transfer table", addRepoTransfer), } // Migrate database to current version diff --git a/models/migrations/v136.go b/models/migrations/v136.go new file mode 100644 index 0000000000000..6d89b8fbb04f0 --- /dev/null +++ b/models/migrations/v136.go @@ -0,0 +1,26 @@ +// Copyright 2020 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 ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +func addRepoTransfer(x *xorm.Engine) error { + type RepoTransfer struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 + RecipientID int64 + RepoID int64 + CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL updated"` + Status bool + TeamIDs []int64 + } + + return x.Sync(new(RepoTransfer)) +} diff --git a/models/models.go b/models/models.go index c818c651007b4..d0dceef519f42 100644 --- a/models/models.go +++ b/models/models.go @@ -125,6 +125,7 @@ func init() { new(Task), new(LanguageStat), new(EmailHash), + new(RepoTransfer), ) gonicNames := []string{"SSL", "UID"} diff --git a/models/notification.go b/models/notification.go index 0cee8616cadf5..c4db2c15d3ba7 100644 --- a/models/notification.go +++ b/models/notification.go @@ -40,6 +40,9 @@ const ( NotificationSourcePullRequest // NotificationSourceCommit is a notification of a commit NotificationSourceCommit + // NotificationSourceRepoTransfer is a notification for a repository + // transfer + NotificationSourceRepoTransfer ) // Notification represents a notification @@ -116,6 +119,29 @@ func GetNotifications(opts FindNotificationOptions) (NotificationList, error) { return getNotifications(x, opts) } +// CreateRepoTransferNotification creates notification for the user a repository was transferred to +func CreateRepoTransferNotification(doerID, recipientID int64, repo *Repository) error { + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + notification := &Notification{ + UserID: recipientID, + RepoID: repo.ID, + Status: NotificationStatusUnread, + UpdatedBy: doerID, + Source: NotificationSourceRepoTransfer, + } + + if _, err := sess.Insert(notification); err != nil { + return err + } + + return sess.Commit() +} + // CreateOrUpdateIssueNotifications creates an issue notification // for each watcher, or updates it if already exists func CreateOrUpdateIssueNotifications(issueID, commentID int64, notificationAuthorID int64) error { diff --git a/models/repo.go b/models/repo.go index 74b5a021f46dd..4bce32faf178c 100644 --- a/models/repo.go +++ b/models/repo.go @@ -1117,135 +1117,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(&SearchTeamOptions{}); 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 com.IsExist(wikiPath) { - 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(DBContext{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_test.go b/models/repo_test.go index 20da43fbbfc91..b3f5ef9d4ba27 100644 --- a/models/repo_test.go +++ b/models/repo_test.go @@ -169,6 +169,42 @@ func TestUploadBigAvatar(t *testing.T) { assert.Error(t, err) } +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.Error(t, err) + assert.Nil(t, transfer) + assert.True(t, IsErrNoPendingTransfer(err)) + + user2 := AssertExistsAndLoadBean(t, &User{ID: 2}).(*User) + + assert.NoError(t, StartRepositoryTransfer(doer, user2, repo, 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 = StartRepositoryTransfer(doer, user6, repo, nil) + assert.Error(t, err) + assert.True(t, IsErrRepoTransferInProgress(err)) + + // Unknown user + err = StartRepositoryTransfer(doer, &User{ID: 1000, LowerName: "user1000"}, repo, nil) + assert.Error(t, err) + + // Cancel transfer + assert.NoError(t, CancelRepositoryTransfer(transfer)) +} + func TestDeleteAvatar(t *testing.T) { // Generate image diff --git a/models/repo_transfer.go b/models/repo_transfer.go new file mode 100644 index 0000000000000..41f938b7adb47 --- /dev/null +++ b/models/repo_transfer.go @@ -0,0 +1,328 @@ +// Copyright 2019 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/timeutil" + "xorm.io/xorm" + + "github.com/unknwon/com" +) + +// TransferStatus determines the current state of a transfer +type TransferStatus uint8 + +const ( + // Pending is the default repo transfer state. All initiated transfers + // automatically get this status. + Pending TransferStatus = iota + // Rejected is a status for transfers that get cancelled by either the + // recipient or the user who initiated the transfer + Rejected + // Accepted is a repo transfer state for repository transfers that have + // been acknowledged by the recipient + Accepted +) + +// RepoTransfer is used to manage repository transfers +type RepoTransfer struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 + User *User `xorm:"-"` + RecipientID int64 + Recipient *User `xorm:"-"` + RepoID int64 + CreatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX NOT NULL updated"` + Status TransferStatus + TeamIDs []int64 + Teams []*Team `xorm:"-"` +} + +// LoadAttributes fetches the transfer recipient from the database +func (r *RepoTransfer) LoadAttributes() error { + if r.Recipient != nil && r.User != nil && (len(r.TeamIDs) > 0 && len(r.Teams) == len(r.TeamIDs)) { + return nil + } + + 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.User == nil { + u, err := GetUserByID(r.UserID) + if err != nil { + return err + } + + r.User = u + } + + return nil +} + +// IsTransferForUser checks if the user has the rights to accept/decline a repo +// transfer. +// For organizations, this check if the user is a member of the owners team +func (r *RepoTransfer) IsTransferForUser(u *User) bool { + if err := r.LoadAttributes(); err != nil { + return false + } + + if !r.Recipient.IsOrganization() { + return r.RecipientID == u.ID + } + + t, err := r.Recipient.getOwnerTeam(x) + if err != nil { + return false + } + + if err := t.GetMembers(&SearchMembersOptions{}); err != nil { + return false + } + + for k := range t.Members { + if t.Members[k].ID == u.ID { + return true + } + } + + return false +} + +// 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("status = ? AND repo_id = ? ", Pending, repo.ID). + Get(transfer) + if err != nil { + return nil, err + } + + if transfer.ID == 0 || !has { + return nil, ErrNoPendingRepoTransfer{RepoID: repo.ID} + } + + return transfer, nil +} + +func acceptRepositoryTransfer(sess *xorm.Session, repo *Repository) error { + _, err := sess.Where("repo_id = ?", repo.ID).Cols("status").Update(&RepoTransfer{ + Status: Accepted, + }) + return err +} + +// CancelRepositoryTransfer makes sure to set the transfer process as +// "rejected". Thus ending the transfer process +func CancelRepositoryTransfer(repoTransfer *RepoTransfer) error { + repoTransfer.Status = Rejected + repoTransfer.UpdatedUnix = timeutil.TimeStampNow() + _, err := x.ID(repoTransfer.ID).Cols("updated_unix", "status"). + Update(repoTransfer) + return err +} + +// StartRepositoryTransfer marks the repository transfer as "pending". It +// doesn't actually transfer the repository until the user acks the transfer. +func StartRepositoryTransfer(doer, newOwner *User, repo *Repository, teams []*Team) error { + // Make sure the repo isn't being transferred to someone currently + // Only one transfer process can be initiated at a time. + // It has to be cancelled for a new one to occur + n, err := x.Where("status = ? AND repo_id = ?", Pending, repo.ID). + Count(new(RepoTransfer)) + if err != nil { + return err + } + + if n > 0 { + return ErrRepoTransferInProgress{newOwner.LowerName, repo.Name} + } + + // 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{newOwner.LowerName, repo.Name} + } + + transfer := &RepoTransfer{ + RepoID: repo.ID, + RecipientID: newOwner.ID, + Status: Pending, + CreatedUnix: timeutil.TimeStampNow(), + UpdatedUnix: timeutil.TimeStampNow(), + UserID: doer.ID, + TeamIDs: []int64{}, + } + + for k := range teams { + transfer.TeamIDs = append(transfer.TeamIDs, teams[k].ID) + } + + _, err = x.Insert(transfer) + 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(&SearchTeamOptions{}); 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 com.IsExist(wikiPath) { + if err = os.Rename(wikiPath, WikiPath(newOwner.Name, repo.Name)); err != nil { + return fmt.Errorf("rename repository wiki: %v", err) + } + } + + if err := acceptRepositoryTransfer(sess, repo); err != nil { + return fmt.Errorf("accept repository transfer: %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(DBContext{sess}, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil { + return fmt.Errorf("NewRepoRedirect: %v", err) + } + + return sess.Commit() +} diff --git a/modules/context/repo.go b/modules/context/repo.go index 1f6e5037cc6d1..a2c0420a157d0 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -567,6 +567,22 @@ func RepoAssignment() macaron.Handler { } ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) + if err == nil { + if err := repoTransfer.LoadAttributes(); err != nil { + ctx.ServerError("LoadRecipient", err) + return + } + + ctx.Data["RepoTransfer"] = repoTransfer + ctx.Data["IsRepoTransferInProgress"] = true + } + + if err != nil && !models.IsErrNoPendingTransfer(err) { + ctx.ServerError("GetPendingRepositoryTransfer", err) + return + } + 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) diff --git a/modules/notification/base/null.go b/modules/notification/base/null.go index f6c423b4694c4..e4e0f5d6f2c3d 100644 --- a/modules/notification/base/null.go +++ b/modules/notification/base/null.go @@ -21,6 +21,9 @@ var ( func (*NullNotifier) Run() { } +// NotifyTransferRepository places a place holder function +func (*NullNotifier) NotifyTransferRepository(doer *models.User, repo *models.Repository, _ string) {} + // NotifyCreateIssueComment places a place holder function func (*NullNotifier) NotifyCreateIssueComment(doer *models.User, repo *models.Repository, issue *models.Issue, comment *models.Comment) { @@ -131,10 +134,6 @@ func (*NullNotifier) NotifyDeleteRef(doer *models.User, repo *models.Repository, func (*NullNotifier) NotifyRenameRepository(doer *models.User, repo *models.Repository, oldRepoName string) { } -// NotifyTransferRepository places a place holder function -func (*NullNotifier) NotifyTransferRepository(doer *models.User, repo *models.Repository, oldOwnerName string) { -} - // NotifySyncPushCommits places a place holder function func (*NullNotifier) NotifySyncPushCommits(pusher *models.User, repo *models.Repository, refName, oldCommitID, newCommitID string, commits *repository.PushCommits) { } diff --git a/modules/notification/ui/ui.go b/modules/notification/ui/ui.go index 525753425a4ab..9f9f50d58a18b 100644 --- a/modules/notification/ui/ui.go +++ b/modules/notification/ui/ui.go @@ -26,7 +26,7 @@ type ( ) var ( - _ base.Notifier = ¬ificationService{} + _ base.Notifier = (*notificationService)(nil) ) // NewNotifier create a new notificationService notifier diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ed6d74d35a6cd..bd5cc2ca490d7 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -285,6 +285,7 @@ activate_email = Verify your email address reset_password = Recover your account register_success = Registration successful register_notify = Welcome to Gitea +repo_transfer_notify = A repository has been transferred to you [modal] yes = Yes @@ -663,6 +664,7 @@ template.invalid = Must select a template repository archive.title = This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests. archive.issue.nocomment = This repo is archived. You cannot comment on issues. archive.pull.nocomment = This repo is archived. You cannot comment on pull requests. +archive.repo_transfer = This repo is archived. You cannot transfer this repo until it is un-archived form.reach_limit_of_creation = You have already reached your limit of %d repositories. form.name_reserved = The repository name '%s' is reserved. @@ -1319,6 +1321,8 @@ settings.transfer_desc = Transfer this repository to a user or to an organizatio 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.success = Repository transfer was successful. +settings.transfer.rejected = Repository transfer was rejected. settings.wiki_delete = Delete Wiki Data settings.wiki_delete_desc = Deleting repository wiki data is permanent and cannot be undone. settings.wiki_delete_notices_1 = - This will permanently delete and disable the repository wiki for %s. @@ -1333,7 +1337,12 @@ 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.abort_transfer = Cancel transfer +settings.abort_transfer_invalid = You cannot cancel a non existent repository transfer. +settings.abort_transfer_success = The repository transfer to %s was successfully cancelled. settings.transfer_succeed = The repository has been transferred. +settings.transfer_started = This repository has been marked for transfer and awaits confirmation from "%s" +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.confirm_delete = Delete Repository settings.add_collaborator = Add Collaborator settings.add_collaborator_success = The collaborator has been added. diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go index 847028d1067d5..46907cac99537 100644 --- a/routers/api/v1/repo/transfer.go +++ b/routers/api/v1/repo/transfer.go @@ -61,6 +61,7 @@ func Transfer(ctx *context.APIContext, opts api.TransferRepoOption) { } var teams []*models.Team + if opts.TeamIDs != nil { if !newOwner.IsOrganization() { ctx.Error(http.StatusUnprocessableEntity, "repoTransfer", "Teams can only be added to organization-owned repositories") @@ -84,17 +85,16 @@ func Transfer(ctx *context.APIContext, opts api.TransferRepoOption) { } } - if err = repo_service.TransferOwnership(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil { - ctx.InternalServerError(err) - return - } + if err = repo_service.StartRepositoryTransfer(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil { + if models.IsErrRepoTransferInProgress(err) { + ctx.Error(http.StatusConflict, "StartRepositoryTransfer", err) + return + } - newRepo, err := models.GetRepositoryByName(newOwner.ID, ctx.Repo.Repository.Name) - if err != nil { ctx.InternalServerError(err) return } - log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) - ctx.JSON(http.StatusAccepted, newRepo.APIFormat(models.AccessModeAdmin)) + log.Trace("Repository transfer initiated: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) + ctx.JSON(http.StatusAccepted, ctx.Repo.Repository.APIFormat(models.AccessModeAdmin)) } diff --git a/routers/repo/repo.go b/routers/repo/repo.go index 3f135dd216d0a..b74ba118da91b 100644 --- a/routers/repo/repo.go +++ b/routers/repo/repo.go @@ -6,6 +6,7 @@ package repo import ( + "errors" "fmt" "net/url" "os" @@ -394,15 +395,65 @@ 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 "desc": // FIXME: this is not used - if !ctx.Repo.IsOwner() { - ctx.Error(404) + case "accept_transfer": + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) + if err != nil { + if models.IsErrNoPendingTransfer(err) { + ctx.Redirect(ctx.Repo.Repository.HTMLURL()) + } else { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + } + return + } + + if err := repoTransfer.LoadAttributes(); err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) return } - ctx.Repo.Repository.Description = ctx.Query("desc") - ctx.Repo.Repository.Website = ctx.Query("site") - err = models.UpdateRepository(ctx.Repo.Repository, false) + if !repoTransfer.IsTransferForUser(ctx.User) { + ctx.NotFound("IsTransferForUser", errors.New("user does not have enough permissions")) + return + } + + if err := repo_service.TransferOwnership(repoTransfer.User, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) + ctx.Redirect(ctx.Repo.Repository.HTMLURL()) + return + + case "decline_transfer": + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) + if err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + + if err := repoTransfer.LoadAttributes(); err != nil { + ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.Params(":action")), err) + return + } + + if !repoTransfer.IsTransferForUser(ctx.User) { + ctx.NotFound("IsTransferForUser", errors.New("user does not have enough permissions")) + return + } + + if err := models.CancelRepositoryTransfer(repoTransfer); err != nil { + if models.IsErrNoPendingTransfer(err) { + ctx.Redirect(ctx.Repo.Repository.HTMLURL()) + } else { + ctx.ServerError("CancelRepositoryTransfer", err) + } + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) + ctx.Redirect(setting.AppSubURL + "/") + return } if err != nil { diff --git a/routers/repo/setting.go b/routers/repo/setting.go index 7a2db88c1f422..04bbac24ba609 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -364,6 +364,13 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) { ctx.Error(404) return } + + if ctx.Repo.Repository.IsArchived { + ctx.Flash.Error(ctx.Tr("repo.archive.repo_transfer")) + ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings") + return + } + if repo.Name != form.RepoName { ctx.RenderWithErr(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) return @@ -384,18 +391,59 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) { 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 + } + + if setting.Service.EnableNotifyMail { + mailer.SendRepoTransferNotifyMail(ctx.Locale, ctx.Repo.Owner, ctx.Repo.Repository) + } + + 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.LowerName)) + 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.abort_transfer_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(repoTransfer); 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.abort_transfer_success", repoTransfer.Recipient.Name)) + ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings") case "delete": if !ctx.Repo.IsOwner() { diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 4409830dfe2e8..fd7b0b8aebf4a 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -703,7 +703,8 @@ func RegisterRoutes(m *macaron.Macaron) { }) }, reqSignIn, context.RepoAssignment(), context.UnitTypes(), reqRepoAdmin, context.RepoRef()) - m.Post("/:username/:reponame/action/:action", reqSignIn, context.RepoAssignment(), context.UnitTypes(), repo.Action) + m.Get("/:username/:reponame/action/:action", reqSignIn, context.RepoAssignment(), + context.UnitTypes(), context.RepoMustNotBeArchived(), repo.Action) m.Group("/:username/:reponame", func() { m.Group("/issues", func() { diff --git a/services/mailer/mail.go b/services/mailer/mail.go index 3241ae728d880..d8392fbe7586d 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -33,6 +33,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 index 0000000000000..afd5f037487ca --- /dev/null +++ b/services/mailer/mail_repo.go @@ -0,0 +1,55 @@ +// Copyright 2020 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" + "code.gitea.io/gitea/modules/log" +) + +// SendRepoTransferNotifyMail triggers a notification e-mail when a repository +// transfer is initiated +func SendRepoTransferNotifyMail(locale Locale, u *models.User, repo *models.Repository) { + data := map[string]interface{}{ + "Subject": locale.Tr("mail.repo_transfer_notify"), + "RepoName": repo.FullName(), + "Link": repo.HTMLURL(), + "AcceptTransferLink": repo.HTMLURL() + "/action/accept_transfer", + "DeclineTransferLink": repo.HTMLURL() + "/action/decline_transfer", + } + + var content bytes.Buffer + + if err := bodyTemplates.ExecuteTemplate(&content, string(mailRepoTransferNotify), data); err != nil { + log.Error("Template: %v", err) + return + } + + var email = u.Email + + if u.IsOrganization() && u.Email == "" { + t, err := u.GetOwnerTeam() + if err != nil { + log.Error("Could not retrieve owners team for organization", err) + return + } + + if err := t.GetMembers(&models.SearchMembersOptions{}); err != nil { + log.Error("Could not retrieve members of the owners team", err) + return + } + + // Just use the email address of the first user + email = t.Members[0].Email + } + + msg := NewMessage([]string{email}, locale.Tr("mail.repo_transfer_notify"), content.String()) + msg.Info = fmt.Sprintf("UID: %d, repository transfer notification", u.ID) + + SendAsync(msg) +} diff --git a/services/repository/transfer.go b/services/repository/transfer.go index d34c812b86973..c097c95c07191 100644 --- a/services/repository/transfer.go +++ b/services/repository/transfer.go @@ -72,3 +72,8 @@ func ChangeRepositoryName(doer *models.User, repo *models.Repository, newRepoNam return nil } + +// StartRepositoryTransfer marks the repository transfer as "pending". +func StartRepositoryTransfer(doer, newOwner *models.User, repo *models.Repository, teams []*models.Team) error { + return models.StartRepositoryTransfer(doer, newOwner, repo, teams) +} diff --git a/templates/mail/notify/repo_transfer.tmpl b/templates/mail/notify/repo_transfer.tmpl new file mode 100644 index 0000000000000..038afe2377823 --- /dev/null +++ b/templates/mail/notify/repo_transfer.tmpl @@ -0,0 +1,18 @@ + + + + + {{.Subject}} + + + +

You have been invited as an owner of the repository, {{.RepoName}}. + Please click here to accept the invitation or + here to reject the invitation

+

+ --- +
+ View it on Gitea. +

+ + diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index c674fcf7f962e..8f1903e258a8d 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -372,11 +372,23 @@ {{end}}
+ {{ if .IsRepoTransferInProgress }} +
+ {{.CsrfTokenHtml}} + + +
+ {{ else }} + {{ end }}
{{.i18n.Tr "repo.settings.transfer"}}
+ {{if .IsRepoTransferInProgress }} +

{{.i18n.Tr "repo.settings.transfer_started" .RepoTransfer.Recipient.Name}}

+ {{else}}

{{.i18n.Tr "repo.settings.transfer_desc"}}

+ {{end}}
diff --git a/templates/user/notification/notification.tmpl b/templates/user/notification/notification.tmpl index c4f744a291738..bc264fde1b408 100644 --- a/templates/user/notification/notification.tmpl +++ b/templates/user/notification/notification.tmpl @@ -61,9 +61,17 @@ {{end}} + {{ if eq $notification.Source 4}} + + {{ $.i18n.Tr "mail.repo_transfer_notify"}} + + + {{ else }} + #{{$issue.Index}} - {{$issue.Title}} + {{ end }}