Files
Gitea/services/repository/files/content.go
T
wxiaoguang 9dc04289aa refactor: make git package handle all git operations (#38543)
before: gitrepo vs git packages
after: git package fully handle all git operations

by the way, use `WithRepo(repo)` instead of `WithDir(repo.Path)` to hide
path details.

benefits:

1. remove all unnecessary wrappers, developers no need to struggle with
"which package should be used"
2. simplify code, RepositoryFacade can (will) be used everywhere, all
"path" details are (will be) hidden
2026-07-20 16:07:38 +00:00

308 lines
10 KiB
Go

// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package files
import (
"context"
"io"
"net/url"
"path"
"strings"
repo_model "gitea.dev/models/repo"
"gitea.dev/modules/cache"
"gitea.dev/modules/git"
"gitea.dev/modules/lfs"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/util"
"gitea.dev/routers/api/v1/utils"
)
// ContentType repo content type
type ContentType string
// The string representations of different content types
const (
ContentTypeRegular ContentType = "file" // regular content type (file)
ContentTypeDir ContentType = "dir" // dir content type (dir)
ContentTypeLink ContentType = "symlink" // link content type (symlink)
ContentTypeSubmodule ContentType = "submodule" // submodule content type (submodule)
)
type GetContentsOrListOptions struct {
TreePath string
IncludeSingleFileContent bool // include the file's content when the tree path is a file
IncludeLfsMetadata bool
IncludeCommitMetadata bool
IncludeCommitMessage 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, gitRepo *git.Repository, refCommit *utils.RefCommit, opts GetContentsOrListOptions) (ret api.ContentsExtResponse, _ error) {
entry, err := prepareGetContentsEntry(ctx, gitRepo, refCommit, &opts.TreePath)
if repo.IsEmpty && opts.TreePath == "" {
return api.ContentsExtResponse{DirContents: make([]*api.ContentsResponse, 0)}, nil
}
if err != nil {
return ret, err
}
// get file contents
if entry.Type() != "tree" {
ret.FileContents, err = getFileContentsByEntryInternal(ctx, repo, gitRepo, refCommit, entry, opts)
return ret, err
}
// list directory contents
gitTree, err := refCommit.Commit.SubTree(ctx, gitRepo, opts.TreePath)
if err != nil {
return ret, err
}
entries, err := gitTree.ListEntries(ctx, gitRepo)
if err != nil {
return ret, err
}
ret.DirContents = make([]*api.ContentsResponse, 0, len(entries))
for _, e := range entries {
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 ret, err
}
ret.DirContents = append(ret.DirContents, fileContentResponse)
}
return ret, nil
}
// GetObjectTypeFromTreeEntry check what content is behind it
func GetObjectTypeFromTreeEntry(entry *git.TreeEntry) ContentType {
switch {
case entry.IsDir():
return ContentTypeDir
case entry.IsSubModule():
return ContentTypeSubmodule
case entry.IsExecutable(), entry.IsRegular():
return ContentTypeRegular
case entry.IsLink():
return ContentTypeLink
default:
return ""
}
}
func prepareGetContentsEntry(ctx context.Context, gitRepo *git.Repository, 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}
}
*treePath = cleanTreePath
// 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)
}
return refCommit.Commit.GetTreeEntryByPath(ctx, gitRepo, *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(ctx, gitRepo, refCommit, &opts.TreePath)
if err != nil {
return nil, err
}
return getFileContentsByEntryInternal(ctx, repo, gitRepo, refCommit, entry, opts)
}
func addLastCommitCache(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, cacheKey, fullName, sha string) error {
if gitRepo.LastCommitCache == nil {
commitsCount, err := cache.GetInt64(cacheKey, func() (int64, error) {
return git.CommitsCountOfCommit(ctx, repo, sha)
})
if err != nil {
return err
}
gitRepo.LastCommitCache = git.NewLastCommitCache(commitsCount, fullName, gitRepo, cache.GetCache())
}
return nil
}
func getFileContentsByEntryInternal(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, refCommit *utils.RefCommit, entry *git.TreeEntry, opts GetContentsOrListOptions) (*api.ContentsResponse, error) {
refType := refCommit.RefName.RefType()
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
}
selfURLString := selfURL.String()
// All content types have these fields in populated
contentsResponse := &api.ContentsResponse{
Name: entry.Name(),
Path: opts.TreePath,
SHA: entry.ID.String(),
Size: entry.GetSize(ctx, gitRepo),
URL: &selfURLString,
Links: &api.FileLinksResponse{
Self: &selfURLString,
},
}
if opts.IncludeCommitMetadata || opts.IncludeCommitMessage {
err = addLastCommitCache(ctx, repo, gitRepo, repo.GetCommitsCountCacheKey(refCommit.InputRef, refType != git.RefTypeCommit), repo.FullName(), refCommit.CommitID)
if err != nil {
return nil, err
}
lastCommit, err := refCommit.Commit.GetCommitByPath(ctx, gitRepo, opts.TreePath)
if err != nil {
return nil, err
}
if opts.IncludeCommitMetadata {
contentsResponse.LastCommitSHA = new(lastCommit.ID.String())
// GitHub doesn't have these fields in the response, but we could follow other similar APIs to name them
// https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#list-commits
if lastCommit.Committer != nil {
contentsResponse.LastCommitterDate = new(lastCommit.Committer.When)
}
if lastCommit.Author != nil {
contentsResponse.LastAuthorDate = new(lastCommit.Author.When)
}
}
if opts.IncludeCommitMessage {
contentsResponse.LastCommitMessage = new(lastCommit.MessageUTF8())
}
}
// Now populate the rest of the ContentsResponse based on the entry type
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 opts.IncludeSingleFileContent {
blobResponse, err := GetBlobBySHA(ctx, 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(ctx, gitRepo, entry.ID.String())
if err != nil {
return nil, err
}
}
} else if entry.IsDir() {
contentsResponse.Type = string(ContentTypeDir)
} else if entry.IsLink() {
contentsResponse.Type = string(ContentTypeLink)
// The target of a symlink file is the content of the file
targetFromContent, err := entry.Blob(gitRepo).GetBlobContent(ctx, 1024)
if err != nil {
return nil, err
}
contentsResponse.Target = &targetFromContent
} else if entry.IsSubModule() {
contentsResponse.Type = string(ContentTypeSubmodule)
submodule, err := commit.GetSubModule(ctx, gitRepo, opts.TreePath)
if err != nil {
return nil, err
}
if submodule != nil && submodule.URL != "" {
contentsResponse.SubmoduleGitURL = &submodule.URL
}
}
// Handle links
if entry.IsRegular() || entry.IsLink() || entry.IsExecutable() {
downloadURL, err := url.Parse(repo.HTMLURL() + "/raw/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(opts.TreePath))
if err != nil {
return nil, err
}
downloadURLString := downloadURL.String()
contentsResponse.DownloadURL = &downloadURLString
}
if !entry.IsSubModule() {
htmlURL, err := url.Parse(repo.HTMLURL() + "/src/" + refCommit.RefName.RefWebLinkPath() + "/" + util.PathEscapeSegments(opts.TreePath))
if err != nil {
return nil, err
}
htmlURLString := htmlURL.String()
contentsResponse.HTMLURL = &htmlURLString
contentsResponse.Links.HTMLURL = &htmlURLString
gitURL, err := url.Parse(repo.APIURL() + "/git/blobs/" + url.PathEscape(entry.ID.String()))
if err != nil {
return nil, err
}
gitURLString := gitURL.String()
contentsResponse.GitURL = &gitURLString
contentsResponse.Links.GitURL = &gitURLString
}
return contentsResponse, nil
}
func GetBlobBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, sha string) (*api.GitBlobResponse, error) {
gitBlob, err := gitRepo.GetBlob(sha)
if err != nil {
return nil, err
}
ret := &api.GitBlobResponse{
SHA: gitBlob.ID.String(),
URL: repo.APIURL() + "/git/blobs/" + url.PathEscape(gitBlob.ID.String()),
Size: gitBlob.Size(ctx),
}
blobSize := gitBlob.Size(ctx)
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(ctx, originContent)
if err != nil {
return nil, err
}
ret.Encoding, ret.Content = new("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(ctx context.Context, gitRepo *git.Repository, sha string) (*string, *int64, error) {
gitBlob, err := gitRepo.GetBlob(sha)
if err != nil {
return nil, nil, err
}
if gitBlob.Size(ctx) > lfs.MetaFileMaxSize {
return nil, nil, nil // not a LFS pointer
}
buf, err := gitBlob.GetBlobContent(ctx, lfs.MetaFileMaxSize)
if err != nil {
return nil, nil, err
}
oid, size := parsePossibleLfsPointerBuffer(strings.NewReader(buf))
return oid, size, nil
}