From f467b896d81f8a689f43dd494330ea32cf1810d2 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Mon, 29 Dec 2025 13:39:54 +0400 Subject: [PATCH] feat: adds Location in CompleteMultipartUpload response Closes #1714 There is a `Location` field in the `CompleteMultipartUpload` result that represents the newly created object URL. This PR adds this property to the `CompleteMultipartUpload` response, generating it dynamically in either host-style or path-style format, depending on the gateway configuration. --- s3api/controllers/base.go | 28 +++++++++++--------- s3api/controllers/object-post.go | 4 +++ s3api/controllers/object-post_test.go | 3 ++- s3api/router.go | 4 +-- s3api/router_test.go | 2 +- s3api/server.go | 2 +- s3api/utils/utils.go | 23 ++++++++++++++++ tests/integration/CompleteMultipartUpload.go | 7 +++++ tests/integration/utils.go | 22 +++++++++++++++ 9 files changed, 77 insertions(+), 18 deletions(-) diff --git a/s3api/controllers/base.go b/s3api/controllers/base.go index 953ee33..fabf8d7 100644 --- a/s3api/controllers/base.go +++ b/s3api/controllers/base.go @@ -31,12 +31,13 @@ import ( ) type S3ApiController struct { - be backend.Backend - iam auth.IAMService - logger s3log.AuditLogger - evSender s3event.S3EventSender - mm metrics.Manager - readonly bool + be backend.Backend + iam auth.IAMService + logger s3log.AuditLogger + evSender s3event.S3EventSender + mm metrics.Manager + readonly bool + virtualDomain string } const ( @@ -56,14 +57,15 @@ var ( xmlhdr = []byte(`` + "\n") ) -func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly bool) S3ApiController { +func New(be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly bool, virtualDomain string) S3ApiController { return S3ApiController{ - be: be, - iam: iam, - logger: logger, - evSender: evs, - readonly: readonly, - mm: mm, + be: be, + iam: iam, + logger: logger, + evSender: evs, + readonly: readonly, + mm: mm, + virtualDomain: virtualDomain, } } diff --git a/s3api/controllers/object-post.go b/s3api/controllers/object-post.go index be4e363..aa17b73 100644 --- a/s3api/controllers/object-post.go +++ b/s3api/controllers/object-post.go @@ -352,6 +352,10 @@ func (c S3ApiController) CompleteMultipartUpload(ctx *fiber.Ctx) (*Response, err IfMatch: ifMatch, IfNoneMatch: ifNoneMatch, }) + if err == nil { + objUrl := utils.GenerateObjectLocation(ctx, c.virtualDomain, bucket, key) + res.Location = &objUrl + } return &Response{ Data: res, Headers: map[string]*string{ diff --git a/s3api/controllers/object-post_test.go b/s3api/controllers/object-post_test.go index 86475d1..89568f4 100644 --- a/s3api/controllers/object-post_test.go +++ b/s3api/controllers/object-post_test.go @@ -536,7 +536,8 @@ func TestS3ApiController_CompleteMultipartUpload(t *testing.T) { output: testOutput{ response: &Response{ Data: s3response.CompleteMultipartUploadResult{ - ETag: &ETag, + ETag: &ETag, + Location: utils.GetStringPtr("http://example.com/bucket/object"), }, Headers: map[string]*string{ "x-amz-version-id": &versionId, diff --git a/s3api/router.go b/s3api/router.go index fcde5d4..9e9a591 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -30,8 +30,8 @@ type S3ApiRouter struct { WithAdmSrv bool } -func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, aLogger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly bool, region string, root middlewares.RootUserConfig) { - ctrl := controllers.New(be, iam, logger, evs, mm, readonly) +func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMService, logger s3log.AuditLogger, aLogger s3log.AuditLogger, evs s3event.S3EventSender, mm metrics.Manager, readonly bool, region, virtualDomain string, root middlewares.RootUserConfig) { + ctrl := controllers.New(be, iam, logger, evs, mm, readonly, virtualDomain) adminServices := &controllers.Services{ Logger: aLogger, } diff --git a/s3api/router_test.go b/s3api/router_test.go index 003a0e0..4205f12 100644 --- a/s3api/router_test.go +++ b/s3api/router_test.go @@ -46,7 +46,7 @@ func TestS3ApiRouter_Init(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tt.sa.Init(tt.args.app, tt.args.be, tt.args.iam, nil, nil, nil, nil, false, "us-east-1", middlewares.RootUserConfig{}) + tt.sa.Init(tt.args.app, tt.args.be, tt.args.iam, nil, nil, nil, nil, false, "us-east-1", "", middlewares.RootUserConfig{}) }) } } diff --git a/s3api/server.go b/s3api/server.go index a69e7c6..16e6038 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -123,7 +123,7 @@ func New( app.Use(middlewares.DebugLogger()) } - server.router.Init(app, be, iam, l, adminLogger, evs, mm, server.readonly, region, root) + server.router.Init(app, be, iam, l, adminLogger, evs, mm, server.readonly, region, server.virtualDomain, root) return server, nil } diff --git a/s3api/utils/utils.go b/s3api/utils/utils.go index e5c3f96..3e14f65 100644 --- a/s3api/utils/utils.go +++ b/s3api/utils/utils.go @@ -887,3 +887,26 @@ func ValidateVersionId(versionId string) error { return nil } + +// GenerateObjectLocation generates the object location path-styled or host-styled +// depending on the gateway configuration +func GenerateObjectLocation(ctx *fiber.Ctx, virtualDomain, bucket, object string) string { + scheme := ctx.Protocol() + host := ctx.Hostname() + + // escape the object name + obj := url.PathEscape(object) + + if virtualDomain != "" && strings.Contains(host, virtualDomain) { + // the host already contains the bucket name + return fmt.Sprintf("%s://%s/%s", scheme, host, obj) + } + + return fmt.Sprintf( + "%s://%s/%s/%s", + scheme, + host, + bucket, + obj, + ) +} diff --git a/tests/integration/CompleteMultipartUpload.go b/tests/integration/CompleteMultipartUpload.go index 36e7e68..09a7b0c 100644 --- a/tests/integration/CompleteMultipartUpload.go +++ b/tests/integration/CompleteMultipartUpload.go @@ -1631,6 +1631,13 @@ func CompleteMultipartUpload_success(s *S3Conf) error { if getString(res.Key) != obj { return fmt.Errorf("expected object key to be %v, instead got %v", obj, *res.Key) } + location := constructObjectLocation(s.endpoint, bucket, obj, s.hostStyle) + if res.Location == nil { + return fmt.Errorf("expected non nil Location") + } + if *res.Location != location { + return fmt.Errorf("expected Location to be %s, instead got %s", location, *res.Location) + } ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) resp, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ diff --git a/tests/integration/utils.go b/tests/integration/utils.go index c6a710e..f88504d 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -480,6 +480,28 @@ func listObjects(client *s3.Client, bucket, prefix, delimiter string, maxKeys in return contents, commonPrefixes, nil } +func constructObjectLocation(endpoint, bucket, object string, hostStyle bool) string { + // Normalize endpoint (no trailing slash) + endpoint = strings.TrimRight(endpoint, "/") + + if !hostStyle { + // Path-style: http://endpoint/bucket/object + return fmt.Sprintf("%s/%s/%s", endpoint, bucket, object) + } + + // Host-style: http://bucket.endpoint/object + u, err := url.Parse(endpoint) + if err != nil || u.Host == "" { + // Fallback for raw host:port endpoints (e.g. "127.0.0.1:7070") + return fmt.Sprintf("http://%s.%s/%s", bucket, endpoint, object) + } + + host := u.Host + u.Host = fmt.Sprintf("%s.%s", bucket, host) + + return fmt.Sprintf("%s/%s", u.String(), object) +} + func hasObjNames(objs []types.Object, names []string) bool { if len(objs) != len(names) { return false