diff --git a/backend/backend.go b/backend/backend.go index 021bca2e..9b992955 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -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) diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 9bff308a..1e34ff0d 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -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) { diff --git a/backend/s3proxy/s3.go b/backend/s3proxy/s3.go index 715ad959..cb662055 100644 --- a/backend/s3proxy/s3.go +++ b/backend/s3proxy/s3.go @@ -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 diff --git a/s3api/controllers/backend_moq_test.go b/s3api/controllers/backend_moq_test.go index 530df639..ba96058e 100644 --- a/s3api/controllers/backend_moq_test.go +++ b/s3api/controllers/backend_moq_test.go @@ -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 { diff --git a/s3api/controllers/bucket-delete.go b/s3api/controllers/bucket-delete.go index 564f293c..6d517ef3 100644 --- a/s3api/controllers/bucket-delete.go +++ b/s3api/controllers/bucket-delete.go @@ -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) diff --git a/s3api/controllers/bucket-get.go b/s3api/controllers/bucket-get.go index 199333f8..3a31156b 100644 --- a/s3api/controllers/bucket-get.go +++ b/s3api/controllers/bucket-get.go @@ -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) diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go index 860fb015..a83c291f 100644 --- a/s3api/controllers/bucket-put.go +++ b/s3api/controllers/bucket-put.go @@ -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) diff --git a/s3api/middlewares/website.go b/s3api/middlewares/website.go new file mode 100644 index 00000000..90c0db0a --- /dev/null +++ b/s3api/middlewares/website.go @@ -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) +} diff --git a/s3api/router.go b/s3api/router.go index b1cfdd98..b735ebbc 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -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), diff --git a/s3err/s3err.go b/s3err/s3err.go index ddbec479..3be9fdc7 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -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", diff --git a/s3response/website.go b/s3response/website.go new file mode 100644 index 00000000..85d2bf24 --- /dev/null +++ b/s3response/website.go @@ -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 +} diff --git a/s3response/website_test.go b/s3response/website_test.go new file mode 100644 index 00000000..a8f6b4fb --- /dev/null +++ b/s3response/website_test.go @@ -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 := ` + index.html + error.html + ` + + 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") + } +}