code.gitea.io/gitea@v1.21.7/services/lfs/server.go (about) 1 // Copyright 2021 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package lfs 5 6 import ( 7 stdCtx "context" 8 "encoding/base64" 9 "encoding/hex" 10 "errors" 11 "fmt" 12 "io" 13 "net/http" 14 "net/url" 15 "path" 16 "regexp" 17 "strconv" 18 "strings" 19 20 actions_model "code.gitea.io/gitea/models/actions" 21 auth_model "code.gitea.io/gitea/models/auth" 22 git_model "code.gitea.io/gitea/models/git" 23 "code.gitea.io/gitea/models/perm" 24 access_model "code.gitea.io/gitea/models/perm/access" 25 repo_model "code.gitea.io/gitea/models/repo" 26 "code.gitea.io/gitea/models/unit" 27 user_model "code.gitea.io/gitea/models/user" 28 "code.gitea.io/gitea/modules/context" 29 "code.gitea.io/gitea/modules/json" 30 lfs_module "code.gitea.io/gitea/modules/lfs" 31 "code.gitea.io/gitea/modules/log" 32 "code.gitea.io/gitea/modules/setting" 33 "code.gitea.io/gitea/modules/storage" 34 35 "github.com/golang-jwt/jwt/v5" 36 "github.com/minio/sha256-simd" 37 ) 38 39 // requestContext contain variables from the HTTP request. 40 type requestContext struct { 41 User string 42 Repo string 43 Authorization string 44 } 45 46 // Claims is a JWT Token Claims 47 type Claims struct { 48 RepoID int64 49 Op string 50 UserID int64 51 jwt.RegisteredClaims 52 } 53 54 // DownloadLink builds a URL to download the object. 55 func (rc *requestContext) DownloadLink(p lfs_module.Pointer) string { 56 return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid)) 57 } 58 59 // UploadLink builds a URL to upload the object. 60 func (rc *requestContext) UploadLink(p lfs_module.Pointer) string { 61 return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/objects", url.PathEscape(p.Oid), strconv.FormatInt(p.Size, 10)) 62 } 63 64 // VerifyLink builds a URL for verifying the object. 65 func (rc *requestContext) VerifyLink(p lfs_module.Pointer) string { 66 return setting.AppURL + path.Join(url.PathEscape(rc.User), url.PathEscape(rc.Repo+".git"), "info/lfs/verify") 67 } 68 69 // CheckAcceptMediaType checks if the client accepts the LFS media type. 70 func CheckAcceptMediaType(ctx *context.Context) { 71 mediaParts := strings.Split(ctx.Req.Header.Get("Accept"), ";") 72 73 if mediaParts[0] != lfs_module.MediaType { 74 log.Trace("Calling a LFS method without accepting the correct media type: %s", lfs_module.MediaType) 75 writeStatus(ctx, http.StatusUnsupportedMediaType) 76 return 77 } 78 } 79 80 var rangeHeaderRegexp = regexp.MustCompile(`bytes=(\d+)\-(\d*).*`) 81 82 // DownloadHandler gets the content from the content store 83 func DownloadHandler(ctx *context.Context) { 84 rc := getRequestContext(ctx) 85 p := lfs_module.Pointer{Oid: ctx.Params("oid")} 86 87 meta := getAuthenticatedMeta(ctx, rc, p, false) 88 if meta == nil { 89 return 90 } 91 92 // Support resume download using Range header 93 var fromByte, toByte int64 94 toByte = meta.Size - 1 95 statusCode := http.StatusOK 96 if rangeHdr := ctx.Req.Header.Get("Range"); rangeHdr != "" { 97 match := rangeHeaderRegexp.FindStringSubmatch(rangeHdr) 98 if len(match) > 1 { 99 statusCode = http.StatusPartialContent 100 fromByte, _ = strconv.ParseInt(match[1], 10, 32) 101 102 if fromByte >= meta.Size { 103 writeStatus(ctx, http.StatusRequestedRangeNotSatisfiable) 104 return 105 } 106 107 if match[2] != "" { 108 _toByte, _ := strconv.ParseInt(match[2], 10, 32) 109 if _toByte >= fromByte && _toByte < toByte { 110 toByte = _toByte 111 } 112 } 113 114 ctx.Resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, toByte, meta.Size-fromByte)) 115 ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Range") 116 } 117 } 118 119 contentStore := lfs_module.NewContentStore() 120 content, err := contentStore.Get(meta.Pointer) 121 if err != nil { 122 writeStatus(ctx, http.StatusNotFound) 123 return 124 } 125 defer content.Close() 126 127 if fromByte > 0 { 128 _, err = content.Seek(fromByte, io.SeekStart) 129 if err != nil { 130 log.Error("Whilst trying to read LFS OID[%s]: Unable to seek to %d Error: %v", meta.Oid, fromByte, err) 131 writeStatus(ctx, http.StatusInternalServerError) 132 return 133 } 134 } 135 136 contentLength := toByte + 1 - fromByte 137 ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(contentLength, 10)) 138 ctx.Resp.Header().Set("Content-Type", "application/octet-stream") 139 140 filename := ctx.Params("filename") 141 if len(filename) > 0 { 142 decodedFilename, err := base64.RawURLEncoding.DecodeString(filename) 143 if err == nil { 144 ctx.Resp.Header().Set("Content-Disposition", "attachment; filename=\""+string(decodedFilename)+"\"") 145 ctx.Resp.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") 146 } 147 } 148 149 ctx.Resp.WriteHeader(statusCode) 150 if written, err := io.CopyN(ctx.Resp, content, contentLength); err != nil { 151 log.Error("Error whilst copying LFS OID[%s] to the response after %d bytes. Error: %v", meta.Oid, written, err) 152 } 153 } 154 155 // BatchHandler provides the batch api 156 func BatchHandler(ctx *context.Context) { 157 var br lfs_module.BatchRequest 158 if err := decodeJSON(ctx.Req, &br); err != nil { 159 log.Trace("Unable to decode BATCH request vars: Error: %v", err) 160 writeStatus(ctx, http.StatusBadRequest) 161 return 162 } 163 164 var isUpload bool 165 if br.Operation == "upload" { 166 isUpload = true 167 } else if br.Operation == "download" { 168 isUpload = false 169 } else { 170 log.Trace("Attempt to BATCH with invalid operation: %s", br.Operation) 171 writeStatus(ctx, http.StatusBadRequest) 172 return 173 } 174 175 rc := getRequestContext(ctx) 176 177 repository := getAuthenticatedRepository(ctx, rc, isUpload) 178 if repository == nil { 179 return 180 } 181 182 contentStore := lfs_module.NewContentStore() 183 184 var responseObjects []*lfs_module.ObjectResponse 185 186 for _, p := range br.Objects { 187 if !p.IsValid() { 188 responseObjects = append(responseObjects, buildObjectResponse(rc, p, false, false, &lfs_module.ObjectError{ 189 Code: http.StatusUnprocessableEntity, 190 Message: "Oid or size are invalid", 191 })) 192 continue 193 } 194 195 exists, err := contentStore.Exists(p) 196 if err != nil { 197 log.Error("Unable to check if LFS OID[%s] exist. Error: %v", p.Oid, rc.User, rc.Repo, err) 198 writeStatus(ctx, http.StatusInternalServerError) 199 return 200 } 201 202 meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid) 203 if err != nil && err != git_model.ErrLFSObjectNotExist { 204 log.Error("Unable to get LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err) 205 writeStatus(ctx, http.StatusInternalServerError) 206 return 207 } 208 209 if meta != nil && p.Size != meta.Size { 210 responseObjects = append(responseObjects, buildObjectResponse(rc, p, false, false, &lfs_module.ObjectError{ 211 Code: http.StatusUnprocessableEntity, 212 Message: fmt.Sprintf("Object %s is not %d bytes", p.Oid, p.Size), 213 })) 214 continue 215 } 216 217 var responseObject *lfs_module.ObjectResponse 218 if isUpload { 219 var err *lfs_module.ObjectError 220 if !exists && setting.LFS.MaxFileSize > 0 && p.Size > setting.LFS.MaxFileSize { 221 err = &lfs_module.ObjectError{ 222 Code: http.StatusUnprocessableEntity, 223 Message: fmt.Sprintf("Size must be less than or equal to %d", setting.LFS.MaxFileSize), 224 } 225 } 226 227 if exists && meta == nil { 228 accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) 229 if err != nil { 230 log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err) 231 writeStatus(ctx, http.StatusInternalServerError) 232 return 233 } 234 if accessible { 235 _, err := git_model.NewLFSMetaObject(ctx, &git_model.LFSMetaObject{Pointer: p, RepositoryID: repository.ID}) 236 if err != nil { 237 log.Error("Unable to create LFS MetaObject [%s] for %s/%s. Error: %v", p.Oid, rc.User, rc.Repo, err) 238 writeStatus(ctx, http.StatusInternalServerError) 239 return 240 } 241 } else { 242 exists = false 243 } 244 } 245 246 responseObject = buildObjectResponse(rc, p, false, !exists, err) 247 } else { 248 var err *lfs_module.ObjectError 249 if !exists || meta == nil { 250 err = &lfs_module.ObjectError{ 251 Code: http.StatusNotFound, 252 Message: http.StatusText(http.StatusNotFound), 253 } 254 } 255 256 responseObject = buildObjectResponse(rc, p, true, false, err) 257 } 258 responseObjects = append(responseObjects, responseObject) 259 } 260 261 respobj := &lfs_module.BatchResponse{Objects: responseObjects} 262 263 ctx.Resp.Header().Set("Content-Type", lfs_module.MediaType) 264 265 enc := json.NewEncoder(ctx.Resp) 266 if err := enc.Encode(respobj); err != nil { 267 log.Error("Failed to encode representation as json. Error: %v", err) 268 } 269 } 270 271 // UploadHandler receives data from the client and puts it into the content store 272 func UploadHandler(ctx *context.Context) { 273 rc := getRequestContext(ctx) 274 275 p := lfs_module.Pointer{Oid: ctx.Params("oid")} 276 var err error 277 if p.Size, err = strconv.ParseInt(ctx.Params("size"), 10, 64); err != nil { 278 writeStatusMessage(ctx, http.StatusUnprocessableEntity, err.Error()) 279 } 280 281 if !p.IsValid() { 282 log.Trace("Attempt to access invalid LFS OID[%s] in %s/%s", p.Oid, rc.User, rc.Repo) 283 writeStatus(ctx, http.StatusUnprocessableEntity) 284 return 285 } 286 287 repository := getAuthenticatedRepository(ctx, rc, true) 288 if repository == nil { 289 return 290 } 291 292 contentStore := lfs_module.NewContentStore() 293 exists, err := contentStore.Exists(p) 294 if err != nil { 295 log.Error("Unable to check if LFS OID[%s] exist. Error: %v", p.Oid, err) 296 writeStatus(ctx, http.StatusInternalServerError) 297 return 298 } 299 300 uploadOrVerify := func() error { 301 if exists { 302 accessible, err := git_model.LFSObjectAccessible(ctx, ctx.Doer, p.Oid) 303 if err != nil { 304 log.Error("Unable to check if LFS MetaObject [%s] is accessible. Error: %v", p.Oid, err) 305 return err 306 } 307 if !accessible { 308 // The file exists but the user has no access to it. 309 // The upload gets verified by hashing and size comparison to prove access to it. 310 hash := sha256.New() 311 written, err := io.Copy(hash, ctx.Req.Body) 312 if err != nil { 313 log.Error("Error creating hash. Error: %v", err) 314 return err 315 } 316 317 if written != p.Size { 318 return lfs_module.ErrSizeMismatch 319 } 320 if hex.EncodeToString(hash.Sum(nil)) != p.Oid { 321 return lfs_module.ErrHashMismatch 322 } 323 } 324 } else if err := contentStore.Put(p, ctx.Req.Body); err != nil { 325 log.Error("Error putting LFS MetaObject [%s] into content store. Error: %v", p.Oid, err) 326 return err 327 } 328 _, err := git_model.NewLFSMetaObject(ctx, &git_model.LFSMetaObject{Pointer: p, RepositoryID: repository.ID}) 329 return err 330 } 331 332 defer ctx.Req.Body.Close() 333 if err := uploadOrVerify(); err != nil { 334 if errors.Is(err, lfs_module.ErrSizeMismatch) || errors.Is(err, lfs_module.ErrHashMismatch) { 335 log.Error("Upload does not match LFS MetaObject [%s]. Error: %v", p.Oid, err) 336 writeStatusMessage(ctx, http.StatusUnprocessableEntity, err.Error()) 337 } else { 338 log.Error("Error whilst uploadOrVerify LFS OID[%s]: %v", p.Oid, err) 339 writeStatus(ctx, http.StatusInternalServerError) 340 } 341 if _, err = git_model.RemoveLFSMetaObjectByOid(ctx, repository.ID, p.Oid); err != nil { 342 log.Error("Error whilst removing MetaObject for LFS OID[%s]: %v", p.Oid, err) 343 } 344 return 345 } 346 347 writeStatus(ctx, http.StatusOK) 348 } 349 350 // VerifyHandler verify oid and its size from the content store 351 func VerifyHandler(ctx *context.Context) { 352 var p lfs_module.Pointer 353 if err := decodeJSON(ctx.Req, &p); err != nil { 354 writeStatus(ctx, http.StatusUnprocessableEntity) 355 return 356 } 357 358 rc := getRequestContext(ctx) 359 360 meta := getAuthenticatedMeta(ctx, rc, p, true) 361 if meta == nil { 362 return 363 } 364 365 contentStore := lfs_module.NewContentStore() 366 ok, err := contentStore.Verify(meta.Pointer) 367 368 status := http.StatusOK 369 if err != nil { 370 log.Error("Error whilst verifying LFS OID[%s]: %v", p.Oid, err) 371 status = http.StatusInternalServerError 372 } else if !ok { 373 status = http.StatusNotFound 374 } 375 writeStatus(ctx, status) 376 } 377 378 func decodeJSON(req *http.Request, v any) error { 379 defer req.Body.Close() 380 381 dec := json.NewDecoder(req.Body) 382 return dec.Decode(v) 383 } 384 385 func getRequestContext(ctx *context.Context) *requestContext { 386 return &requestContext{ 387 User: ctx.Params("username"), 388 Repo: strings.TrimSuffix(ctx.Params("reponame"), ".git"), 389 Authorization: ctx.Req.Header.Get("Authorization"), 390 } 391 } 392 393 func getAuthenticatedMeta(ctx *context.Context, rc *requestContext, p lfs_module.Pointer, requireWrite bool) *git_model.LFSMetaObject { 394 if !p.IsValid() { 395 log.Info("Attempt to access invalid LFS OID[%s] in %s/%s", p.Oid, rc.User, rc.Repo) 396 writeStatusMessage(ctx, http.StatusUnprocessableEntity, "Oid or size are invalid") 397 return nil 398 } 399 400 repository := getAuthenticatedRepository(ctx, rc, requireWrite) 401 if repository == nil { 402 return nil 403 } 404 405 meta, err := git_model.GetLFSMetaObjectByOid(ctx, repository.ID, p.Oid) 406 if err != nil { 407 log.Error("Unable to get LFS OID[%s] Error: %v", p.Oid, err) 408 writeStatus(ctx, http.StatusNotFound) 409 return nil 410 } 411 412 return meta 413 } 414 415 func getAuthenticatedRepository(ctx *context.Context, rc *requestContext, requireWrite bool) *repo_model.Repository { 416 repository, err := repo_model.GetRepositoryByOwnerAndName(ctx, rc.User, rc.Repo) 417 if err != nil { 418 log.Error("Unable to get repository: %s/%s Error: %v", rc.User, rc.Repo, err) 419 writeStatus(ctx, http.StatusNotFound) 420 return nil 421 } 422 423 if !authenticate(ctx, repository, rc.Authorization, false, requireWrite) { 424 requireAuth(ctx) 425 return nil 426 } 427 428 if requireWrite { 429 context.CheckRepoScopedToken(ctx, repository, auth_model.Write) 430 } else { 431 context.CheckRepoScopedToken(ctx, repository, auth_model.Read) 432 } 433 434 if ctx.Written() { 435 return nil 436 } 437 438 return repository 439 } 440 441 func buildObjectResponse(rc *requestContext, pointer lfs_module.Pointer, download, upload bool, err *lfs_module.ObjectError) *lfs_module.ObjectResponse { 442 rep := &lfs_module.ObjectResponse{Pointer: pointer} 443 if err != nil { 444 rep.Error = err 445 } else { 446 rep.Actions = make(map[string]*lfs_module.Link) 447 448 header := make(map[string]string) 449 450 if len(rc.Authorization) > 0 { 451 header["Authorization"] = rc.Authorization 452 } 453 454 if download { 455 var link *lfs_module.Link 456 if setting.LFS.Storage.MinioConfig.ServeDirect { 457 // If we have a signed url (S3, object storage), redirect to this directly. 458 u, err := storage.LFS.URL(pointer.RelativePath(), pointer.Oid) 459 if u != nil && err == nil { 460 // Presigned url does not need the Authorization header 461 // https://github.com/go-gitea/gitea/issues/21525 462 delete(header, "Authorization") 463 link = &lfs_module.Link{Href: u.String(), Header: header} 464 } 465 } 466 if link == nil { 467 link = &lfs_module.Link{Href: rc.DownloadLink(pointer), Header: header} 468 } 469 rep.Actions["download"] = link 470 } 471 if upload { 472 rep.Actions["upload"] = &lfs_module.Link{Href: rc.UploadLink(pointer), Header: header} 473 474 verifyHeader := make(map[string]string) 475 for key, value := range header { 476 verifyHeader[key] = value 477 } 478 479 // This is only needed to workaround https://github.com/git-lfs/git-lfs/issues/3662 480 verifyHeader["Accept"] = lfs_module.MediaType 481 482 rep.Actions["verify"] = &lfs_module.Link{Href: rc.VerifyLink(pointer), Header: verifyHeader} 483 } 484 } 485 return rep 486 } 487 488 func writeStatus(ctx *context.Context, status int) { 489 writeStatusMessage(ctx, status, http.StatusText(status)) 490 } 491 492 func writeStatusMessage(ctx *context.Context, status int, message string) { 493 ctx.Resp.Header().Set("Content-Type", lfs_module.MediaType) 494 ctx.Resp.WriteHeader(status) 495 496 er := lfs_module.ErrorResponse{Message: message} 497 498 enc := json.NewEncoder(ctx.Resp) 499 if err := enc.Encode(er); err != nil { 500 log.Error("Failed to encode error response as json. Error: %v", err) 501 } 502 } 503 504 // authenticate uses the authorization string to determine whether 505 // or not to proceed. This server assumes an HTTP Basic auth format. 506 func authenticate(ctx *context.Context, repository *repo_model.Repository, authorization string, requireSigned, requireWrite bool) bool { 507 accessMode := perm.AccessModeRead 508 if requireWrite { 509 accessMode = perm.AccessModeWrite 510 } 511 512 if ctx.Data["IsActionsToken"] == true { 513 taskID := ctx.Data["ActionsTaskID"].(int64) 514 task, err := actions_model.GetTaskByID(ctx, taskID) 515 if err != nil { 516 log.Error("Unable to GetTaskByID for task[%d] Error: %v", taskID, err) 517 return false 518 } 519 if task.RepoID != repository.ID { 520 return false 521 } 522 523 if task.IsForkPullRequest { 524 return accessMode <= perm.AccessModeRead 525 } 526 return accessMode <= perm.AccessModeWrite 527 } 528 529 // ctx.IsSigned is unnecessary here, this will be checked in perm.CanAccess 530 perm, err := access_model.GetUserRepoPermission(ctx, repository, ctx.Doer) 531 if err != nil { 532 log.Error("Unable to GetUserRepoPermission for user %-v in repo %-v Error: %v", ctx.Doer, repository, err) 533 return false 534 } 535 536 canRead := perm.CanAccess(accessMode, unit.TypeCode) 537 if canRead && (!requireSigned || ctx.IsSigned) { 538 return true 539 } 540 541 user, err := parseToken(ctx, authorization, repository, accessMode) 542 if err != nil { 543 // Most of these are Warn level - the true internal server errors are logged in parseToken already 544 log.Warn("Authentication failure for provided token with Error: %v", err) 545 return false 546 } 547 ctx.Doer = user 548 return true 549 } 550 551 func handleLFSToken(ctx stdCtx.Context, tokenSHA string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) { 552 if !strings.Contains(tokenSHA, ".") { 553 return nil, nil 554 } 555 token, err := jwt.ParseWithClaims(tokenSHA, &Claims{}, func(t *jwt.Token) (any, error) { 556 if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { 557 return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) 558 } 559 return setting.LFS.JWTSecretBytes, nil 560 }) 561 if err != nil { 562 return nil, nil 563 } 564 565 claims, claimsOk := token.Claims.(*Claims) 566 if !token.Valid || !claimsOk { 567 return nil, fmt.Errorf("invalid token claim") 568 } 569 570 if claims.RepoID != target.ID { 571 return nil, fmt.Errorf("invalid token claim") 572 } 573 574 if mode == perm.AccessModeWrite && claims.Op != "upload" { 575 return nil, fmt.Errorf("invalid token claim") 576 } 577 578 u, err := user_model.GetUserByID(ctx, claims.UserID) 579 if err != nil { 580 log.Error("Unable to GetUserById[%d]: Error: %v", claims.UserID, err) 581 return nil, err 582 } 583 return u, nil 584 } 585 586 func parseToken(ctx stdCtx.Context, authorization string, target *repo_model.Repository, mode perm.AccessMode) (*user_model.User, error) { 587 if authorization == "" { 588 return nil, fmt.Errorf("no token") 589 } 590 591 parts := strings.SplitN(authorization, " ", 2) 592 if len(parts) != 2 { 593 return nil, fmt.Errorf("no token") 594 } 595 tokenSHA := parts[1] 596 switch strings.ToLower(parts[0]) { 597 case "bearer": 598 fallthrough 599 case "token": 600 return handleLFSToken(ctx, tokenSHA, target, mode) 601 } 602 return nil, fmt.Errorf("token not found") 603 } 604 605 func requireAuth(ctx *context.Context) { 606 ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs") 607 writeStatus(ctx, http.StatusUnauthorized) 608 }