github.1485827954.workers.dev/nektos/act@v0.2.63/pkg/artifacts/arifacts_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  	"io"
    88  	"io/fs"
    89  	"net/http"
    90  	"net/url"
    91  	"os"
    92  	"path"
    93  	"strconv"
    94  	"strings"
    95  	"time"
    96  
    97  	"github.com/julienschmidt/httprouter"
    98  	log "github.com/sirupsen/logrus"
    99  	"google.golang.org/protobuf/encoding/protojson"
   100  	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
   101  	"google.golang.org/protobuf/types/known/timestamppb"
   102  )
   103  
   104  const (
   105  	ArtifactV4RouteBase       = "/twirp/github.actions.results.api.v1.ArtifactService"
   106  	ArtifactV4ContentEncoding = "application/zip"
   107  )
   108  
   109  type artifactV4Routes struct {
   110  	prefix  string
   111  	fs      WriteFS
   112  	rfs     fs.FS
   113  	AppURL  string
   114  	baseDir string
   115  }
   116  
   117  type ArtifactContext struct {
   118  	Req  *http.Request
   119  	Resp http.ResponseWriter
   120  }
   121  
   122  func (c ArtifactContext) Error(status int, _ ...interface{}) {
   123  	c.Resp.WriteHeader(status)
   124  }
   125  
   126  func (c ArtifactContext) JSON(status int, _ ...interface{}) {
   127  	c.Resp.WriteHeader(status)
   128  }
   129  
   130  func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (interface{}, int64, bool) {
   131  	runID, err := strconv.ParseInt(rawRunID, 10, 64)
   132  	if err != nil /* || task.Job.RunID != runID*/ {
   133  		log.Error("Error runID not match")
   134  		ctx.Error(http.StatusBadRequest, "run-id does not match")
   135  		return nil, 0, false
   136  	}
   137  	return nil, runID, true
   138  }
   139  
   140  func RoutesV4(router *httprouter.Router, baseDir string, fsys WriteFS, rfs fs.FS) {
   141  	route := &artifactV4Routes{
   142  		fs:      fsys,
   143  		rfs:     rfs,
   144  		baseDir: baseDir,
   145  		prefix:  ArtifactV4RouteBase,
   146  	}
   147  	router.POST(path.Join(ArtifactV4RouteBase, "CreateArtifact"), func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
   148  		route.AppURL = r.Host
   149  		route.createArtifact(&ArtifactContext{
   150  			Req:  r,
   151  			Resp: w,
   152  		})
   153  	})
   154  	router.POST(path.Join(ArtifactV4RouteBase, "FinalizeArtifact"), func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
   155  		route.finalizeArtifact(&ArtifactContext{
   156  			Req:  r,
   157  			Resp: w,
   158  		})
   159  	})
   160  	router.POST(path.Join(ArtifactV4RouteBase, "ListArtifacts"), func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
   161  		route.listArtifacts(&ArtifactContext{
   162  			Req:  r,
   163  			Resp: w,
   164  		})
   165  	})
   166  	router.POST(path.Join(ArtifactV4RouteBase, "GetSignedArtifactURL"), func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
   167  		route.AppURL = r.Host
   168  		route.getSignedArtifactURL(&ArtifactContext{
   169  			Req:  r,
   170  			Resp: w,
   171  		})
   172  	})
   173  	router.POST(path.Join(ArtifactV4RouteBase, "DeleteArtifact"), func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
   174  		route.AppURL = r.Host
   175  		route.deleteArtifact(&ArtifactContext{
   176  			Req:  r,
   177  			Resp: w,
   178  		})
   179  	})
   180  	router.PUT(path.Join(ArtifactV4RouteBase, "UploadArtifact"), func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
   181  		route.uploadArtifact(&ArtifactContext{
   182  			Req:  r,
   183  			Resp: w,
   184  		})
   185  	})
   186  	router.GET(path.Join(ArtifactV4RouteBase, "DownloadArtifact"), func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
   187  		route.downloadArtifact(&ArtifactContext{
   188  			Req:  r,
   189  			Resp: w,
   190  		})
   191  	})
   192  }
   193  
   194  func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, taskID int64) []byte {
   195  	mac := hmac.New(sha256.New, []byte{0xba, 0xdb, 0xee, 0xf0})
   196  	mac.Write([]byte(endp))
   197  	mac.Write([]byte(expires))
   198  	mac.Write([]byte(artifactName))
   199  	mac.Write([]byte(fmt.Sprint(taskID)))
   200  	return mac.Sum(nil)
   201  }
   202  
   203  func (r artifactV4Routes) buildArtifactURL(endp, artifactName string, taskID int64) string {
   204  	expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST")
   205  	uploadURL := "http://" + strings.TrimSuffix(r.AppURL, "/") + strings.TrimSuffix(r.prefix, "/") +
   206  		"/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID)
   207  	return uploadURL
   208  }
   209  
   210  func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (int64, string, bool) {
   211  	rawTaskID := ctx.Req.URL.Query().Get("taskID")
   212  	sig := ctx.Req.URL.Query().Get("sig")
   213  	expires := ctx.Req.URL.Query().Get("expires")
   214  	artifactName := ctx.Req.URL.Query().Get("artifactName")
   215  	dsig, _ := base64.URLEncoding.DecodeString(sig)
   216  	taskID, _ := strconv.ParseInt(rawTaskID, 10, 64)
   217  
   218  	expecedsig := r.buildSignature(endp, expires, artifactName, taskID)
   219  	if !hmac.Equal(dsig, expecedsig) {
   220  		log.Error("Error unauthorized")
   221  		ctx.Error(http.StatusUnauthorized, "Error unauthorized")
   222  		return -1, "", false
   223  	}
   224  	t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires)
   225  	if err != nil || t.Before(time.Now()) {
   226  		log.Error("Error link expired")
   227  		ctx.Error(http.StatusUnauthorized, "Error link expired")
   228  		return -1, "", false
   229  	}
   230  	return taskID, artifactName, true
   231  }
   232  
   233  func (r *artifactV4Routes) parseProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool {
   234  	body, err := io.ReadAll(ctx.Req.Body)
   235  	if err != nil {
   236  		log.Errorf("Error decode request body: %v", err)
   237  		ctx.Error(http.StatusInternalServerError, "Error decode request body")
   238  		return false
   239  	}
   240  	err = protojson.Unmarshal(body, req)
   241  	if err != nil {
   242  		log.Errorf("Error decode request body: %v", err)
   243  		ctx.Error(http.StatusInternalServerError, "Error decode request body")
   244  		return false
   245  	}
   246  	return true
   247  }
   248  
   249  func (r *artifactV4Routes) sendProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) {
   250  	resp, err := protojson.Marshal(req)
   251  	if err != nil {
   252  		log.Errorf("Error encode response body: %v", err)
   253  		ctx.Error(http.StatusInternalServerError, "Error encode response body")
   254  		return
   255  	}
   256  	ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8")
   257  	ctx.Resp.WriteHeader(http.StatusOK)
   258  	_, _ = ctx.Resp.Write(resp)
   259  }
   260  
   261  func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) {
   262  	var req CreateArtifactRequest
   263  
   264  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   265  		return
   266  	}
   267  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   268  	if !ok {
   269  		return
   270  	}
   271  
   272  	artifactName := req.Name
   273  
   274  	respData := CreateArtifactResponse{
   275  		Ok:              true,
   276  		SignedUploadUrl: r.buildArtifactURL("UploadArtifact", artifactName, runID),
   277  	}
   278  	r.sendProtbufBody(ctx, &respData)
   279  }
   280  
   281  func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) {
   282  	task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact")
   283  	if !ok {
   284  		return
   285  	}
   286  
   287  	comp := ctx.Req.URL.Query().Get("comp")
   288  	switch comp {
   289  	case "block", "appendBlock":
   290  
   291  		safeRunPath := safeResolve(r.baseDir, fmt.Sprint(task))
   292  		safePath := safeResolve(safeRunPath, artifactName)
   293  		safePath = safeResolve(safePath, artifactName+".zip")
   294  
   295  		file, err := func() (WritableFile, error) {
   296  			if comp == "appendBlock" {
   297  				return r.fs.OpenAppendable(safePath)
   298  			}
   299  			return r.fs.OpenWritable(safePath)
   300  		}()
   301  
   302  		if err != nil {
   303  			panic(err)
   304  		}
   305  		defer file.Close()
   306  
   307  		writer, ok := file.(io.Writer)
   308  		if !ok {
   309  			panic(errors.New("File is not writable"))
   310  		}
   311  
   312  		if ctx.Req.Body == nil {
   313  			panic(errors.New("No body given"))
   314  		}
   315  
   316  		_, err = io.Copy(writer, ctx.Req.Body)
   317  		if err != nil {
   318  			panic(err)
   319  		}
   320  		ctx.JSON(http.StatusCreated, "appended")
   321  	case "blocklist":
   322  		ctx.JSON(http.StatusCreated, "created")
   323  	}
   324  }
   325  
   326  func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) {
   327  	var req FinalizeArtifactRequest
   328  
   329  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   330  		return
   331  	}
   332  	_, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   333  	if !ok {
   334  		return
   335  	}
   336  
   337  	respData := FinalizeArtifactResponse{
   338  		Ok:         true,
   339  		ArtifactId: 1,
   340  	}
   341  	r.sendProtbufBody(ctx, &respData)
   342  }
   343  
   344  func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) {
   345  	var req ListArtifactsRequest
   346  
   347  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   348  		return
   349  	}
   350  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   351  	if !ok {
   352  		return
   353  	}
   354  
   355  	safePath := safeResolve(r.baseDir, fmt.Sprint(runID))
   356  
   357  	entries, err := fs.ReadDir(r.rfs, safePath)
   358  	if err != nil {
   359  		panic(err)
   360  	}
   361  
   362  	list := []*ListArtifactsResponse_MonolithArtifact{}
   363  
   364  	for _, entry := range entries {
   365  		if req.NameFilter == nil || req.NameFilter.Value == entry.Name() {
   366  			data := &ListArtifactsResponse_MonolithArtifact{
   367  				Name:                    entry.Name(),
   368  				CreatedAt:               timestamppb.Now(),
   369  				DatabaseId:              1,
   370  				WorkflowRunBackendId:    req.WorkflowRunBackendId,
   371  				WorkflowJobRunBackendId: req.WorkflowJobRunBackendId,
   372  				Size:                    0,
   373  			}
   374  			if info, err := entry.Info(); err == nil {
   375  				data.Size = info.Size()
   376  				data.CreatedAt = timestamppb.New(info.ModTime())
   377  			}
   378  			list = append(list, data)
   379  		}
   380  	}
   381  
   382  	respData := ListArtifactsResponse{
   383  		Artifacts: list,
   384  	}
   385  	r.sendProtbufBody(ctx, &respData)
   386  }
   387  
   388  func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) {
   389  	var req GetSignedArtifactURLRequest
   390  
   391  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   392  		return
   393  	}
   394  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   395  	if !ok {
   396  		return
   397  	}
   398  
   399  	artifactName := req.Name
   400  
   401  	respData := GetSignedArtifactURLResponse{}
   402  
   403  	respData.SignedUrl = r.buildArtifactURL("DownloadArtifact", artifactName, runID)
   404  	r.sendProtbufBody(ctx, &respData)
   405  }
   406  
   407  func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) {
   408  	task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact")
   409  	if !ok {
   410  		return
   411  	}
   412  
   413  	safeRunPath := safeResolve(r.baseDir, fmt.Sprint(task))
   414  	safePath := safeResolve(safeRunPath, artifactName)
   415  	safePath = safeResolve(safePath, artifactName+".zip")
   416  
   417  	file, _ := r.rfs.Open(safePath)
   418  
   419  	_, _ = io.Copy(ctx.Resp, file)
   420  }
   421  
   422  func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) {
   423  	var req DeleteArtifactRequest
   424  
   425  	if ok := r.parseProtbufBody(ctx, &req); !ok {
   426  		return
   427  	}
   428  	_, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId)
   429  	if !ok {
   430  		return
   431  	}
   432  	safeRunPath := safeResolve(r.baseDir, fmt.Sprint(runID))
   433  	safePath := safeResolve(safeRunPath, req.Name)
   434  
   435  	_ = os.RemoveAll(safePath)
   436  
   437  	respData := DeleteArtifactResponse{
   438  		Ok:         true,
   439  		ArtifactId: 1,
   440  	}
   441  	r.sendProtbufBody(ctx, &respData)
   442  }