Skip to content

Commit

Permalink
feat: Add HEAD request support
Browse files Browse the repository at this point in the history
Close #474
  • Loading branch information
oxyno-zeta committed Sep 23, 2024
1 parent 5968733 commit 7fd1750
Show file tree
Hide file tree
Showing 24 changed files with 2,748 additions and 82 deletions.
8 changes: 8 additions & 0 deletions conf/config-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/configuration/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/configuration/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions docs/feature-guide/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions docs/feature-guide/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 |
Expand Down
148 changes: 116 additions & 32 deletions pkg/s3-proxy/bucket/bucket-req-impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -157,31 +166,36 @@ 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 {
// Get S3 client
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
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 16 additions & 8 deletions pkg/s3-proxy/bucket/bucket-req-impl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions pkg/s3-proxy/bucket/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 7fd1750

Please sign in to comment.