From 7cc44491fae1e5f00616a5b8d8da2934a8a619f8 Mon Sep 17 00:00:00 2001 From: qwerty287 <80460567+qwerty287@users.noreply.github.com> Date: Fri, 24 Dec 2021 05:26:52 +0100 Subject: [PATCH] Add API to manage repo tranfers (#17963) --- integrations/api_repo_test.go | 79 +++++++++++++++++++++++++ modules/convert/repository.go | 30 ++++++++++ modules/structs/repo.go | 8 +++ routers/api/v1/api.go | 2 + routers/api/v1/repo/transfer.go | 102 ++++++++++++++++++++++++++++++++ templates/swagger/v1_json.tmpl | 101 +++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+) diff --git a/integrations/api_repo_test.go b/integrations/api_repo_test.go index 1e443362f..c909e96f0 100644 --- a/integrations/api_repo_test.go +++ b/integrations/api_repo_test.go @@ -498,6 +498,85 @@ func TestAPIRepoTransfer(t *testing.T) { _ = models.DeleteRepository(user, repo.OwnerID, repo.ID) } +func transfer(t *testing.T) *repo_model.Repository { + //create repo to move + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session) + repoName := "moveME" + apiRepo := new(api.Repository) + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/repos?token=%s", token), &api.CreateRepoOption{ + Name: repoName, + Description: "repo move around", + Private: false, + Readme: "Default", + AutoInit: true, + }) + + resp := session.MakeRequest(t, req, http.StatusCreated) + DecodeJSON(t, resp, apiRepo) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID}).(*repo_model.Repository) + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer?token=%s", repo.OwnerName, repo.Name, token), &api.TransferRepoOption{ + NewOwner: "user4", + }) + session.MakeRequest(t, req, http.StatusCreated) + + return repo +} + +func TestAPIAcceptTransfer(t *testing.T) { + defer prepareTestEnv(t)() + + repo := transfer(t) + + // try to accept with not authorized user + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session) + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token)) + session.MakeRequest(t, req, http.StatusForbidden) + + // try to accept repo that's not marked as transferred + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept?token=%s", "user2", "repo1", token)) + session.MakeRequest(t, req, http.StatusNotFound) + + // accept transfer + session = loginUser(t, "user4") + token = getTokenForLoggedInUser(t, session) + + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept?token=%s", repo.OwnerName, repo.Name, token)) + resp := session.MakeRequest(t, req, http.StatusAccepted) + apiRepo := new(api.Repository) + DecodeJSON(t, resp, apiRepo) + assert.Equal(t, "user4", apiRepo.Owner.UserName) +} + +func TestAPIRejectTransfer(t *testing.T) { + defer prepareTestEnv(t)() + + repo := transfer(t) + + // try to reject with not authorized user + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session) + req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token)) + session.MakeRequest(t, req, http.StatusForbidden) + + // try to reject repo that's not marked as transferred + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", "user2", "repo1", token)) + session.MakeRequest(t, req, http.StatusNotFound) + + // reject transfer + session = loginUser(t, "user4") + token = getTokenForLoggedInUser(t, session) + + req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject?token=%s", repo.OwnerName, repo.Name, token)) + resp := session.MakeRequest(t, req, http.StatusOK) + apiRepo := new(api.Repository) + DecodeJSON(t, resp, apiRepo) + assert.Equal(t, "user2", apiRepo.Owner.UserName) +} + func TestAPIGenerateRepo(t *testing.T) { defer prepareTestEnv(t)() diff --git a/modules/convert/repository.go b/modules/convert/repository.go index 9859ea267..725b04e2c 100644 --- a/modules/convert/repository.go +++ b/modules/convert/repository.go @@ -10,6 +10,7 @@ import ( "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" ) @@ -106,6 +107,20 @@ func innerToRepo(repo *repo_model.Repository, mode perm.AccessMode, isParent boo } } + var transfer *api.RepoTransfer + if repo.Status == repo_model.RepositoryPendingTransfer { + t, err := models.GetPendingRepositoryTransfer(repo) + if err != nil && !models.IsErrNoPendingTransfer(err) { + log.Warn("GetPendingRepositoryTransfer: %v", err) + } else { + if err := t.LoadAttributes(); err != nil { + log.Warn("LoadAttributes of RepoTransfer: %v", err) + } else { + transfer = ToRepoTransfer(t) + } + } + } + return &api.Repository{ ID: repo.ID, Owner: ToUserWithAccessMode(repo.Owner, mode), @@ -151,5 +166,20 @@ func innerToRepo(repo *repo_model.Repository, mode perm.AccessMode, isParent boo AvatarURL: repo.AvatarLink(), Internal: !repo.IsPrivate && repo.Owner.Visibility == api.VisibleTypePrivate, MirrorInterval: mirrorInterval, + RepoTransfer: transfer, + } +} + +// ToRepoTransfer convert a models.RepoTransfer to a structs.RepeTransfer +func ToRepoTransfer(t *models.RepoTransfer) *api.RepoTransfer { + var teams []*api.Team + for _, v := range t.Teams { + teams = append(teams, ToTeam(v)) + } + + return &api.RepoTransfer{ + Doer: ToUser(t.Doer, nil), + Recipient: ToUser(t.Recipient, nil), + Teams: teams, } } diff --git a/modules/structs/repo.go b/modules/structs/repo.go index b1a3781d0..38d80db70 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -93,6 +93,7 @@ type Repository struct { AvatarURL string `json:"avatar_url"` Internal bool `json:"internal"` MirrorInterval string `json:"mirror_interval"` + RepoTransfer *RepoTransfer `json:"repo_transfer"` } // CreateRepoOption options when creating repository @@ -336,3 +337,10 @@ var ( CodebaseService, } ) + +// RepoTransfer represents a pending repo transfer +type RepoTransfer struct { + Doer *User `json:"doer"` + Recipient *User `json:"recipient"` + Teams []*Team `json:"teams"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index af5ab96d0..c587907d4 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -736,6 +736,8 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route { Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) m.Post("/transfer", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer) + m.Post("/transfer/accept", reqToken(), repo.AcceptTransfer) + m.Post("/transfer/reject", reqToken(), repo.RejectTransfer) m.Combo("/notifications"). Get(reqToken(), notify.ListRepoNotifications). Put(reqToken(), notify.ReadRepoNotifications) diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go index dd7730b42..a997444f4 100644 --- a/routers/api/v1/repo/transfer.go +++ b/routers/api/v1/repo/transfer.go @@ -127,3 +127,105 @@ func Transfer(ctx *context.APIContext) { log.Trace("Repository transferred: %s -> %s", ctx.Repo.Repository.FullName(), newOwner.Name) ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, perm.AccessModeAdmin)) } + +// AcceptTransfer accept a repo transfer +func AcceptTransfer(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/transfer/accept repository acceptRepoTransfer + // --- + // summary: Accept a repo transfer + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to transfer + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to transfer + // type: string + // required: true + // responses: + // "202": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + err := acceptOrRejectRepoTransfer(ctx, true) + if ctx.Written() { + return + } + if err != nil { + ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err) + return + } + + ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx.Repo.Repository, ctx.Repo.AccessMode)) +} + +// RejectTransfer reject a repo transfer +func RejectTransfer(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/transfer/reject repository rejectRepoTransfer + // --- + // summary: Reject a repo transfer + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to transfer + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to transfer + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + err := acceptOrRejectRepoTransfer(ctx, false) + if ctx.Written() { + return + } + if err != nil { + ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToRepo(ctx.Repo.Repository, ctx.Repo.AccessMode)) +} + +func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error { + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx.Repo.Repository) + if err != nil { + if models.IsErrNoPendingTransfer(err) { + ctx.NotFound() + return nil + } + return err + } + + if err := repoTransfer.LoadAttributes(); err != nil { + return err + } + + if !repoTransfer.CanUserAcceptTransfer(ctx.User) { + ctx.Error(http.StatusForbidden, "CanUserAcceptTransfer", nil) + return fmt.Errorf("user does not have permissions to do this") + } + + if accept { + return repo_service.TransferOwnership(repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams) + } + + return models.CancelRepositoryTransfer(ctx.Repo.Repository) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index b016ad22a..96dd26230 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -9895,6 +9895,84 @@ } } }, + "/repos/{owner}/{repo}/transfer/accept": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Accept a repo transfer", + "operationId": "acceptRepoTransfer", + "parameters": [ + { + "type": "string", + "description": "owner of the repo to transfer", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo to transfer", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "$ref": "#/responses/Repository" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/transfer/reject": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Reject a repo transfer", + "operationId": "rejectRepoTransfer", + "parameters": [ + { + "type": "string", + "description": "owner of the repo to transfer", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo to transfer", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Repository" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/wiki/new": { "post": { "consumes": [ @@ -16890,6 +16968,26 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "RepoTransfer": { + "description": "RepoTransfer represents a pending repo transfer", + "type": "object", + "properties": { + "doer": { + "$ref": "#/definitions/User" + }, + "recipient": { + "$ref": "#/definitions/User" + }, + "teams": { + "type": "array", + "items": { + "$ref": "#/definitions/Team" + }, + "x-go-name": "Teams" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Repository": { "description": "Repository represents a repository", "type": "object", @@ -17042,6 +17140,9 @@ "format": "int64", "x-go-name": "Releases" }, + "repo_transfer": { + "$ref": "#/definitions/RepoTransfer" + }, "size": { "type": "integer", "format": "int64",