Skip to content

Artifacts download api for artifact actions v4 #33510

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 47 commits into from
Feb 16, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
32b6aef
Artifacts download api for artifact actions v4
ChristopherHX Feb 5, 2025
f7a1dca
Update swagger docs
ChristopherHX Feb 5, 2025
3f0cafd
fix swagger
ChristopherHX Feb 5, 2025
ef5b069
format code
ChristopherHX Feb 5, 2025
26514c9
use MakeAbsoluteURL
ChristopherHX Feb 5, 2025
e7308ac
fix lint? swagger command defect?
ChristopherHX Feb 5, 2025
e129fe8
revert encoding /
ChristopherHX Feb 5, 2025
d5520bd
302 => http.StatusFound
ChristopherHX Feb 5, 2025
d3dbfcb
extract getArtifactByID into a helper function
ChristopherHX Feb 5, 2025
d50358f
move old and new logic to modules
ChristopherHX Feb 5, 2025
bbb7017
fixup
ChristopherHX Feb 5, 2025
fcb8d55
intial tests
ChristopherHX Feb 5, 2025
db619c3
use correct json lib
ChristopherHX Feb 5, 2025
f4b0811
use http.ServeContent
ChristopherHX Feb 5, 2025
c6f1557
fix tests
ChristopherHX Feb 5, 2025
dfae484
use MakeAbsoluteURL
ChristopherHX Feb 5, 2025
ae2202d
add copyright header
ChristopherHX Feb 5, 2025
b4c318c
more tests fix old type from my side
ChristopherHX Feb 5, 2025
5e3c79d
fmt code
ChristopherHX Feb 5, 2025
b1b0ef3
add DeleteArtifact api
ChristopherHX Feb 6, 2025
e1ce404
Merge branch 'main' of https://github.com/go-gitea/gitea into artifac…
ChristopherHX Feb 9, 2025
59cf964
fix some swagger docu issues
ChristopherHX Feb 9, 2025
4721100
use repository apiurl method
ChristopherHX Feb 9, 2025
7a35443
remove unused ctx
ChristopherHX Feb 10, 2025
e137972
Merge branch 'main' into artifacts-download-api
ChristopherHX Feb 10, 2025
44bde87
fix merge
ChristopherHX Feb 10, 2025
a18c8dd
fix private repository artifact download redirect
ChristopherHX Feb 10, 2025
f8ce2f6
fmt
ChristopherHX Feb 10, 2025
5b76dbe
fix import
ChristopherHX Feb 10, 2025
6009e63
Merge branch 'main' into artifacts-download-api
silverwind Feb 11, 2025
8485044
add download test for private repo and remove the token to the redirect
ChristopherHX Feb 11, 2025
f58f33d
Merge branch 'main' into artifacts-download-api
wxiaoguang Feb 13, 2025
d56cebf
refactor path parm & error handling
wxiaoguang Feb 15, 2025
ee37003
fix type casting
wxiaoguang Feb 15, 2025
5d7bf81
refactor getArtifactByPathParam
wxiaoguang Feb 15, 2025
3d1b156
refactor build endpoint
wxiaoguang Feb 15, 2025
e6629fc
remove repoAssignment for raw endpoint
wxiaoguang Feb 15, 2025
7861808
fix test
wxiaoguang Feb 15, 2025
8e48777
fix comment
wxiaoguang Feb 15, 2025
421b7b4
remove TestActionsArtifactV4DownloadRawArtifactMismatchedRepoOwnerMis…
ChristopherHX Feb 15, 2025
a00f7f8
Update test comments and names to reflect only repo check
ChristopherHX Feb 15, 2025
156f0db
use unix timestamp in sig
ChristopherHX Feb 15, 2025
3df911a
fix comment
ChristopherHX Feb 15, 2025
4c1399d
revert owner_id changes to reflect only repoid check
ChristopherHX Feb 15, 2025
a0ed53d
Update routers/api/v1/repo/action.go
wxiaoguang Feb 15, 2025
12c627a
Merge branch 'main' into artifacts-download-api
wxiaoguang Feb 15, 2025
f80f4d1
Merge branch 'main' into artifacts-download-api
GiteaBot Feb 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/migrate_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ func migrateActionsLog(ctx context.Context, dstStorage storage.ObjectStorage) er

func migrateActionsArtifacts(ctx context.Context, dstStorage storage.ObjectStorage) error {
return db.Iterate(ctx, nil, func(ctx context.Context, artifact *actions_model.ActionArtifact) error {
if artifact.Status == int64(actions_model.ArtifactStatusExpired) {
if artifact.Status == actions_model.ArtifactStatusExpired {
return nil
}

Expand Down
10 changes: 5 additions & 5 deletions models/actions/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ type ActionArtifact struct {
ContentEncoding string // The content encoding of the artifact
ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it
ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it
Status int64 `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated index"`
ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired
Expand All @@ -68,7 +68,7 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa
RepoID: t.RepoID,
OwnerID: t.OwnerID,
CommitSHA: t.CommitSHA,
Status: int64(ArtifactStatusUploadPending),
Status: ArtifactStatusUploadPending,
ExpiredUnix: timeutil.TimeStamp(time.Now().Unix() + timeutil.Day*expiredDays),
}
if _, err := db.GetEngine(ctx).Insert(artifact); err != nil {
Expand Down Expand Up @@ -177,18 +177,18 @@ func ListPendingDeleteArtifacts(ctx context.Context, limit int) ([]*ActionArtifa

// SetArtifactExpired sets an artifact to expired
func SetArtifactExpired(ctx context.Context, artifactID int64) error {
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusExpired)})
_, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusExpired})
return err
}

// SetArtifactNeedDelete sets an artifact to need-delete, cron job will delete it
func SetArtifactNeedDelete(ctx context.Context, runID int64, name string) error {
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusPendingDeletion)})
_, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion})
return err
}

// SetArtifactDeleted sets an artifact to deleted
func SetArtifactDeleted(ctx context.Context, artifactID int64) error {
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: int64(ArtifactStatusDeleted)})
_, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusDeleted})
return err
}
2 changes: 1 addition & 1 deletion routers/api/actions/artifacts_chunks.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ func mergeChunksForArtifact(ctx *ArtifactContext, chunks []*chunkFileItem, st st
}

artifact.StoragePath = storagePath
artifact.Status = int64(actions.ArtifactStatusUploadConfirmed)
artifact.Status = actions.ArtifactStatusUploadConfirmed
if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil {
return fmt.Errorf("update artifact error: %v", err)
}
Expand Down
1 change: 1 addition & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,7 @@ func Routes() *web.Router {
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))

// Artifacts direct download endpoint authenticates via signed url
// it is protected by the "sig" parameter (to help to access private repo), so no need to use other middlewares
m.Get("/repos/{username}/{reponame}/actions/artifacts/{artifact_id}/zip/raw", repo.DownloadArtifactRaw)

// Notifications (requires notifications scope)
Expand Down
106 changes: 58 additions & 48 deletions routers/api/v1/repo/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import (

actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
secret_model "code.gitea.io/gitea/models/secret"
"code.gitea.io/gitea/modules/actions"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
Expand Down Expand Up @@ -1028,8 +1028,8 @@ func GetArtifact(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

art, ok := getArtifactByID(ctx)
if !ok {
art := getArtifactByPathParam(ctx, ctx.Repo.Repository)
if ctx.Written() {
return
}

Expand All @@ -1043,7 +1043,7 @@ func GetArtifact(ctx *context.APIContext) {
return
}
// v3 not supported due to not having one unique id
ctx.Error(http.StatusNotFound, "artifact not found", fmt.Errorf("artifact not found"))
ctx.Error(http.StatusNotFound, "GetArtifact", "Artifact not found")
}

// DeleteArtifact Deletes a specific artifact for a workflow run.
Expand Down Expand Up @@ -1077,21 +1077,21 @@ func DeleteArtifact(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

art, ok := getArtifactByID(ctx)
if !ok {
art := getArtifactByPathParam(ctx, ctx.Repo.Repository)
if ctx.Written() {
return
}

if actions.IsArtifactV4(art) {
if err := actions_model.SetArtifactNeedDelete(ctx, art.RunID, art.ArtifactName); err != nil {
ctx.Error(http.StatusInternalServerError, err.Error(), err)
ctx.Error(http.StatusInternalServerError, "DeleteArtifact", err)
return
}
ctx.Status(http.StatusNoContent)
return
}
// v3 not supported due to not having one unique id
ctx.Error(http.StatusNotFound, "artifact not found", fmt.Errorf("artifact not found"))
ctx.Error(http.StatusNotFound, "DeleteArtifact", "Artifact not found")
}

func buildSignature(endp, expires string, artifactID int64) []byte {
Expand All @@ -1102,10 +1102,14 @@ func buildSignature(endp, expires string, artifactID int64) []byte {
return mac.Sum(nil)
}

func buildSigURL(ctx go_context.Context, endp string, artifactID int64) string {
func buildDownloadRawEndpoint(repo *repo_model.Repository, artifactID int64) string {
return fmt.Sprintf("api/v1/repos/%s/%s/actions/artifacts/%d/zip/raw", url.PathEscape(repo.OwnerName), url.PathEscape(repo.Name), artifactID)
}

func buildSigURL(ctx go_context.Context, endPoint string, artifactID int64) string {
// endPoint is a path like "api/v1/repos/owner/repo/actions/artifacts/1/zip/raw"
expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST")
uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") +
"/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(buildSignature(endp, expires, artifactID)) + "&expires=" + url.QueryEscape(expires)
uploadURL := httplib.GuessCurrentAppURL(ctx) + endPoint + "?sig=" + base64.URLEncoding.EncodeToString(buildSignature(endPoint, expires, artifactID)) + "&expires=" + url.QueryEscape(expires)
return uploadURL
}

Expand Down Expand Up @@ -1140,14 +1144,14 @@ func DownloadArtifact(ctx *context.APIContext) {
// "404":
// "$ref": "#/responses/notFound"

art, ok := getArtifactByID(ctx)
if !ok {
art := getArtifactByPathParam(ctx, ctx.Repo.Repository)
if ctx.Written() {
return
}

// if artifacts status is not uploaded-confirmed, treat it as not found
if art.Status == int64(actions_model.ArtifactStatusExpired) {
ctx.Error(http.StatusNotFound, "artifact has expired", fmt.Errorf("artifact has expired"))
if art.Status == actions_model.ArtifactStatusExpired {
ctx.Error(http.StatusNotFound, "DownloadArtifact", "Artifact has expired")
return
}
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(art.ArtifactName), art.ArtifactName))
Expand All @@ -1158,79 +1162,85 @@ func DownloadArtifact(ctx *context.APIContext) {
return
}
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error(), err)
ctx.Error(http.StatusInternalServerError, "DownloadArtifactV4ServeDirectOnly", err)
return
}

rurl := buildSigURL(ctx, "api/v1/repos/"+url.PathEscape(ctx.Repo.Repository.OwnerName)+"/"+url.PathEscape(ctx.Repo.Repository.Name)+"/actions/artifacts/"+fmt.Sprintf("%d", art.ID)+"/zip/raw", art.ID)
ctx.Redirect(rurl, http.StatusFound)
redirectURL := buildSigURL(ctx, buildDownloadRawEndpoint(ctx.Repo.Repository, art.ID), art.ID)
ctx.Redirect(redirectURL, http.StatusFound)
return
}
// v3 not supported due to not having one unique id
ctx.Error(http.StatusNotFound, "artifact not found", fmt.Errorf("artifact not found"))
ctx.Error(http.StatusNotFound, "DownloadArtifact", "Artifact not found")
}

// DownloadArtifactRaw Downloads a specific artifact for a workflow run directly.
func DownloadArtifactRaw(ctx *context.APIContext) {
username := ctx.PathParam("username")
reponame := ctx.PathParam("reponame")
artifactID := ctx.PathParamInt64("artifact_id")
sig := ctx.Req.URL.Query().Get("sig")
// it doesn't use repoAssignment middleware, so it needs to prepare the repo and check permission (sig) by itself
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, ctx.PathParam("username"), ctx.PathParam("reponame"))
if err != nil {
if errors.Is(err, util.ErrNotExist) {
ctx.NotFound()
} else {
ctx.InternalServerError(err)
}
return
}
art := getArtifactByPathParam(ctx, repo)
if ctx.Written() {
return
}

sigStr := ctx.Req.URL.Query().Get("sig")
expires := ctx.Req.URL.Query().Get("expires")
dsig, _ := base64.URLEncoding.DecodeString(sig)
sigBytes, _ := base64.URLEncoding.DecodeString(sigStr)

endp := "api/v1/repos/" + url.PathEscape(username) + "/" + url.PathEscape(reponame) + "/actions/artifacts/" + fmt.Sprintf("%d", artifactID) + "/zip/raw"
expecedsig := buildSignature(endp, expires, artifactID)
if !hmac.Equal(dsig, expecedsig) {
log.Error("Error unauthorized")
ctx.Error(http.StatusUnauthorized, "Error unauthorized", nil)
expectedSig := buildSignature(buildDownloadRawEndpoint(repo, art.ID), expires, art.ID)
if !hmac.Equal(sigBytes, expectedSig) {
ctx.Error(http.StatusUnauthorized, "DownloadArtifactRaw", "Error unauthorized")
return
}
t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires)
if err != nil || t.Before(time.Now()) {
log.Error("Error link expired")
ctx.Error(http.StatusUnauthorized, "Error link expired", nil)
return
}

art, ok := getArtifactByID(ctx)
if !ok {
ctx.Error(http.StatusUnauthorized, "DownloadArtifactRaw", "Error link expired")
return
}

// if artifacts status is not uploaded-confirmed, treat it as not found
if art.Status == int64(actions_model.ArtifactStatusExpired) {
ctx.Error(http.StatusNotFound, "artifact has expired", fmt.Errorf("artifact has expired"))
if art.Status == actions_model.ArtifactStatusExpired {
ctx.Error(http.StatusNotFound, "DownloadArtifactRaw", "Artifact has expired")
return
}
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(art.ArtifactName), art.ArtifactName))

if actions.IsArtifactV4(art) {
err := actions.DownloadArtifactV4(ctx.Base, art)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error(), err)
ctx.Error(http.StatusInternalServerError, "DownloadArtifactV4", err)
return
}
return
}
// v3 not supported due to not having one unique id
ctx.Error(http.StatusNotFound, "artifact not found", fmt.Errorf("artifact not found"))
ctx.Error(http.StatusNotFound, "DownloadArtifactRaw", "artifact not found")
}

// Try to get the artifact by ID and check access
func getArtifactByID(ctx *context.APIContext) (*actions_model.ActionArtifact, bool) {
func getArtifactByPathParam(ctx *context.APIContext, repo *repo_model.Repository) *actions_model.ActionArtifact {
artifactID := ctx.PathParamInt64("artifact_id")

art, ok, err := db.GetByID[actions_model.ActionArtifact](ctx, artifactID)
if err != nil {
ctx.Error(http.StatusInternalServerError, err.Error(), err)
return nil, false
ctx.Error(http.StatusInternalServerError, "getArtifactByPathParam", err)
return nil
}
// if artifacts status is not uploaded-confirmed, treat it as not found
// ctx.Repo.Repository is nil for the raw download endpoint that checked this already
if !ok || ctx.Repo != nil && ctx.Repo.Repository != nil && (art.RepoID != ctx.Repo.Repository.ID || art.OwnerID != ctx.Repo.Repository.OwnerID) || art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) && art.Status != int64(actions_model.ArtifactStatusExpired) {
ctx.Error(http.StatusNotFound, "artifact not found", fmt.Errorf("artifact not found"))
return nil, false
// FIXME: is the OwnerID check right? What if a repo is transferred to a new owner?
if !ok ||
(art.RepoID != repo.ID || art.OwnerID != repo.OwnerID) ||
art.Status != actions_model.ArtifactStatusUploadConfirmed && art.Status != actions_model.ArtifactStatusExpired {
ctx.Error(http.StatusNotFound, "getArtifactByPathParam", "artifact not found")
return nil
}
return art, true
return art
}
2 changes: 1 addition & 1 deletion routers/web/repo/actions/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,7 @@ func ArtifactsDownloadView(ctx *context_module.Context) {

// if artifacts status is not uploaded-confirmed, treat it as not found
for _, art := range artifacts {
if art.Status != int64(actions_model.ArtifactStatusUploadConfirmed) {
if art.Status != actions_model.ArtifactStatusUploadConfirmed {
ctx.Error(http.StatusNotFound, "artifact not found")
return
}
Expand Down
2 changes: 1 addition & 1 deletion services/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArti
ID: art.ID,
Name: art.ArtifactName,
SizeInBytes: art.FileSize,
Expired: art.Status == int64(actions_model.ArtifactStatusExpired),
Expired: art.Status == actions_model.ArtifactStatusExpired,
URL: url,
ArchiveDownloadURL: url + "/zip",
CreatedAt: art.CreatedUnix.AsLocalTime(),
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/api_actions_artifact_v4_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,9 +536,10 @@ func TestActionsArtifactV4DownloadRawArtifactMismatchedRepoOwnerMissingSignature
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)

// confirm artifacts of wrong owner or repo is not visible
// TODO: is this test right? `zip/raw` endpoint doesn't use the token?
req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d/zip/raw", repo.FullName(), 22), nil).
AddTokenAuth(token)
MakeRequest(t, req, http.StatusUnauthorized)
MakeRequest(t, req, http.StatusNotFound)
}

func TestActionsArtifactV4DownloadRawArtifactCorrectRepoOwnerMissingSignatureUnauthorized(t *testing.T) {
Expand Down
Loading