diff options
| -rw-r--r-- | cmd/git-lfs-server/main.go | 321 |
1 files changed, 263 insertions, 58 deletions
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index b886557..fe62724 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
| @@ -4,11 +4,14 @@ import ( | |||
| 4 | "bytes" | 4 | "bytes" |
| 5 | "context" | 5 | "context" |
| 6 | "crypto/ed25519" | 6 | "crypto/ed25519" |
| 7 | "crypto/sha256" | ||
| 7 | "encoding/base64" | 8 | "encoding/base64" |
| 8 | "encoding/hex" | 9 | "encoding/hex" |
| 9 | "encoding/json" | 10 | "encoding/json" |
| 10 | "errors" | 11 | "errors" |
| 11 | "fmt" | 12 | "fmt" |
| 13 | "hash" | ||
| 14 | "io" | ||
| 12 | "mime" | 15 | "mime" |
| 13 | "net/http" | 16 | "net/http" |
| 14 | "net/http/cgi" | 17 | "net/http/cgi" |
| @@ -48,7 +51,7 @@ type batchRef struct { | |||
| 48 | 51 | ||
| 49 | type batchRequestObject struct { | 52 | type batchRequestObject struct { |
| 50 | OID string `json:"oid"` | 53 | OID string `json:"oid"` |
| 51 | Size uint64 `json:"size"` | 54 | Size int64 `json:"size"` |
| 52 | } | 55 | } |
| 53 | 56 | ||
| 54 | type batchRequest struct { | 57 | type batchRequest struct { |
| @@ -75,7 +78,7 @@ type batchError struct { | |||
| 75 | 78 | ||
| 76 | type batchResponseObject struct { | 79 | type batchResponseObject struct { |
| 77 | OID string `json:"oid"` | 80 | OID string `json:"oid"` |
| 78 | Size uint64 `json:"size"` | 81 | Size int64 `json:"size"` |
| 79 | Authenticated *bool `json:"authenticated"` | 82 | Authenticated *bool `json:"authenticated"` |
| 80 | Actions map[operation]batchAction `json:"actions,omitempty"` | 83 | Actions map[operation]batchAction `json:"actions,omitempty"` |
| 81 | Error *batchError `json:"error,omitempty"` | 84 | Error *batchError `json:"error,omitempty"` |
| @@ -92,10 +95,10 @@ type handler struct { | |||
| 92 | bucket string | 95 | bucket string |
| 93 | anonUser string | 96 | anonUser string |
| 94 | gitolitePath string | 97 | gitolitePath string |
| 95 | publicKey ed25519.PublicKey | 98 | privateKey ed25519.PrivateKey |
| 99 | baseURL *url.URL | ||
| 96 | } | 100 | } |
| 97 | 101 | ||
| 98 | // Requires lowercase hash | ||
| 99 | func isValidSHA256Hash(hash string) bool { | 102 | func isValidSHA256Hash(hash string) bool { |
| 100 | if len(hash) != 64 { | 103 | if len(hash) != 64 { |
| 101 | return false | 104 | return false |
| @@ -161,7 +164,7 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
| 161 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { | 164 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { |
| 162 | return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity) | 165 | return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity) |
| 163 | } | 166 | } |
| 164 | if uint64(info.Size) != obj.size { | 167 | if info.Size != obj.size { |
| 165 | return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity) | 168 | return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity) |
| 166 | } | 169 | } |
| 167 | 170 | ||
| @@ -187,42 +190,198 @@ func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj par | |||
| 187 | } | 190 | } |
| 188 | } | 191 | } |
| 189 | 192 | ||
| 190 | func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { | 193 | type uploadObjectGitolfs3Claims struct { |
| 194 | Repository string `json:"repository"` | ||
| 195 | OID string `json:"oid"` | ||
| 196 | Size int64 `json:"size"` | ||
| 197 | } | ||
| 198 | |||
| 199 | type uploadObjectCustomClaims struct { | ||
| 200 | Gitolfs3 uploadObjectGitolfs3Claims `json:"gitolfs3"` | ||
| 201 | *jwt.RegisteredClaims | ||
| 202 | } | ||
| 203 | |||
| 204 | // Return nil when the object already exists | ||
| 205 | func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) *batchResponseObject { | ||
| 191 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | 206 | fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) |
| 207 | _, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.GetObjectOptions{}) | ||
| 208 | if err == nil { | ||
| 209 | // The object exists | ||
| 210 | return nil | ||
| 211 | } | ||
| 212 | |||
| 213 | var resp minio.ErrorResponse | ||
| 214 | if !errors.As(err, &resp) || resp.StatusCode != http.StatusNotFound { | ||
| 215 | // TODO: consider not making this an object-specific, but rather a | ||
| 216 | // generic error such that the entire Batch API request fails. | ||
| 217 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | ||
| 218 | objErr := makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | ||
| 219 | return &objErr | ||
| 220 | } | ||
| 221 | |||
| 192 | expiresIn := time.Hour * 24 | 222 | expiresIn := time.Hour * 24 |
| 223 | claims := uploadObjectCustomClaims{ | ||
| 224 | Gitolfs3: uploadObjectGitolfs3Claims{ | ||
| 225 | Repository: repo, | ||
| 226 | OID: obj.fullHash, | ||
| 227 | Size: obj.size, | ||
| 228 | }, | ||
| 229 | RegisteredClaims: &jwt.RegisteredClaims{ | ||
| 230 | IssuedAt: jwt.NewNumericDate(time.Now()), | ||
| 231 | ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), | ||
| 232 | }, | ||
| 233 | } | ||
| 193 | 234 | ||
| 194 | presigned, err := h.mc.Presign(ctx, http.MethodPut, h.bucket, fullPath, expiresIn, url.Values{ | 235 | token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) |
| 195 | "x-amz-sdk-checksum-algorithm": {"sha256"}, | 236 | ss, err := token.SignedString(h.privateKey) |
| 196 | "x-amz-checksum-sha256": {sha256AsBase64(obj.fullHash)}, | ||
| 197 | "x-amz-content-sha256": {obj.fullHash}, | ||
| 198 | "Content-Length": {strconv.FormatUint(obj.size, 10)}, | ||
| 199 | }) | ||
| 200 | if err != nil { | 237 | if err != nil { |
| 201 | // TODO: consider not making this an object-specific, but rather a | 238 | // TODO: consider not making this an object-specific, but rather a |
| 202 | // generic error such that the entire Batch API request fails. | 239 | // generic error such that the entire Batch API request fails. |
| 203 | reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err) | 240 | reqlog(ctx, "Fatal: failed to generate JWT: %s", err) |
| 204 | return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | 241 | objErr := makeObjError(obj, "Failed to generate token", http.StatusInternalServerError) |
| 242 | return &objErr | ||
| 205 | } | 243 | } |
| 206 | 244 | ||
| 245 | uploadPath := path.Join(repo+".git", "info/lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash) | ||
| 246 | uploadHRef := h.baseURL.ResolveReference(&url.URL{Path: uploadPath}).String() | ||
| 247 | // The object does not exist. | ||
| 207 | authenticated := true | 248 | authenticated := true |
| 208 | return batchResponseObject{ | 249 | return &batchResponseObject{ |
| 209 | OID: obj.fullHash, | 250 | OID: obj.fullHash, |
| 210 | Size: obj.size, | 251 | Size: obj.size, |
| 211 | Authenticated: &authenticated, | 252 | Authenticated: &authenticated, |
| 212 | Actions: map[operation]batchAction{ | 253 | Actions: map[operation]batchAction{ |
| 213 | operationUpload: { | 254 | operationUpload: { |
| 214 | HRef: presigned.String(), | 255 | Header: map[string]string{ |
| 256 | "Authorization": "Bearer " + ss, | ||
| 257 | }, | ||
| 258 | HRef: uploadHRef, | ||
| 215 | ExpiresIn: int64(expiresIn.Seconds()), | 259 | ExpiresIn: int64(expiresIn.Seconds()), |
| 216 | }, | 260 | }, |
| 217 | }, | 261 | }, |
| 218 | } | 262 | } |
| 219 | } | 263 | } |
| 220 | 264 | ||
| 265 | type validatingReader struct { | ||
| 266 | promisedSize int64 | ||
| 267 | promisedSha256 []byte | ||
| 268 | |||
| 269 | reader io.Reader | ||
| 270 | bytesRead int64 | ||
| 271 | current hash.Hash | ||
| 272 | err error | ||
| 273 | } | ||
| 274 | |||
| 275 | func newValidatingReader(promisedSize int64, promisedSha256 []byte, r io.Reader) *validatingReader { | ||
| 276 | return &validatingReader{ | ||
| 277 | promisedSize: promisedSize, | ||
| 278 | promisedSha256: promisedSha256, | ||
| 279 | reader: r, | ||
| 280 | current: sha256.New(), | ||
| 281 | } | ||
| 282 | } | ||
| 283 | |||
| 284 | var errTooBig = errors.New("validator: uploaded file bigger than indicated") | ||
| 285 | var errTooSmall = errors.New("validator: uploaded file smaller than indicated") | ||
| 286 | var errBadSum = errors.New("validator: bad checksum provided or file corrupted") | ||
| 287 | |||
| 288 | func (i *validatingReader) Read(b []byte) (int, error) { | ||
| 289 | if i.err != nil { | ||
| 290 | return 0, i.err | ||
| 291 | } | ||
| 292 | n, err := i.reader.Read(b) | ||
| 293 | i.bytesRead += int64(n) | ||
| 294 | if i.bytesRead > i.promisedSize { | ||
| 295 | i.err = errTooBig | ||
| 296 | return 0, i.err | ||
| 297 | } | ||
| 298 | if err != nil && errors.Is(err, io.EOF) { | ||
| 299 | if i.bytesRead < i.promisedSize { | ||
| 300 | i.err = errTooSmall | ||
| 301 | return n, i.err | ||
| 302 | } | ||
| 303 | } | ||
| 304 | // According to the documentation, Hash.Write never returns an error | ||
| 305 | i.current.Write(b[:n]) | ||
| 306 | if i.bytesRead == i.promisedSize { | ||
| 307 | if !bytes.Equal(i.promisedSha256, i.current.Sum(nil)) { | ||
| 308 | i.err = errBadSum | ||
| 309 | return 0, i.err | ||
| 310 | } | ||
| 311 | } | ||
| 312 | return n, err | ||
| 313 | } | ||
| 314 | |||
| 315 | func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, oid string) { | ||
| 316 | ctx := r.Context() | ||
| 317 | |||
| 318 | authz := r.Header.Get("Authorization") | ||
| 319 | if authz == "" { | ||
| 320 | makeRespError(ctx, w, "Missing Authorization header", http.StatusBadRequest) | ||
| 321 | return | ||
| 322 | } | ||
| 323 | if !strings.HasPrefix(authz, "Bearer ") { | ||
| 324 | makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest) | ||
| 325 | return | ||
| 326 | } | ||
| 327 | authz = strings.TrimPrefix(authz, "Bearer ") | ||
| 328 | |||
| 329 | var claims uploadObjectCustomClaims | ||
| 330 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | ||
| 331 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | ||
| 332 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | ||
| 333 | } | ||
| 334 | return h.privateKey.Public(), nil | ||
| 335 | }) | ||
| 336 | if err != nil { | ||
| 337 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 338 | return | ||
| 339 | } | ||
| 340 | if claims.Gitolfs3.Repository != repo { | ||
| 341 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 342 | return | ||
| 343 | } | ||
| 344 | if claims.Gitolfs3.OID != oid { | ||
| 345 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 346 | return | ||
| 347 | } | ||
| 348 | |||
| 349 | // Check with claims | ||
| 350 | if lengthStr := r.Header.Get("Content-Length"); lengthStr != "" { | ||
| 351 | length, err := strconv.ParseInt(lengthStr, 10, 64) | ||
| 352 | if err != nil { | ||
| 353 | makeRespError(ctx, w, "Bad Content-Length format", http.StatusBadRequest) | ||
| 354 | return | ||
| 355 | } | ||
| 356 | if length != claims.Gitolfs3.Size { | ||
| 357 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | ||
| 358 | return | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | sha256Raw, err := hex.DecodeString(oid) | ||
| 363 | if err != nil || len(sha256Raw) != sha256.Size { | ||
| 364 | makeRespError(ctx, w, "Invalid OID", http.StatusBadRequest) | ||
| 365 | return | ||
| 366 | } | ||
| 367 | |||
| 368 | reader := newValidatingReader(claims.Gitolfs3.Size, sha256Raw, r.Body) | ||
| 369 | |||
| 370 | fullPath := path.Join(repo+".git", "lfs/objects", oid[:2], oid[2:4], oid) | ||
| 371 | _, err = h.mc.PutObject(ctx, h.bucket, fullPath, reader, int64(claims.Gitolfs3.Size), minio.PutObjectOptions{ | ||
| 372 | SendContentMd5: true, | ||
| 373 | }) | ||
| 374 | if err != nil { | ||
| 375 | makeRespError(ctx, w, "Failed to upload object", http.StatusInternalServerError) | ||
| 376 | return | ||
| 377 | } | ||
| 378 | } | ||
| 379 | |||
| 221 | type parsedBatchObject struct { | 380 | type parsedBatchObject struct { |
| 222 | firstByte string | 381 | firstByte string |
| 223 | secondByte string | 382 | secondByte string |
| 224 | fullHash string | 383 | fullHash string |
| 225 | size uint64 | 384 | size int64 |
| 226 | } | 385 | } |
| 227 | 386 | ||
| 228 | func isLFSMediaType(t string) bool { | 387 | func isLFSMediaType(t string) bool { |
| @@ -236,20 +395,21 @@ func isLFSMediaType(t string) bool { | |||
| 236 | return false | 395 | return false |
| 237 | } | 396 | } |
| 238 | 397 | ||
| 239 | var re = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`) | 398 | var reBatchAPI = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`) |
| 399 | var reObjUpload = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/([0-9a-f]{2})/([0-9a-f]{2})/([0-9a-f]{2}){64}$`) | ||
| 240 | 400 | ||
| 241 | type requestID struct{} | 401 | type requestID struct{} |
| 242 | 402 | ||
| 243 | var requestIDKey requestID | 403 | var requestIDKey requestID |
| 244 | 404 | ||
| 245 | // TODO: make a shared package for this | 405 | // TODO: make a shared package for this |
| 246 | type gitolfs3Claims struct { | 406 | type lfsAuthGitolfs3Claims struct { |
| 247 | Repository string `json:"repository"` | 407 | Repository string `json:"repository"` |
| 248 | Permission operation `json:"permission"` | 408 | Permission operation `json:"permission"` |
| 249 | } | 409 | } |
| 250 | 410 | ||
| 251 | type customClaims struct { | 411 | type lfsAuthCustomClaims struct { |
| 252 | Gitolfs3 gitolfs3Claims `json:"gitolfs3"` | 412 | Gitolfs3 lfsAuthGitolfs3Claims `json:"gitolfs3"` |
| 253 | *jwt.RegisteredClaims | 413 | *jwt.RegisteredClaims |
| 254 | } | 414 | } |
| 255 | 415 | ||
| @@ -278,8 +438,9 @@ func (h *handler) getGitoliteAccess(repo, user, gitolitePerm string, refspec *st | |||
| 278 | return true, nil | 438 | return true, nil |
| 279 | } | 439 | } |
| 280 | 440 | ||
| 281 | func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http.Request, or operationRequest) bool { | 441 | func (h *handler) authorize(w http.ResponseWriter, r *http.Request, or operationRequest) bool { |
| 282 | user := h.anonUser | 442 | user := h.anonUser |
| 443 | ctx := r.Context() | ||
| 283 | 444 | ||
| 284 | if authz := r.Header.Get("Authorization"); authz != "" { | 445 | if authz := r.Header.Get("Authorization"); authz != "" { |
| 285 | if !strings.HasPrefix(authz, "Bearer ") { | 446 | if !strings.HasPrefix(authz, "Bearer ") { |
| @@ -288,12 +449,12 @@ func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http. | |||
| 288 | } | 449 | } |
| 289 | authz = strings.TrimPrefix(authz, "Bearer ") | 450 | authz = strings.TrimPrefix(authz, "Bearer ") |
| 290 | 451 | ||
| 291 | var claims customClaims | 452 | var claims lfsAuthCustomClaims |
| 292 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { | 453 | _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) { |
| 293 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { | 454 | if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok { |
| 294 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) | 455 | return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"]) |
| 295 | } | 456 | } |
| 296 | return h.publicKey, nil | 457 | return h.privateKey.Public(), nil |
| 297 | }) | 458 | }) |
| 298 | if err != nil { | 459 | if err != nil { |
| 299 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) | 460 | makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized) |
| @@ -339,29 +500,8 @@ func (h *handler) authorize(ctx context.Context, w http.ResponseWriter, r *http. | |||
| 339 | return true | 500 | return true |
| 340 | } | 501 | } |
| 341 | 502 | ||
| 342 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 503 | func (h *handler) handleBatchAPI(w http.ResponseWriter, r *http.Request, repo string) { |
| 343 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) | 504 | ctx := r.Context() |
| 344 | |||
| 345 | if r.Method != http.MethodPost { | ||
| 346 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | ||
| 347 | return | ||
| 348 | } | ||
| 349 | |||
| 350 | reqPath := os.Getenv("PATH_INFO") | ||
| 351 | if reqPath == "" { | ||
| 352 | reqPath = r.URL.Path | ||
| 353 | } | ||
| 354 | reqlog(ctx, "reqPath: %s", reqPath) | ||
| 355 | reqPath = strings.TrimPrefix(path.Clean(reqPath), "/") | ||
| 356 | reqlog(ctx, "Cleaned reqPath: %s", reqPath) | ||
| 357 | submatches := re.FindStringSubmatch(reqPath) | ||
| 358 | if len(submatches) != 2 { | ||
| 359 | reqlog(ctx, "Got path: %s, did not match regex", reqPath) | ||
| 360 | makeRespError(ctx, w, "Not found", http.StatusNotFound) | ||
| 361 | return | ||
| 362 | } | ||
| 363 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | ||
| 364 | reqlog(ctx, "Repository: %s", repo) | ||
| 365 | 505 | ||
| 366 | if !slices.ContainsFunc(r.Header.Values("Accept"), isLFSMediaType) { | 506 | if !slices.ContainsFunc(r.Header.Values("Accept"), isLFSMediaType) { |
| 367 | makeRespError(ctx, w, "Expected "+lfsMIME+" (with UTF-8 charset) in list of acceptable response media types", http.StatusNotAcceptable) | 507 | makeRespError(ctx, w, "Expected "+lfsMIME+" (with UTF-8 charset) in list of acceptable response media types", http.StatusNotAcceptable) |
| @@ -389,7 +529,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 389 | if body.Ref != nil { | 529 | if body.Ref != nil { |
| 390 | or.refspec = &body.Ref.Name | 530 | or.refspec = &body.Ref.Name |
| 391 | } | 531 | } |
| 392 | if !h.authorize(ctx, w, r, or) { | 532 | if !h.authorize(w, r.WithContext(ctx), or) { |
| 393 | return | 533 | return |
| 394 | } | 534 | } |
| 395 | 535 | ||
| @@ -427,7 +567,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 427 | case operationDownload: | 567 | case operationDownload: |
| 428 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) | 568 | resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj)) |
| 429 | case operationUpload: | 569 | case operationUpload: |
| 430 | resp.Objects = append(resp.Objects, h.handleUploadObject(ctx, repo, obj)) | 570 | if respObj := h.handleUploadObject(ctx, repo, obj); respObj != nil { |
| 571 | resp.Objects = append(resp.Objects, *respObj) | ||
| 572 | } | ||
| 431 | } | 573 | } |
| 432 | } | 574 | } |
| 433 | 575 | ||
| @@ -436,6 +578,53 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 436 | json.NewEncoder(w).Encode(resp) | 578 | json.NewEncoder(w).Encode(resp) |
| 437 | } | 579 | } |
| 438 | 580 | ||
| 581 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 582 | ctx := context.WithValue(r.Context(), requestIDKey, xid.New().String()) | ||
| 583 | |||
| 584 | reqPath := os.Getenv("PATH_INFO") | ||
| 585 | if reqPath == "" { | ||
| 586 | reqPath = r.URL.Path | ||
| 587 | } | ||
| 588 | reqPath = strings.TrimPrefix(path.Clean(reqPath), "/") | ||
| 589 | |||
| 590 | if submatches := reBatchAPI.FindStringSubmatch(reqPath); len(submatches) == 2 { | ||
| 591 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | ||
| 592 | reqlog(ctx, "Repository: %s", repo) | ||
| 593 | |||
| 594 | if r.Method != http.MethodPost { | ||
| 595 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | ||
| 596 | return | ||
| 597 | } | ||
| 598 | |||
| 599 | h.handleBatchAPI(w, r.WithContext(ctx), repo) | ||
| 600 | return | ||
| 601 | } | ||
| 602 | |||
| 603 | if submatches := reObjUpload.FindStringSubmatch(reqPath); len(submatches) == 5 { | ||
| 604 | repo := strings.TrimPrefix(path.Clean(submatches[1]), "/") | ||
| 605 | oid0, oid1, oid := submatches[2], submatches[3], submatches[4] | ||
| 606 | |||
| 607 | if !isValidSHA256Hash(oid) { | ||
| 608 | panic("Regex should only allow valid SHA256 hashes") | ||
| 609 | } | ||
| 610 | if oid0 != oid[:2] || oid1 != oid[2:4] { | ||
| 611 | makeRespError(ctx, w, "Bad URL format: malformed OID pattern", http.StatusBadRequest) | ||
| 612 | return | ||
| 613 | } | ||
| 614 | reqlog(ctx, "Repository: %s; OID: %s", repo, oid) | ||
| 615 | |||
| 616 | if r.Method != http.MethodPost { | ||
| 617 | makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed) | ||
| 618 | return | ||
| 619 | } | ||
| 620 | |||
| 621 | h.handleBatchAPI(w, r.WithContext(ctx), repo) | ||
| 622 | return | ||
| 623 | } | ||
| 624 | |||
| 625 | makeRespError(ctx, w, "Not found", http.StatusNotFound) | ||
| 626 | } | ||
| 627 | |||
| 439 | func reqlog(ctx context.Context, msg string, args ...any) { | 628 | func reqlog(ctx context.Context, msg string, args ...any) { |
| 440 | fmt.Fprint(os.Stderr, "[gitolfs3] ") | 629 | fmt.Fprint(os.Stderr, "[gitolfs3] ") |
| 441 | if val := ctx.Value(requestIDKey); val != nil { | 630 | if val := ctx.Value(requestIDKey); val != nil { |
| @@ -460,31 +649,38 @@ func die(msg string, args ...any) { | |||
| 460 | os.Exit(1) | 649 | os.Exit(1) |
| 461 | } | 650 | } |
| 462 | 651 | ||
| 463 | func loadPublicKey(path string) ed25519.PublicKey { | 652 | func loadPrivateKey(path string) ed25519.PrivateKey { |
| 464 | raw, err := os.ReadFile(path) | 653 | raw, err := os.ReadFile(path) |
| 465 | if err != nil { | 654 | if err != nil { |
| 466 | die("Failed to open specified public key: %s", err) | 655 | die("Failed to open specified public key: %s", err) |
| 467 | } | 656 | } |
| 468 | raw = bytes.TrimSpace(raw) | 657 | raw = bytes.TrimSpace(raw) |
| 469 | 658 | ||
| 470 | if hex.DecodedLen(len(raw)) != ed25519.PublicKeySize { | 659 | if hex.DecodedLen(len(raw)) != ed25519.SeedSize { |
| 471 | die("Specified public key file does not contain key of appropriate length") | 660 | die("Specified public key file does not contain key (seed) of appropriate length") |
| 472 | } | 661 | } |
| 473 | decoded := make([]byte, hex.DecodedLen(len(raw))) | 662 | decoded := make([]byte, hex.DecodedLen(len(raw))) |
| 474 | if _, err = hex.Decode(decoded, raw); err != nil { | 663 | if _, err = hex.Decode(decoded, raw); err != nil { |
| 475 | die("Failed to decode specified public key: %s", err) | 664 | die("Failed to decode specified public key: %s", err) |
| 476 | } | 665 | } |
| 477 | return decoded | 666 | return ed25519.NewKeyFromSeed(decoded) |
| 667 | } | ||
| 668 | |||
| 669 | func wipe(b []byte) { | ||
| 670 | for i := range b { | ||
| 671 | b[i] = 0 | ||
| 672 | } | ||
| 478 | } | 673 | } |
| 479 | 674 | ||
| 480 | func main() { | 675 | func main() { |
| 481 | anonUser := os.Getenv("ANON_USER") | 676 | anonUser := os.Getenv("ANON_USER") |
| 482 | publicKeyPath := os.Getenv("GITOLFS3_PUBLIC_KEY_PATH") | 677 | privateKeyPath := os.Getenv("GITOLFS3_PRIVATE_KEY_PATH") |
| 483 | endpoint := os.Getenv("S3_ENDPOINT") | 678 | endpoint := os.Getenv("S3_ENDPOINT") |
| 484 | bucket := os.Getenv("S3_BUCKET") | 679 | bucket := os.Getenv("S3_BUCKET") |
| 485 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") | 680 | accessKeyIDFile := os.Getenv("S3_ACCESS_KEY_ID_FILE") |
| 486 | secretAccessKeyFile := os.Getenv("S3_SECRET_ACCESS_KEY_FILE") | 681 | secretAccessKeyFile := os.Getenv("S3_SECRET_ACCESS_KEY_FILE") |
| 487 | gitolitePath := os.Getenv("GITOLITE_PATH") | 682 | gitolitePath := os.Getenv("GITOLITE_PATH") |
| 683 | baseURLStr := os.Getenv("BASE_URL") | ||
| 488 | 684 | ||
| 489 | if gitolitePath == "" { | 685 | if gitolitePath == "" { |
| 490 | gitolitePath = "gitolite" | 686 | gitolitePath = "gitolite" |
| @@ -493,8 +689,11 @@ func main() { | |||
| 493 | if anonUser == "" { | 689 | if anonUser == "" { |
| 494 | die("Fatal: expected environment variable ANON_USER to be set") | 690 | die("Fatal: expected environment variable ANON_USER to be set") |
| 495 | } | 691 | } |
| 496 | if publicKeyPath == "" { | 692 | if privateKeyPath == "" { |
| 497 | die("Fatal: expected environment variable GITOLFS3_PUBLIC_KEY_PATH to be set") | 693 | die("Fatal: expected environment variable GITOLFS3_PRIVATE_KEY_PATH to be set") |
| 694 | } | ||
| 695 | if baseURLStr == "" { | ||
| 696 | die("Fatal: expected environment variable BASE_URL to be set") | ||
| 498 | } | 697 | } |
| 499 | if endpoint == "" { | 698 | if endpoint == "" { |
| 500 | die("Fatal: expected environment variable S3_ENDPOINT to be set") | 699 | die("Fatal: expected environment variable S3_ENDPOINT to be set") |
| @@ -519,7 +718,13 @@ func main() { | |||
| 519 | die("Fatal: failed to read secret access key from specified file: %s", err) | 718 | die("Fatal: failed to read secret access key from specified file: %s", err) |
| 520 | } | 719 | } |
| 521 | 720 | ||
| 522 | publicKey := loadPublicKey(publicKeyPath) | 721 | privateKey := loadPrivateKey(privateKeyPath) |
| 722 | defer wipe(privateKey) | ||
| 723 | |||
| 724 | baseURL, err := url.Parse(baseURLStr) | ||
| 725 | if err != nil { | ||
| 726 | die("Fatal: provided BASE_URL has bad format: %s", err) | ||
| 727 | } | ||
| 523 | 728 | ||
| 524 | mc, err := minio.New(endpoint, &minio.Options{ | 729 | mc, err := minio.New(endpoint, &minio.Options{ |
| 525 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), | 730 | Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""), |
| @@ -529,7 +734,7 @@ func main() { | |||
| 529 | die("Fatal: failed to create S3 client: %s", err) | 734 | die("Fatal: failed to create S3 client: %s", err) |
| 530 | } | 735 | } |
| 531 | 736 | ||
| 532 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath, publicKey}); err != nil { | 737 | if err = cgi.Serve(&handler{mc, bucket, anonUser, gitolitePath, privateKey, baseURL}); err != nil { |
| 533 | die("Fatal: failed to serve CGI: %s", err) | 738 | die("Fatal: failed to serve CGI: %s", err) |
| 534 | } | 739 | } |
| 535 | } | 740 | } |