mirror of
https://github.com/versity/versitygw.git
synced 2026-09-18 22:14:24 +00:00
feat: add response header overrides for GetObject
GetObject allows overriding response headers with the following paramters: response-cache-control response-content-disposition response-content-encoding response-content-language response-content-type response-expires This is only valid for signed (and pre-singed) requests. An error is returned for anonymous requests if these are set. More info on the GetObject overrides can be found in the GetObject API reference. This also clarifies the naming of the AccessOptions IsPublicBucket to IsPublicRequest to indicate this is a public access request and not just accessing a bucket that allows public access. Fixes #1501
This commit is contained in:
@@ -211,6 +211,9 @@ func TestGetObject(s *S3Conf) {
|
||||
GetObject_directory_success(s)
|
||||
GetObject_by_range_resp_status(s)
|
||||
GetObject_non_existing_dir_object(s)
|
||||
GetObject_overrides_success(s)
|
||||
GetObject_overrides_presign_success(s)
|
||||
GetObject_overrides_fail_public(s)
|
||||
}
|
||||
|
||||
func TestListObjects(s *S3Conf) {
|
||||
@@ -896,7 +899,7 @@ func TestAccessControl(s *S3Conf) {
|
||||
}
|
||||
|
||||
func TestPublicBuckets(s *S3Conf) {
|
||||
PublicBucket_default_privet_bucket(s)
|
||||
PublicBucket_default_private_bucket(s)
|
||||
PublicBucket_public_bucket_policy(s)
|
||||
PublicBucket_public_object_policy(s)
|
||||
PublicBucket_public_acl(s)
|
||||
@@ -1133,6 +1136,9 @@ func GetIntTests() IntTests {
|
||||
"GetObject_directory_success": GetObject_directory_success,
|
||||
"GetObject_by_range_resp_status": GetObject_by_range_resp_status,
|
||||
"GetObject_non_existing_dir_object": GetObject_non_existing_dir_object,
|
||||
"GetObject_overrides_success": GetObject_overrides_success,
|
||||
"GetObject_overrides_presign_success": GetObject_overrides_presign_success,
|
||||
"GetObject_overrides_fail_public": GetObject_overrides_fail_public,
|
||||
"ListObjects_non_existing_bucket": ListObjects_non_existing_bucket,
|
||||
"ListObjects_with_prefix": ListObjects_with_prefix,
|
||||
"ListObjects_truncated": ListObjects_truncated,
|
||||
@@ -1484,7 +1490,7 @@ func GetIntTests() IntTests {
|
||||
"AccessControl_root_PutBucketAcl": AccessControl_root_PutBucketAcl,
|
||||
"AccessControl_user_PutBucketAcl_with_policy_access": AccessControl_user_PutBucketAcl_with_policy_access,
|
||||
"AccessControl_copy_object_with_starting_slash_for_user": AccessControl_copy_object_with_starting_slash_for_user,
|
||||
"PublicBucket_default_privet_bucket": PublicBucket_default_privet_bucket,
|
||||
"PublicBucket_default_private_bucket": PublicBucket_default_private_bucket,
|
||||
"PublicBucket_public_bucket_policy": PublicBucket_public_bucket_policy,
|
||||
"PublicBucket_public_object_policy": PublicBucket_public_object_policy,
|
||||
"PublicBucket_public_acl": PublicBucket_public_acl,
|
||||
|
||||
+401
-2
@@ -4773,6 +4773,405 @@ func GetObject_non_existing_dir_object(s *S3Conf) error {
|
||||
})
|
||||
}
|
||||
|
||||
func GetObject_overrides_success(s *S3Conf) error {
|
||||
testName := "GetObject_overrides_success"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
// Test data
|
||||
objKey := "test-object"
|
||||
objContent := "test content for response overrides"
|
||||
exp := time.Now()
|
||||
|
||||
// Put an object first
|
||||
_, err := s3client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
Body: strings.NewReader(objContent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to put object: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []PublicBucketTestCase{
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseCacheControl: getPtr("max-age=90"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentDisposition: getPtr("inline"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentEncoding: getPtr("txt"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentLanguage: getPtr("en"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentType: getPtr("application/json"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseExpires: &exp,
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
} {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
err := test.Call(ctx)
|
||||
cancel()
|
||||
if err == nil && test.ExpectedErr != nil {
|
||||
return fmt.Errorf("%v: expected err %v, instead got successful response", test.Action, test.ExpectedErr)
|
||||
}
|
||||
if err != nil {
|
||||
if test.ExpectedErr == nil {
|
||||
return fmt.Errorf("%v: expected no error, instead got %v", test.Action, err)
|
||||
}
|
||||
|
||||
apiErr, ok := test.ExpectedErr.(s3err.APIError)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid error type provided in the test, expected s3err.APIError")
|
||||
}
|
||||
|
||||
if err := checkApiErr(err, apiErr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetObject_overrides_presign_success(s *S3Conf) error {
|
||||
testName := "GetObject_overrides_presign_success"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
// Test data
|
||||
objKey := "test-object"
|
||||
objContent := "test content for response overrides"
|
||||
|
||||
// Put an object first
|
||||
_, err := s3client.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
Body: strings.NewReader(objContent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to put object: %v", err)
|
||||
}
|
||||
|
||||
// Test cases for each response override parameter
|
||||
testCases := []struct {
|
||||
name string
|
||||
queryParam string
|
||||
expectedHeader string
|
||||
expectedValue string
|
||||
}{
|
||||
{
|
||||
name: "response-cache-control",
|
||||
queryParam: "response-cache-control=no-cache",
|
||||
expectedHeader: "Cache-Control",
|
||||
expectedValue: "no-cache",
|
||||
},
|
||||
{
|
||||
name: "response-content-disposition",
|
||||
queryParam: "response-content-disposition=attachment%3B%20filename%3D%22test.txt%22",
|
||||
expectedHeader: "Content-Disposition",
|
||||
expectedValue: "attachment; filename=\"test.txt\"",
|
||||
},
|
||||
{
|
||||
name: "response-content-encoding",
|
||||
queryParam: "response-content-encoding=txt",
|
||||
expectedHeader: "Content-Encoding",
|
||||
expectedValue: "txt",
|
||||
},
|
||||
{
|
||||
name: "response-content-language",
|
||||
queryParam: "response-content-language=en-US",
|
||||
expectedHeader: "Content-Language",
|
||||
expectedValue: "en-US",
|
||||
},
|
||||
{
|
||||
name: "response-content-type",
|
||||
queryParam: "response-content-type=text%2Fplain",
|
||||
expectedHeader: "Content-Type",
|
||||
expectedValue: "text/plain",
|
||||
},
|
||||
{
|
||||
name: "response-expires",
|
||||
queryParam: "response-expires=Thu%2C%2001%20Dec%202024%2016%3A00%3A00%20GMT",
|
||||
expectedHeader: "Expires",
|
||||
expectedValue: "Thu, 01 Dec 2024 16:00:00 GMT",
|
||||
},
|
||||
}
|
||||
|
||||
// Test each override parameter individually
|
||||
for _, tc := range testCases {
|
||||
// Create a signed request with the response override parameter
|
||||
req, err := createSignedReq(
|
||||
http.MethodGet,
|
||||
s.endpoint,
|
||||
fmt.Sprintf("%s/%s?%s", bucket, objKey, tc.queryParam),
|
||||
s.awsID,
|
||||
s.awsSecret,
|
||||
"s3",
|
||||
s.awsRegion,
|
||||
nil,
|
||||
time.Now(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create signed request for %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request for %s: %v", tc.name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200 for %s, got %d", tc.name, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify the response override header is set correctly
|
||||
actualValue := resp.Header.Get(tc.expectedHeader)
|
||||
if actualValue != tc.expectedValue {
|
||||
return fmt.Errorf("expected %s header to be %q for %s, got %q",
|
||||
tc.expectedHeader, tc.expectedValue, tc.name, actualValue)
|
||||
}
|
||||
|
||||
// Verify content is still correct
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body for %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
if string(body) != objContent {
|
||||
return fmt.Errorf("expected content %q for %s, got %q", objContent, tc.name, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// Test multiple override parameters together
|
||||
multiParam := "response-cache-control=max-age%3D3600&response-content-type=application%2Fjson&response-content-disposition=inline"
|
||||
req, err := createSignedReq(
|
||||
http.MethodGet,
|
||||
s.endpoint,
|
||||
fmt.Sprintf("%s/%s?%s", bucket, objKey, multiParam),
|
||||
s.awsID,
|
||||
s.awsSecret,
|
||||
"s3",
|
||||
s.awsRegion,
|
||||
nil,
|
||||
time.Now(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create signed request for multiple overrides: %v", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request for multiple overrides: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200 for multiple overrides, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify all override headers are set correctly
|
||||
expectedHeaders := map[string]string{
|
||||
"Cache-Control": "max-age=3600",
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": "inline",
|
||||
}
|
||||
|
||||
for headerName, expectedValue := range expectedHeaders {
|
||||
actualValue := resp.Header.Get(headerName)
|
||||
if actualValue != expectedValue {
|
||||
return fmt.Errorf("expected %s header to be %q for multiple overrides, got %q",
|
||||
headerName, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetObject_overrides_fail_public(s *S3Conf) error {
|
||||
testName := "GetObject_overrides_fail_public"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
rootClient := s.GetClient()
|
||||
// Grant public access to the bucket for bucket operations
|
||||
err := grantPublicBucketPolicy(rootClient, bucket, policyTypeObject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Test data
|
||||
objKey := "test-object"
|
||||
objContent := "test content for response overrides"
|
||||
exp := time.Now()
|
||||
|
||||
// Put an object first
|
||||
_, err = rootClient.PutObject(context.Background(), &s3.PutObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
Body: strings.NewReader(objContent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to put object: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []PublicBucketTestCase{
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseCacheControl: getPtr("max-age=90"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders),
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentDisposition: getPtr("inline"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders),
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentEncoding: getPtr("txt"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders),
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentLanguage: getPtr("en"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders),
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseContentType: getPtr("application/json"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders),
|
||||
},
|
||||
{
|
||||
Action: "GetObject",
|
||||
Call: func(ctx context.Context) error {
|
||||
_, err := s3client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &bucket,
|
||||
Key: &objKey,
|
||||
ResponseExpires: &exp,
|
||||
})
|
||||
return err
|
||||
},
|
||||
ExpectedErr: s3err.GetAPIError(s3err.ErrAnonymousResponseHeaders),
|
||||
},
|
||||
} {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
err := test.Call(ctx)
|
||||
cancel()
|
||||
if err == nil && test.ExpectedErr != nil {
|
||||
return fmt.Errorf("%v: expected err %v, instead got successful response", test.Action, test.ExpectedErr)
|
||||
}
|
||||
if err != nil {
|
||||
if test.ExpectedErr == nil {
|
||||
return fmt.Errorf("%v: expected no error, instead got %v", test.Action, err)
|
||||
}
|
||||
|
||||
apiErr, ok := test.ExpectedErr.(s3err.APIError)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid error type provided in the test, expected s3err.APIError")
|
||||
}
|
||||
|
||||
if err := checkApiErr(err, apiErr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, withAnonymousClient())
|
||||
}
|
||||
|
||||
func ListObjects_non_existing_bucket(s *S3Conf) error {
|
||||
testName := "ListObjects_non_existing_bucket"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
@@ -17194,8 +17593,8 @@ func AccessControl_copy_object_with_starting_slash_for_user(s *S3Conf) error {
|
||||
}
|
||||
|
||||
// Public bucket tests
|
||||
func PublicBucket_default_privet_bucket(s *S3Conf) error {
|
||||
testName := "PublicBucket_default_privet_bucket"
|
||||
func PublicBucket_default_private_bucket(s *S3Conf) error {
|
||||
testName := "PublicBucket_default_private_bucket"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
partNumber := int32(1)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user