From 2262811e407facea09047e94aa1850c192511587 Mon Sep 17 00:00:00 2001 From: Richard Mahn Date: Wed, 17 Apr 2019 10:06:35 -0600 Subject: [PATCH] Fixes 4762 - Content API for Creating, Updating, Deleting Files (#6314) --- custom/conf/app.ini.sample | 2 + .../doc/advanced/config-cheat-sheet.en-us.md | 5 +- .../doc/advanced/config-cheat-sheet.zh-cn.md | 3 +- integrations/api_repo_file_content_test.go | 114 ++++ integrations/api_repo_file_create_test.go | 215 +++++++ integrations/api_repo_file_delete_test.go | 163 ++++++ integrations/api_repo_file_helpers.go | 27 + integrations/api_repo_file_update_test.go | 234 ++++++++ integrations/api_repo_git_blobs_test.go | 76 +++ integrations/api_repo_git_trees_test.go | 74 +++ integrations/integration_test.go | 3 +- integrations/migration-test/migration_test.go | 3 +- models/error.go | 168 +++++- models/unit_tests.go | 2 + modules/auth/repo_form.go | 1 + modules/base/tool.go | 25 + modules/context/repo.go | 15 + modules/git/blob.go | 30 + modules/git/commit.go | 5 + modules/git/repo_commit.go | 13 +- modules/repofiles/blob.go | 38 ++ modules/repofiles/blob_test.go | 38 ++ modules/repofiles/content.go | 73 +++ modules/repofiles/content_test.go | 90 +++ modules/repofiles/delete.go | 209 +++++++ modules/repofiles/delete_test.go | 183 ++++++ modules/{uploader => repofiles}/diff.go | 7 +- modules/repofiles/diff_test.go | 143 +++++ modules/repofiles/file.go | 125 ++++ modules/repofiles/file_test.go | 90 +++ modules/repofiles/repofiles.go | 23 + modules/repofiles/repofiles_test.go | 27 + .../repo.go => repofiles/temp_repo.go} | 86 ++- modules/repofiles/tree.go | 92 +++ .../repo => modules/repofiles}/tree_test.go | 24 +- modules/repofiles/update.go | 331 +++++++++++ modules/repofiles/update_test.go | 357 ++++++++++++ modules/{uploader => repofiles}/upload.go | 8 +- modules/repofiles/verification.go | 29 + modules/setting/setting.go | 2 + modules/uploader/delete.go | 100 ---- modules/uploader/update.go | 159 ------ options/locale/locale_en-US.ini | 5 +- routers/api/v1/api.go | 11 +- routers/api/v1/repo/blob.go | 51 ++ routers/api/v1/repo/file.go | 274 ++++++++- routers/api/v1/repo/tree.go | 94 +-- routers/api/v1/swagger/options.go | 10 + routers/api/v1/swagger/repo.go | 28 + routers/repo/editor.go | 290 +++++----- routers/repo/editor_test.go | 1 + templates/repo/editor/commit_form.tmpl | 2 +- templates/repo/editor/delete.tmpl | 3 +- templates/swagger/v1_json.tmpl | 536 +++++++++++++++++- 54 files changed, 4154 insertions(+), 563 deletions(-) create mode 100644 integrations/api_repo_file_content_test.go create mode 100644 integrations/api_repo_file_create_test.go create mode 100644 integrations/api_repo_file_delete_test.go create mode 100644 integrations/api_repo_file_helpers.go create mode 100644 integrations/api_repo_file_update_test.go create mode 100644 integrations/api_repo_git_blobs_test.go create mode 100644 integrations/api_repo_git_trees_test.go create mode 100644 modules/repofiles/blob.go create mode 100644 modules/repofiles/blob_test.go create mode 100644 modules/repofiles/content.go create mode 100644 modules/repofiles/content_test.go create mode 100644 modules/repofiles/delete.go create mode 100644 modules/repofiles/delete_test.go rename modules/{uploader => repofiles}/diff.go (92%) create mode 100644 modules/repofiles/diff_test.go create mode 100644 modules/repofiles/file.go create mode 100644 modules/repofiles/file_test.go create mode 100644 modules/repofiles/repofiles.go create mode 100644 modules/repofiles/repofiles_test.go rename modules/{uploader/repo.go => repofiles/temp_repo.go} (80%) create mode 100644 modules/repofiles/tree.go rename {routers/api/v1/repo => modules/repofiles}/tree_test.go (78%) create mode 100644 modules/repofiles/update.go create mode 100644 modules/repofiles/update_test.go rename modules/{uploader => repofiles}/upload.go (96%) create mode 100644 modules/repofiles/verification.go delete mode 100644 modules/uploader/delete.go delete mode 100644 modules/uploader/update.go create mode 100644 routers/api/v1/repo/blob.go diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample index 4b846d3af..159ab845b 100644 --- a/custom/conf/app.ini.sample +++ b/custom/conf/app.ini.sample @@ -672,6 +672,8 @@ MAX_RESPONSE_ITEMS = 50 DEFAULT_PAGING_NUM = 30 ; Default and maximum number of items per page for git trees api DEFAULT_GIT_TREES_PER_PAGE = 1000 +; Default size of a blob returned by the blobs API (default is 10MiB) +DEFAULT_MAX_BLOB_SIZE = 10485760 [oauth2] ; Enables OAuth2 provider diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index 8ae89ef4d..9fe8ef231 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -402,8 +402,9 @@ NB: You must `REDIRECT_MACARON_LOG` and have `DISABLE_ROUTER_LOG` set to `false` - `ENABLE_SWAGGER`: **true**: Enables /api/swagger, /api/v1/swagger etc. endpoints. True or false; default is true. - `MAX_RESPONSE_ITEMS`: **50**: Max number of items in a page. -- `DEFAULT_PAGING_NUM`: **30**: Default paging number of api. -- `DEFAULT_GIT_TREES_PER_PAGE`: **1000**: Default and maximum number of items per page for git trees api. +- `DEFAULT_PAGING_NUM`: **30**: Default paging number of API. +- `DEFAULT_GIT_TREES_PER_PAGE`: **1000**: Default and maximum number of items per page for git trees API. +- `DEFAULT_MAX_BLOB_SIZE`: **10485760**: Default max size of a blob that can be return by the blobs API. ## OAuth2 (`oauth2`) diff --git a/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md b/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md index 23be54ea2..021233f2d 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md +++ b/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md @@ -215,7 +215,8 @@ menu: - `ENABLE_SWAGGER`: **true**: 是否启用swagger路由 /api/swagger, /api/v1/swagger etc. endpoints. True 或 false; 默认是 true. - `MAX_RESPONSE_ITEMS`: **50**: 一个页面最大的项目数。 - `DEFAULT_PAGING_NUM`: **30**: API中默认分页条数。 -- `DEFAULT_GIT_TREES_PER_PAGE`: **1000**: GIT TREES API每页的默认和最大项数. +- `DEFAULT_GIT_TREES_PER_PAGE`: **1000**: GIT TREES API每页的默认最大项数. +- `DEFAULT_MAX_BLOB_SIZE`: **10485760**: BLOBS API默认最大大小. ## Markup (`markup`) diff --git a/integrations/api_repo_file_content_test.go b/integrations/api_repo_file_content_test.go new file mode 100644 index 000000000..cb4666070 --- /dev/null +++ b/integrations/api_repo_file_content_test.go @@ -0,0 +1,114 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "net/http" + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func getExpectedFileContentResponseForFileContents(branch string) *api.FileContentResponse { + treePath := "README.md" + sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f" + return &api.FileContentResponse{ + Name: filepath.Base(treePath), + Path: treePath, + SHA: sha, + Size: 30, + URL: setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath, + HTMLURL: setting.AppURL + "user2/repo1/blob/" + branch + "/" + treePath, + GitURL: setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha, + DownloadURL: setting.AppURL + "user2/repo1/raw/branch/" + branch + "/" + treePath, + Type: "blob", + Links: &api.FileLinksResponse{ + Self: setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath, + GitURL: setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha, + HTMLURL: setting.AppURL + "user2/repo1/blob/" + branch + "/" + treePath, + }, + } +} + +func TestAPIGetFileContents(t *testing.T) { + prepareTestEnv(t) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of the repo1 & repo16 + user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of the repo3, is an org + user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // owner of neither repos + repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) // public repo + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) // public repo + repo16 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 16}).(*models.Repository) // private repo + treePath := "README.md" + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + + // Make a second master branch in repo1 + repo1.CreateNewBranch(user2, repo1.DefaultBranch, "master2") + + // ref is default branch + branch := repo1.DefaultBranch + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, branch) + resp := session.MakeRequest(t, req, http.StatusOK) + var fileContentResponse api.FileContentResponse + DecodeJSON(t, resp, &fileContentResponse) + assert.NotNil(t, fileContentResponse) + expectedFileContentResponse := getExpectedFileContentResponseForFileContents(branch) + assert.EqualValues(t, *expectedFileContentResponse, fileContentResponse) + + // No ref + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &fileContentResponse) + assert.NotNil(t, fileContentResponse) + expectedFileContentResponse = getExpectedFileContentResponseForFileContents(repo1.DefaultBranch) + assert.EqualValues(t, *expectedFileContentResponse, fileContentResponse) + + // ref is master2 + branch = "master2" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, branch) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &fileContentResponse) + assert.NotNil(t, fileContentResponse) + expectedFileContentResponse = getExpectedFileContentResponseForFileContents("master2") + assert.EqualValues(t, *expectedFileContentResponse, fileContentResponse) + + // Test file contents a file with the wrong branch + branch = "badbranch" + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, branch) + resp = session.MakeRequest(t, req, http.StatusInternalServerError) + expectedAPIError := context.APIError{ + Message: "object does not exist [id: " + branch + ", rel_path: ]", + URL: base.DocURL, + } + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, expectedAPIError, apiError) + + // Test accessing private branch with user token that does not have access - should fail + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo16.Name, treePath, token4) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test access private branch of owner of token + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md?token=%s", user2.Name, repo16.Name, token2) + session.MakeRequest(t, req, http.StatusOK) + + // Test access of org user3 private repo file by owner user2 + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?token=%s", user3.Name, repo3.Name, treePath, token2) + session.MakeRequest(t, req, http.StatusOK) +} diff --git a/integrations/api_repo_file_create_test.go b/integrations/api_repo_file_create_test.go new file mode 100644 index 000000000..da35127a5 --- /dev/null +++ b/integrations/api_repo_file_create_test.go @@ -0,0 +1,215 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "encoding/base64" + "fmt" + "net/http" + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func getCreateFileOptions() api.CreateFileOptions { + content := "This is new text" + contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) + return api.CreateFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "Creates new/file.txt", + Author: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Committer: api.Identity{ + Name: "Jane Doe", + Email: "janedoe@example.com", + }, + }, + Content: contentEncoded, + } +} + +func getExpectedFileResponseForCreate(commitID, treePath string) *api.FileResponse { + sha := "a635aa942442ddfdba07468cf9661c08fbdf0ebf" + return &api.FileResponse{ + Content: &api.FileContentResponse{ + Name: filepath.Base(treePath), + Path: treePath, + SHA: sha, + Size: 16, + URL: setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath, + HTMLURL: setting.AppURL + "user2/repo1/blob/master/" + treePath, + GitURL: setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha, + DownloadURL: setting.AppURL + "user2/repo1/raw/branch/master/" + treePath, + Type: "blob", + Links: &api.FileLinksResponse{ + Self: setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath, + GitURL: setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha, + HTMLURL: setting.AppURL + "user2/repo1/blob/master/" + treePath, + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Jane Doe", + Email: "janedoe@example.com", + }, + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + }, + Message: "Updates README.md\n", + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "unsigned", + Signature: "", + Payload: "", + }, + } +} + +func TestAPICreateFile(t *testing.T) { + prepareTestEnv(t) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of the repo1 & repo16 + user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of the repo3, is an org + user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // owner of neither repos + repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) // public repo + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) // public repo + repo16 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 16}).(*models.Repository) // private repo + fileID := 0 + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + + // Test creating a file in repo1 which user2 owns, try both with branch and empty branch + for _, branch := range [...]string{ + "master", // Branch + "", // Empty branch + } { + createFileOptions := getCreateFileOptions() + createFileOptions.BranchName = branch + fileID++ + treePath := fmt.Sprintf("new/file%d.txt", fileID) + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req := NewRequestWithJSON(t, "POST", url, &createFileOptions) + resp := session.MakeRequest(t, req, http.StatusCreated) + gitRepo, _ := git.OpenRepository(repo1.RepoPath()) + commitID, _ := gitRepo.GetBranchCommitID(createFileOptions.NewBranchName) + expectedFileResponse := getExpectedFileResponseForCreate(commitID, treePath) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) + } + + // Test creating a file in a new branch + createFileOptions := getCreateFileOptions() + createFileOptions.BranchName = repo1.DefaultBranch + createFileOptions.NewBranchName = "new_branch" + fileID++ + treePath := fmt.Sprintf("new/file%d.txt", fileID) + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req := NewRequestWithJSON(t, "POST", url, &createFileOptions) + resp := session.MakeRequest(t, req, http.StatusCreated) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + expectedSHA := "a635aa942442ddfdba07468cf9661c08fbdf0ebf" + expectedHTMLURL := fmt.Sprintf("http://localhost:"+setting.HTTPPort+"/user2/repo1/blob/new_branch/new/file%d.txt", fileID) + expectedDownloadURL := fmt.Sprintf("http://localhost:"+setting.HTTPPort+"/user2/repo1/raw/branch/new_branch/new/file%d.txt", fileID) + assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA) + assert.EqualValues(t, expectedHTMLURL, fileResponse.Content.HTMLURL) + assert.EqualValues(t, expectedDownloadURL, fileResponse.Content.DownloadURL) + + // Test trying to create a file that already exists, should fail + createFileOptions = getCreateFileOptions() + treePath = "README.md" + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req = NewRequestWithJSON(t, "POST", url, &createFileOptions) + resp = session.MakeRequest(t, req, http.StatusInternalServerError) + expectedAPIError := context.APIError{ + Message: "repository file already exists [path: " + treePath + "]", + URL: base.DocURL, + } + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, expectedAPIError, apiError) + + // Test creating a file in repo1 by user4 who does not have write access + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo16.Name, treePath, token4) + req = NewRequestWithJSON(t, "POST", url, &createFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath) + req = NewRequestWithJSON(t, "POST", url, &createFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo16.Name, treePath, token2) + req = NewRequestWithJSON(t, "POST", url, &createFileOptions) + session.MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "user3/repo3" where user2 is a collaborator + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user3.Name, repo3.Name, treePath, token2) + req = NewRequestWithJSON(t, "POST", url, &createFileOptions) + session.MakeRequest(t, req, http.StatusCreated) + + // Test using org repo "user3/repo3" with no user token + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user3.Name, repo3.Name, treePath) + req = NewRequestWithJSON(t, "POST", url, &createFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + createFileOptions = getCreateFileOptions() + fileID++ + treePath = fmt.Sprintf("new/file%d.txt", fileID) + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token4) + req = NewRequestWithJSON(t, "POST", url, &createFileOptions) + session.MakeRequest(t, req, http.StatusForbidden) +} diff --git a/integrations/api_repo_file_delete_test.go b/integrations/api_repo_file_delete_test.go new file mode 100644 index 000000000..2c2cd5643 --- /dev/null +++ b/integrations/api_repo_file_delete_test.go @@ -0,0 +1,163 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "fmt" + "net/http" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func getDeleteFileOptions() *api.DeleteFileOptions { + return &api.DeleteFileOptions{ + FileOptions: api.FileOptions{ + BranchName: "master", + NewBranchName: "master", + Message: "Updates new/file.txt", + Author: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + Committer: api.Identity{ + Name: "Jane Doe", + Email: "janedoe@example.com", + }, + }, + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + } +} + +func TestAPIDeleteFile(t *testing.T) { + prepareTestEnv(t) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of the repo1 & repo16 + user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of the repo3, is an org + user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // owner of neither repos + repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) // public repo + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) // public repo + repo16 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 16}).(*models.Repository) // private repo + fileID := 0 + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + + // Test deleting a file in repo1 which user2 owns, try both with branch and empty branch + for _, branch := range [...]string{ + "master", // Branch + "", // Empty branch + } { + fileID++ + treePath := fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions := getDeleteFileOptions() + deleteFileOptions.BranchName = branch + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req := NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + resp := session.MakeRequest(t, req, http.StatusOK) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.NotNil(t, fileResponse) + assert.Nil(t, fileResponse.Content) + } + + // Test deleting file and making the delete in a new branch + fileID++ + treePath := fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions := getDeleteFileOptions() + deleteFileOptions.BranchName = repo1.DefaultBranch + deleteFileOptions.NewBranchName = "new_branch" + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req := NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + resp := session.MakeRequest(t, req, http.StatusOK) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.NotNil(t, fileResponse) + assert.Nil(t, fileResponse.Content) + + // Test deleting a file with the wrong SHA + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions = getDeleteFileOptions() + correctSHA := deleteFileOptions.SHA + deleteFileOptions.SHA = "badsha" + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req = NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + resp = session.MakeRequest(t, req, http.StatusInternalServerError) + expectedAPIError := context.APIError{ + Message: "sha does not match [given: " + deleteFileOptions.SHA + ", expected: " + correctSHA + "]", + URL: base.DocURL, + } + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, expectedAPIError, apiError) + + // Test creating a file in repo1 by user4 who does not have write access + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, treePath) + deleteFileOptions = getDeleteFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo16.Name, treePath, token4) + req = NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, treePath) + deleteFileOptions = getDeleteFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath) + req = NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo16, treePath) + deleteFileOptions = getDeleteFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo16.Name, treePath, token2) + req = NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + session.MakeRequest(t, req, http.StatusOK) + + // Test using org repo "user3/repo3" where user2 is a collaborator + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user3, repo3, treePath) + deleteFileOptions = getDeleteFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user3.Name, repo3.Name, treePath, token2) + req = NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + session.MakeRequest(t, req, http.StatusOK) + + // Test using org repo "user3/repo3" with no user token + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user3, repo3, treePath) + deleteFileOptions = getDeleteFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user3.Name, repo3.Name, treePath) + req = NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + fileID++ + treePath = fmt.Sprintf("delete/file%d.txt", fileID) + createFile(user2, repo1, treePath) + deleteFileOptions = getDeleteFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token4) + req = NewRequestWithJSON(t, "DELETE", url, &deleteFileOptions) + session.MakeRequest(t, req, http.StatusForbidden) +} diff --git a/integrations/api_repo_file_helpers.go b/integrations/api_repo_file_helpers.go new file mode 100644 index 000000000..d06f484dd --- /dev/null +++ b/integrations/api_repo_file_helpers.go @@ -0,0 +1,27 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/repofiles" + api "code.gitea.io/sdk/gitea" +) + +func createFileInBranch(user *models.User, repo *models.Repository, treePath, branchName string) (*api.FileResponse, error) { + opts := &repofiles.UpdateRepoFileOptions{ + OldBranch: branchName, + TreePath: treePath, + Content: "This is a NEW file", + IsNewFile: true, + Author: nil, + Committer: nil, + } + return repofiles.CreateOrUpdateRepoFile(repo, user, opts) +} + +func createFile(user *models.User, repo *models.Repository, treePath string) (*api.FileResponse, error) { + return createFileInBranch(user, repo, treePath, repo.DefaultBranch) +} diff --git a/integrations/api_repo_file_update_test.go b/integrations/api_repo_file_update_test.go new file mode 100644 index 000000000..d62a0e279 --- /dev/null +++ b/integrations/api_repo_file_update_test.go @@ -0,0 +1,234 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "encoding/base64" + "fmt" + "net/http" + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func getUpdateFileOptions() *api.UpdateFileOptions { + content := "This is updated text" + contentEncoded := base64.StdEncoding.EncodeToString([]byte(content)) + return &api.UpdateFileOptions{ + DeleteFileOptions: *getDeleteFileOptions(), + Content: contentEncoded, + } +} + +func getExpectedFileResponseForUpdate(commitID, treePath string) *api.FileResponse { + sha := "08bd14b2e2852529157324de9c226b3364e76136" + return &api.FileResponse{ + Content: &api.FileContentResponse{ + Name: filepath.Base(treePath), + Path: treePath, + SHA: sha, + Size: 20, + URL: setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath, + HTMLURL: setting.AppURL + "user2/repo1/blob/master/" + treePath, + GitURL: setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha, + DownloadURL: setting.AppURL + "user2/repo1/raw/branch/master/" + treePath, + Type: "blob", + Links: &api.FileLinksResponse{ + Self: setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath, + GitURL: setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha, + HTMLURL: setting.AppURL + "user2/repo1/blob/master/" + treePath, + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "Jane Doe", + Email: "janedoe@example.com", + }, + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "John Doe", + Email: "johndoe@example.com", + }, + }, + Message: "Updates README.md\n", + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "unsigned", + Signature: "", + Payload: "", + }, + } +} + +func TestAPIUpdateFile(t *testing.T) { + prepareTestEnv(t) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of the repo1 & repo16 + user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of the repo3, is an org + user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // owner of neither repos + repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) // public repo + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) // public repo + repo16 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 16}).(*models.Repository) // private repo + fileID := 0 + + // Get user2's token + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) + + // Test updating a file in repo1 which user2 owns, try both with branch and empty branch + for _, branch := range [...]string{ + "master", // Branch + "", // Empty branch + } { + fileID++ + treePath := fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions := getUpdateFileOptions() + updateFileOptions.BranchName = branch + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req := NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + resp := session.MakeRequest(t, req, http.StatusOK) + gitRepo, _ := git.OpenRepository(repo1.RepoPath()) + commitID, _ := gitRepo.GetBranchCommitID(updateFileOptions.NewBranchName) + expectedFileResponse := getExpectedFileResponseForUpdate(commitID, treePath) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) + } + + // Test updating a file in a new branch + updateFileOptions := getUpdateFileOptions() + updateFileOptions.BranchName = repo1.DefaultBranch + updateFileOptions.NewBranchName = "new_branch" + fileID++ + treePath := fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + url := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req := NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + resp := session.MakeRequest(t, req, http.StatusOK) + var fileResponse api.FileResponse + DecodeJSON(t, resp, &fileResponse) + expectedSHA := "08bd14b2e2852529157324de9c226b3364e76136" + expectedHTMLURL := fmt.Sprintf("http://localhost:"+setting.HTTPPort+"/user2/repo1/blob/new_branch/update/file%d.txt", fileID) + expectedDownloadURL := fmt.Sprintf("http://localhost:"+setting.HTTPPort+"/user2/repo1/raw/branch/new_branch/update/file%d.txt", fileID) + assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA) + assert.EqualValues(t, expectedHTMLURL, fileResponse.Content.HTMLURL) + assert.EqualValues(t, expectedDownloadURL, fileResponse.Content.DownloadURL) + + // Test updating a file and renaming it + updateFileOptions = getUpdateFileOptions() + updateFileOptions.BranchName = repo1.DefaultBranch + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions.FromPath = treePath + treePath = "rename/" + treePath + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &fileResponse) + expectedSHA = "08bd14b2e2852529157324de9c226b3364e76136" + expectedHTMLURL = fmt.Sprintf("http://localhost:"+setting.HTTPPort+"/user2/repo1/blob/master/rename/update/file%d.txt", fileID) + expectedDownloadURL = fmt.Sprintf("http://localhost:"+setting.HTTPPort+"/user2/repo1/raw/branch/master/rename/update/file%d.txt", fileID) + assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA) + assert.EqualValues(t, expectedHTMLURL, fileResponse.Content.HTMLURL) + assert.EqualValues(t, expectedDownloadURL, fileResponse.Content.DownloadURL) + + // Test updating a file with the wrong SHA + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions = getUpdateFileOptions() + correctSHA := updateFileOptions.SHA + updateFileOptions.SHA = "badsha" + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token2) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + resp = session.MakeRequest(t, req, http.StatusInternalServerError) + expectedAPIError := context.APIError{ + Message: "sha does not match [given: " + updateFileOptions.SHA + ", expected: " + correctSHA + "]", + URL: base.DocURL, + } + var apiError context.APIError + DecodeJSON(t, resp, &apiError) + assert.Equal(t, expectedAPIError, apiError) + + // Test creating a file in repo1 by user4 who does not have write access + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo16, treePath) + updateFileOptions = getUpdateFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo16.Name, treePath, token4) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Tests a repo with no token given so will fail + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo16, treePath) + updateFileOptions = getUpdateFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo16, treePath) + updateFileOptions = getUpdateFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo16.Name, treePath, token2) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + session.MakeRequest(t, req, http.StatusOK) + + // Test using org repo "user3/repo3" where user2 is a collaborator + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user3, repo3, treePath) + updateFileOptions = getUpdateFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user3.Name, repo3.Name, treePath, token2) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + session.MakeRequest(t, req, http.StatusOK) + + // Test using org repo "user3/repo3" with no user token + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user3, repo3, treePath) + updateFileOptions = getUpdateFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user3.Name, repo3.Name, treePath) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test using repo "user2/repo1" where user4 is a NOT collaborator + fileID++ + treePath = fmt.Sprintf("update/file%d.txt", fileID) + createFile(user2, repo1, treePath) + updateFileOptions = getUpdateFileOptions() + url = fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s?token=%s", user2.Name, repo1.Name, treePath, token4) + req = NewRequestWithJSON(t, "PUT", url, &updateFileOptions) + session.MakeRequest(t, req, http.StatusForbidden) +} diff --git a/integrations/api_repo_git_blobs_test.go b/integrations/api_repo_git_blobs_test.go new file mode 100644 index 000000000..560f108fc --- /dev/null +++ b/integrations/api_repo_git_blobs_test.go @@ -0,0 +1,76 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func TestAPIReposGitBlobs(t *testing.T) { + prepareTestEnv(t) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of the repo1 & repo16 + user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of the repo3 + user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // owner of neither repos + repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) // public repo + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) // public repo + repo16 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 16}).(*models.Repository) // private repo + repo1ReadmeSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + repo3ReadmeSHA := "d56a3073c1dbb7b15963110a049d50cdb5db99fc" + repo16ReadmeSHA := "f90451c72ef61a7645293d17b47be7a8e983da57" + badSHA := "0000000000000000000000000000000000000000" + + // Login as User2. + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) // don't want anyone logged in for this + + // Test a public repo that anyone can GET the blob of + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo1.Name, repo1ReadmeSHA) + resp := session.MakeRequest(t, req, http.StatusOK) + var gitBlobResponse api.GitBlobResponse + DecodeJSON(t, resp, &gitBlobResponse) + assert.NotNil(t, gitBlobResponse) + expectedContent := "Y29tbWl0IDY1ZjFiZjI3YmMzYmY3MGY2NDY1NzY1ODYzNWU2NjA5NGVkYmNiNGQKQXV0aG9yOiB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+CkRhdGU6ICAgU3VuIE1hciAxOSAxNjo0Nzo1OSAyMDE3IC0wNDAwCgogICAgSW5pdGlhbCBjb21taXQKCmRpZmYgLS1naXQgYS9SRUFETUUubWQgYi9SRUFETUUubWQKbmV3IGZpbGUgbW9kZSAxMDA2NDQKaW5kZXggMDAwMDAwMC4uNGI0ODUxYQotLS0gL2Rldi9udWxsCisrKyBiL1JFQURNRS5tZApAQCAtMCwwICsxLDMgQEAKKyMgcmVwbzEKKworRGVzY3JpcHRpb24gZm9yIHJlcG8xClwgTm8gbmV3bGluZSBhdCBlbmQgb2YgZmlsZQo=" + assert.Equal(t, expectedContent, gitBlobResponse.Content) + + // Tests a private repo with no token so will fail + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo16.Name, repo16ReadmeSHA) + session.MakeRequest(t, req, http.StatusNotFound) + + // Test using access token for a private repo that the user of the token owns + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s?token=%s", user2.Name, repo16.Name, repo16ReadmeSHA, token) + session.MakeRequest(t, req, http.StatusOK) + + // Test using bad sha + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo1.Name, badSHA) + session.MakeRequest(t, req, http.StatusBadRequest) + + // Test using org repo "user3/repo3" where user2 is a collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s?token=%s", user3.Name, repo3.Name, repo3ReadmeSHA, token) + session.MakeRequest(t, req, http.StatusOK) + + // Test using org repo "user3/repo3" where user2 is a collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s?token=%s", user3.Name, repo3.Name, repo3ReadmeSHA, token) + session.MakeRequest(t, req, http.StatusOK) + + // Test using org repo "user3/repo3" with no user token + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user3.Name, repo3ReadmeSHA, repo3.Name) + session.MakeRequest(t, req, http.StatusNotFound) + + // Login as User4. + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) // don't want anyone logged in for this + + // Test using org repo "user3/repo3" where user4 is a NOT collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", user3.Name, repo3.Name, token4) + session.MakeRequest(t, req, http.StatusNotFound) +} diff --git a/integrations/api_repo_git_trees_test.go b/integrations/api_repo_git_trees_test.go new file mode 100644 index 000000000..95da7738f --- /dev/null +++ b/integrations/api_repo_git_trees_test.go @@ -0,0 +1,74 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models" +) + +func TestAPIReposGitTrees(t *testing.T) { + prepareTestEnv(t) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) // owner of the repo1 & repo16 + user3 := models.AssertExistsAndLoadBean(t, &models.User{ID: 3}).(*models.User) // owner of the repo3 + user4 := models.AssertExistsAndLoadBean(t, &models.User{ID: 4}).(*models.User) // owner of neither repos + repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) // public repo + repo3 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 3}).(*models.Repository) // public repo + repo16 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 16}).(*models.Repository) // private repo + repo1TreeSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + repo3TreeSHA := "2a47ca4b614a9f5a43abbd5ad851a54a616ffee6" + repo16TreeSHA := "69554a64c1e6030f051e5c3f94bfbd773cd6a324" + badSHA := "0000000000000000000000000000000000000000" + + // Login as User2. + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) // don't want anyone logged in for this + + // Test a public repo that anyone can GET the tree of + for _, ref := range [...]string{ + "master", // Branch + repo1TreeSHA, // Tree SHA + } { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo1.Name, ref) + session.MakeRequest(t, req, http.StatusOK) + } + + // Tests a private repo with no token so will fail + for _, ref := range [...]string{ + "master", // Branch + repo1TreeSHA, // Tag + } { + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo16.Name, ref) + session.MakeRequest(t, req, http.StatusNotFound) + } + + // Test using access token for a private repo that the user of the token owns + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s?token=%s", user2.Name, repo16.Name, repo16TreeSHA, token) + session.MakeRequest(t, req, http.StatusOK) + + // Test using bad sha + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo1.Name, badSHA) + session.MakeRequest(t, req, http.StatusBadRequest) + + // Test using org repo "user3/repo3" where user2 is a collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s?token=%s", user3.Name, repo3.Name, repo3TreeSHA, token) + session.MakeRequest(t, req, http.StatusOK) + + // Test using org repo "user3/repo3" with no user token + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user3.Name, repo3TreeSHA, repo3.Name) + session.MakeRequest(t, req, http.StatusNotFound) + + // Login as User4. + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session) + session = emptyTestSession(t) // don't want anyone logged in for this + + // Test using org repo "user3/repo3" where user4 is a NOT collaborator + req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", user3.Name, repo3.Name, token4) + session.MakeRequest(t, req, http.StatusNotFound) +} diff --git a/integrations/integration_test.go b/integrations/integration_test.go index b23c28ee5..43c817943 100644 --- a/integrations/integration_test.go +++ b/integrations/integration_test.go @@ -22,6 +22,7 @@ import ( "testing" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers/routes" @@ -96,7 +97,7 @@ func TestMain(m *testing.M) { } func initIntegrationTest() { - giteaRoot := os.Getenv("GITEA_ROOT") + giteaRoot := base.SetupGiteaRoot() if giteaRoot == "" { fmt.Println("Environment variable $GITEA_ROOT not set") os.Exit(1) diff --git a/integrations/migration-test/migration_test.go b/integrations/migration-test/migration_test.go index 93b60e0e3..fafe0fe22 100644 --- a/integrations/migration-test/migration_test.go +++ b/integrations/migration-test/migration_test.go @@ -18,6 +18,7 @@ import ( "code.gitea.io/gitea/integrations" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/migrations" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "github.com/go-xorm/xorm" @@ -28,7 +29,7 @@ var currentEngine *xorm.Engine func initMigrationTest(t *testing.T) { integrations.PrintCurrentTest(t, 2) - giteaRoot := os.Getenv("GITEA_ROOT") + giteaRoot := base.SetupGiteaRoot() if giteaRoot == "" { integrations.Printf("Environment variable $GITEA_ROOT not set\n") os.Exit(1) diff --git a/models/error.go b/models/error.go index f079af4e1..3dd2c79e8 100644 --- a/models/error.go +++ b/models/error.go @@ -1,10 +1,15 @@ // Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package models -import "fmt" +import ( + "fmt" + + "code.gitea.io/gitea/modules/git" +) // ErrNameReserved represents a "reserved name" error. type ErrNameReserved struct { @@ -26,8 +31,7 @@ type ErrNamePatternNotAllowed struct { Pattern string } -// IsErrNamePatternNotAllowed checks if an error is an -// ErrNamePatternNotAllowed. +// IsErrNamePatternNotAllowed checks if an error is an ErrNamePatternNotAllowed. func IsErrNamePatternNotAllowed(err error) bool { _, ok := err.(ErrNamePatternNotAllowed) return ok @@ -676,7 +680,7 @@ type ErrRepoRedirectNotExist struct { RepoName string } -// IsErrRepoRedirectNotExist check if an error is an ErrRepoRedirectNotExist +// IsErrRepoRedirectNotExist check if an error is an ErrRepoRedirectNotExist. func IsErrRepoRedirectNotExist(err error) bool { _, ok := err.(ErrRepoRedirectNotExist) return ok @@ -765,28 +769,95 @@ func (err ErrInvalidTagName) Error() string { return fmt.Sprintf("release tag name is not valid [tag_name: %s]", err.TagName) } -// ErrRepoFileAlreadyExist represents a "RepoFileAlreadyExist" kind of error. -type ErrRepoFileAlreadyExist struct { - FileName string +// ErrRepoFileAlreadyExists represents a "RepoFileAlreadyExist" kind of error. +type ErrRepoFileAlreadyExists struct { + Path string +} + +// IsErrRepoFileAlreadyExists checks if an error is a ErrRepoFileAlreadyExists. +func IsErrRepoFileAlreadyExists(err error) bool { + _, ok := err.(ErrRepoFileAlreadyExists) + return ok +} + +func (err ErrRepoFileAlreadyExists) Error() string { + return fmt.Sprintf("repository file already exists [path: %s]", err.Path) +} + +// ErrRepoFileDoesNotExist represents a "RepoFileDoesNotExist" kind of error. +type ErrRepoFileDoesNotExist struct { + Path string + Name string +} + +// IsErrRepoFileDoesNotExist checks if an error is a ErrRepoDoesNotExist. +func IsErrRepoFileDoesNotExist(err error) bool { + _, ok := err.(ErrRepoFileDoesNotExist) + return ok +} + +func (err ErrRepoFileDoesNotExist) Error() string { + return fmt.Sprintf("repository file does not exist [path: %s]", err.Path) +} + +// ErrFilenameInvalid represents a "FilenameInvalid" kind of error. +type ErrFilenameInvalid struct { + Path string +} + +// IsErrFilenameInvalid checks if an error is an ErrFilenameInvalid. +func IsErrFilenameInvalid(err error) bool { + _, ok := err.(ErrFilenameInvalid) + return ok +} + +func (err ErrFilenameInvalid) Error() string { + return fmt.Sprintf("path contains a malformed path component [path: %s]", err.Path) +} + +// ErrUserCannotCommit represents "UserCannotCommit" kind of error. +type ErrUserCannotCommit struct { + UserName string +} + +// IsErrUserCannotCommit checks if an error is an ErrUserCannotCommit. +func IsErrUserCannotCommit(err error) bool { + _, ok := err.(ErrUserCannotCommit) + return ok +} + +func (err ErrUserCannotCommit) Error() string { + return fmt.Sprintf("user cannot commit to repo [user: %s]", err.UserName) +} + +// ErrFilePathInvalid represents a "FilePathInvalid" kind of error. +type ErrFilePathInvalid struct { + Message string + Path string + Name string + Type git.EntryMode } -// IsErrRepoFileAlreadyExist checks if an error is a ErrRepoFileAlreadyExist. -func IsErrRepoFileAlreadyExist(err error) bool { - _, ok := err.(ErrRepoFileAlreadyExist) +// IsErrFilePathInvalid checks if an error is an ErrFilePathInvalid. +func IsErrFilePathInvalid(err error) bool { + _, ok := err.(ErrFilePathInvalid) return ok } -func (err ErrRepoFileAlreadyExist) Error() string { - return fmt.Sprintf("repository file already exists [file_name: %s]", err.FileName) +func (err ErrFilePathInvalid) Error() string { + if err.Message != "" { + return err.Message + } + return fmt.Sprintf("path is invalid [path: %s]", err.Path) } -// ErrUserDoesNotHaveAccessToRepo represets an error where the user doesn't has access to a given repo +// ErrUserDoesNotHaveAccessToRepo represets an error where the user doesn't has access to a given repo. type ErrUserDoesNotHaveAccessToRepo struct { UserID int64 RepoName string } -// IsErrUserDoesNotHaveAccessToRepo checks if an error is a ErrRepoFileAlreadyExist. +// IsErrUserDoesNotHaveAccessToRepo checks if an error is a ErrRepoFileAlreadyExists. func IsErrUserDoesNotHaveAccessToRepo(err error) bool { _, ok := err.(ErrUserDoesNotHaveAccessToRepo) return ok @@ -818,7 +889,7 @@ func (err ErrBranchNotExist) Error() string { return fmt.Sprintf("branch does not exist [name: %s]", err.Name) } -// ErrBranchAlreadyExists represents an error that branch with such name already exists +// ErrBranchAlreadyExists represents an error that branch with such name already exists. type ErrBranchAlreadyExists struct { BranchName string } @@ -833,7 +904,7 @@ func (err ErrBranchAlreadyExists) Error() string { return fmt.Sprintf("branch already exists [name: %s]", err.BranchName) } -// ErrBranchNameConflict represents an error that branch name conflicts with other branch +// ErrBranchNameConflict represents an error that branch name conflicts with other branch. type ErrBranchNameConflict struct { BranchName string } @@ -848,7 +919,7 @@ func (err ErrBranchNameConflict) Error() string { return fmt.Sprintf("branch conflicts with existing branch [name: %s]", err.BranchName) } -// ErrNotAllowedToMerge represents an error that a branch is protected and the current user is not allowed to modify it +// ErrNotAllowedToMerge represents an error that a branch is protected and the current user is not allowed to modify it. type ErrNotAllowedToMerge struct { Reason string } @@ -863,7 +934,7 @@ func (err ErrNotAllowedToMerge) Error() string { return fmt.Sprintf("not allowed to merge [reason: %s]", err.Reason) } -// ErrTagAlreadyExists represents an error that tag with such name already exists +// ErrTagAlreadyExists represents an error that tag with such name already exists. type ErrTagAlreadyExists struct { TagName string } @@ -878,6 +949,67 @@ func (err ErrTagAlreadyExists) Error() string { return fmt.Sprintf("tag already exists [name: %s]", err.TagName) } +// ErrSHADoesNotMatch represents a "SHADoesNotMatch" kind of error. +type ErrSHADoesNotMatch struct { + Path string + GivenSHA string + CurrentSHA string +} + +// IsErrSHADoesNotMatch checks if an error is a ErrSHADoesNotMatch. +func IsErrSHADoesNotMatch(err error) bool { + _, ok := err.(ErrSHADoesNotMatch) + return ok +} + +func (err ErrSHADoesNotMatch) Error() string { + return fmt.Sprintf("sha does not match [given: %s, expected: %s]", err.GivenSHA, err.CurrentSHA) +} + +// ErrSHANotFound represents a "SHADoesNotMatch" kind of error. +type ErrSHANotFound struct { + SHA string +} + +// IsErrSHANotFound checks if an error is a ErrSHANotFound. +func IsErrSHANotFound(err error) bool { + _, ok := err.(ErrSHANotFound) + return ok +} + +func (err ErrSHANotFound) Error() string { + return fmt.Sprintf("sha not found [%s]", err.SHA) +} + +// ErrCommitIDDoesNotMatch represents a "CommitIDDoesNotMatch" kind of error. +type ErrCommitIDDoesNotMatch struct { + GivenCommitID string + CurrentCommitID string +} + +// IsErrCommitIDDoesNotMatch checks if an error is a ErrCommitIDDoesNotMatch. +func IsErrCommitIDDoesNotMatch(err error) bool { + _, ok := err.(ErrCommitIDDoesNotMatch) + return ok +} + +func (err ErrCommitIDDoesNotMatch) Error() string { + return fmt.Sprintf("file CommitID does not match [given: %s, expected: %s]", err.GivenCommitID, err.CurrentCommitID) +} + +// ErrSHAOrCommitIDNotProvided represents a "SHAOrCommitIDNotProvided" kind of error. +type ErrSHAOrCommitIDNotProvided struct{} + +// IsErrSHAOrCommitIDNotProvided checks if an error is a ErrSHAOrCommitIDNotProvided. +func IsErrSHAOrCommitIDNotProvided(err error) bool { + _, ok := err.(ErrSHAOrCommitIDNotProvided) + return ok +} + +func (err ErrSHAOrCommitIDNotProvided) Error() string { + return fmt.Sprintf("a SHA or commmit ID must be proved when updating a file") +} + // __ __ ___. .__ __ // / \ / \ ____\_ |__ | |__ ____ ____ | | __ // \ \/\/ // __ \| __ \| | \ / _ \ / _ \| |/ / diff --git a/models/unit_tests.go b/models/unit_tests.go index 28cd91215..19fc95ea6 100644 --- a/models/unit_tests.go +++ b/models/unit_tests.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/setting" "github.com/Unknwon/com" @@ -116,6 +117,7 @@ func PrepareTestEnv(t testing.TB) { assert.NoError(t, removeAllWithRetry(setting.RepoRootPath)) metaPath := filepath.Join(giteaRoot, "integrations", "gitea-repositories-meta") assert.NoError(t, com.CopyDir(metaPath, setting.RepoRootPath)) + base.SetupGiteaRoot() // Makes sure GITEA_ROOT is set } type testCond struct { diff --git a/modules/auth/repo_form.go b/modules/auth/repo_form.go index 0a97b08c7..990a94dd6 100644 --- a/modules/auth/repo_form.go +++ b/modules/auth/repo_form.go @@ -590,6 +590,7 @@ type DeleteRepoFileForm struct { CommitMessage string CommitChoice string `binding:"Required;MaxSize(50)"` NewBranchName string `binding:"GitRefName;MaxSize(100)"` + LastCommit string } // Validate validates the fields diff --git a/modules/base/tool.go b/modules/base/tool.go index 681577b76..97fd87e85 100644 --- a/modules/base/tool.go +++ b/modules/base/tool.go @@ -16,7 +16,10 @@ import ( "math" "net/http" "net/url" + "os" "path" + "path/filepath" + "runtime" "strconv" "strings" "time" @@ -603,3 +606,25 @@ func EntryIcon(entry *git.TreeEntry) string { return "file-text" } + +// SetupGiteaRoot Sets GITEA_ROOT if it is not already set and returns the value +func SetupGiteaRoot() string { + giteaRoot := os.Getenv("GITEA_ROOT") + if giteaRoot == "" { + _, filename, _, _ := runtime.Caller(0) + giteaRoot = strings.TrimSuffix(filename, "modules/base/tool.go") + wd, err := os.Getwd() + if err != nil { + rel, err := filepath.Rel(giteaRoot, wd) + if err != nil && strings.HasPrefix(filepath.ToSlash(rel), "../") { + giteaRoot = wd + } + } + if _, err := os.Stat(filepath.Join(giteaRoot, "gitea")); os.IsNotExist(err) { + giteaRoot = "" + } else if err := os.Setenv("GITEA_ROOT", giteaRoot); err != nil { + giteaRoot = "" + } + } + return giteaRoot +} diff --git a/modules/context/repo.go b/modules/context/repo.go index 2c6a4f536..9d3fb7cfe 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -128,6 +128,21 @@ func (r *Repository) BranchNameSubURL() string { return "" } +// FileExists returns true if a file exists in the given repo branch +func (r *Repository) FileExists(path string, branch string) (bool, error) { + if branch == "" { + branch = r.Repository.DefaultBranch + } + commit, err := r.GitRepo.GetBranchCommit(branch) + if err != nil { + return false, err + } + if _, err := commit.GetTreeEntryByPath(path); err != nil { + return false, err + } + return true, nil +} + // GetEditorconfig returns the .editorconfig definition if found in the // HEAD of the default repo branch. func (r *Repository) GetEditorconfig() (*editorconfig.Editorconfig, error) { diff --git a/modules/git/blob.go b/modules/git/blob.go index a6e392eeb..e194b973d 100644 --- a/modules/git/blob.go +++ b/modules/git/blob.go @@ -6,6 +6,7 @@ package git import ( "bytes" + "encoding/base64" "fmt" "io" "io/ioutil" @@ -71,3 +72,32 @@ func (b *Blob) DataAsync() (io.ReadCloser, error) { return cmdReadCloser{stdout: stdout, cmd: cmd}, nil } + +// GetBlobContentBase64 Reads the content of the blob with a base64 encode and returns the encoded string +func (b *Blob) GetBlobContentBase64() (string, error) { + dataRc, err := b.DataAsync() + if err != nil { + return "", err + } + defer dataRc.Close() + + pr, pw := io.Pipe() + encoder := base64.NewEncoder(base64.StdEncoding, pw) + + go func() { + _, err := io.Copy(encoder, dataRc) + encoder.Close() + + if err != nil { + pw.CloseWithError(err) + } else { + pw.Close() + } + }() + + out, err := ioutil.ReadAll(pr) + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/modules/git/commit.go b/modules/git/commit.go index 85c9554bb..dad67dada 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -263,6 +263,11 @@ func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) return c.repo.getFilesChanged(pastCommit, c.ID.String()) } +// FileChangedSinceCommit Returns true if the file given has changed since the the past commit +func (c *Commit) FileChangedSinceCommit(filename, pastCommit string) (bool, error) { + return c.repo.FileChangedBetweenCommits(filename, pastCommit, c.ID.String()) +} + // GetSubModules get all the sub modules of current revision git tree func (c *Commit) GetSubModules() (*ObjectCache, error) { if c.submoduleCache != nil { diff --git a/modules/git/repo_commit.go b/modules/git/repo_commit.go index 1ecd1f889..7c65d6e92 100644 --- a/modules/git/repo_commit.go +++ b/modules/git/repo_commit.go @@ -10,7 +10,7 @@ import ( "strconv" "strings" - version "github.com/mcuadros/go-version" + "github.com/mcuadros/go-version" ) // GetRefCommitID returns the last commit ID string of given reference (branch or tag). @@ -270,7 +270,7 @@ func (repo *Repository) searchCommits(id SHA1, opts SearchCommitsOptions) (*list return repo.parsePrettyFormatLogToList(stdout) } -func (repo *Repository) getFilesChanged(id1 string, id2 string) ([]string, error) { +func (repo *Repository) getFilesChanged(id1, id2 string) ([]string, error) { stdout, err := NewCommand("diff", "--name-only", id1, id2).RunInDirBytes(repo.Path) if err != nil { return nil, err @@ -278,6 +278,15 @@ func (repo *Repository) getFilesChanged(id1 string, id2 string) ([]string, error return strings.Split(string(stdout), "\n"), nil } +// FileChangedBetweenCommits Returns true if the file changed between commit IDs id1 and id2 +func (repo *Repository) FileChangedBetweenCommits(filename, id1, id2 string) (bool, error) { + stdout, err := NewCommand("diff", "--name-only", "-z", id1, id2, "--", filename).RunInDirBytes(repo.Path) + if err != nil { + return false, err + } + return len(strings.TrimSpace(string(stdout))) > 0, nil +} + // FileCommitsCount return the number of files at a revison func (repo *Repository) FileCommitsCount(revision, file string) (int64, error) { return commitsCount(repo.Path, revision, file) diff --git a/modules/repofiles/blob.go b/modules/repofiles/blob.go new file mode 100644 index 000000000..2f9ca72bd --- /dev/null +++ b/modules/repofiles/blob.go @@ -0,0 +1,38 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/sdk/gitea" +) + +// GetBlobBySHA get the GitBlobResponse of a repository using a sha hash. +func GetBlobBySHA(repo *models.Repository, sha string) (*api.GitBlobResponse, error) { + gitRepo, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + return nil, err + } + gitBlob, err := gitRepo.GetBlob(sha) + if err != nil { + return nil, err + } + content := "" + if gitBlob.Size() <= setting.API.DefaultMaxBlobSize { + content, err = gitBlob.GetBlobContentBase64() + if err != nil { + return nil, err + } + } + return &api.GitBlobResponse{ + SHA: gitBlob.ID.String(), + URL: repo.APIURL() + "/git/blobs/" + gitBlob.ID.String(), + Size: gitBlob.Size(), + Encoding: "base64", + Content: content, + }, nil +} diff --git a/modules/repofiles/blob_test.go b/modules/repofiles/blob_test.go new file mode 100644 index 000000000..260b775fc --- /dev/null +++ b/modules/repofiles/blob_test.go @@ -0,0 +1,38 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/test" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func TestGetBlobBySHA(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + ctx.SetParams(":id", "1") + ctx.SetParams(":sha", sha) + + gbr, err := GetBlobBySHA(ctx.Repo.Repository, ctx.Params(":sha")) + expectedGBR := &api.GitBlobResponse{ + Content: "Y29tbWl0IDY1ZjFiZjI3YmMzYmY3MGY2NDY1NzY1ODYzNWU2NjA5NGVkYmNiNGQKQXV0aG9yOiB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+CkRhdGU6ICAgU3VuIE1hciAxOSAxNjo0Nzo1OSAyMDE3IC0wNDAwCgogICAgSW5pdGlhbCBjb21taXQKCmRpZmYgLS1naXQgYS9SRUFETUUubWQgYi9SRUFETUUubWQKbmV3IGZpbGUgbW9kZSAxMDA2NDQKaW5kZXggMDAwMDAwMC4uNGI0ODUxYQotLS0gL2Rldi9udWxsCisrKyBiL1JFQURNRS5tZApAQCAtMCwwICsxLDMgQEAKKyMgcmVwbzEKKworRGVzY3JpcHRpb24gZm9yIHJlcG8xClwgTm8gbmV3bGluZSBhdCBlbmQgb2YgZmlsZQo=", + Encoding: "base64", + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + Size: 180, + } + assert.Nil(t, err) + assert.Equal(t, expectedGBR, gbr) +} diff --git a/modules/repofiles/content.go b/modules/repofiles/content.go new file mode 100644 index 000000000..d55ca497c --- /dev/null +++ b/modules/repofiles/content.go @@ -0,0 +1,73 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "net/url" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/sdk/gitea" +) + +// GetFileContents gets the meta data on a file's contents +func GetFileContents(repo *models.Repository, treePath, ref string) (*api.FileContentResponse, error) { + if ref == "" { + ref = repo.DefaultBranch + } + + // Check that the path given in opts.treePath is valid (not a git path) + treePath = CleanUploadFileName(treePath) + if treePath == "" { + return nil, models.ErrFilenameInvalid{ + Path: treePath, + } + } + + gitRepo, err := git.OpenRepository(repo.RepoPath()) + if err != nil { + return nil, err + } + + // Get the commit object for the ref + commit, err := gitRepo.GetCommit(ref) + if err != nil { + return nil, err + } + + entry, err := commit.GetTreeEntryByPath(treePath) + if err != nil { + return nil, err + } + + urlRef := ref + if _, err := gitRepo.GetBranchCommit(ref); err == nil { + urlRef = "branch/" + ref + } + + selfURL, _ := url.Parse(repo.APIURL() + "/contents/" + treePath) + gitURL, _ := url.Parse(repo.APIURL() + "/git/blobs/" + entry.ID.String()) + downloadURL, _ := url.Parse(repo.HTMLURL() + "/raw/" + urlRef + "/" + treePath) + htmlURL, _ := url.Parse(repo.HTMLURL() + "/blob/" + ref + "/" + treePath) + + fileContent := &api.FileContentResponse{ + Name: entry.Name(), + Path: treePath, + SHA: entry.ID.String(), + Size: entry.Size(), + URL: selfURL.String(), + HTMLURL: htmlURL.String(), + GitURL: gitURL.String(), + DownloadURL: downloadURL.String(), + Type: string(entry.Type), + Links: &api.FileLinksResponse{ + Self: selfURL.String(), + GitURL: gitURL.String(), + HTMLURL: htmlURL.String(), + }, + } + + return fileContent, nil +} diff --git a/modules/repofiles/content_test.go b/modules/repofiles/content_test.go new file mode 100644 index 000000000..0257284a5 --- /dev/null +++ b/modules/repofiles/content_test.go @@ -0,0 +1,90 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + models.MainTest(m, filepath.Join("..", "..")) +} + +func TestGetFileContents(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + treePath := "README.md" + ref := ctx.Repo.Repository.DefaultBranch + + expectedFileContentResponse := &gitea.FileContentResponse{ + Name: treePath, + Path: treePath, + SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", + Size: 30, + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/README.md", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/README.md", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f", + DownloadURL: "https://try.gitea.io/user2/repo1/raw/branch/master/README.md", + Type: "blob", + Links: &gitea.FileLinksResponse{ + Self: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/README.md", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/README.md", + }, + } + + t.Run("Get README.md contents", func(t *testing.T) { + fileContentResponse, err := GetFileContents(ctx.Repo.Repository, treePath, ref) + assert.EqualValues(t, expectedFileContentResponse, fileContentResponse) + assert.Nil(t, err) + }) + + t.Run("Get REAMDE.md contents with ref as empty string (should then use the repo's default branch)", func(t *testing.T) { + fileContentResponse, err := GetFileContents(ctx.Repo.Repository, treePath, "") + assert.EqualValues(t, expectedFileContentResponse, fileContentResponse) + assert.Nil(t, err) + }) +} + +func TestGetFileContentsErrors(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + treePath := "README.md" + ref := repo.DefaultBranch + + t.Run("bad treePath", func(t *testing.T) { + badTreePath := "bad/tree.md" + fileContentResponse, err := GetFileContents(repo, badTreePath, ref) + assert.Error(t, err) + assert.EqualError(t, err, "object does not exist [id: , rel_path: bad]") + assert.Nil(t, fileContentResponse) + }) + + t.Run("bad ref", func(t *testing.T) { + badRef := "bad_ref" + fileContentResponse, err := GetFileContents(repo, treePath, badRef) + assert.Error(t, err) + assert.EqualError(t, err, "object does not exist [id: "+badRef+", rel_path: ]") + assert.Nil(t, fileContentResponse) + }) +} diff --git a/modules/repofiles/delete.go b/modules/repofiles/delete.go new file mode 100644 index 000000000..ce7993dc5 --- /dev/null +++ b/modules/repofiles/delete.go @@ -0,0 +1,209 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "fmt" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/sdk/gitea" +) + +// DeleteRepoFileOptions holds the repository delete file options +type DeleteRepoFileOptions struct { + LastCommitID string + OldBranch string + NewBranch string + TreePath string + Message string + SHA string + Author *IdentityOptions + Committer *IdentityOptions +} + +// DeleteRepoFile deletes a file in the given repository +func DeleteRepoFile(repo *models.Repository, doer *models.User, opts *DeleteRepoFileOptions) (*api.FileResponse, error) { + // If no branch name is set, assume the repo's default branch + if opts.OldBranch == "" { + opts.OldBranch = repo.DefaultBranch + } + if opts.NewBranch == "" { + opts.NewBranch = opts.OldBranch + } + + // oldBranch must exist for this operation + if _, err := repo.GetBranch(opts.OldBranch); err != nil { + return nil, err + } + + // A NewBranch can be specified for the file to be created/updated in a new branch. + // Check to make sure the branch does not already exist, otherwise we can't proceed. + // If we aren't branching to a new branch, make sure user can commit to the given branch + if opts.NewBranch != opts.OldBranch { + newBranch, err := repo.GetBranch(opts.NewBranch) + if git.IsErrNotExist(err) { + return nil, err + } + if newBranch != nil { + return nil, models.ErrBranchAlreadyExists{ + BranchName: opts.NewBranch, + } + } + } else { + if protected, _ := repo.IsProtectedBranchForPush(opts.OldBranch, doer); protected { + return nil, models.ErrUserCannotCommit{ + UserName: doer.LowerName, + } + } + } + + // Check that the path given in opts.treeName is valid (not a git path) + treePath := CleanUploadFileName(opts.TreePath) + if treePath == "" { + return nil, models.ErrFilenameInvalid{ + Path: opts.TreePath, + } + } + + message := strings.TrimSpace(opts.Message) + + author, committer := GetAuthorAndCommitterUsers(opts.Committer, opts.Author, doer) + + t, err := NewTemporaryUploadRepository(repo) + defer t.Close() + if err != nil { + return nil, err + } + if err := t.Clone(opts.OldBranch); err != nil { + return nil, err + } + if err := t.SetDefaultIndex(); err != nil { + return nil, err + } + + // Get the commit of the original branch + commit, err := t.GetBranchCommit(opts.OldBranch) + if err != nil { + return nil, err // Couldn't get a commit for the branch + } + + // Assigned LastCommitID in opts if it hasn't been set + if opts.LastCommitID == "" { + opts.LastCommitID = commit.ID.String() + } + + // Get the files in the index + filesInIndex, err := t.LsFiles(opts.TreePath) + if err != nil { + return nil, fmt.Errorf("DeleteRepoFile: %v", err) + } + + // Find the file we want to delete in the index + inFilelist := false + for _, file := range filesInIndex { + if file == opts.TreePath { + inFilelist = true + break + } + } + if !inFilelist { + return nil, models.ErrRepoFileDoesNotExist{ + Path: opts.TreePath, + } + } + + // Get the entry of treePath and check if the SHA given is the same as the file + entry, err := commit.GetTreeEntryByPath(treePath) + if err != nil { + return nil, err + } + if opts.SHA != "" { + // If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error + if opts.SHA != entry.ID.String() { + return nil, models.ErrSHADoesNotMatch{ + Path: treePath, + GivenSHA: opts.SHA, + CurrentSHA: entry.ID.String(), + } + } + } else if opts.LastCommitID != "" { + // If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw + // an error, but only if we aren't creating a new branch. + if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch { + // CommitIDs don't match, but we don't want to throw a ErrCommitIDDoesNotMatch unless + // this specific file has been edited since opts.LastCommitID + if changed, err := commit.FileChangedSinceCommit(treePath, opts.LastCommitID); err != nil { + return nil, err + } else if changed { + return nil, models.ErrCommitIDDoesNotMatch{ + GivenCommitID: opts.LastCommitID, + CurrentCommitID: opts.LastCommitID, + } + } + // The file wasn't modified, so we are good to delete it + } + } else { + // When deleting a file, a lastCommitID or SHA needs to be given to make sure other commits haven't been + // made. We throw an error if one wasn't provided. + return nil, models.ErrSHAOrCommitIDNotProvided{} + } + + // Remove the file from the index + if err := t.RemoveFilesFromIndex(opts.TreePath); err != nil { + return nil, err + } + + // Now write the tree + treeHash, err := t.WriteTree() + if err != nil { + return nil, err + } + + // Now commit the tree + commitHash, err := t.CommitTree(author, committer, treeHash, message) + if err != nil { + return nil, err + } + + // Then push this tree to NewBranch + if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + return nil, err + } + + // Simulate push event. + oldCommitID := opts.LastCommitID + if opts.NewBranch != opts.OldBranch { + oldCommitID = git.EmptySHA + } + + if err = repo.GetOwner(); err != nil { + return nil, fmt.Errorf("GetOwner: %v", err) + } + err = models.PushUpdate( + opts.NewBranch, + models.PushUpdateOptions{ + PusherID: doer.ID, + PusherName: doer.Name, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + RefFullName: git.BranchPrefix + opts.NewBranch, + OldCommitID: oldCommitID, + NewCommitID: commitHash, + }, + ) + if err != nil { + return nil, fmt.Errorf("PushUpdate: %v", err) + } + + // FIXME: Should we UpdateRepoIndexer(repo) here? + + file, err := GetFileResponseFromCommit(repo, commit, opts.NewBranch, treePath) + if err != nil { + return nil, err + } + return file, nil +} diff --git a/modules/repofiles/delete_test.go b/modules/repofiles/delete_test.go new file mode 100644 index 000000000..0b0558e76 --- /dev/null +++ b/modules/repofiles/delete_test.go @@ -0,0 +1,183 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/test" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func getDeleteRepoFileOptions(repo *models.Repository) *DeleteRepoFileOptions { + return &DeleteRepoFileOptions{ + LastCommitID: "", + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + TreePath: "README.md", + Message: "Deletes README.md", + SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", + Author: nil, + Committer: nil, + } +} + +func getExpectedDeleteFileResponse() *api.FileResponse { + return &api.FileResponse{ + Content: nil, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + }, + HTMLURL: "https://try.gitea.io/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "user1", + Email: "address1@example.com", + }, + Date: "2017-03-19T20:47:59Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-03-19T20:47:59Z", + }, + Parents: []*api.CommitMeta{}, + Message: "Initial commit\n", + Tree: &api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/trees/2a2f1d4670728a2e10049e345bd7a276468beab6", + SHA: "2a2f1d4670728a2e10049e345bd7a276468beab6", + }, + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "", + Signature: "", + Payload: "", + }, + } +} + +func TestDeleteRepoFile(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + opts := getDeleteRepoFileOptions(repo) + + t.Run("Delete README.md file", func(t *testing.T) { + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Nil(t, err) + expectedFileResponse := getExpectedDeleteFileResponse() + assert.EqualValues(t, expectedFileResponse, fileResponse) + }) + + t.Run("Verify README.md has been deleted", func(t *testing.T) { + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + expectedError := "repository file does not exist [path: " + opts.TreePath + "]" + assert.EqualError(t, err, expectedError) + }) +} + +// Test opts with branch names removed, same results +func TestDeleteRepoFileWithoutBranchNames(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + opts := getDeleteRepoFileOptions(repo) + opts.OldBranch = "" + opts.NewBranch = "" + + t.Run("Delete README.md without Branch Name", func(t *testing.T) { + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Nil(t, err) + expectedFileResponse := getExpectedDeleteFileResponse() + assert.EqualValues(t, expectedFileResponse, fileResponse) + }) +} + +func TestDeleteRepoFileErrors(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + + t.Run("Bad branch", func(t *testing.T) { + opts := getDeleteRepoFileOptions(repo) + opts.OldBranch = "bad_branch" + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Error(t, err) + assert.Nil(t, fileResponse) + expectedError := "branch does not exist [name: " + opts.OldBranch + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("Bad SHA", func(t *testing.T) { + opts := getDeleteRepoFileOptions(repo) + origSHA := opts.SHA + opts.SHA = "bad_sha" + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "sha does not match [given: " + opts.SHA + ", expected: " + origSHA + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("New branch already exists", func(t *testing.T) { + opts := getDeleteRepoFileOptions(repo) + opts.NewBranch = "develop" + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "branch already exists [name: " + opts.NewBranch + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("TreePath is empty:", func(t *testing.T) { + opts := getDeleteRepoFileOptions(repo) + opts.TreePath = "" + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "path contains a malformed path component [path: ]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("TreePath is a git directory:", func(t *testing.T) { + opts := getDeleteRepoFileOptions(repo) + opts.TreePath = ".git" + fileResponse, err := DeleteRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "path contains a malformed path component [path: " + opts.TreePath + "]" + assert.EqualError(t, err, expectedError) + }) +} diff --git a/modules/uploader/diff.go b/modules/repofiles/diff.go similarity index 92% rename from modules/uploader/diff.go rename to modules/repofiles/diff.go index e01947ea6..3b5de5fa6 100644 --- a/modules/uploader/diff.go +++ b/modules/repofiles/diff.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package uploader +package repofiles import ( "strings" @@ -12,11 +12,14 @@ import ( // GetDiffPreview produces and returns diff result of a file which is not yet committed. func GetDiffPreview(repo *models.Repository, branch, treePath, content string) (*models.Diff, error) { + if branch == "" { + branch = repo.DefaultBranch + } t, err := NewTemporaryUploadRepository(repo) - defer t.Close() if err != nil { return nil, err } + defer t.Close() if err := t.Clone(branch); err != nil { return nil, err } diff --git a/modules/repofiles/diff_test.go b/modules/repofiles/diff_test.go new file mode 100644 index 000000000..bc7d4ebad --- /dev/null +++ b/modules/repofiles/diff_test.go @@ -0,0 +1,143 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestGetDiffPreview(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + branch := ctx.Repo.Repository.DefaultBranch + treePath := "README.md" + content := "# repo1\n\nDescription for repo1\nthis is a new line" + + expectedDiff := &models.Diff{ + TotalAddition: 2, + TotalDeletion: 1, + Files: []*models.DiffFile{ + { + Name: "README.md", + OldName: "README.md", + Index: 1, + Addition: 2, + Deletion: 1, + Type: 2, + IsCreated: false, + IsDeleted: false, + IsBin: false, + IsLFSFile: false, + IsRenamed: false, + IsSubmodule: false, + Sections: []*models.DiffSection{ + { + Name: "", + Lines: []*models.DiffLine{ + { + LeftIdx: 0, + RightIdx: 0, + Type: 4, + Content: "@@ -1,3 +1,4 @@", + Comments: nil, + }, + { + LeftIdx: 1, + RightIdx: 1, + Type: 1, + Content: " # repo1", + Comments: nil, + }, + { + LeftIdx: 2, + RightIdx: 2, + Type: 1, + Content: " ", + Comments: nil, + }, + { + LeftIdx: 3, + RightIdx: 0, + Type: 3, + Content: "-Description for repo1", + Comments: nil, + }, + { + LeftIdx: 0, + RightIdx: 3, + Type: 2, + Content: "+Description for repo1", + Comments: nil, + }, + { + LeftIdx: 0, + RightIdx: 4, + Type: 2, + Content: "+this is a new line", + Comments: nil, + }, + }, + }, + }, + IsIncomplete: false, + }, + }, + IsIncomplete: false, + } + + t.Run("with given branch", func(t *testing.T) { + diff, err := GetDiffPreview(ctx.Repo.Repository, branch, treePath, content) + assert.Nil(t, err) + assert.EqualValues(t, expectedDiff, diff) + }) + + t.Run("empty branch, same results", func(t *testing.T) { + diff, err := GetDiffPreview(ctx.Repo.Repository, "", treePath, content) + assert.Nil(t, err) + assert.EqualValues(t, expectedDiff, diff) + }) +} + +func TestGetDiffPreviewErrors(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + branch := ctx.Repo.Repository.DefaultBranch + treePath := "README.md" + content := "# repo1\n\nDescription for repo1\nthis is a new line" + + t.Run("empty repo", func(t *testing.T) { + diff, err := GetDiffPreview(&models.Repository{}, branch, treePath, content) + assert.Nil(t, diff) + assert.EqualError(t, err, "repository does not exist [id: 0, uid: 0, owner_name: , name: ]") + }) + + t.Run("bad branch", func(t *testing.T) { + badBranch := "bad_branch" + diff, err := GetDiffPreview(ctx.Repo.Repository, badBranch, treePath, content) + assert.Nil(t, diff) + assert.EqualError(t, err, "branch does not exist [name: "+badBranch+"]") + }) + + t.Run("empty treePath", func(t *testing.T) { + diff, err := GetDiffPreview(ctx.Repo.Repository, branch, "", content) + assert.Nil(t, diff) + assert.EqualError(t, err, "path is invalid [path: ]") + }) +} diff --git a/modules/repofiles/file.go b/modules/repofiles/file.go new file mode 100644 index 000000000..913a9ed53 --- /dev/null +++ b/modules/repofiles/file.go @@ -0,0 +1,125 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "fmt" + "net/url" + "strings" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/sdk/gitea" +) + +// GetFileResponseFromCommit Constructs a FileResponse from a Commit object +func GetFileResponseFromCommit(repo *models.Repository, commit *git.Commit, branch, treeName string) (*api.FileResponse, error) { + fileContents, _ := GetFileContents(repo, treeName, branch) // ok if fails, then will be nil + fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil + verification := GetPayloadCommitVerification(commit) + fileResponse := &api.FileResponse{ + Content: fileContents, + Commit: fileCommitResponse, + Verification: verification, + } + return fileResponse, nil +} + +// GetFileCommitResponse Constructs a FileCommitResponse from a Commit object +func GetFileCommitResponse(repo *models.Repository, commit *git.Commit) (*api.FileCommitResponse, error) { + if repo == nil { + return nil, fmt.Errorf("repo cannot be nil") + } + if commit == nil { + return nil, fmt.Errorf("commit cannot be nil") + } + commitURL, _ := url.Parse(repo.APIURL() + "/git/commits/" + commit.ID.String()) + commitTreeURL, _ := url.Parse(repo.APIURL() + "/git/trees/" + commit.Tree.ID.String()) + parents := make([]*api.CommitMeta, commit.ParentCount()) + for i := 0; i <= commit.ParentCount(); i++ { + if parent, err := commit.Parent(i); err == nil && parent != nil { + parentCommitURL, _ := url.Parse(repo.APIURL() + "/git/commits/" + parent.ID.String()) + parents[i] = &api.CommitMeta{ + SHA: parent.ID.String(), + URL: parentCommitURL.String(), + } + } + } + commitHTMLURL, _ := url.Parse(repo.HTMLURL() + "/commit/" + commit.ID.String()) + fileCommit := &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + SHA: commit.ID.String(), + URL: commitURL.String(), + }, + HTMLURL: commitHTMLURL.String(), + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: commit.Author.Name, + Email: commit.Author.Email, + }, + Date: commit.Author.When.UTC().Format(time.RFC3339), + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: commit.Committer.Name, + Email: commit.Committer.Email, + }, + Date: commit.Committer.When.UTC().Format(time.RFC3339), + }, + Message: commit.Message(), + Tree: &api.CommitMeta{ + URL: commitTreeURL.String(), + SHA: commit.Tree.ID.String(), + }, + Parents: parents, + } + return fileCommit, nil +} + +// GetAuthorAndCommitterUsers Gets the author and committer user objects from the IdentityOptions +func GetAuthorAndCommitterUsers(author, committer *IdentityOptions, doer *models.User) (committerUser, authorUser *models.User) { + // Committer and author are optional. If they are not the doer (not same email address) + // then we use bogus User objects for them to store their FullName and Email. + // If only one of the two are provided, we set both of them to it. + // If neither are provided, both are the doer. + if committer != nil && committer.Email != "" { + if doer != nil && strings.ToLower(doer.Email) == strings.ToLower(committer.Email) { + committerUser = doer // the committer is the doer, so will use their user object + if committer.Name != "" { + committerUser.FullName = committer.Name + } + } else { + committerUser = &models.User{ + FullName: committer.Name, + Email: committer.Email, + } + } + } + if author != nil && author.Email != "" { + if doer != nil && strings.ToLower(doer.Email) == strings.ToLower(author.Email) { + authorUser = doer // the author is the doer, so will use their user object + if authorUser.Name != "" { + authorUser.FullName = author.Name + } + } else { + authorUser = &models.User{ + FullName: author.Name, + Email: author.Email, + } + } + } + if authorUser == nil { + if committerUser != nil { + authorUser = committerUser // No valid author was given so use the committer + } else if doer != nil { + authorUser = doer // No valid author was given and no valid committer so use the doer + } + } + if committerUser == nil { + committerUser = authorUser // No valid committer so use the author as the committer (was set to a valid user above) + } + return authorUser, committerUser +} diff --git a/modules/repofiles/file_test.go b/modules/repofiles/file_test.go new file mode 100644 index 000000000..c9ee7f21e --- /dev/null +++ b/modules/repofiles/file_test.go @@ -0,0 +1,90 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "testing" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/test" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func getExpectedFileResponse() *api.FileResponse { + return &api.FileResponse{ + Content: &api.FileContentResponse{ + Name: "README.md", + Path: "README.md", + SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", + Size: 30, + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/README.md", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/README.md", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f", + DownloadURL: "https://try.gitea.io/user2/repo1/raw/branch/master/README.md", + Type: "blob", + Links: &api.FileLinksResponse{ + Self: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/README.md", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/4b4851ad51df6a7d9f25c979345979eaeb5b349f", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/README.md", + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + }, + HTMLURL: "https://try.gitea.io/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d", + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "user1", + Email: "address1@example.com", + }, + Date: "2017-03-19T20:47:59Z", + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "Ethan Koenig", + Email: "ethantkoenig@gmail.com", + }, + Date: "2017-03-19T20:47:59Z", + }, + Parents: []*api.CommitMeta{}, + Message: "Initial commit\n", + Tree: &api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/trees/2a2f1d4670728a2e10049e345bd7a276468beab6", + SHA: "2a2f1d4670728a2e10049e345bd7a276468beab6", + }, + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "", + Signature: "", + Payload: "", + }, + } +} + +func TestGetFileResponseFromCommit(t *testing.T) { + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + branch := repo.DefaultBranch + treePath := "README.md" + gitRepo, _ := git.OpenRepository(repo.RepoPath()) + commit, _ := gitRepo.GetBranchCommit(branch) + expectedFileResponse := getExpectedFileResponse() + + fileResponse, err := GetFileResponseFromCommit(repo, commit, branch, treePath) + assert.Nil(t, err) + assert.EqualValues(t, expectedFileResponse, fileResponse) +} diff --git a/modules/repofiles/repofiles.go b/modules/repofiles/repofiles.go new file mode 100644 index 000000000..1fc900490 --- /dev/null +++ b/modules/repofiles/repofiles.go @@ -0,0 +1,23 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file.package repofiles + +package repofiles + +import ( + "path" + "strings" +) + +// CleanUploadFileName Trims a filename and returns empty string if it is a .git directory +func CleanUploadFileName(name string) string { + // Rebase the filename + name = strings.Trim(path.Clean("/"+name), " /") + // Git disallows any filenames to have a .git directory in them. + for _, part := range strings.Split(name, "/") { + if strings.ToLower(part) == ".git" { + return "" + } + } + return name +} diff --git a/modules/repofiles/repofiles_test.go b/modules/repofiles/repofiles_test.go new file mode 100644 index 000000000..1686378a4 --- /dev/null +++ b/modules/repofiles/repofiles_test.go @@ -0,0 +1,27 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCleanUploadFileName(t *testing.T) { + t.Run("Clean regular file", func(t *testing.T) { + name := "this/is/test" + cleanName := CleanUploadFileName(name) + expectedCleanName := name + assert.EqualValues(t, expectedCleanName, cleanName) + }) + + t.Run("Clean a .git path", func(t *testing.T) { + name := "this/is/test/.git" + cleanName := CleanUploadFileName(name) + expectedCleanName := "" + assert.EqualValues(t, expectedCleanName, cleanName) + }) +} diff --git a/modules/uploader/repo.go b/modules/repofiles/temp_repo.go similarity index 80% rename from modules/uploader/repo.go rename to modules/repofiles/temp_repo.go index 33cc160ca..5bf64d52a 100644 --- a/modules/uploader/repo.go +++ b/modules/repofiles/temp_repo.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package uploader +package repofiles import ( "bytes" @@ -12,19 +12,22 @@ import ( "os" "os/exec" "path" + "regexp" "strings" "time" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/setting" "github.com/Unknwon/com" ) -// TemporaryUploadRepository is a type to wrap our upload repositories +// TemporaryUploadRepository is a type to wrap our upload repositories as a shallow clone type TemporaryUploadRepository struct { repo *models.Repository + gitRepo *git.Repository basePath string } @@ -33,7 +36,10 @@ func NewTemporaryUploadRepository(repo *models.Repository) (*TemporaryUploadRepo timeStr := com.ToStr(time.Now().Nanosecond()) // SHOULD USE SOMETHING UNIQUE basePath := path.Join(models.LocalCopyPath(), "upload-"+timeStr+".git") if err := os.MkdirAll(path.Dir(basePath), os.ModePerm); err != nil { - return nil, fmt.Errorf("Failed to create dir %s: %v", basePath, err) + return nil, fmt.Errorf("failed to create dir %s: %v", basePath, err) + } + if repo.RepoPath() == "" { + return nil, fmt.Errorf("no path to repository on system") } t := &TemporaryUploadRepository{repo: repo, basePath: basePath} return t, nil @@ -51,8 +57,26 @@ func (t *TemporaryUploadRepository) Clone(branch string) error { if _, stderr, err := process.GetManager().ExecTimeout(5*time.Minute, fmt.Sprintf("Clone (git clone -s --bare): %s", t.basePath), "git", "clone", "-s", "--bare", "-b", branch, t.repo.RepoPath(), t.basePath); err != nil { - return fmt.Errorf("Clone: %v %s", err, stderr) + if matched, _ := regexp.MatchString(".*Remote branch .* not found in upstream origin.*", stderr); matched { + return models.ErrBranchNotExist{ + Name: branch, + } + } else if matched, _ := regexp.MatchString(".* repository .* does not exist.*", stderr); matched { + return models.ErrRepoNotExist{ + ID: t.repo.ID, + UID: t.repo.OwnerID, + OwnerName: t.repo.OwnerName, + Name: t.repo.Name, + } + } else { + return fmt.Errorf("Clone: %v %s", err, stderr) + } + } + gitRepo, err := git.OpenRepository(t.basePath) + if err != nil { + return err } + t.gitRepo = gitRepo return nil } @@ -186,6 +210,12 @@ func (t *TemporaryUploadRepository) AddObjectToIndex(mode, objectHash, objectPat t.basePath, fmt.Sprintf("addObjectToIndex (git update-index): %s", t.basePath), "git", "update-index", "--add", "--replace", "--cacheinfo", mode, objectHash, objectPath); err != nil { + if matched, _ := regexp.MatchString(".*Invalid path '.*", stderr); matched { + return models.ErrFilePathInvalid{ + Message: objectPath, + Path: objectPath, + } + } return fmt.Errorf("git update-index: %s", stderr) } return nil @@ -201,22 +231,42 @@ func (t *TemporaryUploadRepository) WriteTree() (string, error) { return "", fmt.Errorf("git write-tree: %s", stderr) } return strings.TrimSpace(treeHash), nil +} +// GetLastCommit gets the last commit ID SHA of the repo +func (t *TemporaryUploadRepository) GetLastCommit() (string, error) { + return t.GetLastCommitByRef("HEAD") +} + +// GetLastCommitByRef gets the last commit ID SHA of the repo by ref +func (t *TemporaryUploadRepository) GetLastCommitByRef(ref string) (string, error) { + if ref == "" { + ref = "HEAD" + } + treeHash, stderr, err := process.GetManager().ExecDir(5*time.Minute, + t.basePath, + fmt.Sprintf("GetLastCommit (git rev-parse %s): %s", ref, t.basePath), + "git", "rev-parse", ref) + if err != nil { + return "", fmt.Errorf("git rev-parse %s: %s", ref, stderr) + } + return strings.TrimSpace(treeHash), nil } // CommitTree creates a commit from a given tree for the user with provided message -func (t *TemporaryUploadRepository) CommitTree(doer *models.User, treeHash string, message string) (string, error) { +func (t *TemporaryUploadRepository) CommitTree(author, committer *models.User, treeHash string, message string) (string, error) { commitTimeStr := time.Now().Format(time.UnixDate) - sig := doer.NewGitSig() + authorSig := author.NewGitSig() + committerSig := committer.NewGitSig() // FIXME: Should we add SSH_ORIGINAL_COMMAND to this // Because this may call hooks we should pass in the environment env := append(os.Environ(), - "GIT_AUTHOR_NAME="+sig.Name, - "GIT_AUTHOR_EMAIL="+sig.Email, + "GIT_AUTHOR_NAME="+authorSig.Name, + "GIT_AUTHOR_EMAIL="+authorSig.Email, "GIT_AUTHOR_DATE="+commitTimeStr, - "GIT_COMMITTER_NAME="+sig.Name, - "GIT_COMMITTER_EMAIL="+sig.Email, + "GIT_COMMITTER_NAME="+committerSig.Name, + "GIT_COMMITTER_EMAIL="+committerSig.Email, "GIT_COMMITTER_DATE="+commitTimeStr, ) commitHash, stderr, err := process.GetManager().ExecDirEnv(5*time.Minute, @@ -357,3 +407,19 @@ func (t *TemporaryUploadRepository) CheckAttribute(attribute string, args ...str return name2attribute2info, err } + +// GetBranchCommit Gets the commit object of the given branch +func (t *TemporaryUploadRepository) GetBranchCommit(branch string) (*git.Commit, error) { + if t.gitRepo == nil { + return nil, fmt.Errorf("repository has not been cloned") + } + return t.gitRepo.GetBranchCommit(branch) +} + +// GetCommit Gets the commit object of the given commit ID +func (t *TemporaryUploadRepository) GetCommit(commitID string) (*git.Commit, error) { + if t.gitRepo == nil { + return nil, fmt.Errorf("repository has not been cloned") + } + return t.gitRepo.GetCommit(commitID) +} diff --git a/modules/repofiles/tree.go b/modules/repofiles/tree.go new file mode 100644 index 000000000..8766ed36d --- /dev/null +++ b/modules/repofiles/tree.go @@ -0,0 +1,92 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "fmt" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/sdk/gitea" +) + +// GetTreeBySHA get the GitTreeResponse of a repository using a sha hash. +func GetTreeBySHA(repo *models.Repository, sha string, page, perPage int, recursive bool) (*api.GitTreeResponse, error) { + gitRepo, err := git.OpenRepository(repo.RepoPath()) + gitTree, err := gitRepo.GetTree(sha) + if err != nil || gitTree == nil { + return nil, models.ErrSHANotFound{ + SHA: sha, + } + } + tree := new(api.GitTreeResponse) + tree.SHA = gitTree.ID.String() + tree.URL = repo.APIURL() + "/git/trees/" + tree.SHA + var entries git.Entries + if recursive { + entries, err = gitTree.ListEntriesRecursive() + } else { + entries, err = gitTree.ListEntries() + } + if err != nil { + return nil, err + } + apiURL := repo.APIURL() + apiURLLen := len(apiURL) + + // 51 is len(sha1) + len("/git/blobs/"). 40 + 11. + blobURL := make([]byte, apiURLLen+51) + copy(blobURL[:], apiURL) + copy(blobURL[apiURLLen:], "/git/blobs/") + + // 51 is len(sha1) + len("/git/trees/"). 40 + 11. + treeURL := make([]byte, apiURLLen+51) + copy(treeURL[:], apiURL) + copy(treeURL[apiURLLen:], "/git/trees/") + + // 40 is the size of the sha1 hash in hexadecimal format. + copyPos := len(treeURL) - 40 + + if perPage <= 0 || perPage > setting.API.DefaultGitTreesPerPage { + perPage = setting.API.DefaultGitTreesPerPage + } + if page <= 0 { + page = 1 + } + tree.Page = page + tree.TotalCount = len(entries) + rangeStart := perPage * (page - 1) + if rangeStart >= len(entries) { + return tree, nil + } + var rangeEnd int + if len(entries) > perPage { + tree.Truncated = true + } + if rangeStart+perPage < len(entries) { + rangeEnd = rangeStart + perPage + } else { + rangeEnd = len(entries) + } + tree.Entries = make([]api.GitEntry, rangeEnd-rangeStart) + for e := rangeStart; e < rangeEnd; e++ { + i := e - rangeStart + tree.Entries[i].Path = entries[e].Name() + tree.Entries[i].Mode = fmt.Sprintf("%06x", entries[e].Mode()) + tree.Entries[i].Type = string(entries[e].Type) + tree.Entries[i].Size = entries[e].Size() + tree.Entries[i].SHA = entries[e].ID.String() + + if entries[e].IsDir() { + copy(treeURL[copyPos:], entries[e].ID.String()) + tree.Entries[i].URL = string(treeURL[:]) + } else { + copy(blobURL[copyPos:], entries[e].ID.String()) + tree.Entries[i].URL = string(blobURL[:]) + } + } + return tree, nil +} diff --git a/routers/api/v1/repo/tree_test.go b/modules/repofiles/tree_test.go similarity index 78% rename from routers/api/v1/repo/tree_test.go rename to modules/repofiles/tree_test.go index 708516e97..266dc9167 100644 --- a/routers/api/v1/repo/tree_test.go +++ b/modules/repofiles/tree_test.go @@ -2,34 +2,37 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package repo +package repofiles import ( - "github.com/stretchr/testify/assert" "testing" "code.gitea.io/gitea/models" - "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/test" - "code.gitea.io/sdk/gitea" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" ) func TestGetTreeBySHA(t *testing.T) { models.PrepareTestEnv(t) - sha := "master" ctx := test.MockContext(t, "user2/repo1") - ctx.SetParams(":id", "1") - ctx.SetParams(":sha", sha) test.LoadRepo(t, ctx, 1) test.LoadRepoCommit(t, ctx) test.LoadUser(t, ctx, 2) test.LoadGitRepo(t, ctx) + sha := ctx.Repo.Repository.DefaultBranch + page := 1 + perPage := 10 + ctx.SetParams(":id", "1") + ctx.SetParams(":sha", sha) - tree := GetTreeBySHA(&context.APIContext{Context: ctx, Org: nil}, ctx.Params("sha")) - expectedTree := &gitea.GitTreeResponse{ + tree, err := GetTreeBySHA(ctx.Repo.Repository, ctx.Params(":sha"), page, perPage, true) + assert.Nil(t, err) + expectedTree := &api.GitTreeResponse{ SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/trees/65f1bf27bc3bf70f64657658635e66094edbcb4d", - Entries: []gitea.GitEntry{ + Entries: []api.GitEntry{ { Path: "README.md", Mode: "100644", @@ -43,6 +46,5 @@ func TestGetTreeBySHA(t *testing.T) { Page: 1, TotalCount: 1, } - assert.EqualValues(t, tree, expectedTree) } diff --git a/modules/repofiles/update.go b/modules/repofiles/update.go new file mode 100644 index 000000000..216df18cd --- /dev/null +++ b/modules/repofiles/update.go @@ -0,0 +1,331 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "fmt" + "path" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/sdk/gitea" +) + +// IdentityOptions for a person's identity like an author or committer +type IdentityOptions struct { + Name string + Email string +} + +// UpdateRepoFileOptions holds the repository file update options +type UpdateRepoFileOptions struct { + LastCommitID string + OldBranch string + NewBranch string + TreePath string + FromTreePath string + Message string + Content string + SHA string + IsNewFile bool + Author *IdentityOptions + Committer *IdentityOptions +} + +// CreateOrUpdateRepoFile adds or updates a file in the given repository +func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *UpdateRepoFileOptions) (*gitea.FileResponse, error) { + // If no branch name is set, assume master + if opts.OldBranch == "" { + opts.OldBranch = repo.DefaultBranch + } + if opts.NewBranch == "" { + opts.NewBranch = opts.OldBranch + } + + // oldBranch must exist for this operation + if _, err := repo.GetBranch(opts.OldBranch); err != nil { + return nil, err + } + + // A NewBranch can be specified for the file to be created/updated in a new branch. + // Check to make sure the branch does not already exist, otherwise we can't proceed. + // If we aren't branching to a new branch, make sure user can commit to the given branch + if opts.NewBranch != opts.OldBranch { + existingBranch, err := repo.GetBranch(opts.NewBranch) + if existingBranch != nil { + return nil, models.ErrBranchAlreadyExists{ + BranchName: opts.NewBranch, + } + } + if err != nil && !models.IsErrBranchNotExist(err) { + return nil, err + } + } else { + if protected, _ := repo.IsProtectedBranchForPush(opts.OldBranch, doer); protected { + return nil, models.ErrUserCannotCommit{UserName: doer.LowerName} + } + } + + // If FromTreePath is not set, set it to the opts.TreePath + if opts.TreePath != "" && opts.FromTreePath == "" { + opts.FromTreePath = opts.TreePath + } + + // Check that the path given in opts.treePath is valid (not a git path) + treePath := CleanUploadFileName(opts.TreePath) + if treePath == "" { + return nil, models.ErrFilenameInvalid{ + Path: opts.TreePath, + } + } + // If there is a fromTreePath (we are copying it), also clean it up + fromTreePath := CleanUploadFileName(opts.FromTreePath) + if fromTreePath == "" && opts.FromTreePath != "" { + return nil, models.ErrFilenameInvalid{ + Path: opts.FromTreePath, + } + } + + message := strings.TrimSpace(opts.Message) + + author, committer := GetAuthorAndCommitterUsers(opts.Committer, opts.Author, doer) + + t, err := NewTemporaryUploadRepository(repo) + defer t.Close() + if err != nil { + return nil, err + } + if err := t.Clone(opts.OldBranch); err != nil { + return nil, err + } + if err := t.SetDefaultIndex(); err != nil { + return nil, err + } + + // Get the commit of the original branch + commit, err := t.GetBranchCommit(opts.OldBranch) + if err != nil { + return nil, err // Couldn't get a commit for the branch + } + + // Assigned LastCommitID in opts if it hasn't been set + if opts.LastCommitID == "" { + opts.LastCommitID = commit.ID.String() + } + + if !opts.IsNewFile { + fromEntry, err := commit.GetTreeEntryByPath(fromTreePath) + if err != nil { + return nil, err + } + if opts.SHA != "" { + // If a SHA was given and the SHA given doesn't match the SHA of the fromTreePath, throw error + if opts.SHA != fromEntry.ID.String() { + return nil, models.ErrSHADoesNotMatch{ + Path: treePath, + GivenSHA: opts.SHA, + CurrentSHA: fromEntry.ID.String(), + } + } + } else if opts.LastCommitID != "" { + // If a lastCommitID was given and it doesn't match the commitID of the head of the branch throw + // an error, but only if we aren't creating a new branch. + if commit.ID.String() != opts.LastCommitID && opts.OldBranch == opts.NewBranch { + if changed, err := commit.FileChangedSinceCommit(treePath, opts.LastCommitID); err != nil { + return nil, err + } else if changed { + return nil, models.ErrCommitIDDoesNotMatch{ + GivenCommitID: opts.LastCommitID, + CurrentCommitID: opts.LastCommitID, + } + } + // The file wasn't modified, so we are good to delete it + } + } else { + // When updating a file, a lastCommitID or SHA needs to be given to make sure other commits + // haven't been made. We throw an error if one wasn't provided. + return nil, models.ErrSHAOrCommitIDNotProvided{} + } + } + + // For the path where this file will be created/updated, we need to make + // sure no parts of the path are existing files or links except for the last + // item in the path which is the file name, and that shouldn't exist IF it is + // a new file OR is being moved to a new path. + treePathParts := strings.Split(treePath, "/") + subTreePath := "" + for index, part := range treePathParts { + subTreePath = path.Join(subTreePath, part) + entry, err := commit.GetTreeEntryByPath(subTreePath) + if err != nil { + if git.IsErrNotExist(err) { + // Means there is no item with that name, so we're good + break + } + return nil, err + } + if index < len(treePathParts)-1 { + if !entry.IsDir() { + return nil, models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a file exists where you’re trying to create a subdirectory [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeBlob, + } + } + } else if entry.IsLink() { + return nil, models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a symbolic link exists where you’re trying to create a subdirectory [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeSymlink, + } + } else if entry.IsDir() { + return nil, models.ErrFilePathInvalid{ + Message: fmt.Sprintf("a directory exists where you’re trying to create a file [path: %s]", subTreePath), + Path: subTreePath, + Name: part, + Type: git.EntryModeTree, + } + } else if fromTreePath != treePath || opts.IsNewFile { + // The entry shouldn't exist if we are creating new file or moving to a new path + return nil, models.ErrRepoFileAlreadyExists{ + Path: treePath, + } + } + + } + + // Get the two paths (might be the same if not moving) from the index if they exist + filesInIndex, err := t.LsFiles(opts.TreePath, opts.FromTreePath) + if err != nil { + return nil, fmt.Errorf("UpdateRepoFile: %v", err) + } + // If is a new file (not updating) then the given path shouldn't exist + if opts.IsNewFile { + for _, file := range filesInIndex { + if file == opts.TreePath { + return nil, models.ErrRepoFileAlreadyExists{ + Path: opts.TreePath, + } + } + } + } + + // Remove the old path from the tree + if fromTreePath != treePath && len(filesInIndex) > 0 { + for _, file := range filesInIndex { + if file == fromTreePath { + if err := t.RemoveFilesFromIndex(opts.FromTreePath); err != nil { + return nil, err + } + } + } + } + + // Check there is no way this can return multiple infos + filename2attribute2info, err := t.CheckAttribute("filter", treePath) + if err != nil { + return nil, err + } + + content := opts.Content + var lfsMetaObject *models.LFSMetaObject + + if filename2attribute2info[treePath] != nil && filename2attribute2info[treePath]["filter"] == "lfs" { + // OK so we are supposed to LFS this data! + oid, err := models.GenerateLFSOid(strings.NewReader(opts.Content)) + if err != nil { + return nil, err + } + lfsMetaObject = &models.LFSMetaObject{Oid: oid, Size: int64(len(opts.Content)), RepositoryID: repo.ID} + content = lfsMetaObject.Pointer() + } + + // Add the object to the database + objectHash, err := t.HashObject(strings.NewReader(content)) + if err != nil { + return nil, err + } + + // Add the object to the index + if err := t.AddObjectToIndex("100644", objectHash, treePath); err != nil { + return nil, err + } + + // Now write the tree + treeHash, err := t.WriteTree() + if err != nil { + return nil, err + } + + // Now commit the tree + commitHash, err := t.CommitTree(author, committer, treeHash, message) + if err != nil { + return nil, err + } + + if lfsMetaObject != nil { + // We have an LFS object - create it + lfsMetaObject, err = models.NewLFSMetaObject(lfsMetaObject) + if err != nil { + return nil, err + } + contentStore := &lfs.ContentStore{BasePath: setting.LFS.ContentPath} + if !contentStore.Exists(lfsMetaObject) { + if err := contentStore.Put(lfsMetaObject, strings.NewReader(opts.Content)); err != nil { + if err2 := repo.RemoveLFSMetaObjectByOid(lfsMetaObject.Oid); err2 != nil { + return nil, fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %v)", lfsMetaObject.Oid, err2, err) + } + return nil, err + } + } + } + + // Then push this tree to NewBranch + if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { + return nil, err + } + + // Simulate push event. + oldCommitID := opts.LastCommitID + if opts.NewBranch != opts.OldBranch || oldCommitID == "" { + oldCommitID = git.EmptySHA + } + + if err = repo.GetOwner(); err != nil { + return nil, fmt.Errorf("GetOwner: %v", err) + } + err = models.PushUpdate( + opts.NewBranch, + models.PushUpdateOptions{ + PusherID: doer.ID, + PusherName: doer.Name, + RepoUserName: repo.Owner.Name, + RepoName: repo.Name, + RefFullName: git.BranchPrefix + opts.NewBranch, + OldCommitID: oldCommitID, + NewCommitID: commitHash, + }, + ) + if err != nil { + return nil, fmt.Errorf("PushUpdate: %v", err) + } + models.UpdateRepoIndexer(repo) + + commit, err = t.GetCommit(commitHash) + if err != nil { + return nil, err + } + + file, err := GetFileResponseFromCommit(repo, commit, opts.NewBranch, treePath) + if err != nil { + return nil, err + } + return file, nil +} diff --git a/modules/repofiles/update_test.go b/modules/repofiles/update_test.go new file mode 100644 index 000000000..bf2802179 --- /dev/null +++ b/modules/repofiles/update_test.go @@ -0,0 +1,357 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "testing" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/test" + api "code.gitea.io/sdk/gitea" + + "github.com/stretchr/testify/assert" +) + +func getCreateRepoFileOptions(repo *models.Repository) *UpdateRepoFileOptions { + return &UpdateRepoFileOptions{ + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + TreePath: "new/file.txt", + Message: "Creates new/file.txt", + Content: "This is a NEW file", + IsNewFile: true, + Author: nil, + Committer: nil, + } +} + +func getUpdateRepoFileOptions(repo *models.Repository) *UpdateRepoFileOptions { + return &UpdateRepoFileOptions{ + OldBranch: repo.DefaultBranch, + NewBranch: repo.DefaultBranch, + TreePath: "README.md", + Message: "Updates README.md", + SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f", + Content: "This is UPDATED content for the README file", + IsNewFile: false, + Author: nil, + Committer: nil, + } +} + +func getExpectedFileResponseForCreate(commitID string) *api.FileResponse { + return &api.FileResponse{ + Content: &api.FileContentResponse{ + Name: "file.txt", + Path: "new/file.txt", + SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885", + Size: 18, + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/new/file.txt", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/new/file.txt", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/103ff9234cefeee5ec5361d22b49fbb04d385885", + DownloadURL: "https://try.gitea.io/user2/repo1/raw/branch/master/new/file.txt", + Type: "blob", + Links: &api.FileLinksResponse{ + Self: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/new/file.txt", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/103ff9234cefeee5ec5361d22b49fbb04d385885", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/new/file.txt", + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: "https://try.gitea.io/user2/repo1/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Parents: []*api.CommitMeta{ + { + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + }, + }, + Message: "Updates README.md\n", + Tree: &api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc", + SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc", + }, + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "unsigned", + Signature: "", + Payload: "", + }, + } +} + +func getExpectedFileResponseForUpdate(commitID string) *api.FileResponse { + return &api.FileResponse{ + Content: &api.FileContentResponse{ + Name: "README.md", + Path: "README.md", + SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647", + Size: 43, + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/README.md", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/README.md", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/dbf8d00e022e05b7e5cf7e535de857de57925647", + DownloadURL: "https://try.gitea.io/user2/repo1/raw/branch/master/README.md", + Type: "blob", + Links: &api.FileLinksResponse{ + Self: "https://try.gitea.io/api/v1/repos/user2/repo1/contents/README.md", + GitURL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/dbf8d00e022e05b7e5cf7e535de857de57925647", + HTMLURL: "https://try.gitea.io/user2/repo1/blob/master/README.md", + }, + }, + Commit: &api.FileCommitResponse{ + CommitMeta: api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/commits/" + commitID, + SHA: commitID, + }, + HTMLURL: "https://try.gitea.io/user2/repo1/commit/" + commitID, + Author: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Committer: &api.CommitUser{ + Identity: api.Identity{ + Name: "User Two", + Email: "user2@", + }, + Date: time.Now().UTC().Format(time.RFC3339), + }, + Parents: []*api.CommitMeta{ + { + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + }, + }, + Message: "Updates README.md\n", + Tree: &api.CommitMeta{ + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc", + SHA: "f93e3a1a1525fb5b91020da86e44810c87a2d7bc", + }, + }, + Verification: &api.PayloadCommitVerification{ + Verified: false, + Reason: "unsigned", + Signature: "", + Payload: "", + }, + } +} + +func TestCreateOrUpdateRepoFileForCreate(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + opts := getCreateRepoFileOptions(repo) + + // test + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + + // asserts + assert.Nil(t, err) + gitRepo, _ := git.OpenRepository(repo.RepoPath()) + commitID, _ := gitRepo.GetBranchCommitID(opts.NewBranch) + expectedFileResponse := getExpectedFileResponseForCreate(commitID) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) +} + +func TestCreateOrUpdateRepoFileForUpdate(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + opts := getUpdateRepoFileOptions(repo) + + // test + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + + // asserts + assert.Nil(t, err) + gitRepo, _ := git.OpenRepository(repo.RepoPath()) + commitID, _ := gitRepo.GetBranchCommitID(opts.NewBranch) + expectedFileResponse := getExpectedFileResponseForUpdate(commitID) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email) + assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name) +} + +func TestCreateOrUpdateRepoFileForUpdateWithFileMove(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + opts := getUpdateRepoFileOptions(repo) + suffix := "_new" + opts.FromTreePath = "README.md" + opts.TreePath = "README.md" + suffix // new file name, README.md_new + + // test + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + + // asserts + assert.Nil(t, err) + gitRepo, _ := git.OpenRepository(repo.RepoPath()) + commit, _ := gitRepo.GetBranchCommit(opts.NewBranch) + expectedFileResponse := getExpectedFileResponseForUpdate(commit.ID.String()) + // assert that the old file no longer exists in the last commit of the branch + fromEntry, err := commit.GetTreeEntryByPath(opts.FromTreePath) + toEntry, err := commit.GetTreeEntryByPath(opts.TreePath) + assert.Nil(t, fromEntry) // Should no longer exist here + assert.NotNil(t, toEntry) // Should exist here + // assert SHA has remained the same but paths use the new file name + assert.EqualValues(t, expectedFileResponse.Content.SHA, fileResponse.Content.SHA) + assert.EqualValues(t, expectedFileResponse.Content.Name+suffix, fileResponse.Content.Name) + assert.EqualValues(t, expectedFileResponse.Content.Path+suffix, fileResponse.Content.Path) + assert.EqualValues(t, expectedFileResponse.Content.URL+suffix, fileResponse.Content.URL) + assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA) + assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL) +} + +// Test opts with branch names removed, should get same results as above test +func TestCreateOrUpdateRepoFileWithoutBranchNames(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + opts := getUpdateRepoFileOptions(repo) + opts.OldBranch = "" + opts.NewBranch = "" + + // test + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + + // asserts + assert.Nil(t, err) + gitRepo, _ := git.OpenRepository(repo.RepoPath()) + commitID, _ := gitRepo.GetBranchCommitID(repo.DefaultBranch) + expectedFileResponse := getExpectedFileResponseForUpdate(commitID) + assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content) +} + +func TestCreateOrUpdateRepoFileErrors(t *testing.T) { + // setup + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "user2/repo1") + ctx.SetParams(":id", "1") + test.LoadRepo(t, ctx, 1) + test.LoadRepoCommit(t, ctx) + test.LoadUser(t, ctx, 2) + test.LoadGitRepo(t, ctx) + repo := ctx.Repo.Repository + doer := ctx.User + + t.Run("bad branch", func(t *testing.T) { + opts := getUpdateRepoFileOptions(repo) + opts.OldBranch = "bad_branch" + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + assert.Error(t, err) + assert.Nil(t, fileResponse) + expectedError := "branch does not exist [name: " + opts.OldBranch + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("bad SHA", func(t *testing.T) { + opts := getUpdateRepoFileOptions(repo) + origSHA := opts.SHA + opts.SHA = "bad_sha" + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "sha does not match [given: " + opts.SHA + ", expected: " + origSHA + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("new branch already exists", func(t *testing.T) { + opts := getUpdateRepoFileOptions(repo) + opts.NewBranch = "develop" + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "branch already exists [name: " + opts.NewBranch + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("treePath is empty:", func(t *testing.T) { + opts := getUpdateRepoFileOptions(repo) + opts.TreePath = "" + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "path contains a malformed path component [path: ]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("treePath is a git directory:", func(t *testing.T) { + opts := getUpdateRepoFileOptions(repo) + opts.TreePath = ".git" + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "path contains a malformed path component [path: " + opts.TreePath + "]" + assert.EqualError(t, err, expectedError) + }) + + t.Run("create file that already exists", func(t *testing.T) { + opts := getCreateRepoFileOptions(repo) + opts.TreePath = "README.md" //already exists + fileResponse, err := CreateOrUpdateRepoFile(repo, doer, opts) + assert.Nil(t, fileResponse) + assert.Error(t, err) + expectedError := "repository file already exists [path: " + opts.TreePath + "]" + assert.EqualError(t, err, expectedError) + }) +} diff --git a/modules/uploader/upload.go b/modules/repofiles/upload.go similarity index 96% rename from modules/uploader/upload.go rename to modules/repofiles/upload.go index 81d7c3ba2..ed6a9438c 100644 --- a/modules/uploader/upload.go +++ b/modules/repofiles/upload.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package uploader +package repofiles import ( "fmt" @@ -127,8 +127,12 @@ func UploadRepoFiles(repo *models.Repository, doer *models.User, opts *UploadRep return err } + // make author and committer the doer + author := doer + committer := doer + // Now commit the tree - commitHash, err := t.CommitTree(doer, treeHash, opts.Message) + commitHash, err := t.CommitTree(author, committer, treeHash, opts.Message) if err != nil { return err } diff --git a/modules/repofiles/verification.go b/modules/repofiles/verification.go new file mode 100644 index 000000000..75ead92d0 --- /dev/null +++ b/modules/repofiles/verification.go @@ -0,0 +1,29 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repofiles + +import ( + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/sdk/gitea" +) + +// GetPayloadCommitVerification returns the verification information of a commit +func GetPayloadCommitVerification(commit *git.Commit) *gitea.PayloadCommitVerification { + verification := &gitea.PayloadCommitVerification{} + commitVerification := models.ParseCommitWithSignature(commit) + if commit.Signature != nil { + verification.Signature = commit.Signature.Signature + verification.Payload = commit.Signature.Payload + } + if verification.Reason != "" { + verification.Reason = commitVerification.Reason + } else { + if verification.Verified { + verification.Reason = "unsigned" + } + } + return verification +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index c3d57452c..ed24d74d9 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -293,11 +293,13 @@ var ( MaxResponseItems int DefaultPagingNum int DefaultGitTreesPerPage int + DefaultMaxBlobSize int64 }{ EnableSwagger: true, MaxResponseItems: 50, DefaultPagingNum: 30, DefaultGitTreesPerPage: 1000, + DefaultMaxBlobSize: 10485760, } OAuth2 = struct { diff --git a/modules/uploader/delete.go b/modules/uploader/delete.go deleted file mode 100644 index 2353f18c4..000000000 --- a/modules/uploader/delete.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package uploader - -import ( - "fmt" - - "code.gitea.io/gitea/models" - "code.gitea.io/gitea/modules/git" -) - -// DeleteRepoFileOptions holds the repository delete file options -type DeleteRepoFileOptions struct { - LastCommitID string - OldBranch string - NewBranch string - TreePath string - Message string -} - -// DeleteRepoFile deletes a file in the given repository -func DeleteRepoFile(repo *models.Repository, doer *models.User, opts *DeleteRepoFileOptions) error { - t, err := NewTemporaryUploadRepository(repo) - defer t.Close() - if err != nil { - return err - } - if err := t.Clone(opts.OldBranch); err != nil { - return err - } - if err := t.SetDefaultIndex(); err != nil { - return err - } - - filesInIndex, err := t.LsFiles(opts.TreePath) - if err != nil { - return fmt.Errorf("UpdateRepoFile: %v", err) - } - - inFilelist := false - for _, file := range filesInIndex { - if file == opts.TreePath { - inFilelist = true - } - } - if !inFilelist { - return git.ErrNotExist{RelPath: opts.TreePath} - } - - if err := t.RemoveFilesFromIndex(opts.TreePath); err != nil { - return err - } - - // Now write the tree - treeHash, err := t.WriteTree() - if err != nil { - return err - } - - // Now commit the tree - commitHash, err := t.CommitTree(doer, treeHash, opts.Message) - if err != nil { - return err - } - - // Then push this tree to NewBranch - if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { - return err - } - - // Simulate push event. - oldCommitID := opts.LastCommitID - if opts.NewBranch != opts.OldBranch { - oldCommitID = git.EmptySHA - } - - if err = repo.GetOwner(); err != nil { - return fmt.Errorf("GetOwner: %v", err) - } - err = models.PushUpdate( - opts.NewBranch, - models.PushUpdateOptions{ - PusherID: doer.ID, - PusherName: doer.Name, - RepoUserName: repo.Owner.Name, - RepoName: repo.Name, - RefFullName: git.BranchPrefix + opts.NewBranch, - OldCommitID: oldCommitID, - NewCommitID: commitHash, - }, - ) - if err != nil { - return fmt.Errorf("PushUpdate: %v", err) - } - - // FIXME: Should we UpdateRepoIndexer(repo) here? - return nil -} diff --git a/modules/uploader/update.go b/modules/uploader/update.go deleted file mode 100644 index bc543c7ff..000000000 --- a/modules/uploader/update.go +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. - -package uploader - -import ( - "fmt" - "strings" - - "code.gitea.io/gitea/models" - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/lfs" - "code.gitea.io/gitea/modules/setting" -) - -// UpdateRepoFileOptions holds the repository file update options -type UpdateRepoFileOptions struct { - LastCommitID string - OldBranch string - NewBranch string - OldTreeName string - NewTreeName string - Message string - Content string - IsNewFile bool -} - -// UpdateRepoFile adds or updates a file in the given repository -func UpdateRepoFile(repo *models.Repository, doer *models.User, opts *UpdateRepoFileOptions) error { - t, err := NewTemporaryUploadRepository(repo) - defer t.Close() - if err != nil { - return err - } - if err := t.Clone(opts.OldBranch); err != nil { - return err - } - if err := t.SetDefaultIndex(); err != nil { - return err - } - - filesInIndex, err := t.LsFiles(opts.NewTreeName, opts.OldTreeName) - if err != nil { - return fmt.Errorf("UpdateRepoFile: %v", err) - } - - if opts.IsNewFile { - for _, file := range filesInIndex { - if file == opts.NewTreeName { - return models.ErrRepoFileAlreadyExist{FileName: opts.NewTreeName} - } - } - } - - //var stdout string - if opts.OldTreeName != opts.NewTreeName && len(filesInIndex) > 0 { - for _, file := range filesInIndex { - if file == opts.OldTreeName { - if err := t.RemoveFilesFromIndex(opts.OldTreeName); err != nil { - return err - } - } - } - - } - - // Check there is no way this can return multiple infos - filename2attribute2info, err := t.CheckAttribute("filter", opts.NewTreeName) - if err != nil { - return err - } - - content := opts.Content - var lfsMetaObject *models.LFSMetaObject - - if filename2attribute2info[opts.NewTreeName] != nil && filename2attribute2info[opts.NewTreeName]["filter"] == "lfs" { - // OK so we are supposed to LFS this data! - oid, err := models.GenerateLFSOid(strings.NewReader(opts.Content)) - if err != nil { - return err - } - lfsMetaObject = &models.LFSMetaObject{Oid: oid, Size: int64(len(opts.Content)), RepositoryID: repo.ID} - content = lfsMetaObject.Pointer() - } - - // Add the object to the database - objectHash, err := t.HashObject(strings.NewReader(content)) - if err != nil { - return err - } - - // Add the object to the index - if err := t.AddObjectToIndex("100644", objectHash, opts.NewTreeName); err != nil { - return err - } - - // Now write the tree - treeHash, err := t.WriteTree() - if err != nil { - return err - } - - // Now commit the tree - commitHash, err := t.CommitTree(doer, treeHash, opts.Message) - if err != nil { - return err - } - - if lfsMetaObject != nil { - // We have an LFS object - create it - lfsMetaObject, err = models.NewLFSMetaObject(lfsMetaObject) - if err != nil { - return err - } - contentStore := &lfs.ContentStore{BasePath: setting.LFS.ContentPath} - if !contentStore.Exists(lfsMetaObject) { - if err := contentStore.Put(lfsMetaObject, strings.NewReader(opts.Content)); err != nil { - if err2 := repo.RemoveLFSMetaObjectByOid(lfsMetaObject.Oid); err2 != nil { - return fmt.Errorf("Error whilst removing failed inserted LFS object %s: %v (Prev Error: %v)", lfsMetaObject.Oid, err2, err) - } - return err - } - } - } - - // Then push this tree to NewBranch - if err := t.Push(doer, commitHash, opts.NewBranch); err != nil { - return err - } - - // Simulate push event. - oldCommitID := opts.LastCommitID - if opts.NewBranch != opts.OldBranch { - oldCommitID = git.EmptySHA - } - - if err = repo.GetOwner(); err != nil { - return fmt.Errorf("GetOwner: %v", err) - } - err = models.PushUpdate( - opts.NewBranch, - models.PushUpdateOptions{ - PusherID: doer.ID, - PusherName: doer.Name, - RepoUserName: repo.Owner.Name, - RepoName: repo.Name, - RefFullName: git.BranchPrefix + opts.NewBranch, - OldCommitID: oldCommitID, - NewCommitID: commitHash, - }, - ) - if err != nil { - return fmt.Errorf("PushUpdate: %v", err) - } - models.UpdateRepoIndexer(repo) - - return nil -} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 9fc1e3da2..4562f785c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -667,7 +667,7 @@ editor.filename_help = Add a directory by typing its name followed by a slash (' editor.or = or editor.cancel_lower = Cancel editor.commit_changes = Commit Changes -editor.add_tmpl = Add '%s/' +editor.add_tmpl = Add '' editor.add = Add '%s' editor.update = Update '%s' editor.delete = Delete '%s' @@ -677,11 +677,14 @@ editor.create_new_branch = Create a new branch for this commit editor.new_branch_name_desc = New branch name… editor.cancel = Cancel editor.filename_cannot_be_empty = The filename cannot be empty. +editor.filename_is_invalid = The filename is invalid: '%s'. +editor.branch_does_not_exist = Branch '%s' does not exist in this repository. editor.branch_already_exists = Branch '%s' already exists in this repository. editor.directory_is_a_file = Directory name '%s' is already used as a filename in this repository. editor.file_is_a_symlink = '%s' is a symbolic link. Symbolic links cannot be edited in the web editor editor.filename_is_a_directory = Filename '%s' is already used as a directory name in this repository. editor.file_editing_no_longer_exists = The file being edited, '%s', no longer exists in this repository. +editor.file_deleting_no_longer_exists = The file being deleted, '%s', no longer exists in this repository. editor.file_changed_while_editing = The file contents have changed since you started editing. Click here to see them or Commit Changes again to overwrite them. editor.file_already_exists = A file named '%s' already exists in this repository. editor.no_changes_to_show = There are no changes to show. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f5a1fd6d8..8418ab94a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -659,7 +659,16 @@ func RegisterRoutes(m *macaron.Macaron) { }) m.Get("/refs", repo.GetGitAllRefs) m.Get("/refs/*", repo.GetGitRefs) - m.Combo("/trees/:sha", context.RepoRef()).Get(repo.GetTree) + m.Get("/trees/:sha", context.RepoRef(), repo.GetTree) + m.Get("/blobs/:sha", context.RepoRef(), repo.GetBlob) + }, reqRepoReader(models.UnitTypeCode)) + m.Group("/contents", func() { + m.Get("/*", repo.GetFileContents) + m.Group("/*", func() { + m.Post("", bind(api.CreateFileOptions{}), repo.CreateFile) + m.Put("", bind(api.UpdateFileOptions{}), repo.UpdateFile) + m.Delete("", bind(api.DeleteFileOptions{}), repo.DeleteFile) + }, reqRepoWriter(models.UnitTypeCode), reqToken()) }, reqRepoReader(models.UnitTypeCode)) }, repoAssignment()) }) diff --git a/routers/api/v1/repo/blob.go b/routers/api/v1/repo/blob.go new file mode 100644 index 000000000..d6265e16c --- /dev/null +++ b/routers/api/v1/repo/blob.go @@ -0,0 +1,51 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/repofiles" +) + +// GetBlob get the blob of a repository file. +func GetBlob(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/blobs/{sha} repository GetBlob + // --- + // summary: Gets the blob of a repository. + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: sha + // in: path + // description: sha of the commit + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/GitBlobResponse" + + sha := ctx.Params("sha") + if len(sha) == 0 { + ctx.Error(http.StatusBadRequest, "", "sha not provided") + return + } + if blob, err := repofiles.GetBlobBySHA(ctx.Repo.Repository, sha); err != nil { + ctx.Error(http.StatusBadRequest, "", err) + } else { + ctx.JSON(http.StatusOK, blob) + } +} diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index 3ce80d24f..10108e11c 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -6,10 +6,15 @@ package repo import ( + "encoding/base64" + "net/http" + "code.gitea.io/gitea/models" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/repofiles" "code.gitea.io/gitea/routers/repo" + api "code.gitea.io/sdk/gitea" ) // GetRawFile get a file by path on a repository @@ -48,12 +53,12 @@ func GetRawFile(ctx *context.APIContext) { if git.IsErrNotExist(err) { ctx.NotFound() } else { - ctx.Error(500, "GetBlobByPath", err) + ctx.Error(http.StatusInternalServerError, "GetBlobByPath", err) } return } if err = repo.ServeBlob(ctx.Context, blob); err != nil { - ctx.Error(500, "ServeBlob", err) + ctx.Error(http.StatusInternalServerError, "ServeBlob", err) } } @@ -86,7 +91,7 @@ func GetArchive(ctx *context.APIContext) { repoPath := models.RepoPath(ctx.Params(":username"), ctx.Params(":reponame")) gitRepo, err := git.OpenRepository(repoPath) if err != nil { - ctx.Error(500, "OpenRepository", err) + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) return } ctx.Repo.GitRepo = gitRepo @@ -125,7 +130,7 @@ func GetEditorconfig(ctx *context.APIContext) { if git.IsErrNotExist(err) { ctx.NotFound(err) } else { - ctx.Error(500, "GetEditorconfig", err) + ctx.Error(http.StatusInternalServerError, "GetEditorconfig", err) } return } @@ -136,5 +141,264 @@ func GetEditorconfig(ctx *context.APIContext) { ctx.NotFound(err) return } - ctx.JSON(200, def) + ctx.JSON(http.StatusOK, def) +} + +// CanWriteFiles returns true if repository is editable and user has proper access level. +func CanWriteFiles(r *context.Repository) bool { + return r.Permission.CanWrite(models.UnitTypeCode) && !r.Repository.IsMirror && !r.Repository.IsArchived +} + +// CanReadFiles returns true if repository is readable and user has proper access level. +func CanReadFiles(r *context.Repository) bool { + return r.Permission.CanRead(models.UnitTypeCode) +} + +// CreateFile handles API call for creating a file +func CreateFile(ctx *context.APIContext, apiOpts api.CreateFileOptions) { + // swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile + // --- + // summary: Create a file in a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: filepath + // in: path + // description: path of the file to create + // type: string + // required: true + // - name: body + // in: body + // description: "'content' must be base64 encoded\n\n 'author' and 'committer' are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)\n\n If 'branch' is not given, default branch will be used\n\n 'sha' is the SHA for the file that already exists\n\n 'new_branch' (optional) will make a new branch from 'branch' before creating the file" + // schema: + // "$ref": "#/definitions/CreateFileOptions" + // responses: + // "201": + // "$ref": "#/responses/FileResponse" + + opts := &repofiles.UpdateRepoFileOptions{ + Content: apiOpts.Content, + IsNewFile: true, + Message: apiOpts.Message, + TreePath: ctx.Params("*"), + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + Committer: &repofiles.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &repofiles.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: apiOpts.Author.Email, + }, + } + if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil { + ctx.Error(http.StatusInternalServerError, "CreateFile", err) + } else { + ctx.JSON(http.StatusCreated, fileResponse) + } +} + +// UpdateFile handles API call for updating a file +func UpdateFile(ctx *context.APIContext, apiOpts api.UpdateFileOptions) { + // swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile + // --- + // summary: Update a file in a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: filepath + // in: path + // description: path of the file to update + // type: string + // required: true + // - name: body + // in: body + // description: "'content' must be base64 encoded\n\n 'sha' is the SHA for the file that already exists\n\n 'author' and 'committer' are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)\n\n If 'branch' is not given, default branch will be used\n\n 'new_branch' (optional) will make a new branch from 'branch' before updating the file" + // schema: + // "$ref": "#/definitions/UpdateFileOptions" + // responses: + // "200": + // "$ref": "#/responses/FileResponse" + + opts := &repofiles.UpdateRepoFileOptions{ + Content: apiOpts.Content, + SHA: apiOpts.SHA, + IsNewFile: false, + Message: apiOpts.Message, + FromTreePath: apiOpts.FromPath, + TreePath: ctx.Params("*"), + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + Committer: &repofiles.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &repofiles.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: apiOpts.Author.Email, + }, + } + + if fileResponse, err := createOrUpdateFile(ctx, opts); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateFile", err) + } else { + ctx.JSON(http.StatusOK, fileResponse) + } +} + +// Called from both CreateFile or UpdateFile to handle both +func createOrUpdateFile(ctx *context.APIContext, opts *repofiles.UpdateRepoFileOptions) (*api.FileResponse, error) { + if !CanWriteFiles(ctx.Repo) { + return nil, models.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.User.ID, + RepoName: ctx.Repo.Repository.LowerName, + } + } + + content, err := base64.StdEncoding.DecodeString(opts.Content) + if err != nil { + return nil, err + } + opts.Content = string(content) + + return repofiles.CreateOrUpdateRepoFile(ctx.Repo.Repository, ctx.User, opts) +} + +// DeleteFile Delete a fle in a repository +func DeleteFile(ctx *context.APIContext, apiOpts api.DeleteFileOptions) { + // swagger:operation DELETE /repos/{owner}/{repo}/contents/{filepath} repository repoDeleteFile + // --- + // summary: Delete a file in a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: filepath + // in: path + // description: path of the file to delete + // type: string + // required: true + // - name: body + // in: body + // description: "'sha' is the SHA for the file to be deleted\n\n 'author' and 'committer' are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)\n\n If 'branch' is not given, default branch will be used\n\n 'new_branch' (optional) will make a new branch from 'branch' before deleting the file" + // schema: + // "$ref": "#/definitions/DeleteFileOptions" + // responses: + // "200": + // "$ref": "#/responses/FileDeleteResponse" + if !CanWriteFiles(ctx.Repo) { + ctx.Error(http.StatusInternalServerError, "DeleteFile", models.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.User.ID, + RepoName: ctx.Repo.Repository.LowerName, + }) + return + } + + opts := &repofiles.DeleteRepoFileOptions{ + Message: apiOpts.Message, + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + SHA: apiOpts.SHA, + TreePath: ctx.Params("*"), + Committer: &repofiles.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &repofiles.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: apiOpts.Author.Email, + }, + } + + if fileResponse, err := repofiles.DeleteRepoFile(ctx.Repo.Repository, ctx.User, opts); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteFile", err) + } else { + ctx.JSON(http.StatusOK, fileResponse) + } +} + +// GetFileContents Get the contents of a fle in a repository +func GetFileContents(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetFileContents + // --- + // summary: Gets the contents of a file or directory in a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: filepath + // in: path + // description: path of the file to delete + // type: string + // required: true + // - name: ref + // in: query + // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)" + // required: false + // type: string + // responses: + // "200": + // "$ref": "#/responses/FileContentResponse" + + if !CanReadFiles(ctx.Repo) { + ctx.Error(http.StatusInternalServerError, "GetFileContents", models.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.User.ID, + RepoName: ctx.Repo.Repository.LowerName, + }) + return + } + + treePath := ctx.Params("*") + ref := ctx.QueryTrim("ref") + + if fileContents, err := repofiles.GetFileContents(ctx.Repo.Repository, treePath, ref); err != nil { + ctx.Error(http.StatusInternalServerError, "GetFileContents", err) + } else { + ctx.JSON(http.StatusOK, fileContents) + } } diff --git a/routers/api/v1/repo/tree.go b/routers/api/v1/repo/tree.go index 012eba673..066ca662f 100644 --- a/routers/api/v1/repo/tree.go +++ b/routers/api/v1/repo/tree.go @@ -5,13 +5,8 @@ package repo import ( - "fmt" - "strings" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/sdk/gitea" + "code.gitea.io/gitea/modules/repofiles" ) // GetTree get the tree of a repository. @@ -55,92 +50,15 @@ func GetTree(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/GitTreeResponse" - sha := ctx.Params("sha") + + sha := ctx.Params(":sha") if len(sha) == 0 { ctx.Error(400, "", "sha not provided") return } - tree := GetTreeBySHA(ctx, sha) - if tree != nil { - ctx.JSON(200, tree) - } else { - ctx.Error(400, "", "sha invalid") - } -} - -// GetTreeBySHA get the GitTreeResponse of a repository using a sha hash. -func GetTreeBySHA(ctx *context.APIContext, sha string) *gitea.GitTreeResponse { - gitTree, err := ctx.Repo.GitRepo.GetTree(sha) - if err != nil || gitTree == nil { - return nil - } - tree := new(gitea.GitTreeResponse) - repoID := strings.TrimRight(setting.AppURL, "/") + "/api/v1/repos/" + ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name - tree.SHA = gitTree.ID.String() - tree.URL = repoID + "/git/trees/" + tree.SHA - var entries git.Entries - if ctx.QueryBool("recursive") { - entries, err = gitTree.ListEntriesRecursive() - } else { - entries, err = gitTree.ListEntries() - } - if err != nil { - return tree - } - repoIDLen := len(repoID) - - // 51 is len(sha1) + len("/git/blobs/"). 40 + 11. - blobURL := make([]byte, repoIDLen+51) - copy(blobURL[:], repoID) - copy(blobURL[repoIDLen:], "/git/blobs/") - - // 51 is len(sha1) + len("/git/trees/"). 40 + 11. - treeURL := make([]byte, repoIDLen+51) - copy(treeURL[:], repoID) - copy(treeURL[repoIDLen:], "/git/trees/") - - // 40 is the size of the sha1 hash in hexadecimal format. - copyPos := len(treeURL) - 40 - - page := ctx.QueryInt("page") - perPage := ctx.QueryInt("per_page") - if perPage <= 0 || perPage > setting.API.DefaultGitTreesPerPage { - perPage = setting.API.DefaultGitTreesPerPage - } - if page <= 0 { - page = 1 - } - tree.Page = page - tree.TotalCount = len(entries) - rangeStart := perPage * (page - 1) - if rangeStart >= len(entries) { - return tree - } - var rangeEnd int - if len(entries) > perPage { - tree.Truncated = true - } - if rangeStart+perPage < len(entries) { - rangeEnd = rangeStart + perPage + if tree, err := repofiles.GetTreeBySHA(ctx.Repo.Repository, sha, ctx.QueryInt("page"), ctx.QueryInt("per_page"), ctx.QueryBool("recursive")); err != nil { + ctx.Error(400, "", err.Error()) } else { - rangeEnd = len(entries) - } - tree.Entries = make([]gitea.GitEntry, rangeEnd-rangeStart) - for e := rangeStart; e < rangeEnd; e++ { - i := e - rangeStart - tree.Entries[i].Path = entries[e].Name() - tree.Entries[i].Mode = fmt.Sprintf("%06x", entries[e].Mode()) - tree.Entries[i].Type = string(entries[e].Type) - tree.Entries[i].Size = entries[e].Size() - tree.Entries[i].SHA = entries[e].ID.String() - - if entries[e].IsDir() { - copy(treeURL[copyPos:], entries[e].ID.String()) - tree.Entries[i].URL = string(treeURL[:]) - } else { - copy(blobURL[copyPos:], entries[e].ID.String()) - tree.Entries[i].URL = string(blobURL[:]) - } + ctx.JSON(200, tree) } - return tree } diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 038f79005..83f7cfec8 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -97,6 +97,7 @@ type swaggerParameterBodies struct { // in:body CreateUserOption api.CreateUserOption + // in:body EditUserOption api.EditUserOption @@ -105,4 +106,13 @@ type swaggerParameterBodies struct { // in:body EditAttachmentOptions api.EditAttachmentOptions + + // in:body + CreateFileOptions api.CreateFileOptions + + // in:body + UpdateFileOptions api.UpdateFileOptions + + // in:body + DeleteFileOptions api.DeleteFileOptions } diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index f67103779..a23e670da 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -162,9 +162,37 @@ type swaggerGitTreeResponse struct { Body api.GitTreeResponse `json:"body"` } +// GitBlobResponse +// swagger:response GitBlobResponse +type swaggerGitBlobResponse struct { + //in: body + Body api.GitBlobResponse `json:"body"` +} + // Commit // swagger:response Commit type swaggerCommit struct { //in: body Body api.Commit `json:"body"` } + +// FileResponse +// swagger:response FileResponse +type swaggerFileResponse struct { + //in: body + Body api.FileResponse `json:"body"` +} + +// FileContentResponse +// swagger:response FileContentResponse +type swaggerFileContentResponse struct { + //in: body + Body api.FileContentResponse `json:"body"` +} + +// FileDeleteResponse +// swagger:response FileDeleteResponse +type swaggerFileDeleteResponse struct { + //in: body + Body api.FileDeleteResponse `json:"body"` +} diff --git a/routers/repo/editor.go b/routers/repo/editor.go index 7d528fbac..b5bb2f0ce 100644 --- a/routers/repo/editor.go +++ b/routers/repo/editor.go @@ -17,9 +17,9 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/repofiles" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" - "code.gitea.io/gitea/modules/uploader" "code.gitea.io/gitea/modules/util" ) @@ -139,7 +139,7 @@ func editFile(ctx *context.Context, isNewFile bool) { ctx.Data["commit_choice"] = frmCommitChoiceNewBranch } ctx.Data["new_branch_name"] = "" - ctx.Data["last_commit"] = ctx.Repo.Commit.ID + ctx.Data["last_commit"] = ctx.Repo.CommitID ctx.Data["MarkdownFileExts"] = strings.Join(setting.Markdown.FileExtensions, ",") ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") ctx.Data["PreviewableFileModes"] = strings.Join(setting.Repository.Editor.PreviewableFileModes, ",") @@ -159,39 +159,27 @@ func NewFile(ctx *context.Context) { } func editFilePost(ctx *context.Context, form auth.EditRepoFileForm, isNewFile bool) { - ctx.Data["PageIsEdit"] = true - ctx.Data["IsNewFile"] = isNewFile - ctx.Data["RequireHighlightJS"] = true - ctx.Data["RequireSimpleMDE"] = true canCommit := renderCommitRights(ctx) - - oldBranchName := ctx.Repo.BranchName - branchName := oldBranchName - oldTreePath := cleanUploadFileName(ctx.Repo.TreePath) - lastCommit := form.LastCommit - form.LastCommit = ctx.Repo.Commit.ID.String() - + treeNames, treePaths := getParentTreeFields(form.TreePath) + branchName := ctx.Repo.BranchName if form.CommitChoice == frmCommitChoiceNewBranch { branchName = form.NewBranchName } - form.TreePath = cleanUploadFileName(form.TreePath) - if len(form.TreePath) == 0 { - ctx.Error(500, "Upload file name is invalid") - return - } - treeNames, treePaths := getParentTreeFields(form.TreePath) - + ctx.Data["PageIsEdit"] = true + ctx.Data["IsNewFile"] = isNewFile + ctx.Data["RequireHighlightJS"] = true + ctx.Data["RequireSimpleMDE"] = true ctx.Data["TreePath"] = form.TreePath ctx.Data["TreeNames"] = treeNames ctx.Data["TreePaths"] = treePaths - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + branchName + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/branch/" + ctx.Repo.BranchName ctx.Data["FileContent"] = form.Content ctx.Data["commit_summary"] = form.CommitSummary ctx.Data["commit_message"] = form.CommitMessage ctx.Data["commit_choice"] = form.CommitChoice - ctx.Data["new_branch_name"] = branchName - ctx.Data["last_commit"] = form.LastCommit + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID ctx.Data["MarkdownFileExts"] = strings.Join(setting.Markdown.FileExtensions, ",") ctx.Data["LineWrapExtensions"] = strings.Join(setting.Repository.Editor.LineWrapExtensions, ",") ctx.Data["PreviewableFileModes"] = strings.Join(setting.Repository.Editor.PreviewableFileModes, ",") @@ -201,101 +189,16 @@ func editFilePost(ctx *context.Context, form auth.EditRepoFileForm, isNewFile bo return } - if len(form.TreePath) == 0 { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_cannot_be_empty"), tplEditFile, &form) - return - } - - if oldBranchName != branchName { - if _, err := ctx.Repo.Repository.GetBranch(branchName); err == nil { - ctx.Data["Err_NewBranchName"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchName), tplEditFile, &form) - return - } - } else if !canCommit { + // Cannot commit to a an existing branch if user doesn't have rights + if branchName == ctx.Repo.BranchName && !canCommit { ctx.Data["Err_NewBranchName"] = true ctx.Data["commit_choice"] = frmCommitChoiceNewBranch ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplEditFile, &form) return } - var newTreePath string - for index, part := range treeNames { - newTreePath = path.Join(newTreePath, part) - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(newTreePath) - if err != nil { - if git.IsErrNotExist(err) { - // Means there is no item with that name, so we're good - break - } - - ctx.ServerError("Repo.Commit.GetTreeEntryByPath", err) - return - } - if index != len(treeNames)-1 { - if !entry.IsDir() { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", part), tplEditFile, &form) - return - } - } else { - if entry.IsLink() { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", part), tplEditFile, &form) - return - } - if entry.IsDir() { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", part), tplEditFile, &form) - return - } - } - } - - if !isNewFile { - _, err := ctx.Repo.Commit.GetTreeEntryByPath(oldTreePath) - if err != nil { - if git.IsErrNotExist(err) { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", oldTreePath), tplEditFile, &form) - } else { - ctx.ServerError("GetTreeEntryByPath", err) - } - return - } - if lastCommit != ctx.Repo.CommitID { - files, err := ctx.Repo.Commit.GetFilesChangedSinceCommit(lastCommit) - if err != nil { - ctx.ServerError("GetFilesChangedSinceCommit", err) - return - } - - for _, file := range files { - if file == form.TreePath { - ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+lastCommit+"..."+ctx.Repo.CommitID), tplEditFile, &form) - return - } - } - } - } - - if oldTreePath != form.TreePath { - // We have a new filename (rename or completely new file) so we need to make sure it doesn't already exist, can't clobber. - entry, err := ctx.Repo.Commit.GetTreeEntryByPath(form.TreePath) - if err != nil { - if !git.IsErrNotExist(err) { - ctx.ServerError("GetTreeEntryByPath", err) - return - } - } - if entry != nil { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplEditFile, &form) - return - } - } - + // CommitSummary is optional in the web form, if empty, give it a default message based on add or update + // `message` will be both the summary and message combined message := strings.TrimSpace(form.CommitSummary) if len(message) == 0 { if isNewFile { @@ -304,28 +207,74 @@ func editFilePost(ctx *context.Context, form auth.EditRepoFileForm, isNewFile bo message = ctx.Tr("repo.editor.update", form.TreePath) } } - form.CommitMessage = strings.TrimSpace(form.CommitMessage) if len(form.CommitMessage) > 0 { message += "\n\n" + form.CommitMessage } - if err := uploader.UpdateRepoFile(ctx.Repo.Repository, ctx.User, &uploader.UpdateRepoFileOptions{ - LastCommitID: lastCommit, - OldBranch: oldBranchName, + if _, err := repofiles.CreateOrUpdateRepoFile(ctx.Repo.Repository, ctx.User, &repofiles.UpdateRepoFileOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, NewBranch: branchName, - OldTreeName: oldTreePath, - NewTreeName: form.TreePath, + FromTreePath: ctx.Repo.TreePath, + TreePath: form.TreePath, Message: message, Content: strings.Replace(form.Content, "\r", "", -1), IsNewFile: isNewFile, }); err != nil { - ctx.Data["Err_TreePath"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_update_file", form.TreePath, err), tplEditFile, &form) - return + // This is where we handle all the errors thrown by repofiles.CreateOrUpdateRepoFile + if git.IsErrNotExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_editing_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", form.TreePath), tplEditFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + if fileErr, ok := err.(models.ErrFilePathInvalid); ok { + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplEditFile, &form) + break + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplEditFile, &form) + break + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplEditFile, &form) + break + default: + ctx.Error(500, err.Error()) + break + } + } else { + ctx.Error(500, err.Error()) + } + } else if models.IsErrRepoFileAlreadyExists(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.file_already_exists", form.TreePath), tplEditFile, &form) + } else if models.IsErrBranchNotExist(err) { + // For when a user adds/updates a file to a branch that no longer exists + if branchErr, ok := err.(models.ErrBranchNotExist); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplEditFile, &form) + } else { + ctx.Error(500, err.Error()) + } + } else if models.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + ctx.Data["Err_NewBranchName"] = true + if branchErr, ok := err.(models.ErrBranchAlreadyExists); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) + } else { + ctx.Error(500, err.Error()) + } + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplEditFile, &form) + } else { + ctx.RenderWithErr(ctx.Tr("repo.editor.fail_to_update_file", form.TreePath, err), tplEditFile, &form) + } + } else { + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + branchName + "/" + strings.NewReplacer("%", "%25", "#", "%23", " ", "%20", "?", "%3F").Replace(cleanUploadFileName(form.TreePath))) + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(form.TreePath)) } - - ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(form.TreePath)) } // EditFilePost response for editing file @@ -355,7 +304,7 @@ func DiffPreviewPost(ctx *context.Context, form auth.EditPreviewDiffForm) { return } - diff, err := uploader.GetDiffPreview(ctx.Repo.Repository, ctx.Repo.BranchName, treePath, form.Content) + diff, err := repofiles.GetDiffPreview(ctx.Repo.Repository, ctx.Repo.BranchName, treePath, form.Content) if err != nil { ctx.Error(500, "GetDiffPreview: "+err.Error()) return @@ -386,6 +335,7 @@ func DeleteFile(ctx *context.Context) { ctx.Data["commit_summary"] = "" ctx.Data["commit_message"] = "" + ctx.Data["last_commit"] = ctx.Repo.CommitID if canCommit { ctx.Data["commit_choice"] = frmCommitChoiceDirect } else { @@ -398,41 +348,27 @@ func DeleteFile(ctx *context.Context) { // DeleteFilePost response for deleting file func DeleteFilePost(ctx *context.Context, form auth.DeleteRepoFileForm) { - ctx.Data["PageIsDelete"] = true - ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() - - ctx.Repo.TreePath = cleanUploadFileName(ctx.Repo.TreePath) - if len(ctx.Repo.TreePath) == 0 { - ctx.Error(500, "Delete file name is invalid") - return - } - - ctx.Data["TreePath"] = ctx.Repo.TreePath canCommit := renderCommitRights(ctx) - - oldBranchName := ctx.Repo.BranchName - branchName := oldBranchName - + branchName := ctx.Repo.BranchName if form.CommitChoice == frmCommitChoiceNewBranch { branchName = form.NewBranchName } + + ctx.Data["PageIsDelete"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() + ctx.Data["TreePath"] = ctx.Repo.TreePath ctx.Data["commit_summary"] = form.CommitSummary ctx.Data["commit_message"] = form.CommitMessage ctx.Data["commit_choice"] = form.CommitChoice - ctx.Data["new_branch_name"] = branchName + ctx.Data["new_branch_name"] = form.NewBranchName + ctx.Data["last_commit"] = ctx.Repo.CommitID if ctx.HasError() { ctx.HTML(200, tplDeleteFile) return } - if oldBranchName != branchName { - if _, err := ctx.Repo.Repository.GetBranch(branchName); err == nil { - ctx.Data["Err_NewBranchName"] = true - ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchName), tplDeleteFile, &form) - return - } - } else if !canCommit { + if branchName != ctx.Repo.BranchName && !canCommit { ctx.Data["Err_NewBranchName"] = true ctx.Data["commit_choice"] = frmCommitChoiceNewBranch ctx.RenderWithErr(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", branchName), tplDeleteFile, &form) @@ -443,25 +379,67 @@ func DeleteFilePost(ctx *context.Context, form auth.DeleteRepoFileForm) { if len(message) == 0 { message = ctx.Tr("repo.editor.delete", ctx.Repo.TreePath) } - form.CommitMessage = strings.TrimSpace(form.CommitMessage) if len(form.CommitMessage) > 0 { message += "\n\n" + form.CommitMessage } - if err := uploader.DeleteRepoFile(ctx.Repo.Repository, ctx.User, &uploader.DeleteRepoFileOptions{ - LastCommitID: ctx.Repo.CommitID, - OldBranch: oldBranchName, + if _, err := repofiles.DeleteRepoFile(ctx.Repo.Repository, ctx.User, &repofiles.DeleteRepoFileOptions{ + LastCommitID: form.LastCommit, + OldBranch: ctx.Repo.BranchName, NewBranch: branchName, TreePath: ctx.Repo.TreePath, Message: message, }); err != nil { - ctx.ServerError("DeleteRepoFile", err) - return + // This is where we handle all the errors thrown by repofiles.DeleteRepoFile + if git.IsErrNotExist(err) || models.IsErrRepoFileDoesNotExist(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_deleting_no_longer_exists", ctx.Repo.TreePath), tplEditFile, &form) + } else if models.IsErrFilenameInvalid(err) { + ctx.Data["Err_TreePath"] = true + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_invalid", ctx.Repo.TreePath), tplEditFile, &form) + } else if models.IsErrFilePathInvalid(err) { + ctx.Data["Err_TreePath"] = true + if fileErr, ok := err.(models.ErrFilePathInvalid); ok { + switch fileErr.Type { + case git.EntryModeSymlink: + ctx.RenderWithErr(ctx.Tr("repo.editor.file_is_a_symlink", fileErr.Path), tplEditFile, &form) + break + case git.EntryModeTree: + ctx.RenderWithErr(ctx.Tr("repo.editor.filename_is_a_directory", fileErr.Path), tplEditFile, &form) + break + case git.EntryModeBlob: + ctx.RenderWithErr(ctx.Tr("repo.editor.directory_is_a_file", fileErr.Path), tplEditFile, &form) + break + default: + ctx.ServerError("DeleteRepoFile", err) + break + } + } else { + ctx.ServerError("DeleteRepoFile", err) + } + } else if models.IsErrBranchNotExist(err) { + // For when a user deletes a file to a branch that no longer exists + if branchErr, ok := err.(models.ErrBranchNotExist); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_does_not_exist", branchErr.Name), tplEditFile, &form) + } else { + ctx.Error(500, err.Error()) + } + } else if models.IsErrBranchAlreadyExists(err) { + // For when a user specifies a new branch that already exists + if branchErr, ok := err.(models.ErrBranchAlreadyExists); ok { + ctx.RenderWithErr(ctx.Tr("repo.editor.branch_already_exists", branchErr.BranchName), tplEditFile, &form) + } else { + ctx.Error(500, err.Error()) + } + } else if models.IsErrCommitIDDoesNotMatch(err) { + ctx.RenderWithErr(ctx.Tr("repo.editor.file_changed_while_editing", ctx.Repo.RepoLink+"/compare/"+form.LastCommit+"..."+ctx.Repo.CommitID), tplEditFile, &form) + } else { + ctx.ServerError("DeleteRepoFile", err) + } + } else { + ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", ctx.Repo.TreePath)) + ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName)) } - - ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", ctx.Repo.TreePath)) - ctx.Redirect(ctx.Repo.RepoLink + "/src/branch/" + util.PathEscapeSegments(branchName)) } func renderUploadSettings(ctx *context.Context) { @@ -584,7 +562,7 @@ func UploadFilePost(ctx *context.Context, form auth.UploadRepoFileForm) { message += "\n\n" + form.CommitMessage } - if err := uploader.UploadRepoFiles(ctx.Repo.Repository, ctx.User, &uploader.UploadRepoFileOptions{ + if err := repofiles.UploadRepoFiles(ctx.Repo.Repository, ctx.User, &repofiles.UploadRepoFileOptions{ LastCommitID: ctx.Repo.CommitID, OldBranch: oldBranchName, NewBranch: branchName, diff --git a/routers/repo/editor_test.go b/routers/repo/editor_test.go index b3d4314c2..660f088e8 100644 --- a/routers/repo/editor_test.go +++ b/routers/repo/editor_test.go @@ -8,6 +8,7 @@ import ( "testing" "code.gitea.io/gitea/models" + "github.com/stretchr/testify/assert" ) diff --git a/templates/repo/editor/commit_form.tmpl b/templates/repo/editor/commit_form.tmpl index be2773d62..86749623e 100644 --- a/templates/repo/editor/commit_form.tmpl +++ b/templates/repo/editor/commit_form.tmpl @@ -3,7 +3,7 @@

{{.i18n.Tr "repo.editor.commit_changes"}}

- +
diff --git a/templates/repo/editor/delete.tmpl b/templates/repo/editor/delete.tmpl index a87425307..10eca8f52 100644 --- a/templates/repo/editor/delete.tmpl +++ b/templates/repo/editor/delete.tmpl @@ -5,8 +5,9 @@ {{template "base/alert" .}}
{{.CsrfTokenHtml}} + {{template "repo/editor/commit_form" .}}
-{{template "base/footer" .}} \ No newline at end of file +{{template "base/footer" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 3184559bd..b6160fc84 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1511,6 +1511,199 @@ } } }, + "/repos/{owner}/{repo}/contents/{filepath}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets the contents of a file or directory in a repository", + "operationId": "repoGetFileContents", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "path of the file to delete", + "name": "filepath", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "The name of the commit/branch/tag. Default the repository’s default branch (usually master)", + "name": "ref", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/FileContentResponse" + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Update a file in a repository", + "operationId": "repoUpdateFile", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "path of the file to update", + "name": "filepath", + "in": "path", + "required": true + }, + { + "description": "'content' must be base64 encoded\n\n 'sha' is the SHA for the file that already exists\n\n 'author' and 'committer' are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)\n\n If 'branch' is not given, default branch will be used\n\n 'new_branch' (optional) will make a new branch from 'branch' before updating the file", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateFileOptions" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/FileResponse" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Create a file in a repository", + "operationId": "repoCreateFile", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "path of the file to create", + "name": "filepath", + "in": "path", + "required": true + }, + { + "description": "'content' must be base64 encoded\n\n 'author' and 'committer' are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)\n\n If 'branch' is not given, default branch will be used\n\n 'sha' is the SHA for the file that already exists\n\n 'new_branch' (optional) will make a new branch from 'branch' before creating the file", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateFileOptions" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/FileResponse" + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete a file in a repository", + "operationId": "repoDeleteFile", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "path of the file to delete", + "name": "filepath", + "in": "path", + "required": true + }, + { + "description": "'sha' is the SHA for the file to be deleted\n\n 'author' and 'committer' are optional (if only one is given, it will be used for the other, otherwise the authenticated user will be used)\n\n If 'branch' is not given, default branch will be used\n\n 'new_branch' (optional) will make a new branch from 'branch' before deleting the file", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteFileOptions" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/FileDeleteResponse" + } + } + } + }, "/repos/{owner}/{repo}/editorconfig/{filepath}": { "get": { "produces": [ @@ -1622,6 +1815,46 @@ } } }, + "/repos/{owner}/{repo}/git/blobs/{sha}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets the blob of a repository.", + "operationId": "GetBlob", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "sha of the commit", + "name": "sha", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/GitBlobResponse" + } + } + } + }, "/repos/{owner}/{repo}/git/commits/{sha}": { "get": { "produces": [ @@ -6637,6 +6870,35 @@ }, "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" }, + "CreateFileOptions": { + "description": "CreateFileOptions options for creating files", + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/Identity" + }, + "branch": { + "type": "string", + "x-go-name": "BranchName" + }, + "committer": { + "$ref": "#/definitions/Identity" + }, + "content": { + "type": "string", + "x-go-name": "Content" + }, + "message": { + "type": "string", + "x-go-name": "Message" + }, + "new_branch": { + "type": "string", + "x-go-name": "NewBranchName" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, "CreateForkOption": { "description": "CreateForkOption options for creating a fork", "type": "object", @@ -7129,6 +7391,35 @@ }, "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" }, + "DeleteFileOptions": { + "description": "DeleteFileOptions options for deleting files (used for other File structs below)", + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/Identity" + }, + "branch": { + "type": "string", + "x-go-name": "BranchName" + }, + "committer": { + "$ref": "#/definitions/Identity" + }, + "message": { + "type": "string", + "x-go-name": "Message" + }, + "new_branch": { + "type": "string", + "x-go-name": "NewBranchName" + }, + "sha": { + "type": "string", + "x-go-name": "SHA" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, "DeployKey": { "description": "DeployKey a deploy key", "type": "object", @@ -7567,6 +7858,144 @@ }, "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" }, + "FileCommitResponse": { + "type": "object", + "title": "FileCommitResponse contains information generated from a Git commit for a repo's file.", + "properties": { + "author": { + "$ref": "#/definitions/CommitUser" + }, + "committer": { + "$ref": "#/definitions/CommitUser" + }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, + "message": { + "type": "string", + "x-go-name": "Message" + }, + "parents": { + "type": "array", + "items": { + "$ref": "#/definitions/CommitMeta" + }, + "x-go-name": "Parents" + }, + "sha": { + "type": "string", + "x-go-name": "SHA" + }, + "tree": { + "$ref": "#/definitions/CommitMeta" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, + "FileContentResponse": { + "description": "FileContentResponse contains information about a repo's file stats and content", + "type": "object", + "properties": { + "_links": { + "$ref": "#/definitions/FileLinksResponse" + }, + "download_url": { + "type": "string", + "x-go-name": "DownloadURL" + }, + "git_url": { + "type": "string", + "x-go-name": "GitURL" + }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "path": { + "type": "string", + "x-go-name": "Path" + }, + "sha": { + "type": "string", + "x-go-name": "SHA" + }, + "size": { + "type": "integer", + "format": "int64", + "x-go-name": "Size" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, + "FileDeleteResponse": { + "description": "FileDeleteResponse contains information about a repo's file that was deleted", + "type": "object", + "properties": { + "commit": { + "$ref": "#/definitions/FileCommitResponse" + }, + "content": { + "type": "object", + "x-go-name": "Content" + }, + "verification": { + "$ref": "#/definitions/PayloadCommitVerification" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, + "FileLinksResponse": { + "description": "FileLinksResponse contains the links for a repo's file", + "type": "object", + "properties": { + "git_url": { + "type": "string", + "x-go-name": "GitURL" + }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, + "url": { + "type": "string", + "x-go-name": "Self" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, + "FileResponse": { + "description": "FileResponse contains information about a repo's file", + "type": "object", + "properties": { + "commit": { + "$ref": "#/definitions/FileCommitResponse" + }, + "content": { + "$ref": "#/definitions/FileContentResponse" + }, + "verification": { + "$ref": "#/definitions/PayloadCommitVerification" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, "GPGKey": { "description": "GPGKey a user GPG key to sign commit and tag in repository", "type": "object", @@ -7646,6 +8075,34 @@ }, "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" }, + "GitBlobResponse": { + "description": "GitBlobResponse represents a git blob", + "type": "object", + "properties": { + "content": { + "type": "string", + "x-go-name": "Content" + }, + "encoding": { + "type": "string", + "x-go-name": "Encoding" + }, + "sha": { + "type": "string", + "x-go-name": "SHA" + }, + "size": { + "type": "integer", + "format": "int64", + "x-go-name": "Size" + }, + "url": { + "type": "string", + "x-go-name": "URL" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, "GitEntry": { "description": "GitEntry represents a git tree", "type": "object", @@ -7796,6 +8253,22 @@ }, "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" }, + "Identity": { + "description": "Identity for a person's identity like an author or committer", + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "x-go-name": "Email" + }, + "name": { + "type": "string", + "x-go-name": "Name" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, "Issue": { "description": "Issue represents an issue in a repository", "type": "object", @@ -8855,6 +9328,43 @@ }, "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" }, + "UpdateFileOptions": { + "description": "UpdateFileOptions options for updating files", + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/Identity" + }, + "branch": { + "type": "string", + "x-go-name": "BranchName" + }, + "committer": { + "$ref": "#/definitions/Identity" + }, + "content": { + "type": "string", + "x-go-name": "Content" + }, + "from_path": { + "type": "string", + "x-go-name": "FromPath" + }, + "message": { + "type": "string", + "x-go-name": "Message" + }, + "new_branch": { + "type": "string", + "x-go-name": "NewBranchName" + }, + "sha": { + "type": "string", + "x-go-name": "SHA" + } + }, + "x-go-package": "code.gitea.io/gitea/vendor/code.gitea.io/sdk/gitea" + }, "User": { "description": "User represents a user", "type": "object", @@ -9040,6 +9550,24 @@ } } }, + "FileContentResponse": { + "description": "FileContentResponse", + "schema": { + "$ref": "#/definitions/FileContentResponse" + } + }, + "FileDeleteResponse": { + "description": "FileDeleteResponse", + "schema": { + "$ref": "#/definitions/FileDeleteResponse" + } + }, + "FileResponse": { + "description": "FileResponse", + "schema": { + "$ref": "#/definitions/FileResponse" + } + }, "GPGKey": { "description": "GPGKey", "schema": { @@ -9055,6 +9583,12 @@ } } }, + "GitBlobResponse": { + "description": "GitBlobResponse", + "schema": { + "$ref": "#/definitions/GitBlobResponse" + } + }, "GitHook": { "description": "GitHook", "schema": { @@ -9362,7 +9896,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/EditAttachmentOptions" + "$ref": "#/definitions/DeleteFileOptions" } }, "redirect": {