diff --git a/modules/git/blob.go b/modules/git/blob.go index b7857dbbc6129..ab9deec8d1c2c 100644 --- a/modules/git/blob.go +++ b/modules/git/blob.go @@ -9,6 +9,7 @@ import ( "encoding/base64" "errors" "io" + "strings" "code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/util" @@ -63,33 +64,37 @@ func (b *Blob) GetBlobLineCount(w io.Writer) (int, error) { } } -// GetBlobContentBase64 Reads the content of the blob with a base64 encode and returns the encoded string -func (b *Blob) GetBlobContentBase64() (string, error) { +// GetBlobContentBase64 Reads the content of the blob with a base64 encoding and returns the encoded string +func (b *Blob) GetBlobContentBase64(originContent *strings.Builder) (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() + base64buf := &strings.Builder{} + encoder := base64.NewEncoder(base64.StdEncoding, base64buf) + buf := make([]byte, 32*1024) +loop: + for { + n, err := dataRc.Read(buf) + if n > 0 { + if originContent != nil { + _, _ = originContent.Write(buf[:n]) + } + if _, err := encoder.Write(buf[:n]); err != nil { + return "", err + } + } + switch { + case errors.Is(err, io.EOF): + break loop + case err != nil: + return "", err } - }() - - out, err := io.ReadAll(pr) - if err != nil { - return "", err } - return string(out), nil + _ = encoder.Close() + return base64buf.String(), nil } // GuessContentType guesses the content type of the blob. diff --git a/modules/git/tree_entry_nogogit.go b/modules/git/tree_entry_nogogit.go index 0c0e1835f172d..38a768e3a670f 100644 --- a/modules/git/tree_entry_nogogit.go +++ b/modules/git/tree_entry_nogogit.go @@ -18,7 +18,7 @@ type TreeEntry struct { sized bool } -// Name returns the name of the entry +// Name returns the name of the entry (base name) func (te *TreeEntry) Name() string { return te.name } diff --git a/modules/lfs/pointer.go b/modules/lfs/pointer.go index ebde20f826834..9c95613057be6 100644 --- a/modules/lfs/pointer.go +++ b/modules/lfs/pointer.go @@ -15,15 +15,13 @@ import ( "strings" ) +// spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md const ( - blobSizeCutoff = 1024 + MetaFileMaxSize = 1024 // spec says the maximum size of a pointer file must be smaller than 1024 - // MetaFileIdentifier is the string appearing at the first line of LFS pointer files. - // https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md - MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1" + MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1" // the first line of a pointer file - // MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash. - MetaFileOidPrefix = "oid sha256:" + MetaFileOidPrefix = "oid sha256:" // spec says the only supported hash is sha256 at the moment ) var ( @@ -39,7 +37,7 @@ var ( // ReadPointer tries to read LFS pointer data from the reader func ReadPointer(reader io.Reader) (Pointer, error) { - buf := make([]byte, blobSizeCutoff) + buf := make([]byte, MetaFileMaxSize) n, err := io.ReadFull(reader, buf) if err != nil && err != io.ErrUnexpectedEOF { return Pointer{}, err @@ -65,6 +63,7 @@ func ReadPointerFromBuffer(buf []byte) (Pointer, error) { return p, ErrInvalidStructure } + // spec says "key/value pairs MUST be sorted alphabetically in ascending order (version is exception and must be the first)" oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix) if len(oid) != 64 || !oidPattern.MatchString(oid) { return p, ErrInvalidOIDFormat diff --git a/modules/lfs/pointer_scanner_gogit.go b/modules/lfs/pointer_scanner_gogit.go index f4302c23bcb59..e153b8e24e556 100644 --- a/modules/lfs/pointer_scanner_gogit.go +++ b/modules/lfs/pointer_scanner_gogit.go @@ -31,7 +31,7 @@ func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan c default: } - if blob.Size > blobSizeCutoff { + if blob.Size > MetaFileMaxSize { return nil } diff --git a/modules/structs/git_blob.go b/modules/structs/git_blob.go index 96770cc62e210..643b69ed3726a 100644 --- a/modules/structs/git_blob.go +++ b/modules/structs/git_blob.go @@ -10,4 +10,7 @@ type GitBlobResponse struct { URL string `json:"url"` SHA string `json:"sha"` Size int64 `json:"size"` + + LfsOid *string `json:"lfs_oid,omitempty"` + LfsSize *int64 `json:"lfs_size,omitempty"` } diff --git a/modules/structs/repo_file.go b/modules/structs/repo_file.go index a281620a3b86c..e2e3582088472 100644 --- a/modules/structs/repo_file.go +++ b/modules/structs/repo_file.go @@ -119,6 +119,11 @@ type FileLinksResponse struct { HTMLURL *string `json:"html"` } +type ContentsExtResponse struct { + FileContents *ContentsResponse `json:"file_contents,omitempty"` + DirContents []*ContentsResponse `json:"dir_contents,omitempty"` +} + // ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content type ContentsResponse struct { Name string `json:"name"` @@ -145,6 +150,9 @@ type ContentsResponse struct { // `submodule_git_url` is populated when `type` is `submodule`, otherwise null SubmoduleGitURL *string `json:"submodule_git_url"` Links *FileLinksResponse `json:"_links"` + + LfsOid *string `json:"lfs_oid"` + LfsSize *int64 `json:"lfs_size"` } // FileCommitResponse contains information generated from a Git commit for a repo's file. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 0eb27d776215c..6c366fe4aeba7 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1435,6 +1435,10 @@ func Routes() *web.Router { m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile) }, reqToken()) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) + m.Group("/contents-ext", func() { + m.Get("", repo.GetContentsExt) + m.Get("/*", repo.GetContentsExt) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()). Get(repo.GetFileContentsGet). Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // POST method requires "write" permission, so we also support "GET" method above diff --git a/routers/api/v1/repo/blob.go b/routers/api/v1/repo/blob.go index d1cb72f5f1f74..9a17fc1bbfd2c 100644 --- a/routers/api/v1/repo/blob.go +++ b/routers/api/v1/repo/blob.go @@ -47,7 +47,7 @@ func GetBlob(ctx *context.APIContext) { return } - if blob, err := files_service.GetBlobBySHA(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, sha); err != nil { + if blob, err := files_service.GetBlobBySHA(ctx.Repo.Repository, ctx.Repo.GitRepo, sha); err != nil { ctx.APIError(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 1c7b57b922122..76068c077f7a1 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -905,11 +905,71 @@ func resolveRefCommit(ctx *context.APIContext, ref string, minCommitIDLen ...int return refCommit } +func GetContentsExt(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/contents-ext/{filepath} repository repoGetContentsExt + // --- + // summary: The extended "contents" API, to get file metadata and/or content, or list a directory. + // description: It guarantees that only one of the response fields is set if the request succeeds. + // Users can pass "includes=file_content" or "includes=lfs_metadata" to retrieve more fields. + // 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 dir, file, symlink or submodule in the repo + // type: string + // required: true + // - name: ref + // in: query + // description: the name of the commit/branch/tag, default to the repository’s default branch. + // type: string + // required: false + // - name: includes + // in: query + // description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields. + // Option "file_content" will try to retrieve the file content, option "lfs_metadata" will try to retrieve LFS metadata. + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ContentsExtResponse" + // "404": + // "$ref": "#/responses/notFound" + + opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")} + for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") { + if includeOpt == "" { + continue + } + switch includeOpt { + case "file_content": + opts.IncludeSingleFileContent = true + case "lfs_metadata": + opts.IncludeLfsMetadata = true + default: + ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt)) + return + } + } + ctx.JSON(http.StatusOK, getRepoContents(ctx, opts)) +} + // GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir func GetContents(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetContents // --- - // summary: Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir + // summary: Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir. + // description: This API follows GitHub's design, and it is not easy to use. Recommend to use our "contents-ext" API instead. // produces: // - application/json // parameters: @@ -938,29 +998,35 @@ func GetContents(ctx *context.APIContext) { // "$ref": "#/responses/ContentsResponse" // "404": // "$ref": "#/responses/notFound" - - treePath := ctx.PathParam("*") - refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref")) + ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*"), IncludeSingleFileContent: true}) if ctx.Written() { return } + ctx.JSON(http.StatusOK, util.Iif[any](ret.FileContents != nil, ret.FileContents, ret.DirContents)) +} - if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath); err != nil { +func getRepoContents(ctx *context.APIContext, opts files_service.GetContentsOrListOptions) *api.ContentsExtResponse { + refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref")) + if ctx.Written() { + return nil + } + ret, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts) + if err != nil { if git.IsErrNotExist(err) { ctx.APIErrorNotFound("GetContentsOrList", err) - return + return nil } ctx.APIErrorInternal(err) - } else { - ctx.JSON(http.StatusOK, fileList) } + return &ret } // GetContentsList Get the metadata of all the entries of the root dir func GetContentsList(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/contents repository repoGetContentsList // --- - // summary: Gets the metadata of all the entries of the root dir + // summary: Gets the metadata of all the entries of the root dir. + // description: This API follows GitHub's design, and it is not easy to use. Recommend to use our "contents-ext" API instead. // produces: // - application/json // parameters: @@ -1084,6 +1150,6 @@ func handleGetFileContents(ctx *context.APIContext) { if ctx.Written() { return } - filesResponse := files_service.GetContentsListFromTreePaths(ctx, ctx.Repo.Repository, refCommit, opts.Files) + filesResponse := files_service.GetContentsListFromTreePaths(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts.Files) ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(filesResponse)) } diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go index 3094c1947cd43..8e24ffa465c14 100644 --- a/routers/api/v1/repo/wiki.go +++ b/routers/api/v1/repo/wiki.go @@ -499,7 +499,7 @@ func wikiContentsByEntry(ctx *context.APIContext, entry *git.TreeEntry) string { if blob.Size() > setting.API.DefaultMaxBlobSize { return "" } - content, err := blob.GetBlobContentBase64() + content, err := blob.GetBlobContentBase64(nil) if err != nil { ctx.APIErrorInternal(err) return "" diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index df043b71d3135..9e20c0533b9ce 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -331,6 +331,12 @@ type swaggerContentsListResponse struct { Body []api.ContentsResponse `json:"body"` } +// swagger:response ContentsExtResponse +type swaggerContentsExtResponse struct { + // in:body + Body api.ContentsExtResponse `json:"body"` +} + // FileDeleteResponse // swagger:response FileDeleteResponse type swaggerFileDeleteResponse struct { diff --git a/services/repository/files/content.go b/services/repository/files/content.go index ccba3b759478d..beef38169492d 100644 --- a/services/repository/files/content.go +++ b/services/repository/files/content.go @@ -5,13 +5,14 @@ package files import ( "context" - "fmt" + "io" "net/url" "path" + "strings" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/lfs" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -34,54 +35,50 @@ func (ct *ContentType) String() string { return string(*ct) } +type GetContentsOrListOptions struct { + TreePath string + IncludeSingleFileContent bool // include the file's content when the tree path is a file + IncludeLfsMetadata bool +} + // GetContentsOrList gets the metadata of a file's contents (*ContentsResponse) if treePath not a tree // directory, otherwise a listing of file contents ([]*ContentsResponse). Ref can be a branch, commit or tag -func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treePath string) (any, error) { - if repo.IsEmpty { - return make([]any, 0), nil +func GetContentsOrList(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, opts GetContentsOrListOptions) (ret api.ContentsExtResponse, _ error) { + entry, err := prepareGetContentsEntry(refCommit, &opts.TreePath) + if repo.IsEmpty && opts.TreePath == "" { + return api.ContentsExtResponse{DirContents: make([]*api.ContentsResponse, 0)}, nil } - - // Check that the path given in opts.treePath is valid (not a git path) - cleanTreePath := CleanGitTreePath(treePath) - if cleanTreePath == "" && treePath != "" { - return nil, ErrFilenameInvalid{ - Path: treePath, - } - } - treePath = cleanTreePath - - // Get the commit object for the ref - commit := refCommit.Commit - - entry, err := commit.GetTreeEntryByPath(treePath) if err != nil { - return nil, err + return ret, err } + // get file contents if entry.Type() != "tree" { - return GetContents(ctx, repo, refCommit, treePath, false) + ret.FileContents, err = getFileContentsByEntryInternal(ctx, repo, gitRepo, refCommit, entry, opts) + return ret, err } - // We are in a directory, so we return a list of FileContentResponse objects - var fileList []*api.ContentsResponse - - gitTree, err := commit.SubTree(treePath) + // list directory contents + gitTree, err := refCommit.Commit.SubTree(opts.TreePath) if err != nil { - return nil, err + return ret, err } entries, err := gitTree.ListEntries() if err != nil { - return nil, err + return ret, err } + ret.DirContents = make([]*api.ContentsResponse, 0, len(entries)) for _, e := range entries { - subTreePath := path.Join(treePath, e.Name()) - fileContentResponse, err := GetContents(ctx, repo, refCommit, subTreePath, true) + subOpts := opts + subOpts.TreePath = path.Join(opts.TreePath, e.Name()) + subOpts.IncludeSingleFileContent = false // never include file content when listing a directory + fileContentResponse, err := GetFileContents(ctx, repo, gitRepo, refCommit, subOpts) if err != nil { - return nil, err + return ret, err } - fileList = append(fileList, fileContentResponse) + ret.DirContents = append(ret.DirContents, fileContentResponse) } - return fileList, nil + return ret, nil } // GetObjectTypeFromTreeEntry check what content is behind it @@ -100,35 +97,36 @@ func GetObjectTypeFromTreeEntry(entry *git.TreeEntry) ContentType { } } -// GetContents gets the metadata on a file's contents. Ref can be a branch, commit or tag -func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treePath string, forList bool) (*api.ContentsResponse, error) { +func prepareGetContentsEntry(refCommit *utils.RefCommit, treePath *string) (*git.TreeEntry, error) { // Check that the path given in opts.treePath is valid (not a git path) - cleanTreePath := CleanGitTreePath(treePath) - if cleanTreePath == "" && treePath != "" { - return nil, ErrFilenameInvalid{ - Path: treePath, - } + cleanTreePath := CleanGitTreePath(*treePath) + if cleanTreePath == "" && *treePath != "" { + return nil, ErrFilenameInvalid{Path: *treePath} } - treePath = cleanTreePath + *treePath = cleanTreePath - gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo) - if err != nil { - return nil, err + // Only allow safe ref types + refType := refCommit.RefName.RefType() + if refType != git.RefTypeBranch && refType != git.RefTypeTag && refType != git.RefTypeCommit { + return nil, util.NewNotExistErrorf("no commit found for the ref [ref: %s]", refCommit.RefName) } - defer closer.Close() - commit := refCommit.Commit - entry, err := commit.GetTreeEntryByPath(treePath) + return refCommit.Commit.GetTreeEntryByPath(*treePath) +} + +// GetFileContents gets the metadata on a file's contents. Ref can be a branch, commit or tag +func GetFileContents(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, opts GetContentsOrListOptions) (*api.ContentsResponse, error) { + entry, err := prepareGetContentsEntry(refCommit, &opts.TreePath) if err != nil { return nil, err } + return getFileContentsByEntryInternal(ctx, repo, gitRepo, refCommit, entry, opts) +} +func getFileContentsByEntryInternal(_ context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, entry *git.TreeEntry, opts GetContentsOrListOptions) (*api.ContentsResponse, error) { refType := refCommit.RefName.RefType() - if refType != git.RefTypeBranch && refType != git.RefTypeTag && refType != git.RefTypeCommit { - return nil, fmt.Errorf("no commit found for the ref [ref: %s]", refCommit.RefName) - } - - selfURL, err := url.Parse(repo.APIURL() + "/contents/" + util.PathEscapeSegments(treePath) + "?ref=" + url.QueryEscape(refCommit.InputRef)) + commit := refCommit.Commit + selfURL, err := url.Parse(repo.APIURL() + "/contents/" + util.PathEscapeSegments(opts.TreePath) + "?ref=" + url.QueryEscape(refCommit.InputRef)) if err != nil { return nil, err } @@ -139,7 +137,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *ut return nil, err } - lastCommit, err := commit.GetCommitByPath(treePath) + lastCommit, err := refCommit.Commit.GetCommitByPath(opts.TreePath) if err != nil { return nil, err } @@ -147,7 +145,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *ut // All content types have these fields in populated contentsResponse := &api.ContentsResponse{ Name: entry.Name(), - Path: treePath, + Path: opts.TreePath, SHA: entry.ID.String(), LastCommitSHA: lastCommit.ID.String(), Size: entry.Size(), @@ -170,13 +168,18 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *ut if entry.IsRegular() || entry.IsExecutable() { contentsResponse.Type = string(ContentTypeRegular) // if it is listing the repo root dir, don't waste system resources on reading content - if !forList { - blobResponse, err := GetBlobBySHA(ctx, repo, gitRepo, entry.ID.String()) + if opts.IncludeSingleFileContent { + blobResponse, err := GetBlobBySHA(repo, gitRepo, entry.ID.String()) + if err != nil { + return nil, err + } + contentsResponse.Encoding, contentsResponse.Content = blobResponse.Encoding, blobResponse.Content + contentsResponse.LfsOid, contentsResponse.LfsSize = blobResponse.LfsOid, blobResponse.LfsSize + } else if opts.IncludeLfsMetadata { + contentsResponse.LfsOid, contentsResponse.LfsSize, err = parsePossibleLfsPointerBlob(gitRepo, entry.ID.String()) if err != nil { return nil, err } - contentsResponse.Encoding = blobResponse.Encoding - contentsResponse.Content = blobResponse.Content } } else if entry.IsDir() { contentsResponse.Type = string(ContentTypeDir) @@ -190,7 +193,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *ut contentsResponse.Target = &targetFromContent } else if entry.IsSubModule() { contentsResponse.Type = string(ContentTypeSubmodule) - submodule, err := commit.GetSubModule(treePath) + submodule, err := commit.GetSubModule(opts.TreePath) if err != nil { return nil, err } @@ -200,7 +203,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *ut } // Handle links if entry.IsRegular() || entry.IsLink() || entry.IsExecutable() { - downloadURL, err := url.Parse(repo.HTMLURL() + "/raw/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(treePath)) + downloadURL, err := url.Parse(repo.HTMLURL() + "/raw/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(opts.TreePath)) if err != nil { return nil, err } @@ -208,7 +211,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *ut contentsResponse.DownloadURL = &downloadURLString } if !entry.IsSubModule() { - htmlURL, err := url.Parse(repo.HTMLURL() + "/src/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(treePath)) + htmlURL, err := url.Parse(repo.HTMLURL() + "/src/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(opts.TreePath)) if err != nil { return nil, err } @@ -228,8 +231,7 @@ func GetContents(ctx context.Context, repo *repo_model.Repository, refCommit *ut return contentsResponse, nil } -// GetBlobBySHA get the GitBlobResponse of a repository using a sha hash. -func GetBlobBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, sha string) (*api.GitBlobResponse, error) { +func GetBlobBySHA(repo *repo_model.Repository, gitRepo *git.Repository, sha string) (*api.GitBlobResponse, error) { gitBlob, err := gitRepo.GetBlob(sha) if err != nil { return nil, err @@ -239,12 +241,49 @@ func GetBlobBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git URL: repo.APIURL() + "/git/blobs/" + url.PathEscape(gitBlob.ID.String()), Size: gitBlob.Size(), } - if gitBlob.Size() <= setting.API.DefaultMaxBlobSize { - content, err := gitBlob.GetBlobContentBase64() - if err != nil { - return nil, err - } - ret.Encoding, ret.Content = util.ToPointer("base64"), &content + + blobSize := gitBlob.Size() + if blobSize > setting.API.DefaultMaxBlobSize { + return ret, nil + } + + var originContent *strings.Builder + if 0 < blobSize && blobSize < lfs.MetaFileMaxSize { + originContent = &strings.Builder{} + } + + content, err := gitBlob.GetBlobContentBase64(originContent) + if err != nil { + return nil, err + } + + ret.Encoding, ret.Content = util.ToPointer("base64"), &content + if originContent != nil { + ret.LfsOid, ret.LfsSize = parsePossibleLfsPointerBuffer(strings.NewReader(originContent.String())) } return ret, nil } + +func parsePossibleLfsPointerBuffer(r io.Reader) (*string, *int64) { + p, _ := lfs.ReadPointer(r) + if p.IsValid() { + return &p.Oid, &p.Size + } + return nil, nil +} + +func parsePossibleLfsPointerBlob(gitRepo *git.Repository, sha string) (*string, *int64, error) { + gitBlob, err := gitRepo.GetBlob(sha) + if err != nil { + return nil, nil, err + } + if gitBlob.Size() > lfs.MetaFileMaxSize { + return nil, nil, nil // not a LFS pointer + } + buf, err := gitBlob.GetBlobContent(lfs.MetaFileMaxSize) + if err != nil { + return nil, nil, err + } + oid, size := parsePossibleLfsPointerBuffer(strings.NewReader(buf)) + return oid, size, nil +} diff --git a/services/repository/files/content_test.go b/services/repository/files/content_test.go index eb10f5c9b1188..9357c52ea8210 100644 --- a/services/repository/files/content_test.go +++ b/services/repository/files/content_test.go @@ -8,7 +8,6 @@ import ( "time" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/gitrepo" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/api/v1/utils" @@ -65,145 +64,58 @@ func TestGetContents(t *testing.T) { contexttest.LoadUser(t, ctx, 2) contexttest.LoadGitRepo(t, ctx) defer ctx.Repo.GitRepo.Close() - - treePath := "README.md" + repo, gitRepo := ctx.Repo.Repository, ctx.Repo.GitRepo refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch) require.NoError(t, err) - expectedContentsResponse := getExpectedReadmeContentsResponse() - - t.Run("Get README.md contents with GetContents(ctx, )", func(t *testing.T) { - fileContentResponse, err := GetContents(ctx, ctx.Repo.Repository, refCommit, treePath, false) - assert.Equal(t, expectedContentsResponse, fileContentResponse) + t.Run("GetContentsOrList(README.md)-MetaOnly", func(t *testing.T) { + expectedContentsResponse := getExpectedReadmeContentsResponse() + expectedContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content + expectedContentsResponse.Content = nil + extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: false}) + assert.Equal(t, expectedContentsResponse, extResp.FileContents) assert.NoError(t, err) }) -} - -func TestGetContentsOrListForDir(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - treePath := "" // root dir - refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch) - require.NoError(t, err) - - readmeContentsResponse := getExpectedReadmeContentsResponse() - // because will be in a list, doesn't have encoding and content - readmeContentsResponse.Encoding = nil - readmeContentsResponse.Content = nil - expectedContentsListResponse := []*api.ContentsResponse{ - readmeContentsResponse, - } - - t.Run("Get root dir contents with GetContentsOrList(ctx, )", func(t *testing.T) { - fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath) - assert.EqualValues(t, expectedContentsListResponse, fileContentResponse) + t.Run("GetContentsOrList(README.md)", func(t *testing.T) { + expectedContentsResponse := getExpectedReadmeContentsResponse() + extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "README.md", IncludeSingleFileContent: true}) + assert.Equal(t, expectedContentsResponse, extResp.FileContents) assert.NoError(t, err) }) -} -func TestGetContentsOrListForFile(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - treePath := "README.md" - refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch) - require.NoError(t, err) - - expectedContentsResponse := getExpectedReadmeContentsResponse() - - t.Run("Get README.md contents with GetContentsOrList(ctx, )", func(t *testing.T) { - fileContentResponse, err := GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath) - assert.EqualValues(t, expectedContentsResponse, fileContentResponse) + t.Run("GetContentsOrList(RootDir)", func(t *testing.T) { + readmeContentsResponse := getExpectedReadmeContentsResponse() + readmeContentsResponse.Encoding = nil // because will be in a list, doesn't have encoding and content + readmeContentsResponse.Content = nil + expectedContentsListResponse := []*api.ContentsResponse{readmeContentsResponse} + // even if IncludeFileContent is true, it has no effect for directory listing + extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "", IncludeSingleFileContent: true}) + assert.Equal(t, expectedContentsListResponse, extResp.DirContents) assert.NoError(t, err) }) -} -func TestGetContentsErrors(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - repo := ctx.Repo.Repository - refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch) - require.NoError(t, err) - - t.Run("bad treePath", func(t *testing.T) { - badTreePath := "bad/tree.md" - fileContentResponse, err := GetContents(ctx, repo, refCommit, badTreePath, false) + t.Run("GetContentsOrList(NoSuchTreePath)", func(t *testing.T) { + extResp, err := GetContentsOrList(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: "no-such/file.md"}) assert.Error(t, err) - assert.EqualError(t, err, "object does not exist [id: , rel_path: bad]") - assert.Nil(t, fileContentResponse) + assert.EqualError(t, err, "object does not exist [id: , rel_path: no-such]") + assert.Nil(t, extResp.DirContents) + assert.Nil(t, extResp.FileContents) }) -} - -func TestGetContentsOrListErrors(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - ctx.SetPathParam("id", "1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - repo := ctx.Repo.Repository - refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ctx.Repo.Repository.DefaultBranch) - require.NoError(t, err) - t.Run("bad treePath", func(t *testing.T) { - badTreePath := "bad/tree.md" - fileContentResponse, err := GetContentsOrList(ctx, repo, refCommit, badTreePath) - assert.Error(t, err) - assert.EqualError(t, err, "object does not exist [id: , rel_path: bad]") - assert.Nil(t, fileContentResponse) + t.Run("GetBlobBySHA", func(t *testing.T) { + sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" + ctx.SetPathParam("id", "1") + ctx.SetPathParam("sha", sha) + gbr, err := GetBlobBySHA(ctx.Repo.Repository, ctx.Repo.GitRepo, ctx.PathParam("sha")) + expectedGBR := &api.GitBlobResponse{ + Content: util.ToPointer("dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK"), + Encoding: util.ToPointer("base64"), + URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/65f1bf27bc3bf70f64657658635e66094edbcb4d", + SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", + Size: 180, + } + assert.NoError(t, err) + assert.Equal(t, expectedGBR, gbr) }) } - -func TestGetBlobBySHA(t *testing.T) { - unittest.PrepareTestEnv(t) - ctx, _ := contexttest.MockContext(t, "user2/repo1") - contexttest.LoadRepo(t, ctx, 1) - contexttest.LoadRepoCommit(t, ctx) - contexttest.LoadUser(t, ctx, 2) - contexttest.LoadGitRepo(t, ctx) - defer ctx.Repo.GitRepo.Close() - - sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" - ctx.SetPathParam("id", "1") - ctx.SetPathParam("sha", sha) - - gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) - if err != nil { - t.Fail() - } - - gbr, err := GetBlobBySHA(ctx, ctx.Repo.Repository, gitRepo, ctx.PathParam("sha")) - expectedGBR := &api.GitBlobResponse{ - Content: util.ToPointer("dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK"), - Encoding: util.ToPointer("base64"), - URL: "https://try.gitea.io/api/v1/repos/user2/repo1/git/blobs/65f1bf27bc3bf70f64657658635e66094edbcb4d", - SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d", - Size: 180, - } - assert.NoError(t, err) - assert.Equal(t, expectedGBR, gbr) -} diff --git a/services/repository/files/file.go b/services/repository/files/file.go index 855dc5c8edde0..2a63a0a5b9704 100644 --- a/services/repository/files/file.go +++ b/services/repository/files/file.go @@ -19,12 +19,12 @@ import ( "code.gitea.io/gitea/routers/api/v1/utils" ) -func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) { +func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treePaths []string) (files []*api.ContentsResponse) { var size int64 for _, treePath := range treePaths { - fileContents, _ := GetContents(ctx, repo, refCommit, treePath, false) // ok if fails, then will be nil + fileContents, _ := GetFileContents(ctx, repo, gitRepo, refCommit, GetContentsOrListOptions{TreePath: treePath, IncludeSingleFileContent: true}) // ok if fails, then will be nil if fileContents != nil && fileContents.Content != nil && *fileContents.Content != "" { - // if content isn't empty (e.g. due to the single blob being too large), add file size to response size + // if content isn't empty (e.g., due to the single blob being too large), add file size to response size size += int64(len(*fileContents.Content)) } if size > setting.API.DefaultMaxResponseSize { @@ -38,8 +38,8 @@ func GetContentsListFromTreePaths(ctx context.Context, repo *repo_model.Reposito return files } -func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, refCommit *utils.RefCommit, treeNames []string) (*api.FilesResponse, error) { - files := GetContentsListFromTreePaths(ctx, repo, refCommit, treeNames) +func GetFilesResponseFromCommit(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, treeNames []string) (*api.FilesResponse, error) { + files := GetContentsListFromTreePaths(ctx, repo, gitRepo, refCommit, treeNames) fileCommitResponse, _ := GetFileCommitResponse(repo, refCommit.Commit) // ok if fails, then will be nil verification := GetPayloadCommitVerification(ctx, refCommit.Commit) filesResponse := &api.FilesResponse{ diff --git a/services/repository/files/update.go b/services/repository/files/update.go index 5aaa394e9a389..47951dc3344ed 100644 --- a/services/repository/files/update.go +++ b/services/repository/files/update.go @@ -315,7 +315,7 @@ func ChangeRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use // FIXME: this call seems not right, why it needs to read the file content again // FIXME: why it uses the NewBranch as "ref", it should use the commit ID because the response is only for this commit - filesResponse, err := GetFilesResponseFromCommit(ctx, repo, utils.NewRefCommit(git.RefNameFromBranch(opts.NewBranch), commit), treePaths) + filesResponse, err := GetFilesResponseFromCommit(ctx, repo, gitRepo, utils.NewRefCommit(git.RefNameFromBranch(opts.NewBranch), commit), treePaths) if err != nil { return nil, err } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 42f09ebeb28ee..da0ba18b74288 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7424,13 +7424,14 @@ }, "/repos/{owner}/{repo}/contents": { "get": { + "description": "This API follows GitHub's design, and it is not easy to use. Recommend to use our \"contents-ext\" API instead.", "produces": [ "application/json" ], "tags": [ "repository" ], - "summary": "Gets the metadata of all the entries of the root dir", + "summary": "Gets the metadata of all the entries of the root dir.", "operationId": "repoGetContentsList", "parameters": [ { @@ -7518,15 +7519,72 @@ } } }, + "/repos/{owner}/{repo}/contents-ext/{filepath}": { + "get": { + "description": "It guarantees that only one of the response fields is set if the request succeeds. Users can pass \"includes=file_content\" or \"includes=lfs_metadata\" to retrieve more fields.", + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "The extended \"contents\" API, to get file metadata and/or content, or list a directory.", + "operationId": "repoGetContentsExt", + "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 dir, file, symlink or submodule in the repo", + "name": "filepath", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the name of the commit/branch/tag, default to the repository’s default branch.", + "name": "ref", + "in": "query" + }, + { + "type": "string", + "description": "By default this API's response only contains file's metadata. Use comma-separated \"includes\" options to retrieve more fields. Option \"file_content\" will try to retrieve the file content, option \"lfs_metadata\" will try to retrieve LFS metadata.", + "name": "includes", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ContentsExtResponse" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/contents/{filepath}": { "get": { + "description": "This API follows GitHub's design, and it is not easy to use. Recommend to use our \"contents-ext\" API instead.", "produces": [ "application/json" ], "tags": [ "repository" ], - "summary": "Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir", + "summary": "Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir.", "operationId": "repoGetContents", "parameters": [ { @@ -22255,6 +22313,22 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ContentsExtResponse": { + "type": "object", + "properties": { + "dir_contents": { + "type": "array", + "items": { + "$ref": "#/definitions/ContentsResponse" + }, + "x-go-name": "DirContents" + }, + "file_contents": { + "$ref": "#/definitions/ContentsResponse" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "ContentsResponse": { "description": "ContentsResponse contains information about a repo's entry's (dir, file, symlink, submodule) metadata and content", "type": "object", @@ -22298,6 +22372,15 @@ "format": "date-time", "x-go-name": "LastCommitterDate" }, + "lfs_oid": { + "type": "string", + "x-go-name": "LfsOid" + }, + "lfs_size": { + "type": "integer", + "format": "int64", + "x-go-name": "LfsSize" + }, "name": { "type": "string", "x-go-name": "Name" @@ -24947,6 +25030,15 @@ "type": "string", "x-go-name": "Encoding" }, + "lfs_oid": { + "type": "string", + "x-go-name": "LfsOid" + }, + "lfs_size": { + "type": "integer", + "format": "int64", + "x-go-name": "LfsSize" + }, "sha": { "type": "string", "x-go-name": "SHA" @@ -28693,6 +28785,12 @@ "$ref": "#/definitions/Compare" } }, + "ContentsExtResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/ContentsExtResponse" + } + }, "ContentsListResponse": { "description": "ContentsListResponse", "schema": { diff --git a/tests/integration/api_repo_get_contents_test.go b/tests/integration/api_repo_get_contents_test.go index a7de97c052880..9517db4c87cee 100644 --- a/tests/integration/api_repo_get_contents_test.go +++ b/tests/integration/api_repo_get_contents_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "slices" "testing" "time" @@ -20,9 +21,9 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" repo_service "code.gitea.io/gitea/services/repository" - "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) *api.ContentsResponse { @@ -54,7 +55,11 @@ func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) } func TestAPIGetContents(t *testing.T) { - onGiteaRun(t, testAPIGetContents) + onGiteaRun(t, func(t *testing.T, u *url.URL) { + testAPIGetContentsRefFormats(t) + testAPIGetContents(t, u) + testAPIGetContentsExt(t) + }) } func testAPIGetContents(t *testing.T, u *url.URL) { @@ -76,20 +81,20 @@ func testAPIGetContents(t *testing.T, u *url.URL) { // Get the commit ID of the default branch gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1) - assert.NoError(t, err) + require.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) + require.NoError(t, err) commitID, err := gitRepo.GetBranchCommitID(repo1.DefaultBranch) - assert.NoError(t, err) + require.NoError(t, err) // Make a new tag in repo1 newTag := "test_tag" err = gitRepo.CreateTag(newTag, commitID) - assert.NoError(t, err) + require.NoError(t, err) /*** END SETUP ***/ // ref is default ref @@ -99,7 +104,6 @@ func testAPIGetContents(t *testing.T, u *url.URL) { resp := MakeRequest(t, req, http.StatusOK) var contentsResponse api.ContentsResponse DecodeJSON(t, resp, &contentsResponse) - assert.NotNil(t, contentsResponse) lastCommit, _ := gitRepo.GetCommitByPath("README.md") expectedContentsResponse := getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String()) assert.Equal(t, *expectedContentsResponse, contentsResponse) @@ -109,7 +113,6 @@ func testAPIGetContents(t *testing.T, u *url.URL) { req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsResponse) - assert.NotNil(t, contentsResponse) expectedContentsResponse = getExpectedContentsResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String()) assert.Equal(t, *expectedContentsResponse, contentsResponse) @@ -119,7 +122,6 @@ func testAPIGetContents(t *testing.T, u *url.URL) { req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsResponse) - assert.NotNil(t, contentsResponse) branchCommit, _ := gitRepo.GetBranchCommit(ref) lastCommit, _ = branchCommit.GetCommitByPath("README.md") expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String()) @@ -131,7 +133,6 @@ func testAPIGetContents(t *testing.T, u *url.URL) { req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsResponse) - assert.NotNil(t, contentsResponse) tagCommit, _ := gitRepo.GetTagCommit(ref) lastCommit, _ = tagCommit.GetCommitByPath("README.md") expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String()) @@ -143,7 +144,6 @@ func testAPIGetContents(t *testing.T, u *url.URL) { req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref) resp = MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &contentsResponse) - assert.NotNil(t, contentsResponse) expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, commitID) assert.Equal(t, *expectedContentsResponse, contentsResponse) @@ -168,9 +168,7 @@ func testAPIGetContents(t *testing.T, u *url.URL) { MakeRequest(t, req, http.StatusOK) } -func TestAPIGetContentsRefFormats(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testAPIGetContentsRefFormats(t *testing.T) { file := "README.md" sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d" content := "# repo1\n\nDescription for repo1" @@ -203,3 +201,76 @@ func TestAPIGetContentsRefFormats(t *testing.T) { // FIXME: this is an incorrect behavior, non-existing branch falls back to default branch _ = MakeRequest(t, NewRequest(t, http.MethodGet, "/api/v1/repos/user2/repo1/raw/README.md?ref=no-such"), http.StatusOK) } + +func testAPIGetContentsExt(t *testing.T) { + session := loginUser(t, "user2") + token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + t.Run("DirContents", func(t *testing.T) { + req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check") + resp := MakeRequest(t, req, http.StatusOK) + var contentsResponse api.ContentsExtResponse + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.FileContents) + assert.Equal(t, "README.md", contentsResponse.DirContents[0].Name) + assert.Nil(t, contentsResponse.DirContents[0].Encoding) + assert.Nil(t, contentsResponse.DirContents[0].Content) + + // "includes=file_content" shouldn't affect directory listing + req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs?ref=sub-home-md-img-check&includes=file_content") + resp = MakeRequest(t, req, http.StatusOK) + contentsResponse = api.ContentsExtResponse{} + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.FileContents) + assert.Equal(t, "README.md", contentsResponse.DirContents[0].Name) + assert.Nil(t, contentsResponse.DirContents[0].Encoding) + assert.Nil(t, contentsResponse.DirContents[0].Content) + + req = NewRequestf(t, "GET", "/api/v1/repos/user2/lfs/contents-ext?includes=file_content,lfs_metadata").AddTokenAuth(token2) + resp = session.MakeRequest(t, req, http.StatusOK) + contentsResponse = api.ContentsExtResponse{} + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.FileContents) + respFileIdx := slices.IndexFunc(contentsResponse.DirContents, func(response *api.ContentsResponse) bool { return response.Name == "jpeg.jpg" }) + require.NotEqual(t, -1, respFileIdx) + respFile := contentsResponse.DirContents[respFileIdx] + assert.Equal(t, "jpeg.jpg", respFile.Name) + assert.Nil(t, respFile.Encoding) + assert.Nil(t, respFile.Content) + assert.Equal(t, util.ToPointer(int64(107)), respFile.LfsSize) + assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid) + }) + t.Run("FileContents", func(t *testing.T) { + // by default, no file content is returned + req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check") + resp := MakeRequest(t, req, http.StatusOK) + var contentsResponse api.ContentsExtResponse + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.DirContents) + assert.Equal(t, "README.md", contentsResponse.FileContents.Name) + assert.Nil(t, contentsResponse.FileContents.Encoding) + assert.Nil(t, contentsResponse.FileContents.Content) + + // file content is only returned when `includes=file_content` + req = NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/contents-ext/docs/README.md?ref=sub-home-md-img-check&includes=file_content") + resp = MakeRequest(t, req, http.StatusOK) + contentsResponse = api.ContentsExtResponse{} + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.DirContents) + assert.Equal(t, "README.md", contentsResponse.FileContents.Name) + assert.NotNil(t, contentsResponse.FileContents.Encoding) + assert.NotNil(t, contentsResponse.FileContents.Content) + + req = NewRequestf(t, "GET", "/api/v1/repos/user2/lfs/contents-ext/jpeg.jpg?includes=file_content").AddTokenAuth(token2) + resp = session.MakeRequest(t, req, http.StatusOK) + contentsResponse = api.ContentsExtResponse{} + DecodeJSON(t, resp, &contentsResponse) + assert.Nil(t, contentsResponse.DirContents) + assert.NotNil(t, contentsResponse.FileContents) + respFile := contentsResponse.FileContents + assert.Equal(t, "jpeg.jpg", respFile.Name) + assert.NotNil(t, respFile.Encoding) + assert.NotNil(t, respFile.Content) + assert.Equal(t, util.ToPointer(int64(107)), respFile.LfsSize) + assert.Equal(t, util.ToPointer("0b8d8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351"), respFile.LfsOid) + }) +} diff --git a/tests/integration/repofiles_change_test.go b/tests/integration/repofiles_change_test.go index 461175e1ccb19..b63d06a866932 100644 --- a/tests/integration/repofiles_change_test.go +++ b/tests/integration/repofiles_change_test.go @@ -287,6 +287,8 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str details := []struct { filename, sha, content string size int64 + lfsOid *string + lfsSize *int64 }{ { filename: "README.txt", @@ -299,6 +301,8 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str sha: "d4a41a0d4db4949e129bd22f871171ea988103ef", size: 129, content: "dmVyc2lvbiBodHRwczovL2dpdC1sZnMuZ2l0aHViLmNvbS9zcGVjL3YxCm9pZCBzaGEyNTY6MmVjY2RiNDM4MjVkMmE0OWQ5OWQ1NDJkYWEyMDA3NWNmZjFkOTdkOWQyMzQ5YTg5NzdlZmU5YzAzNjYxNzM3YwpzaXplIDIwNDgK", + lfsOid: util.ToPointer("2eccdb43825d2a49d99d542daa20075cff1d97d9d2349a8977efe9c03661737c"), + lfsSize: util.ToPointer(int64(2048)), }, { filename: "jpeg.jpeg", @@ -311,6 +315,8 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str sha: "2b6c6c4eaefa24b22f2092c3d54b263ff26feb58", size: 127, content: "dmVyc2lvbiBodHRwczovL2dpdC1sZnMuZ2l0aHViLmNvbS9zcGVjL3YxCm9pZCBzaGEyNTY6N2I2YjJjODhkYmE5Zjc2MGExYTU4NDY5YjY3ZmVlMmI2OThlZjdlOTM5OWM0Y2E0ZjM0YTE0Y2NiZTM5ZjYyMwpzaXplIDI3Cg==", + lfsOid: util.ToPointer("7b6b2c88dba9f760a1a58469b67fee2b698ef7e9399c4ca4f34a14ccbe39f623"), + lfsSize: util.ToPointer(int64(27)), }, } @@ -339,6 +345,8 @@ func getExpectedFileResponseForRepoFilesUpdateRename(commitID, lastCommitSHA str GitURL: &gitURL, HTMLURL: &htmlURL, }, + LfsOid: detail.lfsOid, + LfsSize: detail.lfsSize, }) }