* 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 | |||
expectedStatus int | |||
}{ | |||
{ctxUserID: 1, newOwner: "user2", teams: nil, expectedStatus: http.StatusAccepted}, | |||
{ctxUserID: 2, newOwner: "user1", teams: nil, expectedStatus: http.StatusAccepted}, | |||
{ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusForbidden}, | |||
{ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity}, | |||
// Disclaimer for test story: "user1" is an admin, "user2" is normal user and part of in owner team of org "user3" | |||
// Transfer to a user with teams in another org should fail | |||
{ctxUserID: 1, newOwner: "user3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden}, | |||
// Transfer to a user with non-existent team IDs should fail | |||
{ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity}, | |||
// Transfer should go through | |||
{ctxUserID: 1, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted}, | |||
// Let user transfer it back to himself | |||
{ctxUserID: 2, newOwner: "user2", expectedStatus: http.StatusAccepted}, | |||
// And revert transfer | |||
{ctxUserID: 2, newOwner: "user3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted}, | |||
// Cannot start transfer to an existing repo | |||
{ctxUserID: 2, newOwner: "user3", teams: nil, expectedStatus: http.StatusUnprocessableEntity}, | |||
// Start transfer, repo is now in pending transfer mode | |||
{ctxUserID: 2, newOwner: "user6", teams: nil, expectedStatus: http.StatusCreated}, | |||
} | |||
defer prepareTestEnv(t)() | |||
@@ -757,6 +757,40 @@ func (err ErrRepoNotExist) Error() string { | |||
err.ID, err.UID, err.OwnerName, err.Name) | |||
} | |||
// ErrNoPendingRepoTransfer is an error type for repositories without a pending | |||
// transfer request | |||
type ErrNoPendingRepoTransfer struct { | |||
RepoID int64 | |||
} | |||
func (e ErrNoPendingRepoTransfer) Error() string { | |||
return fmt.Sprintf("repository doesn't have a pending transfer [repo_id: %d]", e.RepoID) | |||
} | |||
// IsErrNoPendingTransfer is an error type when a repository has no pending | |||
// transfers | |||
func IsErrNoPendingTransfer(err error) bool { | |||
_, ok := err.(ErrNoPendingRepoTransfer) | |||
return ok | |||
} | |||
// ErrRepoTransferInProgress represents the state of a repository that has an | |||
// ongoing transfer | |||
type ErrRepoTransferInProgress struct { | |||
Uname string | |||
Name string | |||
} | |||
// IsErrRepoTransferInProgress checks if an error is a ErrRepoTransferInProgress. | |||
func IsErrRepoTransferInProgress(err error) bool { | |||
_, ok := err.(ErrRepoTransferInProgress) | |||
return ok | |||
} | |||
func (err ErrRepoTransferInProgress) Error() string { | |||
return fmt.Sprintf("repository is already being transferred [uname: %s, name: %s]", err.Uname, err.Name) | |||
} | |||
// ErrRepoAlreadyExist represents a "RepoAlreadyExist" kind of error. | |||
type ErrRepoAlreadyExist struct { | |||
Uname string | |||
@@ -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 setNotificationStatusReadIfUnread(x, userID, issue.ID) | |||
return setIssueNotificationStatusReadIfUnread(x, userID, issue.ID) | |||
} | |||
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), | |||
// v173 -> v174 | |||
NewMigration("Add time_id column to Comment", addTimeIDCommentColumn), | |||
// v174 -> v175 | |||
NewMigration("create repo transfer table", addRepoTransfer), | |||
} | |||
// 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(ProjectIssue), | |||
new(Session), | |||
new(RepoTransfer), | |||
) | |||
gonicNames := []string{"SSL", "UID"} | |||
@@ -39,6 +39,8 @@ const ( | |||
NotificationSourcePullRequest | |||
// NotificationSourceCommit is a notification of a commit | |||
NotificationSourceCommit | |||
// NotificationSourceRepository is a notification for a repository | |||
NotificationSourceRepository | |||
) | |||
// Notification represents a notification | |||
@@ -119,6 +121,46 @@ func GetNotifications(opts FindNotificationOptions) (NotificationList, error) { | |||
return getNotifications(x, opts) | |||
} | |||
// CreateRepoTransferNotification creates notification for the user a repository was transferred to | |||
func CreateRepoTransferNotification(doer, newOwner *User, repo *Repository) error { | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err := sess.Begin(); err != nil { | |||
return err | |||
} | |||
var notify []*Notification | |||
if newOwner.IsOrganization() { | |||
users, err := getUsersWhoCanCreateOrgRepo(sess, newOwner.ID) | |||
if err != nil || len(users) == 0 { | |||
return err | |||
} | |||
for i := range users { | |||
notify = append(notify, &Notification{ | |||
UserID: users[i].ID, | |||
RepoID: repo.ID, | |||
Status: NotificationStatusUnread, | |||
UpdatedBy: doer.ID, | |||
Source: NotificationSourceRepository, | |||
}) | |||
} | |||
} else { | |||
notify = []*Notification{{ | |||
UserID: newOwner.ID, | |||
RepoID: repo.ID, | |||
Status: NotificationStatusUnread, | |||
UpdatedBy: doer.ID, | |||
Source: NotificationSourceRepository, | |||
}} | |||
} | |||
if _, err := sess.InsertMulti(notify); err != nil { | |||
return err | |||
} | |||
return sess.Commit() | |||
} | |||
// CreateOrUpdateIssueNotifications creates an issue notification | |||
// for each watcher, or updates it if already exists | |||
// receiverID > 0 just send to reciver, else send to all watcher | |||
@@ -363,7 +405,7 @@ func (n *Notification) loadRepo(e Engine) (err error) { | |||
} | |||
func (n *Notification) loadIssue(e Engine) (err error) { | |||
if n.Issue == nil { | |||
if n.Issue == nil && n.IssueID != 0 { | |||
n.Issue, err = getIssueByID(e, n.IssueID) | |||
if err != nil { | |||
return fmt.Errorf("getIssueByID [%d]: %v", n.IssueID, err) | |||
@@ -374,7 +416,7 @@ func (n *Notification) loadIssue(e Engine) (err error) { | |||
} | |||
func (n *Notification) loadComment(e Engine) (err error) { | |||
if n.Comment == nil && n.CommentID > 0 { | |||
if n.Comment == nil && n.CommentID != 0 { | |||
n.Comment, err = getCommentByID(e, n.CommentID) | |||
if err != nil { | |||
return fmt.Errorf("GetCommentByID [%d] for issue ID [%d]: %v", n.CommentID, n.IssueID, err) | |||
@@ -405,10 +447,18 @@ func (n *Notification) GetIssue() (*Issue, error) { | |||
// HTMLURL formats a URL-string to the notification | |||
func (n *Notification) HTMLURL() string { | |||
if n.Comment != nil { | |||
return n.Comment.HTMLURL() | |||
switch n.Source { | |||
case NotificationSourceIssue, NotificationSourcePullRequest: | |||
if n.Comment != nil { | |||
return n.Comment.HTMLURL() | |||
} | |||
return n.Issue.HTMLURL() | |||
case NotificationSourceCommit: | |||
return n.Repository.HTMLURL() + "/commit/" + n.CommitID | |||
case NotificationSourceRepository: | |||
return n.Repository.HTMLURL() | |||
} | |||
return n.Issue.HTMLURL() | |||
return "" | |||
} | |||
// APIURL formats a URL-string to the notification | |||
@@ -562,8 +612,10 @@ func (nl NotificationList) LoadIssues() ([]int, error) { | |||
if notification.Issue == nil { | |||
notification.Issue = issues[notification.IssueID] | |||
if notification.Issue == nil { | |||
log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID) | |||
failures = append(failures, i) | |||
if notification.IssueID != 0 { | |||
log.Error("Notification[%d]: IssueID: %d Not Found", notification.ID, notification.IssueID) | |||
failures = append(failures, i) | |||
} | |||
continue | |||
} | |||
notification.Issue.Repo = notification.Repository | |||
@@ -683,7 +735,7 @@ func GetUIDsAndNotificationCounts(since, until timeutil.TimeStamp) ([]UserIDCoun | |||
return res, x.SQL(sql, since, until, NotificationStatusUnread).Find(&res) | |||
} | |||
func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | |||
func setIssueNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | |||
notification, err := getIssueNotification(e, userID, issueID) | |||
// ignore if not exists | |||
if err != nil { | |||
@@ -700,6 +752,16 @@ func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error { | |||
return err | |||
} | |||
func setRepoNotificationStatusReadIfUnread(e Engine, userID, repoID int64) error { | |||
_, err := e.Where(builder.Eq{ | |||
"user_id": userID, | |||
"status": NotificationStatusUnread, | |||
"source": NotificationSourceRepository, | |||
"repo_id": repoID, | |||
}).Cols("status").Update(&Notification{Status: NotificationStatusRead}) | |||
return err | |||
} | |||
// SetNotificationStatus change the notification status | |||
func SetNotificationStatus(notificationID int64, user *User, status NotificationStatus) error { | |||
notification, err := getNotificationByID(x, notificationID) | |||
@@ -391,6 +391,20 @@ func CanCreateOrgRepo(orgID, uid int64) (bool, error) { | |||
Exist(new(Team)) | |||
} | |||
// GetUsersWhoCanCreateOrgRepo returns users which are able to create repo in organization | |||
func GetUsersWhoCanCreateOrgRepo(orgID int64) ([]*User, error) { | |||
return getUsersWhoCanCreateOrgRepo(x, orgID) | |||
} | |||
func getUsersWhoCanCreateOrgRepo(e Engine, orgID int64) ([]*User, error) { | |||
users := make([]*User, 0, 10) | |||
return users, x. | |||
Join("INNER", "`team_user`", "`team_user`.uid=`user`.id"). | |||
Join("INNER", "`team`", "`team`.id=`team_user`.team_id"). | |||
Where(builder.Eq{"team.can_create_org_repo": true}.Or(builder.Eq{"team.authorize": AccessModeOwner})). | |||
And("team_user.org_id = ?", orgID).Asc("`user`.name").Find(&users) | |||
} | |||
func getOrgsByUserID(sess *xorm.Session, userID int64, showAll bool) ([]*User, error) { | |||
orgs := make([]*User, 0, 10) | |||
if !showAll { | |||
@@ -635,3 +635,21 @@ func TestHasOrgVisibleTypePrivate(t *testing.T) { | |||
assert.Equal(t, test2, false) // user not a part of org | |||
assert.Equal(t, test3, false) // logged out user | |||
} | |||
func TestGetUsersWhoCanCreateOrgRepo(t *testing.T) { | |||
assert.NoError(t, PrepareTestDatabase()) | |||
users, err := GetUsersWhoCanCreateOrgRepo(3) | |||
assert.NoError(t, err) | |||
assert.Len(t, users, 2) | |||
var ids []int64 | |||
for i := range users { | |||
ids = append(ids, users[i].ID) | |||
} | |||
assert.ElementsMatch(t, ids, []int64{2, 28}) | |||
users, err = GetUsersWhoCanCreateOrgRepo(7) | |||
assert.NoError(t, err) | |||
assert.Len(t, users, 1) | |||
assert.EqualValues(t, 5, users[0].ID) | |||
} |
@@ -139,8 +139,9 @@ type RepositoryStatus int | |||
// all kinds of RepositoryStatus | |||
const ( | |||
RepositoryReady RepositoryStatus = iota // a normal repository | |||
RepositoryBeingMigrated // repository is migrating | |||
RepositoryReady RepositoryStatus = iota // a normal repository | |||
RepositoryBeingMigrated // repository is migrating | |||
RepositoryPendingTransfer // repository pending in ownership transfer state | |||
) | |||
// TrustModelType defines the types of trust model for this repository | |||
@@ -872,6 +873,11 @@ func (repo *Repository) DescriptionHTML() template.HTML { | |||
return template.HTML(markup.Sanitize(string(desc))) | |||
} | |||
// ReadBy sets repo to be visited by given user. | |||
func (repo *Repository) ReadBy(userID int64) error { | |||
return setRepoNotificationStatusReadIfUnread(x, userID, repo.ID) | |||
} | |||
func isRepositoryExist(e Engine, u *User, repoName string) (bool, error) { | |||
has, err := e.Get(&Repository{ | |||
OwnerID: u.ID, | |||
@@ -1189,140 +1195,6 @@ func IncrementRepoForkNum(ctx DBContext, repoID int64) error { | |||
return err | |||
} | |||
// TransferOwnership transfers all corresponding setting from old user to new one. | |||
func TransferOwnership(doer *User, newOwnerName string, repo *Repository) error { | |||
newOwner, err := GetUserByName(newOwnerName) | |||
if err != nil { | |||
return fmt.Errorf("get new owner '%s': %v", newOwnerName, err) | |||
} | |||
// Check if new owner has repository with same name. | |||
has, err := IsRepositoryExist(newOwner, repo.Name) | |||
if err != nil { | |||
return fmt.Errorf("IsRepositoryExist: %v", err) | |||
} else if has { | |||
return ErrRepoAlreadyExist{newOwnerName, repo.Name} | |||
} | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err = sess.Begin(); err != nil { | |||
return fmt.Errorf("sess.Begin: %v", err) | |||
} | |||
oldOwner := repo.Owner | |||
// Note: we have to set value here to make sure recalculate accesses is based on | |||
// new owner. | |||
repo.OwnerID = newOwner.ID | |||
repo.Owner = newOwner | |||
repo.OwnerName = newOwner.Name | |||
// Update repository. | |||
if _, err := sess.ID(repo.ID).Update(repo); err != nil { | |||
return fmt.Errorf("update owner: %v", err) | |||
} | |||
// Remove redundant collaborators. | |||
collaborators, err := repo.getCollaborators(sess, ListOptions{}) | |||
if err != nil { | |||
return fmt.Errorf("getCollaborators: %v", err) | |||
} | |||
// Dummy object. | |||
collaboration := &Collaboration{RepoID: repo.ID} | |||
for _, c := range collaborators { | |||
if c.ID != newOwner.ID { | |||
isMember, err := isOrganizationMember(sess, newOwner.ID, c.ID) | |||
if err != nil { | |||
return fmt.Errorf("IsOrgMember: %v", err) | |||
} else if !isMember { | |||
continue | |||
} | |||
} | |||
collaboration.UserID = c.ID | |||
if _, err = sess.Delete(collaboration); err != nil { | |||
return fmt.Errorf("remove collaborator '%d': %v", c.ID, err) | |||
} | |||
} | |||
// Remove old team-repository relations. | |||
if oldOwner.IsOrganization() { | |||
if err = oldOwner.removeOrgRepo(sess, repo.ID); err != nil { | |||
return fmt.Errorf("removeOrgRepo: %v", err) | |||
} | |||
} | |||
if newOwner.IsOrganization() { | |||
if err := newOwner.getTeams(sess); err != nil { | |||
return fmt.Errorf("GetTeams: %v", err) | |||
} | |||
for _, t := range newOwner.Teams { | |||
if t.IncludesAllRepositories { | |||
if err := t.addRepository(sess, repo); err != nil { | |||
return fmt.Errorf("addRepository: %v", err) | |||
} | |||
} | |||
} | |||
} else if err = repo.recalculateAccesses(sess); err != nil { | |||
// Organization called this in addRepository method. | |||
return fmt.Errorf("recalculateAccesses: %v", err) | |||
} | |||
// Update repository count. | |||
if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos+1 WHERE id=?", newOwner.ID); err != nil { | |||
return fmt.Errorf("increase new owner repository count: %v", err) | |||
} else if _, err = sess.Exec("UPDATE `user` SET num_repos=num_repos-1 WHERE id=?", oldOwner.ID); err != nil { | |||
return fmt.Errorf("decrease old owner repository count: %v", err) | |||
} | |||
if err = watchRepo(sess, doer.ID, repo.ID, true); err != nil { | |||
return fmt.Errorf("watchRepo: %v", err) | |||
} | |||
// Remove watch for organization. | |||
if oldOwner.IsOrganization() { | |||
if err = watchRepo(sess, oldOwner.ID, repo.ID, false); err != nil { | |||
return fmt.Errorf("watchRepo [false]: %v", err) | |||
} | |||
} | |||
// Rename remote repository to new path and delete local copy. | |||
dir := UserPath(newOwner.Name) | |||
if err := os.MkdirAll(dir, os.ModePerm); err != nil { | |||
return fmt.Errorf("Failed to create dir %s: %v", dir, err) | |||
} | |||
if err = os.Rename(RepoPath(oldOwner.Name, repo.Name), RepoPath(newOwner.Name, repo.Name)); err != nil { | |||
return fmt.Errorf("rename repository directory: %v", err) | |||
} | |||
// Rename remote wiki repository to new path and delete local copy. | |||
wikiPath := WikiPath(oldOwner.Name, repo.Name) | |||
isExist, err := util.IsExist(wikiPath) | |||
if err != nil { | |||
log.Error("Unable to check if %s exists. Error: %v", wikiPath, err) | |||
return err | |||
} | |||
if isExist { | |||
if err = os.Rename(wikiPath, WikiPath(newOwner.Name, repo.Name)); err != nil { | |||
return fmt.Errorf("rename repository wiki: %v", err) | |||
} | |||
} | |||
// If there was previously a redirect at this location, remove it. | |||
if err = deleteRepoRedirect(sess, newOwner.ID, repo.Name); err != nil { | |||
return fmt.Errorf("delete repo redirect: %v", err) | |||
} | |||
if err := newRepoRedirect(sess, oldOwner.ID, repo.ID, repo.Name, repo.Name); err != nil { | |||
return fmt.Errorf("newRepoRedirect: %v", err) | |||
} | |||
return sess.Commit() | |||
} | |||
// ChangeRepositoryName changes all corresponding setting from old repository name to new one. | |||
func ChangeRepositoryName(doer *User, repo *Repository, newRepoName string) (err error) { | |||
oldRepoName := repo.Name | |||
@@ -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["PullRequestCtx"] = ctx.Repo.PullRequest | |||
if ctx.Repo.Repository.Status == models.RepositoryPendingTransfer { | |||
repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) | |||
if err != nil { | |||
ctx.ServerError("GetPendingRepositoryTransfer", err) | |||
return | |||
} | |||
if err := repoTransfer.LoadAttributes(); err != nil { | |||
ctx.ServerError("LoadRecipient", err) | |||
return | |||
} | |||
ctx.Data["RepoTransfer"] = repoTransfer | |||
if ctx.User != nil { | |||
ctx.Data["CanUserAcceptTransfer"] = repoTransfer.CanUserAcceptTransfer(ctx.User) | |||
} | |||
} | |||
if ctx.Query("go-get") == "1" { | |||
ctx.Data["GoGetImport"] = ComposeGoGetImport(owner.Name, repo.Name) | |||
prefix := setting.AppURL + path.Join(owner.Name, repo.Name, "src", "branch", ctx.Repo.BranchName) | |||
@@ -52,8 +52,14 @@ func ToNotificationThread(n *models.Notification) *api.NotificationThread { | |||
result.Subject = &api.NotificationSubject{ | |||
Type: "Commit", | |||
Title: n.CommitID, | |||
URL: n.Repository.HTMLURL() + "/commit/" + n.CommitID, | |||
} | |||
case models.NotificationSourceRepository: | |||
result.Subject = &api.NotificationSubject{ | |||
Type: "Repository", | |||
Title: n.Repository.FullName(), | |||
URL: n.Repository.Link(), | |||
} | |||
//unused until now | |||
} | |||
return result | |||
@@ -57,4 +57,6 @@ type Notifier interface { | |||
NotifySyncPushCommits(pusher *models.User, repo *models.Repository, opts *repository.PushUpdateOptions, commits *repository.PushCommits) | |||
NotifySyncCreateRef(doer *models.User, repo *models.Repository, refType, refFullName string) | |||
NotifySyncDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) | |||
NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) | |||
} |
@@ -166,3 +166,7 @@ func (*NullNotifier) NotifySyncCreateRef(doer *models.User, repo *models.Reposit | |||
// NotifySyncDeleteRef places a place holder function | |||
func (*NullNotifier) NotifySyncDeleteRef(doer *models.User, repo *models.Repository, refType, refFullName string) { | |||
} | |||
// NotifyRepoPendingTransfer places a place holder function | |||
func (*NullNotifier) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | |||
} |
@@ -170,3 +170,9 @@ func (m *mailNotifier) NotifyNewRelease(rel *models.Release) { | |||
mailer.MailNewRelease(rel) | |||
} | |||
func (m *mailNotifier) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | |||
if err := mailer.SendRepoTransferNotifyMail(doer, newOwner, repo); err != nil { | |||
log.Error("NotifyRepoPendingTransfer: %v", err) | |||
} | |||
} |
@@ -290,3 +290,10 @@ func NotifySyncDeleteRef(pusher *models.User, repo *models.Repository, refType, | |||
notifier.NotifySyncDeleteRef(pusher, repo, refType, refFullName) | |||
} | |||
} | |||
// NotifyRepoPendingTransfer notifies creation of pending transfer to notifiers | |||
func NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | |||
for _, notifier := range notifiers { | |||
notifier.NotifyRepoPendingTransfer(doer, newOwner, repo) | |||
} | |||
} |
@@ -201,3 +201,9 @@ func (ns *notificationService) NotifyPullReviewRequest(doer *models.User, issue | |||
_ = ns.issueQueue.Push(opts) | |||
} | |||
} | |||
func (ns *notificationService) NotifyRepoPendingTransfer(doer, newOwner *models.User, repo *models.Repository) { | |||
if err := models.CreateRepoTransferNotification(doer, newOwner, repo); err != nil { | |||
log.Error("NotifyRepoPendingTransfer: %v", err) | |||
} | |||
} |
@@ -735,6 +735,13 @@ delete_preexisting = Delete pre-existing files | |||
delete_preexisting_content = Delete files in %s | |||
delete_preexisting_success = Deleted unadopted files in %s | |||
transfer.accept = Accept Transfer | |||
transfer.accept_desc = Transfer to "%s" | |||
transfer.reject = Reject Transfer | |||
transfer.reject_desc = Cancel transfer to "%s" | |||
transfer.no_permission_to_accept = You do not have permission to Accept | |||
transfer.no_permission_to_reject = You do not have permission to Reject | |||
desc.private = Private | |||
desc.public = Public | |||
desc.private_template = Private template | |||
@@ -1554,10 +1561,20 @@ settings.convert_fork_notices_1 = This operation will convert the fork into a re | |||
settings.convert_fork_confirm = Convert Repository | |||
settings.convert_fork_succeed = The fork has been converted into a regular repository. | |||
settings.transfer = Transfer Ownership | |||
settings.transfer.rejected = Repository transfer was rejected. | |||
settings.transfer.success = Repository transfer was successful. | |||
settings.transfer_abort = Cancel transfer | |||
settings.transfer_abort_invalid = You cannot cancel a non existent repository transfer. | |||
settings.transfer_abort_success = The repository transfer to %s was successfully cancelled. | |||
settings.transfer_desc = Transfer this repository to a user or to an organization for which you have administrator rights. | |||
settings.transfer_form_title = Enter the repository name as confirmation: | |||
settings.transfer_in_progress = There is currently an ongoing transfer. Please cancel it if you will like to transfer this repository to another user. | |||
settings.transfer_notices_1 = - You will lose access to the repository if you transfer it to an individual user. | |||
settings.transfer_notices_2 = - You will keep access to the repository if you transfer it to an organization that you (co-)own. | |||
settings.transfer_form_title = Enter the repository name as confirmation: | |||
settings.transfer_owner = New Owner | |||
settings.transfer_perform = Perform Transfer | |||
settings.transfer_started = This repository has been marked for transfer and awaits confirmation from "%s" | |||
settings.transfer_succeed = The repository has been transferred. | |||
settings.signing_settings = Signing Verification Settings | |||
settings.trust_model = Signature Trust Model | |||
settings.trust_model.default = Default Trust Model | |||
@@ -1583,9 +1600,6 @@ settings.delete_notices_2 = - This operation will permanently delete the <strong | |||
settings.delete_notices_fork_1 = - Forks of this repository will become independent after deletion. | |||
settings.deletion_success = The repository has been deleted. | |||
settings.update_settings_success = The repository settings have been updated. | |||
settings.transfer_owner = New Owner | |||
settings.make_transfer = Perform Transfer | |||
settings.transfer_succeed = The repository has been transferred. | |||
settings.confirm_delete = Delete Repository | |||
settings.add_collaborator = Add Collaborator | |||
settings.add_collaborator_success = The collaborator has been added. | |||
@@ -96,17 +96,27 @@ func Transfer(ctx *context.APIContext) { | |||
} | |||
} | |||
if err = repo_service.TransferOwnership(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil { | |||
if err := repo_service.StartRepositoryTransfer(ctx.User, newOwner, ctx.Repo.Repository, teams); err != nil { | |||
if models.IsErrRepoTransferInProgress(err) { | |||
ctx.Error(http.StatusConflict, "CreatePendingRepositoryTransfer", err) | |||
return | |||
} | |||
if models.IsErrRepoAlreadyExist(err) { | |||
ctx.Error(http.StatusUnprocessableEntity, "CreatePendingRepositoryTransfer", err) | |||
return | |||
} | |||
ctx.InternalServerError(err) | |||
return | |||
} | |||
newRepo, err := models.GetRepositoryByName(newOwner.ID, ctx.Repo.Repository.Name) | |||
if err != nil { | |||
ctx.InternalServerError(err) | |||
if ctx.Repo.Repository.Status == models.RepositoryPendingTransfer { | |||
log.Trace("Repository transfer initiated: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) | |||
ctx.JSON(http.StatusCreated, convert.ToRepo(ctx.Repo.Repository, models.AccessModeAdmin)) | |||
return | |||
} | |||
log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) | |||
ctx.JSON(http.StatusAccepted, convert.ToRepo(newRepo, models.AccessModeAdmin)) | |||
ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, models.AccessModeAdmin)) | |||
} |
@@ -6,6 +6,7 @@ | |||
package repo | |||
import ( | |||
"errors" | |||
"fmt" | |||
"strings" | |||
"time" | |||
@@ -274,6 +275,10 @@ func Action(ctx *context.Context) { | |||
err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, true) | |||
case "unstar": | |||
err = models.StarRepo(ctx.User.ID, ctx.Repo.Repository.ID, false) | |||
case "accept_transfer": | |||
err = acceptOrRejectRepoTransfer(ctx, true) | |||
case "reject_transfer": | |||
err = acceptOrRejectRepoTransfer(ctx, false) | |||
case "desc": // FIXME: this is not used | |||
if !ctx.Repo.IsOwner() { | |||
ctx.Error(404) | |||
@@ -293,6 +298,36 @@ func Action(ctx *context.Context) { | |||
ctx.RedirectToFirst(ctx.Query("redirect_to"), ctx.Repo.RepoLink) | |||
} | |||
func acceptOrRejectRepoTransfer(ctx *context.Context, accept bool) error { | |||
repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) | |||
if err != nil { | |||
return err | |||
} | |||
if err := repoTransfer.LoadAttributes(); err != nil { | |||
return err | |||
} | |||
if !repoTransfer.CanUserAcceptTransfer(ctx.User) { | |||
return errors.New("user does not have enough permissions") | |||
} | |||
if accept { | |||
if err := repo_service.TransferOwnership(repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams); err != nil { | |||
return err | |||
} | |||
ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) | |||
} else { | |||
if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { | |||
return err | |||
} | |||
ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) | |||
} | |||
ctx.Redirect(ctx.Repo.Repository.HTMLURL()) | |||
return nil | |||
} | |||
// RedirectDownload return a file based on the following infos: | |||
func RedirectDownload(ctx *context.Context) { | |||
var ( | |||
@@ -477,18 +477,54 @@ func SettingsPost(ctx *context.Context) { | |||
ctx.Repo.GitRepo.Close() | |||
ctx.Repo.GitRepo = nil | |||
} | |||
if err = repo_service.TransferOwnership(ctx.User, newOwner, repo, nil); err != nil { | |||
if err := repo_service.StartRepositoryTransfer(ctx.User, newOwner, repo, nil); err != nil { | |||
if models.IsErrRepoAlreadyExist(err) { | |||
ctx.RenderWithErr(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) | |||
} else if models.IsErrRepoTransferInProgress(err) { | |||
ctx.RenderWithErr(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) | |||
} else { | |||
ctx.ServerError("TransferOwnership", err) | |||
} | |||
return | |||
} | |||
log.Trace("Repository transfer process was started: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) | |||
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_started", newOwner.DisplayName())) | |||
ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings") | |||
case "cancel_transfer": | |||
if !ctx.Repo.IsOwner() { | |||
ctx.Error(404) | |||
return | |||
} | |||
repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) | |||
if err != nil { | |||
if models.IsErrNoPendingTransfer(err) { | |||
ctx.Flash.Error("repo.settings.transfer_abort_invalid") | |||
ctx.Redirect(setting.AppSubURL + "/" + ctx.User.Name + "/" + repo.Name + "/settings") | |||
} else { | |||
ctx.ServerError("GetPendingRepositoryTransfer", err) | |||
} | |||
return | |||
} | |||
if err := repoTransfer.LoadAttributes(); err != nil { | |||
ctx.ServerError("LoadRecipient", err) | |||
return | |||
} | |||
if err := models.CancelRepositoryTransfer(ctx.Repo.Repository); err != nil { | |||
ctx.ServerError("CancelRepositoryTransfer", err) | |||
return | |||
} | |||
log.Trace("Repository transferred: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newOwner) | |||
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) | |||
ctx.Redirect(setting.AppSubURL + "/" + newOwner.Name + "/" + repo.Name) | |||
log.Trace("Repository transfer process was cancelled: %s/%s ", ctx.Repo.Owner.Name, repo.Name) | |||
ctx.Flash.Success(ctx.Tr("repo.settings.transfer_abort_success", repoTransfer.Recipient.Name)) | |||
ctx.Redirect(setting.AppSubURL + "/" + ctx.Repo.Owner.Name + "/" + repo.Name + "/settings") | |||
case "delete": | |||
if !ctx.Repo.IsOwner() { | |||
@@ -586,6 +586,14 @@ func Home(ctx *context.Context) { | |||
return | |||
} | |||
if ctx.IsSigned { | |||
// Set repo notification-status read if unread | |||
if err := ctx.Repo.Repository.ReadBy(ctx.User.ID); err != nil { | |||
ctx.ServerError("ReadBy", err) | |||
return | |||
} | |||
} | |||
var firstUnit *models.Unit | |||
for _, repoUnit := range ctx.Repo.Units { | |||
if repoUnit.Type == models.UnitTypeCode { | |||
@@ -34,6 +34,8 @@ const ( | |||
mailNotifyCollaborator base.TplName = "notify/collaborator" | |||
mailRepoTransferNotify base.TplName = "notify/repo_transfer" | |||
// There's no actual limit for subject in RFC 5322 | |||
mailMaxSubjectRunes = 256 | |||
) | |||
@@ -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 | |||
} | |||
// 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> | |||
{{if not .IsBeingCreated}} | |||
<div class="repo-buttons"> | |||
{{if $.RepoTransfer}} | |||
<form method="post" action="{{$.RepoLink}}/action/accept_transfer?redirect_to={{$.RepoLink}}"> | |||
{{$.CsrfTokenHtml}} | |||
<div class="ui poping up" data-content="{{if $.CanUserAcceptTransfer}}{{$.i18n.Tr "repo.transfer.accept_desc" $.RepoTransfer.Recipient.DisplayName}}{{else}}{{$.i18n.Tr "repo.transfer.no_permission_to_accept"}}{{end}}" data-position="bottom center" data-variation="tiny"> | |||
<button type="submit" class="ui button {{if $.CanUserAcceptTransfer}}green {{end}} ok inverted small"{{if not $.CanUserAcceptTransfer}} disabled{{end}}> | |||
{{$.i18n.Tr "repo.transfer.accept"}} | |||
</button> | |||
</div> | |||
</form> | |||
<form method="post" action="{{$.RepoLink}}/action/reject_transfer?redirect_to={{$.RepoLink}}"> | |||
{{$.CsrfTokenHtml}} | |||
<div class="ui poping up" data-content="{{if $.CanUserAcceptTransfer}}{{$.i18n.Tr "repo.transfer.reject_desc" $.RepoTransfer.Recipient.DisplayName}}{{else}}{{$.i18n.Tr "repo.transfer.no_permission_to_reject"}}{{end}}" data-position="bottom center" data-variation="tiny"> | |||
<button type="submit" class="ui button {{if $.CanUserAcceptTransfer}}red {{end}}ok inverted small"{{if not $.CanUserAcceptTransfer}} disabled{{end}}> | |||
{{$.i18n.Tr "repo.transfer.reject"}} | |||
</button> | |||
</div> | |||
</form> | |||
{{end}} | |||
<form method="post" action="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}un{{end}}watch?redirect_to={{$.Link}}"> | |||
{{$.CsrfTokenHtml}} | |||
<div class="ui labeled button{{if not $.IsSigned}} poping up{{end}}" tabindex="0"{{if not $.IsSigned}} data-content="{{$.i18n.Tr "repo.watch_guest_user" }}" data-position="top center" data-variation="tiny"{{end}}> | |||
@@ -444,11 +444,23 @@ | |||
{{end}} | |||
<div class="item"> | |||
<div class="ui right"> | |||
<button class="ui basic red show-modal button" data-modal="#transfer-repo-modal">{{.i18n.Tr "repo.settings.transfer"}}</button> | |||
{{if .RepoTransfer}} | |||
<form class="ui form" action="{{.Link}}" method="post"> | |||
{{.CsrfTokenHtml}} | |||
<input type="hidden" name="action" value="cancel_transfer"> | |||
<button class="ui red button">{{.i18n.Tr "repo.settings.transfer_abort"}}</button> | |||
</form> | |||
{{ else }} | |||
<button class="ui basic red show-modal button" data-modal="#transfer-repo-modal">{{.i18n.Tr "repo.settings.transfer"}}</button> | |||
{{ end }} | |||
</div> | |||
<div> | |||
<h5>{{.i18n.Tr "repo.settings.transfer"}}</h5> | |||
<p>{{.i18n.Tr "repo.settings.transfer_desc"}}</p> | |||
{{if .RepoTransfer}} | |||
<p>{{.i18n.Tr "repo.settings.transfer_started" .RepoTransfer.Recipient.DisplayName}}</p> | |||
{{else}} | |||
<p>{{.i18n.Tr "repo.settings.transfer_desc"}}</p> | |||
{{end}} | |||
</div> | |||
</div> | |||
@@ -599,7 +611,7 @@ | |||
<div class="text right actions"> | |||
<div class="ui cancel button">{{.i18n.Tr "settings.cancel"}}</div> | |||
<button class="ui red button">{{.i18n.Tr "repo.settings.make_transfer"}}</button> | |||
<button class="ui red button">{{.i18n.Tr "repo.settings.transfer_perform"}}</button> | |||
</div> | |||
</form> | |||
</div> | |||
@@ -39,6 +39,8 @@ | |||
<td class="collapsing" data-href="{{.HTMLURL}}"> | |||
{{if eq .Status 3}} | |||
<span class="blue">{{svg "octicon-pin"}}</span> | |||
{{else if not $issue}} | |||
<span class="gray">{{svg "octicon-repo"}}</span> | |||
{{else if $issue.IsPull}} | |||
{{if $issue.IsClosed}} | |||
{{if $issue.GetPullRequest.HasMerged}} | |||
@@ -59,7 +61,11 @@ | |||
</td> | |||
<td class="eleven wide" data-href="{{.HTMLURL}}"> | |||
<a class="item" href="{{.HTMLURL}}"> | |||
#{{$issue.Index}} - {{$issue.Title}} | |||
{{if $issue}} | |||
#{{$issue.Index}} - {{$issue.Title}} | |||
{{else}} | |||
{{$repo.FullName}} | |||
{{end}} | |||
</a> | |||
</td> | |||
<td data-href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}"> | |||