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 }