Skip to content

Commit 703be6b

Browse files
authored
Add github compatible tarball download API endpoints (#32572)
Fix #29654 Fix #32481
1 parent 44909f6 commit 703be6b

File tree

7 files changed

+152
-31
lines changed

7 files changed

+152
-31
lines changed

routers/api/v1/api.go

+2
Original file line numberDiff line numberDiff line change
@@ -1377,6 +1377,8 @@ func Routes() *web.Router {
13771377
m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar)
13781378
m.Delete("", repo.DeleteAvatar)
13791379
}, reqAdmin(), reqToken())
1380+
1381+
m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive)
13801382
}, repoAssignment(), checkTokenPublicOnly())
13811383
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
13821384

routers/api/v1/repo/download.go

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"fmt"
8+
"net/http"
9+
10+
"code.gitea.io/gitea/modules/git"
11+
"code.gitea.io/gitea/modules/gitrepo"
12+
"code.gitea.io/gitea/services/context"
13+
archiver_service "code.gitea.io/gitea/services/repository/archiver"
14+
)
15+
16+
func DownloadArchive(ctx *context.APIContext) {
17+
var tp git.ArchiveType
18+
switch ballType := ctx.PathParam("ball_type"); ballType {
19+
case "tarball":
20+
tp = git.TARGZ
21+
case "zipball":
22+
tp = git.ZIP
23+
case "bundle":
24+
tp = git.BUNDLE
25+
default:
26+
ctx.Error(http.StatusBadRequest, "", fmt.Sprintf("Unknown archive type: %s", ballType))
27+
return
28+
}
29+
30+
if ctx.Repo.GitRepo == nil {
31+
gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository)
32+
if err != nil {
33+
ctx.Error(http.StatusInternalServerError, "OpenRepository", err)
34+
return
35+
}
36+
ctx.Repo.GitRepo = gitRepo
37+
defer gitRepo.Close()
38+
}
39+
40+
r, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, ctx.PathParam("*"), tp)
41+
if err != nil {
42+
ctx.ServerError("NewRequest", err)
43+
return
44+
}
45+
46+
archive, err := r.Await(ctx)
47+
if err != nil {
48+
ctx.ServerError("archive.Await", err)
49+
return
50+
}
51+
52+
download(ctx, r.GetArchiveName(), archive)
53+
}

routers/api/v1/repo/file.go

+12-3
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,13 @@ func GetArchive(ctx *context.APIContext) {
301301

302302
func archiveDownload(ctx *context.APIContext) {
303303
uri := ctx.PathParam("*")
304-
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri)
304+
ext, tp, err := archiver_service.ParseFileName(uri)
305+
if err != nil {
306+
ctx.Error(http.StatusBadRequest, "ParseFileName", err)
307+
return
308+
}
309+
310+
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp)
305311
if err != nil {
306312
if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) {
307313
ctx.Error(http.StatusBadRequest, "unknown archive format", err)
@@ -327,9 +333,12 @@ func download(ctx *context.APIContext, archiveName string, archiver *repo_model.
327333

328334
// Add nix format link header so tarballs lock correctly:
329335
// https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md
330-
ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.tar.gz?rev=%s>; rel="immutable"`,
336+
ctx.Resp.Header().Add("Link", fmt.Sprintf(`<%s/archive/%s.%s?rev=%s>; rel="immutable"`,
331337
ctx.Repo.Repository.APIURL(),
332-
archiver.CommitID, archiver.CommitID))
338+
archiver.CommitID,
339+
archiver.Type.String(),
340+
archiver.CommitID,
341+
))
333342

334343
rPath := archiver.RelativePath()
335344
if setting.RepoArchive.Storage.ServeDirect() {

routers/web/repo/repo.go

+12-2
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,12 @@ func RedirectDownload(ctx *context.Context) {
464464
// Download an archive of a repository
465465
func Download(ctx *context.Context) {
466466
uri := ctx.PathParam("*")
467-
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri)
467+
ext, tp, err := archiver_service.ParseFileName(uri)
468+
if err != nil {
469+
ctx.ServerError("ParseFileName", err)
470+
return
471+
}
472+
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp)
468473
if err != nil {
469474
if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) {
470475
ctx.Error(http.StatusBadRequest, err.Error())
@@ -523,7 +528,12 @@ func download(ctx *context.Context, archiveName string, archiver *repo_model.Rep
523528
// kind of drop it on the floor if this is the case.
524529
func InitiateDownload(ctx *context.Context) {
525530
uri := ctx.PathParam("*")
526-
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri)
531+
ext, tp, err := archiver_service.ParseFileName(uri)
532+
if err != nil {
533+
ctx.ServerError("ParseFileName", err)
534+
return
535+
}
536+
aReq, err := archiver_service.NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, strings.TrimSuffix(uri, ext), tp)
527537
if err != nil {
528538
ctx.ServerError("archiver_service.NewRequest", err)
529539
return

services/repository/archiver/archiver.go

+20-14
Original file line numberDiff line numberDiff line change
@@ -67,30 +67,36 @@ func (e RepoRefNotFoundError) Is(err error) bool {
6767
return ok
6868
}
6969

70-
// NewRequest creates an archival request, based on the URI. The
71-
// resulting ArchiveRequest is suitable for being passed to Await()
72-
// if it's determined that the request still needs to be satisfied.
73-
func NewRequest(repoID int64, repo *git.Repository, uri string) (*ArchiveRequest, error) {
74-
r := &ArchiveRequest{
75-
RepoID: repoID,
76-
}
77-
78-
var ext string
70+
func ParseFileName(uri string) (ext string, tp git.ArchiveType, err error) {
7971
switch {
8072
case strings.HasSuffix(uri, ".zip"):
8173
ext = ".zip"
82-
r.Type = git.ZIP
74+
tp = git.ZIP
8375
case strings.HasSuffix(uri, ".tar.gz"):
8476
ext = ".tar.gz"
85-
r.Type = git.TARGZ
77+
tp = git.TARGZ
8678
case strings.HasSuffix(uri, ".bundle"):
8779
ext = ".bundle"
88-
r.Type = git.BUNDLE
80+
tp = git.BUNDLE
8981
default:
90-
return nil, ErrUnknownArchiveFormat{RequestFormat: uri}
82+
return "", 0, ErrUnknownArchiveFormat{RequestFormat: uri}
83+
}
84+
return ext, tp, nil
85+
}
86+
87+
// NewRequest creates an archival request, based on the URI. The
88+
// resulting ArchiveRequest is suitable for being passed to Await()
89+
// if it's determined that the request still needs to be satisfied.
90+
func NewRequest(repoID int64, repo *git.Repository, refName string, fileType git.ArchiveType) (*ArchiveRequest, error) {
91+
if fileType < git.ZIP || fileType > git.BUNDLE {
92+
return nil, ErrUnknownArchiveFormat{RequestFormat: fileType.String()}
9193
}
9294

93-
r.refName = strings.TrimSuffix(uri, ext)
95+
r := &ArchiveRequest{
96+
RepoID: repoID,
97+
refName: refName,
98+
Type: fileType,
99+
}
94100

95101
// Get corresponding commit.
96102
commitID, err := repo.ConvertToGitID(r.refName)

services/repository/archiver/archiver_test.go

+13-12
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
"code.gitea.io/gitea/models/db"
1212
"code.gitea.io/gitea/models/unittest"
13+
"code.gitea.io/gitea/modules/git"
1314
"code.gitea.io/gitea/services/contexttest"
1415

1516
_ "code.gitea.io/gitea/models/actions"
@@ -31,47 +32,47 @@ func TestArchive_Basic(t *testing.T) {
3132
contexttest.LoadGitRepo(t, ctx)
3233
defer ctx.Repo.GitRepo.Close()
3334

34-
bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
35+
bogusReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
3536
assert.NoError(t, err)
3637
assert.NotNil(t, bogusReq)
3738
assert.EqualValues(t, firstCommit+".zip", bogusReq.GetArchiveName())
3839

3940
// Check a series of bogus requests.
4041
// Step 1, valid commit with a bad extension.
41-
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".dilbert")
42+
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, 100)
4243
assert.Error(t, err)
4344
assert.Nil(t, bogusReq)
4445

4546
// Step 2, missing commit.
46-
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff.zip")
47+
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "dbffff", git.ZIP)
4748
assert.Error(t, err)
4849
assert.Nil(t, bogusReq)
4950

5051
// Step 3, doesn't look like branch/tag/commit.
51-
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db.zip")
52+
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "db", git.ZIP)
5253
assert.Error(t, err)
5354
assert.Nil(t, bogusReq)
5455

55-
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master.zip")
56+
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "master", git.ZIP)
5657
assert.NoError(t, err)
5758
assert.NotNil(t, bogusReq)
5859
assert.EqualValues(t, "master.zip", bogusReq.GetArchiveName())
5960

60-
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive.zip")
61+
bogusReq, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, "test/archive", git.ZIP)
6162
assert.NoError(t, err)
6263
assert.NotNil(t, bogusReq)
6364
assert.EqualValues(t, "test-archive.zip", bogusReq.GetArchiveName())
6465

6566
// Now two valid requests, firstCommit with valid extensions.
66-
zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
67+
zipReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
6768
assert.NoError(t, err)
6869
assert.NotNil(t, zipReq)
6970

70-
tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".tar.gz")
71+
tgzReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.TARGZ)
7172
assert.NoError(t, err)
7273
assert.NotNil(t, tgzReq)
7374

74-
secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".zip")
75+
secondReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.ZIP)
7576
assert.NoError(t, err)
7677
assert.NotNil(t, secondReq)
7778

@@ -91,7 +92,7 @@ func TestArchive_Basic(t *testing.T) {
9192
// Sleep two seconds to make sure the queue doesn't change.
9293
time.Sleep(2 * time.Second)
9394

94-
zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
95+
zipReq2, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
9596
assert.NoError(t, err)
9697
// This zipReq should match what's sitting in the queue, as we haven't
9798
// let it release yet. From the consumer's point of view, this looks like
@@ -106,12 +107,12 @@ func TestArchive_Basic(t *testing.T) {
106107
// Now we'll submit a request and TimedWaitForCompletion twice, before and
107108
// after we release it. We should trigger both the timeout and non-timeout
108109
// cases.
109-
timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit+".tar.gz")
110+
timedReq, err := NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, secondCommit, git.TARGZ)
110111
assert.NoError(t, err)
111112
assert.NotNil(t, timedReq)
112113
doArchive(db.DefaultContext, timedReq)
113114

114-
zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit+".zip")
115+
zipReq2, err = NewRequest(ctx.Repo.Repository.ID, ctx.Repo.GitRepo, firstCommit, git.ZIP)
115116
assert.NoError(t, err)
116117
// Now, we're guaranteed to have released the original zipReq from the queue.
117118
// Ensure that we don't get handed back the released entry somehow, but they

tests/integration/api_repo_archive_test.go

+40
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,43 @@ func TestAPIDownloadArchive(t *testing.T) {
5959
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name))
6060
MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest)
6161
}
62+
63+
func TestAPIDownloadArchive2(t *testing.T) {
64+
defer tests.PrepareTestEnv(t)()
65+
66+
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
67+
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
68+
session := loginUser(t, user2.LowerName)
69+
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
70+
71+
link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/zipball/master", user2.Name, repo.Name))
72+
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
73+
bs, err := io.ReadAll(resp.Body)
74+
assert.NoError(t, err)
75+
assert.Len(t, bs, 320)
76+
77+
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/tarball/master", user2.Name, repo.Name))
78+
resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
79+
bs, err = io.ReadAll(resp.Body)
80+
assert.NoError(t, err)
81+
assert.Len(t, bs, 266)
82+
83+
// Must return a link to a commit ID as the "immutable" archive link
84+
linkHeaderRe := regexp.MustCompile(`^<(https?://.*/api/v1/repos/user2/repo1/archive/[a-f0-9]+\.tar\.gz.*)>; rel="immutable"$`)
85+
m := linkHeaderRe.FindStringSubmatch(resp.Header().Get("Link"))
86+
assert.NotEmpty(t, m[1])
87+
resp = MakeRequest(t, NewRequest(t, "GET", m[1]).AddTokenAuth(token), http.StatusOK)
88+
bs2, err := io.ReadAll(resp.Body)
89+
assert.NoError(t, err)
90+
// The locked URL should give the same bytes as the non-locked one
91+
assert.EqualValues(t, bs, bs2)
92+
93+
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/bundle/master", user2.Name, repo.Name))
94+
resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
95+
bs, err = io.ReadAll(resp.Body)
96+
assert.NoError(t, err)
97+
assert.Len(t, bs, 382)
98+
99+
link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name))
100+
MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest)
101+
}

0 commit comments

Comments
 (0)