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:
Marc Singer
2026-04-05 11:34:46 +02:00
committed by niksis02
parent 8ae0d36d80
commit ac5c3b4a86
12 changed files with 956 additions and 6 deletions
+12
View File
@@ -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)
+72
View File
@@ -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
View File
@@ -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
+156
View File
@@ -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 {
+36
View File
@@ -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)
+44
View File
@@ -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)
+56
View File
@@ -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)
+94
View File
@@ -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
View File
@@ -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),
+24
View File
@@ -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",
+151
View File
@@ -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
}
+274
View File
@@ -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")
}
}