diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index 0cd88b3703..b0e0bd979e 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -176,3 +176,8 @@ type FileDeleteResponse struct { Commit *FileCommitResponse `json:"commit"` Verification *PayloadCommitVerification `json:"verification"` } + +// GetFilesOptions options for retrieving metadate and content of multiple files +type GetFilesOptions struct { + Files []string `json:"files" binding:"Required"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5cd08a3618..36d2563e17 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1389,6 +1389,7 @@ func Routes() *web.Router { m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile) }, reqToken()) }, reqRepoReader(unit.TypeCode)) + m.Post("/files", context.ReferencesGitRepo(), context.RepoRefForAPI, bind(api.GetFilesOptions{}), reqRepoReader(unit.TypeCode), repo.GetFiles) m.Get("/signing-key.gpg", misc.SigningKey) m.Group("/topics", func() { m.Combo("").Get(repo.ListTopics). diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index d8e9fde2c0..d923859723 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -25,7 +25,9 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/context" pull_service "code.gitea.io/gitea/services/pull" @@ -982,3 +984,69 @@ func GetContentsList(ctx *context.APIContext) { // same as GetContents(), this function is here because swagger fails if path is empty in GetContents() interface GetContents(ctx) } + +// GetFiles Get the metadata and contents of requested files +func GetFiles(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/files repository repoGetFiles + // --- + // summary: Get the metadata and contents of requested files + // 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: ref + // in: query + // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)" + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/GetFilesOptions" + // responses: + // "200": + // "$ref": "#/responses/ContentsListResponse" + // "404": + // "$ref": "#/responses/notFound" + + apiOpts := web.GetForm(ctx).(*api.GetFilesOptions) + + ref := ctx.FormTrim("ref") + if ref == "" { + ref = ctx.Repo.Repository.DefaultBranch + } + + if !ctx.Repo.GitRepo.IsReferenceExist(ref) { + ctx.APIErrorNotFound("GetFiles", "ref does not exist") + return + } + + files := apiOpts.Files + + filesResponse := files_service.GetContentsListFromTrees(ctx, ctx.Repo.Repository, ref, files) + count := len(filesResponse) + + listOpts := utils.GetListOptions(ctx) + filesResponse = util.PaginateSlice(filesResponse, listOpts.Page, listOpts.PageSize).([]*api.ContentsResponse) + + ctx.SetTotalCountHeader(int64(count)) + ctx.JSON(http.StatusOK, filesResponse) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index aa5990eb38..44919141d5 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -118,6 +118,9 @@ type swaggerParameterBodies struct { // in:body EditAttachmentOptions api.EditAttachmentOptions + // in:body + GetFilesOptions api.GetFilesOptions + // in:body ChangeFilesOptions api.ChangeFilesOptions diff --git a/services/repository/files/file.go b/services/repository/files/file.go index a8ad5889cb..d8daf572b5 100644 --- a/services/repository/files/file.go +++ b/services/repository/files/file.go @@ -17,12 +17,17 @@ import ( "code.gitea.io/gitea/modules/util" ) -func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branch string, treeNames []string) (*api.FilesResponse, error) { +func GetContentsListFromTrees(ctx context.Context, repo *repo_model.Repository, branch string, treeNames []string) []*api.ContentsResponse { files := []*api.ContentsResponse{} for _, file := range treeNames { fileContents, _ := GetContents(ctx, repo, file, branch, false) // ok if fails, then will be nil files = append(files, fileContents) } + return files +} + +func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, commit *git.Commit, branch string, treeNames []string) (*api.FilesResponse, error) { + files := GetContentsListFromTrees(ctx, repo, branch, treeNames) fileCommitResponse, _ := GetFileCommitResponse(repo, commit) // ok if fails, then will be nil verification := GetPayloadCommitVerification(ctx, commit) filesResponse := &api.FilesResponse{ diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d0e41e8094..3e9d898f46 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6771,6 +6771,68 @@ } } }, + "/repos/{owner}/{repo}/files": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Get the metadata and contents of requested files", + "operationId": "repoGetFiles", + "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": "The name of the commit/branch/tag. Default the repository’s default branch (usually master)", + "name": "ref", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/GetFilesOptions" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/ContentsListResponse" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/forks": { "get": { "produces": [ @@ -23018,6 +23080,20 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "GetFilesOptions": { + "description": "GetFilesOptions options for retrieving metadate and content of multiple files", + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Files" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "GitBlobResponse": { "description": "GitBlobResponse represents a git blob", "type": "object", diff --git a/tests/integration/api_repo_files_get_test.go b/tests/integration/api_repo_files_get_test.go new file mode 100644 index 0000000000..fd46c8590c --- /dev/null +++ b/tests/integration/api_repo_files_get_test.go @@ -0,0 +1,145 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + api "code.gitea.io/gitea/modules/structs" + repo_service "code.gitea.io/gitea/services/repository" + + "github.com/stretchr/testify/assert" +) + +func getExpectedcontentsListResponseForFiles(ref, refType, lastCommitSHA string) []*api.ContentsResponse { + return []*api.ContentsResponse{getExpectedContentsResponseForContents(ref, refType, lastCommitSHA)} +} + +func TestAPIGetRequestedFiles(t *testing.T) { + onGiteaRun(t, testAPIGetRequestedFiles) +} + +func testAPIGetRequestedFiles(t *testing.T, u *url.URL) { + /*** SETUP ***/ + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 + org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo + repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo + repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo + filesOptions := &api.GetFilesOptions{ + Files: []string{ + "README.md", + }, + } + + // Get user2's token req.Body = + session := loginUser(t, user2.Name) + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) // TODO: allow for a POST-request to be scope read + // Get user4's token + session = loginUser(t, user4.Name) + token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) // TODO: allow for a POST-request to be scope read + + // Get the commit ID of the default branch + gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1) + assert.NoError(t, err) + defer gitRepo.Close() + + // Make a new branch in repo1 + newBranch := "test_branch" + err = repo_service.CreateNewBranch(git.DefaultContext, user2, repo1, gitRepo, repo1.DefaultBranch, newBranch) + assert.NoError(t, err) + + commitID, err := gitRepo.GetBranchCommitID(repo1.DefaultBranch) + assert.NoError(t, err) + // Make a new tag in repo1 + newTag := "test_tag" + err = gitRepo.CreateTag(newTag, commitID) + assert.NoError(t, err) + /*** END SETUP ***/ + + // ref is default ref + ref := repo1.DefaultBranch + refType := "branch" + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files?ref=%s", user2.Name, repo1.Name, ref), &filesOptions) + resp := MakeRequest(t, req, http.StatusOK) + var contentsListResponse []*api.ContentsResponse + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + lastCommit, _ := gitRepo.GetCommitByPath("README.md") + expectedcontentsListResponse := getExpectedcontentsListResponseForFiles(ref, refType, lastCommit.ID.String()) + assert.Equal(t, expectedcontentsListResponse, contentsListResponse) + + // No ref + refType = "branch" + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files", user2.Name, repo1.Name), &filesOptions) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + expectedcontentsListResponse = getExpectedcontentsListResponseForFiles(repo1.DefaultBranch, refType, lastCommit.ID.String()) + assert.Equal(t, expectedcontentsListResponse, contentsListResponse) + + // ref is the branch we created above in setup + ref = newBranch + refType = "branch" + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files?ref=%s", user2.Name, repo1.Name, ref), &filesOptions) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + branchCommit, _ := gitRepo.GetBranchCommit(ref) + lastCommit, _ = branchCommit.GetCommitByPath("README.md") + expectedcontentsListResponse = getExpectedcontentsListResponseForFiles(ref, refType, lastCommit.ID.String()) + assert.Equal(t, expectedcontentsListResponse, contentsListResponse) + + // ref is the new tag we created above in setup + ref = newTag + refType = "tag" + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files?ref=%s", user2.Name, repo1.Name, ref), &filesOptions) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + tagCommit, _ := gitRepo.GetTagCommit(ref) + lastCommit, _ = tagCommit.GetCommitByPath("README.md") + expectedcontentsListResponse = getExpectedcontentsListResponseForFiles(ref, refType, lastCommit.ID.String()) + assert.Equal(t, expectedcontentsListResponse, contentsListResponse) + + // ref is a commit + ref = commitID + refType = "commit" + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files?ref=%s", user2.Name, repo1.Name, ref), &filesOptions) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &contentsListResponse) + assert.NotNil(t, contentsListResponse) + expectedcontentsListResponse = getExpectedcontentsListResponseForFiles(ref, refType, commitID) + assert.Equal(t, expectedcontentsListResponse, contentsListResponse) + + // Test file contents a file with a bad ref + ref = "badref" + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files?ref=%s", user2.Name, repo1.Name, ref), &filesOptions) + MakeRequest(t, req, http.StatusNotFound) + + // Test accessing private ref with user token that does not have access - should fail + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files", user2.Name, repo16.Name), &filesOptions). + AddTokenAuth(token4) + MakeRequest(t, req, http.StatusNotFound) + + // Test access private ref of owner of token + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files", user2.Name, repo16.Name), &filesOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) + + // Test access of org org3 private repo file by owner user2 + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/files", org3.Name, repo3.Name), &filesOptions). + AddTokenAuth(token2) + MakeRequest(t, req, http.StatusOK) +}