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 }