mirror of
https://github.com/go-gitea/gitea
synced 2025-02-24 22:04:18 +00:00
Artifacts download api for artifact actions v4 (#33510)
* download endpoint has to use 302 redirect * fake blob download used if direct download not possible * downloading v3 artifacts not possible New repo apis based on GitHub Rest V3 - GET /runs/{run}/artifacts (Cannot use run index of url due to not being unique) - GET /artifacts - GET + DELETE /artifacts/{artifact_id} - GET /artifacts/{artifact_id}/zip - (GET /artifacts/{artifact_id}/zip/raw this is a workaround for a http 302 assertion in actions/toolkit) - api docs removed this is protected by a signed url like the internal artifacts api and no longer usable with any token or swagger - returns http 401 if the signature is invalid - or change the artifact id - or expired after 1 hour Closes #33353 Closes #32124 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
01bf8da02e
commit
2b8cfb557d
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
48
modules/actions/artifacts.go
Normal file
48
modules/actions/artifacts.go
Normal file
@ -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)
|
||||
}
|
@ -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"`
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
// <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
|
@ -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() {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
336
templates/swagger/v1_json.tmpl
generated
336
templates/swagger/v1_json.tmpl
generated
@ -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": {
|
||||
|
@ -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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user