Add github compatible tarball download API endpoints (#32572)

Fix #29654 
Fix #32481
This commit is contained in:
Lunny Xiao 2024-11-25 11:35:49 -08:00 committed by GitHub
parent 44909f6e2c
commit 703be6bf30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 152 additions and 31 deletions

View File

@ -1377,6 +1377,8 @@ func Routes() *web.Router {
m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar)
m.Delete("", repo.DeleteAvatar) m.Delete("", repo.DeleteAvatar)
}, reqAdmin(), reqToken()) }, reqAdmin(), reqToken())
m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive)
}, repoAssignment(), checkTokenPublicOnly()) }, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))

View File

@ -0,0 +1,53 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"fmt"
"net/http"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/services/context"
archiver_service "code.gitea.io/gitea/services/repository/archiver"
)
func DownloadArchive(ctx *context.APIContext) {
var tp git.ArchiveType
switch ballType := ctx.PathParam("ball_type"); ballType {
case "tarball":
tp = git.TARGZ
case "zipball":
tp = git.ZIP
case "bundle":
tp = git.BUNDLE
default:
ctx.Error(http.StatusBadRequest, "", fmt.Sprintf("Unknown archive type: %s", ballType))
return
}
if ctx.Repo.GitRepo == nil {
gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
if err != nil {
ctx.Error(http.StatusInternalServerError, "OpenRepository", err)
return
}
ctx.Repo.GitRepo = gitRepo
defer gitRepo.Close()
}
r, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, ctx.PathParam("*"), tp)
if err != nil {
ctx.ServerError("NewRequest", err)
return
}
archive, err := r.Await(ctx)
if err != nil {
ctx.ServerError("archive.Await", err)
return
}
download(ctx, r.GetArchiveName(), archive)
}

View File

@ -301,7 +301,13 @@ func GetArchive(ctx *context.APIContext) {
func archiveDownload(ctx *context.APIContext) { func archiveDownload(ctx *context.APIContext) {
uri := ctx.PathParam("*") uri := ctx.PathParam("*")
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) ext, tp, err := archiver_service.ParseFileName(uri)
if err != nil {
ctx.Error(http.StatusBadRequest, "ParseFileName", err)
return
}
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp)
if err != nil { if err != nil {
if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) {
ctx.Error(http.StatusBadRequest, "unknown archive format", err) ctx.Error(http.StatusBadRequest, "unknown archive format", err)
@ -327,9 +333,12 @@ func download(ctx *context.APIContext, archiveName string, archiver *repo_model.
// Add nix format link header so tarballs lock correctly: // Add nix format link header so tarballs lock correctly:
// https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md // https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md
ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.tar.gz?rev=%s>; rel="immutable"`, ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.%s?rev=%s>; rel="immutable"`,
ctx.Repo.Repository.APIURL(), ctx.Repo.Repository.APIURL(),
archiver.CommitID, archiver.CommitID)) archiver.CommitID,
archiver.Type.String(),
archiver.CommitID,
))
rPath := archiver.RelativePath() rPath := archiver.RelativePath()
if setting.RepoArchive.Storage.ServeDirect() { if setting.RepoArchive.Storage.ServeDirect() {

View File

@ -464,7 +464,12 @@ func RedirectDownload(ctx *context.Context) {
// Download an archive of a repository // Download an archive of a repository
func Download(ctx *context.Context) { func Download(ctx *context.Context) {
uri := ctx.PathParam("*") uri := ctx.PathParam("*")
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) ext, tp, err := archiver_service.ParseFileName(uri)
if err != nil {
ctx.ServerError("ParseFileName", err)
return
}
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp)
if err != nil { if err != nil {
if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) {
ctx.Error(http.StatusBadRequest, err.Error()) ctx.Error(http.StatusBadRequest, err.Error())
@ -523,7 +528,12 @@ func download(ctx *context.Context, archiveName string, archiver *repo_model.Rep
// kind of drop it on the floor if this is the case. // kind of drop it on the floor if this is the case.
func InitiateDownload(ctx *context.Context) { func InitiateDownload(ctx *context.Context) {
uri := ctx.PathParam("*") uri := ctx.PathParam("*")
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) ext, tp, err := archiver_service.ParseFileName(uri)
if err != nil {
ctx.ServerError("ParseFileName", err)
return
}
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp)
if err != nil { if err != nil {
ctx.ServerError("archiver_service.NewRequest", err) ctx.ServerError("archiver_service.NewRequest", err)
return return

View File

@ -67,30 +67,36 @@ func (e RepoRefNotFoundError) Is(err error) bool {
return ok return ok
} }
// NewRequest creates an archival request, based on the URI. The func ParseFileName(uri string) (ext string, tp git.ArchiveType, err error) {
// resulting ArchiveRequest is suitable for being passed to Await()
// if it's determined that the request still needs to be satisfied.
func NewRequest(repoID int64, repo *git.Repository, uri string) (*ArchiveRequest, error) {
r := &ArchiveRequest{
RepoID: repoID,
}
var ext string
switch { switch {
case strings.HasSuffix(uri, ".zip"): case strings.HasSuffix(uri, ".zip"):
ext = ".zip" ext = ".zip"
r.Type = git.ZIP tp = git.ZIP
case strings.HasSuffix(uri, ".tar.gz"): case strings.HasSuffix(uri, ".tar.gz"):
ext = ".tar.gz" ext = ".tar.gz"
r.Type = git.TARGZ tp = git.TARGZ
case strings.HasSuffix(uri, ".bundle"): case strings.HasSuffix(uri, ".bundle"):
ext = ".bundle" ext = ".bundle"
r.Type = git.BUNDLE tp = git.BUNDLE
default: default:
return nil, ErrUnknownArchiveFormat{RequestFormat: uri} return "", 0, ErrUnknownArchiveFormat{RequestFormat: uri}
}
return ext, tp, nil
} }
r.refName = strings.TrimSuffix(uri, ext) // NewRequest creates an archival request, based on the URI. The
// resulting ArchiveRequest is suitable for being passed to Await()
// if it's determined that the request still needs to be satisfied.
func NewRequest(repoID int64, repo *git.Repository, refName string, fileType git.ArchiveType) (*ArchiveRequest, error) {
if fileType < git.ZIP || fileType > git.BUNDLE {
return nil, ErrUnknownArchiveFormat{RequestFormat: fileType.String()}
}
r := &ArchiveRequest{
RepoID: repoID,
refName: refName,
Type: fileType,
}
// Get corresponding commit. // Get corresponding commit.
commitID, err := repo.ConvertToGitID(r.refName) commitID, err := repo.ConvertToGitID(r.refName)

View File

@ -10,6 +10,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/services/contexttest" "code.gitea.io/gitea/services/contexttest"
_ "code.gitea.io/gitea/models/actions" _ "code.gitea.io/gitea/models/actions"
@ -31,47 +32,47 @@ func TestArchive_Basic(t *testing.T) {
contexttest.LoadGitRepo(t, ctx) contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close() defer ctx.Repo.GitRepo.Close()
bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, bogusReq) assert.NotNil(t, bogusReq)
assert.EqualValues(t, firstCommit+".zip", bogusReq.GetArchiveName()) assert.EqualValues(t, firstCommit+".zip", bogusReq.GetArchiveName())
// Check a series of bogus requests. // Check a series of bogus requests.
// Step 1, valid commit with a bad extension. // Step 1, valid commit with a bad extension.
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".dilbert") bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, 100)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, bogusReq) assert.Nil(t, bogusReq)
// Step 2, missing commit. // Step 2, missing commit.
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff.zip") bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff", git.ZIP)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, bogusReq) assert.Nil(t, bogusReq)
// Step 3, doesn't look like branch/tag/commit. // Step 3, doesn't look like branch/tag/commit.
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db.zip") bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db", git.ZIP)
assert.Error(t, err) assert.Error(t, err)
assert.Nil(t, bogusReq) assert.Nil(t, bogusReq)
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master.zip") bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master", git.ZIP)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, bogusReq) assert.NotNil(t, bogusReq)
assert.EqualValues(t, "master.zip", bogusReq.GetArchiveName()) assert.EqualValues(t, "master.zip", bogusReq.GetArchiveName())
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive.zip") bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive", git.ZIP)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, bogusReq) assert.NotNil(t, bogusReq)
assert.EqualValues(t, "test-archive.zip", bogusReq.GetArchiveName()) assert.EqualValues(t, "test-archive.zip", bogusReq.GetArchiveName())
// Now two valid requests, firstCommit with valid extensions. // Now two valid requests, firstCommit with valid extensions.
zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, zipReq) assert.NotNil(t, zipReq)
tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".tar.gz") tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.TARGZ)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, tgzReq) assert.NotNil(t, tgzReq)
secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".zip") secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.ZIP)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, secondReq) assert.NotNil(t, secondReq)
@ -91,7 +92,7 @@ func TestArchive_Basic(t *testing.T) {
// Sleep two seconds to make sure the queue doesn't change. // Sleep two seconds to make sure the queue doesn't change.
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
assert.NoError(t, err) assert.NoError(t, err)
// This zipReq should match what's sitting in the queue, as we haven't // This zipReq should match what's sitting in the queue, as we haven't
// let it release yet. From the consumer's point of view, this looks like // let it release yet. From the consumer's point of view, this looks like
@ -106,12 +107,12 @@ func TestArchive_Basic(t *testing.T) {
// Now we'll submit a request and TimedWaitForCompletion twice, before and // Now we'll submit a request and TimedWaitForCompletion twice, before and
// after we release it. We should trigger both the timeout and non-timeout // after we release it. We should trigger both the timeout and non-timeout
// cases. // cases.
timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".tar.gz") timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.TARGZ)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, timedReq) assert.NotNil(t, timedReq)
doArchive(db.DefaultContext, timedReq) doArchive(db.DefaultContext, timedReq)
zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip") zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
assert.NoError(t, err) assert.NoError(t, err)
// Now, we're guaranteed to have released the original zipReq from the queue. // Now, we're guaranteed to have released the original zipReq from the queue.
// Ensure that we don't get handed back the released entry somehow, but they // Ensure that we don't get handed back the released entry somehow, but they

View File

@ -59,3 +59,43 @@ func TestAPIDownloadArchive(t *testing.T) {
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name)) link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name))
MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest) MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest)
} }
func TestAPIDownloadArchive2(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
session := loginUser(t, user2.LowerName)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/zipball/master", user2.Name, repo.Name))
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
bs, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Len(t, bs, 320)
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/tarball/master", user2.Name, repo.Name))
resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Len(t, bs, 266)
// Must return a link to a commit ID as the "immutable" archive link
linkHeaderRe := regexp.MustCompile(`^<(https?://.*/api/v1/repos/user2/repo1/archive/[a-f0-9]+\.tar\.gz.*)>; rel="immutable"$`)
m := linkHeaderRe.FindStringSubmatch(resp.Header().Get("Link"))
assert.NotEmpty(t, m[1])
resp = MakeRequest(t, NewRequest(t, "GET", m[1]).AddTokenAuth(token), http.StatusOK)
bs2, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
// The locked URL should give the same bytes as the non-locked one
assert.EqualValues(t, bs, bs2)
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/bundle/master", user2.Name, repo.Name))
resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Len(t, bs, 382)
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name))
MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest)
}