* 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 notificationstags/v1.15.0-dev
@@ -444,12 +444,22 @@ func TestAPIRepoTransfer(t *testing.T) { | |||||
teams *[]int64 | teams *[]int64 | ||||
expectedStatus int | 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}, | {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}, | {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)() | defer prepareTestEnv(t)() | ||||
@@ -757,6 +757,40 @@ func (err ErrRepoNotExist) Error() string { | |||||
err.ID, err.UID, err.OwnerName, err.Name) | 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. | // ErrRepoAlreadyExist represents a "RepoAlreadyExist" kind of error. | ||||
type ErrRepoAlreadyExist struct { | type ErrRepoAlreadyExist struct { | ||||
Uname string | Uname string | ||||
@@ -0,0 +1,7 @@ | |||||
- | |||||
id: 1 | |||||
doer_id: 3 | |||||
recipient_id: 1 | |||||
repo_id: 3 | |||||
created_unix: 1553610671 | |||||
updated_unix: 1553610671 |
@@ -563,7 +563,7 @@ func (issue *Issue) ReadBy(userID int64) error { | |||||
return err | return err | ||||
} | } | ||||
return setNotificationStatusReadIfUnread(x, userID, issue.ID) | |||||
return setIssueNotificationStatusReadIfUnread(x, userID, issue.ID) | |||||
} | } | ||||
func updateIssueCols(e Engine, issue *Issue, cols ...string) error { | func updateIssueCols(e Engine, issue *Issue, cols ...string) error { | ||||
@@ -294,6 +294,8 @@ var migrations = []Migration{ | |||||
NewMigration("Add sessions table for go-chi/session", addSessionTable), | NewMigration("Add sessions table for go-chi/session", addSessionTable), | ||||
// v173 -> v174 | // v173 -> v174 | ||||
NewMigration("Add time_id column to Comment", addTimeIDCommentColumn), | NewMigration("Add time_id column to Comment", addTimeIDCommentColumn), | ||||
// v174 -> v175 | |||||
NewMigration("create repo transfer table", addRepoTransfer), | |||||
} | } | ||||
// GetCurrentDBVersion returns the current db version | // GetCurrentDBVersion returns the current db version | ||||
@@ -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)) | |||||
} |
@@ -133,6 +133,7 @@ func init() { | |||||
new(ProjectBoard), | new(ProjectBoard), | ||||
new(ProjectIssue), | new(ProjectIssue), | ||||
new(Session), | new(Session), | ||||
new(RepoTransfer), | |||||
) | ) | ||||
gonicNames := []string{"SSL", "UID"} | gonicNames := []string{"SSL", "UID"} | ||||
@@ -39,6 +39,8 @@ const ( | |||||
NotificationSourcePullRequest | NotificationSourcePullRequest | ||||
// NotificationSourceCommit is a notification of a commit | // NotificationSourceCommit is a notification of a commit | ||||
NotificationSourceCommit | NotificationSourceCommit | ||||
// NotificationSourceRepository is a notification for a repository | |||||
NotificationSourceRepository | |||||
) | ) | ||||
// Notification represents a notification | // Notification represents a notification | ||||
@@ -119,6 +121,46 @@ func GetNotifications(opts FindNotificationOptions) (NotificationList, error) { | |||||
return getNotifications(x, opts) | 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 | // CreateOrUpdateIssueNotifications creates an issue notification | ||||
// for each watcher, or updates it if already exists | // for each watcher, or updates it if already exists | ||||
// receiverID > 0 just send to reciver, else send to all watcher | // 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) { | 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) | n.Issue, err = getIssueByID(e, n.IssueID) | ||||
if err != nil { | if err != nil { | ||||
return fmt.Errorf("getIssueByID [%d]: %v", n.IssueID, err) | 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) { | 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) | n.Comment, err = getCommentByID(e, n.CommentID) | ||||
if err != nil { | if err != nil { | ||||
return fmt.Errorf("GetCommentByID [%d] for issue ID [%d]: %v", n.CommentID, n.IssueID, err) | 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 | // HTMLURL formats a URL-string to the notification | ||||
func (n *Notification) HTMLURL() string { | 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 | // APIURL formats a URL-string to the notification | ||||
@@ -562,8 +612,10 @@ func (nl NotificationList) LoadIssues() ([]int, error) { | |||||
if notification.Issue == nil { | if notification.Issue == nil { | ||||
notification.Issue = issues[notification.IssueID] | notification.Issue = issues[notification.IssueID] | ||||
if notification.Issue == nil { | 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 | continue | ||||
} | } | ||||
notification.Issue.Repo = notification.Repository | 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) | 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) | notification, err := getIssueNotification(e, userID, issueID) | ||||
// ignore if not exists | // ignore if not exists | ||||
if err != nil { | if err != nil { | ||||
@@ -700,6 +752,16 @@ func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | |||||
return err | 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 | // SetNotificationStatus change the notification status | ||||
func SetNotificationStatus(notificationID int64, user *User, status NotificationStatus) error { | func SetNotificationStatus(notificationID int64, user *User, status NotificationStatus) error { | ||||
notification, err := getNotificationByID(x, notificationID) | notification, err := getNotificationByID(x, notificationID) | ||||
@@ -391,6 +391,20 @@ func CanCreateOrgRepo(orgID, uid int64) (bool, error) { | |||||
Exist(new(Team)) | 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) { | func getOrgsByUserID(sess *xorm.Session, userID int64, showAll bool) ([]*User, error) { | ||||
orgs := make([]*User, 0, 10) | orgs := make([]*User, 0, 10) | ||||
if !showAll { | if !showAll { | ||||
@@ -635,3 +635,21 @@ func TestHasOrgVisibleTypePrivate(t *testing.T) { | |||||
assert.Equal(t, test2, false) // user not a part of org | assert.Equal(t, test2, false) // user not a part of org | ||||
assert.Equal(t, test3, false) // logged out user | 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) | |||||
} |
@@ -139,8 +139,9 @@ type RepositoryStatus int | |||||
// all kinds of RepositoryStatus | // all kinds of RepositoryStatus | ||||
const ( | 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 | // 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))) | 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) { | func isRepositoryExist(e Engine, u *User, repoName string) (bool, error) { | ||||
has, err := e.Get(&Repository{ | has, err := e.Get(&Repository{ | ||||
OwnerID: u.ID, | OwnerID: u.ID, | ||||
@@ -1189,140 +1195,6 @@ func IncrementRepoForkNum(ctx DBContext, repoID int64) error { | |||||
return err | 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. | // ChangeRepositoryName changes all corresponding setting from old repository name to new one. | ||||
func ChangeRepositoryName(doer *User, repo *Repository, newRepoName string) (err error) { | func ChangeRepositoryName(doer *User, repo *Repository, newRepoName string) (err error) { | ||||
oldRepoName := repo.Name | oldRepoName := repo.Name | ||||
@@ -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() | |||||
} |
@@ -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)) | |||||
} |
@@ -600,6 +600,24 @@ func RepoAssignment() func(http.Handler) http.Handler { | |||||
ctx.Data["CanCompareOrPull"] = canCompare | ctx.Data["CanCompareOrPull"] = canCompare | ||||
ctx.Data["PullRequestCtx"] = ctx.Repo.PullRequest | 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" { | if ctx.Query("go-get") == "1" { | ||||
ctx.Data["GoGetImport"] = ComposeGoGetImport(owner.Name, repo.Name) | ctx.Data["GoGetImport"] = ComposeGoGetImport(owner.Name, repo.Name) | ||||
prefix := setting.AppURL + path.Join(owner.Name, repo.Name, "src", "branch", ctx.Repo.BranchName) | prefix := setting.AppURL + path.Join(owner.Name, repo.Name, "src", "branch", ctx.Repo.BranchName) | ||||
@@ -52,8 +52,14 @@ func ToNotificationThread(n *models.Notification) *api.NotificationThread { | |||||
result.Subject = &api.NotificationSubject{ | result.Subject = &api.NotificationSubject{ | ||||
Type: "Commit", | Type: "Commit", | ||||
Title: n.CommitID, | 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 | return result | ||||
@@ -57,4 +57,6 @@ type Notifier interface { | |||||
NotifySyncPushCommits(pusher *models.User, repo *models.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) | NotifySyncPushCommits(pusher *models.User, repo *models.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) | ||||
NotifySyncCreateRef(doer *models.User, repo *models.Repository, refType, refFullName string) | NotifySyncCreateRef(doer *models.User, repo *models.Repository, refType, refFullName string) | ||||
NotifySyncDeleteRef(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) | |||||
} | } |
@@ -166,3 +166,7 @@ func (*NullNotifier) NotifySyncCreateRef(doer *models.User, repo *models.Reposit | |||||
// NotifySyncDeleteRef places a place holder function | // NotifySyncDeleteRef places a place holder function | ||||
func (*NullNotifier) NotifySyncDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) { | 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) { | |||||
} |
@@ -170,3 +170,9 @@ func (m *mailNotifier) NotifyNewRelease(rel *models.Release) { | |||||
mailer.MailNewRelease(rel) | 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) | |||||
} | |||||
} |
@@ -290,3 +290,10 @@ func NotifySyncDeleteRef(pusher *models.User, repo *models.Repository, refType, | |||||
notifier.NotifySyncDeleteRef(pusher, repo, refType, refFullName) | 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) | |||||
} | |||||
} |
@@ -201,3 +201,9 @@ func (ns *notificationService) NotifyPullReviewRequest(doer *models.User, issue | |||||
_ = ns.issueQueue.Push(opts) | _ = 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) | |||||
} | |||||
} |
@@ -735,6 +735,13 @@ delete_preexisting = Delete pre-existing files | |||||
delete_preexisting_content = Delete files in %s | delete_preexisting_content = Delete files in %s | ||||
delete_preexisting_success = Deleted unadopted 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.private = Private | ||||
desc.public = Public | desc.public = Public | ||||
desc.private_template = Private template | 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_confirm = Convert Repository | ||||
settings.convert_fork_succeed = The fork has been converted into a regular repository. | settings.convert_fork_succeed = The fork has been converted into a regular repository. | ||||
settings.transfer = Transfer Ownership | 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_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_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_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.signing_settings = Signing Verification Settings | ||||
settings.trust_model = Signature Trust Model | settings.trust_model = Signature Trust Model | ||||
settings.trust_model.default = Default 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.delete_notices_fork_1 = - Forks of this repository will become independent after deletion. | ||||
settings.deletion_success = The repository has been deleted. | settings.deletion_success = The repository has been deleted. | ||||
settings.update_settings_success = The repository settings have been updated. | 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.confirm_delete = Delete Repository | ||||
settings.add_collaborator = Add Collaborator | settings.add_collaborator = Add Collaborator | ||||
settings.add_collaborator_success = The collaborator has been added. | settings.add_collaborator_success = The collaborator has been added. | ||||
@@ -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) | ctx.InternalServerError(err) | ||||
return | 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 | return | ||||
} | } | ||||
log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) | 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)) | |||||
} | } |
@@ -6,6 +6,7 @@ | |||||
package repo | package repo | ||||
import ( | import ( | ||||
"errors" | |||||
"fmt" | "fmt" | ||||
"strings" | "strings" | ||||
"time" | "time" | ||||
@@ -274,6 +275,10 @@ func Action(ctx *context.Context) { | |||||
err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, true) | err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, true) | ||||
case "unstar": | case "unstar": | ||||
err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, false) | 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 | case "desc": // FIXME: this is not used | ||||
if !ctx.Repo.IsOwner() { | if !ctx.Repo.IsOwner() { | ||||
ctx.Error(404) | ctx.Error(404) | ||||
@@ -293,6 +298,36 @@ func Action(ctx *context.Context) { | |||||
ctx.RedirectToFirst(ctx.Query("redirect_to"), ctx.Repo.RepoLink) | 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: | // RedirectDownload return a file based on the following infos: | ||||
func RedirectDownload(ctx *context.Context) { | func RedirectDownload(ctx *context.Context) { | ||||
var ( | var ( | ||||
@@ -477,18 +477,54 @@ func SettingsPost(ctx *context.Context) { | |||||
ctx.Repo.GitRepo.Close() | ctx.Repo.GitRepo.Close() | ||||
ctx.Repo.GitRepo = nil | 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) { | if models.IsErrRepoAlreadyExist(err) { | ||||
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) | 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 { | } else { | ||||
ctx.ServerError("TransferOwnership", err) | 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 | 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": | case "delete": | ||||
if !ctx.Repo.IsOwner() { | if !ctx.Repo.IsOwner() { | ||||
@@ -586,6 +586,14 @@ func Home(ctx *context.Context) { | |||||
return | 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 | var firstUnit *models.Unit | ||||
for _, repoUnit := range ctx.Repo.Units { | for _, repoUnit := range ctx.Repo.Units { | ||||
if repoUnit.Type == models.UnitTypeCode { | if repoUnit.Type == models.UnitTypeCode { | ||||
@@ -34,6 +34,8 @@ const ( | |||||
mailNotifyCollaborator base.TplName = "notify/collaborator" | mailNotifyCollaborator base.TplName = "notify/collaborator" | ||||
mailRepoTransferNotify base.TplName = "notify/repo_transfer" | |||||
// There's no actual limit for subject in RFC 5322 | // There's no actual limit for subject in RFC 5322 | ||||
mailMaxSubjectRunes = 256 | mailMaxSubjectRunes = 256 | ||||
) | ) | ||||
@@ -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 | |||||
} |
@@ -70,3 +70,38 @@ func ChangeRepositoryName(doer *models.User, repo *models.Repository, newRepoNam | |||||
return nil | 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 | |||||
} |
@@ -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> |
@@ -42,6 +42,24 @@ | |||||
</div> | </div> | ||||
{{if not .IsBeingCreated}} | {{if not .IsBeingCreated}} | ||||
<div class="repo-buttons"> | <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}}"> | <form method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}un{{end}}watch?redirect_to={{$.Link}}"> | ||||
{{$.CsrfTokenHtml}} | {{$.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}}> | <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}}> | ||||
@@ -444,11 +444,23 @@ | |||||
{{end}} | {{end}} | ||||
<div class="item"> | <div class="item"> | ||||
<div class="ui right"> | <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> | ||||
<div> | <div> | ||||
<h5>{{.i18n.Tr "repo.settings.transfer"}}</h5> | <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> | </div> | ||||
@@ -599,7 +611,7 @@ | |||||
<div class="text right actions"> | <div class="text right actions"> | ||||
<div class="ui cancel button">{{.i18n.Tr "settings.cancel"}}</div> | <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> | </div> | ||||
</form> | </form> | ||||
</div> | </div> | ||||
@@ -39,6 +39,8 @@ | |||||
<td class="collapsing" data-href="{{.HTMLURL}}"> | <td class="collapsing" data-href="{{.HTMLURL}}"> | ||||
{{if eq .Status 3}} | {{if eq .Status 3}} | ||||
<span class="blue">{{svg "octicon-pin"}}</span> | <span class="blue">{{svg "octicon-pin"}}</span> | ||||
{{else if not $issue}} | |||||
<span class="gray">{{svg "octicon-repo"}}</span> | |||||
{{else if $issue.IsPull}} | {{else if $issue.IsPull}} | ||||
{{if $issue.IsClosed}} | {{if $issue.IsClosed}} | ||||
{{if $issue.GetPullRequest.HasMerged}} | {{if $issue.GetPullRequest.HasMerged}} | ||||
@@ -59,7 +61,11 @@ | |||||
</td> | </td> | ||||
<td class="eleven wide" data-href="{{.HTMLURL}}"> | <td class="eleven wide" data-href="{{.HTMLURL}}"> | ||||
<a class="item" href="{{.HTMLURL}}"> | <a class="item" href="{{.HTMLURL}}"> | ||||
#{{$issue.Index}} - {{$issue.Title}} | |||||
{{if $issue}} | |||||
#{{$issue.Index}} - {{$issue.Title}} | |||||
{{else}} | |||||
{{$repo.FullName}} | |||||
{{end}} | |||||
</a> | </a> | ||||
</td> | </td> | ||||
<td data-href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}"> | <td data-href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}"> | ||||