diff --git a/cmd/migrate_storage.go b/cmd/migrate_storage.go index 6ece4bf661b..2e3aba021d2 100644 --- a/cmd/migrate_storage.go +++ b/cmd/migrate_storage.go @@ -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 } diff --git a/models/actions/artifact.go b/models/actions/artifact.go index 706eb2e43ad..524224f0701 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -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 @@ -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 { @@ -108,10 +108,11 @@ func UpdateArtifactByID(ctx context.Context, id int64, art *ActionArtifact) erro type FindArtifactsOptions struct { db.ListOptions - RepoID int64 - RunID int64 - ArtifactName string - Status int + RepoID int64 + RunID int64 + ArtifactName string + Status int + FinalizedArtifactsV4 bool } func (opts FindArtifactsOptions) ToOrders() string { @@ -134,6 +135,10 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond { if opts.Status > 0 { cond = cond.And(builder.Eq{"status": opts.Status}) } + if opts.FinalizedArtifactsV4 { + cond = cond.And(builder.Eq{"status": ArtifactStatusUploadConfirmed}.Or(builder.Eq{"status": ArtifactStatusExpired})) + cond = cond.And(builder.Eq{"content_encoding": "application/zip"}) + } return cond } @@ -172,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 } diff --git a/models/fixtures/action_artifact.yml b/models/fixtures/action_artifact.yml index 2c51c11ebd0..485474108f7 100644 --- a/models/fixtures/action_artifact.yml +++ b/models/fixtures/action_artifact.yml @@ -69,3 +69,21 @@ created_unix: 1730330775 updated_unix: 1730330775 expired_unix: 1738106775 + +- + id: 23 + run_id: 793 + runner_id: 1 + repo_id: 2 + owner_id: 2 + commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 + storage_path: "27/5/1730330775594233150.chunk" + file_size: 1024 + file_compressed_size: 1024 + content_encoding: "application/zip" + artifact_path: "artifact-v4-download.zip" + artifact_name: "artifact-v4-download" + status: 2 + created_unix: 1730330775 + updated_unix: 1730330775 + expired_unix: 1738106775 diff --git a/modules/actions/artifacts.go b/modules/actions/artifacts.go new file mode 100644 index 00000000000..4d074435efc --- /dev/null +++ b/modules/actions/artifacts.go @@ -0,0 +1,48 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/services/context" +) + +// Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend +// The v4 backend ensures ContentEncoding is set to "application/zip", which is not the case for the old backend +func IsArtifactV4(art *actions_model.ActionArtifact) bool { + return art.ArtifactName+".zip" == art.ArtifactPath && art.ContentEncoding == "application/zip" +} + +func DownloadArtifactV4ServeDirectOnly(ctx *context.Base, art *actions_model.ActionArtifact) (bool, error) { + if setting.Actions.ArtifactStorage.ServeDirect() { + u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, nil) + if u != nil && err == nil { + ctx.Redirect(u.String(), http.StatusFound) + return true, nil + } + } + return false, nil +} + +func DownloadArtifactV4Fallback(ctx *context.Base, art *actions_model.ActionArtifact) error { + f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if err != nil { + return err + } + defer f.Close() + http.ServeContent(ctx.Resp, ctx.Req, art.ArtifactName+".zip", art.CreatedUnix.AsLocalTime(), f) + return nil +} + +func DownloadArtifactV4(ctx *context.Base, art *actions_model.ActionArtifact) error { + ok, err := DownloadArtifactV4ServeDirectOnly(ctx, art) + if ok || err != nil { + return err + } + return DownloadArtifactV4Fallback(ctx, art) +} diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index e6d11a8acb3..203491ac026 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -65,3 +65,34 @@ type ActionWorkflowResponse struct { Workflows []*ActionWorkflow `json:"workflows"` TotalCount int64 `json:"total_count"` } + +// ActionArtifact represents a ActionArtifact +type ActionArtifact struct { + ID int64 `json:"id"` + Name string `json:"name"` + SizeInBytes int64 `json:"size_in_bytes"` + URL string `json:"url"` + ArchiveDownloadURL string `json:"archive_download_url"` + Expired bool `json:"expired"` + WorkflowRun *ActionWorkflowRun `json:"workflow_run"` + + // swagger:strfmt date-time + CreatedAt time.Time `json:"created_at"` + // swagger:strfmt date-time + UpdatedAt time.Time `json:"updated_at"` + // swagger:strfmt date-time + ExpiresAt time.Time `json:"expires_at"` +} + +// ActionWorkflowRun represents a WorkflowRun +type ActionWorkflowRun struct { + ID int64 `json:"id"` + RepositoryID int64 `json:"repository_id"` + HeadSha string `json:"head_sha"` +} + +// ActionArtifactsResponse returns ActionArtifacts +type ActionArtifactsResponse struct { + Entries []*ActionArtifact `json:"artifacts"` + TotalCount int64 `json:"total_count"` +} diff --git a/routers/api/actions/artifacts_chunks.go b/routers/api/actions/artifacts_chunks.go index cf48da12aa8..9d2b69820cb 100644 --- a/routers/api/actions/artifacts_chunks.go +++ b/routers/api/actions/artifacts_chunks.go @@ -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) } diff --git a/routers/api/actions/artifactsv4.go b/routers/api/actions/artifactsv4.go index 8917a7a8a23..d29754b6e98 100644 --- a/routers/api/actions/artifactsv4.go +++ b/routers/api/actions/artifactsv4.go @@ -25,7 +25,7 @@ package actions // 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded // PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock // 1.4. BlockList xml payload to Blobstorage (unauthenticated request) -// Files of about 800MB are parallel in parallel and / or out of order, this file is needed to enshure the correct order +// Files of about 800MB are parallel in parallel and / or out of order, this file is needed to ensure the correct order // PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList // Request // diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8d9e4bfd6ca..8c39393246a 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1241,6 +1241,13 @@ func Routes() *web.Router { }, reqToken(), reqAdmin()) m.Group("/actions", func() { m.Get("/tasks", repo.ListActionTasks) + m.Get("/runs/{run}/artifacts", repo.GetArtifactsOfRun) + m.Get("/artifacts", repo.GetArtifacts) + m.Group("/artifacts/{artifact_id}", func() { + m.Get("", repo.GetArtifact) + m.Delete("", reqRepoWriter(unit.TypeActions), repo.DeleteArtifact) + }) + m.Get("/artifacts/{artifact_id}/zip", repo.DownloadArtifact) }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) m.Group("/keys", func() { m.Combo("").Get(repo.ListDeployKeys). @@ -1401,6 +1408,10 @@ func Routes() *web.Router { }, repoAssignment(), checkTokenPublicOnly()) }, 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) m.Group("/repos", func() { m.Group("/{username}/{reponame}", func() { diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 850384e778a..480e29cfd33 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -4,13 +4,25 @@ package repo import ( + go_context "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "errors" + "fmt" "net/http" + "net/url" + "strconv" "strings" + "time" 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/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -855,3 +867,382 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +// GetArtifacts Lists all artifacts for a repository. +func GetArtifactsOfRun(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/artifacts repository getArtifactsOfRun + // --- + // summary: Lists all artifacts for a repository run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: runid of the workflow run + // type: integer + // required: true + // - name: name + // in: query + // description: name of the artifact + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ArtifactsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repoID := ctx.Repo.Repository.ID + artifactName := ctx.Req.URL.Query().Get("name") + + runID := ctx.PathParamInt64("run") + + artifacts, total, err := db.FindAndCount[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RepoID: repoID, + RunID: runID, + ArtifactName: artifactName, + FinalizedArtifactsV4: true, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error(), err) + return + } + + res := new(api.ActionArtifactsResponse) + res.TotalCount = total + + res.Entries = make([]*api.ActionArtifact, len(artifacts)) + for i := range artifacts { + convertedArtifact, err := convert.ToActionArtifact(ctx.Repo.Repository, artifacts[i]) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ToActionArtifact", err) + return + } + res.Entries[i] = convertedArtifact + } + + ctx.JSON(http.StatusOK, &res) +} + +// GetArtifacts Lists all artifacts for a repository. +func GetArtifacts(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/artifacts repository getArtifacts + // --- + // summary: Lists all artifacts for a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: name + // in: query + // description: name of the artifact + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ArtifactsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repoID := ctx.Repo.Repository.ID + artifactName := ctx.Req.URL.Query().Get("name") + + artifacts, total, err := db.FindAndCount[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RepoID: repoID, + ArtifactName: artifactName, + FinalizedArtifactsV4: true, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error(), err) + return + } + + res := new(api.ActionArtifactsResponse) + res.TotalCount = total + + res.Entries = make([]*api.ActionArtifact, len(artifacts)) + for i := range artifacts { + convertedArtifact, err := convert.ToActionArtifact(ctx.Repo.Repository, artifacts[i]) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ToActionArtifact", err) + return + } + res.Entries[i] = convertedArtifact + } + + ctx.JSON(http.StatusOK, &res) +} + +// GetArtifact Gets a specific artifact for a workflow run. +func GetArtifact(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id} repository getArtifact + // --- + // summary: Gets a specific artifact for a workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: artifact_id + // in: path + // description: id of the artifact + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Artifact" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + art := getArtifactByPathParam(ctx, ctx.Repo.Repository) + if ctx.Written() { + return + } + + if actions.IsArtifactV4(art) { + convertedArtifact, err := convert.ToActionArtifact(ctx.Repo.Repository, art) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ToActionArtifact", err) + return + } + ctx.JSON(http.StatusOK, convertedArtifact) + return + } + // v3 not supported due to not having one unique id + ctx.Error(http.StatusNotFound, "GetArtifact", "Artifact not found") +} + +// DeleteArtifact Deletes a specific artifact for a workflow run. +func DeleteArtifact(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/artifacts/{artifact_id} repository deleteArtifact + // --- + // summary: Deletes a specific artifact for a workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: artifact_id + // in: path + // description: id of the artifact + // type: string + // required: true + // responses: + // "204": + // description: "No Content" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + 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, "DeleteArtifact", err) + return + } + ctx.Status(http.StatusNoContent) + return + } + // v3 not supported due to not having one unique id + ctx.Error(http.StatusNotFound, "DeleteArtifact", "Artifact not found") +} + +func buildSignature(endp string, expires, artifactID int64) []byte { + mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) + mac.Write([]byte(endp)) + mac.Write([]byte(fmt.Sprint(expires))) + mac.Write([]byte(fmt.Sprint(artifactID))) + return mac.Sum(nil) +} + +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).Unix() + uploadURL := httplib.GuessCurrentAppURL(ctx) + endPoint + "?sig=" + base64.URLEncoding.EncodeToString(buildSignature(endPoint, expires, artifactID)) + "&expires=" + strconv.FormatInt(expires, 10) + return uploadURL +} + +// DownloadArtifact Downloads a specific artifact for a workflow run redirects to blob url. +func DownloadArtifact(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip repository downloadArtifact + // --- + // summary: Downloads a specific artifact for a workflow run redirects to blob url + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: artifact_id + // in: path + // description: id of the artifact + // type: string + // required: true + // responses: + // "302": + // description: redirect to the blob download + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + 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 == 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)) + + if actions.IsArtifactV4(art) { + ok, err := actions.DownloadArtifactV4ServeDirectOnly(ctx.Base, art) + if ok { + return + } + if err != nil { + ctx.Error(http.StatusInternalServerError, "DownloadArtifactV4ServeDirectOnly", err) + return + } + + 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, "DownloadArtifact", "Artifact not found") +} + +// DownloadArtifactRaw Downloads a specific artifact for a workflow run directly. +func DownloadArtifactRaw(ctx *context.APIContext) { + // 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") + expiresStr := ctx.Req.URL.Query().Get("expires") + sigBytes, _ := base64.URLEncoding.DecodeString(sigStr) + expires, _ := strconv.ParseInt(expiresStr, 10, 64) + + expectedSig := buildSignature(buildDownloadRawEndpoint(repo, art.ID), expires, art.ID) + if !hmac.Equal(sigBytes, expectedSig) { + ctx.Error(http.StatusUnauthorized, "DownloadArtifactRaw", "Error unauthorized") + return + } + t := time.Unix(expires, 0) + if t.Before(time.Now()) { + ctx.Error(http.StatusUnauthorized, "DownloadArtifactRaw", "Error link expired") + return + } + + // if artifacts status is not uploaded-confirmed, treat it as not found + 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, "DownloadArtifactV4", err) + return + } + return + } + // v3 not supported due to not having one unique id + ctx.Error(http.StatusNotFound, "DownloadArtifactRaw", "artifact not found") +} + +// Try to get the artifact by ID and check access +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, "getArtifactByPathParam", err) + return nil + } + // if artifacts status is not uploaded-confirmed, treat it as not found + // only check RepoID here, because the repository owner may change over the time + if !ok || + art.RepoID != repo.ID || + art.Status != actions_model.ArtifactStatusUploadConfirmed && art.Status != actions_model.ArtifactStatusExpired { + ctx.Error(http.StatusNotFound, "getArtifactByPathParam", "artifact not found") + return nil + } + return art +} diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index f754c80a5b3..25f137f3bf8 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -443,6 +443,20 @@ type swaggerRepoTasksList struct { Body api.ActionTaskResponse `json:"body"` } +// ArtifactsList +// swagger:response ArtifactsList +type swaggerRepoArtifactsList struct { + // in:body + Body api.ActionArtifactsResponse `json:"body"` +} + +// Artifact +// swagger:response Artifact +type swaggerRepoArtifact struct { + // in:body + Body api.ActionArtifact `json:"body"` +} + // swagger:response Compare type swaggerCompare struct { // in:body diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index 7099582c1b8..0e71ce6ff83 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -26,7 +26,6 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/timeutil" @@ -669,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 } @@ -677,23 +676,12 @@ func ArtifactsDownloadView(ctx *context_module.Context) { ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.zip; filename*=UTF-8''%s.zip", url.PathEscape(artifactName), artifactName)) - // Artifacts using the v4 backend are stored as a single combined zip file per artifact on the backend - // The v4 backend enshures ContentEncoding is set to "application/zip", which is not the case for the old backend - if len(artifacts) == 1 && artifacts[0].ArtifactName+".zip" == artifacts[0].ArtifactPath && artifacts[0].ContentEncoding == "application/zip" { - art := artifacts[0] - if setting.Actions.ArtifactStorage.ServeDirect() { - u, err := storage.ActionsArtifacts.URL(art.StoragePath, art.ArtifactPath, nil) - if u != nil && err == nil { - ctx.Redirect(u.String()) - return - } - } - f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) if err != nil { ctx.Error(http.StatusInternalServerError, err.Error()) return } - _, _ = io.Copy(ctx.Resp, f) return } diff --git a/services/convert/convert.go b/services/convert/convert.go index c8cad2a2ad0..fb276499d43 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -229,6 +229,28 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action }, nil } +// ToActionArtifact convert a actions_model.ActionArtifact to an api.ActionArtifact +func ToActionArtifact(repo *repo_model.Repository, art *actions_model.ActionArtifact) (*api.ActionArtifact, error) { + url := fmt.Sprintf("%s/actions/artifacts/%d", repo.APIURL(), art.ID) + + return &api.ActionArtifact{ + ID: art.ID, + Name: art.ArtifactName, + SizeInBytes: art.FileSize, + Expired: art.Status == actions_model.ArtifactStatusExpired, + URL: url, + ArchiveDownloadURL: url + "/zip", + CreatedAt: art.CreatedUnix.AsLocalTime(), + UpdatedAt: art.UpdatedUnix.AsLocalTime(), + ExpiresAt: art.ExpiredUnix.AsLocalTime(), + WorkflowRun: &api.ActionWorkflowRun{ + ID: art.RunID, + RepositoryID: art.RepoID, + HeadSha: art.CommitSHA, + }, + }, nil +} + // ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification func ToVerification(ctx context.Context, c *git.Commit) *api.PayloadCommitVerification { verif := asymkey_model.ParseCommitWithSignature(ctx, c) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 80cf1b5623d..091ede2ff9a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3919,6 +3919,187 @@ } } }, + "/repos/{owner}/{repo}/actions/artifacts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Lists all artifacts for a repository", + "operationId": "getArtifacts", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the artifact", + "name": "name", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ArtifactsList" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/actions/artifacts/{artifact_id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Gets a specific artifact for a workflow run", + "operationId": "getArtifact", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the artifact", + "name": "artifact_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Artifact" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Deletes a specific artifact for a workflow run", + "operationId": "deleteArtifact", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the artifact", + "name": "artifact_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Downloads a specific artifact for a workflow run redirects to blob url", + "operationId": "downloadArtifact", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the artifact", + "name": "artifact_id", + "in": "path", + "required": true + } + ], + "responses": { + "302": { + "description": "redirect to the blob download" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/runners/registration-token": { "get": { "produces": [ @@ -3952,6 +4133,58 @@ } } }, + "/repos/{owner}/{repo}/actions/runs/{run}/artifacts": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Lists all artifacts for a repository run", + "operationId": "getArtifactsOfRun", + "parameters": [ + { + "type": "string", + "description": "name of the owner", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "runid of the workflow run", + "name": "run", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the artifact", + "name": "name", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/ArtifactsList" + }, + "400": { + "$ref": "#/responses/error" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/actions/secrets": { "get": { "produces": [ @@ -18837,6 +19070,76 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ActionArtifact": { + "description": "ActionArtifact represents a ActionArtifact", + "type": "object", + "properties": { + "archive_download_url": { + "type": "string", + "x-go-name": "ArchiveDownloadURL" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "CreatedAt" + }, + "expired": { + "type": "boolean", + "x-go-name": "Expired" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "x-go-name": "ExpiresAt" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "size_in_bytes": { + "type": "integer", + "format": "int64", + "x-go-name": "SizeInBytes" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "UpdatedAt" + }, + "url": { + "type": "string", + "x-go-name": "URL" + }, + "workflow_run": { + "$ref": "#/definitions/ActionWorkflowRun" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "ActionArtifactsResponse": { + "description": "ActionArtifactsResponse returns ActionArtifacts", + "type": "object", + "properties": { + "artifacts": { + "type": "array", + "items": { + "$ref": "#/definitions/ActionArtifact" + }, + "x-go-name": "Entries" + }, + "total_count": { + "type": "integer", + "format": "int64", + "x-go-name": "TotalCount" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "ActionTask": { "description": "ActionTask represents a ActionTask", "type": "object", @@ -18999,6 +19302,27 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ActionWorkflowRun": { + "description": "ActionWorkflowRun represents a WorkflowRun", + "type": "object", + "properties": { + "head_sha": { + "type": "string", + "x-go-name": "HeadSha" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "repository_id": { + "type": "integer", + "format": "int64", + "x-go-name": "RepositoryID" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Activity": { "type": "object", "properties": { @@ -26064,6 +26388,18 @@ "$ref": "#/definitions/AnnotatedTag" } }, + "Artifact": { + "description": "Artifact", + "schema": { + "$ref": "#/definitions/ActionArtifact" + } + }, + "ArtifactsList": { + "description": "ArtifactsList", + "schema": { + "$ref": "#/definitions/ActionArtifactsResponse" + } + }, "Attachment": { "description": "Attachment", "schema": { diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go index 8821472801d..b6dfa6e7994 100644 --- a/tests/integration/api_actions_artifact_v4_test.go +++ b/tests/integration/api_actions_artifact_v4_test.go @@ -8,13 +8,20 @@ import ( "crypto/sha256" "encoding/hex" "encoding/xml" + "fmt" "io" "net/http" "strings" "testing" "time" + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/storage" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/actions" actions_service "code.gitea.io/gitea/services/actions" @@ -334,6 +341,206 @@ func TestActionsArtifactV4DownloadSingle(t *testing.T) { assert.Equal(t, body, resp.Body.String()) } +func TestActionsArtifactV4RunDownloadSinglePublicApi(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifact can be listed and found by name + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/runs/792/artifacts?name=artifact-v4-download", repo.FullName()), nil). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var listResp api.ActionArtifactsResponse + err := json.Unmarshal(resp.Body.Bytes(), &listResp) + assert.NoError(t, err) + assert.NotEmpty(t, listResp.Entries[0].ArchiveDownloadURL) + assert.Equal(t, "artifact-v4-download", listResp.Entries[0].Name) + + // confirm artifact blob storage url can be retrieved + req = NewRequestWithBody(t, "GET", listResp.Entries[0].ArchiveDownloadURL, nil). + AddTokenAuth(token) + + resp = MakeRequest(t, req, http.StatusFound) + + // confirm artifact can be downloaded and has expected content + req = NewRequestWithBody(t, "GET", resp.Header().Get("Location"), nil). + AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + + body := strings.Repeat("D", 1024) + assert.Equal(t, body, resp.Body.String()) +} + +func TestActionsArtifactV4DownloadSinglePublicApi(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifact can be listed and found by name + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts?name=artifact-v4-download", repo.FullName()), nil). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var listResp api.ActionArtifactsResponse + err := json.Unmarshal(resp.Body.Bytes(), &listResp) + assert.NoError(t, err) + assert.NotEmpty(t, listResp.Entries[0].ArchiveDownloadURL) + assert.Equal(t, "artifact-v4-download", listResp.Entries[0].Name) + + // confirm artifact blob storage url can be retrieved + req = NewRequestWithBody(t, "GET", listResp.Entries[0].ArchiveDownloadURL, nil). + AddTokenAuth(token) + + resp = MakeRequest(t, req, http.StatusFound) + + blobLocation := resp.Header().Get("Location") + + // confirm artifact can be downloaded without token and has expected content + req = NewRequestWithBody(t, "GET", blobLocation, nil) + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat("D", 1024) + assert.Equal(t, body, resp.Body.String()) + + // confirm artifact can not be downloaded without query + req = NewRequestWithBody(t, "GET", blobLocation, nil) + req.URL.RawQuery = "" + _ = MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestActionsArtifactV4DownloadSinglePublicApiPrivateRepo(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifact can be listed and found by name + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts?name=artifact-v4-download", repo.FullName()), nil). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var listResp api.ActionArtifactsResponse + err := json.Unmarshal(resp.Body.Bytes(), &listResp) + assert.NoError(t, err) + assert.Equal(t, int64(23), listResp.Entries[0].ID) + assert.NotEmpty(t, listResp.Entries[0].ArchiveDownloadURL) + assert.Equal(t, "artifact-v4-download", listResp.Entries[0].Name) + + // confirm artifact blob storage url can be retrieved + req = NewRequestWithBody(t, "GET", listResp.Entries[0].ArchiveDownloadURL, nil). + AddTokenAuth(token) + + resp = MakeRequest(t, req, http.StatusFound) + + blobLocation := resp.Header().Get("Location") + // confirm artifact can be downloaded without token and has expected content + req = NewRequestWithBody(t, "GET", blobLocation, nil) + resp = MakeRequest(t, req, http.StatusOK) + body := strings.Repeat("D", 1024) + assert.Equal(t, body, resp.Body.String()) + + // confirm artifact can not be downloaded without query + req = NewRequestWithBody(t, "GET", blobLocation, nil) + req.URL.RawQuery = "" + _ = MakeRequest(t, req, http.StatusUnauthorized) +} + +func TestActionsArtifactV4ListAndGetPublicApi(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifact can be listed + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts", repo.FullName()), nil). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusOK) + var listResp api.ActionArtifactsResponse + err := json.Unmarshal(resp.Body.Bytes(), &listResp) + assert.NoError(t, err) + + for _, artifact := range listResp.Entries { + assert.Contains(t, artifact.URL, fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), artifact.ID)) + assert.Contains(t, artifact.ArchiveDownloadURL, fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d/zip", repo.FullName(), artifact.ID)) + req = NewRequestWithBody(t, "GET", listResp.Entries[0].URL, nil). + AddTokenAuth(token) + + resp = MakeRequest(t, req, http.StatusOK) + var artifactResp api.ActionArtifact + err := json.Unmarshal(resp.Body.Bytes(), &artifactResp) + assert.NoError(t, err) + + assert.Equal(t, artifact.ID, artifactResp.ID) + assert.Equal(t, artifact.Name, artifactResp.Name) + assert.Equal(t, artifact.SizeInBytes, artifactResp.SizeInBytes) + assert.Equal(t, artifact.URL, artifactResp.URL) + assert.Equal(t, artifact.ArchiveDownloadURL, artifactResp.ArchiveDownloadURL) + } +} + +func TestActionsArtifactV4GetArtifactMismatchedRepoNotFound(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifacts of wrong repo is not visible + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestActionsArtifactV4DownloadArtifactMismatchedRepoNotFound(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifacts of wrong repo is not visible + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d/zip", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestActionsArtifactV4DownloadArtifactCorrectRepoFound(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifacts of correct repo is visible + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d/zip", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusFound) +} + +func TestActionsArtifactV4DownloadRawArtifactCorrectRepoMissingSignatureUnauthorized(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm cannot use the raw artifact endpoint even with a correct access 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) +} + func TestActionsArtifactV4Delete(t *testing.T) { defer prepareTestEnvActionsArtifacts(t)() @@ -351,3 +558,51 @@ func TestActionsArtifactV4Delete(t *testing.T) { protojson.Unmarshal(resp.Body.Bytes(), &deleteResp) assert.True(t, deleteResp.Ok) } + +func TestActionsArtifactV4DeletePublicApi(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + // confirm artifacts exists + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // delete artifact by id + req = NewRequestWithBody(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + + // confirm artifacts has been deleted + req = NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusNotFound) +} + +func TestActionsArtifactV4DeletePublicApiNotAllowedReadScope(t *testing.T) { + defer prepareTestEnvActionsArtifacts(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository) + + // confirm artifacts exists + req := NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + // try delete artifact by id + req = NewRequestWithBody(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + + // confirm artifacts has not been deleted + req = NewRequestWithBody(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/artifacts/%d", repo.FullName(), 22), nil). + AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) +}