Merge pull request #1865 from versity/sis/obj-default-content-type

fix: fixes object default Content-Type
This commit is contained in:
Ben McClelland
2026-02-18 09:31:57 -08:00
committed by GitHub
10 changed files with 164 additions and 17 deletions
+1 -10
View File
@@ -352,10 +352,6 @@ func (az *Azure) PutObject(ctx context.Context, po s3response.PutObjectInput) (s
opts.HTTPHeaders.BlobContentType = po.ContentType
}
if opts.HTTPHeaders.BlobContentType == nil {
opts.HTTPHeaders.BlobContentType = backend.GetPtrFromString(backend.DefaultContentType)
}
uploadResp, err := az.client.UploadStream(ctx, *po.Bucket, *po.Key, po.Body, opts)
if err != nil {
return s3response.PutObjectOutput{}, azureErrToS3Err(err)
@@ -487,16 +483,11 @@ func (az *Azure) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.G
tagcount = int32(*blobDownloadResponse.TagCount)
}
contentType := blobDownloadResponse.ContentType
if contentType == nil {
contentType = backend.GetPtrFromString(backend.DefaultContentType)
}
return &s3.GetObjectOutput{
AcceptRanges: backend.GetPtrFromString("bytes"),
ContentLength: blobDownloadResponse.ContentLength,
ContentEncoding: blobDownloadResponse.ContentEncoding,
ContentType: contentType,
ContentType: blobDownloadResponse.ContentType,
ContentDisposition: blobDownloadResponse.ContentDisposition,
ContentLanguage: blobDownloadResponse.ContentLanguage,
CacheControl: blobDownloadResponse.CacheControl,
+1 -2
View File
@@ -38,8 +38,7 @@ import (
const (
// this is the media type for directories in AWS and Nextcloud
DirContentType = "application/x-directory"
DefaultContentType = "binary/octet-stream"
DirContentType = "application/x-directory"
// this is the minimum allowed size for mp parts
MinPartSize = 5 * 1024 * 1024
+2 -1
View File
@@ -51,7 +51,8 @@ const (
minPartNumber = 1
maxPartNumber = 10000
defaultRegion = "us-east-1"
defaultRegion = "us-east-1"
defaultContentType = "binary/octet-stream"
)
var (
+1 -1
View File
@@ -145,7 +145,7 @@ func (c S3ApiController) SelectObjectContent(ctx *fiber.Ctx) (*Response, error)
func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error) {
bucket := ctx.Params("bucket")
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
contentType := ctx.Get("Content-Type")
contentType := ctx.Get("Content-Type", defaultContentType)
contentDisposition := ctx.Get("Content-Disposition")
contentLanguage := ctx.Get("Content-Language")
cacheControl := ctx.Get("Cache-Control")
+2 -2
View File
@@ -497,7 +497,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) {
copySource := strings.TrimPrefix(ctx.Get("X-Amz-Copy-Source"), "/")
metaDirective := types.MetadataDirective(ctx.Get("X-Amz-Metadata-Directive", string(types.MetadataDirectiveCopy)))
taggingDirective := types.TaggingDirective(ctx.Get("X-Amz-Tagging-Directive", string(types.TaggingDirectiveCopy)))
contentType := ctx.Get("Content-Type")
contentType := ctx.Get("Content-Type", defaultContentType)
contentEncoding := ctx.Get("Content-Encoding")
contentDisposition := ctx.Get("Content-Disposition")
contentLanguage := ctx.Get("Content-Language")
@@ -655,7 +655,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) {
func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) {
bucket := ctx.Params("bucket")
key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket))
contentType := ctx.Get("Content-Type")
contentType := ctx.Get("Content-Type", defaultContentType)
contentEncoding := ctx.Get("Content-Encoding")
contentDisposition := ctx.Get("Content-Disposition")
contentLanguage := ctx.Get("Content-Language")
@@ -1607,6 +1607,57 @@ func CompleteMultipartUpload_invalid_part_number(s *S3Conf) error {
})
}
func CompleteMultipartUpload_defualt_content_type(s *S3Conf) error {
testName := "CompleteMultipartUpload_defualt_content_type"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
mp, err := createMp(s3client, bucket, obj)
if err != nil {
return err
}
parts, _, err := uploadParts(s3client, 10, 1, bucket, obj, *mp.UploadId)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: &bucket,
Key: &obj,
UploadId: mp.UploadId,
MultipartUpload: &types.CompletedMultipartUpload{
Parts: []types.CompletedPart{
{
ETag: parts[0].ETag,
PartNumber: parts[0].PartNumber,
},
},
},
})
cancel()
if err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.GetObject(ctx, &s3.GetObjectInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
if err != nil {
return err
}
if getString(res.ContentType) != defaultContentType {
return fmt.Errorf("expected default %s Content-Type, instead got %s", defaultContentType, getString(res.ContentType))
}
return nil
})
}
func CompleteMultipartUpload_success(s *S3Conf) error {
testName := "CompleteMultipartUpload_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+44
View File
@@ -746,6 +746,49 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error {
})
}
func CopyObject_default_content_type_with_replace_metadata(s *S3Conf) error {
testName := "CopyObject_default_content_type_with_replace_metadata"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
srcObj, dstObj := "source-object", "destination-object"
_, err := putObjectWithData(1, &s3.PutObjectInput{
Bucket: &bucket,
Key: &srcObj,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{
Bucket: &bucket,
Key: &dstObj,
CopySource: getPtr(fmt.Sprintf("%s/%s", bucket, srcObj)),
// with metadata directive REPLACE, Content-Type should default to binary/octet-stream
MetadataDirective: types.MetadataDirectiveReplace,
})
cancel()
if err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &bucket,
Key: &dstObj,
})
cancel()
if err != nil {
return err
}
if getString(res.ContentType) != defaultContentType {
return fmt.Errorf("expected default %s Content-Type, instead got %s", defaultContentType, getString(res.ContentType))
}
return nil
})
}
func CopyObject_missing_bucket_lock(s *S3Conf) error {
testName := "CopyObject_missing_bucket_lock"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -808,6 +851,7 @@ func CopyObject_invalid_legal_hold(s *S3Conf) error {
return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidLegalHoldStatus))
}, withLock())
}
func CopyObject_invalid_object_lock_mode(s *S3Conf) error {
testName := "CopyObject_invalid_object_lock_mode"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+31
View File
@@ -913,6 +913,37 @@ func PutObject_success(s *S3Conf) error {
})
}
func PutObject_default_content_type(s *S3Conf) error {
testName := "PutObject_default_content_type"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
key := "my-object"
_, err := putObjectWithData(10, &s3.PutObjectInput{
Bucket: &bucket,
Key: &key,
ContentType: nil,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &bucket,
Key: &key,
})
cancel()
if err != nil {
return err
}
if getString(res.ContentType) != defaultContentType {
return fmt.Errorf("expected default %s Content-Type, instead got %s", defaultContentType, getString(res.ContentType))
}
return nil
})
}
func PutObject_invalid_credentials(s *S3Conf) error {
testName := "PutObject_invalid_credentials"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+6
View File
@@ -183,6 +183,7 @@ func TestPutObject(ts *TestState) {
ts.Run(PutObject_with_metadata)
}
ts.Run(PutObject_success)
ts.Run(PutObject_default_content_type)
if !ts.conf.versioningEnabled {
ts.Run(PutObject_racey_success)
}
@@ -330,6 +331,7 @@ func TestCopyObject(ts *TestState) {
ts.Run(CopyObject_non_existing_dir_object)
ts.Run(CopyObject_should_copy_meta_props)
ts.Run(CopyObject_should_replace_meta_props)
ts.Run(CopyObject_default_content_type_with_replace_metadata)
ts.Run(CopyObject_missing_bucket_lock)
ts.Run(CopyObject_invalid_legal_hold)
ts.Run(CopyObject_invalid_object_lock_mode)
@@ -487,6 +489,7 @@ func TestCompleteMultipartUpload(ts *TestState) {
ts.Run(CompletedMultipartUpload_non_existing_bucket)
ts.Run(CompleteMultipartUpload_incorrect_part_number)
ts.Run(CompleteMultipartUpload_invalid_part_number)
ts.Run(CompleteMultipartUpload_defualt_content_type)
ts.Run(CompleteMultipartUpload_invalid_ETag)
ts.Run(CompleteMultipartUpload_small_upload_size)
ts.Run(CompleteMultipartUpload_empty_parts)
@@ -1292,6 +1295,7 @@ func GetIntTests() IntTests {
"PutObject_special_chars": PutObject_special_chars,
"PutObject_tagging": PutObject_tagging,
"PutObject_success": PutObject_success,
"PutObject_default_content_type": PutObject_default_content_type,
"PutObject_invalid_object_names": PutObject_invalid_object_names,
"PutObject_object_acl_not_supported": PutObject_object_acl_not_supported,
"PutObject_false_negative_object_names": PutObject_false_negative_object_names,
@@ -1392,6 +1396,7 @@ func GetIntTests() IntTests {
"CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object,
"CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props,
"CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props,
"CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata,
"CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock,
"CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold,
"CopyObject_invalid_object_lock_mode": CopyObject_invalid_object_lock_mode,
@@ -1497,6 +1502,7 @@ func GetIntTests() IntTests {
"AbortMultipartUpload_if_match_initiated_time": AbortMultipartUpload_if_match_initiated_time,
"CompletedMultipartUpload_non_existing_bucket": CompletedMultipartUpload_non_existing_bucket,
"CompleteMultipartUpload_invalid_part_number": CompleteMultipartUpload_invalid_part_number,
"CompleteMultipartUpload_defualt_content_type": CompleteMultipartUpload_defualt_content_type,
"CompleteMultipartUpload_invalid_ETag": CompleteMultipartUpload_invalid_ETag,
"CompleteMultipartUpload_small_upload_size": CompleteMultipartUpload_small_upload_size,
"CompleteMultipartUpload_empty_parts": CompleteMultipartUpload_empty_parts,
+25 -1
View File
@@ -49,6 +49,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"github.com/aws/smithy-go/middleware"
smithyhttp "github.com/aws/smithy-go/transport/http"
"github.com/versity/versitygw/s3err"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
@@ -560,7 +562,29 @@ func putObjectWithData(lgth int64, input *s3.PutObjectInput, client *s3.Client)
}
ctx, cancel := context.WithTimeout(context.Background(), longTimeout)
res, err := client.PutObject(ctx, input)
res, err := client.PutObject(ctx, input, func(o *s3.Options) {
// if input.Body is not nil, aws sdk hardcodes Content-Type: application/octet-stream
// this adds a new middleware in the stack to remove the Content-Type header, if
// it isn't explicitly provided as 'PutObject' input. Place the middleware
// right before "Signing" middleware to avoid incorrect request signature calculation
if input.ContentType == nil {
o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error {
return stack.Finalize.Insert(
middleware.FinalizeMiddlewareFunc("UnsetContentType",
func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) (
out middleware.FinalizeOutput, md middleware.Metadata, err error,
) {
if req, ok := in.Request.(*smithyhttp.Request); ok {
req.Header.Del("Content-Type")
}
return next.HandleFinalize(ctx, in)
}),
"Signing",
middleware.Before,
)
})
}
})
cancel()
if err != nil {
return nil, err