github.com/nektos/act@v0.2.83/pkg/artifacts/artifacts_v4.go (about)

     1  // Copyright 2024 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package artifacts
     5  
     6  // GitHub Actions Artifacts V4 API Simple Description
     7  //
     8  // 1. Upload artifact
     9  // 1.1. CreateArtifact
    10  // Post: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact
    11  // Request:
    12  // {
    13  //     "workflow_run_backend_id": "21",
    14  //     "workflow_job_run_backend_id": "49",
    15  //     "name": "test",
    16  //     "version": 4
    17  // }
    18  // Response:
    19  // {
    20  //     "ok": true,
    21  //     "signedUploadUrl": "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"
    22  // }
    23  // 1.2. Upload Zip Content to Blobstorage (unauthenticated request)
    24  // 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=block
    25  // 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded
    26  // 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
    27  // 1.4. Unknown xml payload to Blobstorage (unauthenticated request), ignored for now
    28  // 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
    29  // 1.5. FinalizeArtifact
    30  // Post: /twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact
    31  // Request
    32  // {
    33  //     "workflow_run_backend_id": "21",
    34  //     "workflow_job_run_backend_id": "49",
    35  //     "name": "test",
    36  //     "size": "2097",
    37  //     "hash": "sha256:b6325614d5649338b87215d9536b3c0477729b8638994c74cdefacb020a2cad4"
    38  // }
    39  // Response
    40  // {
    41  //     "ok": true,
    42  //     "artifactId": "4"
    43  // }
    44  // 2. Download artifact
    45  // 2.1. ListArtifacts and optionally filter by artifact exact name or id
    46  // Post: /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts
    47  // Request
    48  // {
    49  //     "workflow_run_backend_id": "21",
    50  //     "workflow_job_run_backend_id": "49",
    51  //     "name_filter": "test"
    52  // }
    53  // Response
    54  // {
    55  //     "artifacts": [
    56  //         {
    57  //             "workflowRunBackendId": "21",
    58  //             "workflowJobRunBackendId": "49",
    59  //             "databaseId": "4",
    60  //             "name": "test",
    61  //             "size": "2093",
    62  //             "createdAt": "2024-01-23T00:13:28Z"
    63  //         }
    64  //     ]
    65  // }
    66  // 2.2. GetSignedArtifactURL get the URL to download the artifact zip file of a specific artifact
    67  // Post: /twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL
    68  // Request
    69  // {
    70  //     "workflow_run_backend_id": "21",
    71  //     "workflow_job_run_backend_id": "49",
    72  //     "name": "test"
    73  // }
    74  // Response
    75  // {
    76  //     "signedUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76"
    77  // }
    78  // 2.3. Download Zip from Blobstorage (unauthenticated request)
    79  // GET: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76
    80  
    81  import (
    82  	"crypto/hmac"
    83  	"crypto/sha256"
    84  	"encoding/base64"
    85  	"errors"
    86  	"fmt"
    87  	"hash/fnv"
    88  	"io"
    89  	"io/fs"
    90  	"net/http"
    91  	"net/url"
    92  	"os"
    93  	"path"
    94  	"strconv"
    95  	"strings"
    96  	"time"
    97  
    98  	"github.com/julienschmidt/httprouter"
    99  	log "github.com/sirupsen/logrus"
   100  	"google.golang.org/protobuf/encoding/protojson"
   101  	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
   102  	"google.golang.org/protobuf/types/known/timestamppb"
   103  )
   104  
   105  const (
   106  	ArtifactV4RouteBase       = "/twirp/github.actions.results.api.v1.ArtifactService"
   107  	ArtifactV4ContentEncoding = "application/zip"
   108  )
   109  
   110  type artifactV4Routes struct {
   111  	prefix  string
   112  	fs      WriteFS
   113  	rfs     fs.FS
   114  	AppURL  string
   115  	baseDir string
   116  }
   117  
   118  type ArtifactContext struct {
   119  	Req  *http.Request
   120  	Resp http.ResponseWriter
   121  }
   122  
   123  func artifactNameToID(s string) int64 {
   124  	h := fnv.New32a()
   125  	h.Write([]byte(s))
   126  	return int64(h.Sum32())
   127  }
   128  
   129  func (c ArtifactContext) Error(status int, _ ...interface{}) {
   130  	c.Resp.WriteHeader(status)
   131  }
   132  
   133  func (c ArtifactContext) JSON(status int, _ ...interface{}) {
   134  	c.Resp.WriteHeader(status)
   135  }
   136  
   137  func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (interface{}, int64, bool) {
   138  	runID, err := strconv.ParseInt(rawRunID, 10, 64)
   139  	if err != nil /* || task.Job.RunID != runID*/ {
   140  		log.Error("Error runID not match")
   141  		ctx.Error(http.StatusBadRequest, "run-id does not match")
   142  		return nil, 0, false
   143  	}
   144  	return nil, runID, true
   145  }
   146  
   147  func RoutesV4(router *httprouter.Router, baseDir string, fsys WriteFS, rfs fs.FS) {
   148  	route := &artifactV4Routes{
   149  		fs:      fsys,
   150  		rfs:     rfs,
   151  		baseDir: baseDir,
   152  		prefix:  ArtifactV4RouteBase,
   153  	}
   154  	router.POST(path.Join(ArtifactV4RouteBase, "CreateArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   155  		route.AppURL = r.Host
   156  		route.createArtifact(&ArtifactContext{
   157  			Req:  r,
   158  			Resp: w,
   159  		})
   160  	})
   161  	router.POST(path.Join(ArtifactV4RouteBase, "FinalizeArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   162  		route.finalizeArtifact(&ArtifactContext{
   163  			Req:  r,
   164  			Resp: w,
   165  		})
   166  	})
   167  	router.POST(path.Join(ArtifactV4RouteBase, "ListArtifacts"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   168  		route.listArtifacts(&ArtifactContext{
   169  			Req:  r,
   170  			Resp: w,
   171  		})
   172  	})
   173  	router.POST(path.Join(ArtifactV4RouteBase, "GetSignedArtifactURL"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   174  		route.AppURL = r.Host
   175  		route.getSignedArtifactURL(&ArtifactContext{
   176  			Req:  r,
   177  			Resp: w,
   178  		})
   179  	})
   180  	router.POST(path.Join(ArtifactV4RouteBase, "DeleteArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   181  		route.AppURL = r.Host
   182  		route.deleteArtifact(&ArtifactContext{
   183  			Req:  r,
   184  			Resp: w,
   185  		})
   186  	})
   187  	router.PUT(path.Join(ArtifactV4RouteBase, "UploadArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   188  		route.uploadArtifact(&ArtifactContext{
   189  			Req:  r,
   190  			Resp: w,
   191  		})
   192  	})
   193  	router.GET(path.Join(ArtifactV4RouteBase, "DownloadArtifact"), func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
   194  		route.downloadArtifact(&ArtifactContext{
   195  			Req:  r,
   196  			Resp: w,
   197  		})
   198  	})
   199  }
   200  
   201  func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, taskID int64) []byte {
   202  	mac := hmac.New(sha256.New, []byte{0xba, 0xdb, 0xee, 0xf0})
   203  	mac.Write([]byte(endp))
   204  	mac.Write([]byte(expires))
   205  	mac.Write([]byte(artifactName))
   206  	mac.Write([]byte(fmt.Sprint(taskID)))
   207  	return mac.Sum(nil)
   208  }
   209  
   210  func (r artifactV4Routes) buildArtifactURL(endp, artifactName string, taskID int64) string {
   211  	expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST")
   212  	uploadURL := "http://" + strings.TrimSuffix(r.AppURL, "/") + strings.TrimSuffix(r.prefix, "/") +
   213  		"/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID)
   214  	return uploadURL
   215  }
   216  
   217  func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (int64, string, bool) {
   218  	rawTaskID := ctx.Req.URL.Query().Get("taskID")
   219  	sig := ctx.Req.URL.Query().Get("sig")
   220  	expires := ctx.Req.URL.Query().Get("expires")
   221  	artifactName := ctx.Req.URL.Query().Get("artifactName")
   222  	dsig, _ := base64.URLEncoding.DecodeString(sig)
   223  	taskID, _ := strconv.ParseInt(rawTaskID, 10, 64)
   224  
   225  	expecedsig := r.buildSignature(endp, expires, artifactName, taskID)
   226  	if !hmac.Equal(dsig, expecedsig) {
   227  		log.Error("Error unauthorized")
   228  		ctx.Error(http.StatusUnauthorized, "Error unauthorized")
   229  		return -1, "", false
   230  	}
   231  	t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires)
   232  	if err != nil || t.Before(time.Now()) {
   233  		log.Error("Error link expired")
   234  		ctx.Error(http.StatusUnauthorized, "Error link expired")
   235  		return -1, "", false
   236  	}
   237  	return taskID, artifactName, true
   238  }
   239  
   240  func (r *artifactV4Routes) parseProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool {
   241  	body, err := io.ReadAll(ctx.Req.Body)
   242  	if err != nil {
   243  		log.Errorf("Error decode request body: %v", err)
   244  		ctx.Error(http.StatusInternalServerError, "Error decode request body")
   245  		return false
   246  	}
   247  	err = protojson.Unmarshal(body, req)
   248  	if err != nil {
   249  		log.Errorf("Error decode request body: %v", err)
   250  		ctx.Error(http.StatusInternalServerError, "Error decode request body")
   251  		return false
   252  	}
   253  	return true
   254  }
   255  
   256  func (r *artifactV4Routes) sendProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) {
   257  	resp, err := protojson.Marshal(req)
   258  	if err != nil {
   259  		log.Errorf("Error encode response body: %v", err)
   260  		ctx.Error(http.StatusInternalServerError, "Error encode response body")
   261  		return
   262  	}
   263  	ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8")
   264  	ctx.Resp.WriteHeader(http.StatusOK)
   265  	_, _ = ctx.Resp.Write(resp)
   266  }
   267  
   268  func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) {
   269  	var req CreateArtifactRequest
   270  
   271  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   272  		return
   273  	}
   274  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   275  	if !ok {
   276  		return
   277  	}
   278  
   279  	artifactName := req.Name
   280  
   281  	safeRunPath := safeResolve(r.baseDir, fmt.Sprint(runID))
   282  	safePath := safeResolve(safeRunPath, artifactName)
   283  	safePath = safeResolve(safePath, artifactName+".zip")
   284  	file, err := r.fs.OpenWritable(safePath)
   285  
   286  	if err != nil {
   287  		panic(err)
   288  	}
   289  	file.Close()
   290  
   291  	respData := CreateArtifactResponse{
   292  		Ok:              true,
   293  		SignedUploadUrl: r.buildArtifactURL("UploadArtifact", artifactName, runID),
   294  	}
   295  	r.sendProtbufBody(ctx, &respData)
   296  }
   297  
   298  func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
   299  	task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact")
   300  	if !ok {
   301  		return
   302  	}
   303  
   304  	comp := ctx.Req.URL.Query().Get("comp")
   305  	switch comp {
   306  	case "block", "appendBlock":
   307  
   308  		safeRunPath := safeResolve(r.baseDir, fmt.Sprint(task))
   309  		safePath := safeResolve(safeRunPath, artifactName)
   310  		safePath = safeResolve(safePath, artifactName+".zip")
   311  
   312  		file, err := r.fs.OpenAppendable(safePath)
   313  
   314  		if err != nil {
   315  			panic(err)
   316  		}
   317  		defer file.Close()
   318  
   319  		writer, ok := file.(io.Writer)
   320  		if !ok {
   321  			panic(errors.New("File is not writable"))
   322  		}
   323  
   324  		if ctx.Req.Body == nil {
   325  			panic(errors.New("No body given"))
   326  		}
   327  
   328  		_, err = io.Copy(writer, ctx.Req.Body)
   329  		if err != nil {
   330  			panic(err)
   331  		}
   332  		file.Close()
   333  		ctx.JSON(http.StatusCreated, "appended")
   334  	case "blocklist":
   335  		ctx.JSON(http.StatusCreated, "created")
   336  	}
   337  }
   338  
   339  func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
   340  	var req FinalizeArtifactRequest
   341  
   342  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   343  		return
   344  	}
   345  	_, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   346  	if !ok {
   347  		return
   348  	}
   349  
   350  	respData := FinalizeArtifactResponse{
   351  		Ok:         true,
   352  		ArtifactId: artifactNameToID(req.Name),
   353  	}
   354  	r.sendProtbufBody(ctx, &respData)
   355  }
   356  
   357  func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
   358  	var req ListArtifactsRequest
   359  
   360  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   361  		return
   362  	}
   363  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   364  	if !ok {
   365  		return
   366  	}
   367  
   368  	safePath := safeResolve(r.baseDir, fmt.Sprint(runID))
   369  
   370  	entries, err := fs.ReadDir(r.rfs, safePath)
   371  	if err != nil {
   372  		panic(err)
   373  	}
   374  
   375  	list := []*ListArtifactsResponse_MonolithArtifact{}
   376  
   377  	for _, entry := range entries {
   378  		id := artifactNameToID(entry.Name())
   379  		if (req.NameFilter == nil || req.NameFilter.Value == entry.Name()) && (req.IdFilter == nil || req.IdFilter.Value == id) {
   380  			data := &ListArtifactsResponse_MonolithArtifact{
   381  				Name:                    entry.Name(),
   382  				CreatedAt:               timestamppb.Now(),
   383  				DatabaseId:              id,
   384  				WorkflowRunBackendId:    req.WorkflowRunBackendId,
   385  				WorkflowJobRunBackendId: req.WorkflowJobRunBackendId,
   386  				Size:                    0,
   387  			}
   388  			if info, err := entry.Info(); err == nil {
   389  				data.Size = info.Size()
   390  				data.CreatedAt = timestamppb.New(info.ModTime())
   391  			}
   392  			list = append(list, data)
   393  		}
   394  	}
   395  
   396  	respData := ListArtifactsResponse{
   397  		Artifacts: list,
   398  	}
   399  	r.sendProtbufBody(ctx, &respData)
   400  }
   401  
   402  func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
   403  	var req GetSignedArtifactURLRequest
   404  
   405  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   406  		return
   407  	}
   408  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   409  	if !ok {
   410  		return
   411  	}
   412  
   413  	artifactName := req.Name
   414  
   415  	respData := GetSignedArtifactURLResponse{}
   416  
   417  	respData.SignedUrl = r.buildArtifactURL("DownloadArtifact", artifactName, runID)
   418  	r.sendProtbufBody(ctx, &respData)
   419  }
   420  
   421  func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
   422  	task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact")
   423  	if !ok {
   424  		return
   425  	}
   426  
   427  	safeRunPath := safeResolve(r.baseDir, fmt.Sprint(task))
   428  	safePath := safeResolve(safeRunPath, artifactName)
   429  	safePath = safeResolve(safePath, artifactName+".zip")
   430  
   431  	file, _ := r.rfs.Open(safePath)
   432  
   433  	_, _ = io.Copy(ctx.Resp, file)
   434  }
   435  
   436  func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
   437  	var req DeleteArtifactRequest
   438  
   439  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   440  		return
   441  	}
   442  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   443  	if !ok {
   444  		return
   445  	}
   446  	safeRunPath := safeResolve(r.baseDir, fmt.Sprint(runID))
   447  	safePath := safeResolve(safeRunPath, req.Name)
   448  
   449  	_ = os.RemoveAll(safePath)
   450  
   451  	respData := DeleteArtifactResponse{
   452  		Ok:         true,
   453  		ArtifactId: artifactNameToID(req.Name),
   454  	}
   455  	r.sendProtbufBody(ctx, &respData)
   456  }