mirror of https://github.com/harness/drone.git
feat: [CODE-2528]: Update raw and get-content APIs to download lfs objects (#3549)
parent
88d1f60157
commit
d3261ebc20
|
@ -23,17 +23,38 @@ import (
|
|||
"github.com/harness/gitness/types/enum"
|
||||
)
|
||||
|
||||
type Content struct {
|
||||
Data io.ReadCloser
|
||||
Size int64
|
||||
}
|
||||
|
||||
func (c *Content) Read(p []byte) (n int, err error) {
|
||||
return c.Data.Read(p)
|
||||
}
|
||||
|
||||
func (c *Content) Close() error {
|
||||
return c.Data.Close()
|
||||
}
|
||||
|
||||
func (c *Controller) Download(ctx context.Context,
|
||||
session *auth.Session,
|
||||
repoRef string,
|
||||
oid string,
|
||||
) (io.ReadCloser, error) {
|
||||
) (*Content, error) {
|
||||
repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoView)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to acquire access to repo: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.lfsStore.Find(ctx, repo.ID, oid)
|
||||
return c.DownloadNoAuth(ctx, repo.ID, oid)
|
||||
}
|
||||
|
||||
func (c *Controller) DownloadNoAuth(
|
||||
ctx context.Context,
|
||||
repoID int64,
|
||||
oid string,
|
||||
) (*Content, error) {
|
||||
obj, err := c.lfsStore.Find(ctx, repoID, oid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find the oid %q for the repo: %w", oid, err)
|
||||
}
|
||||
|
@ -44,5 +65,8 @@ func (c *Controller) Download(ctx context.Context,
|
|||
return nil, fmt.Errorf("failed to download file from blobstore: %w", err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
return &Content{
|
||||
Data: file,
|
||||
Size: obj.Size,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ func (c *Controller) Upload(ctx context.Context,
|
|||
return nil, usererror.BadRequest("no file or content provided")
|
||||
}
|
||||
|
||||
bufReader := bufio.NewReader(file)
|
||||
bufReader := bufio.NewReader(io.LimitReader(file, pointer.Size))
|
||||
objPath := getLFSObjectPath(pointer.OId)
|
||||
|
||||
err = c.blobStore.Upload(ctx, bufReader, objPath)
|
||||
|
|
|
@ -24,6 +24,7 @@ import (
|
|||
"github.com/harness/gitness/app/auth"
|
||||
"github.com/harness/gitness/errors"
|
||||
"github.com/harness/gitness/git"
|
||||
"github.com/harness/gitness/git/parser"
|
||||
"github.com/harness/gitness/types"
|
||||
"github.com/harness/gitness/types/enum"
|
||||
|
||||
|
@ -33,7 +34,7 @@ import (
|
|||
const (
|
||||
// maxGetContentFileSize specifies the maximum number of bytes a file content response contains.
|
||||
// If a file is any larger, the content is truncated.
|
||||
maxGetContentFileSize = 1 << 22 // 4 MB
|
||||
maxGetContentFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
|
@ -64,10 +65,12 @@ type Content interface {
|
|||
}
|
||||
|
||||
type FileContent struct {
|
||||
Encoding enum.ContentEncodingType `json:"encoding"`
|
||||
Data string `json:"data"`
|
||||
Size int64 `json:"size"`
|
||||
DataSize int64 `json:"data_size"`
|
||||
Encoding enum.ContentEncodingType `json:"encoding"`
|
||||
Data string `json:"data"`
|
||||
Size int64 `json:"size"`
|
||||
DataSize int64 `json:"data_size"`
|
||||
LFSObjectID string `json:"lfs_object_id,omitempty"`
|
||||
LFSObjectSize int64 `json:"lfs_object_size,omitempty"`
|
||||
}
|
||||
|
||||
func (c *FileContent) isContent() {}
|
||||
|
@ -200,6 +203,19 @@ func (c *Controller) getFileContent(ctx context.Context,
|
|||
return nil, fmt.Errorf("failed to read blob content: %w", err)
|
||||
}
|
||||
|
||||
// check if blob is an LFS pointer
|
||||
lfsInfo, ok := parser.IsLFSPointer(ctx, content, output.Size)
|
||||
if ok {
|
||||
return &FileContent{
|
||||
Size: output.Size,
|
||||
DataSize: output.ContentSize,
|
||||
Encoding: enum.ContentEncodingTypeBase64,
|
||||
Data: base64.StdEncoding.EncodeToString(content),
|
||||
LFSObjectID: lfsInfo.OID,
|
||||
LFSObjectSize: lfsInfo.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &FileContent{
|
||||
Size: output.Size,
|
||||
DataSize: output.ContentSize,
|
||||
|
|
|
@ -22,6 +22,7 @@ import (
|
|||
"strings"
|
||||
|
||||
apiauth "github.com/harness/gitness/app/api/auth"
|
||||
"github.com/harness/gitness/app/api/controller/lfs"
|
||||
"github.com/harness/gitness/app/api/controller/limiter"
|
||||
"github.com/harness/gitness/app/api/usererror"
|
||||
"github.com/harness/gitness/app/auth"
|
||||
|
@ -110,6 +111,7 @@ type Controller struct {
|
|||
instrumentation instrument.Service
|
||||
rulesSvc *rules.Service
|
||||
sseStreamer sse.Streamer
|
||||
lfsCtrl *lfs.Controller
|
||||
}
|
||||
|
||||
func NewController(
|
||||
|
@ -148,6 +150,7 @@ func NewController(
|
|||
userGroupService usergroup.SearchService,
|
||||
rulesSvc *rules.Service,
|
||||
sseStreamer sse.Streamer,
|
||||
lfsCtrl *lfs.Controller,
|
||||
) *Controller {
|
||||
return &Controller{
|
||||
defaultBranch: config.Git.DefaultBranch,
|
||||
|
@ -185,6 +188,7 @@ func NewController(
|
|||
userGroupService: userGroupService,
|
||||
rulesSvc: rulesSvc,
|
||||
sseStreamer: sseStreamer,
|
||||
lfsCtrl: lfsCtrl,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,10 +22,17 @@ import (
|
|||
"github.com/harness/gitness/app/api/usererror"
|
||||
"github.com/harness/gitness/app/auth"
|
||||
"github.com/harness/gitness/git"
|
||||
"github.com/harness/gitness/git/parser"
|
||||
"github.com/harness/gitness/git/sha"
|
||||
"github.com/harness/gitness/types/enum"
|
||||
)
|
||||
|
||||
type RawContent struct {
|
||||
Data io.ReadCloser
|
||||
Size int64
|
||||
SHA sha.SHA
|
||||
}
|
||||
|
||||
// Raw finds the file of the repo at the given path and returns its raw content.
|
||||
// If no gitRef is provided, the content is retrieved from the default branch.
|
||||
func (c *Controller) Raw(ctx context.Context,
|
||||
|
@ -33,10 +40,10 @@ func (c *Controller) Raw(ctx context.Context,
|
|||
repoRef string,
|
||||
gitRef string,
|
||||
path string,
|
||||
) (io.ReadCloser, int64, sha.SHA, error) {
|
||||
) (*RawContent, error) {
|
||||
repo, err := c.getRepoCheckAccess(ctx, session, repoRef, enum.PermissionRepoView)
|
||||
if err != nil {
|
||||
return nil, 0, sha.Nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set gitRef to default branch in case an empty reference was provided
|
||||
|
@ -53,12 +60,12 @@ func (c *Controller) Raw(ctx context.Context,
|
|||
IncludeLatestCommit: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, sha.Nil, fmt.Errorf("failed to read tree node: %w", err)
|
||||
return nil, fmt.Errorf("failed to read tree node: %w", err)
|
||||
}
|
||||
|
||||
// viewing Raw content is only supported for blob content
|
||||
if treeNodeOutput.Node.Type != git.TreeNodeTypeBlob {
|
||||
return nil, 0, sha.Nil, usererror.BadRequestf(
|
||||
return nil, usererror.BadRequestf(
|
||||
"Object in '%s' at '/%s' is of type '%s'. Only objects of type %s support raw viewing.",
|
||||
gitRef, path, treeNodeOutput.Node.Type, git.TreeNodeTypeBlob)
|
||||
}
|
||||
|
@ -69,8 +76,32 @@ func (c *Controller) Raw(ctx context.Context,
|
|||
SizeLimit: 0, // no size limit, we stream whatever data there is
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, sha.Nil, fmt.Errorf("failed to read blob: %w", err)
|
||||
return nil, fmt.Errorf("failed to read blob: %w", err)
|
||||
}
|
||||
|
||||
return blobReader.Content, blobReader.ContentSize, blobReader.SHA, nil
|
||||
// check if blob is LFS
|
||||
content, err := io.ReadAll(io.LimitReader(blobReader.Content, parser.LfsPointerMaxSize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read LFS file content: %w", err)
|
||||
}
|
||||
|
||||
lfsInfo, ok := parser.IsLFSPointer(ctx, content, blobReader.Size)
|
||||
if !ok {
|
||||
return &RawContent{
|
||||
Data: blobReader.Content,
|
||||
Size: blobReader.ContentSize,
|
||||
SHA: blobReader.SHA,
|
||||
}, nil
|
||||
}
|
||||
|
||||
file, err := c.lfsCtrl.DownloadNoAuth(ctx, repo.ID, lfsInfo.OID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download LFS file: %w", err)
|
||||
}
|
||||
|
||||
return &RawContent{
|
||||
Data: file,
|
||||
Size: lfsInfo.Size,
|
||||
SHA: blobReader.SHA,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"github.com/harness/gitness/app/api/controller/lfs"
|
||||
"github.com/harness/gitness/app/api/controller/limiter"
|
||||
"github.com/harness/gitness/app/auth/authz"
|
||||
repoevents "github.com/harness/gitness/app/events/repo"
|
||||
|
@ -84,6 +85,7 @@ func ProvideController(
|
|||
userGroupService usergroup.SearchService,
|
||||
rulesSvc *rules.Service,
|
||||
sseStreamer sse.Streamer,
|
||||
lfsCtrl *lfs.Controller,
|
||||
) *Controller {
|
||||
return NewController(config, tx, urlProvider,
|
||||
authorizer,
|
||||
|
@ -92,7 +94,7 @@ func ProvideController(
|
|||
principalInfoCache, protectionManager, rpcClient, spaceFinder, repoFinder, importer,
|
||||
codeOwners, repoReporter, indexer, limiter, locker, auditService, mtxManager, identifierCheck,
|
||||
repoChecks, publicAccess, labelSvc, instrumentation, userGroupStore, userGroupService,
|
||||
rulesSvc, sseStreamer,
|
||||
rulesSvc, sseStreamer, lfsCtrl,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ package lfs
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
apiauth "github.com/harness/gitness/app/api/auth"
|
||||
|
@ -24,6 +25,8 @@ import (
|
|||
"github.com/harness/gitness/app/api/request"
|
||||
"github.com/harness/gitness/app/auth"
|
||||
"github.com/harness/gitness/app/url"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func HandleLFSDownload(controller *lfs.Controller, urlProvider url.Provider) http.HandlerFunc {
|
||||
|
@ -42,7 +45,7 @@ func HandleLFSDownload(controller *lfs.Controller, urlProvider url.Provider) htt
|
|||
return
|
||||
}
|
||||
|
||||
file, err := controller.Download(ctx, session, repoRef, oid)
|
||||
resp, err := controller.Download(ctx, session, repoRef, oid)
|
||||
if errors.Is(err, apiauth.ErrNotAuthorized) && auth.IsAnonymousSession(session) {
|
||||
render.GitBasicAuth(ctx, w, urlProvider)
|
||||
return
|
||||
|
@ -51,8 +54,14 @@ func HandleLFSDownload(controller *lfs.Controller, urlProvider url.Provider) htt
|
|||
render.TranslatedUserError(ctx, w, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if err := resp.Data.Close(); err != nil {
|
||||
log.Ctx(ctx).Warn().Err(err).Msgf("failed to close LFS file reader for %q.", oid)
|
||||
}
|
||||
}()
|
||||
|
||||
w.Header().Add("Content-Length", fmt.Sprint(resp.Size))
|
||||
// apply max byte size
|
||||
render.Reader(ctx, w, http.StatusOK, file)
|
||||
render.Reader(ctx, w, http.StatusOK, resp.Data)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,26 +41,26 @@ func HandleRaw(repoCtrl *repo.Controller) http.HandlerFunc {
|
|||
gitRef := request.GetGitRefFromQueryOrDefault(r, "")
|
||||
path := request.GetOptionalRemainderFromPath(r)
|
||||
|
||||
dataReader, dataLength, sha, err := repoCtrl.Raw(ctx, session, repoRef, gitRef, path)
|
||||
resp, err := repoCtrl.Raw(ctx, session, repoRef, gitRef, path)
|
||||
if err != nil {
|
||||
render.TranslatedUserError(ctx, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := dataReader.Close(); err != nil {
|
||||
if err := resp.Data.Close(); err != nil {
|
||||
log.Ctx(ctx).Warn().Err(err).Msgf("failed to close blob content reader.")
|
||||
}
|
||||
}()
|
||||
|
||||
ifNoneMatch, ok := request.GetIfNoneMatchFromHeader(r)
|
||||
if ok && ifNoneMatch == sha.String() {
|
||||
if ok && ifNoneMatch == resp.SHA.String() {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Length", fmt.Sprint(dataLength))
|
||||
w.Header().Add(request.HeaderETag, sha.String())
|
||||
render.Reader(ctx, w, http.StatusOK, dataReader)
|
||||
w.Header().Add("Content-Length", fmt.Sprint(resp.Size))
|
||||
w.Header().Add(request.HeaderETag, resp.SHA.String())
|
||||
render.Reader(ctx, w, http.StatusOK, resp.Data)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,6 +63,12 @@ func processGitRequest(r *http.Request) (bool, error) {
|
|||
const receivePackPath = "/" + receivePack
|
||||
const serviceParam = "service"
|
||||
|
||||
const lfsTransferPath = "/info/lfs/objects"
|
||||
const lfsTransferBatchPath = lfsTransferPath + "/batch"
|
||||
|
||||
const oidParam = "oid"
|
||||
const sizeParam = "size"
|
||||
|
||||
allowedServices := []string{
|
||||
uploadPack,
|
||||
receivePack,
|
||||
|
@ -84,6 +90,11 @@ func processGitRequest(r *http.Request) (bool, error) {
|
|||
}
|
||||
return pathTerminatedWithMarkerAndURL(r, "", infoRefsPath, infoRefsPath, urlPath)
|
||||
}
|
||||
// check if request is coming from git lfs client
|
||||
if strings.HasSuffix(urlPath, lfsTransferPath) && r.URL.Query().Has(oidParam) {
|
||||
return pathTerminatedWithMarkerAndURL(r, "", lfsTransferPath, lfsTransferPath, urlPath)
|
||||
}
|
||||
|
||||
case http.MethodPost:
|
||||
if strings.HasSuffix(urlPath, uploadPackPath) {
|
||||
return pathTerminatedWithMarkerAndURL(r, "", uploadPackPath, uploadPackPath, urlPath)
|
||||
|
@ -92,6 +103,16 @@ func processGitRequest(r *http.Request) (bool, error) {
|
|||
if strings.HasSuffix(urlPath, receivePackPath) {
|
||||
return pathTerminatedWithMarkerAndURL(r, "", receivePackPath, receivePackPath, urlPath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(urlPath, lfsTransferBatchPath) {
|
||||
return pathTerminatedWithMarkerAndURL(r, "", lfsTransferBatchPath, lfsTransferBatchPath, urlPath)
|
||||
}
|
||||
|
||||
case http.MethodPut:
|
||||
if strings.HasSuffix(urlPath, lfsTransferPath) &&
|
||||
r.URL.Query().Has(oidParam) && r.URL.Query().Has(sizeParam) {
|
||||
return pathTerminatedWithMarkerAndURL(r, "", lfsTransferPath, lfsTransferPath, urlPath)
|
||||
}
|
||||
}
|
||||
|
||||
// no other APIs are called by git - just treat it as a full repo path.
|
||||
|
|
|
@ -122,7 +122,7 @@ func GitLFSHandler(r chi.Router, lfsCtrl *lfs.Controller, urlProvider url.Provid
|
|||
r.Route("/info/lfs", func(r chi.Router) {
|
||||
r.Route("/objects", func(r chi.Router) {
|
||||
r.Post("/batch", handlerlfs.HandleLFSTransfer(lfsCtrl, urlProvider))
|
||||
// direct download and upload handlers for lfs objects
|
||||
// direct upload and download handlers for lfs objects
|
||||
r.Put("/", handlerlfs.HandleLFSUpload(lfsCtrl, urlProvider))
|
||||
r.Get("/", handlerlfs.HandleLFSDownload(lfsCtrl, urlProvider))
|
||||
})
|
||||
|
|
|
@ -282,7 +282,18 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
|
|||
return nil, err
|
||||
}
|
||||
rulesService := rules.ProvideService(transactor, ruleStore, repoStore, spaceStore, protectionManager, auditService, instrumentService, principalInfoCache, userGroupStore, searchService, reporter2, streamer)
|
||||
repoController := repo.ProvideController(config, transactor, provider, authorizer, repoStore, spaceStore, pipelineStore, principalStore, executionStore, ruleStore, checkStore, pullReqStore, settingsService, principalInfoCache, protectionManager, gitInterface, spaceFinder, repoFinder, repository, codeownersService, eventsReporter, indexer, resourceLimiter, lockerLocker, auditService, mutexManager, repoIdentifier, repoCheck, publicaccessService, labelService, instrumentService, userGroupStore, searchService, rulesService, streamer)
|
||||
lfsObjectStore := database.ProvideLFSObjectStore(db)
|
||||
blobConfig, err := server.ProvideBlobStoreConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blobStore, err := blob.ProvideStore(ctx, blobConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remoteauthService := remoteauth.ProvideRemoteAuth(tokenStore, principalStore)
|
||||
lfsController := lfs.ProvideController(authorizer, repoFinder, principalStore, lfsObjectStore, blobStore, remoteauthService, provider)
|
||||
repoController := repo.ProvideController(config, transactor, provider, authorizer, repoStore, spaceStore, pipelineStore, principalStore, executionStore, ruleStore, checkStore, pullReqStore, settingsService, principalInfoCache, protectionManager, gitInterface, spaceFinder, repoFinder, repository, codeownersService, eventsReporter, indexer, resourceLimiter, lockerLocker, auditService, mutexManager, repoIdentifier, repoCheck, publicaccessService, labelService, instrumentService, userGroupStore, searchService, rulesService, streamer, lfsController)
|
||||
reposettingsController := reposettings.ProvideController(authorizer, repoFinder, settingsService, auditService)
|
||||
stageStore := database.ProvideStageStore(db)
|
||||
schedulerScheduler, err := scheduler.ProvideScheduler(stageStore, mutexManager)
|
||||
|
@ -438,7 +449,6 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lfsObjectStore := database.ProvideLFSObjectStore(db)
|
||||
githookController := githook.ProvideController(authorizer, principalStore, repoStore, repoFinder, reporter9, eventsReporter, gitInterface, pullReqStore, provider, protectionManager, clientFactory, resourceLimiter, settingsService, preReceiveExtender, updateExtender, postReceiveExtender, streamer, lfsObjectStore)
|
||||
serviceaccountController := serviceaccount.NewController(principalUID, authorizer, principalStore, spaceStore, repoStore, tokenStore)
|
||||
principalController := principal.ProvideController(principalStore, authorizer)
|
||||
|
@ -446,14 +456,6 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
|
|||
v2 := check2.ProvideCheckSanitizers()
|
||||
checkController := check2.ProvideController(transactor, authorizer, spaceStore, checkStore, spaceFinder, repoFinder, gitInterface, v2, streamer)
|
||||
systemController := system.NewController(principalStore, config)
|
||||
blobConfig, err := server.ProvideBlobStoreConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blobStore, err := blob.ProvideStore(ctx, blobConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uploadController := upload.ProvideController(authorizer, repoFinder, blobStore)
|
||||
searcher := keywordsearch.ProvideSearcher(localIndexSearcher)
|
||||
keywordsearchController := keywordsearch2.ProvideController(authorizer, searcher, repoController, spaceController)
|
||||
|
@ -541,8 +543,6 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
|
|||
handler4 := router.PackageHandlerProvider(packagesHandler, mavenHandler, genericHandler, pythonHandler, nugetHandler)
|
||||
appRouter := router.AppRouterProvider(registryOCIHandler, apiHandler, handler2, handler3, handler4)
|
||||
sender := usage.ProvideMediator(ctx, config, spaceFinder, usageMetricStore)
|
||||
remoteauthService := remoteauth.ProvideRemoteAuth(tokenStore, principalStore)
|
||||
lfsController := lfs.ProvideController(authorizer, repoFinder, principalStore, lfsObjectStore, blobStore, remoteauthService, provider)
|
||||
routerRouter := router2.ProvideRouter(ctx, config, authenticator, repoController, reposettingsController, executionController, logsController, spaceController, pipelineController, secretController, triggerController, connectorController, templateController, pluginController, pullreqController, webhookController, githookController, gitInterface, serviceaccountController, controller, principalController, usergroupController, checkController, systemController, uploadController, keywordsearchController, infraproviderController, gitspaceController, migrateController, provider, openapiService, appRouter, sender, lfsController)
|
||||
serverServer := server2.ProvideServer(config, routerRouter)
|
||||
publickeyService := publickey.ProvidePublicKey(publicKeyStore, principalInfoCache)
|
||||
|
|
|
@ -25,11 +25,6 @@ import (
|
|||
"github.com/harness/gitness/git/sha"
|
||||
)
|
||||
|
||||
// lfsPointerMaxSize is the maximum size for an LFS pointer file.
|
||||
// This is used to identify blobs that are too large to be valid LFS pointers.
|
||||
// lfs-pointer specification ref: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md#the-pointer
|
||||
const lfsPointerMaxSize = 200
|
||||
|
||||
type GetBlobParams struct {
|
||||
ReadParams
|
||||
SHA string
|
||||
|
@ -94,7 +89,7 @@ func (s *Service) FindLFSPointers(
|
|||
|
||||
var candidateObjects []parser.BatchCheckObject
|
||||
for _, obj := range objects {
|
||||
if obj.Type == string(TreeNodeTypeBlob) && obj.Size <= lfsPointerMaxSize {
|
||||
if obj.Type == string(TreeNodeTypeBlob) && obj.Size <= parser.LfsPointerMaxSize {
|
||||
candidateObjects = append(candidateObjects, obj)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,15 +16,29 @@ package parser
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// LfsPointerMaxSize is the maximum size for an LFS pointer file.
|
||||
// This is used to identify blobs that are too large to be valid LFS pointers.
|
||||
// lfs-pointer specification ref: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md#the-pointer
|
||||
const LfsPointerMaxSize = 200
|
||||
|
||||
const lfsPointerVersionPrefix = "version https://git-lfs.github.com/spec"
|
||||
|
||||
type LFSPointer struct {
|
||||
OID string
|
||||
Size int64
|
||||
}
|
||||
|
||||
var (
|
||||
regexLFSOID = regexp.MustCompile(`(?m)^oid sha256:([a-f0-9]{64})$`)
|
||||
regexLFSSize = regexp.MustCompile(`(?m)^size [0-9]+$`)
|
||||
regexLFSSize = regexp.MustCompile(`(?m)^size (\d+)+$`)
|
||||
|
||||
ErrInvalidLFSPointer = errors.New("invalid lfs pointer")
|
||||
)
|
||||
|
@ -45,3 +59,35 @@ func GetLFSObjectID(content []byte) (string, error) {
|
|||
|
||||
return string(oidMatch[1]), nil
|
||||
}
|
||||
|
||||
func IsLFSPointer(
|
||||
ctx context.Context,
|
||||
content []byte,
|
||||
size int64,
|
||||
) (*LFSPointer, bool) {
|
||||
if size > LfsPointerMaxSize {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix(content, []byte(lfsPointerVersionPrefix)) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
oidMatch := regexLFSOID.FindSubmatch(content)
|
||||
if oidMatch == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
sizeMatch := regexLFSSize.FindSubmatch(content)
|
||||
if sizeMatch == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
contentSize, err := strconv.ParseInt(string(sizeMatch[1]), 10, 64)
|
||||
if err != nil {
|
||||
log.Ctx(ctx).Warn().Err(err).Msgf("failed to parse lfs pointer size for object ID %s", oidMatch[1])
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &LFSPointer{OID: string(oidMatch[1]), Size: contentSize}, true
|
||||
}
|
||||
|
|
|
@ -248,7 +248,7 @@ func (s *Service) findLFSPointers(
|
|||
) (*FindLFSPointersOutput, error) {
|
||||
var candidateObjects []parser.BatchCheckObject
|
||||
for _, obj := range objects {
|
||||
if obj.Type == string(TreeNodeTypeBlob) && obj.Size <= lfsPointerMaxSize {
|
||||
if obj.Type == string(TreeNodeTypeBlob) && obj.Size <= parser.LfsPointerMaxSize {
|
||||
candidateObjects = append(candidateObjects, obj)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -610,6 +610,7 @@ export interface StringsMap {
|
|||
language: string
|
||||
lastTriggeredAt: string
|
||||
leaveAComment: string
|
||||
lfsInfo: string
|
||||
license: string
|
||||
lineBreaks: string
|
||||
loading: string
|
||||
|
|
|
@ -602,6 +602,7 @@ tagEmpty: There are no tags in your repo. Click the button below to create a tag
|
|||
newTag: New Tag
|
||||
overview: Overview
|
||||
fileTooLarge: File is too large to open. {download}
|
||||
lfsInfo: Stored with Git LFS
|
||||
clickHereToDownload: Click here to download.
|
||||
viewFileHistory: View the file at this point in the history
|
||||
viewRepo: View the repository at this point in the history
|
||||
|
|
|
@ -25,7 +25,8 @@ import {
|
|||
Layout,
|
||||
StringSubstitute,
|
||||
Tabs,
|
||||
Utils
|
||||
Utils,
|
||||
Text
|
||||
} from '@harnessio/uicore'
|
||||
import { Icon } from '@harnessio/icons'
|
||||
import { Color } from '@harnessio/design-system'
|
||||
|
@ -36,6 +37,7 @@ import type { EditorDidMount } from 'react-monaco-editor'
|
|||
import type { editor } from 'monaco-editor'
|
||||
import { SourceCodeViewer } from 'components/SourceCodeViewer/SourceCodeViewer'
|
||||
import type { OpenapiContentInfo, RepoFileContent, TypesCommit } from 'services/code'
|
||||
|
||||
import {
|
||||
normalizeGitRef,
|
||||
decodeGitContent,
|
||||
|
@ -84,7 +86,7 @@ export function FileContent({
|
|||
const { routes } = useAppContext()
|
||||
const { getString } = useStrings()
|
||||
const downloadFile = useDownloadRawFile()
|
||||
const { category, isText, isFileTooLarge, isViewable, filename, extension, size, base64Data, rawURL } =
|
||||
const { category, isFileTooLarge, isText, isFileLFS, isViewable, filename, extension, size, base64Data, rawURL } =
|
||||
useFileContentViewerDecision({ repoMetadata, gitRef, resourcePath, resourceContent })
|
||||
const history = useHistory()
|
||||
const [activeTab, setActiveTab] = React.useState<string>(FileSection.CONTENT)
|
||||
|
@ -171,7 +173,10 @@ export function FileContent({
|
|||
},
|
||||
lazy: !repoMetadata
|
||||
})
|
||||
const editButtonDisabled = useMemo(() => permsFinal.disabled || !isText, [permsFinal.disabled, isText])
|
||||
const editButtonDisabled = useMemo(
|
||||
() => permsFinal.disabled || (!isText && !isFileLFS),
|
||||
[permsFinal.disabled, isText, isFileLFS]
|
||||
)
|
||||
const editAsText = useMemo(
|
||||
() => editButtonDisabled && !isFileTooLarge && category === FileCategory.OTHER,
|
||||
[editButtonDisabled, isFileTooLarge, category]
|
||||
|
@ -205,6 +210,10 @@ export function FileContent({
|
|||
}
|
||||
}
|
||||
|
||||
const fullRawURL = standalone
|
||||
? `${window.location.origin}${rawURL.replace(/^\/code/, '')}`
|
||||
: `${window.location.origin}${getConfig(rawURL)}`.replace('//', '/')
|
||||
|
||||
return (
|
||||
<Container className={css.tabsContainer} ref={ref}>
|
||||
<Tabs
|
||||
|
@ -229,8 +238,18 @@ export function FileContent({
|
|||
/>
|
||||
<Container className={css.container} background={Color.WHITE}>
|
||||
<Layout.Horizontal padding="small" className={css.heading}>
|
||||
<Heading level={5} color={Color.BLACK}>
|
||||
{resourceContent.name}
|
||||
<Heading level={5}>
|
||||
<Layout.Horizontal spacing="small" flex={{ alignItems: 'center' }}>
|
||||
<span style={{ color: Color.BLACK }}>{resourceContent.name}</span>
|
||||
{isFileLFS && (
|
||||
<Layout.Horizontal spacing="xsmall" flex={{ alignItems: 'center' }}>
|
||||
<Icon name="info" size={12} color={Color.GREY_500} padding={{ left: 'small' }} />
|
||||
<Text font={{ size: 'small' }} color={Color.GREY_500}>
|
||||
{getString('lfsInfo')}
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
)}
|
||||
</Layout.Horizontal>
|
||||
</Heading>
|
||||
<FlexExpander />
|
||||
<Layout.Horizontal spacing="xsmall" style={{ alignItems: 'center' }}>
|
||||
|
@ -408,21 +427,27 @@ export function FileContent({
|
|||
<Match expr={category}>
|
||||
<Case val={FileCategory.SVG}>
|
||||
<img
|
||||
src={`data:image/svg+xml;base64,${base64Data}`}
|
||||
src={
|
||||
isFileLFS ? `${fullRawURL}` : `data:image/svg+xml;base64,${base64Data}`
|
||||
}
|
||||
alt={filename}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%' }}
|
||||
/>
|
||||
</Case>
|
||||
<Case val={FileCategory.IMAGE}>
|
||||
<img
|
||||
src={`data:image/${extension};base64,${base64Data}`}
|
||||
src={
|
||||
isFileLFS
|
||||
? `${fullRawURL}`
|
||||
: `data:image/${extension};base64,${base64Data}`
|
||||
}
|
||||
alt={filename}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%' }}
|
||||
/>
|
||||
</Case>
|
||||
<Case val={FileCategory.PDF}>
|
||||
<Document
|
||||
file={`data:application/pdf;base64,${base64Data}`}
|
||||
file={isFileLFS ? fullRawURL : `data:application/pdf;base64,${base64Data}`}
|
||||
options={{
|
||||
// TODO: Configure this to use a local worker/webpack loader
|
||||
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
|
||||
|
@ -443,19 +468,27 @@ export function FileContent({
|
|||
</Case>
|
||||
<Case val={FileCategory.AUDIO}>
|
||||
<audio controls>
|
||||
<source src={`data:audio/${extension};base64,${base64Data}`} />
|
||||
<source
|
||||
src={
|
||||
isFileLFS ? fullRawURL : `data:audio/${extension};base64,${base64Data}`
|
||||
}
|
||||
/>
|
||||
</audio>
|
||||
</Case>
|
||||
<Case val={FileCategory.VIDEO}>
|
||||
<video controls height={500}>
|
||||
<source src={`data:video/${extension};base64,${base64Data}`} />
|
||||
<source
|
||||
src={
|
||||
isFileLFS ? fullRawURL : `data:video/${extension};base64,${base64Data}`
|
||||
}
|
||||
/>
|
||||
</video>
|
||||
</Case>
|
||||
<Case val={FileCategory.TEXT}>
|
||||
<SourceCodeViewer
|
||||
editorDidMount={onEditorMount}
|
||||
language={filenameToLanguage(filename)}
|
||||
source={decodeGitContent(base64Data)}
|
||||
source={isFileLFS ? fullRawURL : decodeGitContent(base64Data)}
|
||||
/>
|
||||
</Case>
|
||||
<Case val={FileCategory.SUBMODULE}>
|
||||
|
|
|
@ -533,6 +533,12 @@ export interface OpenapiGetContentOutput {
|
|||
type?: OpenapiContentType
|
||||
}
|
||||
|
||||
export interface OpenapiRawOutput {
|
||||
data?: ArrayBuffer
|
||||
size?: number
|
||||
sha?: string
|
||||
}
|
||||
|
||||
export interface OpenapiLoginRequest {
|
||||
login_identifier?: string
|
||||
password?: string
|
||||
|
@ -858,6 +864,8 @@ export interface RepoFileContent {
|
|||
data_size?: number
|
||||
encoding?: EnumContentEncodingType
|
||||
size?: number
|
||||
lfs_object_id?: string
|
||||
lfs_object_size?: number
|
||||
}
|
||||
|
||||
export interface RepoListPathsOutput {
|
||||
|
@ -6448,22 +6456,28 @@ export interface GetRawPathParams {
|
|||
path: string
|
||||
}
|
||||
|
||||
export type GetRawProps = Omit<GetProps<void, UsererrorError, GetRawQueryParams, GetRawPathParams>, 'path'> &
|
||||
export type GetRawProps = Omit<
|
||||
GetProps<OpenapiRawOutput, UsererrorError, GetRawQueryParams, GetRawPathParams>,
|
||||
'path'
|
||||
> &
|
||||
GetRawPathParams
|
||||
|
||||
export const GetRaw = ({ repo_ref, path, ...props }: GetRawProps) => (
|
||||
<Get<void, UsererrorError, GetRawQueryParams, GetRawPathParams>
|
||||
<Get<OpenapiRawOutput, UsererrorError, GetRawQueryParams, GetRawPathParams>
|
||||
path={`/repos/${repo_ref}/raw/${path}`}
|
||||
base={getConfig('code/api/v1')}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
export type UseGetRawProps = Omit<UseGetProps<void, UsererrorError, GetRawQueryParams, GetRawPathParams>, 'path'> &
|
||||
export type UseGetRawProps = Omit<
|
||||
UseGetProps<OpenapiRawOutput, UsererrorError, GetRawQueryParams, GetRawPathParams>,
|
||||
'path'
|
||||
> &
|
||||
GetRawPathParams
|
||||
|
||||
export const useGetRaw = ({ repo_ref, path, ...props }: UseGetRawProps) =>
|
||||
useGet<void, UsererrorError, GetRawQueryParams, GetRawPathParams>(
|
||||
useGet<OpenapiRawOutput, UsererrorError, GetRawQueryParams, GetRawPathParams>(
|
||||
(paramsInPath: GetRawPathParams) => `/repos/${paramsInPath.repo_ref}/raw/${paramsInPath.path}`,
|
||||
{ base: getConfig('code/api/v1'), pathParams: { repo_ref, path }, ...props }
|
||||
)
|
||||
|
|
|
@ -6581,6 +6581,10 @@ paths:
|
|||
type: string
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OpenapiRawOutput'
|
||||
description: OK
|
||||
'401':
|
||||
content:
|
||||
|
@ -13701,6 +13705,19 @@ components:
|
|||
$ref: '#/components/schemas/EnumContentEncodingType'
|
||||
size:
|
||||
type: integer
|
||||
lfs_object_id:
|
||||
type: string
|
||||
lfs_object_size:
|
||||
type: integer
|
||||
type: object
|
||||
OpenapiRawOutput:
|
||||
properties:
|
||||
data:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
sha:
|
||||
type: string
|
||||
type: object
|
||||
RepoListPathsOutput:
|
||||
properties:
|
||||
|
|
|
@ -29,6 +29,7 @@ type UseFileViewerDecisionProps = Pick<GitInfoProps, 'repoMetadata' | 'gitRef' |
|
|||
interface UseFileViewerDecisionResult {
|
||||
category: FileCategory
|
||||
isFileTooLarge: boolean
|
||||
isFileLFS: boolean
|
||||
isViewable: string | boolean
|
||||
filename: string
|
||||
extension: string
|
||||
|
@ -95,19 +96,28 @@ export function useFileContentViewerDecision({
|
|||
: FileCategory.OTHER
|
||||
const isViewable = isPdf || isSVG || isImage || isAudio || isVideo || isText || isSubmodule || isSymlink
|
||||
const resourceData = resourceContent?.content as RepoContentExtended
|
||||
const isFileLFS = resourceData?.lfs_object_id ? true : false
|
||||
|
||||
const isFileTooLarge =
|
||||
resourceData?.size && resourceData?.data_size ? resourceData?.size !== resourceData?.data_size : false
|
||||
(isFileLFS
|
||||
? resourceData?.data_size &&
|
||||
resourceData?.lfs_object_size &&
|
||||
resourceData?.lfs_object_size > MAX_VIEWABLE_FILE_SIZE
|
||||
: resourceData?.data_size && resourceData?.size && resourceData?.data_size !== resourceData?.size) || false
|
||||
|
||||
const rawURL = `/code/api/v1/repos/${repoMetadata?.path}/+/raw/${resourcePath}?routingId=${routingId}&git_ref=${gitRef}`
|
||||
|
||||
return {
|
||||
category,
|
||||
|
||||
isFileTooLarge,
|
||||
isViewable,
|
||||
isText,
|
||||
isFileLFS,
|
||||
isViewable,
|
||||
|
||||
filename,
|
||||
extension,
|
||||
size: resourceData?.size || 0,
|
||||
size: isFileLFS ? resourceData?.lfs_object_size || 0 : resourceData?.size || 0,
|
||||
|
||||
// base64 data returned from content API. This snapshot can be truncated by backend
|
||||
base64Data: resourceData?.data || resourceData?.target || resourceData?.url || '',
|
||||
|
@ -119,7 +129,7 @@ export function useFileContentViewerDecision({
|
|||
return metadata
|
||||
}
|
||||
|
||||
export const MAX_VIEWABLE_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
|
||||
export const MAX_VIEWABLE_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
export enum FileCategory {
|
||||
MARKDOWN = 'MARKDOWN',
|
||||
|
|
Loading…
Reference in New Issue