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-06-10 12:40:45 +04:00
committed by niksis02
parent 8ae0d36d80
commit ac5c3b4a86
12 changed files with 956 additions and 6 deletions
+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),