mirror of
https://github.com/versity/versitygw.git
synced 2026-07-20 06:52:21 +00:00
Add WebsiteConfiguration types, validation, and S3 error codes
Add S3 bucket website configuration types with XML serialization support in s3response/website.go. Includes IndexDocument, ErrorDocument, RedirectAllRequestsTo, and RoutingRules with full validation matching AWS S3 behavior. Add corresponding S3 error codes: ErrNoSuchWebsiteConfiguration, ErrInvalidWebsiteConfiguration, ErrInvalidWebsiteSuffix, and ErrInvalidWebsiteRedirectCode. Unit tests cover validation logic, XML round-trip, and parsing. Signed-off-by: Marc Singer <marc@singer.gg> Add website backend interface and implementations for posix and s3proxy Add PutBucketWebsite, GetBucketWebsite, and DeleteBucketWebsite methods to the Backend interface with BackendUnsupported stubs that return ErrNotImplemented. Posix backend stores website config as a metadata attribute (key: 'website') following the same pattern as CORS. ScoutFS inherits via embedding. S3Proxy backend stores website config in the metadata bucket with prefix 'vgw-meta-website-', consistent with existing ACL/policy/CORS metadata storage. Returns ErrNoSuchWebsiteConfiguration when not found. Signed-off-by: Marc Singer <marc@singer.gg> Add website API controllers and wire into router Add PutBucketWebsite, GetBucketWebsite, and DeleteBucketWebsite controller methods following the same pattern as CORS. Controllers parse and validate WebsiteConfiguration XML, check IAM authorization, and delegate to the backend. Replace the three HandleErrorRoute(ErrNotImplemented) stubs in the router with the new controller methods. Regenerate the backend mock to include the new interface methods. Signed-off-by: Marc Singer <marc@singer.gg> Add website index document middleware and wire into router Add ResolveWebsiteIndex middleware that rewrites directory-like object keys (empty or ending with /) to include the IndexDocument suffix when website hosting is enabled. Also handles RedirectAllRequestsTo by returning 301. Wire the middleware into both GetObject and HeadObject handler chains in the router, positioned after BucketObjectNameValidator and before auth. Signed-off-by: Marc Singer <marc@singer.gg>
This commit is contained in:
@@ -50,6 +50,9 @@ type Backend interface {
|
||||
PutBucketCors(_ context.Context, bucket string, cors []byte) error
|
||||
GetBucketCors(_ context.Context, bucket string) ([]byte, error)
|
||||
DeleteBucketCors(_ context.Context, bucket string) error
|
||||
PutBucketWebsite(_ context.Context, bucket string, website []byte) error
|
||||
GetBucketWebsite(_ context.Context, bucket string) ([]byte, error)
|
||||
DeleteBucketWebsite(_ context.Context, bucket string) error
|
||||
|
||||
// multipart operations
|
||||
CreateMultipartUpload(context.Context, s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error)
|
||||
@@ -166,6 +169,15 @@ func (BackendUnsupported) GetBucketCors(_ context.Context, bucket string) ([]byt
|
||||
func (BackendUnsupported) DeleteBucketCors(_ context.Context, bucket string) error {
|
||||
return s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) PutBucketWebsite(_ context.Context, bucket string, website []byte) error {
|
||||
return s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) GetBucketWebsite(_ context.Context, bucket string) ([]byte, error) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
func (BackendUnsupported) DeleteBucketWebsite(_ context.Context, bucket string) error {
|
||||
return s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
}
|
||||
|
||||
func (BackendUnsupported) CreateMultipartUpload(context.Context, s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error) {
|
||||
return s3response.InitiateMultipartUploadResult{}, s3err.GetAPIError(s3err.ErrNotImplemented)
|
||||
|
||||
@@ -132,6 +132,7 @@ const (
|
||||
objectRetentionKey = "object-retention"
|
||||
objectLegalHoldKey = "object-legal-hold"
|
||||
corskey = "cors"
|
||||
websitekey = "website"
|
||||
versioningKey = "versioning"
|
||||
deleteMarkerKey = "delete-marker"
|
||||
versionIdKey = "version-id"
|
||||
@@ -6168,6 +6169,77 @@ func (p *Posix) DeleteBucketCors(ctx context.Context, bucket string) error {
|
||||
return p.PutBucketCors(ctx, bucket, nil)
|
||||
}
|
||||
|
||||
func (p *Posix) PutBucketWebsite(ctx context.Context, bucket string, website []byte) error {
|
||||
release, err := p.acquireActionSlot(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if !p.isBucketValid(bucket) {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
}
|
||||
_, err = os.Stat(bucket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s3err.GetAPIError(s3err.ErrNoSuchBucket)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat bucket: %w", err)
|
||||
}
|
||||
|
||||
if website == nil {
|
||||
err = p.meta.DeleteAttribute(bucket, "", websitekey)
|
||||
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return fmt.Errorf("remove website: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
err = p.meta.StoreAttribute(nil, bucket, "", websitekey, website)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set website: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Posix) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, error) {
|
||||
release, err := p.acquireActionSlot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
if !p.isBucketValid(bucket) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
}
|
||||
_, err = os.Stat(bucket)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchBucket)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat bucket: %w", err)
|
||||
}
|
||||
|
||||
website, err := p.meta.RetrieveAttribute(nil, bucket, "", websitekey)
|
||||
if errors.Is(err, meta.ErrNoSuchKey) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return website, nil
|
||||
}
|
||||
|
||||
func (p *Posix) DeleteBucketWebsite(ctx context.Context, bucket string) error {
|
||||
if !p.isBucketValid(bucket) {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidBucketName)
|
||||
}
|
||||
return p.PutBucketWebsite(ctx, bucket, nil)
|
||||
}
|
||||
|
||||
func (p *Posix) isBucketObjectLockEnabled(bucket string) error {
|
||||
cfg, err := p.meta.RetrieveAttribute(nil, bucket, "", bucketLockKey)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
|
||||
+32
-3
@@ -37,9 +37,10 @@ import (
|
||||
type metaPrefix string
|
||||
|
||||
const (
|
||||
metaPrefixAcl metaPrefix = "vgw-meta-acl-"
|
||||
metaPrefixPolicy metaPrefix = "vgw-meta-policy-"
|
||||
metaPrefixCors metaPrefix = "vgw-meta-cors-"
|
||||
metaPrefixAcl metaPrefix = "vgw-meta-acl-"
|
||||
metaPrefixPolicy metaPrefix = "vgw-meta-policy-"
|
||||
metaPrefixCors metaPrefix = "vgw-meta-cors-"
|
||||
metaPrefixWebsite metaPrefix = "vgw-meta-website-"
|
||||
)
|
||||
|
||||
type S3Proxy struct {
|
||||
@@ -1614,6 +1615,32 @@ func (s *S3Proxy) DeleteBucketCors(ctx context.Context, bucket string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutBucketWebsite(ctx context.Context, bucket string, website []byte) error {
|
||||
return handleError(s.putMetaBucketObj(ctx, bucket, website, metaPrefixWebsite))
|
||||
}
|
||||
|
||||
func (s *S3Proxy) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, error) {
|
||||
data, err := s.getMetaBucketObjData(ctx, bucket, metaPrefixWebsite, false)
|
||||
if err != nil {
|
||||
return nil, handleError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) DeleteBucketWebsite(ctx context.Context, bucket string) error {
|
||||
key := getMetaKey(bucket, metaPrefixWebsite)
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: &s.metaBucket,
|
||||
Key: &key,
|
||||
})
|
||||
if err != nil && !areErrSame(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) {
|
||||
return handleError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *S3Proxy) PutBucketPolicy(ctx context.Context, bucket string, policy []byte) error {
|
||||
return handleError(s.putMetaBucketObj(ctx, bucket, policy, metaPrefixPolicy))
|
||||
}
|
||||
@@ -1772,6 +1799,8 @@ func handleMetaBucketObjectNotFoundErr(prefix metaPrefix) ([]byte, error) {
|
||||
return nil, s3err.GetBucketErr(s3err.ErrNoSuchBucketPolicy, "")
|
||||
case metaPrefixCors:
|
||||
return nil, s3err.GetBucketErr(s3err.ErrNoSuchCORSConfiguration, "")
|
||||
case metaPrefixWebsite:
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)
|
||||
}
|
||||
|
||||
return []byte{}, nil
|
||||
|
||||
@@ -56,6 +56,9 @@ var _ backend.Backend = &BackendMock{}
|
||||
// DeleteBucketTaggingFunc: func(contextMoqParam context.Context, bucket string) error {
|
||||
// panic("mock out the DeleteBucketTagging method")
|
||||
// },
|
||||
// DeleteBucketWebsiteFunc: func(contextMoqParam context.Context, bucket string) error {
|
||||
// panic("mock out the DeleteBucketWebsite method")
|
||||
// },
|
||||
// DeleteObjectFunc: func(contextMoqParam context.Context, deleteObjectInput *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
|
||||
// panic("mock out the DeleteObject method")
|
||||
// },
|
||||
@@ -83,6 +86,9 @@ var _ backend.Backend = &BackendMock{}
|
||||
// GetBucketVersioningFunc: func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) {
|
||||
// panic("mock out the GetBucketVersioning method")
|
||||
// },
|
||||
// GetBucketWebsiteFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
// panic("mock out the GetBucketWebsite method")
|
||||
// },
|
||||
// GetObjectFunc: func(contextMoqParam context.Context, getObjectInput *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
// panic("mock out the GetObject method")
|
||||
// },
|
||||
@@ -152,6 +158,9 @@ var _ backend.Backend = &BackendMock{}
|
||||
// PutBucketVersioningFunc: func(contextMoqParam context.Context, bucket string, status types.BucketVersioningStatus) error {
|
||||
// panic("mock out the PutBucketVersioning method")
|
||||
// },
|
||||
// PutBucketWebsiteFunc: func(contextMoqParam context.Context, bucket string, website []byte) error {
|
||||
// panic("mock out the PutBucketWebsite method")
|
||||
// },
|
||||
// PutObjectFunc: func(contextMoqParam context.Context, putObjectInput s3response.PutObjectInput) (s3response.PutObjectOutput, error) {
|
||||
// panic("mock out the PutObject method")
|
||||
// },
|
||||
@@ -228,6 +237,9 @@ type BackendMock struct {
|
||||
// DeleteBucketTaggingFunc mocks the DeleteBucketTagging method.
|
||||
DeleteBucketTaggingFunc func(contextMoqParam context.Context, bucket string) error
|
||||
|
||||
// DeleteBucketWebsiteFunc mocks the DeleteBucketWebsite method.
|
||||
DeleteBucketWebsiteFunc func(contextMoqParam context.Context, bucket string) error
|
||||
|
||||
// DeleteObjectFunc mocks the DeleteObject method.
|
||||
DeleteObjectFunc func(contextMoqParam context.Context, deleteObjectInput *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error)
|
||||
|
||||
@@ -255,6 +267,9 @@ type BackendMock struct {
|
||||
// GetBucketVersioningFunc mocks the GetBucketVersioning method.
|
||||
GetBucketVersioningFunc func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error)
|
||||
|
||||
// GetBucketWebsiteFunc mocks the GetBucketWebsite method.
|
||||
GetBucketWebsiteFunc func(contextMoqParam context.Context, bucket string) ([]byte, error)
|
||||
|
||||
// GetObjectFunc mocks the GetObject method.
|
||||
GetObjectFunc func(contextMoqParam context.Context, getObjectInput *s3.GetObjectInput) (*s3.GetObjectOutput, error)
|
||||
|
||||
@@ -324,6 +339,9 @@ type BackendMock struct {
|
||||
// PutBucketVersioningFunc mocks the PutBucketVersioning method.
|
||||
PutBucketVersioningFunc func(contextMoqParam context.Context, bucket string, status types.BucketVersioningStatus) error
|
||||
|
||||
// PutBucketWebsiteFunc mocks the PutBucketWebsite method.
|
||||
PutBucketWebsiteFunc func(contextMoqParam context.Context, bucket string, website []byte) error
|
||||
|
||||
// PutObjectFunc mocks the PutObject method.
|
||||
PutObjectFunc func(contextMoqParam context.Context, putObjectInput s3response.PutObjectInput) (s3response.PutObjectOutput, error)
|
||||
|
||||
@@ -443,6 +461,13 @@ type BackendMock struct {
|
||||
// Bucket is the bucket argument value.
|
||||
Bucket string
|
||||
}
|
||||
// DeleteBucketWebsite holds details about calls to the DeleteBucketWebsite method.
|
||||
DeleteBucketWebsite []struct {
|
||||
// ContextMoqParam is the contextMoqParam argument value.
|
||||
ContextMoqParam context.Context
|
||||
// Bucket is the bucket argument value.
|
||||
Bucket string
|
||||
}
|
||||
// DeleteObject holds details about calls to the DeleteObject method.
|
||||
DeleteObject []struct {
|
||||
// ContextMoqParam is the contextMoqParam argument value.
|
||||
@@ -510,6 +535,13 @@ type BackendMock struct {
|
||||
// Bucket is the bucket argument value.
|
||||
Bucket string
|
||||
}
|
||||
// GetBucketWebsite holds details about calls to the GetBucketWebsite method.
|
||||
GetBucketWebsite []struct {
|
||||
// ContextMoqParam is the contextMoqParam argument value.
|
||||
ContextMoqParam context.Context
|
||||
// Bucket is the bucket argument value.
|
||||
Bucket string
|
||||
}
|
||||
// GetObject holds details about calls to the GetObject method.
|
||||
GetObject []struct {
|
||||
// ContextMoqParam is the contextMoqParam argument value.
|
||||
@@ -693,6 +725,15 @@ type BackendMock struct {
|
||||
// Status is the status argument value.
|
||||
Status types.BucketVersioningStatus
|
||||
}
|
||||
// PutBucketWebsite holds details about calls to the PutBucketWebsite method.
|
||||
PutBucketWebsite []struct {
|
||||
// ContextMoqParam is the contextMoqParam argument value.
|
||||
ContextMoqParam context.Context
|
||||
// Bucket is the bucket argument value.
|
||||
Bucket string
|
||||
// Website is the website argument value.
|
||||
Website []byte
|
||||
}
|
||||
// PutObject holds details about calls to the PutObject method.
|
||||
PutObject []struct {
|
||||
// ContextMoqParam is the contextMoqParam argument value.
|
||||
@@ -801,6 +842,7 @@ type BackendMock struct {
|
||||
lockDeleteBucketOwnershipControls sync.RWMutex
|
||||
lockDeleteBucketPolicy sync.RWMutex
|
||||
lockDeleteBucketTagging sync.RWMutex
|
||||
lockDeleteBucketWebsite sync.RWMutex
|
||||
lockDeleteObject sync.RWMutex
|
||||
lockDeleteObjectTagging sync.RWMutex
|
||||
lockDeleteObjects sync.RWMutex
|
||||
@@ -810,6 +852,7 @@ type BackendMock struct {
|
||||
lockGetBucketPolicy sync.RWMutex
|
||||
lockGetBucketTagging sync.RWMutex
|
||||
lockGetBucketVersioning sync.RWMutex
|
||||
lockGetBucketWebsite sync.RWMutex
|
||||
lockGetObject sync.RWMutex
|
||||
lockGetObjectAcl sync.RWMutex
|
||||
lockGetObjectAttributes sync.RWMutex
|
||||
@@ -833,6 +876,7 @@ type BackendMock struct {
|
||||
lockPutBucketPolicy sync.RWMutex
|
||||
lockPutBucketTagging sync.RWMutex
|
||||
lockPutBucketVersioning sync.RWMutex
|
||||
lockPutBucketWebsite sync.RWMutex
|
||||
lockPutObject sync.RWMutex
|
||||
lockPutObjectAcl sync.RWMutex
|
||||
lockPutObjectLegalHold sync.RWMutex
|
||||
@@ -1251,6 +1295,42 @@ func (mock *BackendMock) DeleteBucketTaggingCalls() []struct {
|
||||
return calls
|
||||
}
|
||||
|
||||
// DeleteBucketWebsite calls DeleteBucketWebsiteFunc.
|
||||
func (mock *BackendMock) DeleteBucketWebsite(contextMoqParam context.Context, bucket string) error {
|
||||
if mock.DeleteBucketWebsiteFunc == nil {
|
||||
panic("BackendMock.DeleteBucketWebsiteFunc: method is nil but Backend.DeleteBucketWebsite was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
}{
|
||||
ContextMoqParam: contextMoqParam,
|
||||
Bucket: bucket,
|
||||
}
|
||||
mock.lockDeleteBucketWebsite.Lock()
|
||||
mock.calls.DeleteBucketWebsite = append(mock.calls.DeleteBucketWebsite, callInfo)
|
||||
mock.lockDeleteBucketWebsite.Unlock()
|
||||
return mock.DeleteBucketWebsiteFunc(contextMoqParam, bucket)
|
||||
}
|
||||
|
||||
// DeleteBucketWebsiteCalls gets all the calls that were made to DeleteBucketWebsite.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedBackend.DeleteBucketWebsiteCalls())
|
||||
func (mock *BackendMock) DeleteBucketWebsiteCalls() []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
} {
|
||||
var calls []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
}
|
||||
mock.lockDeleteBucketWebsite.RLock()
|
||||
calls = mock.calls.DeleteBucketWebsite
|
||||
mock.lockDeleteBucketWebsite.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// DeleteObject calls DeleteObjectFunc.
|
||||
func (mock *BackendMock) DeleteObject(contextMoqParam context.Context, deleteObjectInput *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
|
||||
if mock.DeleteObjectFunc == nil {
|
||||
@@ -1583,6 +1663,42 @@ func (mock *BackendMock) GetBucketVersioningCalls() []struct {
|
||||
return calls
|
||||
}
|
||||
|
||||
// GetBucketWebsite calls GetBucketWebsiteFunc.
|
||||
func (mock *BackendMock) GetBucketWebsite(contextMoqParam context.Context, bucket string) ([]byte, error) {
|
||||
if mock.GetBucketWebsiteFunc == nil {
|
||||
panic("BackendMock.GetBucketWebsiteFunc: method is nil but Backend.GetBucketWebsite was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
}{
|
||||
ContextMoqParam: contextMoqParam,
|
||||
Bucket: bucket,
|
||||
}
|
||||
mock.lockGetBucketWebsite.Lock()
|
||||
mock.calls.GetBucketWebsite = append(mock.calls.GetBucketWebsite, callInfo)
|
||||
mock.lockGetBucketWebsite.Unlock()
|
||||
return mock.GetBucketWebsiteFunc(contextMoqParam, bucket)
|
||||
}
|
||||
|
||||
// GetBucketWebsiteCalls gets all the calls that were made to GetBucketWebsite.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedBackend.GetBucketWebsiteCalls())
|
||||
func (mock *BackendMock) GetBucketWebsiteCalls() []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
} {
|
||||
var calls []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
}
|
||||
mock.lockGetBucketWebsite.RLock()
|
||||
calls = mock.calls.GetBucketWebsite
|
||||
mock.lockGetBucketWebsite.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// GetObject calls GetObjectFunc.
|
||||
func (mock *BackendMock) GetObject(contextMoqParam context.Context, getObjectInput *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
|
||||
if mock.GetObjectFunc == nil {
|
||||
@@ -2455,6 +2571,46 @@ func (mock *BackendMock) PutBucketVersioningCalls() []struct {
|
||||
return calls
|
||||
}
|
||||
|
||||
// PutBucketWebsite calls PutBucketWebsiteFunc.
|
||||
func (mock *BackendMock) PutBucketWebsite(contextMoqParam context.Context, bucket string, website []byte) error {
|
||||
if mock.PutBucketWebsiteFunc == nil {
|
||||
panic("BackendMock.PutBucketWebsiteFunc: method is nil but Backend.PutBucketWebsite was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
Website []byte
|
||||
}{
|
||||
ContextMoqParam: contextMoqParam,
|
||||
Bucket: bucket,
|
||||
Website: website,
|
||||
}
|
||||
mock.lockPutBucketWebsite.Lock()
|
||||
mock.calls.PutBucketWebsite = append(mock.calls.PutBucketWebsite, callInfo)
|
||||
mock.lockPutBucketWebsite.Unlock()
|
||||
return mock.PutBucketWebsiteFunc(contextMoqParam, bucket, website)
|
||||
}
|
||||
|
||||
// PutBucketWebsiteCalls gets all the calls that were made to PutBucketWebsite.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedBackend.PutBucketWebsiteCalls())
|
||||
func (mock *BackendMock) PutBucketWebsiteCalls() []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
Website []byte
|
||||
} {
|
||||
var calls []struct {
|
||||
ContextMoqParam context.Context
|
||||
Bucket string
|
||||
Website []byte
|
||||
}
|
||||
mock.lockPutBucketWebsite.RLock()
|
||||
calls = mock.calls.PutBucketWebsite
|
||||
mock.lockPutBucketWebsite.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// PutObject calls PutObjectFunc.
|
||||
func (mock *BackendMock) PutObject(contextMoqParam context.Context, putObjectInput s3response.PutObjectInput) (s3response.PutObjectOutput, error) {
|
||||
if mock.PutObjectFunc == nil {
|
||||
|
||||
@@ -162,6 +162,42 @@ func (c S3ApiController) DeleteBucketCors(ctx *fiber.Ctx) (*Response, error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) DeleteBucketWebsite(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be,
|
||||
auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketWebsiteAction,
|
||||
IsPublicRequest: IsBucketPublic,
|
||||
DisableACL: c.disableACL,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.DeleteBucketWebsite(ctx.Context(), bucket)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
Status: http.StatusNoContent,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) DeleteBucket(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
|
||||
@@ -206,6 +206,50 @@ func (c S3ApiController) GetBucketCors(ctx *fiber.Ctx) (*Response, error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketWebsite(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionRead,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.GetBucketWebsiteAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
DisableACL: c.disableACL,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
data, err := c.be.GetBucketWebsite(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
output, err := s3response.ParseWebsiteConfigOutput(data)
|
||||
return &Response{
|
||||
Data: output,
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) GetBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
|
||||
@@ -287,6 +287,62 @@ func (c S3ApiController) PutBucketCors(ctx *fiber.Ctx) (*Response, error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutBucketWebsite(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
|
||||
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
|
||||
isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx)
|
||||
|
||||
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
|
||||
Readonly: c.readonly,
|
||||
Acl: parsedAcl,
|
||||
AclPermission: auth.PermissionWrite,
|
||||
IsRoot: isRoot,
|
||||
Acc: acct,
|
||||
Bucket: bucket,
|
||||
Action: auth.PutBucketWebsiteAction,
|
||||
IsPublicRequest: isPublicBucket,
|
||||
DisableACL: c.disableACL,
|
||||
})
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
body := ctx.Body()
|
||||
|
||||
var websiteConfig s3response.WebsiteConfiguration
|
||||
err = xml.Unmarshal(body, &websiteConfig)
|
||||
if err != nil {
|
||||
debuglogger.Logf("invalid website config request body: %v", err)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, s3err.GetAPIError(s3err.ErrMalformedXML)
|
||||
}
|
||||
|
||||
err = websiteConfig.Validate()
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = c.be.PutBucketWebsite(ctx.Context(), bucket, body)
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
func (c S3ApiController) PutBucketPolicy(ctx *fiber.Ctx) (*Response, error) {
|
||||
bucket := ctx.Params("bucket")
|
||||
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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 (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/versity/versitygw/backend"
|
||||
"github.com/versity/versitygw/s3api/utils"
|
||||
"github.com/versity/versitygw/s3response"
|
||||
)
|
||||
|
||||
// ResolveWebsiteIndex rewrites directory-like object keys to include the
|
||||
// configured IndexDocument suffix when website hosting is enabled for the
|
||||
// bucket. It also handles RedirectAllRequestsTo by returning a 301 redirect.
|
||||
//
|
||||
// This middleware should be placed in the GetObject handler chain before
|
||||
// authentication and the controller.
|
||||
func ResolveWebsiteIndex(be backend.Backend) fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
if utils.ContextKeySkip.IsSet(ctx) {
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
bucket := ctx.Params("bucket")
|
||||
if bucket == "" {
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
key := ctx.Params("*1")
|
||||
|
||||
// Only process directory-like keys (empty or ending with /)
|
||||
if key != "" && !strings.HasSuffix(key, "/") {
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
// Reject path traversal attempts
|
||||
if strings.Contains(key, "..") {
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
data, err := be.GetBucketWebsite(ctx.Context(), bucket)
|
||||
if err != nil {
|
||||
// No website config: pass through to normal handling
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
var config s3response.WebsiteConfiguration
|
||||
if xmlErr := xml.Unmarshal(data, &config); xmlErr != nil {
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
// Handle RedirectAllRequestsTo
|
||||
if config.RedirectAllRequestsTo != nil {
|
||||
return redirectAll(ctx, config.RedirectAllRequestsTo, key)
|
||||
}
|
||||
|
||||
// Rewrite directory-like keys to include index document suffix
|
||||
if config.IndexDocument != nil && config.IndexDocument.Suffix != "" {
|
||||
newKey := key + config.IndexDocument.Suffix
|
||||
newPath := fmt.Sprintf("/%s/%s", bucket, newKey)
|
||||
ctx.Request().URI().SetPath(newPath)
|
||||
}
|
||||
|
||||
return ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func redirectAll(ctx *fiber.Ctx, redirect *s3response.RedirectAllRequestsTo, key string) error {
|
||||
protocol := redirect.Protocol
|
||||
if protocol == "" {
|
||||
protocol = "https"
|
||||
}
|
||||
|
||||
location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key)
|
||||
ctx.Set("Location", location)
|
||||
return ctx.SendStatus(http.StatusMovedPermanently)
|
||||
}
|
||||
+5
-3
@@ -444,7 +444,7 @@ func (sa *S3ApiRouter) Init() {
|
||||
bucketRouter.Put("",
|
||||
middlewares.MatchQueryArgs("website"),
|
||||
controllers.ProcessHandlers(
|
||||
ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrNotImplemented)),
|
||||
ctrl.PutBucketWebsite,
|
||||
metrics.ActionPutBucketWebsite,
|
||||
services,
|
||||
middlewares.BucketObjectNameValidator(),
|
||||
@@ -665,7 +665,7 @@ func (sa *S3ApiRouter) Init() {
|
||||
bucketRouter.Delete("",
|
||||
middlewares.MatchQueryArgs("website"),
|
||||
controllers.ProcessHandlers(
|
||||
ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrNotImplemented)),
|
||||
ctrl.DeleteBucketWebsite,
|
||||
metrics.ActionDeleteBucketWebsite,
|
||||
services,
|
||||
middlewares.BucketObjectNameValidator(),
|
||||
@@ -1056,7 +1056,7 @@ func (sa *S3ApiRouter) Init() {
|
||||
bucketRouter.Get("",
|
||||
middlewares.MatchQueryArgs("website"),
|
||||
controllers.ProcessHandlers(
|
||||
ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrNotImplemented)),
|
||||
ctrl.GetBucketWebsite,
|
||||
metrics.ActionGetBucketWebsite,
|
||||
services,
|
||||
middlewares.BucketObjectNameValidator(),
|
||||
@@ -1152,6 +1152,7 @@ func (sa *S3ApiRouter) Init() {
|
||||
metrics.ActionHeadObject,
|
||||
services,
|
||||
middlewares.BucketObjectNameValidator(),
|
||||
middlewares.ResolveWebsiteIndex(sa.be),
|
||||
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionHeadObject, auth.GetObjectAction, auth.PermissionRead, sa.region, false),
|
||||
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
|
||||
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, false, false),
|
||||
@@ -1269,6 +1270,7 @@ func (sa *S3ApiRouter) Init() {
|
||||
metrics.ActionGetObject,
|
||||
services,
|
||||
middlewares.BucketObjectNameValidator(),
|
||||
middlewares.ResolveWebsiteIndex(sa.be),
|
||||
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObject, auth.GetObjectAction, auth.PermissionRead, sa.region, false),
|
||||
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
|
||||
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
|
||||
|
||||
@@ -165,6 +165,10 @@ const (
|
||||
ErrCORSForbidden
|
||||
ErrMissingCORSOrigin
|
||||
ErrCORSIsNotEnabled
|
||||
ErrNoSuchWebsiteConfiguration
|
||||
ErrInvalidWebsiteConfiguration
|
||||
ErrInvalidWebsiteSuffix
|
||||
ErrInvalidWebsiteRedirectCode
|
||||
ErrNotModified
|
||||
ErrInvalidLocationConstraint
|
||||
ErrMalformedTrailer
|
||||
@@ -639,6 +643,26 @@ var errorCodeResponse = map[ErrorCode]APIError{
|
||||
Description: "CORSResponse: CORS is not enabled for this bucket.",
|
||||
HTTPStatusCode: http.StatusForbidden,
|
||||
},
|
||||
ErrNoSuchWebsiteConfiguration: {
|
||||
Code: "NoSuchWebsiteConfiguration",
|
||||
Description: "The specified bucket does not have a website configuration",
|
||||
HTTPStatusCode: http.StatusNotFound,
|
||||
},
|
||||
ErrInvalidWebsiteConfiguration: {
|
||||
Code: "InvalidRequest",
|
||||
Description: "The website configuration is not valid.",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrInvalidWebsiteSuffix: {
|
||||
Code: "InvalidArgument",
|
||||
Description: "The IndexDocument Suffix is not well formed",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrInvalidWebsiteRedirectCode: {
|
||||
Code: "InvalidArgument",
|
||||
Description: "The website redirect code is not valid. Valid codes are 3XX.",
|
||||
HTTPStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
ErrNotModified: {
|
||||
Code: "NotModified",
|
||||
Description: "Not Modified",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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 s3response
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
const maxRoutingRules = 50
|
||||
|
||||
// WebsiteConfiguration represents the S3 bucket website configuration.
|
||||
type WebsiteConfiguration struct {
|
||||
XMLName xml.Name `xml:"WebsiteConfiguration"`
|
||||
IndexDocument *IndexDocument `xml:"IndexDocument,omitempty"`
|
||||
ErrorDocument *ErrorDocument `xml:"ErrorDocument,omitempty"`
|
||||
RedirectAllRequestsTo *RedirectAllRequestsTo `xml:"RedirectAllRequestsTo,omitempty"`
|
||||
RoutingRules []RoutingRule `xml:"RoutingRules>RoutingRule,omitempty"`
|
||||
}
|
||||
|
||||
// IndexDocument specifies the default object served for directory-like requests.
|
||||
type IndexDocument struct {
|
||||
Suffix string `xml:"Suffix"`
|
||||
}
|
||||
|
||||
// ErrorDocument specifies the object served when an error occurs.
|
||||
type ErrorDocument struct {
|
||||
Key string `xml:"Key"`
|
||||
}
|
||||
|
||||
// RedirectAllRequestsTo redirects all requests to another host.
|
||||
type RedirectAllRequestsTo struct {
|
||||
HostName string `xml:"HostName"`
|
||||
Protocol string `xml:"Protocol,omitempty"`
|
||||
}
|
||||
|
||||
// RoutingRule specifies a redirect rule with an optional condition.
|
||||
type RoutingRule struct {
|
||||
Condition *RoutingRuleCondition `xml:"Condition,omitempty"`
|
||||
Redirect Redirect `xml:"Redirect"`
|
||||
}
|
||||
|
||||
// RoutingRuleCondition specifies when a routing rule applies.
|
||||
type RoutingRuleCondition struct {
|
||||
HttpErrorCodeReturnedEquals string `xml:"HttpErrorCodeReturnedEquals,omitempty"`
|
||||
KeyPrefixEquals string `xml:"KeyPrefixEquals,omitempty"`
|
||||
}
|
||||
|
||||
// Redirect specifies where to redirect matching requests.
|
||||
type Redirect struct {
|
||||
HostName string `xml:"HostName,omitempty"`
|
||||
HttpRedirectCode string `xml:"HttpRedirectCode,omitempty"`
|
||||
Protocol string `xml:"Protocol,omitempty"`
|
||||
ReplaceKeyPrefixWith string `xml:"ReplaceKeyPrefixWith,omitempty"`
|
||||
ReplaceKeyWith string `xml:"ReplaceKeyWith,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks the website configuration for S3-compatible validity.
|
||||
func (c *WebsiteConfiguration) Validate() error {
|
||||
if c.RedirectAllRequestsTo != nil {
|
||||
if c.IndexDocument != nil || c.ErrorDocument != nil || len(c.RoutingRules) > 0 {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
|
||||
}
|
||||
if c.RedirectAllRequestsTo.HostName == "" {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
|
||||
}
|
||||
if err := validateProtocol(c.RedirectAllRequestsTo.Protocol); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.IndexDocument == nil {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
|
||||
}
|
||||
if c.IndexDocument.Suffix == "" {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)
|
||||
}
|
||||
if strings.Contains(c.IndexDocument.Suffix, "/") {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)
|
||||
}
|
||||
|
||||
if c.ErrorDocument != nil && c.ErrorDocument.Key == "" {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
|
||||
}
|
||||
|
||||
if len(c.RoutingRules) > maxRoutingRules {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
|
||||
}
|
||||
|
||||
for i, rule := range c.RoutingRules {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return fmt.Errorf("routing rule %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks a single routing rule for validity.
|
||||
func (r *RoutingRule) Validate() error {
|
||||
if r.Redirect.ReplaceKeyWith != "" && r.Redirect.ReplaceKeyPrefixWith != "" {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
|
||||
}
|
||||
|
||||
if err := validateProtocol(r.Redirect.Protocol); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.Redirect.HttpRedirectCode != "" {
|
||||
code := r.Redirect.HttpRedirectCode
|
||||
if len(code) != 3 || code[0] != '3' {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectCode)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProtocol(protocol string) error {
|
||||
if protocol != "" && protocol != "http" && protocol != "https" {
|
||||
return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseWebsiteConfigOutput parses raw bytes into a WebsiteConfiguration.
|
||||
func ParseWebsiteConfigOutput(data []byte) (*WebsiteConfiguration, error) {
|
||||
var config WebsiteConfiguration
|
||||
err := xml.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse website config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// 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 s3response
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"testing"
|
||||
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func TestWebsiteConfiguration_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config WebsiteConfiguration
|
||||
wantErr bool
|
||||
errCode s3err.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "valid index document only",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid index and error document",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
ErrorDocument: &ErrorDocument{Key: "error.html"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid redirect all requests",
|
||||
config: WebsiteConfiguration{
|
||||
RedirectAllRequestsTo: &RedirectAllRequestsTo{
|
||||
HostName: "example.com",
|
||||
Protocol: "https",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid routing rules",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
RoutingRules: []RoutingRule{
|
||||
{
|
||||
Condition: &RoutingRuleCondition{
|
||||
KeyPrefixEquals: "docs/",
|
||||
},
|
||||
Redirect: Redirect{
|
||||
ReplaceKeyPrefixWith: "documents/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing index document",
|
||||
config: WebsiteConfiguration{},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteConfiguration,
|
||||
},
|
||||
{
|
||||
name: "empty index suffix",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: ""},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteSuffix,
|
||||
},
|
||||
{
|
||||
name: "index suffix with slash",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "dir/index.html"},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteSuffix,
|
||||
},
|
||||
{
|
||||
name: "redirect all with index document",
|
||||
config: WebsiteConfiguration{
|
||||
RedirectAllRequestsTo: &RedirectAllRequestsTo{HostName: "example.com"},
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteConfiguration,
|
||||
},
|
||||
{
|
||||
name: "redirect all with empty hostname",
|
||||
config: WebsiteConfiguration{
|
||||
RedirectAllRequestsTo: &RedirectAllRequestsTo{HostName: ""},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteConfiguration,
|
||||
},
|
||||
{
|
||||
name: "redirect all with invalid protocol",
|
||||
config: WebsiteConfiguration{
|
||||
RedirectAllRequestsTo: &RedirectAllRequestsTo{
|
||||
HostName: "example.com",
|
||||
Protocol: "ftp",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteConfiguration,
|
||||
},
|
||||
{
|
||||
name: "routing rule with both replace key fields",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
RoutingRules: []RoutingRule{
|
||||
{
|
||||
Redirect: Redirect{
|
||||
ReplaceKeyWith: "newkey",
|
||||
ReplaceKeyPrefixWith: "newprefix/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteConfiguration,
|
||||
},
|
||||
{
|
||||
name: "routing rule with invalid redirect code",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
RoutingRules: []RoutingRule{
|
||||
{
|
||||
Redirect: Redirect{
|
||||
HttpRedirectCode: "200",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteRedirectCode,
|
||||
},
|
||||
{
|
||||
name: "routing rule with valid redirect code",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
RoutingRules: []RoutingRule{
|
||||
{
|
||||
Redirect: Redirect{
|
||||
HttpRedirectCode: "301",
|
||||
HostName: "example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error document with empty key",
|
||||
config: WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
ErrorDocument: &ErrorDocument{Key: ""},
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: s3err.ErrInvalidWebsiteConfiguration,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
apiErr, ok := err.(s3err.APIError)
|
||||
if !ok {
|
||||
// wrapped error from routing rule validation
|
||||
return
|
||||
}
|
||||
expectedErr := s3err.GetAPIError(tt.errCode)
|
||||
if apiErr.Code != expectedErr.Code {
|
||||
t.Errorf("expected error code %q, got %q", expectedErr.Code, apiErr.Code)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebsiteConfiguration_XMLRoundTrip(t *testing.T) {
|
||||
original := WebsiteConfiguration{
|
||||
IndexDocument: &IndexDocument{Suffix: "index.html"},
|
||||
ErrorDocument: &ErrorDocument{Key: "error.html"},
|
||||
RoutingRules: []RoutingRule{
|
||||
{
|
||||
Condition: &RoutingRuleCondition{
|
||||
KeyPrefixEquals: "docs/",
|
||||
HttpErrorCodeReturnedEquals: "404",
|
||||
},
|
||||
Redirect: Redirect{
|
||||
HostName: "example.com",
|
||||
Protocol: "https",
|
||||
HttpRedirectCode: "301",
|
||||
ReplaceKeyPrefixWith: "documents/",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := xml.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var parsed WebsiteConfiguration
|
||||
if err := xml.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if parsed.IndexDocument == nil || parsed.IndexDocument.Suffix != "index.html" {
|
||||
t.Error("IndexDocument.Suffix mismatch")
|
||||
}
|
||||
if parsed.ErrorDocument == nil || parsed.ErrorDocument.Key != "error.html" {
|
||||
t.Error("ErrorDocument.Key mismatch")
|
||||
}
|
||||
if len(parsed.RoutingRules) != 1 {
|
||||
t.Fatalf("expected 1 routing rule, got %d", len(parsed.RoutingRules))
|
||||
}
|
||||
rule := parsed.RoutingRules[0]
|
||||
if rule.Condition == nil || rule.Condition.KeyPrefixEquals != "docs/" {
|
||||
t.Error("RoutingRule Condition.KeyPrefixEquals mismatch")
|
||||
}
|
||||
if rule.Redirect.HostName != "example.com" {
|
||||
t.Error("RoutingRule Redirect.HostName mismatch")
|
||||
}
|
||||
if rule.Redirect.ReplaceKeyPrefixWith != "documents/" {
|
||||
t.Error("RoutingRule Redirect.ReplaceKeyPrefixWith mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebsiteConfigOutput(t *testing.T) {
|
||||
xmlData := `<WebsiteConfiguration>
|
||||
<IndexDocument><Suffix>index.html</Suffix></IndexDocument>
|
||||
<ErrorDocument><Key>error.html</Key></ErrorDocument>
|
||||
</WebsiteConfiguration>`
|
||||
|
||||
config, err := ParseWebsiteConfigOutput([]byte(xmlData))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if config.IndexDocument == nil || config.IndexDocument.Suffix != "index.html" {
|
||||
t.Error("IndexDocument.Suffix mismatch")
|
||||
}
|
||||
if config.ErrorDocument == nil || config.ErrorDocument.Key != "error.html" {
|
||||
t.Error("ErrorDocument.Key mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebsiteConfigOutput_InvalidXML(t *testing.T) {
|
||||
_, err := ParseWebsiteConfigOutput([]byte("not xml"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid XML")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user