Upstream versity/versitygw state of the touched files before 5d5ee06ee6
This commit is contained in:
1 parent
1d06fadbbd
commit
59493b527e
4 files changed
+5028
No files matched your search
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright 2023 Versity Software
|
||||||
|
// This file is licensed under the Apache License, Version 2.0
|
||||||
|
// (the "License"); you may not use this file except in compliance
|
||||||
|
// with the License. You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing,
|
||||||
|
// software distributed under the License is distributed on an
|
||||||
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
// KIND, either express or implied. See the License for the
|
||||||
|
// specific language governing permissions and limitations
|
||||||
|
// under the License.
|
||||||
|
|
||||||
|
package middlewares
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecodeURL url path unescapes the request url for the gateway
|
||||||
|
// to handle some special characters
|
||||||
|
func DecodeURL(ctx fiber.Ctx) error {
|
||||||
|
unescp, err := url.PathUnescape(string(ctx.Request().URI().PathOriginal()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx.Path(unescp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
// Copyright 2023 Versity Software
|
||||||
|
// This file is licensed under the Apache License, Version 2.0
|
||||||
|
// (the "License"); you may not use this file except in compliance
|
||||||
|
// with the License. You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing,
|
||||||
|
// software distributed under the License is distributed on an
|
||||||
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
// KIND, either express or implied. See the License for the
|
||||||
|
// specific language governing permissions and limitations
|
||||||
|
// under the License.
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
|
"github.com/versity/versitygw/s3err"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
shortTimeout = 30 * time.Second
|
||||||
|
longTimeout = 60 * time.Second
|
||||||
|
iso8601Format = "20060102T150405Z"
|
||||||
|
timefmt = "Mon, 02 Jan 2006 15:04:05 GMT"
|
||||||
|
nullVersionId = "null"
|
||||||
|
)
|
||||||
|
|
||||||
|
// router tests
|
||||||
|
func RouterPutPartNumberWithoutUploadId(s *S3Conf) error {
|
||||||
|
testName := "RouterPutPartNumberWithoutUploadId"
|
||||||
|
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodPut, s.endpoint+"/bucket/object", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := req.URL.Query()
|
||||||
|
query.Add("partNumber", "1")
|
||||||
|
req.URL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgMissingUploadId, "partNumber")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterPostRoot(s *S3Conf) error {
|
||||||
|
testName := "RouterPostRoot"
|
||||||
|
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, s.endpoint+"/", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkHTTPResponseApiErr(resp, s3err.GetMethodNotAllowedErr(http.MethodPost, s3err.ResourceTypeService, nil)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterPostObjectWithoutQuery(s *S3Conf) error {
|
||||||
|
testName := "RouterPostObjectWithoutQuery"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, s.endpoint+"/bucket/object", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkHTTPResponseApiErr(resp, s3err.GetMethodNotAllowedErr(http.MethodPost, s3err.ResourceTypeService, nil)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterPUTObjectOnlyUploadId(s *S3Conf) error {
|
||||||
|
testName := "RouterPUTObjectOnlyUploadId"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodPut, s.endpoint+"/bucket/object", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := req.URL.Query()
|
||||||
|
query.Add("uploadId", "my-upload-id")
|
||||||
|
req.URL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkHTTPResponseApiErr(resp, s3err.GetMethodNotAllowedErr(
|
||||||
|
http.MethodPut,
|
||||||
|
s3err.ResourceTypeUpload,
|
||||||
|
[]string{http.MethodDelete, http.MethodPost, http.MethodGet},
|
||||||
|
)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterGetUploadsWithKey(s *S3Conf) error {
|
||||||
|
testName := "RouterGetUploadsWithKey"
|
||||||
|
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, s.endpoint+"/bucket/object?uploads", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrGetUploadsWithKey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterCopySourceNotAllowed(s *S3Conf) error {
|
||||||
|
testName := "RouterCopySourceNotAllowed"
|
||||||
|
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
for _, method := range []string{
|
||||||
|
http.MethodPost,
|
||||||
|
http.MethodDelete,
|
||||||
|
http.MethodGet,
|
||||||
|
http.MethodHead,
|
||||||
|
} {
|
||||||
|
for _, path := range []string{
|
||||||
|
"/bucket",
|
||||||
|
"/bucket/object",
|
||||||
|
} {
|
||||||
|
if method == http.MethodPost {
|
||||||
|
// the error for POST request occurs only when uploadId is there
|
||||||
|
path += "?uploadId=something"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, s.endpoint+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to make %s request to %s", method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("x-amz-copy-source", "bucket/object")
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send %s request to %s", method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == http.MethodHead {
|
||||||
|
// for head requests only check the status code
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
return fmt.Errorf("expected 400 status code for HEAD %s request, instead got %v", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySource, "bucket/object")); err != nil {
|
||||||
|
return fmt.Errorf("%s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterListVersionsWithKey(s *S3Conf) error {
|
||||||
|
testName := "RouterListVersionsWithKey"
|
||||||
|
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, s.endpoint+"/bucket/object?versions", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrVersionsWithKey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterObjectAnnotationNotImplemented(s *S3Conf) error {
|
||||||
|
testName := "RouterObjectAnnotationNotImplemented"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
obj, name := "my-obj", "label"
|
||||||
|
_, err := putObjectWithData(10, &s3.PutObjectInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &obj,
|
||||||
|
}, s3client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||||
|
_, err = s3client.PutObjectAnnotation(ctx, &s3.PutObjectAnnotationInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &obj,
|
||||||
|
AnnotationName: &name,
|
||||||
|
AnnotationPayload: strings.NewReader(`{"label":"x"}`),
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)); err != nil {
|
||||||
|
return fmt.Errorf("PutObjectAnnotation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||||
|
_, err = s3client.GetObjectAnnotation(ctx, &s3.GetObjectAnnotationInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &obj,
|
||||||
|
AnnotationName: &name,
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)); err != nil {
|
||||||
|
return fmt.Errorf("GetObjectAnnotation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||||
|
_, err = s3client.ListObjectAnnotations(ctx, &s3.ListObjectAnnotationsInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &obj,
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)); err != nil {
|
||||||
|
return fmt.Errorf("ListObjectAnnotations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||||
|
_, err = s3client.DeleteObjectAnnotation(ctx, &s3.DeleteObjectAnnotationInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &obj,
|
||||||
|
AnnotationName: &name,
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)); err != nil {
|
||||||
|
return fmt.Errorf("DeleteObjectAnnotation: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterRenameObjectNotImplemented(s *S3Conf) error {
|
||||||
|
testName := "RouterRenameObjectNotImplemented"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
src, dst := "src-obj", "dst-obj"
|
||||||
|
_, err := putObjectWithData(10, &s3.PutObjectInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &src,
|
||||||
|
}, s3client)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||||
|
_, err = s3client.RenameObject(ctx, &s3.RenameObjectInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &dst,
|
||||||
|
RenameSource: &src,
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RouterObjectTorrentMethodNotAllowed(s *S3Conf) error {
|
||||||
|
testName := "RouterObjectTorrentMethodNotAllowed"
|
||||||
|
return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
for _, method := range []string{
|
||||||
|
http.MethodGet,
|
||||||
|
http.MethodHead,
|
||||||
|
http.MethodPut,
|
||||||
|
http.MethodDelete,
|
||||||
|
http.MethodPost,
|
||||||
|
} {
|
||||||
|
req, err := http.NewRequest(method, s.endpoint+"/bucket/object?torrent", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to make %s request: %w", method, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send %s request: %w", method, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowed []string
|
||||||
|
if method != http.MethodGet {
|
||||||
|
allowed = []string{http.MethodGet}
|
||||||
|
}
|
||||||
|
expected := s3err.GetMethodNotAllowedErr(method, s3err.ResourceTypeTorrent, allowed)
|
||||||
|
if got, want := resp.Header.Get("Allow"), expected.AllowedMethodsString(); got != want {
|
||||||
|
return fmt.Errorf("%s: expected Allow header %q, instead got %q", method, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if method == http.MethodHead {
|
||||||
|
// for head requests only check the status code
|
||||||
|
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||||
|
return fmt.Errorf("expected 405 status code for HEAD request, instead got %v", resp.StatusCode)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkHTTPResponseApiErr(resp, expected); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", method, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS middleware tests
|
||||||
|
func CORSMiddleware_invalid_method(s *S3Conf) error {
|
||||||
|
testName := "CORSMiddleware_invalid_method"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
CORSConfiguration: &types.CORSConfiguration{
|
||||||
|
CORSRules: []types.CORSRule{
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"http://www.example.com"},
|
||||||
|
AllowedMethods: []string{http.MethodPut},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a PutObject signed request
|
||||||
|
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), map[string]string{
|
||||||
|
"Origin": "http://www.example.com",
|
||||||
|
"Access-Control-Request-Method": "invalid_method",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := extractCORSHeaders(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkApiErr(result.err, s3err.GetInvalidCORSMethodErr("invalid_method"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORSMiddleware_invalid_headers(s *S3Conf) error {
|
||||||
|
testName := "CORSMiddleware_invalid_headers"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
CORSConfiguration: &types.CORSConfiguration{
|
||||||
|
CORSRules: []types.CORSRule{
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"http://www.example.com"},
|
||||||
|
AllowedMethods: []string{http.MethodPut},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a PutObject signed request
|
||||||
|
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), map[string]string{
|
||||||
|
"Origin": "http://www.example.com",
|
||||||
|
"Access-Control-Request-Headers": "invalid header",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := extractCORSHeaders(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkApiErr(result.err, s3err.GetInvalidCORSRequestHeaderErr("invalid header"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORSMiddleware_access_forbidden(s *S3Conf) error {
|
||||||
|
testName := "CORSMiddleware_access_forbidden"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
CORSConfiguration: &types.CORSConfiguration{
|
||||||
|
CORSRules: []types.CORSRule{
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"http://example.com", "https://example.com"},
|
||||||
|
AllowedMethods: []string{http.MethodGet},
|
||||||
|
AllowedHeaders: []string{"X-Amz-Date", "X-Amz-Content-Sha256"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"*"},
|
||||||
|
AllowedMethods: []string{http.MethodHead},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
origin string
|
||||||
|
method string
|
||||||
|
headers string
|
||||||
|
}{
|
||||||
|
// origin deson't match
|
||||||
|
{"http://non-matching-origin.net", http.MethodGet, "X-Amz-Date"},
|
||||||
|
// method doesn't match
|
||||||
|
{"http://example.com", http.MethodPut, "X-Amz-Content-Sha256"},
|
||||||
|
// header doesn't match
|
||||||
|
{"http://example.com", http.MethodGet, "X-Amz-Expected-Bucket-Owner"},
|
||||||
|
// extra header
|
||||||
|
{"http://example.com", http.MethodGet, "X-Amz-Date,X-Amz-Content-Sha256,Extra-Header"},
|
||||||
|
// extra header (2nd rule)
|
||||||
|
{"https://any-origin.com", http.MethodHead, "X-Amz-Extra-Header"},
|
||||||
|
// origin match, method not (2nd rule)
|
||||||
|
{"https://any-origin.com", http.MethodPost, ""},
|
||||||
|
} {
|
||||||
|
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), map[string]string{
|
||||||
|
"Origin": test.origin,
|
||||||
|
"Access-Control-Request-Headers": test.headers,
|
||||||
|
"Access-Control-Request-Method": test.method,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := extractCORSHeaders(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// no error expected, all the headers should be empty
|
||||||
|
if err := comparePreflightResult(&PreflightResult{}, res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORSMiddleware_access_granted(s *S3Conf) error {
|
||||||
|
testName := "CORSMiddleware_access_granted"
|
||||||
|
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||||
|
err := putBucketCors(s3client, &s3.PutBucketCorsInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
CORSConfiguration: &types.CORSConfiguration{
|
||||||
|
CORSRules: []types.CORSRule{
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"http://example.com", "https://example.com"},
|
||||||
|
AllowedMethods: []string{http.MethodGet, http.MethodHead},
|
||||||
|
AllowedHeaders: []string{"X-Amz-Date", "X-Amz-Content-Sha256"},
|
||||||
|
ExposeHeaders: []string{"Content-Type", "Content-Length"},
|
||||||
|
MaxAgeSeconds: getPtr(int32(100)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"*"},
|
||||||
|
AllowedMethods: []string{http.MethodHead},
|
||||||
|
AllowedHeaders: []string{"X-Amz-Meta-Something"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AllowedOrigins: []string{"something.net"},
|
||||||
|
AllowedMethods: []string{http.MethodPost, http.MethodPut},
|
||||||
|
AllowedHeaders: []string{"Authorization"},
|
||||||
|
ExposeHeaders: []string{"Content-Disposition", "Content-Encoding"},
|
||||||
|
MaxAgeSeconds: getPtr(int32(3000)),
|
||||||
|
ID: getPtr("unique_id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
varyHdr := "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
origin string
|
||||||
|
method string
|
||||||
|
headers string
|
||||||
|
result PreflightResult
|
||||||
|
}{
|
||||||
|
// first rule matches
|
||||||
|
{"http://example.com", http.MethodGet, "X-Amz-Date", PreflightResult{"http://example.com", "GET, HEAD", "x-amz-date, x-amz-content-sha256", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
|
||||||
|
{"http://example.com", http.MethodGet, "X-Amz-Content-Sha256", PreflightResult{"http://example.com", "GET, HEAD", "x-amz-date, x-amz-content-sha256", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
|
||||||
|
{"http://example.com", http.MethodHead, "", PreflightResult{"http://example.com", "GET, HEAD", "", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
|
||||||
|
{"https://example.com", http.MethodGet, "X-Amz-Date,X-Amz-Content-Sha256", PreflightResult{"https://example.com", "GET, HEAD", "x-amz-date, x-amz-content-sha256", "Content-Type, Content-Length", "100", "true", varyHdr, nil}},
|
||||||
|
// second rule matches
|
||||||
|
{"http://anything.com", http.MethodHead, "X-Amz-Meta-Something", PreflightResult{"*", "HEAD", "x-amz-meta-something", "", "", "false", varyHdr, nil}},
|
||||||
|
{"hello.com", http.MethodHead, "", PreflightResult{"*", "HEAD", "", "", "", "false", varyHdr, nil}},
|
||||||
|
// third rule matches
|
||||||
|
{"something.net", http.MethodPut, "Authorization", PreflightResult{"something.net", "POST, PUT", "authorization", "Content-Disposition, Content-Encoding", "3000", "true", varyHdr, nil}},
|
||||||
|
{"something.net", http.MethodPost, "", PreflightResult{"something.net", "POST, PUT", "", "Content-Disposition, Content-Encoding", "3000", "true", varyHdr, nil}},
|
||||||
|
} {
|
||||||
|
req, err := createSignedReq(http.MethodPut, s.endpoint, bucket+"/my-obj", s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), map[string]string{
|
||||||
|
"Origin": test.origin,
|
||||||
|
"Access-Control-Request-Headers": test.headers,
|
||||||
|
"Access-Control-Request-Method": test.method,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := extractCORSHeaders(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := comparePreflightResult(&test.result, res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,595 @@
|
|||||||
|
// Copyright 2026 Versity Software
|
||||||
|
// This file is licensed under the Apache License, Version 2.0
|
||||||
|
// (the "License"); you may not use this file except in compliance
|
||||||
|
// with the License. You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing,
|
||||||
|
// software distributed under the License is distributed on an
|
||||||
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
// KIND, either express or implied. See the License for the
|
||||||
|
// specific language governing permissions and limitations
|
||||||
|
// under the License.
|
||||||
|
|
||||||
|
package website
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/versity/versitygw/auth"
|
||||||
|
"github.com/versity/versitygw/backend"
|
||||||
|
"github.com/versity/versitygw/debuglogger"
|
||||||
|
"github.com/versity/versitygw/s3api/middlewares"
|
||||||
|
"github.com/versity/versitygw/s3api/utils"
|
||||||
|
"github.com/versity/versitygw/s3err"
|
||||||
|
"github.com/versity/versitygw/s3response"
|
||||||
|
)
|
||||||
|
|
||||||
|
var websiteAllowedMethods = []string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions}
|
||||||
|
|
||||||
|
type websiteController struct {
|
||||||
|
be backend.Backend
|
||||||
|
domain string
|
||||||
|
domainSuffix string
|
||||||
|
applyCORS fiber.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// newWebsiteController returns a controller that serves static website content.
|
||||||
|
// It resolves the bucket name from the Host header using the configured domain,
|
||||||
|
// fetches the website configuration, and serves objects accordingly.
|
||||||
|
//
|
||||||
|
// Virtual-host routing with --website-domain example.com:
|
||||||
|
// - Host "blog.example.com" -> bucket "blog"
|
||||||
|
// - Host "example.com" -> bucket "example.com" (apex)
|
||||||
|
//
|
||||||
|
// Catch-all mode (--website-domain omitted or empty):
|
||||||
|
// - Host "blog.example.com" -> bucket "blog.example.com"
|
||||||
|
// - Host "mysite.org" -> bucket "mysite.org"
|
||||||
|
func newWebsiteController(be backend.Backend, domain string) *websiteController {
|
||||||
|
controller := &websiteController{
|
||||||
|
be: be,
|
||||||
|
domain: domain,
|
||||||
|
domainSuffix: "." + domain,
|
||||||
|
}
|
||||||
|
controller.applyCORS = middlewares.ApplyBucketCORS(be, controller.resolveBucket, "")
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) Get(ctx fiber.Ctx) error {
|
||||||
|
return c.serve(ctx, c.getObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) Head(ctx fiber.Ctx) error {
|
||||||
|
return c.serve(ctx, c.headObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) Options(ctx fiber.Ctx) error {
|
||||||
|
bucket, err := c.resolveBucket(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origin := ctx.Get("Origin")
|
||||||
|
method := auth.CORSHTTPMethod(ctx.Get("Access-Control-Request-Method"))
|
||||||
|
headers := ctx.Get("Access-Control-Request-Headers")
|
||||||
|
|
||||||
|
if origin == "" {
|
||||||
|
debuglogger.Logf("origin is missing: %v", origin)
|
||||||
|
return sendError(ctx, s3err.GetAPIError(s3err.ErrMissingCORSOrigin))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !method.IsValid() {
|
||||||
|
debuglogger.Logf("invalid cors method: %s", method)
|
||||||
|
return sendError(ctx, s3err.GetInvalidCORSMethodErr(method.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedHeaders, err := auth.ParseCORSHeaders(headers)
|
||||||
|
if err != nil {
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cors, err := c.be.GetBucketCors(ctx.RequestCtx(), bucket)
|
||||||
|
if err != nil {
|
||||||
|
debuglogger.Logf("failed to get bucket cors: %v", err)
|
||||||
|
if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)) {
|
||||||
|
err = s3err.GetAccessForbiddenErr(s3err.ErrCORSIsNotEnabled, http.MethodOptions, s3err.ResourceTypeBucket)
|
||||||
|
debuglogger.Logf("bucket cors is not set: %v", err)
|
||||||
|
}
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
corsConfig, err := auth.ParseCORSOutput(cors)
|
||||||
|
if err != nil {
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowConfig, err := corsConfig.IsAllowed(origin, method, parsedHeaders, s3err.ResourceTypeObject)
|
||||||
|
if err != nil {
|
||||||
|
debuglogger.Logf("cors access forbidden: %v", err)
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCORSPreflightHeaders(ctx, allowConfig)
|
||||||
|
ctx.Status(http.StatusOK)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerWebsiteRoutes(app *fiber.App, be backend.Backend, domain string) {
|
||||||
|
controller := newWebsiteController(be, domain)
|
||||||
|
|
||||||
|
// percent-decode the request path, so object keys containing special
|
||||||
|
// characters resolve to the actual key. The s3 api and admin servers
|
||||||
|
// mount the same middleware.
|
||||||
|
app.Use("*", decodeURL)
|
||||||
|
|
||||||
|
app.Head("*", controller.Head)
|
||||||
|
app.Get("*", controller.Get)
|
||||||
|
app.Options("*", controller.Options)
|
||||||
|
app.All("*", controller.MethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeURL wraps the shared DecodeURL middleware to continue the chain and to
|
||||||
|
// report malformed percent-encoding as an html error page.
|
||||||
|
func decodeURL(ctx fiber.Ctx) error {
|
||||||
|
if err := middlewares.DecodeURL(ctx); err != nil {
|
||||||
|
debuglogger.Logf("failed to unescape the request path: %v", err)
|
||||||
|
return sendError(ctx, s3err.GetAPIError(s3err.ErrInvalidURI))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCORSPreflightHeaders(ctx fiber.Ctx, allowConfig *auth.CORSAllowanceConfig) {
|
||||||
|
ctx.Set("Access-Control-Allow-Origin", allowConfig.Origin)
|
||||||
|
ctx.Set("Access-Control-Allow-Methods", allowConfig.Methods)
|
||||||
|
ctx.Set("Access-Control-Expose-Headers", allowConfig.ExposedHeaders)
|
||||||
|
ctx.Set("Access-Control-Allow-Credentials", allowConfig.AllowCredentials)
|
||||||
|
ctx.Set("Access-Control-Allow-Headers", allowConfig.AllowHeaders)
|
||||||
|
ctx.Set("Vary", middlewares.VaryHdr)
|
||||||
|
if allowConfig.MaxAge != nil {
|
||||||
|
ctx.Set("Access-Control-Max-Age", strconv.Itoa(int(*allowConfig.MaxAge)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) MethodNotAllowed(ctx fiber.Ctx) error {
|
||||||
|
return sendError(ctx, s3err.GetMethodNotAllowedErr(ctx.Method(), s3err.ResourceTypeObject, websiteAllowedMethods))
|
||||||
|
}
|
||||||
|
|
||||||
|
type websiteRequestInfo struct {
|
||||||
|
bucket string
|
||||||
|
config *s3response.WebsiteConfiguration
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
type websiteObjectReader func(ctx fiber.Ctx, bucket, key string) websiteResult
|
||||||
|
|
||||||
|
func (c *websiteController) serve(ctx fiber.Ctx, readObject websiteObjectReader) error {
|
||||||
|
req, err := c.resolveRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.applyCORS(ctx); err != nil {
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.config.RedirectAllRequestsTo != nil {
|
||||||
|
return handleRedirectAll(ctx, req.config.RedirectAllRequestsTo, req.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule := req.config.MatchPrefetchRoutingRule(req.key); rule != nil {
|
||||||
|
return applyRedirect(ctx, rule.Redirect, rule.Condition, req.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedKey := resolveIndexKey(req.key, req.config)
|
||||||
|
result := readObject(ctx, req.bucket, resolvedKey)
|
||||||
|
if result.Err == nil {
|
||||||
|
return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject)
|
||||||
|
}
|
||||||
|
if result.StatusCode >= http.StatusInternalServerError {
|
||||||
|
return sendError(ctx, result.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule := req.config.MatchPostErrorRoutingRule(req.key, result.StatusCode); rule != nil {
|
||||||
|
return applyRedirect(ctx, rule.Redirect, rule.Condition, req.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) resolveRequest(ctx fiber.Ctx) (*websiteRequestInfo, error) {
|
||||||
|
bucket, err := c.resolveBucket(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key := strings.TrimPrefix(ctx.Path(), "/")
|
||||||
|
if err := validateWebsiteNames(bucket, key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := c.be.GetBucketWebsite(ctx.RequestCtx(), bucket)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := s3response.ParseWebsiteConfigOutput(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &websiteRequestInfo{
|
||||||
|
bucket: bucket,
|
||||||
|
config: config,
|
||||||
|
key: key,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWebsiteNames(bucket, key string) error {
|
||||||
|
if !utils.IsValidBucketName(bucket) {
|
||||||
|
return s3err.GetBucketErr(s3err.ErrInvalidBucketName, bucket)
|
||||||
|
}
|
||||||
|
if key != "" && !utils.IsObjectNameValid(key) {
|
||||||
|
return s3err.GetAPIError(s3err.ErrBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBucket extracts the bucket name from the request host header.
|
||||||
|
//
|
||||||
|
// It strips the port when present before applying website endpoint routing.
|
||||||
|
//
|
||||||
|
// When domain is set:
|
||||||
|
// - If host equals the domain exactly, the bucket IS the domain (apex).
|
||||||
|
// - If host ends with ".<domain>", the bucket is the subdomain part.
|
||||||
|
// - Otherwise, no bucket can be resolved.
|
||||||
|
//
|
||||||
|
// When domain is empty (catch-all mode):
|
||||||
|
// - The full hostname is used as the bucket name.
|
||||||
|
func (c *websiteController) resolveBucket(ctx fiber.Ctx) (string, error) {
|
||||||
|
host := ctx.Host()
|
||||||
|
if host == "" {
|
||||||
|
ctx.Set("Location", c.noBucketLocation(ctx, host))
|
||||||
|
return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip port from host if present. Be careful with IPv6: only strip if the
|
||||||
|
// last colon is not inside brackets.
|
||||||
|
host = stripHostPort(host)
|
||||||
|
|
||||||
|
if c.domain == "" {
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(host, c.domain) {
|
||||||
|
return c.domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerHost := strings.ToLower(host)
|
||||||
|
lowerDomainSuffix := strings.ToLower(c.domainSuffix)
|
||||||
|
if strings.HasSuffix(lowerHost, lowerDomainSuffix) {
|
||||||
|
bucket := host[:len(host)-len(c.domainSuffix)]
|
||||||
|
if bucket != "" && !strings.Contains(bucket, ".") {
|
||||||
|
return bucket, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Set("Location", c.noBucketLocation(ctx, ctx.Host()))
|
||||||
|
return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) noBucketLocation(ctx fiber.Ctx, host string) string {
|
||||||
|
locationHost := c.domain
|
||||||
|
if locationHost == "" {
|
||||||
|
locationHost = stripHostPort(host)
|
||||||
|
}
|
||||||
|
if locationHost == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
if c.domain != "" {
|
||||||
|
if port := hostPort(host); port != "" {
|
||||||
|
locationHost += ":" + port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s://%s/", ctx.Scheme(), locationHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripHostPort(host string) string {
|
||||||
|
if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") {
|
||||||
|
return host[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostPort(host string) string {
|
||||||
|
if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") {
|
||||||
|
return host[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type websiteResult struct {
|
||||||
|
Key string
|
||||||
|
StatusCode int
|
||||||
|
Object websiteObject
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type websiteObject struct {
|
||||||
|
Body io.ReadCloser
|
||||||
|
Headers map[string]*string
|
||||||
|
Metadata map[string]string
|
||||||
|
WebsiteRedirectLocation *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveIndexKey(key string, config *s3response.WebsiteConfiguration) string {
|
||||||
|
if config.IndexDocument != nil && config.IndexDocument.Suffix != "" {
|
||||||
|
if key == "" || strings.HasSuffix(key, "/") {
|
||||||
|
return key + config.IndexDocument.Suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) getObject(ctx fiber.Ctx, bucket, key string) websiteResult {
|
||||||
|
if err := auth.VerifyPublicAccess(ctx, c.be, []auth.Action{auth.GetObjectAction}, auth.PermissionRead, bucket, key); err != nil {
|
||||||
|
return websiteResult{
|
||||||
|
Key: key,
|
||||||
|
StatusCode: statusCodeFromError(err),
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.be.GetObject(ctx.RequestCtx(), &s3.GetObjectInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return websiteResult{
|
||||||
|
Key: key,
|
||||||
|
StatusCode: statusCodeFromError(err),
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return websiteResult{
|
||||||
|
Key: key,
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Object: websiteObject{
|
||||||
|
Body: result.Body,
|
||||||
|
Headers: getObjectHeaders(result),
|
||||||
|
Metadata: result.Metadata,
|
||||||
|
WebsiteRedirectLocation: result.WebsiteRedirectLocation,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *websiteController) headObject(ctx fiber.Ctx, bucket, key string) websiteResult {
|
||||||
|
if err := auth.VerifyPublicAccess(ctx, c.be, []auth.Action{auth.GetObjectAction}, auth.PermissionRead, bucket, key); err != nil {
|
||||||
|
return websiteResult{
|
||||||
|
Key: key,
|
||||||
|
StatusCode: statusCodeFromError(err),
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := c.be.HeadObject(ctx.RequestCtx(), &s3.HeadObjectInput{
|
||||||
|
Bucket: &bucket,
|
||||||
|
Key: &key,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return websiteResult{
|
||||||
|
Key: key,
|
||||||
|
StatusCode: statusCodeFromError(err),
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return websiteResult{
|
||||||
|
Key: key,
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Object: websiteObject{
|
||||||
|
Headers: headObjectHeaders(result),
|
||||||
|
Metadata: result.Metadata,
|
||||||
|
WebsiteRedirectLocation: result.WebsiteRedirectLocation,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusCodeFromError(err error) int {
|
||||||
|
var serr s3err.S3Error
|
||||||
|
if errors.As(err, &serr) {
|
||||||
|
return serr.StatusCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRedirectAll sends a 301 redirect for RedirectAllRequestsTo configuration.
|
||||||
|
func handleRedirectAll(ctx fiber.Ctx, redirect *s3response.RedirectAllRequestsTo, key string) error {
|
||||||
|
protocol := redirect.Protocol
|
||||||
|
if protocol == "" {
|
||||||
|
protocol = ctx.Scheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
location := websiteLocation(protocol, redirect.HostName, key,
|
||||||
|
string(ctx.Request().URI().QueryString()))
|
||||||
|
return sendRedirect(ctx, http.StatusMovedPermanently, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRedirect constructs and sends a redirect response from a routing rule.
|
||||||
|
func applyRedirect(ctx fiber.Ctx, redirect *s3response.Redirect, condition *s3response.RoutingRuleCondition, originalKey string) error {
|
||||||
|
protocol := redirect.Protocol
|
||||||
|
if protocol == "" {
|
||||||
|
protocol = ctx.Scheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
host := redirect.HostName
|
||||||
|
if host == "" {
|
||||||
|
host = ctx.Host()
|
||||||
|
}
|
||||||
|
|
||||||
|
key := originalKey
|
||||||
|
if redirect.ReplaceKeyWith != "" {
|
||||||
|
key = redirect.ReplaceKeyWith
|
||||||
|
} else if redirect.ReplaceKeyPrefixWith != "" && condition != nil && condition.KeyPrefixEquals != "" {
|
||||||
|
key = redirect.ReplaceKeyPrefixWith + strings.TrimPrefix(originalKey, condition.KeyPrefixEquals)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpCode := http.StatusMovedPermanently
|
||||||
|
if redirect.HttpRedirectCode != "" {
|
||||||
|
if code, err := strconv.Atoi(redirect.HttpRedirectCode); err == nil {
|
||||||
|
httpCode = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location := websiteLocation(protocol, host, key,
|
||||||
|
string(ctx.Request().URI().QueryString()))
|
||||||
|
return sendRedirect(ctx, httpCode, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// websiteLocation builds a redirect target url. The object key is
|
||||||
|
// percent-encoded, as the keys reaching this point are decoded.
|
||||||
|
func websiteLocation(protocol, host, key, query string) string {
|
||||||
|
location := url.URL{
|
||||||
|
Scheme: protocol,
|
||||||
|
Host: host,
|
||||||
|
Path: "/" + key,
|
||||||
|
RawQuery: query,
|
||||||
|
}
|
||||||
|
|
||||||
|
return location.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendRedirect(ctx fiber.Ctx, statusCode int, location string) error {
|
||||||
|
ctx.Set("Location", location)
|
||||||
|
_, _ = utils.EnsureRequestIDs(ctx)
|
||||||
|
ctx.Status(statusCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getObjectHeaders(result *s3.GetObjectOutput) map[string]*string {
|
||||||
|
return map[string]*string{
|
||||||
|
"ETag": result.ETag,
|
||||||
|
"accept-ranges": result.AcceptRanges,
|
||||||
|
"Cache-Control": result.CacheControl,
|
||||||
|
"Content-Disposition": result.ContentDisposition,
|
||||||
|
"Content-Encoding": result.ContentEncoding,
|
||||||
|
"Content-Language": result.ContentLanguage,
|
||||||
|
"Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength),
|
||||||
|
"Content-Range": result.ContentRange,
|
||||||
|
"Content-Type": result.ContentType,
|
||||||
|
"Expires": result.ExpiresString,
|
||||||
|
"Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat),
|
||||||
|
"x-amz-restore": result.Restore,
|
||||||
|
"x-amz-version-id": result.VersionId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func headObjectHeaders(result *s3.HeadObjectOutput) map[string]*string {
|
||||||
|
return map[string]*string{
|
||||||
|
"ETag": result.ETag,
|
||||||
|
"accept-ranges": result.AcceptRanges,
|
||||||
|
"Cache-Control": result.CacheControl,
|
||||||
|
"Content-Disposition": result.ContentDisposition,
|
||||||
|
"Content-Encoding": result.ContentEncoding,
|
||||||
|
"Content-Language": result.ContentLanguage,
|
||||||
|
"Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength),
|
||||||
|
"Content-Range": result.ContentRange,
|
||||||
|
"Content-Type": result.ContentType,
|
||||||
|
"Expires": result.ExpiresString,
|
||||||
|
"Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat),
|
||||||
|
"x-amz-restore": result.Restore,
|
||||||
|
"x-amz-version-id": result.VersionId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveWebsiteResult(ctx fiber.Ctx, bucket string, config *s3response.WebsiteConfiguration, result websiteResult, readObject websiteObjectReader) error {
|
||||||
|
if result.Err == nil {
|
||||||
|
// Precedence: RedirectAllRequestsTo, pre-fetch routing rules, object
|
||||||
|
// redirect metadata, then post-error routing/error documents.
|
||||||
|
if location := backend.GetStringFromPtr(result.Object.WebsiteRedirectLocation); location != "" {
|
||||||
|
if result.Object.Body != nil {
|
||||||
|
_ = result.Object.Body.Close()
|
||||||
|
}
|
||||||
|
return sendRedirect(ctx, http.StatusMovedPermanently, location)
|
||||||
|
}
|
||||||
|
return serveObject(ctx, result.Object, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.ErrorDocument != nil && config.ErrorDocument.Key != "" {
|
||||||
|
return serveErrorDocument(ctx, readObject, bucket, config.ErrorDocument.Key, result.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendError(ctx, result.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveObject(ctx fiber.Ctx, object websiteObject, statusCode int) error {
|
||||||
|
ctx.Status(statusCode)
|
||||||
|
setWebsiteObjectHeaders(ctx, object)
|
||||||
|
|
||||||
|
if object.Body == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
_, err := io.Copy(ctx.Response().BodyWriter(), object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return sendError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWebsiteObjectHeaders(ctx fiber.Ctx, object websiteObject) {
|
||||||
|
utils.SetMetaHeaders(ctx, object.Metadata)
|
||||||
|
for key, value := range object.Headers {
|
||||||
|
if value != nil && *value != "" {
|
||||||
|
ctx.Set(key, *value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveErrorDocument fetches and serves the configured error document.
|
||||||
|
func serveErrorDocument(ctx fiber.Ctx, readObject websiteObjectReader, bucket, errorDocKey string, statusCode int) error {
|
||||||
|
result := readObject(ctx, bucket, errorDocKey)
|
||||||
|
if result.Err != nil {
|
||||||
|
return sendError(ctx, result.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return serveObject(ctx, result.Object, statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendError sends a simple HTML error page.
|
||||||
|
func sendError(ctx fiber.Ctx, err error) error {
|
||||||
|
requestId, hostId := utils.EnsureRequestIDs(ctx)
|
||||||
|
serr, ok := err.(s3err.S3Error)
|
||||||
|
if !ok {
|
||||||
|
debuglogger.InternalError(err)
|
||||||
|
serr = s3err.GetAPIError(s3err.ErrInternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response().Header.Set("x-amz-error-code", serr.BaseError().Code)
|
||||||
|
ctx.Response().Header.Set("x-amz-error-message", serr.BaseError().Description)
|
||||||
|
if methodErr, ok := serr.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 {
|
||||||
|
ctx.Response().Header.Set("Allow", methodErr.AllowedMethodsString())
|
||||||
|
}
|
||||||
|
utils.SetRegionMismatchHeader(ctx, serr)
|
||||||
|
|
||||||
|
ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8)
|
||||||
|
return ctx.Status(serr.StatusCode()).Send(serr.HTMLBody(requestId, hostId))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user