diff --git a/conf/config-example.yaml b/conf/config-example.yaml index 71f3bdbf..374157b5 100644 --- a/conf/config-example.yaml +++ b/conf/config-example.yaml @@ -348,6 +348,14 @@ targets: # url: http://localhost:8181/v1/data/example/authz/allowed # ## Actions # actions: + # # Action for HEAD requests on target + # HEAD: + # # Will allow HEAD requests + # enabled: true + # # Configuration for HEAD requests + # config: + # # Webhooks + # webhooks: [] # # Action for GET requests on target # GET: # # Will allow GET requests diff --git a/docs/configuration/example.md b/docs/configuration/example.md index 8ea357e7..26ed7fe3 100644 --- a/docs/configuration/example.md +++ b/docs/configuration/example.md @@ -358,6 +358,14 @@ targets: # url: http://localhost:8181/v1/data/example/authz/allowed # ## Actions # actions: + # # Action for HEAD requests on target + # HEAD: + # # Will allow HEAD requests + # enabled: true + # # Configuration for HEAD requests + # config: + # # Webhooks + # webhooks: [] # # Action for GET requests on target # GET: # # Will allow GET requests diff --git a/docs/configuration/structure.md b/docs/configuration/structure.md index b5bf9482..fd2fab7b 100644 --- a/docs/configuration/structure.md +++ b/docs/configuration/structure.md @@ -231,10 +231,24 @@ See more information [here](../feature-guide/key-rewrite.md). | Key | Type | Required | Default | Description | | ------ | ------------------------------------------------------- | -------- | ------- | -------------------------------------------------- | +| HEAD | [HeadActionConfiguration](#headactionconfiguration) | No | None | Action configuration for HEAD requests on target | | GET | [GetActionConfiguration](#getactionconfiguration) | No | None | Action configuration for GET requests on target | | PUT | [PutActionConfiguration](#putactionconfiguration) | No | None | Action configuration for PUT requests on target | | DELETE | [DeleteActionConfiguration](#deleteactionconfiguration) | No | None | Action configuration for DELETE requests on target | +## HeadActionConfiguration + +| Key | Type | Required | Default | Description | +| ------- | ----------------------------------------------------------------- | -------- | ------- | ------------------------------- | +| enabled | Boolean | No | `false` | Will allow HEAD requests | +| config | [HeadActionConfigConfiguration](#deleteactionconfigconfiguration) | No | None | Configuration for HEAD requests | + +## HeadActionConfigConfiguration + +| Key | Type | Required | Default | Description | +| -------- | ----------------------------------------------- | -------- | ------- | -------------------------------------------------------------------- | +| webhooks | [[WebhookConfiguration](#webhookconfiguration)] | No | `nil` | Webhooks configuration list to call when a HEAD request is performed | + ## GetActionConfiguration | Key | Type | Required | Default | Description | diff --git a/docs/feature-guide/api.md b/docs/feature-guide/api.md index 000959d3..87a1216d 100644 --- a/docs/feature-guide/api.md +++ b/docs/feature-guide/api.md @@ -11,6 +11,12 @@ There is 2 different management cases: - If path doesn't end with a slash, the backend will consider this as a file request. Example: `GET /file.pdf` +## HEAD + +Those kind of requests is similar to `GET` ones but won't provide any result body. + +There are working the same way for management cases for directories (eg: `HEAD /dir1/`) or files (eg: `HEAD /file.pdf`). + ## PUT This kind of requests will allow to send file in directory (so to upload a file in S3). diff --git a/docs/feature-guide/webhooks.md b/docs/feature-guide/webhooks.md index 599a0df5..d5733d8d 100644 --- a/docs/feature-guide/webhooks.md +++ b/docs/feature-guide/webhooks.md @@ -27,6 +27,7 @@ The main body is called the [HookBody](#hookbody). Here are all cased for input metadata: - GET: [GetInputMetadataHookBody](#getinputmetadatahookbody) +- HEAD: [HeadInputMetadataHookBody](#headinputmetadatahookbody) - PUT: [PutInputMetadataHookBody](#putinputmetadatahookbody) - DELETE: [DeleteInputMetadataHookBody](#deleteinputmetadatahookbody) @@ -55,6 +56,15 @@ Here are all cased for input metadata: | ----- | ------ | -------------------------------- | | name | String | Target name matching the request | +### HeadInputMetadataHookBody + +| Field | Type | Description | +| ----------------- | ------ | ---------------------------------- | +| ifModifiedSince | String | `If-Modified-Since` header value | +| ifMatch | String | `If-Match` header value | +| ifNoneMatch | String | `If-None-Match` header value | +| ifUnmodifiedSince | String | `If-Unmodified-Since` header value | + ### GetInputMetadataHookBody | Field | Type | Description | diff --git a/pkg/s3-proxy/bucket/bucket-req-impl.go b/pkg/s3-proxy/bucket/bucket-req-impl.go index cc7eedef..2c196b30 100644 --- a/pkg/s3-proxy/bucket/bucket-req-impl.go +++ b/pkg/s3-proxy/bucket/bucket-req-impl.go @@ -139,8 +139,17 @@ func (bri *bucketReqImpl) manageKeyRewrite(ctx context.Context, key string) (str return key, nil } -// Get proxy GET requests. +// Proxy GET requests. func (bri *bucketReqImpl) Get(ctx context.Context, input *GetInput) { + bri.internalGetOrHead(ctx, input, false) +} + +// Proxy HEAD requests. +func (bri *bucketReqImpl) Head(ctx context.Context, input *GetInput) { + bri.internalGetOrHead(ctx, input, true) +} + +func (bri *bucketReqImpl) internalGetOrHead(ctx context.Context, input *GetInput, isHeadReq bool) { // Get response handler resHan := responsehandler.GetResponseHandlerFromContext(ctx) @@ -157,15 +166,15 @@ func (bri *bucketReqImpl) Get(ctx context.Context, input *GetInput) { // Check that the path ends with a / for a directory listing or the main path special case (empty path) if strings.HasSuffix(input.RequestPath, "/") || input.RequestPath == "" { - bri.manageGetFolder(ctx, key, input) + bri.manageGetFolder(ctx, key, input, isHeadReq) // Stop return } - // Get object case + // Get or Head object case - // Check if it is asked to redirect to signed url - if bri.targetCfg.Actions != nil && + // Check if it is a HEAD request or if it is asked to redirect to signed url + if isHeadReq || bri.targetCfg.Actions != nil && bri.targetCfg.Actions.GET != nil && bri.targetCfg.Actions.GET.Config != nil && bri.targetCfg.Actions.GET.Config.RedirectToSignedURL { @@ -173,15 +182,20 @@ func (bri *bucketReqImpl) Get(ctx context.Context, input *GetInput) { s3cl := bri.s3ClientManager. GetClientForTarget(bri.targetCfg.Name) // Head file in bucket - headOutput, err2 := s3cl.HeadObject(ctx, key) + headOutput, hInfo, err2 := s3cl.HeadObject(ctx, key) // Check if there is an error if err2 != nil { // Save error err = err2 } else if headOutput != nil { // File found - // Redirect to signed url - err = bri.redirectToSignedURL(ctx, key, input) + // Check head request + if isHeadReq { + err = bri.answerHead(ctx, input, headOutput, hInfo) + } else { + // Redirect to signed url + err = bri.redirectToSignedURL(ctx, key, input) + } } } else { // Stream object @@ -225,7 +239,7 @@ func (bri *bucketReqImpl) Get(ctx context.Context, input *GetInput) { } } -func (bri *bucketReqImpl) manageGetFolder(ctx context.Context, key string, input *GetInput) { +func (bri *bucketReqImpl) manageGetFolder(ctx context.Context, key string, input *GetInput, isHeadReq bool) { // Get response handler resHan := responsehandler.GetResponseHandlerFromContext(ctx) @@ -236,7 +250,7 @@ func (bri *bucketReqImpl) manageGetFolder(ctx context.Context, key string, input // Create index key path indexKey := path.Join(key, bri.targetCfg.Actions.GET.Config.IndexDocument) // Head index file in bucket - headOutput, err := bri.s3ClientManager. + headOutput, hInfo, err := bri.s3ClientManager. GetClientForTarget(bri.targetCfg.Name). HeadObject(ctx, indexKey) // Check if error exists and not a not found error @@ -248,8 +262,11 @@ func (bri *bucketReqImpl) manageGetFolder(ctx context.Context, key string, input } // Check that we found the file if headOutput != nil { - // Check if it is asked to redirect to signed url - if bri.targetCfg.Actions.GET.Config.RedirectToSignedURL { + // Check if it is head request + if isHeadReq { //nolint:gocritic // Ignore this + // Answer with head + err = bri.answerHead(ctx, input, headOutput, hInfo) + } else if bri.targetCfg.Actions.GET.Config.RedirectToSignedURL { // Check if it is asked to redirect to signed url // Redirect to signed url err = bri.redirectToSignedURL(ctx, indexKey, input) } else { @@ -311,25 +328,46 @@ func (bri *bucketReqImpl) manageGetFolder(ctx context.Context, key string, input return } - // Send hook - bri.webhookManager.ManageGETHooks( - ctx, - bri.targetCfg.Name, - input.RequestPath, - &webhook.GetInputMetadata{ - IfModifiedSince: input.IfModifiedSince, - IfMatch: input.IfMatch, - IfNoneMatch: input.IfNoneMatch, - IfUnmodifiedSince: input.IfUnmodifiedSince, - Range: input.Range, - }, - &webhook.S3Metadata{ - Bucket: info.Bucket, - Region: info.Region, - S3Endpoint: info.S3Endpoint, - Key: info.Key, - }, - ) + if isHeadReq { + // Send hook + bri.webhookManager.ManageHEADHooks( + ctx, + bri.targetCfg.Name, + input.RequestPath, + &webhook.HeadInputMetadata{ + IfModifiedSince: input.IfModifiedSince, + IfMatch: input.IfMatch, + IfNoneMatch: input.IfNoneMatch, + IfUnmodifiedSince: input.IfUnmodifiedSince, + }, + &webhook.S3Metadata{ + Bucket: info.Bucket, + Region: info.Region, + S3Endpoint: info.S3Endpoint, + Key: info.Key, + }, + ) + } else { + // Send hook + bri.webhookManager.ManageGETHooks( + ctx, + bri.targetCfg.Name, + input.RequestPath, + &webhook.GetInputMetadata{ + IfModifiedSince: input.IfModifiedSince, + IfMatch: input.IfMatch, + IfNoneMatch: input.IfNoneMatch, + IfUnmodifiedSince: input.IfUnmodifiedSince, + Range: input.Range, + }, + &webhook.S3Metadata{ + Bucket: info.Bucket, + Region: info.Region, + S3Endpoint: info.S3Endpoint, + Key: info.Key, + }, + ) + } // Transform entries in entry with path objects bucketRootPrefixKey := bri.targetCfg.Bucket.GetRootPrefix() @@ -513,7 +551,7 @@ func (bri *bucketReqImpl) Put(ctx context.Context, inp *PutInput) { // Check if allow override is enabled if !bri.targetCfg.Actions.PUT.Config.AllowOverride { // Need to check if file already exists - headOutput, err2 := bri.s3ClientManager. + headOutput, _, err2 := bri.s3ClientManager. GetClientForTarget(bri.targetCfg.Name). HeadObject(ctx, key) // Check if error is not found if exists @@ -740,6 +778,52 @@ func (bri *bucketReqImpl) redirectToSignedURL(ctx context.Context, key string, i return nil } +func (bri *bucketReqImpl) answerHead( + ctx context.Context, + input *GetInput, + hOutput *s3client.HeadOutput, + info *s3client.ResultInfo, +) error { + // Get response handler from context + resHan := responsehandler.GetResponseHandlerFromContext(ctx) + + // Send hook + bri.webhookManager.ManageHEADHooks( + ctx, + bri.targetCfg.Name, + input.RequestPath, + &webhook.HeadInputMetadata{ + IfModifiedSince: input.IfModifiedSince, + IfMatch: input.IfMatch, + IfNoneMatch: input.IfNoneMatch, + IfUnmodifiedSince: input.IfUnmodifiedSince, + }, + &webhook.S3Metadata{ + Bucket: info.Bucket, + Region: info.Region, + S3Endpoint: info.S3Endpoint, + Key: info.Key, + }, + ) + + // Transform input + inp := &responsehandler.StreamInput{ + CacheControl: hOutput.CacheControl, + Expires: hOutput.Expires, + ContentDisposition: hOutput.ContentDisposition, + ContentEncoding: hOutput.ContentEncoding, + ContentLanguage: hOutput.ContentLanguage, + ContentLength: hOutput.ContentLength, + ContentType: hOutput.ContentType, + ETag: hOutput.ETag, + LastModified: hOutput.LastModified, + Metadata: hOutput.Metadata, + } + + // Stream + return resHan.StreamFile(bri.LoadFileContent, inp) +} + func (bri *bucketReqImpl) streamFileForResponse(ctx context.Context, key string, input *GetInput) error { // Get response handler from context resHan := responsehandler.GetResponseHandlerFromContext(ctx) diff --git a/pkg/s3-proxy/bucket/bucket-req-impl_test.go b/pkg/s3-proxy/bucket/bucket-req-impl_test.go index dc30fe53..e1a86a24 100644 --- a/pkg/s3-proxy/bucket/bucket-req-impl_test.go +++ b/pkg/s3-proxy/bucket/bucket-req-impl_test.go @@ -1389,6 +1389,7 @@ func Test_requestContext_Put(t *testing.T) { HeadObject(ctx, tt.s3ClientHeadObjectMockResult.input2). Return( tt.s3ClientHeadObjectMockResult.res, + nil, tt.s3ClientHeadObjectMockResult.err, ). Times(tt.s3ClientHeadObjectMockResult.times) @@ -1630,8 +1631,10 @@ func Test_requestContext_Get(t *testing.T) { Key: "/folder/index.html", }, res: &s3client.GetOutput{ - Body: body, - ContentType: "text/html; charset=utf-8", + Body: body, + BaseFileOutput: &s3client.BaseFileOutput{ + ContentType: "text/html; charset=utf-8", + }, }, res2: &s3client.ResultInfo{ Bucket: "bucket", @@ -1943,9 +1946,11 @@ func Test_requestContext_Get(t *testing.T) { Key: "/folder/index.html", }, res: &s3client.GetOutput{ - Body: body, - ContentDisposition: "disposition", - ContentType: "type", + Body: body, + BaseFileOutput: &s3client.BaseFileOutput{ + ContentDisposition: "disposition", + ContentType: "type", + }, }, res2: &s3client.ResultInfo{ Bucket: "bucket", @@ -2111,9 +2116,11 @@ func Test_requestContext_Get(t *testing.T) { Key: "/fake/fake.html", }, res: &s3client.GetOutput{ - Body: body, - ContentType: "type", - ContentEncoding: "encoding", + Body: body, + BaseFileOutput: &s3client.BaseFileOutput{ + ContentType: "type", + ContentEncoding: "encoding", + }, }, res2: &s3client.ResultInfo{ Bucket: "bucket", @@ -2225,6 +2232,7 @@ func Test_requestContext_Get(t *testing.T) { HeadObject(ctx, tt.s3ClientHeadObjectMockResult.input2). Return( tt.s3ClientHeadObjectMockResult.res, + nil, tt.s3ClientHeadObjectMockResult.err, ). Times(tt.s3ClientHeadObjectMockResult.times) diff --git a/pkg/s3-proxy/bucket/client.go b/pkg/s3-proxy/bucket/client.go index dd67f7b0..9bf036e3 100644 --- a/pkg/s3-proxy/bucket/client.go +++ b/pkg/s3-proxy/bucket/client.go @@ -22,6 +22,8 @@ var ErrRemovalFolder = errors.New("can't remove folder") type Client interface { // Get allow to GET what's inside a request path Get(ctx context.Context, input *GetInput) + // Head allow to HEAD what's inside a request path + Head(ctx context.Context, input *GetInput) // Put will put a file following input Put(ctx context.Context, inp *PutInput) // Delete will delete file on request path diff --git a/pkg/s3-proxy/bucket/mocks/mock_Client.go b/pkg/s3-proxy/bucket/mocks/mock_Client.go index 0144bbc2..0598e4c3 100644 --- a/pkg/s3-proxy/bucket/mocks/mock_Client.go +++ b/pkg/s3-proxy/bucket/mocks/mock_Client.go @@ -64,6 +64,18 @@ func (mr *MockClientMockRecorder) Get(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), arg0, arg1) } +// Head mocks base method. +func (m *MockClient) Head(arg0 context.Context, arg1 *bucket.GetInput) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Head", arg0, arg1) +} + +// Head indicates an expected call of Head. +func (mr *MockClientMockRecorder) Head(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Head", reflect.TypeOf((*MockClient)(nil).Head), arg0, arg1) +} + // LoadFileContent mocks base method. func (m *MockClient) LoadFileContent(arg0 context.Context, arg1 string) (string, error) { m.ctrl.T.Helper() diff --git a/pkg/s3-proxy/config/config.go b/pkg/s3-proxy/config/config.go index 1edc8b41..0f99916f 100644 --- a/pkg/s3-proxy/config/config.go +++ b/pkg/s3-proxy/config/config.go @@ -381,11 +381,23 @@ type TargetTemplateConfigItem struct { // ActionsConfig is dedicated to actions configuration in a target. type ActionsConfig struct { + HEAD *HeadActionConfig `json:"HEAD" mapstructure:"HEAD"` GET *GetActionConfig `json:"GET" mapstructure:"GET"` PUT *PutActionConfig `json:"PUT" mapstructure:"PUT"` DELETE *DeleteActionConfig `json:"DELETE" mapstructure:"DELETE"` } +// HeadActionConfig Head action configuration. +type HeadActionConfig struct { + Config *HeadActionConfigConfig `json:"config" mapstructure:"config"` + Enabled bool `json:"enabled" mapstructure:"enabled"` +} + +// HeadActionConfigConfig Head action configuration object configuration. +type HeadActionConfigConfig struct { + Webhooks []*WebhookConfig `json:"webhooks" mapstructure:"webhooks" validate:"dive"` +} + // DeleteActionConfig Delete action configuration. type DeleteActionConfig struct { Config *DeleteActionConfigConfig `json:"config" mapstructure:"config"` diff --git a/pkg/s3-proxy/response-handler/client.go b/pkg/s3-proxy/response-handler/client.go index aee823a3..80f6bae0 100644 --- a/pkg/s3-proxy/response-handler/client.go +++ b/pkg/s3-proxy/response-handler/client.go @@ -120,9 +120,10 @@ type ResponseHandler interface { // NewHandler will return a new response handler object. func NewHandler(req *http.Request, res http.ResponseWriter, cfgManager config.Manager, targetKey string) ResponseHandler { return &handler{ - req: req, - res: res, - cfgManager: cfgManager, - targetKey: targetKey, + req: req, + res: res, + cfgManager: cfgManager, + targetKey: targetKey, + headAnswerMode: req.Method == http.MethodHead, } } diff --git a/pkg/s3-proxy/response-handler/handler.go b/pkg/s3-proxy/response-handler/handler.go index b5cf8d61..c0bca033 100644 --- a/pkg/s3-proxy/response-handler/handler.go +++ b/pkg/s3-proxy/response-handler/handler.go @@ -13,10 +13,15 @@ import ( ) type handler struct { - req *http.Request - res http.ResponseWriter - cfgManager config.Manager - targetKey string + req *http.Request + res http.ResponseWriter + cfgManager config.Manager + targetKey string + headAnswerMode bool +} + +func (h *handler) EnableHeadAnswerMode() { + h.headAnswerMode = true } func (h *handler) GetRequest() *http.Request { @@ -231,10 +236,16 @@ func (h *handler) StreamFile( // Set headers from object setHeadersFromObjectOutput(h.res, input) - // Copy data stream to output stream - _, err := io.Copy(h.res, input.Body) + // Check if we aren't in head answer mode + if !h.headAnswerMode { + // Copy data stream to output stream + _, err := io.Copy(h.res, input.Body) + + return errors.WithStack(err) + } - return errors.WithStack(err) + // Default + return nil } func (h *handler) FoldersFilesList( diff --git a/pkg/s3-proxy/response-handler/utils.go b/pkg/s3-proxy/response-handler/utils.go index 8bc4a474..6c76f31d 100644 --- a/pkg/s3-proxy/response-handler/utils.go +++ b/pkg/s3-proxy/response-handler/utils.go @@ -88,11 +88,14 @@ func (h *handler) send(bodyBuf io.WriterTo, headers map[string]string, status in // Set status code h.res.WriteHeader(status) - // Write to response - _, err := bodyBuf.WriteTo(h.res) - // Check if error exists - if err != nil { - return errors.WithStack(err) + // Check if we aren't in head answer + if !h.headAnswerMode { + // Write to response + _, err := bodyBuf.WriteTo(h.res) + // Check if error exists + if err != nil { + return errors.WithStack(err) + } } return nil diff --git a/pkg/s3-proxy/s3client/client.go b/pkg/s3-proxy/s3client/client.go index e54da5be..acf7cabf 100644 --- a/pkg/s3-proxy/s3client/client.go +++ b/pkg/s3-proxy/s3client/client.go @@ -28,7 +28,7 @@ type Client interface { // ListFilesAndDirectories will list files and directories in S3. ListFilesAndDirectories(ctx context.Context, key string) ([]*ListElementOutput, *ResultInfo, error) // HeadObject will head a key. - HeadObject(ctx context.Context, key string) (*HeadOutput, error) + HeadObject(ctx context.Context, key string) (*HeadOutput, *ResultInfo, error) // GetObject will get an object. GetObject(ctx context.Context, input *GetInput) (*GetOutput, *ResultInfo, error) // PutObject will put an object. @@ -63,8 +63,22 @@ type ListElementOutput struct { Size int64 } +type BaseFileOutput struct { + LastModified time.Time + Metadata map[string]string + CacheControl string + Expires string + ContentDisposition string + ContentEncoding string + ContentLanguage string + ContentType string + ETag string + ContentLength int64 +} + // HeadOutput represents output of Head. type HeadOutput struct { + *BaseFileOutput Type string Key string } @@ -90,18 +104,9 @@ type GetInput struct { // GetOutput Object output for S3 get object. type GetOutput struct { - LastModified time.Time - Body io.ReadCloser - Metadata map[string]string - CacheControl string - Expires string - ContentDisposition string - ContentEncoding string - ContentLanguage string - ContentRange string - ContentType string - ETag string - ContentLength int64 + *BaseFileOutput + Body io.ReadCloser + ContentRange string } // PutInput Put input object for PUT request. diff --git a/pkg/s3-proxy/s3client/mocks/mock_Client.go b/pkg/s3-proxy/s3client/mocks/mock_Client.go index efb1d29d..7e3f9537 100644 --- a/pkg/s3-proxy/s3client/mocks/mock_Client.go +++ b/pkg/s3-proxy/s3client/mocks/mock_Client.go @@ -88,12 +88,13 @@ func (mr *MockClientMockRecorder) GetObjectSignedURL(arg0, arg1, arg2 any) *gomo } // HeadObject mocks base method. -func (m *MockClient) HeadObject(arg0 context.Context, arg1 string) (*s3client.HeadOutput, error) { +func (m *MockClient) HeadObject(arg0 context.Context, arg1 string) (*s3client.HeadOutput, *s3client.ResultInfo, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "HeadObject", arg0, arg1) ret0, _ := ret[0].(*s3client.HeadOutput) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(*s3client.ResultInfo) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // HeadObject indicates an expected call of HeadObject. diff --git a/pkg/s3-proxy/s3client/s3-client.go b/pkg/s3-proxy/s3client/s3-client.go index 5af5f639..25828b1e 100644 --- a/pkg/s3-proxy/s3client/s3-client.go +++ b/pkg/s3-proxy/s3client/s3-client.go @@ -325,7 +325,8 @@ func (s3cl *s3client) GetObject(ctx context.Context, input *GetInput) (*GetOutpu } // Build output output := &GetOutput{ - Body: obj.Body, + BaseFileOutput: &BaseFileOutput{}, + Body: obj.Body, } // Metadata transformation @@ -496,7 +497,7 @@ func (s3cl *s3client) PutObject(ctx context.Context, input *PutInput) (*ResultIn return info, nil } -func (s3cl *s3client) HeadObject(ctx context.Context, key string) (*HeadOutput, error) { +func (s3cl *s3client) HeadObject(ctx context.Context, key string) (*HeadOutput, *ResultInfo, error) { // Get trace parentTrace := tracing.GetTraceFromContext(ctx) // Create child trace @@ -528,7 +529,7 @@ func (s3cl *s3client) HeadObject(ctx context.Context, key string) (*HeadOutput, } // Head object in bucket - _, err := s3cl.svcClient.HeadObjectWithContext( + obj, err := s3cl.svcClient.HeadObjectWithContext( ctx, &s3.HeadObjectInput{ Bucket: aws.String(s3cl.target.Bucket.Name), @@ -546,23 +547,73 @@ func (s3cl *s3client) HeadObject(ctx context.Context, key string) (*HeadOutput, if ok { // Issue not fixed: https://github.com/aws/aws-sdk-go/issues/1208 if aerr.Code() == "NotFound" { - return nil, ErrNotFound + return nil, nil, ErrNotFound } } - return nil, errors.WithStack(err) + return nil, nil, errors.WithStack(err) } // Generate output output := &HeadOutput{ - Type: FileType, - Key: key, + BaseFileOutput: &BaseFileOutput{}, + Type: FileType, + Key: key, + } + + // Metadata transformation + if obj.Metadata != nil { + output.Metadata = aws.StringValueMap(obj.Metadata) + } + + if obj.CacheControl != nil { + output.CacheControl = *obj.CacheControl + } + + if obj.Expires != nil { + output.Expires = *obj.Expires + } + + if obj.ContentDisposition != nil { + output.ContentDisposition = *obj.ContentDisposition + } + + if obj.ContentEncoding != nil { + output.ContentEncoding = *obj.ContentEncoding + } + + if obj.ContentLanguage != nil { + output.ContentLanguage = *obj.ContentLanguage + } + + if obj.ContentLength != nil { + output.ContentLength = *obj.ContentLength + } + + if obj.ContentType != nil { + output.ContentType = *obj.ContentType + } + + if obj.ETag != nil { + output.ETag = *obj.ETag + } + + if obj.LastModified != nil { + output.LastModified = *obj.LastModified + } + + // Create info + info := &ResultInfo{ + Bucket: s3cl.target.Bucket.Name, + S3Endpoint: s3cl.target.Bucket.S3Endpoint, + Region: s3cl.target.Bucket.Region, + Key: key, } // Log logger.Debugf("Head object done with success") // Return output - return output, nil + return output, info, nil } func (s3cl *s3client) DeleteObject(ctx context.Context, key string) (*ResultInfo, error) { diff --git a/pkg/s3-proxy/server/internal-server_integration_test.go b/pkg/s3-proxy/server/internal-server_integration_test.go index 5d947525..e9860d70 100644 --- a/pkg/s3-proxy/server/internal-server_integration_test.go +++ b/pkg/s3-proxy/server/internal-server_integration_test.go @@ -406,6 +406,7 @@ func TestInternalServer_config_endpoint(t *testing.T) { "mount": { "host": "", "path": ["/test/"] }, "actions": { "GET": { "config": null, "enabled": true }, + "HEAD": null, "PUT": null, "DELETE": null }, diff --git a/pkg/s3-proxy/server/server.go b/pkg/s3-proxy/server/server.go index 7b1c7fb6..58d728d1 100644 --- a/pkg/s3-proxy/server/server.go +++ b/pkg/s3-proxy/server/server.go @@ -287,8 +287,88 @@ func (svr *Server) generateRouter() (http.Handler, error) { // Add authorization middleware to router rt2.Use(authorization.Middleware(svr.cfgManager, svr.metricsCl)) + // Check if HEAD action is enabled + if tgt.Actions.HEAD != nil && tgt.Actions.HEAD.Enabled { //nolint:dupl + rt2.Head("/*", func(_ http.ResponseWriter, req *http.Request) { + // Get bucket request context + brctx := bucket.GetBucketRequestContextFromContext(req.Context()) + // Get response handler + resHan := responsehandler.GetResponseHandlerFromContext(req.Context()) + + // Get request path + requestPath := chi.URLParam(req, "*") + + // Unescape it + // Found a bug where sometimes the request path isn't unescaped + requestPath, err := url.PathUnescape(requestPath) + // Check error + if err != nil { + resHan.InternalServerError(brctx.LoadFileContent, errors.WithStack(err)) + + return + } + + // Get ETag headers + + // Get If-Modified-Since as string + ifModifiedSinceStr := req.Header.Get("If-Modified-Since") + // Create result + var ifModifiedSince *time.Time + // Check if content exists + if ifModifiedSinceStr != "" { + // Parse time + ifModifiedSinceTime, err := http.ParseTime(ifModifiedSinceStr) + // Check error + if err != nil { + resHan.BadRequestError(brctx.LoadFileContent, errors.WithStack(err)) + + return + } + // Save result + ifModifiedSince = &ifModifiedSinceTime + } + + // Get Range + byteRange := req.Header.Get("Range") + + // Get If-Match + ifMatch := req.Header.Get("If-Match") + + // Get If-None-Match + ifNoneMatch := req.Header.Get("If-None-Match") + + // Get If-Unmodified-Since as string + ifUnmodifiedSinceStr := req.Header.Get("If-Unmodified-Since") + // Create result + var ifUnmodifiedSince *time.Time + // Check if content exists + if ifUnmodifiedSinceStr != "" { + // Parse time + ifUnmodifiedSinceTime, err := http.ParseTime(ifUnmodifiedSinceStr) + // Check error + if err != nil { + resHan.BadRequestError(brctx.LoadFileContent, errors.WithStack(err)) + + return + } + // Save result + ifUnmodifiedSince = &ifUnmodifiedSinceTime + } + + // Proxy GET Request + brctx.Head(req.Context(), &bucket.GetInput{ + RequestPath: requestPath, + IfModifiedSince: ifModifiedSince, + IfMatch: ifMatch, + IfNoneMatch: ifNoneMatch, + IfUnmodifiedSince: ifUnmodifiedSince, + Range: byteRange, + }) + }) + } + // Check if GET action is enabled - if tgt.Actions.GET != nil && tgt.Actions.GET.Enabled { + if tgt.Actions.GET != nil && tgt.Actions.GET.Enabled { //nolint:dupl // Add GET method to router rt2.Get("/*", func(_ http.ResponseWriter, req *http.Request) { // Get bucket request context diff --git a/pkg/s3-proxy/server/server_integration_test.go b/pkg/s3-proxy/server/server_integration_test.go index 859866b7..cbe6b845 100644 --- a/pkg/s3-proxy/server/server_integration_test.go +++ b/pkg/s3-proxy/server/server_integration_test.go @@ -88,8 +88,1532 @@ func TestPublicRouter(t *testing.T) { expectedS3ObjectExpires *string expectedS3ObjectStorageClass *string notExpectedBody string + expectedEmptyBody bool wantErr bool }{ + { + name: "HEAD a not found path", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/not-found/", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a folder without index document enabled", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a folder without index document enabled (json)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + inputHeaders: map[string]string{ + "Accept": "application/json", + }, + expectedCode: 200, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "application/json; charset=utf-8", + }, + expectedEmptyBody: true, + }, + { + name: "HEAD a folder without index document enabled and custom folder list template override", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + Templates: &config.TargetTemplateConfig{ + FolderList: &config.TargetTemplateConfigItem{ + InBucket: true, + Path: "templates/folder-list.tpl", + Headers: map[string]string{ + "Content-Type": "{{ template \"main.headers.contentType\" . }}", + }, + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a file with success", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + }, + }, + { + name: "HEAD a file with success with compress enabled", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/content-type/file.txt", + inputHeaders: map[string]string{ + "Accept-Encoding": "gzip", + }, + expectedEmptyBody: true, + expectedCode: 200, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + // "Content-Encoding": "gzip", // Testing implementation don't support it... + }, + }, + { + name: "HEAD a file with success without compress enabled", + args: args{ + cfg: &config.Config{ + Server: &config.ServerConfig{ + Compress: &config.ServerCompressConfig{ + Enabled: &falseValue, + Level: config.DefaultServerCompressLevel, + Types: config.DefaultServerCompressTypes, + }, + }, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/content-type/file.txt", + inputHeaders: map[string]string{ + "Accept-Encoding": "gzip", + }, + expectedEmptyBody: true, + expectedCode: 200, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + }, + }, + { + name: "HEAD a file with no cache enabled _no cache config_", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + "Expires": time.Unix(0, 0).UTC().Format(http.TimeFormat), + "Pragma": "no-cache", + "X-Accel-Expires": "0", + }, + }, + { + name: "HEAD a file with no cache enabled _no cache enabled_", + args: args{ + cfg: &config.Config{ + Server: &config.ServerConfig{ + Cache: &config.CacheConfig{NoCacheEnabled: true}, + Compress: svrCfg.Compress, + }, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + "Expires": time.Unix(0, 0).UTC().Format(http.TimeFormat), + "Pragma": "no-cache", + "X-Accel-Expires": "0", + }, + }, + { + name: "HEAD a file with cache management enabled", + args: args{ + cfg: &config.Config{ + Server: &config.ServerConfig{ + Cache: &config.CacheConfig{ + Expires: "expires", + CacheControl: "must-revalidate, max-age=0", + Pragma: "pragma", + XAccelExpires: "xaccelexpires", + }, + Compress: svrCfg.Compress, + }, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "must-revalidate, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + "Expires": "expires", + "Pragma": "pragma", + "X-Accel-Expires": "xaccelexpires", + }, + }, + { + name: "HEAD a file with success (redirect to signed url enabled)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Enabled: true, + }, + GET: &config.GetActionConfig{ + Enabled: true, + Config: &config.GetActionConfigConfig{ + RedirectToSignedURL: true, + SignedURLExpiration: time.Minute, + }, + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 200, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + }, + expectedEmptyBody: true, + }, + { + name: "HEAD a file with a not found error", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt-not-existing", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a file with a not found error (redirect to signed url enabled)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Enabled: true, + }, + GET: &config.GetActionConfig{ + Enabled: true, + Config: &config.GetActionConfigConfig{ + RedirectToSignedURL: true, + SignedURLExpiration: time.Minute, + }, + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt-not-existing", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + "Location": "", + }, + }, + { + name: "HEAD a file with a not found error because of not valid host", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + Host: "test.local", + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a file with success on specific host", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + Host: "test.local", + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://test.local/mount/folder1/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + }, + }, + { + name: "HEAD a file with forbidden error in case of no resource found", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + AuthProviders: &config.AuthProviderConfig{ + Basic: map[string]*config.BasicAuthConfig{ + "provider1": { + Realm: "realm1", + }, + }, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Resources: []*config.Resource{ + { + Path: "/mount/folder2/*", + Methods: []string{"HEAD"}, + Provider: "provider1", + Basic: &config.ResourceBasic{ + Credentials: []*config.BasicAuthUserConfig{ + { + User: "user1", + Password: &config.CredentialConfig{ + Value: "pass1", + }, + }, + }, + }, + }, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 403, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a file with forbidden error in case of no resource found because no valid http methods", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + AuthProviders: &config.AuthProviderConfig{ + Basic: map[string]*config.BasicAuthConfig{ + "provider1": { + Realm: "realm1", + }, + }, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Resources: []*config.Resource{ + { + Path: "/mount/folder2/*", + Methods: []string{"PUT"}, + Provider: "provider1", + Basic: &config.ResourceBasic{ + Credentials: []*config.BasicAuthUserConfig{ + { + User: "user1", + Password: &config.CredentialConfig{ + Value: "pass1", + }, + }, + }, + }, + }, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 403, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a file with unauthorized error in case of no basic auth", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + AuthProviders: &config.AuthProviderConfig{ + Basic: map[string]*config.BasicAuthConfig{ + "provider1": { + Realm: "realm1", + }, + }, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Resources: []*config.Resource{ + { + Path: "/mount/folder1/*", + Methods: []string{"HEAD"}, + Provider: "provider1", + Basic: &config.ResourceBasic{ + Credentials: []*config.BasicAuthUserConfig{ + { + User: "user1", + Password: &config.CredentialConfig{ + Value: "pass1", + }, + }, + }, + }, + }, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 401, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + "Www-Authenticate": "Basic realm=\"realm1\"", + }, + }, + { + name: "HEAD a file with unauthorized error in case of not found basic auth user", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + AuthProviders: &config.AuthProviderConfig{ + Basic: map[string]*config.BasicAuthConfig{ + "provider1": { + Realm: "realm1", + }, + }, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Resources: []*config.Resource{ + { + Path: "/mount/folder1/*", + Methods: []string{"HEAD"}, + Provider: "provider1", + Basic: &config.ResourceBasic{ + Credentials: []*config.BasicAuthUserConfig{ + { + User: "user1", + Password: &config.CredentialConfig{ + Value: "pass1", + }, + }, + }, + }, + }, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + inputBasicUser: "user2", + inputBasicPassword: "pass2", + expectedCode: 401, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + "Www-Authenticate": "Basic realm=\"realm1\"", + }, + }, + { + name: "HEAD a file with unauthorized error in case of wrong basic auth password", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + AuthProviders: &config.AuthProviderConfig{ + Basic: map[string]*config.BasicAuthConfig{ + "provider1": { + Realm: "realm1", + }, + }, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Resources: []*config.Resource{ + { + Path: "/mount/folder1/*", + Methods: []string{"HEAD"}, + Provider: "provider1", + Basic: &config.ResourceBasic{ + Credentials: []*config.BasicAuthUserConfig{ + { + User: "user1", + Password: &config.CredentialConfig{ + Value: "pass1", + }, + }, + }, + }, + }, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + inputBasicUser: "user1", + inputBasicPassword: "pass2", + expectedCode: 401, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + "Www-Authenticate": "Basic realm=\"realm1\"", + }, + }, + { + name: "HEAD a file with success in case of valid basic auth", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + AuthProviders: &config.AuthProviderConfig{ + Basic: map[string]*config.BasicAuthConfig{ + "provider1": { + Realm: "realm1", + }, + }, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Resources: []*config.Resource{ + { + Path: "/mount/folder1/*", + Methods: []string{"HEAD"}, + Provider: "provider1", + Basic: &config.ResourceBasic{ + Credentials: []*config.BasicAuthUserConfig{ + { + User: "user1", + Password: &config.CredentialConfig{ + Value: "pass1", + }, + }, + }, + }, + }, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + inputBasicUser: "user1", + inputBasicPassword: "pass1", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + }, + }, + { + name: "HEAD a file with success in case of whitelist", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + AuthProviders: &config.AuthProviderConfig{ + Basic: map[string]*config.BasicAuthConfig{ + "provider1": { + Realm: "realm1", + }, + }, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Resources: []*config.Resource{ + { + Path: "/mount/folder1/test.txt", + Methods: []string{"HEAD"}, + WhiteList: &trueValue, + }, + { + Path: "/mount/folder1/*", + Methods: []string{"HEAD"}, + Provider: "provider1", + Basic: &config.ResourceBasic{ + Credentials: []*config.BasicAuthUserConfig{ + { + User: "user1", + Password: &config.CredentialConfig{ + Value: "pass1", + }, + }, + }, + }, + }, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + }, + }, + { + name: "HEAD a file with success with space in path", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder0/test%20with%20space%20and%20special%20(1).txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + }, + }, + { + name: "HEAD a file with success with custom headers (general helpers)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + GET: &config.GetActionConfig{ + Enabled: true, + Config: &config.GetActionConfigConfig{ + StreamedFileHeaders: map[string]string{ + "Fake": "{{ index .StreamFile.Metadata \"M1-Key\" }}", + }, + }, + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder3/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + "Fake": "v1", + }, + }, + { + name: "HEAD a file with success with custom headers (target helpers)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Templates: &config.TargetTemplateConfig{ + Helpers: []*config.TargetHelperConfigItem{{ + InBucket: false, + Path: "../../../templates/_helpers.tpl", + }}, + }, + Actions: &config.ActionsConfig{ + GET: &config.GetActionConfig{ + Enabled: true, + Config: &config.GetActionConfigConfig{ + StreamedFileHeaders: map[string]string{ + "Fake": "{{ index .StreamFile.Metadata \"M1-Key\" }}", + }, + }, + }, + HEAD: &config.HeadActionConfig{Enabled: true}, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder3/test.txt", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + // "Content-Type": "text/plain; charset=utf-8", // Testing implementation don't support it... + "Fake": "v1", + }, + }, + { + name: "HEAD a folder list with another status code and another content (general templates)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: &config.TemplateConfig{ + Helpers: testsDefaultHelpersTemplateConfig, + FolderList: testsDefaultNotFoundErrorTemplateConfig, + TargetList: testsDefaultTargetListTemplateConfig, + BadRequestError: testsDefaultBadRequestErrorTemplateConfig, + NotFoundError: testsDefaultNotFoundErrorTemplateConfig, + InternalServerError: testsDefaultInternalServerErrorTemplateConfig, + UnauthorizedError: testsDefaultUnauthorizedErrorTemplateConfig, + ForbiddenError: testsDefaultForbiddenErrorTemplateConfig, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Enabled: true, + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a folder list with another status code and another content (target override)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Enabled: true, + }, + }, + Templates: &config.TargetTemplateConfig{ + FolderList: &config.TargetTemplateConfigItem{ + Path: "../../../templates/not-found-error.tpl", + Headers: map[string]string{ + "Content-Type": "{{ template \"main.headers.contentType\" . }}", + }, + Status: "404", + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a folder list with disable listing enabled", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{Enabled: true}, + GET: &config.GetActionConfig{ + Enabled: true, + Config: &config.GetActionConfigConfig{DisableListing: true}, + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + expectedCode: 200, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a folder list with disable listing enabled, another status code and another content (general templates)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: &config.TemplateConfig{ + Helpers: testsDefaultHelpersTemplateConfig, + FolderList: testsDefaultNotFoundErrorTemplateConfig, + TargetList: testsDefaultTargetListTemplateConfig, + BadRequestError: testsDefaultBadRequestErrorTemplateConfig, + NotFoundError: testsDefaultNotFoundErrorTemplateConfig, + InternalServerError: testsDefaultInternalServerErrorTemplateConfig, + UnauthorizedError: testsDefaultUnauthorizedErrorTemplateConfig, + ForbiddenError: testsDefaultForbiddenErrorTemplateConfig, + }, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Enabled: true, + }, + GET: &config.GetActionConfig{ + Enabled: true, + Config: &config.GetActionConfigConfig{DisableListing: true}, + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, + { + name: "HEAD a folder list with disable listing enabled, another status code and another content (target override)", + args: args{ + cfg: &config.Config{ + Server: svrCfg, + ListTargets: &config.ListTargetsConfig{}, + Tracing: tracingConfig, + Templates: testsDefaultGeneralTemplateConfig, + Targets: map[string]*config.TargetConfig{ + "target1": { + Name: "target1", + Bucket: &config.BucketConfig{ + Name: bucket, + Region: region, + S3Endpoint: s3server.URL, + Credentials: &config.BucketCredentialConfig{ + AccessKey: &config.CredentialConfig{Value: accessKey}, + SecretKey: &config.CredentialConfig{Value: secretAccessKey}, + }, + DisableSSL: true, + }, + Mount: &config.MountConfig{ + Path: []string{"/mount/"}, + }, + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Enabled: true, + }, + }, + Templates: &config.TargetTemplateConfig{ + FolderList: &config.TargetTemplateConfigItem{ + Path: "../../../templates/not-found-error.tpl", + Headers: map[string]string{ + "Content-Type": "{{ template \"main.headers.contentType\" . }}", + }, + Status: "404", + }, + }, + }, + }, + }, + }, + inputMethod: "HEAD", + inputURL: "http://localhost/mount/folder1/", + expectedCode: 404, + expectedEmptyBody: true, + expectedHeaders: map[string]string{ + "Cache-Control": "no-cache, no-store, no-transform, must-revalidate, private, max-age=0", + "Content-Type": "text/html; charset=utf-8", + }, + }, { name: "GET a not found path", args: args{ @@ -3802,6 +5326,12 @@ func TestPublicRouter(t *testing.T) { assert.NotEqual(t, tt.notExpectedBody, body) } + if tt.expectedEmptyBody { + body := w.Body.String() + + assert.Empty(t, body) + } + if tt.expectedHeaders != nil { for key, val := range tt.expectedHeaders { wheader := w.HeaderMap.Get(key) diff --git a/pkg/s3-proxy/webhook/client.go b/pkg/s3-proxy/webhook/client.go index 1944ae49..e705a684 100644 --- a/pkg/s3-proxy/webhook/client.go +++ b/pkg/s3-proxy/webhook/client.go @@ -18,12 +18,20 @@ type PutInputMetadata struct { // GetInputMetadata Get input metadata. type GetInputMetadata struct { IfModifiedSince *time.Time + IfUnmodifiedSince *time.Time IfMatch string IfNoneMatch string - IfUnmodifiedSince *time.Time Range string } +// HeadInputMetadata Get input metadata. +type HeadInputMetadata struct { + IfModifiedSince *time.Time + IfUnmodifiedSince *time.Time + IfMatch string + IfNoneMatch string +} + // S3Metadata S3 Metadata. type S3Metadata struct { Bucket string @@ -38,6 +46,8 @@ type S3Metadata struct { type Manager interface { // ManageGETHooks will manage GET hooks. ManageGETHooks(ctx context.Context, targetKey, requestPath string, inputMetadata *GetInputMetadata, s3Metadata *S3Metadata) + // ManageHEADHooks will manage GET hooks. + ManageHEADHooks(ctx context.Context, targetKey, requestPath string, inputMetadata *HeadInputMetadata, s3Metadata *S3Metadata) // ManageGETHooks will manage PUT hooks. ManagePUTHooks(ctx context.Context, targetKey, requestPath string, inputMetadata *PutInputMetadata, s3Metadata *S3Metadata) // ManageGETHooks will manage DELETE hooks. diff --git a/pkg/s3-proxy/webhook/manager.go b/pkg/s3-proxy/webhook/manager.go index 6f75fb66..9af8ce5f 100644 --- a/pkg/s3-proxy/webhook/manager.go +++ b/pkg/s3-proxy/webhook/manager.go @@ -27,6 +27,7 @@ type manager struct { type hooksCfgStorage struct { Get []*hookStorage + Head []*hookStorage Put []*hookStorage Delete []*hookStorage } @@ -51,6 +52,7 @@ func (m *manager) Load() error { // Create storage structure entry := &hooksCfgStorage{ Get: []*hookStorage{}, + Head: []*hookStorage{}, Put: []*hookStorage{}, Delete: []*hookStorage{}, } @@ -69,6 +71,18 @@ func (m *manager) Load() error { entry.Get = list } + // Check if HEAD action is present and have a config + if targetCfg.Actions.HEAD != nil && targetCfg.Actions.HEAD.Config != nil { + // Create list + list, err := m.createRestClients(targetCfg.Actions.HEAD.Config.Webhooks) + // Check error + if err != nil { + return err + } + // Store + entry.Head = list + } + // Check if PUT action is present and have a config if targetCfg.Actions.PUT != nil && targetCfg.Actions.PUT.Config != nil { // Create list @@ -348,6 +362,73 @@ func (m *manager) manageGETHooksInternal( ) } +func (m *manager) ManageHEADHooks( + ctx context.Context, + targetKey, requestPath string, + metadata *HeadInputMetadata, + s3Metadata *S3Metadata, +) { + // Separate functions to test logic without routine + go m.manageHEADHooksInternal(ctx, targetKey, requestPath, metadata, s3Metadata) +} + +func (m *manager) manageHEADHooksInternal( + ctx context.Context, + targetKey, requestPath string, + metadata *HeadInputMetadata, + s3Metadata *S3Metadata, +) { + // Get logger + logger := log.GetLoggerFromContext(ctx) + + // Get target storage + sto := m.storageMap[targetKey] + + // Check if storage is empty + if sto == nil || len(sto.Head) == 0 { + // Stop here + logger.Debugf("No HEAD hook declared for target %s", targetKey) + + return + } + + // Get hooks declared + hookClients := sto.Head + + // Create input metadata + inputMetadata := &HeadInputMetadataHookBody{ + IfMatch: metadata.IfMatch, + IfNoneMatch: metadata.IfNoneMatch, + } + // Manage if modified since + if metadata.IfModifiedSince != nil { + inputMetadata.IfModifiedSince = metadata.IfModifiedSince.Format(time.RFC3339) + } + // Manage if unmodified since + if metadata.IfUnmodifiedSince != nil { + inputMetadata.IfUnmodifiedSince = metadata.IfUnmodifiedSince.Format(time.RFC3339) + } + + // Create output metadata + outputMetadata := &OutputMetadataHookBody{ + Bucket: s3Metadata.Bucket, + Region: s3Metadata.Region, + S3Endpoint: s3Metadata.S3Endpoint, + Key: s3Metadata.Key, + } + + // Run hooks + m.runHooks( + ctx, + requestPath, + inputMetadata, + outputMetadata, + HEADAction, + targetKey, + hookClients, + ) +} + func (m *manager) runHooks( ctx context.Context, requestPath string, diff --git a/pkg/s3-proxy/webhook/manager_test.go b/pkg/s3-proxy/webhook/manager_test.go index 72ec18aa..46f9cddd 100644 --- a/pkg/s3-proxy/webhook/manager_test.go +++ b/pkg/s3-proxy/webhook/manager_test.go @@ -2390,3 +2390,711 @@ func Test_manager_manageGETHooksInternal(t *testing.T) { }) } } + +func Test_manager_manageHEADHooksInternal(t *testing.T) { + type responseMock struct { + statusCode int + body string + } + type requestResult struct { + method string + body string + headers map[string]string + } + type args struct { + targetKey string + requestPath string + metadata *HeadInputMetadata + s3Metadata *S3Metadata + } + type metricsIncXWebhooksMockResult struct { + input1 string + input2 string + times int + } + tests := []struct { + name string + args args + cfg *config.Config + injectMockServers bool + metricsIncFailedWebhooksMockResult metricsIncXWebhooksMockResult + metricsIncSucceedWebhooksMockResult metricsIncXWebhooksMockResult + responseMockList []responseMock + requestResult []requestResult + }{ + { + name: "no storage for target", + cfg: &config.Config{}, + args: args{targetKey: "tgt1"}, + }, + { + name: "empty storage for target", + cfg: &config.Config{ + Targets: map[string]*config.TargetConfig{ + "tgt1": {}, + }, + }, + args: args{targetKey: "tgt1"}, + }, + { + name: "should fail to call url", + cfg: &config.Config{ + Targets: map[string]*config.TargetConfig{ + "tgt1": { + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Config: &config.HeadActionConfigConfig{ + Webhooks: []*config.WebhookConfig{ + { + Method: "POST", + URL: "http://not-an-url", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + args: args{ + targetKey: "tgt1", + requestPath: "/fake", + s3Metadata: &S3Metadata{ + Bucket: "bucket", + Region: "region", + S3Endpoint: "s3endpoint", + Key: "key", + }, + metadata: &HeadInputMetadata{ + IfMatch: "ifmatch", + IfNoneMatch: "ifnonematch", + }, + }, + injectMockServers: false, + metricsIncFailedWebhooksMockResult: metricsIncXWebhooksMockResult{ + input1: "tgt1", + input2: "HEAD", + times: 1, + }, + }, + { + name: "should fail when bad request is present", + cfg: &config.Config{ + Targets: map[string]*config.TargetConfig{ + "tgt1": { + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Config: &config.HeadActionConfigConfig{ + Webhooks: []*config.WebhookConfig{ + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + args: args{ + targetKey: "tgt1", + requestPath: "/fake", + s3Metadata: &S3Metadata{ + Bucket: "bucket", + Region: "region", + S3Endpoint: "s3endpoint", + Key: "key", + }, + metadata: &HeadInputMetadata{ + IfMatch: "ifmatch", + IfNoneMatch: "ifnonematch", + }, + }, + injectMockServers: true, + metricsIncFailedWebhooksMockResult: metricsIncXWebhooksMockResult{ + input1: "tgt1", + input2: "HEAD", + times: 1, + }, + responseMockList: []responseMock{ + { + body: `{"error":true}`, + statusCode: 400, + }, + }, + requestResult: []requestResult{ + { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret", + "Content-Type": "application/json", + }, + }, + }, + }, + { + name: "should fail when internal server is present", + cfg: &config.Config{ + Targets: map[string]*config.TargetConfig{ + "tgt1": { + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Config: &config.HeadActionConfigConfig{ + Webhooks: []*config.WebhookConfig{ + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + args: args{ + targetKey: "tgt1", + requestPath: "/fake", + s3Metadata: &S3Metadata{ + Bucket: "bucket", + Region: "region", + S3Endpoint: "s3endpoint", + Key: "key", + }, + metadata: &HeadInputMetadata{ + IfMatch: "ifmatch", + IfNoneMatch: "ifnonematch", + }, + }, + injectMockServers: true, + metricsIncFailedWebhooksMockResult: metricsIncXWebhooksMockResult{ + input1: "tgt1", + input2: "HEAD", + times: 1, + }, + responseMockList: []responseMock{ + { + body: `{"error":true}`, + statusCode: 500, + }, + }, + requestResult: []requestResult{ + { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret", + "Content-Type": "application/json", + }, + }, + }, + }, + { + name: "should fail when internal server and a method not allowed error are present", + cfg: &config.Config{ + Targets: map[string]*config.TargetConfig{ + "tgt1": { + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Config: &config.HeadActionConfigConfig{ + Webhooks: []*config.WebhookConfig{ + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret", + }, + }, + }, + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret2", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + args: args{ + targetKey: "tgt1", + requestPath: "/fake", + s3Metadata: &S3Metadata{ + Bucket: "bucket", + Region: "region", + S3Endpoint: "s3endpoint", + Key: "key", + }, + metadata: &HeadInputMetadata{ + IfMatch: "ifmatch", + IfNoneMatch: "ifnonematch", + }, + }, + injectMockServers: true, + metricsIncFailedWebhooksMockResult: metricsIncXWebhooksMockResult{ + input1: "tgt1", + input2: "HEAD", + times: 2, + }, + responseMockList: []responseMock{ + { + body: `{"error":true}`, + statusCode: 500, + }, { + body: `{"error":"method not allowed"}`, + statusCode: 405, + }, + }, + requestResult: []requestResult{ + { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret", + "Content-Type": "application/json", + }, + }, { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret2", + "Content-Type": "application/json", + }, + }, + }, + }, + { + name: "should be ok with 2 success", + cfg: &config.Config{ + Targets: map[string]*config.TargetConfig{ + "tgt1": { + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Config: &config.HeadActionConfigConfig{ + Webhooks: []*config.WebhookConfig{ + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret", + }, + }, + Headers: map[string]string{ + "h1": "v1", + }, + }, + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret2", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + args: args{ + targetKey: "tgt1", + requestPath: "/fake", + s3Metadata: &S3Metadata{ + Bucket: "bucket", + Region: "region", + S3Endpoint: "s3endpoint", + Key: "key", + }, + metadata: &HeadInputMetadata{ + IfMatch: "ifmatch", + IfNoneMatch: "ifnonematch", + }, + }, + injectMockServers: true, + metricsIncSucceedWebhooksMockResult: metricsIncXWebhooksMockResult{ + input1: "tgt1", + input2: "HEAD", + times: 2, + }, + responseMockList: []responseMock{ + { + body: `{}`, + statusCode: 200, + }, { + body: `{}`, + statusCode: 201, + }, + }, + requestResult: []requestResult{ + { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret", + "Content-Type": "application/json", + "h1": "v1", + }, + }, { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret2", + "Content-Type": "application/json", + }, + }, + }, + }, + { + name: "should be ok with 1 success and 1 fail", + cfg: &config.Config{ + Targets: map[string]*config.TargetConfig{ + "tgt1": { + Actions: &config.ActionsConfig{ + HEAD: &config.HeadActionConfig{ + Config: &config.HeadActionConfigConfig{ + Webhooks: []*config.WebhookConfig{ + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret", + }, + }, + }, + { + Method: "POST", + SecretHeaders: map[string]*config.CredentialConfig{ + "authorization": { + Value: "secret2", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + args: args{ + targetKey: "tgt1", + requestPath: "/fake", + s3Metadata: &S3Metadata{ + Bucket: "bucket", + Region: "region", + S3Endpoint: "s3endpoint", + Key: "key", + }, + metadata: &HeadInputMetadata{ + IfMatch: "ifmatch", + IfNoneMatch: "ifnonematch", + }, + }, + injectMockServers: true, + metricsIncSucceedWebhooksMockResult: metricsIncXWebhooksMockResult{ + input1: "tgt1", + input2: "HEAD", + times: 1, + }, + metricsIncFailedWebhooksMockResult: metricsIncXWebhooksMockResult{ + input1: "tgt1", + input2: "HEAD", + times: 1, + }, + responseMockList: []responseMock{ + { + body: `{"error":"forbidden"}`, + statusCode: 403, + }, { + body: `{}`, + statusCode: 201, + }, + }, + requestResult: []requestResult{ + { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret", + "Content-Type": "application/json", + }, + }, { + method: "POST", + body: ` + { + "action":"HEAD", + "requestPath":"/fake", + "outputMetadata":{ + "bucket":"bucket", + "region":"region", + "s3Endpoint":"s3endpoint", + "key":"key" + }, + "inputMetadata":{ + "ifMatch":"ifmatch", + "ifModifiedSince":"", + "ifNoneMatch":"ifnonematch", + "ifUnmodifiedSince":"" + }, + "target": {"name":"tgt1"} + } + `, + headers: map[string]string{ + "Authorization": "secret2", + "Content-Type": "application/json", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + cfgManagerMock := cmocks.NewMockManager(ctrl) + metricsSvcMock := mmocks.NewMockClient(ctrl) + + cfgManagerMock.EXPECT().GetConfig().Return(tt.cfg) + + metricsSvcMock.EXPECT().IncFailedWebhooks( + tt.metricsIncFailedWebhooksMockResult.input1, + tt.metricsIncFailedWebhooksMockResult.input2, + ).Times( + tt.metricsIncFailedWebhooksMockResult.times, + ) + + metricsSvcMock.EXPECT().IncSucceedWebhooks( + tt.metricsIncSucceedWebhooksMockResult.input1, + tt.metricsIncSucceedWebhooksMockResult.input2, + ).Times( + tt.metricsIncSucceedWebhooksMockResult.times, + ) + + m := &manager{ + cfgManager: cfgManagerMock, + metricsSvc: metricsSvcMock, + storageMap: map[string]*hooksCfgStorage{}, + } + + // Create ctx + ctx := context.TODO() + ctx = log.SetLoggerInContext(ctx, log.NewLogger()) + ctx = opentracing.ContextWithSpan(ctx, opentracing.StartSpan("fake")) + + // Save request + reqs := make([]*struct { + Body string + Method string + Headers http.Header + }, 0) + + if tt.injectMockServers { + // Create mock servers + for _, v := range tt.cfg.Targets { + for i, v2 := range v.Actions.HEAD.Config.Webhooks { + // Get mock + m := tt.responseMockList[i] + + s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + by, err := io.ReadAll(r.Body) + assert.NoError(t, err) + + reqs = append(reqs, &struct { + Body string + Method string + Headers http.Header + }{ + Method: r.Method, + Headers: r.Header, + Body: string(by), + }) + + rw.WriteHeader(m.statusCode) + rw.Write([]byte(m.body)) + })) + + defer s.Close() + + v2.URL = s.URL + } + } + } + + // Load clients + err := m.Load() + assert.NoError(t, err) + + m.manageHEADHooksInternal( + ctx, + tt.args.targetKey, + tt.args.requestPath, + tt.args.metadata, + tt.args.s3Metadata, + ) + + // Test + assert.Len(t, reqs, len(tt.requestResult)) + for i, v := range tt.requestResult { + if i < len(reqs) { + assert.JSONEq(t, v.body, reqs[i].Body) + + assert.Equal(t, v.method, reqs[i].Method) + + for key, val := range v.headers { + assert.Equal(t, val, reqs[i].Headers.Get(key)) + } + } else { + assert.Fail(t, "No test results found for incoming request") + } + } + }) + } +} diff --git a/pkg/s3-proxy/webhook/mocks/mock_Manager.go b/pkg/s3-proxy/webhook/mocks/mock_Manager.go index 5b9bef51..fa2c3fbf 100644 --- a/pkg/s3-proxy/webhook/mocks/mock_Manager.go +++ b/pkg/s3-proxy/webhook/mocks/mock_Manager.go @@ -78,6 +78,18 @@ func (mr *MockManagerMockRecorder) ManageGETHooks(arg0, arg1, arg2, arg3, arg4 a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ManageGETHooks", reflect.TypeOf((*MockManager)(nil).ManageGETHooks), arg0, arg1, arg2, arg3, arg4) } +// ManageHEADHooks mocks base method. +func (m *MockManager) ManageHEADHooks(arg0 context.Context, arg1, arg2 string, arg3 *webhook.HeadInputMetadata, arg4 *webhook.S3Metadata) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ManageHEADHooks", arg0, arg1, arg2, arg3, arg4) +} + +// ManageHEADHooks indicates an expected call of ManageHEADHooks. +func (mr *MockManagerMockRecorder) ManageHEADHooks(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ManageHEADHooks", reflect.TypeOf((*MockManager)(nil).ManageHEADHooks), arg0, arg1, arg2, arg3, arg4) +} + // ManagePUTHooks mocks base method. func (m *MockManager) ManagePUTHooks(arg0 context.Context, arg1, arg2 string, arg3 *webhook.PutInputMetadata, arg4 *webhook.S3Metadata) { m.ctrl.T.Helper() diff --git a/pkg/s3-proxy/webhook/models.go b/pkg/s3-proxy/webhook/models.go index 49f9bfb3..19cfe97f 100644 --- a/pkg/s3-proxy/webhook/models.go +++ b/pkg/s3-proxy/webhook/models.go @@ -2,6 +2,7 @@ package webhook const ( GETAction = "GET" + HEADAction = "HEAD" PUTAction = "PUT" DELETEAction = "DELETE" ) @@ -28,6 +29,13 @@ type GetInputMetadataHookBody struct { Range string `json:"range"` } +type HeadInputMetadataHookBody struct { + IfModifiedSince string `json:"ifModifiedSince"` + IfMatch string `json:"ifMatch"` + IfNoneMatch string `json:"ifNoneMatch"` + IfUnmodifiedSince string `json:"ifUnmodifiedSince"` +} + type OutputMetadataHookBody struct { Bucket string `json:"bucket"` Region string `json:"region"`