diff --git a/.github/workflows/website-hosting-tests.yml b/.github/workflows/website-hosting-tests.yml
new file mode 100644
index 00000000..448d1153
--- /dev/null
+++ b/.github/workflows/website-hosting-tests.yml
@@ -0,0 +1,13 @@
+name: website hosting tests
+permissions: {}
+on: pull_request
+
+jobs:
+ build-and-run:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: run website hosting tests
+ run: make test-website-hosting
diff --git a/Makefile b/Makefile
index 6da6d45f..0bf43207 100644
--- a/Makefile
+++ b/Makefile
@@ -107,3 +107,12 @@ test-host-style:
COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \
exit $$status
+# Run the static website hosting tests in docker containers
+.PHONY: test-website-hosting
+test-website-hosting:
+ @compose_file=tests/website-hosting-tests/docker-compose.yml; \
+ COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans >/dev/null 2>&1 || true; \
+ COMPOSE_MENU=false docker compose -f "$$compose_file" up --build --abort-on-container-exit --exit-code-from test; \
+ status=$$?; \
+ COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \
+ exit $$status
diff --git a/backend/azure/azure.go b/backend/azure/azure.go
index ad411f11..ea5439f7 100644
--- a/backend/azure/azure.go
+++ b/backend/azure/azure.go
@@ -63,6 +63,7 @@ const (
keyTags key = "Tags"
keyPolicy key = "Policy"
keyCors key = "Cors"
+ keyWebsite key = "Website"
keyBucketLock key = "Bucketlock"
keyObjRetention key = "Objectretention"
keyObjLegalHold key = "Objectlegalhold"
@@ -88,6 +89,7 @@ func (key) Table() map[string]struct{} {
"tags": {},
"policy": {},
"bucketlock": {},
+ "website": {},
"objectretention": {},
"vgwexpires": {},
"objectlegalhold": {},
@@ -1994,6 +1996,40 @@ func (az *Azure) DeleteBucketCors(ctx context.Context, bucket string) error {
return az.PutBucketCors(ctx, bucket, nil)
}
+func (az *Azure) PutBucketWebsite(ctx context.Context, bucket string, website []byte) error {
+ if website == nil {
+ return az.deleteContainerMetaData(ctx, bucket, string(keyWebsite))
+ }
+
+ encoded, err := backend.MarshalWebsiteConfig(website, true)
+ if err != nil {
+ return err
+ }
+
+ return az.setContainerMetaData(ctx, bucket, string(keyWebsite), encoded)
+}
+
+func (az *Azure) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, error) {
+ website, err := az.getContainerMetaData(ctx, bucket, string(keyWebsite))
+ if err != nil {
+ return nil, err
+ }
+ if len(website) == 0 {
+ return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket)
+ }
+
+ decoded, err := backend.UnmarshalWebsiteConfig(website, true)
+ if err != nil {
+ return nil, err
+ }
+
+ return decoded, nil
+}
+
+func (az *Azure) DeleteBucketWebsite(ctx context.Context, bucket string) error {
+ return az.PutBucketWebsite(ctx, bucket, nil)
+}
+
func (az *Azure) PutObjectLockConfiguration(ctx context.Context, bucket string, config []byte) error {
return az.setContainerMetaData(ctx, bucket, string(keyBucketLock), config)
}
diff --git a/backend/common.go b/backend/common.go
index baa22307..536f7a4d 100644
--- a/backend/common.go
+++ b/backend/common.go
@@ -436,7 +436,7 @@ func MarshalMpUploadMetadata(mpMeta MpUploadMetadata, base64Encode bool) ([]byte
return nil, fmt.Errorf("marshal mp metadata: %w", err)
}
- compressed, err := compressMpUploadMetadata(mpMetaJSON)
+ compressed, err := CompressData(mpMetaJSON)
if err != nil {
return nil, fmt.Errorf("compress mp metadata: %w", err)
}
@@ -471,7 +471,43 @@ func UnmarshalMpUploadMetadata(data []byte, base64Decode bool) (MpUploadMetadata
return mpMeta, nil
}
-func compressMpUploadMetadata(data []byte) ([]byte, error) {
+// MarshalWebsiteConfig returns a compressed representation of a website
+// configuration. When base64Encode is true, the compressed bytes are
+// base64-encoded so they can be stored in azure string-only metadata values.
+func MarshalWebsiteConfig(website []byte, base64Encode bool) ([]byte, error) {
+ compressed, err := CompressData(website)
+ if err != nil {
+ return nil, fmt.Errorf("compress website config: %w", err)
+ }
+
+ if !base64Encode {
+ return compressed, nil
+ }
+
+ encoded := make([]byte, base64.StdEncoding.EncodedLen(len(compressed)))
+ base64.StdEncoding.Encode(encoded, compressed)
+ return encoded, nil
+}
+
+// UnmarshalWebsiteConfig decodes data produced by MarshalWebsiteConfig.
+func UnmarshalWebsiteConfig(data []byte, base64Decode bool) ([]byte, error) {
+ if base64Decode {
+ compressed, err := base64.StdEncoding.DecodeString(string(data))
+ if err != nil {
+ return nil, fmt.Errorf("decode website config: %w", err)
+ }
+ data = compressed
+ }
+
+ website, err := DecompressData(data)
+ if err != nil {
+ return nil, fmt.Errorf("decompress website config: %w", err)
+ }
+
+ return website, nil
+}
+
+func CompressData(data []byte) ([]byte, error) {
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
if _, err := gz.Write(data); err != nil {
@@ -484,19 +520,28 @@ func compressMpUploadMetadata(data []byte) ([]byte, error) {
return compressed.Bytes(), nil
}
-func unmarshalCompressedMpUploadMetadata(compressed []byte) (MpUploadMetadata, error) {
- var mpMeta MpUploadMetadata
- gz, err := gzip.NewReader(bytes.NewReader(compressed))
+func DecompressData(data []byte) ([]byte, error) {
+ gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
- return mpMeta, fmt.Errorf("decompress mp metadata: %w", err)
+ return nil, err
}
decompressed, err := io.ReadAll(gz)
closeErr := gz.Close()
if err != nil {
- return mpMeta, fmt.Errorf("decompress mp metadata: %w", err)
+ return nil, err
}
if closeErr != nil {
- return mpMeta, fmt.Errorf("decompress mp metadata: %w", closeErr)
+ return nil, err
+ }
+
+ return decompressed, nil
+}
+
+func unmarshalCompressedMpUploadMetadata(compressed []byte) (MpUploadMetadata, error) {
+ var mpMeta MpUploadMetadata
+ decompressed, err := DecompressData(compressed)
+ if err != nil {
+ return mpMeta, fmt.Errorf("decompress mp metadata: %w", err)
}
if err := json.Unmarshal(decompressed, &mpMeta); err != nil {
diff --git a/backend/common_test.go b/backend/common_test.go
index fa69eb66..a0307471 100644
--- a/backend/common_test.go
+++ b/backend/common_test.go
@@ -107,6 +107,53 @@ func TestUnmarshalMpUploadMetadataInvalid(t *testing.T) {
}
}
+func TestWebsiteConfigRawGzipRoundTrip(t *testing.T) {
+ want := []byte(`index.html`)
+
+ stored, err := MarshalWebsiteConfig(want, false)
+ if err != nil {
+ t.Fatalf("MarshalWebsiteConfig: %v", err)
+ }
+ if len(stored) < 2 || stored[0] != 0x1f || stored[1] != 0x8b {
+ t.Fatalf("stored website config should contain raw gzip payload: %q", stored)
+ }
+
+ got, err := UnmarshalWebsiteConfig(stored, false)
+ if err != nil {
+ t.Fatalf("UnmarshalWebsiteConfig: %v", err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("website config mismatch: got %q want %q", got, want)
+ }
+}
+
+func TestWebsiteConfigBase64RoundTrip(t *testing.T) {
+ want := []byte(`example.com`)
+
+ stored, err := MarshalWebsiteConfig(want, true)
+ if err != nil {
+ t.Fatalf("MarshalWebsiteConfig: %v", err)
+ }
+ if len(stored) >= 2 && stored[0] == 0x1f && stored[1] == 0x8b {
+ t.Fatalf("stored website config should not contain raw gzip bytes: %q", stored)
+ }
+
+ got, err := UnmarshalWebsiteConfig(stored, true)
+ if err != nil {
+ t.Fatalf("UnmarshalWebsiteConfig: %v", err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("website config mismatch: got %q want %q", got, want)
+ }
+}
+
+func TestUnmarshalWebsiteConfigInvalid(t *testing.T) {
+ _, err := UnmarshalWebsiteConfig([]byte("not-gzip"), false)
+ if err == nil {
+ t.Fatal("expected invalid website config error")
+ }
+}
+
func TestParseCopySource(t *testing.T) {
tests := []struct {
name string
diff --git a/backend/posix/posix.go b/backend/posix/posix.go
index 1e34ff0d..8494ec64 100644
--- a/backend/posix/posix.go
+++ b/backend/posix/posix.go
@@ -6196,7 +6196,14 @@ func (p *Posix) PutBucketWebsite(ctx context.Context, bucket string, website []b
return nil
}
- err = p.meta.StoreAttribute(nil, bucket, "", websitekey, website)
+ // The website configuration can be up to 128KB
+ // compress the data to fit in 64KB xattr limits
+ encoded, err := backend.MarshalWebsiteConfig(website, false)
+ if err != nil {
+ return err
+ }
+
+ err = p.meta.StoreAttribute(nil, bucket, "", websitekey, encoded)
if err != nil {
return fmt.Errorf("set website: %w", err)
}
@@ -6224,13 +6231,18 @@ func (p *Posix) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, er
website, err := p.meta.RetrieveAttribute(nil, bucket, "", websitekey)
if errors.Is(err, meta.ErrNoSuchKey) {
- return nil, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)
+ return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket)
}
if err != nil {
return nil, err
}
- return website, nil
+ decoded, err := backend.UnmarshalWebsiteConfig(website, false)
+ if err != nil {
+ return nil, err
+ }
+
+ return decoded, nil
}
func (p *Posix) DeleteBucketWebsite(ctx context.Context, bucket string) error {
diff --git a/backend/s3proxy/s3.go b/backend/s3proxy/s3.go
index cb662055..ce3fbb0d 100644
--- a/backend/s3proxy/s3.go
+++ b/backend/s3proxy/s3.go
@@ -1759,7 +1759,7 @@ func (s *S3Proxy) putMetaBucketObj(ctx context.Context, bucket string, data []by
func (s *S3Proxy) getMetaBucketObjData(ctx context.Context, bucket string, prefix metaPrefix, checkExists bool) ([]byte, error) {
// return default bahviour of get bucket policy/acl, if meta bucket is not provided
if s.metaBucket == "" {
- return handleMetaBucketObjectNotFoundErr(prefix)
+ return handleMetaBucketObjectNotFoundErr(bucket, prefix)
}
key := getMetaKey(bucket, prefix)
@@ -1773,7 +1773,7 @@ func (s *S3Proxy) getMetaBucketObjData(ctx context.Context, bucket string, prefi
return nil, err
}
- return handleMetaBucketObjectNotFoundErr(prefix)
+ return handleMetaBucketObjectNotFoundErr(bucket, prefix)
}
if err != nil {
return nil, err
@@ -1790,17 +1790,17 @@ func (s *S3Proxy) getMetaBucketObjData(ctx context.Context, bucket string, prefi
// handles the case when an object with the given metprefix
// is not found in meta bucket. Aggregates the not found errors
// for each meta prefix
-func handleMetaBucketObjectNotFoundErr(prefix metaPrefix) ([]byte, error) {
+func handleMetaBucketObjectNotFoundErr(bucket string, prefix metaPrefix) ([]byte, error) {
switch prefix {
case metaPrefixAcl:
// If bucket acl is not found, return default acl
return []byte{}, nil
case metaPrefixPolicy:
- return nil, s3err.GetBucketErr(s3err.ErrNoSuchBucketPolicy, "")
+ return nil, s3err.GetBucketErr(s3err.ErrNoSuchBucketPolicy, bucket)
case metaPrefixCors:
- return nil, s3err.GetBucketErr(s3err.ErrNoSuchCORSConfiguration, "")
+ return nil, s3err.GetBucketErr(s3err.ErrNoSuchCORSConfiguration, bucket)
case metaPrefixWebsite:
- return nil, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)
+ return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket)
}
return []byte{}, nil
diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go
index c1f62ce6..bed7ab0f 100644
--- a/cmd/versitygw/main.go
+++ b/cmd/versitygw/main.go
@@ -914,6 +914,11 @@ func runGateway(ctx context.Context, be backend.Backend) error {
WebuiAdminGateways: webuiAdminGateways,
WebuiPathPrefix: webuiPathPrefix,
WebuiS3Prefix: webuiS3Prefix,
+ WebsitePorts: websitePorts,
+ WebsiteDomain: websiteDomain,
+ WebsiteCertFile: websiteCertFile,
+ WebsiteKeyFile: websiteKeyFile,
+ WebsiteNoTLS: websiteNoTLS,
SigHup: sigHup,
Version: Version,
Build: Build,
diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go
index 3f33d680..9bdffd8e 100644
--- a/cmd/versitygw/test.go
+++ b/cmd/versitygw/test.go
@@ -16,32 +16,35 @@ package main
import (
"fmt"
+ "strings"
"github.com/urfave/cli/v2"
"github.com/versity/versitygw/tests/integration"
)
var (
- awsID string
- awsSecret string
- endpoint string
- websiteEndpointTest string
- prefix string
- dstBucket string
- partSize int64
- objSize int64
- concurrency int
- files int
- totalReqs int
- upload bool
- download bool
- hostStyle bool
- checksumDisable bool
- versioningEnabled bool
- azureTests bool
- tlsStatus bool
- parallel bool
- sidecarTests bool
+ awsID string
+ awsSecret string
+ endpoint string
+ websiteSchemeTest string
+ websiteDomainTest string
+ websitePortTest string
+ prefix string
+ dstBucket string
+ partSize int64
+ objSize int64
+ concurrency int
+ files int
+ totalReqs int
+ upload bool
+ download bool
+ hostStyle bool
+ checksumDisable bool
+ versioningEnabled bool
+ azureTests bool
+ tlsStatus bool
+ parallel bool
+ sidecarTests bool
)
func testCommand() *cli.Command {
@@ -77,13 +80,6 @@ func initTestFlags() []cli.Flag {
Destination: &endpoint,
Aliases: []string{"e"},
},
- &cli.StringFlag{
- Name: "website-endpoint",
- Usage: "dedicated website hosting endpoint (e.g. 'http://localhost:8080'); required for WebsiteHosting tests",
- EnvVars: []string{"VGW_TEST_WEBSITE_ENDPOINT"},
- Destination: &websiteEndpointTest,
- Aliases: []string{"we"},
- },
&cli.BoolFlag{
Name: "host-style",
Usage: "Use host-style bucket addressing",
@@ -139,6 +135,36 @@ func initTestCommands() []*cli.Command {
},
},
},
+ {
+ Name: "website-hosting",
+ Usage: "Tests static website hosting endpoint.",
+ Description: `Runs the static website hosting integration tests against a dedicated website endpoint.`,
+ Action: websiteHostingAction,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "scheme",
+ Usage: "website endpoint scheme: http or https",
+ EnvVars: []string{"VGW_TEST_WEBSITE_SCHEME"},
+ Destination: &websiteSchemeTest,
+ Aliases: []string{"website-scheme", "protocol"},
+ Value: "http",
+ },
+ &cli.StringFlag{
+ Name: "domain",
+ Usage: "website endpoint base domain used for virtual-host routing",
+ EnvVars: []string{"VGW_TEST_WEBSITE_DOMAIN"},
+ Destination: &websiteDomainTest,
+ Aliases: []string{"website-domain"},
+ },
+ &cli.StringFlag{
+ Name: "port",
+ Usage: "website endpoint port",
+ EnvVars: []string{"VGW_TEST_WEBSITE_PORT"},
+ Destination: &websitePortTest,
+ Aliases: []string{"website-port"},
+ },
+ },
+ },
{
Name: "posix",
Usage: "Tests posix specific features",
@@ -333,6 +359,50 @@ func initTestCommands() []*cli.Command {
type testFunc func(*integration.TestState)
+func websiteHostingAction(ctx *cli.Context) error {
+ websiteSchemeTest = strings.ToLower(strings.TrimSpace(websiteSchemeTest))
+ if websiteSchemeTest != "http" && websiteSchemeTest != "https" {
+ return fmt.Errorf("website scheme must be http or https")
+ }
+ if websiteDomainTest == "" {
+ return fmt.Errorf("must specify website domain")
+ }
+ if websitePortTest == "" {
+ return fmt.Errorf("must specify website port")
+ }
+
+ opts := []integration.Option{
+ integration.WithAccess(awsID),
+ integration.WithSecret(awsSecret),
+ integration.WithRegion(region),
+ integration.WithEndpoint(endpoint),
+ }
+ if websiteSchemeTest != "" {
+ opts = append(opts, integration.WithWebsiteScheme(websiteSchemeTest))
+ }
+ if websiteDomainTest != "" {
+ opts = append(opts, integration.WithWebsiteDomain(websiteDomainTest))
+ }
+ if websitePortTest != "" {
+ opts = append(opts, integration.WithWebsitePort(websitePortTest))
+ }
+ if debug {
+ opts = append(opts, integration.WithDebug())
+ }
+
+ s := integration.NewS3Conf(opts...)
+ ts := integration.NewTestState(ctx.Context, s, false)
+ integration.TestWebsiteHosting(ts)
+ ts.Wait()
+
+ fmt.Println()
+ fmt.Println("RAN:", integration.RunCount.Load(), "PASS:", integration.PassCount.Load(), "FAIL:", integration.FailCount.Load())
+ if integration.FailCount.Load() > 0 {
+ return fmt.Errorf("test failed with %v errors", integration.FailCount.Load())
+ }
+ return nil
+}
+
func getAction(tf testFunc) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
opts := []integration.Option{
@@ -342,9 +412,6 @@ func getAction(tf testFunc) func(ctx *cli.Context) error {
integration.WithEndpoint(endpoint),
integration.WithTLSStatus(tlsStatus),
}
- if websiteEndpointTest != "" {
- opts = append(opts, integration.WithWebsiteEndpoint(websiteEndpointTest))
- }
if debug {
opts = append(opts, integration.WithDebug())
}
@@ -391,9 +458,6 @@ func extractIntTests() (commands []*cli.Command) {
integration.WithEndpoint(endpoint),
integration.WithTLSStatus(tlsStatus),
}
- if websiteEndpointTest != "" {
- opts = append(opts, integration.WithWebsiteEndpoint(websiteEndpointTest))
- }
if debug {
opts = append(opts, integration.WithDebug())
}
diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go
index 524700ea..ae01cbeb 100644
--- a/embedgw/embedgw.go
+++ b/embedgw/embedgw.go
@@ -42,6 +42,7 @@ import (
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3log"
+ "github.com/versity/versitygw/website"
"github.com/versity/versitygw/webui"
)
@@ -423,6 +424,29 @@ type Config struct {
// endpoint.
WebuiS3Prefix string
+ // Static website hosting endpoint
+ //
+ // WebsitePorts is the list of listening addresses for the static website
+ // hosting endpoint. Accepts the same formats as Ports. When empty, the
+ // website endpoint is disabled.
+ WebsitePorts []string
+ // WebsiteDomain is the base domain for website virtual-host routing. For
+ // example, host "blog.example.com" serves bucket "blog" when this is
+ // "example.com". When empty, the full request hostname is used as the
+ // bucket name.
+ WebsiteDomain string
+ // WebsiteCertFile is the path to the TLS certificate for the website
+ // endpoint. When empty and gateway TLS (CertFile/KeyFile) is configured,
+ // the website endpoint inherits those certs. Both WebsiteCertFile and
+ // WebsiteKeyFile must be provided together.
+ WebsiteCertFile string
+ // WebsiteKeyFile is the path to the TLS private key for the website
+ // endpoint.
+ WebsiteKeyFile string
+ // WebsiteNoTLS forces the website endpoint to use plain HTTP even when TLS
+ // certificates are available.
+ WebsiteNoTLS bool
+
// SigHup is an optional channel that signals the gateway to reload TLS
// certificates and rotate log files (equivalent to SIGHUP). When nil,
// this feature is disabled.
@@ -514,7 +538,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; %s\n", suggestion)
}
- if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts); err != nil {
+ if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts, cfg.WebsitePorts); err != nil {
return err
}
@@ -882,6 +906,60 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
}, webOpts...)
}
+ var wsSrv *website.Server
+ wsTLSCert := ""
+ wsTLSKey := ""
+ if len(cfg.WebsitePorts) > 0 {
+ for _, addr := range cfg.WebsitePorts {
+ if utils.IsUnixSocketPath(addr) {
+ continue
+ }
+ _, wsPrt, err := net.SplitHostPort(addr)
+ if err != nil {
+ return fmt.Errorf("website listen address must be in the form ':port' or 'host:port': %w", err)
+ }
+ wsPortNum, err := strconv.Atoi(wsPrt)
+ if err != nil {
+ return fmt.Errorf("website port must be a number: %w", err)
+ }
+ if wsPortNum < 0 || wsPortNum > 65535 {
+ return fmt.Errorf("website port must be between 0 and 65535")
+ }
+ }
+
+ var wsOpts []website.Option
+ if !cfg.WebsiteNoTLS {
+ wsTLSCert = cfg.WebsiteCertFile
+ wsTLSKey = cfg.WebsiteKeyFile
+ if wsTLSCert == "" && wsTLSKey == "" {
+ wsTLSCert = cfg.CertFile
+ wsTLSKey = cfg.KeyFile
+ }
+ if wsTLSCert != "" || wsTLSKey != "" {
+ if wsTLSCert == "" {
+ return fmt.Errorf("website TLS key specified without cert file")
+ }
+ if wsTLSKey == "" {
+ return fmt.Errorf("website TLS cert specified without key file")
+ }
+ cs := utils.NewCertStorage()
+ if err := cs.SetCertificate(wsTLSCert, wsTLSKey); err != nil {
+ return fmt.Errorf("tls: load certs: %v", err)
+ }
+ wsOpts = append(wsOpts, website.WithTLS(cs))
+ }
+ }
+
+ if cfg.Quiet {
+ wsOpts = append(wsOpts, website.WithQuiet())
+ }
+ if cfg.SocketPerm != "" {
+ wsOpts = append(wsOpts, website.WithSocketPerm(parsedSocketPerm))
+ }
+
+ wsSrv = website.NewServer(be, cfg.WebsiteDomain, wsOpts...)
+ }
+
if !cfg.Quiet {
cfg.printBanner()
}
@@ -893,6 +971,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if len(cfg.WebuiPorts) > 0 {
servers++
}
+ if len(cfg.WebsitePorts) > 0 {
+ servers++
+ }
c := make(chan error, servers)
go func() { c <- srv.ServeMultiPort(cfg.Ports) }()
@@ -902,6 +983,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if len(cfg.WebuiPorts) > 0 {
go func() { c <- webSrv.ServeMultiPort(cfg.WebuiPorts) }()
}
+ if len(cfg.WebsitePorts) > 0 {
+ go func() { c <- wsSrv.ServeMultiPort(cfg.WebsitePorts) }()
+ }
// build a nil-safe sighup channel so the select below is always valid
var sigHup <-chan struct{}
@@ -957,6 +1041,14 @@ Loop:
fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey)
}
}
+ if len(cfg.WebsitePorts) > 0 && wsTLSCert != "" && wsTLSKey != "" {
+ reloadErr := wsSrv.CertStorage.SetCertificate(wsTLSCert, wsTLSKey)
+ if reloadErr != nil {
+ debuglogger.InternalError(fmt.Errorf("wsSrv cert reload failed: %w", reloadErr))
+ } else {
+ fmt.Printf("wsSrv cert reloaded (cert: %s, key: %s)\n", wsTLSCert, wsTLSKey)
+ }
+ }
}
}
saveErr := err
@@ -980,6 +1072,13 @@ Loop:
}
}
+ if wsSrv != nil {
+ err := wsSrv.Shutdown()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "shutdown website server: %v\n", err)
+ }
+ }
+
be.Shutdown()
err = iam.Shutdown()
@@ -1023,6 +1122,7 @@ func (cfg Config) printBanner() {
ssl := cfg.CertFile != "" || cfg.KeyFile != ""
admSSL := cfg.AdminCertFile != "" || cfg.AdminKeyFile != ""
webuiSsl := !cfg.WebuiNoTLS && (cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "")
+ websiteSsl := !cfg.WebsiteNoTLS && (cfg.WebsiteCertFile != "" || cfg.WebsiteKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "")
if len(cfg.Ports) == 0 {
fmt.Fprintf(os.Stderr, "No ports specified\n")
@@ -1243,6 +1343,68 @@ func (cfg Config) printBanner() {
}
}
+ if len(cfg.WebsitePorts) > 0 {
+ var allWebsiteInterfaces []string
+ websiteInterfaceMap := make(map[string]bool)
+
+ for _, websiteAddr := range cfg.WebsitePorts {
+ if strings.TrimSpace(websiteAddr) == "" {
+ continue
+ }
+ if utils.IsUnixSocketPath(websiteAddr) {
+ if !websiteInterfaceMap[websiteAddr] {
+ websiteInterfaceMap[websiteAddr] = true
+ allWebsiteInterfaces = append(allWebsiteInterfaces, websiteAddr)
+ }
+ continue
+ }
+ websiteInterfaces, err := getMatchingIPs(websiteAddr)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to match website port local IP addresses for %s: %v\n", websiteAddr, err)
+ continue
+ }
+ _, websitePrt, err := net.SplitHostPort(websiteAddr)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to parse website port %s: %v\n", websiteAddr, err)
+ continue
+ }
+ for _, ip := range websiteInterfaces {
+ key := net.JoinHostPort(ip, websitePrt)
+ if !websiteInterfaceMap[key] {
+ websiteInterfaceMap[key] = true
+ allWebsiteInterfaces = append(allWebsiteInterfaces, key)
+ }
+ }
+ }
+
+ if len(allWebsiteInterfaces) > 0 {
+ domainInfo := ""
+ if cfg.WebsiteDomain != "" {
+ domainInfo = fmt.Sprintf(" (domain: %s)", cfg.WebsiteDomain)
+ }
+ lines = append(lines,
+ centerText(""),
+ leftText("Website endpoint listening on:"+domainInfo),
+ )
+ for _, addrPort := range allWebsiteInterfaces {
+ if utils.IsUnixSocketPath(addrPort) {
+ lines = append(lines, leftText(" unix:"+addrPort))
+ continue
+ }
+ ip, prt, err := net.SplitHostPort(addrPort)
+ if err != nil {
+ continue
+ }
+ hostPort := net.JoinHostPort(ip, prt)
+ u := fmt.Sprintf("http://%s", hostPort)
+ if websiteSsl {
+ u = fmt.Sprintf("https://%s", hostPort)
+ }
+ lines = append(lines, leftText(" "+u))
+ }
+ }
+ }
+
fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
for _, line := range lines {
fmt.Printf("│%-*s│\n", columnWidth-2, line)
@@ -1436,13 +1598,13 @@ func sortGatewayURLs(urls []string) {
}
// validatePortConflicts checks for port conflicts across the S3 API, admin,
-// and WebUI port lists before the servers are started.
+// WebUI, and website port lists before the servers are started.
//
// A bare port spec (e.g. ":7071") binds to all interfaces and conflicts with
// any other spec on the same port number. Two identical "ip:port" specs are
// allowed and will be caught by the OS later. UNIX socket paths are checked
// for duplicate path conflicts only and never conflict with TCP specs.
-func validatePortConflicts(ports, admPorts, webuiPorts []string) error {
+func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) error {
type portSpec struct {
spec string
port string
@@ -1504,6 +1666,23 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error {
})
}
+ for _, p := range websitePorts {
+ if utils.IsUnixSocketPath(p) {
+ allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "website"})
+ continue
+ }
+ _, port, err := net.SplitHostPort(p)
+ if err != nil {
+ continue
+ }
+ allSpecs = append(allSpecs, portSpec{
+ spec: p,
+ port: port,
+ isBare: strings.HasPrefix(p, ":"),
+ portType: "website",
+ })
+ }
+
for i, spec1 := range allSpecs {
for j, spec2 := range allSpecs {
if i >= j {
diff --git a/embedgw/embedgw_test.go b/embedgw/embedgw_test.go
index 781b14a7..a692cd7d 100644
--- a/embedgw/embedgw_test.go
+++ b/embedgw/embedgw_test.go
@@ -20,12 +20,13 @@ import (
func TestValidatePortConflicts(t *testing.T) {
tests := []struct {
- name string
- ports []string
- admPorts []string
- webuiPorts []string
- expectError bool
- description string
+ name string
+ ports []string
+ admPorts []string
+ webuiPorts []string
+ websitePorts []string
+ expectError bool
+ description string
}{
{
name: "bare port conflict with bare port",
@@ -115,11 +116,38 @@ func TestValidatePortConflicts(t *testing.T) {
expectError: true,
description: "should fail: :8080 conflicts with 127.0.0.1:8080",
},
+ {
+ name: "website bare port conflict with s3 port",
+ ports: []string{"127.0.0.1:8080"},
+ admPorts: []string{},
+ webuiPorts: []string{},
+ websitePorts: []string{":8080"},
+ expectError: true,
+ description: "should fail: website bare :8080 conflicts with s3 127.0.0.1:8080",
+ },
+ {
+ name: "website no conflict",
+ ports: []string{":7070"},
+ admPorts: []string{":8080"},
+ webuiPorts: []string{":9090"},
+ websitePorts: []string{":8081"},
+ expectError: false,
+ description: "should pass: website uses a distinct port",
+ },
+ {
+ name: "duplicate website unix socket conflict",
+ ports: []string{"/tmp/versitygw.sock"},
+ admPorts: []string{},
+ webuiPorts: []string{},
+ websitePorts: []string{"/tmp/versitygw.sock"},
+ expectError: true,
+ description: "should fail: duplicate unix socket path conflicts across s3 and website",
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts)
+ err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts, tt.websitePorts)
if tt.expectError && err == nil {
t.Errorf("%s: expected error but got none", tt.description)
}
diff --git a/s3api/controllers/base.go b/s3api/controllers/base.go
index 028f4e48..bbad90e4 100644
--- a/s3api/controllers/base.go
+++ b/s3api/controllers/base.go
@@ -49,9 +49,10 @@ const (
iso8601TimeFormatExtended = "Mon Jan _2 15:04:05 2006"
timefmt = "Mon, 02 Jan 2006 15:04:05 GMT"
- maxXMLBodyLen = 4 * 1024 * 1024
- minPartNumber = 1
- maxPartNumber = 10000
+ maxXMLBodyLen = 4 * 1024 * 1024
+ minPartNumber = 1
+ maxPartNumber = 10000
+ maxWebsiteConfigurationBytes = 131072
defaultRegion = "us-east-1"
defaultContentType = "binary/octet-stream"
diff --git a/s3api/controllers/bucket-delete.go b/s3api/controllers/bucket-delete.go
index 441f5273..2ff9e7d1 100644
--- a/s3api/controllers/bucket-delete.go
+++ b/s3api/controllers/bucket-delete.go
@@ -177,7 +177,7 @@ func (c S3ApiController) DeleteBucketWebsite(ctx *fiber.Ctx) (*Response, error)
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
- Action: auth.DeleteBucketWebsiteAction,
+ Actions: []auth.Action{auth.DeleteBucketWebsiteAction},
IsPublicRequest: IsBucketPublic,
DisableACL: c.disableACL,
})
diff --git a/s3api/controllers/bucket-get.go b/s3api/controllers/bucket-get.go
index 3a31156b..b86fd9b3 100644
--- a/s3api/controllers/bucket-get.go
+++ b/s3api/controllers/bucket-get.go
@@ -220,7 +220,7 @@ func (c S3ApiController) GetBucketWebsite(ctx *fiber.Ctx) (*Response, error) {
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
- Action: auth.GetBucketWebsiteAction,
+ Actions: []auth.Action{auth.GetBucketWebsiteAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go
index a83c291f..6d1521b7 100644
--- a/s3api/controllers/bucket-put.go
+++ b/s3api/controllers/bucket-put.go
@@ -301,7 +301,7 @@ func (c S3ApiController) PutBucketWebsite(ctx *fiber.Ctx) (*Response, error) {
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
- Action: auth.PutBucketWebsiteAction,
+ Actions: []auth.Action{auth.PutBucketWebsiteAction},
IsPublicRequest: isPublicBucket,
DisableACL: c.disableACL,
})
@@ -314,6 +314,14 @@ func (c S3ApiController) PutBucketWebsite(ctx *fiber.Ctx) (*Response, error) {
}
body := ctx.Body()
+ if len(body) > maxWebsiteConfigurationBytes {
+ debuglogger.Logf("the request size exceeded the 128KB limit: %d", len(body))
+ return &Response{
+ MetaOpts: &MetaOptions{
+ BucketOwner: parsedAcl.Owner,
+ },
+ }, s3err.GetMaxMessageLengthExceeded(maxWebsiteConfigurationBytes)
+ }
var websiteConfig s3response.WebsiteConfiguration
err = xml.Unmarshal(body, &websiteConfig)
diff --git a/s3api/controllers/cors_default_origin_test.go b/s3api/controllers/cors_default_origin_test.go
index 761aa551..a8771423 100644
--- a/s3api/controllers/cors_default_origin_test.go
+++ b/s3api/controllers/cors_default_origin_test.go
@@ -35,7 +35,7 @@ func TestApplyBucketCORS_FallbackOrigin_NoBucketCors_NoRequestOrigin(t *testing.
app := fiber.New()
app.Get("/:bucket/test",
- middlewares.ApplyBucketCORS(mockedBackend, origin),
+ middlewares.ApplyBucketCORS(mockedBackend, middlewares.BucketFromPath, origin),
func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
},
@@ -71,7 +71,7 @@ func TestApplyBucketCORS_FallbackOrigin_NotAppliedWhenBucketCorsExists(t *testin
app := fiber.New()
app.Get("/:bucket/test",
- middlewares.ApplyBucketCORS(mockedBackend, origin),
+ middlewares.ApplyBucketCORS(mockedBackend, middlewares.BucketFromPath, origin),
func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
},
diff --git a/s3api/middlewares/apply-bucket-cors.go b/s3api/middlewares/apply-bucket-cors.go
index 9a19543e..0f2c3a81 100644
--- a/s3api/middlewares/apply-bucket-cors.go
+++ b/s3api/middlewares/apply-bucket-cors.go
@@ -28,21 +28,31 @@ import (
// Vary http response header is always the same below
var VaryHdr = "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"
+type BucketResolver func(ctx *fiber.Ctx) (string, error)
+
+func BucketFromPath(ctx *fiber.Ctx) (string, error) {
+ return ctx.Params("bucket"), nil
+}
+
// ApplyBucketCORS retreives the bucket CORS configuration,
// checks if origin and method meets the cors rules and
// adds the necessary response headers.
// CORS check is applied only when 'Origin' request header is present
-func ApplyBucketCORS(be backend.Backend, fallbackOrigin string) fiber.Handler {
+func ApplyBucketCORS(be backend.Backend, resolveBucket BucketResolver, fallbackOrigin string) fiber.Handler {
fallbackOrigin = strings.TrimSpace(fallbackOrigin)
return func(ctx *fiber.Ctx) error {
- bucket := ctx.Params("bucket")
origin := ctx.Get("Origin")
// If neither Origin is present nor a fallback is configured, skip CORS entirely.
if origin == "" && fallbackOrigin == "" {
return nil
}
+ bucket, err := resolveBucket(ctx)
+ if err != nil {
+ return err
+ }
+
// if bucket cors is not set, skip the check
data, err := be.GetBucketCors(ctx.Context(), bucket)
if err != nil {
diff --git a/s3api/router.go b/s3api/router.go
index e4b32432..57e4352b 100644
--- a/s3api/router.go
+++ b/s3api/router.go
@@ -185,6 +185,7 @@ func (sa *S3ApiRouter) Init() {
bucketRouter := sa.app.Group("/:bucket")
objectRouter := sa.app.Group("/:bucket/*")
+ applyBucketCORS := middlewares.ApplyBucketCORS(sa.be, middlewares.BucketFromPath, sa.corsAllowOrigin)
// PUT bucket operations
bucketRouter.Put("",
@@ -194,7 +195,7 @@ func (sa *S3ApiRouter) Init() {
metrics.ActionPutBucketTagging,
services,
middlewares.BucketObjectNameValidator(),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
@@ -212,7 +213,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -226,7 +227,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -240,7 +241,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, true),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -254,7 +255,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, true),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -268,7 +269,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -282,7 +283,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -452,7 +453,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
),
)
@@ -466,7 +467,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
))
// HeadBucket action
@@ -491,7 +492,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionHeadBucket, auth.ListBucketAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -518,7 +519,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Delete("",
@@ -531,7 +532,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketOwnershipControls, auth.PutBucketOwnershipControlsAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Delete("",
@@ -544,7 +545,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketPolicy, auth.PutBucketPolicyAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Delete("",
@@ -557,7 +558,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketCors, auth.PutBucketCorsAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Delete("",
@@ -674,7 +675,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketWebsite, auth.DeleteBucketWebsiteAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
),
)
@@ -687,7 +688,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucket, auth.DeleteBucketAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -714,7 +715,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketLocation, auth.GetBucketLocationAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
),
)
@@ -728,7 +729,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketTagging, auth.GetBucketTaggingAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -741,7 +742,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketOwnershipControls, auth.GetBucketOwnershipControlsAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -754,7 +755,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketVersioning, auth.GetBucketVersioningAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -767,7 +768,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketPolicy, auth.GetBucketPolicyAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -780,7 +781,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketCors, auth.GetBucketCorsAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -793,7 +794,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectLockConfiguration, auth.GetBucketObjectLockConfigurationAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -806,7 +807,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketAcl, auth.GetBucketAclAction, auth.PermissionReadAcp, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -819,7 +820,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListMultipartUploads, auth.ListBucketMultipartUploadsAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -832,7 +833,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListObjectVersions, auth.ListBucketVersionsAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -845,7 +846,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketPolicyStatus, auth.GetBucketPolicyStatusAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -1066,7 +1067,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetBucketWebsite, auth.GetBucketWebsiteAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
),
)
@@ -1080,7 +1081,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListObjectsV2, auth.ListBucketAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Get("",
@@ -1092,7 +1093,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListObjects, auth.ListBucketAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1121,7 +1122,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, true),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1133,7 +1134,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.BucketObjectNameValidator(),
middlewares.AuthorizePostObject(sa.root, sa.iam, sa.region),
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPostObject, auth.PutObjectAction, auth.PermissionWrite, sa.region, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1159,7 +1160,7 @@ func (sa *S3ApiRouter) Init() {
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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1199,7 +1200,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectTagging, auth.GetObjectTaggingAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Get("",
@@ -1212,7 +1213,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectRetention, auth.GetObjectRetentionAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Get("",
@@ -1225,7 +1226,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectLegalHold, auth.GetObjectLegalHoldAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Get("",
@@ -1238,7 +1239,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectAcl, auth.GetObjectAclAction, auth.PermissionReadAcp, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Get("",
@@ -1251,7 +1252,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObjectAttributes, auth.GetObjectAttributesAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Get("",
@@ -1264,7 +1265,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionListParts, auth.ListMultipartUploadPartsAction, 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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Get("",
@@ -1276,7 +1277,7 @@ func (sa *S3ApiRouter) Init() {
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),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1304,7 +1305,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteObjectTagging, auth.DeleteObjectTaggingAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Delete("",
@@ -1317,7 +1318,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionAbortMultipartUpload, auth.AbortMultipartUploadAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Delete("",
@@ -1329,7 +1330,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteObject, auth.DeleteObjectAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1359,7 +1360,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Post("",
@@ -1374,7 +1375,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Post("",
@@ -1387,7 +1388,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionCompleteMultipartUpload, auth.PutObjectAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Post("",
@@ -1400,7 +1401,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionCreateMultipartUpload, auth.PutObjectAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1412,7 +1413,7 @@ func (sa *S3ApiRouter) Init() {
metrics.ActionPutObjectTagging,
services,
middlewares.BucketObjectNameValidator(),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutObjectTagging, auth.PutObjectTaggingAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
@@ -1430,7 +1431,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, true),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Put("",
@@ -1444,7 +1445,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, true),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Put("",
@@ -1458,7 +1459,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Put("",
@@ -1472,7 +1473,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionUploadPartCopy, auth.PutObjectAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
objectRouter.Put("",
@@ -1486,7 +1487,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, true),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, true, true, false),
middlewares.VerifyChecksums(true, false, false),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
@@ -1523,7 +1524,7 @@ func (sa *S3ApiRouter) Init() {
metrics.ActionCopyObject,
services,
middlewares.BucketObjectNameValidator(),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionCopyObject, auth.PutObjectAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
@@ -1535,7 +1536,7 @@ func (sa *S3ApiRouter) Init() {
metrics.ActionPutObject,
services,
middlewares.BucketObjectNameValidator(),
- middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
+ applyBucketCORS,
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutObject, auth.PutObjectAction, auth.PermissionWrite, sa.region, true),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, true),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, true, true, false),
diff --git a/s3err/access-forbidden-error.go b/s3err/access-forbidden-error.go
index 085c3d36..6abee327 100644
--- a/s3err/access-forbidden-error.go
+++ b/s3err/access-forbidden-error.go
@@ -43,6 +43,13 @@ func (e AccessForbiddenError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e AccessForbiddenError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Method", Value: e.Method},
+ ErrorField{Name: "ResourceType", Value: e.ResourceType},
+ )
+}
+
func (e AccessForbiddenError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/bad-digest-error.go b/s3err/bad-digest-error.go
index 1b209a4d..c095bd5a 100644
--- a/s3err/bad-digest-error.go
+++ b/s3err/bad-digest-error.go
@@ -43,6 +43,13 @@ func (e BadDigestError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e BadDigestError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "CalculatedDigest", Value: e.CalculatedDigest},
+ ErrorField{Name: "ExpectedDigest", Value: e.ExpectedDigest},
+ )
+}
+
func (e BadDigestError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/bucket-error.go b/s3err/bucket-error.go
index 0bbff389..37b4fef6 100644
--- a/s3err/bucket-error.go
+++ b/s3err/bucket-error.go
@@ -40,6 +40,12 @@ func (e BucketError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e BucketError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "BucketName", Value: e.BucketName},
+ )
+}
+
func (e BucketError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/content-sha256-mismatch-error.go b/s3err/content-sha256-mismatch-error.go
index 22e9738f..1a3079ff 100644
--- a/s3err/content-sha256-mismatch-error.go
+++ b/s3err/content-sha256-mismatch-error.go
@@ -44,6 +44,13 @@ func (e ContentSHA256MismatchError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e ContentSHA256MismatchError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "ClientComputedContentSHA256", Value: e.ClientComputedContentSHA256},
+ ErrorField{Name: "S3ComputedContentSHA256", Value: e.S3ComputedContentSHA256},
+ )
+}
+
func (e ContentSHA256MismatchError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/entity-too-large-error.go b/s3err/entity-too-large-error.go
index 24f40868..a6a4a96c 100644
--- a/s3err/entity-too-large-error.go
+++ b/s3err/entity-too-large-error.go
@@ -43,6 +43,13 @@ func (e EntityTooLargeError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e EntityTooLargeError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "ProposedSize", Value: e.ProposedSize},
+ ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed},
+ )
+}
+
func (e EntityTooLargeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/entity-too-small-error.go b/s3err/entity-too-small-error.go
index d1c522b0..7a230bb9 100644
--- a/s3err/entity-too-small-error.go
+++ b/s3err/entity-too-small-error.go
@@ -43,6 +43,13 @@ func (e EntityTooSmallError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e EntityTooSmallError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "ProposedSize", Value: e.ProposedSize},
+ ErrorField{Name: "MinSizeAllowed", Value: e.MinSizeAllowed},
+ )
+}
+
func (e EntityTooSmallError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/expired-presigned-url-error.go b/s3err/expired-presigned-url-error.go
index f4406c86..95c4f815 100644
--- a/s3err/expired-presigned-url-error.go
+++ b/s3err/expired-presigned-url-error.go
@@ -46,6 +46,14 @@ func (e ExpiredPresignedURLError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e ExpiredPresignedURLError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "ServerTime", Value: e.ServerTime},
+ ErrorField{Name: "X-Amz-Expires", Value: e.XAmzExpires},
+ ErrorField{Name: "Expires", Value: e.Expires},
+ )
+}
+
func (e ExpiredPresignedURLError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-access-key-id-error.go b/s3err/invalid-access-key-id-error.go
index b70efa3c..8f910db7 100644
--- a/s3err/invalid-access-key-id-error.go
+++ b/s3err/invalid-access-key-id-error.go
@@ -40,6 +40,12 @@ func (e InvalidAccessKeyIdError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidAccessKeyIdError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId},
+ )
+}
+
func (e InvalidAccessKeyIdError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-argument.go b/s3err/invalid-argument.go
index ae14ca81..5e8d65b5 100644
--- a/s3err/invalid-argument.go
+++ b/s3err/invalid-argument.go
@@ -56,6 +56,8 @@ const (
InvalidArgCannedAcl
InvalidArgOnlyAws4HmacSha256
InvalidArgDateHeader
+ InvalidArgIndexDocumentSuffix
+ InvalidArgErrorDocumentKey
)
var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{
@@ -187,6 +189,14 @@ var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{
Description: "X-Amz-Date must be formated via ISO8601 Long format",
ArgumentName: "X-Amz-Date",
},
+ InvalidArgIndexDocumentSuffix: {
+ Description: "The IndexDocument Suffix is not well formed",
+ ArgumentName: "IndexDocument",
+ },
+ InvalidArgErrorDocumentKey: {
+ Description: "The ErrorDocument Key is not well formed",
+ ArgumentName: "ErrorDocument",
+ },
}
// InvalidArgumentError is returned when a request argument is invalid.
@@ -235,6 +245,13 @@ func (e InvalidArgumentError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidArgumentError) HTMLBody(requestID, hostID string) []byte {
+ return e.BaseError().encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "ArgumentName", Value: e.ArgumentName},
+ ErrorField{Name: "ArgumentValue", Value: e.ArgumentValue},
+ )
+}
+
func GetInvalidArgumentErr(code InvalidArgErrorCode, value string) InvalidArgumentError {
err := invalidArgErrResponses[code]
err.ArgumentValue = value
diff --git a/s3err/invalid-chunk-size-error.go b/s3err/invalid-chunk-size-error.go
index 86eff473..7c147a4d 100644
--- a/s3err/invalid-chunk-size-error.go
+++ b/s3err/invalid-chunk-size-error.go
@@ -43,6 +43,13 @@ func (e InvalidChunkSizeError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidChunkSizeError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Chunk", Value: e.Chunk},
+ ErrorField{Name: "BadChunkSize", Value: e.BadChunkSize},
+ )
+}
+
func (e InvalidChunkSizeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-digest-error.go b/s3err/invalid-digest-error.go
index 537d0906..e7ca3113 100644
--- a/s3err/invalid-digest-error.go
+++ b/s3err/invalid-digest-error.go
@@ -40,6 +40,12 @@ func (e InvalidDigestError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidDigestError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Content-MD5", Value: e.ContentMD5},
+ )
+}
+
func (e InvalidDigestError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-location-constraint-error.go b/s3err/invalid-location-constraint-error.go
index 290d6428..bf27a5d7 100644
--- a/s3err/invalid-location-constraint-error.go
+++ b/s3err/invalid-location-constraint-error.go
@@ -40,6 +40,12 @@ func (e InvalidLocationConstraintError) XMLBody(requestID, hostID string) []byte
})
}
+func (e InvalidLocationConstraintError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "LocationConstraint", Value: e.LocationConstraint},
+ )
+}
+
func (e InvalidLocationConstraintError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-part-error.go b/s3err/invalid-part-error.go
index ed47cdcb..4cc7fc11 100644
--- a/s3err/invalid-part-error.go
+++ b/s3err/invalid-part-error.go
@@ -48,6 +48,14 @@ func (e InvalidPartError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidPartError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "UploadId", Value: e.UploadId},
+ ErrorField{Name: "PartNumber", Value: e.PartNumber},
+ ErrorField{Name: "ETag", Value: e.ETag},
+ )
+}
+
func (e InvalidPartError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-part-number-range-error.go b/s3err/invalid-part-number-range-error.go
index 5edd9fea..4cf7dbfc 100644
--- a/s3err/invalid-part-number-range-error.go
+++ b/s3err/invalid-part-number-range-error.go
@@ -44,6 +44,13 @@ func (e InvalidPartNumberRangeError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidPartNumberRangeError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "ActualPartCount", Value: e.ActualPartCount},
+ ErrorField{Name: "PartNumberRequested", Value: e.PartNumberRequested},
+ )
+}
+
func (e InvalidPartNumberRangeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-range-error.go b/s3err/invalid-range-error.go
index 37773768..bc23711d 100644
--- a/s3err/invalid-range-error.go
+++ b/s3err/invalid-range-error.go
@@ -43,6 +43,13 @@ func (e InvalidRangeError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidRangeError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "RangeRequested", Value: e.RangeRequested},
+ ErrorField{Name: "ActualObjectSize", Value: e.ActualObjectSize},
+ )
+}
+
func (e InvalidRangeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/invalid-tag-error.go b/s3err/invalid-tag-error.go
index 981c5183..2c481028 100644
--- a/s3err/invalid-tag-error.go
+++ b/s3err/invalid-tag-error.go
@@ -43,6 +43,13 @@ func (e InvalidTagError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e InvalidTagError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "TagKey", Value: e.TagKey},
+ ErrorField{Name: "TagValue", Value: e.TagValue},
+ )
+}
+
func (e InvalidTagError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/key-too-long-error.go b/s3err/key-too-long-error.go
index d95fdd8a..073d56f7 100644
--- a/s3err/key-too-long-error.go
+++ b/s3err/key-too-long-error.go
@@ -43,6 +43,13 @@ func (e KeyTooLongError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e KeyTooLongError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Size", Value: e.Size},
+ ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed},
+ )
+}
+
func (e KeyTooLongError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/max-message-length-exceeded-error.go b/s3err/max-message-length-exceeded-error.go
new file mode 100644
index 00000000..1c9447d4
--- /dev/null
+++ b/s3err/max-message-length-exceeded-error.go
@@ -0,0 +1,59 @@
+// Copyright 2026 Versity Software
+// This file is licensed under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package s3err
+
+import "encoding/xml"
+
+// MaxMessageLengthExceeded is returned when the request size exceeds the maximum limit
+// Produces the field in the XML response.
+type MaxMessageLengthExceeded struct {
+ APIError
+ MaxMessageLengthBytes int64
+}
+
+func (e MaxMessageLengthExceeded) XMLBody(requestID, hostID string) []byte {
+ return encodeResponse(struct {
+ XMLName xml.Name `xml:"Error"`
+ Code string
+ Message string
+ MaxMessageLengthBytes int64 `xml:",omitempty"`
+ RequestID string `xml:"RequestId,omitempty"`
+ HostID string `xml:"HostId,omitempty"`
+ }{
+ Code: e.Code,
+ Message: e.Description,
+ MaxMessageLengthBytes: e.MaxMessageLengthBytes,
+ RequestID: requestID,
+ HostID: hostID,
+ })
+}
+
+func (e MaxMessageLengthExceeded) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "MaxMessageLengthBytes", Value: e.MaxMessageLengthBytes},
+ )
+}
+
+func (e MaxMessageLengthExceeded) Is(target error) bool {
+ t, ok := target.(APIError)
+ return ok && e.APIError == t
+}
+
+func GetMaxMessageLengthExceeded(maxMessageLengthBytes int64) MaxMessageLengthExceeded {
+ return MaxMessageLengthExceeded{
+ APIError: GetAPIError(ErrMaxMessageLengthExceeded),
+ MaxMessageLengthBytes: maxMessageLengthBytes,
+ }
+}
diff --git a/s3err/metadata-too-large-error.go b/s3err/metadata-too-large-error.go
index 02bc2692..59cd6e87 100644
--- a/s3err/metadata-too-large-error.go
+++ b/s3err/metadata-too-large-error.go
@@ -43,6 +43,13 @@ func (e MetadataTooLargeError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e MetadataTooLargeError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Size", Value: e.Size},
+ ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed},
+ )
+}
+
func (e MetadataTooLargeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/method-not-allowed-error.go b/s3err/method-not-allowed-error.go
index 881c9acf..a0009f28 100644
--- a/s3err/method-not-allowed-error.go
+++ b/s3err/method-not-allowed-error.go
@@ -52,6 +52,13 @@ func (e MethodNotAllowedError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e MethodNotAllowedError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Method", Value: e.Method},
+ ErrorField{Name: "ResourceType", Value: e.ResourceType},
+ )
+}
+
func (e MethodNotAllowedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/no-such-upload-error.go b/s3err/no-such-upload-error.go
index a1576a43..2d6d8452 100644
--- a/s3err/no-such-upload-error.go
+++ b/s3err/no-such-upload-error.go
@@ -40,6 +40,12 @@ func (e NoSuchUploadError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e NoSuchUploadError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "UploadId", Value: e.UploadId},
+ )
+}
+
func (e NoSuchUploadError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/no-such-version-error.go b/s3err/no-such-version-error.go
index 8cd36e60..b1ed81c5 100644
--- a/s3err/no-such-version-error.go
+++ b/s3err/no-such-version-error.go
@@ -43,6 +43,13 @@ func (e NoSuchVersionError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e NoSuchVersionError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Key", Value: e.Key},
+ ErrorField{Name: "VersionId", Value: e.VersionId},
+ )
+}
+
func (e NoSuchVersionError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/not-implemented-error.go b/s3err/not-implemented-error.go
index 64569a69..f4618238 100644
--- a/s3err/not-implemented-error.go
+++ b/s3err/not-implemented-error.go
@@ -52,6 +52,13 @@ func (e NotImplementedError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e NotImplementedError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Header", Value: e.Header},
+ ErrorField{Name: "additionalMessage", Value: e.AdditionalMessage},
+ )
+}
+
func (e NotImplementedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/precondition-failed-error.go b/s3err/precondition-failed-error.go
index 0e8fe3fb..f54b79d6 100644
--- a/s3err/precondition-failed-error.go
+++ b/s3err/precondition-failed-error.go
@@ -52,6 +52,12 @@ func (e PreconditionFailedError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e PreconditionFailedError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Condition", Value: e.Condition},
+ )
+}
+
func (e PreconditionFailedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/request-time-too-skewed-error.go b/s3err/request-time-too-skewed-error.go
index b40eb109..d5ba3785 100644
--- a/s3err/request-time-too-skewed-error.go
+++ b/s3err/request-time-too-skewed-error.go
@@ -48,6 +48,14 @@ func (e RequestTimeTooSkewedError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e RequestTimeTooSkewedError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "RequestTime", Value: e.RequestTime},
+ ErrorField{Name: "ServerTime", Value: e.ServerTime},
+ ErrorField{Name: "MaxAllowedSkewMilliseconds", Value: e.MaxAllowedSkewMilliseconds},
+ )
+}
+
func (e RequestTimeTooSkewedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/s3err.go b/s3err/s3err.go
index ac3fe57c..8aa15e20 100644
--- a/s3err/s3err.go
+++ b/s3err/s3err.go
@@ -18,6 +18,7 @@ import (
"bytes"
"encoding/xml"
"fmt"
+ "html"
"net/http"
"strings"
@@ -31,6 +32,7 @@ type S3Error interface {
StatusCode() int
BaseError() APIError
XMLBody(requestID, hostID string) []byte
+ HTMLBody(requestID, hostID string) []byte
}
// APIError structure
@@ -69,6 +71,10 @@ func (e APIError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e APIError) HTMLBody(requestID, hostID string) []byte {
+ return e.encodeHTMLResponse(requestID, hostID)
+}
+
// ErrorCode type of error status.
type ErrorCode int
@@ -166,9 +172,9 @@ const (
ErrMissingCORSOrigin
ErrCORSIsNotEnabled
ErrNoSuchWebsiteConfiguration
- ErrInvalidWebsiteConfiguration
- ErrInvalidWebsiteSuffix
- ErrInvalidWebsiteRedirectCode
+ ErrInvalidWebsiteRedirectProtocol
+ ErrBothReplaceKeyAndPrefix
+ ErrMaxMessageLengthExceeded
ErrNotModified
ErrInvalidLocationConstraint
ErrMalformedTrailer
@@ -176,6 +182,7 @@ const (
ErrSlowDown
ErrMetadataTooLarge
ErrUnsupportedAuthorizationMechanism
+ ErrNoBucketInRequest
// Non-AWS errors
ErrExistingObjectIsDirectory
@@ -648,19 +655,19 @@ var errorCodeResponse = map[ErrorCode]APIError{
Description: "The specified bucket does not have a website configuration",
HTTPStatusCode: http.StatusNotFound,
},
- ErrInvalidWebsiteConfiguration: {
- Code: "MalformedXML",
- Description: "The XML you provided was not well-formed or did not validate against our published schema.",
+ ErrInvalidWebsiteRedirectProtocol: {
+ Code: "InvalidRequest",
+ Description: "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically.",
HTTPStatusCode: http.StatusBadRequest,
},
- ErrInvalidWebsiteSuffix: {
- Code: "InvalidArgument",
- Description: "The IndexDocument Suffix is not well formed",
+ ErrBothReplaceKeyAndPrefix: {
+ Code: "InvalidRequest",
+ Description: "You can only define ReplaceKeyPrefix or ReplaceKey but not both.",
HTTPStatusCode: http.StatusBadRequest,
},
- ErrInvalidWebsiteRedirectCode: {
- Code: "InvalidArgument",
- Description: "The website redirect code is not valid. Valid codes are 3XX.",
+ ErrMaxMessageLengthExceeded: {
+ Code: "MaxMessageLengthExceeded",
+ Description: "Your request was too big.",
HTTPStatusCode: http.StatusBadRequest,
},
ErrNotModified: {
@@ -698,6 +705,11 @@ var errorCodeResponse = map[ErrorCode]APIError{
Description: "The authorization mechanism you have provided is not supported. Please use AWS4-HMAC-SHA256.",
HTTPStatusCode: http.StatusBadRequest,
},
+ ErrNoBucketInRequest: {
+ Code: "WebsiteRedirect",
+ Description: "Request does not contain a bucket name.",
+ HTTPStatusCode: http.StatusMovedPermanently,
+ },
// non aws errors
ErrExistingObjectIsDirectory: {
@@ -793,6 +805,42 @@ func encodeResponse(response any) []byte {
return bytesBuffer.Bytes()
}
+type ErrorField struct {
+ Name string
+ Value any
+}
+
+func (e APIError) encodeHTMLResponse(requestID, hostID string, fields ...ErrorField) []byte {
+ status := fmt.Sprintf("%d %s", e.HTTPStatusCode, http.StatusText(e.HTTPStatusCode))
+
+ builder := &strings.Builder{}
+ builder.WriteString("\n")
+ builder.WriteString("")
+ builder.WriteString(html.EscapeString(status))
+ builder.WriteString("\n\n")
+ builder.WriteString(html.EscapeString(status))
+ builder.WriteString("
\n\n")
+
+ writeHTMLErrorField(builder, "Code", e.Code)
+ writeHTMLErrorField(builder, "Message", e.Description)
+ for _, field := range fields {
+ writeHTMLErrorField(builder, field.Name, field.Value)
+ }
+ writeHTMLErrorField(builder, "RequestId", requestID)
+ writeHTMLErrorField(builder, "HostId", hostID)
+
+ builder.WriteString("
\n
\n\n\n")
+ return []byte(builder.String())
+}
+
+func writeHTMLErrorField(builder *strings.Builder, name string, value any) {
+ builder.WriteString("")
+ builder.WriteString(html.EscapeString(name))
+ builder.WriteString(": ")
+ builder.WriteString(html.EscapeString(fmt.Sprint(value)))
+ builder.WriteString("\n")
+}
+
// Returns invalid checksum error with the provided header in the error description
func GetInvalidChecksumHeaderErr(header string) APIError {
return APIError{
@@ -918,6 +966,30 @@ func GetCopySourceObjectTooLargeErr(limit int64) APIError {
}
}
+func GetInvalidRedirectCodeErr(input int) APIError {
+ return APIError{
+ Code: "InvalidRequest",
+ Description: fmt.Sprintf("The provided HTTP redirect code (%d) is not valid. Valid codes are 3XX except 300.", input),
+ HTTPStatusCode: http.StatusBadRequest,
+ }
+}
+
+func GetInvalidHTTPErrorCodeErr(input int) APIError {
+ return APIError{
+ Code: "InvalidRequest",
+ Description: fmt.Sprintf("The provided HTTP error code (%d) is not valid. Valid codes are 4XX or 5XX.", input),
+ HTTPStatusCode: http.StatusBadRequest,
+ }
+}
+
+func GetWebsiteRoutingRulesLimitedErr(rules int) APIError {
+ return APIError{
+ Code: "InvalidRequest",
+ Description: fmt.Sprintf("%d routing rules provided, the number of routing rules in a website configuration is limited to 50.", rules),
+ HTTPStatusCode: http.StatusBadRequest,
+ }
+}
+
type ResourceType string
const (
diff --git a/s3err/signature-does-not-match-error.go b/s3err/signature-does-not-match-error.go
index 97cfeb96..51ec315f 100644
--- a/s3err/signature-does-not-match-error.go
+++ b/s3err/signature-does-not-match-error.go
@@ -55,6 +55,17 @@ func (e SignatureDoesNotMatchError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e SignatureDoesNotMatchError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId},
+ ErrorField{Name: "StringToSign", Value: e.StringToSign},
+ ErrorField{Name: "SignatureProvided", Value: e.SignatureProvided},
+ ErrorField{Name: "StringToSignBytes", Value: e.StringToSignBytes},
+ ErrorField{Name: "CanonicalRequest", Value: e.CanonicalRequest},
+ ErrorField{Name: "CanonicalRequestBytes", Value: e.CanonicalRequestBytes},
+ )
+}
+
func (e SignatureDoesNotMatchError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3err/sigv4.go b/s3err/sigv4.go
index a868089d..ebb25be3 100644
--- a/s3err/sigv4.go
+++ b/s3err/sigv4.go
@@ -44,6 +44,12 @@ func (e MalformedAuthError) XMLBody(requestID, hostID string) []byte {
})
}
+func (e MalformedAuthError) HTMLBody(requestID, hostID string) []byte {
+ return e.APIError.encodeHTMLResponse(requestID, hostID,
+ ErrorField{Name: "Region", Value: e.Region},
+ )
+}
+
func (e MalformedAuthError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
diff --git a/s3response/website.go b/s3response/website.go
index 16884e38..976a0769 100644
--- a/s3response/website.go
+++ b/s3response/website.go
@@ -17,8 +17,10 @@ package s3response
import (
"encoding/xml"
"fmt"
+ "strconv"
"strings"
+ "github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3err"
)
@@ -74,10 +76,12 @@ type Redirect struct {
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)
+ debuglogger.Logf("website redirect conflicts with config")
+ return s3err.GetAPIError(s3err.ErrMalformedXML)
}
if c.RedirectAllRequestsTo.HostName == "" {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
+ debuglogger.Logf("website redirect hostname is empty")
+ return s3err.GetAPIError(s3err.ErrMalformedXML)
}
if err := validateProtocol(c.RedirectAllRequestsTo.Protocol); err != nil {
return err
@@ -86,26 +90,31 @@ func (c *WebsiteConfiguration) Validate() error {
}
if c.IndexDocument == nil {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
+ debuglogger.Logf("website index document is missing")
+ return s3err.GetAPIError(s3err.ErrMalformedXML)
}
if c.IndexDocument.Suffix == "" {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)
+ debuglogger.Logf("website index suffix is empty")
+ return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix)
}
if strings.Contains(c.IndexDocument.Suffix, "/") {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)
+ debuglogger.Logf("website index suffix contains slash")
+ return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix)
}
if c.ErrorDocument != nil && c.ErrorDocument.Key == "" {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
+ debuglogger.Logf("website error document key is empty")
+ return s3err.GetInvalidArgumentErr(s3err.InvalidArgErrorDocumentKey, "")
}
if len(c.RoutingRules) > maxRoutingRules {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
+ debuglogger.Logf("too many website routing rules: %d", len(c.RoutingRules))
+ return s3err.GetWebsiteRoutingRulesLimitedErr(len(c.RoutingRules))
}
- for i, rule := range c.RoutingRules {
+ for _, rule := range c.RoutingRules {
if err := rule.Validate(); err != nil {
- return fmt.Errorf("routing rule %d: %w", i, err)
+ return err
}
}
@@ -114,27 +123,84 @@ func (c *WebsiteConfiguration) Validate() error {
// 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 {
+ if err := r.Redirect.Validate(); err != nil {
return err
}
- if r.Redirect.HttpRedirectCode != "" {
- code := r.Redirect.HttpRedirectCode
- if len(code) != 3 || code[0] != '3' {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectCode)
- }
+ if err := r.Condition.Validate(); err != nil {
+ return err
}
return nil
}
+func (c *RoutingRuleCondition) Validate() error {
+ if c == nil {
+ return nil
+ }
+
+ return isValidHTTPCode(c.HttpErrorCodeReturnedEquals, validateErrorCode)
+}
+
+func (r *Redirect) Validate() error {
+ if r.ReplaceKeyWith != "" && r.ReplaceKeyPrefixWith != "" {
+ debuglogger.Logf("website redirect has both key replacements")
+ return s3err.GetAPIError(s3err.ErrBothReplaceKeyAndPrefix)
+ }
+
+ if err := validateProtocol(r.Protocol); err != nil {
+ return err
+ }
+
+ if err := isValidHTTPCode(r.HttpRedirectCode, validateRedirectCode); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+type httpCodeValidator func(code int) error
+
+func isValidHTTPCode(input string, validateCode httpCodeValidator) error {
+ if input == "" {
+ return nil
+ }
+
+ code, err := strconv.Atoi(input)
+ if err != nil {
+ return s3err.GetAPIError(s3err.ErrMalformedXML)
+ }
+
+ return validateCode(code)
+}
+
+// isValidErrorCode checks if the provided code is a valid
+// HTTP error code: S3 considers 400-417 and 500-505 as valid
+func validateErrorCode(code int) error {
+ if (code >= 400 && code <= 417) || (code >= 500 && code <= 505) {
+ return nil
+ }
+
+ debuglogger.Logf("invalid website error code: %d", code)
+ return s3err.GetInvalidHTTPErrorCodeErr(code)
+}
+
+// validateRedirectCode check if the provided code
+// is a valid HTTP redirect code
+func validateRedirectCode(code int) error {
+ switch code {
+ case 301, 302, 303, 304, 305, 307, 308:
+ return nil
+ }
+
+ debuglogger.Logf("invalid website redirect code: %d", code)
+ return s3err.GetInvalidRedirectCodeErr(code)
+}
+
func validateProtocol(protocol string) error {
if protocol != "" && protocol != "http" && protocol != "https" {
- return s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)
+ debuglogger.Logf("invalid website redirect protocol: %q", protocol)
+ return s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol)
}
return nil
}
@@ -144,26 +210,27 @@ func ParseWebsiteConfigOutput(data []byte) (*WebsiteConfiguration, error) {
var config WebsiteConfiguration
err := xml.Unmarshal(data, &config)
if err != nil {
+ debuglogger.Logf("failed to parse website config: %v", err)
return nil, fmt.Errorf("failed to parse website config: %w", err)
}
return &config, nil
}
-// MatchPreRequestRule returns the first routing rule that matches based only
-// on KeyPrefixEquals (i.e. rules without HttpErrorCodeReturnedEquals). These
-// rules can be evaluated before the backend request is made. A rule with no
-// condition at all is treated as an unconditional match.
-func (c *WebsiteConfiguration) MatchPreRequestRule(key string) *RoutingRule {
+// MatchPrefetchRoutingRule returns the first rule that can be evaluated before
+// attempting an object read. Only prefix-only conditions participate in this
+// phase.
+func (c *WebsiteConfiguration) MatchPrefetchRoutingRule(key string) *RoutingRule {
for i := range c.RoutingRules {
rule := &c.RoutingRules[i]
-
- if rule.Condition != nil && rule.Condition.HttpErrorCodeReturnedEquals != "" {
- // This is a post-request rule, skip it
+ condition := rule.Condition
+ if condition == nil ||
+ condition.KeyPrefixEquals == "" ||
+ condition.HttpErrorCodeReturnedEquals != "" {
continue
}
- if rule.Condition == nil || strings.HasPrefix(key, rule.Condition.KeyPrefixEquals) {
+ if condition.KeyPrefixEquals != "" && strings.HasPrefix(key, condition.KeyPrefixEquals) {
return rule
}
}
@@ -171,28 +238,39 @@ func (c *WebsiteConfiguration) MatchPreRequestRule(key string) *RoutingRule {
return nil
}
-// MatchPostRequestRule returns the first routing rule that matches based on
-// HttpErrorCodeReturnedEquals (and optionally KeyPrefixEquals). These rules
-// are evaluated after the backend returns an error.
-func (c *WebsiteConfiguration) MatchPostRequestRule(key, httpErrorCode string) *RoutingRule {
+// MatchPostErrorRoutingRule returns the first rule that matches after a 4xx
+// object-read error. Prefix-only rules are skipped because they have already
+// been evaluated in the pre-fetch phase.
+func (c *WebsiteConfiguration) MatchPostErrorRoutingRule(key string, statusCode int) *RoutingRule {
for i := range c.RoutingRules {
rule := &c.RoutingRules[i]
-
- if rule.Condition == nil || rule.Condition.HttpErrorCodeReturnedEquals == "" {
- // Not a post-request rule
+ condition := rule.Condition
+ if condition != nil && condition.HttpErrorCodeReturnedEquals == "" {
continue
}
- if rule.Condition.HttpErrorCodeReturnedEquals != httpErrorCode {
- continue
+ if condition.Matches(key, statusCode) {
+ return rule
}
-
- if rule.Condition.KeyPrefixEquals != "" && !strings.HasPrefix(key, rule.Condition.KeyPrefixEquals) {
- continue
- }
-
- return rule
}
return nil
}
+
+// Matches reports whether all configured condition fields match.
+func (c *RoutingRuleCondition) Matches(key string, statusCode int) bool {
+ if c == nil {
+ return true
+ }
+
+ if c.KeyPrefixEquals != "" && !strings.HasPrefix(key, c.KeyPrefixEquals) {
+ return false
+ }
+
+ if c.HttpErrorCodeReturnedEquals != "" &&
+ strconv.Itoa(statusCode) != c.HttpErrorCodeReturnedEquals {
+ return false
+ }
+
+ return true
+}
diff --git a/s3response/website_test.go b/s3response/website_test.go
index b644a154..a5867e7f 100644
--- a/s3response/website_test.go
+++ b/s3response/website_test.go
@@ -15,7 +15,7 @@
package s3response
import (
- "encoding/xml"
+ "errors"
"testing"
"github.com/versity/versitygw/s3err"
@@ -26,7 +26,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
name string
config WebsiteConfiguration
wantErr bool
- errCode s3err.ErrorCode
+ errCode string
}{
{
name: "valid index document only",
@@ -70,7 +70,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
name: "missing index document",
config: WebsiteConfiguration{},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteConfiguration,
+ errCode: "MalformedXML",
},
{
name: "empty index suffix",
@@ -78,7 +78,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
IndexDocument: &IndexDocument{Suffix: ""},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteSuffix,
+ errCode: "InvalidArgument",
},
{
name: "index suffix with slash",
@@ -86,7 +86,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
IndexDocument: &IndexDocument{Suffix: "dir/index.html"},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteSuffix,
+ errCode: "InvalidArgument",
},
{
name: "redirect all with index document",
@@ -95,7 +95,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
IndexDocument: &IndexDocument{Suffix: "index.html"},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteConfiguration,
+ errCode: "MalformedXML",
},
{
name: "redirect all with empty hostname",
@@ -103,7 +103,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
RedirectAllRequestsTo: &RedirectAllRequestsTo{HostName: ""},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteConfiguration,
+ errCode: "MalformedXML",
},
{
name: "redirect all with invalid protocol",
@@ -114,7 +114,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteConfiguration,
+ errCode: "InvalidRequest",
},
{
name: "routing rule with both replace key fields",
@@ -130,7 +130,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteConfiguration,
+ errCode: "InvalidRequest",
},
{
name: "routing rule with invalid redirect code",
@@ -145,7 +145,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteRedirectCode,
+ errCode: "InvalidRequest",
},
{
name: "routing rule with valid redirect code",
@@ -168,7 +168,7 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
ErrorDocument: &ErrorDocument{Key: ""},
},
wantErr: true,
- errCode: s3err.ErrInvalidWebsiteConfiguration,
+ errCode: "InvalidArgument",
},
}
@@ -179,14 +179,12 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
if err == nil {
t.Fatal("expected error, got nil")
}
- apiErr, ok := err.(s3err.APIError)
- if !ok {
- // wrapped error from routing rule validation
- return
+ var apiErr s3err.S3Error
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected S3 error, got %T: %v", err, err)
}
- expectedErr := s3err.GetAPIError(tt.errCode)
- if apiErr.Code != expectedErr.Code {
- t.Errorf("expected error code %q, got %q", expectedErr.Code, apiErr.Code)
+ if apiErr.BaseError().Code != tt.errCode {
+ t.Errorf("expected error code %q, got %q", tt.errCode, apiErr.BaseError().Code)
}
} else {
if err != nil {
@@ -197,390 +195,83 @@ func TestWebsiteConfiguration_Validate(t *testing.T) {
}
}
-func TestWebsiteConfiguration_XMLRoundTrip(t *testing.T) {
- original := WebsiteConfiguration{
+func TestWebsiteConfiguration_MatchPrefetchRoutingRuleUsesPrefixOnlyRules(t *testing.T) {
+ config := 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/",
+ HostName: "error.example.com",
+ },
+ },
+ {
+ Condition: &RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ HttpErrorCodeReturnedEquals: "404",
+ },
+ Redirect: Redirect{
+ HostName: "both.example.com",
+ },
+ },
+ {
+ Condition: &RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ },
+ Redirect: Redirect{
+ HostName: "prefix.example.com",
},
},
},
}
- data, err := xml.Marshal(original)
- if err != nil {
- t.Fatalf("marshal: %v", err)
+ rule := config.MatchPrefetchRoutingRule("old/page.html")
+ if rule == nil {
+ t.Fatal("expected a matching rule, got nil")
}
-
- 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")
+ if rule.Redirect.HostName != "prefix.example.com" {
+ t.Fatalf("expected prefix-only rule to match, got %q", rule.Redirect.HostName)
}
}
-func TestParseWebsiteConfigOutput(t *testing.T) {
- xmlData := `
- index.html
- error.html
- `
+func TestRoutingRuleCondition_MatchesUsesAndLogic(t *testing.T) {
+ condition := RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ HttpErrorCodeReturnedEquals: "404",
+ }
- 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")
- }
-}
-
-func TestWebsiteConfiguration_MatchPreRequestRule(t *testing.T) {
tests := []struct {
- name string
- config WebsiteConfiguration
- key string
- wantNil bool
- wantHost string // expected redirect HostName if matched
+ name string
+ key string
+ statusCode int
+ want bool
}{
{
- name: "no routing rules",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- },
- key: "docs/page.html",
- wantNil: true,
+ name: "both match",
+ key: "old/missing.html",
+ statusCode: 404,
+ want: true,
},
{
- name: "key prefix match",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "docs.example.com",
- },
- },
- },
- },
- key: "docs/page.html",
- wantHost: "docs.example.com",
+ name: "prefix only",
+ key: "old/existing.html",
+ statusCode: 200,
+ want: false,
},
{
- name: "key prefix does not match",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "docs.example.com",
- },
- },
- },
- },
- key: "images/photo.jpg",
- wantNil: true,
- },
- {
- name: "unconditional rule (no condition)",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Redirect: Redirect{
- HostName: "redirect.example.com",
- },
- },
- },
- },
- key: "anything",
- wantHost: "redirect.example.com",
- },
- {
- name: "skips post-request rules",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- HttpErrorCodeReturnedEquals: "404",
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "error.example.com",
- },
- },
- },
- },
- key: "docs/page.html",
- wantNil: true,
- },
- {
- name: "first matching rule wins",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "first.example.com",
- },
- },
- {
- Condition: &RoutingRuleCondition{
- KeyPrefixEquals: "docs/api/",
- },
- Redirect: Redirect{
- HostName: "second.example.com",
- },
- },
- },
- },
- key: "docs/api/endpoint",
- wantHost: "first.example.com",
+ name: "status only",
+ key: "other/missing.html",
+ statusCode: 404,
+ want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- rule := tt.config.MatchPreRequestRule(tt.key)
- if tt.wantNil {
- if rule != nil {
- t.Fatalf("expected nil, got rule with redirect to %q", rule.Redirect.HostName)
- }
- return
- }
- if rule == nil {
- t.Fatal("expected a matching rule, got nil")
- }
- if rule.Redirect.HostName != tt.wantHost {
- t.Errorf("expected redirect host %q, got %q", tt.wantHost, rule.Redirect.HostName)
- }
- })
- }
-}
-
-func TestWebsiteConfiguration_MatchPostRequestRule(t *testing.T) {
- tests := []struct {
- name string
- config WebsiteConfiguration
- key string
- httpErrorCode string
- wantNil bool
- wantHost string
- }{
- {
- name: "no routing rules",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- },
- key: "page.html",
- httpErrorCode: "404",
- wantNil: true,
- },
- {
- name: "error code match",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- HttpErrorCodeReturnedEquals: "404",
- },
- Redirect: Redirect{
- HostName: "notfound.example.com",
- },
- },
- },
- },
- key: "page.html",
- httpErrorCode: "404",
- wantHost: "notfound.example.com",
- },
- {
- name: "error code does not match",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- HttpErrorCodeReturnedEquals: "404",
- },
- Redirect: Redirect{
- HostName: "notfound.example.com",
- },
- },
- },
- },
- key: "page.html",
- httpErrorCode: "403",
- wantNil: true,
- },
- {
- name: "error code and key prefix both match",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- HttpErrorCodeReturnedEquals: "404",
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "docs-error.example.com",
- },
- },
- },
- },
- key: "docs/missing.html",
- httpErrorCode: "404",
- wantHost: "docs-error.example.com",
- },
- {
- name: "error code matches but key prefix does not",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- HttpErrorCodeReturnedEquals: "404",
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "docs-error.example.com",
- },
- },
- },
- },
- key: "images/missing.jpg",
- httpErrorCode: "404",
- wantNil: true,
- },
- {
- name: "skips pre-request rules (no error code condition)",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "pre-request.example.com",
- },
- },
- },
- },
- key: "docs/page.html",
- httpErrorCode: "404",
- wantNil: true,
- },
- {
- name: "skips rules with no condition",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Redirect: Redirect{
- HostName: "unconditional.example.com",
- },
- },
- },
- },
- key: "page.html",
- httpErrorCode: "404",
- wantNil: true,
- },
- {
- name: "first matching rule wins",
- config: WebsiteConfiguration{
- IndexDocument: &IndexDocument{Suffix: "index.html"},
- RoutingRules: []RoutingRule{
- {
- Condition: &RoutingRuleCondition{
- HttpErrorCodeReturnedEquals: "404",
- },
- Redirect: Redirect{
- HostName: "first.example.com",
- },
- },
- {
- Condition: &RoutingRuleCondition{
- HttpErrorCodeReturnedEquals: "404",
- KeyPrefixEquals: "docs/",
- },
- Redirect: Redirect{
- HostName: "second.example.com",
- },
- },
- },
- },
- key: "docs/page.html",
- httpErrorCode: "404",
- wantHost: "first.example.com",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- rule := tt.config.MatchPostRequestRule(tt.key, tt.httpErrorCode)
- if tt.wantNil {
- if rule != nil {
- t.Fatalf("expected nil, got rule with redirect to %q", rule.Redirect.HostName)
- }
- return
- }
- if rule == nil {
- t.Fatal("expected a matching rule, got nil")
- }
- if rule.Redirect.HostName != tt.wantHost {
- t.Errorf("expected redirect host %q, got %q", tt.wantHost, rule.Redirect.HostName)
+ if got := condition.Matches(tt.key, tt.statusCode); got != tt.want {
+ t.Fatalf("Matches() = %v, want %v", got, tt.want)
}
})
}
diff --git a/tests/integration/PutBucketWebsite.go b/tests/integration/PutBucketWebsite.go
index 11e97f38..ed0a09d4 100644
--- a/tests/integration/PutBucketWebsite.go
+++ b/tests/integration/PutBucketWebsite.go
@@ -16,12 +16,16 @@ package integration
import (
"context"
+ "fmt"
+ "strings"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/versity/versitygw/s3err"
)
+const maxWebsiteConfigSize = 131072
+
func PutBucketWebsite_non_existing_bucket(s *S3Conf) error {
testName := "PutBucketWebsite_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -52,7 +56,7 @@ func PutBucketWebsite_empty_suffix(s *S3Conf) error {
},
})
cancel()
- return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix))
+ return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, ""))
})
}
@@ -69,7 +73,7 @@ func PutBucketWebsite_suffix_with_slash(s *S3Conf) error {
},
})
cancel()
- return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix))
+ return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, "/index.html"))
})
}
@@ -87,27 +91,284 @@ func PutBucketWebsite_invalid_redirect_protocol(s *S3Conf) error {
},
})
cancel()
- return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration))
+ return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol))
})
}
-func PutBucketWebsite_redirect_and_index(s *S3Conf) error {
- testName := "PutBucketWebsite_redirect_and_index"
+func PutBucketWebsite_redirectAll_index_error_routingRules(s *S3Conf) error {
+ testName := "PutBucketWebsite_redirectAll_index_error_routingRules"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ for _, test := range []struct {
+ name string
+ config *types.WebsiteConfiguration
+ }{
+ {
+ name: "index document",
+ config: &types.WebsiteConfiguration{
+ RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
+ HostName: getPtr("example.com"),
+ },
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ },
+ },
+ {
+ name: "error document",
+ config: &types.WebsiteConfiguration{
+ RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
+ HostName: getPtr("example.com"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
+ },
+ },
+ },
+ {
+ name: "routing rules",
+ config: &types.WebsiteConfiguration{
+ RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
+ HostName: getPtr("example.com"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Redirect: &types.Redirect{
+ HostName: getPtr("redirect.example.com"),
+ },
+ },
+ },
+ },
+ },
+ } {
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: test.config,
+ })
+ cancel()
+ if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)); err != nil {
+ return fmt.Errorf("%s: %w", test.name, err)
+ }
+ }
+
+ return nil
+ })
+}
+
+func PutBucketWebsite_invalid_routing_rule_protocol(s *S3Conf) error {
+ testName := "PutBucketWebsite_invalid_routing_rule_protocol"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
Bucket: &bucket,
WebsiteConfiguration: &types.WebsiteConfiguration{
- RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
- HostName: getPtr("example.com"),
- },
IndexDocument: &types.IndexDocument{
Suffix: getPtr("index.html"),
},
+ RoutingRules: []types.RoutingRule{
+ {
+ Redirect: &types.Redirect{
+ HostName: getPtr("example.com"),
+ Protocol: types.Protocol("ftp"),
+ },
+ },
+ },
},
})
cancel()
- return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration))
+ return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol))
+ })
+}
+
+func PutBucketWebsite_empty_error_document_key(s *S3Conf) error {
+ testName := "PutBucketWebsite_empty_error_document_key"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr(""),
+ },
+ },
+ })
+ cancel()
+ return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgErrorDocumentKey, ""))
+ })
+}
+
+func PutBucketWebsite_too_many_routing_rules(s *S3Conf) error {
+ testName := "PutBucketWebsite_too_many_routing_rules"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ routingRules := make([]types.RoutingRule, 51)
+ for i := range routingRules {
+ routingRules[i] = types.RoutingRule{
+ Condition: &types.Condition{
+ KeyPrefixEquals: getPtr(fmt.Sprintf("prefix-%d/", i)),
+ },
+ Redirect: &types.Redirect{
+ ReplaceKeyPrefixWith: getPtr(fmt.Sprintf("replacement-%d/", i)),
+ },
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ RoutingRules: routingRules,
+ },
+ })
+ cancel()
+ return checkApiErr(err, s3err.GetWebsiteRoutingRulesLimitedErr(51))
+ })
+}
+
+func PutBucketWebsite_routing_rule_replace_key_and_prefix(s *S3Conf) error {
+ testName := "PutBucketWebsite_routing_rule_replace_key_and_prefix"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Redirect: &types.Redirect{
+ ReplaceKeyWith: getPtr("replacement.html"),
+ ReplaceKeyPrefixWith: getPtr("replacement-prefix/"),
+ },
+ },
+ },
+ },
+ })
+ cancel()
+ return checkApiErr(err, s3err.GetAPIError(s3err.ErrBothReplaceKeyAndPrefix))
+ })
+}
+
+func PutBucketWebsite_invalid_http_redirect_code(s *S3Conf) error {
+ testName := "PutBucketWebsite_invalid_http_redirect_code"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ for _, test := range []struct {
+ code string
+ expectedErr s3err.S3Error
+ }{
+ {code: "300", expectedErr: s3err.GetInvalidRedirectCodeErr(300)},
+ {code: "306", expectedErr: s3err.GetInvalidRedirectCodeErr(306)},
+ {code: "309", expectedErr: s3err.GetInvalidRedirectCodeErr(309)},
+ {code: "399", expectedErr: s3err.GetInvalidRedirectCodeErr(399)},
+ {code: "jibberish", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
+ {code: "3xx", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
+ } {
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Redirect: &types.Redirect{
+ HostName: getPtr("example.com"),
+ HttpRedirectCode: getPtr(test.code),
+ },
+ },
+ },
+ },
+ })
+ cancel()
+ if err := checkApiErr(err, test.expectedErr); err != nil {
+ return fmt.Errorf("code %q: %w", test.code, err)
+ }
+ }
+
+ return nil
+ })
+}
+
+func PutBucketWebsite_invalid_http_error_code(s *S3Conf) error {
+ testName := "PutBucketWebsite_invalid_http_error_code"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ for _, test := range []struct {
+ code string
+ expectedErr s3err.S3Error
+ }{
+ {code: "399", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(399)},
+ {code: "418", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(418)},
+ {code: "499", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(499)},
+ {code: "506", expectedErr: s3err.GetInvalidHTTPErrorCodeErr(506)},
+ {code: "jibberish", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
+ {code: "4xx", expectedErr: s3err.GetAPIError(s3err.ErrMalformedXML)},
+ } {
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Condition: &types.Condition{
+ HttpErrorCodeReturnedEquals: getPtr(test.code),
+ },
+ Redirect: &types.Redirect{
+ HostName: getPtr("example.com"),
+ },
+ },
+ },
+ },
+ })
+ cancel()
+ if err := checkApiErr(err, test.expectedErr); err != nil {
+ return fmt.Errorf("code %q: %w", test.code, err)
+ }
+ }
+
+ return nil
+ })
+}
+
+func PutBucketWebsite_request_too_large(s *S3Conf) error {
+ testName := "PutBucketWebsite_request_too_large"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ longValue := strings.Repeat("a", 2048)
+ routingRules := make([]types.RoutingRule, 50)
+ for i := range routingRules {
+ routingRules[i] = types.RoutingRule{
+ Condition: &types.Condition{
+ KeyPrefixEquals: getPtr(fmt.Sprintf("prefix-%d-%s", i, longValue)),
+ },
+ Redirect: &types.Redirect{
+ HostName: getPtr("example.com"),
+ ReplaceKeyWith: getPtr(fmt.Sprintf("replacement-%d-%s", i, longValue)),
+ HttpRedirectCode: getPtr("301"),
+ },
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ RoutingRules: routingRules,
+ },
+ })
+ cancel()
+ return checkApiErr(err, s3err.GetMaxMessageLengthExceeded(maxWebsiteConfigSize))
})
}
diff --git a/tests/integration/WebsiteHosting.go b/tests/integration/WebsiteHosting.go
index 453a02b2..9d970024 100644
--- a/tests/integration/WebsiteHosting.go
+++ b/tests/integration/WebsiteHosting.go
@@ -15,399 +15,770 @@
package integration
import (
- "bytes"
- "context"
- "crypto/tls"
"fmt"
- "io"
"net/http"
"strings"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/versity/versitygw/s3err"
)
-// websiteHTTPClient returns an HTTP client suitable for website endpoint
-// requests. It does not follow redirects and skips TLS verification
-// (matching the behaviour of the S3Conf http client for self-signed certs).
-func websiteHTTPClient() *http.Client {
- return &http.Client{
- Transport: &http.Transport{
- TLSClientConfig: &tls.Config{
- InsecureSkipVerify: true,
- },
- },
- CheckRedirect: func(req *http.Request, via []*http.Request) error {
- return http.ErrUseLastResponse
- },
- }
-}
-
-// websiteGet issues a plain HTTP GET to the dedicated website endpoint.
-// The bucket is resolved from the Host header. No S3 signing is applied.
-func websiteGet(websiteEndpoint, host, path string) (*http.Response, error) {
- url := fmt.Sprintf("%s/%s", strings.TrimRight(websiteEndpoint, "/"), strings.TrimLeft(path, "/"))
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- req.Host = host
- return websiteHTTPClient().Do(req)
-}
-
-// WebsiteHosting_error_document_served tests that when a website-enabled
-// bucket has an error document configured, requesting a non-existing key
-// returns the error document content with the original 404 status code.
+// WebsiteHosting_error_document_served tests that a missing website object
+// serves the configured error document while preserving the original 404 status.
func WebsiteHosting_error_document_served(s *S3Conf) error {
testName := "WebsiteHosting_error_document_served"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
- // Configure website with error document
- ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
- _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
- Bucket: &bucket,
- WebsiteConfiguration: &types.WebsiteConfiguration{
- IndexDocument: &types.IndexDocument{
- Suffix: getPtr("index.html"),
- },
- ErrorDocument: &types.ErrorDocument{
- Key: getPtr("error.html"),
- },
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
},
})
- cancel()
if err != nil {
return err
}
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
- // Upload the error document
errorContent := "Custom Error Page"
- ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
- _, err = s3client.PutObject(ctx, &s3.PutObjectInput{
+ _, err = putObjectWithData(int64(len(errorContent)), &s3.PutObjectInput{
Bucket: &bucket,
Key: getPtr("error.html"),
Body: strings.NewReader(errorContent),
ContentType: getPtr("text/html"),
- })
- cancel()
+ }, s3client)
if err != nil {
return err
}
- // Request a non-existing key via plain HTTP on the website endpoint
- resp, err := websiteGet(s.websiteEndpoint, bucket, "nonexistent-key")
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- return fmt.Errorf("expected status 404, got %v", resp.StatusCode)
- }
-
- body, err := io.ReadAll(resp.Body)
+ resp, err := websiteGet(s, bucket, "nonexistent-key", nil)
if err != nil {
return err
}
- if string(body) != errorContent {
- return fmt.Errorf("expected error document content %q, got %q", errorContent, string(body))
+ if got := resp.Header.Get("Content-Type"); got != "text/html" {
+ return fmt.Errorf("expected text/html Content-Type, got %q", got)
}
-
- return nil
+ return checkWebsiteResponse(resp, http.StatusNotFound, []byte(errorContent))
})
}
-// WebsiteHosting_error_document_not_found tests that when the configured
-// error document itself does not exist, a 404 error page is returned.
+// WebsiteHosting_error_document_not_found tests that a missing configured
+// error document returns the complete website NoSuchKey error response.
func WebsiteHosting_error_document_not_found(s *S3Conf) error {
testName := "WebsiteHosting_error_document_not_found"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
- // Configure website with error document (but don't upload it)
- ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
- _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
- Bucket: &bucket,
- WebsiteConfiguration: &types.WebsiteConfiguration{
- IndexDocument: &types.IndexDocument{
- Suffix: getPtr("index.html"),
- },
- ErrorDocument: &types.ErrorDocument{
- Key: getPtr("error.html"),
- },
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
},
})
- cancel()
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+
+ resp, err := websiteGet(s, bucket, "nonexistent-key", nil)
if err != nil {
return err
}
- // Request a non-existing key - should get 404 since error doc doesn't exist either
- resp, err := websiteGet(s.websiteEndpoint, bucket, "nonexistent-key")
- if err != nil {
- return err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- return fmt.Errorf("expected status 404, got %v", resp.StatusCode)
- }
-
- return nil
+ return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoSuchKey))
})
}
-// WebsiteHosting_no_error_document tests that when website is enabled
-// but no error document is configured, a 404 error page is returned.
+// WebsiteHosting_no_error_document tests that a website bucket without an
+// error document returns the complete website NoSuchKey error response.
func WebsiteHosting_no_error_document(s *S3Conf) error {
testName := "WebsiteHosting_no_error_document"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
- // Configure website without error document
- ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
- _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
- Bucket: &bucket,
- WebsiteConfiguration: &types.WebsiteConfiguration{
- IndexDocument: &types.IndexDocument{
- Suffix: getPtr("index.html"),
- },
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
},
})
- cancel()
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+
+ resp, err := websiteGet(s, bucket, "nonexistent-key", nil)
if err != nil {
return err
}
- // Request a non-existing key - should get 404
- resp, err := websiteGet(s.websiteEndpoint, bucket, "nonexistent-key")
+ return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoSuchKey))
+ })
+}
+
+// WebsiteHosting_private_object_and_error_document tests that website hosting
+// does not serve either the requested object or the configured error document
+// unless public object access has been granted.
+func WebsiteHosting_private_object_and_error_document(s *S3Conf) error {
+ testName := "WebsiteHosting_private_object_and_error_document"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
+ },
+ })
if err != nil {
return err
}
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusNotFound {
- return fmt.Errorf("expected status 404, got %v", resp.StatusCode)
+ privateError := "private error"
+ _, err = putObjectWithData(int64(len(privateError)), &s3.PutObjectInput{
+ Bucket: &bucket,
+ Key: getPtr("error.html"),
+ Body: strings.NewReader(privateError),
+ ContentType: getPtr("text/html"),
+ }, s3client)
+ if err != nil {
+ return err
}
- return nil
+ resp, err := websiteGet(s, bucket, "private.html", nil)
+ if err != nil {
+ return err
+ }
+
+ return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrAccessDenied))
})
}
// WebsiteHosting_routing_rule_post_request_redirect tests that a post-request
-// routing rule (matching on error code) issues a redirect instead of serving
-// the error or error document.
+// routing rule matching a 404 issues a redirect instead of serving an error.
func WebsiteHosting_routing_rule_post_request_redirect(s *S3Conf) error {
testName := "WebsiteHosting_routing_rule_post_request_redirect"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
- // Configure website with a post-request routing rule for 404
- ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
- _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
- Bucket: &bucket,
- WebsiteConfiguration: &types.WebsiteConfiguration{
- IndexDocument: &types.IndexDocument{
- Suffix: getPtr("index.html"),
- },
- ErrorDocument: &types.ErrorDocument{
- Key: getPtr("error.html"),
- },
- RoutingRules: []types.RoutingRule{
- {
- Condition: &types.Condition{
- HttpErrorCodeReturnedEquals: getPtr("404"),
- },
- Redirect: &types.Redirect{
- HostName: getPtr("fallback.example.com"),
- ReplaceKeyWith: getPtr("not-found"),
- HttpRedirectCode: getPtr("302"),
- },
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Condition: &types.Condition{
+ HttpErrorCodeReturnedEquals: getPtr("404"),
+ },
+ Redirect: &types.Redirect{
+ HostName: getPtr("fallback.example.com"),
+ ReplaceKeyWith: getPtr("not-found"),
+ HttpRedirectCode: getPtr("302"),
},
},
},
})
- cancel()
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+
+ resp, err := websiteGet(s, bucket, "missing-page", nil)
if err != nil {
return err
}
- // Request a non-existing key via the website endpoint
- resp, err := websiteGet(s.websiteEndpoint, bucket, "missing-page")
+ wantLocation, err := websiteAbsoluteURL(s, "fallback.example.com", "not-found")
if err != nil {
return err
}
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusFound {
- return fmt.Errorf("expected status 302, got %v", resp.StatusCode)
+ if got := resp.Header.Get("Location"); got != wantLocation {
+ return fmt.Errorf("expected Location %q, got %q", wantLocation, got)
}
-
- location := resp.Header.Get("Location")
- if location == "" {
- return fmt.Errorf("expected Location header, got none")
- }
-
- // The redirect should point to fallback.example.com/not-found
- if !strings.Contains(location, "fallback.example.com") || !strings.Contains(location, "not-found") {
- return fmt.Errorf("expected redirect to fallback.example.com/not-found, got %q", location)
- }
-
- return nil
+ return checkWebsiteResponse(resp, http.StatusFound, []byte(http.StatusText(http.StatusFound)))
})
}
-// WebsiteHosting_routing_rule_pre_request_redirect tests that a pre-request
-// routing rule (matching on key prefix only) issues a redirect before the
-// object is fetched.
+// WebsiteHosting_routing_rule_pre_request_redirect tests that a key-prefix
+// routing rule redirects before public access or object existence is checked.
func WebsiteHosting_routing_rule_pre_request_redirect(s *S3Conf) error {
testName := "WebsiteHosting_routing_rule_pre_request_redirect"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
- // Configure website with a pre-request routing rule
- ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
- _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
- Bucket: &bucket,
- WebsiteConfiguration: &types.WebsiteConfiguration{
- IndexDocument: &types.IndexDocument{
- Suffix: getPtr("index.html"),
- },
- RoutingRules: []types.RoutingRule{
- {
- Condition: &types.Condition{
- KeyPrefixEquals: getPtr("old-docs/"),
- },
- Redirect: &types.Redirect{
- ReplaceKeyPrefixWith: getPtr("new-docs/"),
- HttpRedirectCode: getPtr("301"),
- },
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Condition: &types.Condition{
+ KeyPrefixEquals: getPtr("old-docs/"),
+ },
+ Redirect: &types.Redirect{
+ ReplaceKeyPrefixWith: getPtr("new-docs/"),
+ HttpRedirectCode: getPtr("301"),
},
},
},
})
- cancel()
if err != nil {
return err
}
- // Request old-docs/page.html via the website endpoint
- resp, err := websiteGet(s.websiteEndpoint, bucket, "old-docs/page.html")
+ resp, err := websiteGet(s, bucket, "old-docs/page.html", nil)
if err != nil {
return err
}
defer resp.Body.Close()
- if resp.StatusCode != http.StatusMovedPermanently {
- return fmt.Errorf("expected status 301, got %v", resp.StatusCode)
+ wantLocation, err := websiteURL(s, bucket, "new-docs/page.html")
+ if err != nil {
+ return err
}
-
- location := resp.Header.Get("Location")
- if location == "" {
- return fmt.Errorf("expected Location header, got none")
+ if got := resp.Header.Get("Location"); got != wantLocation {
+ return fmt.Errorf("expected Location %q, got %q", wantLocation, got)
}
-
- // The redirect should rewrite old-docs/ -> new-docs/
- if !strings.Contains(location, "new-docs/page.html") {
- return fmt.Errorf("expected redirect to contain new-docs/page.html, got %q", location)
- }
-
- return nil
+ return checkWebsiteResponse(resp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently)))
})
}
-// WebsiteHosting_redirect_all_requests tests the RedirectAllRequestsTo
-// configuration, which should redirect any request to the specified host.
+// WebsiteHosting_routing_rule_prefix_and_error_redirect tests a routing rule
+// with both KeyPrefixEquals and HttpErrorCodeReturnedEquals conditions.
+func WebsiteHosting_routing_rule_prefix_and_error_redirect(s *S3Conf) error {
+ testName := "WebsiteHosting_routing_rule_prefix_and_error_redirect"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Condition: &types.Condition{
+ KeyPrefixEquals: getPtr("old/"),
+ HttpErrorCodeReturnedEquals: getPtr("404"),
+ },
+ Redirect: &types.Redirect{
+ ReplaceKeyPrefixWith: getPtr("archived/"),
+ HttpRedirectCode: getPtr("307"),
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+
+ resp, err := websiteGet(s, bucket, "old/missing.html?ref=1", nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ wantLocation, err := websiteURL(s, bucket, "archived/missing.html?ref=1")
+ if err != nil {
+ return err
+ }
+ if got := resp.Header.Get("Location"); got != wantLocation {
+ return fmt.Errorf("expected Location %q, got %q", wantLocation, got)
+ }
+ return checkWebsiteResponse(resp, http.StatusTemporaryRedirect, []byte(http.StatusText(http.StatusTemporaryRedirect)))
+ })
+}
+
+// WebsiteHosting_routing_rule_no_match_serves_error_document tests that routing
+// rules which do not match fall back to the configured error document.
+func WebsiteHosting_routing_rule_no_match_serves_error_document(s *S3Conf) error {
+ testName := "WebsiteHosting_routing_rule_no_match_serves_error_document"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Condition: &types.Condition{
+ KeyPrefixEquals: getPtr("docs/"),
+ HttpErrorCodeReturnedEquals: getPtr("404"),
+ },
+ Redirect: &types.Redirect{
+ ReplaceKeyPrefixWith: getPtr("archive/"),
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+ errorContent := "fallback error"
+ _, err = putObjectWithData(int64(len(errorContent)), &s3.PutObjectInput{
+ Bucket: &bucket,
+ Key: getPtr("error.html"),
+ Body: strings.NewReader(errorContent),
+ ContentType: getPtr("text/html"),
+ }, s3client)
+ if err != nil {
+ return err
+ }
+
+ resp, err := websiteGet(s, bucket, "images/missing.png", nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ return checkWebsiteResponse(resp, http.StatusNotFound, []byte(errorContent))
+ })
+}
+
+// WebsiteHosting_redirect_all_requests tests RedirectAllRequestsTo, including
+// path and query preservation, without requiring public object access.
func WebsiteHosting_redirect_all_requests(s *S3Conf) error {
testName := "WebsiteHosting_redirect_all_requests"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
- // Configure redirect-all
- ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
- _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
- Bucket: &bucket,
- WebsiteConfiguration: &types.WebsiteConfiguration{
- RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
- HostName: getPtr("www.example.com"),
- Protocol: types.ProtocolHttps,
- },
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
+ HostName: getPtr("www.example.com"),
+ Protocol: types.ProtocolHttps,
},
})
- cancel()
if err != nil {
return err
}
- // Request any path via the website endpoint
- resp, err := websiteGet(s.websiteEndpoint, bucket, "any/path/here")
+ resp, err := websiteGet(s, bucket, "any/path/here?tracking=1", nil)
if err != nil {
return err
}
defer resp.Body.Close()
- if resp.StatusCode != http.StatusMovedPermanently {
- return fmt.Errorf("expected status 301, got %v", resp.StatusCode)
+ if got, want := resp.Header.Get("Location"), "https://www.example.com/any/path/here?tracking=1"; got != want {
+ return fmt.Errorf("expected Location %q, got %q", want, got)
}
-
- location := resp.Header.Get("Location")
- if !strings.HasPrefix(location, "https://www.example.com/") {
- return fmt.Errorf("expected redirect to https://www.example.com/, got %q", location)
- }
-
- if !strings.Contains(location, "any/path/here") {
- return fmt.Errorf("expected redirect to preserve path, got %q", location)
- }
-
- return nil
+ return checkWebsiteResponse(resp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently)))
})
}
-// WebsiteHosting_index_document tests that requesting a directory-like
-// path on a website-enabled bucket serves the index document.
+// WebsiteHosting_index_document tests root and directory-style index document
+// resolution through the website endpoint.
func WebsiteHosting_index_document(s *S3Conf) error {
testName := "WebsiteHosting_index_document"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
- // Configure website
- ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
- _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
- Bucket: &bucket,
- WebsiteConfiguration: &types.WebsiteConfiguration{
- IndexDocument: &types.IndexDocument{
- Suffix: getPtr("index.html"),
- },
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
},
})
- cancel()
if err != nil {
return err
}
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
- // Upload index document at root
indexContent := "Welcome"
- ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
- _, err = s3client.PutObject(ctx, &s3.PutObjectInput{
+ _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{
Bucket: &bucket,
Key: getPtr("index.html"),
Body: strings.NewReader(indexContent),
ContentType: getPtr("text/html"),
- })
- cancel()
+ }, s3client)
+ if err != nil {
+ return err
+ }
+ docsContent := "Docs Home"
+ _, err = putObjectWithData(int64(len(docsContent)), &s3.PutObjectInput{
+ Bucket: &bucket,
+ Key: getPtr("docs/index.html"),
+ Body: strings.NewReader(docsContent),
+ ContentType: getPtr("text/html"),
+ }, s3client)
if err != nil {
return err
}
- // Request the root path via the website endpoint
- resp, err := websiteGet(s.websiteEndpoint, bucket, "/")
- if err != nil {
- return err
- }
- defer resp.Body.Close()
+ for _, test := range []struct {
+ path string
+ body string
+ }{
+ {"/", indexContent},
+ {"docs/", docsContent},
+ } {
+ resp, err := websiteGet(s, bucket, test.path, nil)
+ if err != nil {
+ return err
+ }
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("expected status 200, got %v; body: %s", resp.StatusCode, body)
- }
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return err
- }
-
- if !bytes.Equal(body, []byte(indexContent)) {
- return fmt.Errorf("expected index document content %q, got %q", indexContent, string(body))
+ err = checkWebsiteResponse(resp, http.StatusOK, []byte(test.body))
+ resp.Body.Close()
+ if err != nil {
+ return fmt.Errorf("%s: %w", test.path, err)
+ }
}
return nil
})
}
+
+// WebsiteHosting_index_error_document_and_routing_rules covers a combined
+// website configuration with index, error document, pre-rule, and post-rule.
+func WebsiteHosting_index_error_document_and_routing_rules(s *S3Conf) error {
+ testName := "WebsiteHosting_index_error_document_and_routing_rules"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ ErrorDocument: &types.ErrorDocument{
+ Key: getPtr("error.html"),
+ },
+ RoutingRules: []types.RoutingRule{
+ {
+ Condition: &types.Condition{
+ KeyPrefixEquals: getPtr("legacy/"),
+ },
+ Redirect: &types.Redirect{
+ ReplaceKeyPrefixWith: getPtr("docs/"),
+ HttpRedirectCode: getPtr("301"),
+ },
+ },
+ {
+ Condition: &types.Condition{
+ HttpErrorCodeReturnedEquals: getPtr("404"),
+ },
+ Redirect: &types.Redirect{
+ HostName: getPtr("fallback.example.com"),
+ ReplaceKeyWith: getPtr("missing"),
+ HttpRedirectCode: getPtr("302"),
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+ indexContent := "combined index"
+ _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{
+ Bucket: &bucket,
+ Key: getPtr("index.html"),
+ Body: strings.NewReader(indexContent),
+ ContentType: getPtr("text/html"),
+ }, s3client)
+ if err != nil {
+ return err
+ }
+ combinedError := "combined error"
+ _, err = putObjectWithData(int64(len(combinedError)), &s3.PutObjectInput{
+ Bucket: &bucket,
+ Key: getPtr("error.html"),
+ Body: strings.NewReader(combinedError),
+ ContentType: getPtr("text/html"),
+ }, s3client)
+ if err != nil {
+ return err
+ }
+
+ indexResp, err := websiteGet(s, bucket, "/", nil)
+ if err != nil {
+ return err
+ }
+ if err := checkWebsiteResponse(indexResp, http.StatusOK, []byte(indexContent)); err != nil {
+ return err
+ }
+ indexResp.Body.Close()
+
+ preResp, err := websiteGet(s, bucket, "legacy/page.html", nil)
+ if err != nil {
+ return err
+ }
+ wantPreLocation, err := websiteURL(s, bucket, "docs/page.html")
+ if err != nil {
+ preResp.Body.Close()
+ return err
+ }
+ if got := preResp.Header.Get("Location"); got != wantPreLocation {
+ preResp.Body.Close()
+ return fmt.Errorf("expected pre-rule Location %q, got %q", wantPreLocation, got)
+ }
+ if err := checkWebsiteResponse(preResp, http.StatusMovedPermanently, []byte(http.StatusText(http.StatusMovedPermanently))); err != nil {
+ return err
+ }
+
+ postResp, err := websiteGet(s, bucket, "unknown.html", nil)
+ if err != nil {
+ return err
+ }
+ wantPostLocation, err := websiteAbsoluteURL(s, "fallback.example.com", "missing")
+ if err != nil {
+ postResp.Body.Close()
+ return err
+ }
+ if got := postResp.Header.Get("Location"); got != wantPostLocation {
+ postResp.Body.Close()
+ return fmt.Errorf("expected post-rule Location %q, got %q", wantPostLocation, got)
+ }
+ if err := checkWebsiteResponse(postResp, http.StatusFound, []byte(http.StatusText(http.StatusFound))); err != nil {
+ return err
+ }
+
+ return nil
+ })
+}
+
+func WebsiteHosting_options_preflight_access_granted(s *S3Conf) error {
+ testName := "WebsiteHosting_options_preflight_access_granted"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketCors(s3client, &s3.PutBucketCorsInput{
+ Bucket: &bucket,
+ CORSConfiguration: &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedOrigins: []string{"https://client.example"},
+ AllowedMethods: []string{http.MethodGet, http.MethodHead},
+ AllowedHeaders: []string{"Content-Type", "X-Amz-Date"},
+ ExposeHeaders: []string{"Content-Length"},
+ MaxAgeSeconds: getPtr(int32(42)),
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ resp, err := websiteOptions(s, bucket, "index.html", map[string]string{
+ "Origin": "https://client.example",
+ "Access-Control-Request-Method": http.MethodGet,
+ "Access-Control-Request-Headers": "content-type, X-Amz-Date",
+ })
+ if err != nil {
+ return err
+ }
+
+ corsHeaders, err := extractCORSHeaders(resp)
+ if err != nil {
+ return err
+ }
+ if err := comparePreflightResult(&PreflightResult{
+ Origin: "https://client.example",
+ Methods: "GET, HEAD",
+ AllowHeaders: "content-type, x-amz-date",
+ ExposeHeaders: "Content-Length, ETag",
+ MaxAge: "42",
+ AllowCredentials: "true",
+ Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method",
+ }, corsHeaders); err != nil {
+ return err
+ }
+
+ return checkWebsiteResponse(resp, http.StatusOK, nil)
+ })
+}
+
+func WebsiteHosting_get_cors_headers(s *S3Conf) error {
+ testName := "WebsiteHosting_get_cors_headers"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ })
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+
+ indexContent := "CORS GET"
+ _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{
+ Bucket: &bucket,
+ Key: getPtr("index.html"),
+ Body: strings.NewReader(indexContent),
+ ContentType: getPtr("text/html"),
+ }, s3client)
+ if err != nil {
+ return err
+ }
+
+ maxAge := int32(42)
+ err = putBucketCors(s3client, &s3.PutBucketCorsInput{
+ Bucket: &bucket,
+ CORSConfiguration: &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedOrigins: []string{"https://client.example"},
+ AllowedMethods: []string{http.MethodGet, http.MethodHead},
+ ExposeHeaders: []string{"Content-Length"},
+ MaxAgeSeconds: &maxAge,
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ resp, err := websiteGet(s, bucket, "/", map[string]string{
+ "Origin": "https://client.example",
+ })
+ if err != nil {
+ return err
+ }
+
+ corsHeaders, err := extractCORSHeaders(resp)
+ if err != nil {
+ resp.Body.Close()
+ return err
+ }
+ if err := comparePreflightResult(&PreflightResult{
+ Origin: "https://client.example",
+ Methods: "GET, HEAD",
+ ExposeHeaders: "Content-Length, ETag, x-amz-storage-class",
+ MaxAge: "42",
+ AllowCredentials: "true",
+ Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method",
+ }, corsHeaders); err != nil {
+ resp.Body.Close()
+ return err
+ }
+
+ return checkWebsiteResponse(resp, http.StatusOK, []byte(indexContent))
+ })
+}
+
+func WebsiteHosting_head_cors_headers(s *S3Conf) error {
+ testName := "WebsiteHosting_head_cors_headers"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{
+ IndexDocument: &types.IndexDocument{
+ Suffix: getPtr("index.html"),
+ },
+ })
+ if err != nil {
+ return err
+ }
+ if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil {
+ return err
+ }
+
+ headContent := "CORS HEAD"
+ _, err = putObjectWithData(int64(len(headContent)), &s3.PutObjectInput{
+ Bucket: &bucket,
+ Key: getPtr("head.html"),
+ Body: strings.NewReader(headContent),
+ ContentType: getPtr("text/html"),
+ }, s3client)
+ if err != nil {
+ return err
+ }
+
+ err = putBucketCors(s3client, &s3.PutBucketCorsInput{
+ Bucket: &bucket,
+ CORSConfiguration: &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedOrigins: []string{"*"},
+ AllowedMethods: []string{http.MethodHead},
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ resp, err := websiteHead(s, bucket, "head.html", map[string]string{
+ "Origin": "https://client.example",
+ })
+ if err != nil {
+ return err
+ }
+
+ corsHeaders, err := extractCORSHeaders(resp)
+ if err != nil {
+ resp.Body.Close()
+ return err
+ }
+ if err := comparePreflightResult(&PreflightResult{
+ Origin: "*",
+ Methods: "HEAD",
+ ExposeHeaders: "ETag, x-amz-storage-class",
+ AllowCredentials: "false",
+ Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method",
+ }, corsHeaders); err != nil {
+ resp.Body.Close()
+ return err
+ }
+
+ return checkWebsiteResponse(resp, http.StatusOK, nil)
+ })
+}
+
+func WebsiteHosting_options_preflight_access_forbidden(s *S3Conf) error {
+ testName := "WebsiteHosting_options_preflight_access_forbidden"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ err := putBucketCors(s3client, &s3.PutBucketCorsInput{
+ Bucket: &bucket,
+ CORSConfiguration: &types.CORSConfiguration{
+ CORSRules: []types.CORSRule{
+ {
+ AllowedOrigins: []string{"https://client.example"},
+ AllowedMethods: []string{http.MethodHead},
+ },
+ },
+ },
+ })
+ if err != nil {
+ return err
+ }
+
+ resp, err := websiteOptions(s, bucket, "index.html", map[string]string{
+ "Origin": "https://client.example",
+ "Access-Control-Request-Method": http.MethodGet,
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ return checkWebsiteErrorResponse(resp,
+ s3err.GetAccessForbiddenErr(s3err.ErrCORSForbidden, http.MethodOptions, s3err.ResourceTypeObject))
+ })
+}
+
+func WebsiteHosting_options_preflight_missing_origin(s *S3Conf) error {
+ testName := "WebsiteHosting_options_preflight_missing_origin"
+ return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
+ resp, err := websiteOptions(s, bucket, "index.html", map[string]string{
+ "Access-Control-Request-Method": http.MethodGet,
+ })
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrMissingCORSOrigin))
+ })
+}
diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go
index 323b20fe..b93e2e94 100644
--- a/tests/integration/group-tests.go
+++ b/tests/integration/group-tests.go
@@ -14,8 +14,6 @@
package integration
-import "fmt"
-
func TestAuthentication(ts *TestState) {
ts.Run(Authentication_invalid_auth_header)
ts.Run(Authentication_unsupported_signature_version)
@@ -670,7 +668,14 @@ func TestPutBucketWebsite(ts *TestState) {
ts.Run(PutBucketWebsite_empty_suffix)
ts.Run(PutBucketWebsite_suffix_with_slash)
ts.Run(PutBucketWebsite_invalid_redirect_protocol)
- ts.Run(PutBucketWebsite_redirect_and_index)
+ ts.Run(PutBucketWebsite_redirectAll_index_error_routingRules)
+ ts.Run(PutBucketWebsite_invalid_routing_rule_protocol)
+ ts.Run(PutBucketWebsite_empty_error_document_key)
+ ts.Run(PutBucketWebsite_too_many_routing_rules)
+ ts.Run(PutBucketWebsite_routing_rule_replace_key_and_prefix)
+ ts.Run(PutBucketWebsite_invalid_http_redirect_code)
+ ts.Run(PutBucketWebsite_invalid_http_error_code)
+ ts.Run(PutBucketWebsite_request_too_large)
ts.Run(PutBucketWebsite_success)
ts.Run(PutBucketWebsite_success_redirect_all)
}
@@ -688,17 +693,22 @@ func TestDeleteBucketWebsite(ts *TestState) {
}
func TestWebsiteHosting(ts *TestState) {
- if ts.conf.websiteEndpoint == "" {
- fmt.Println("skipping TestWebsiteHosting: no website endpoint configured")
- return
- }
ts.Run(WebsiteHosting_error_document_served)
ts.Run(WebsiteHosting_error_document_not_found)
ts.Run(WebsiteHosting_no_error_document)
+ ts.Run(WebsiteHosting_private_object_and_error_document)
ts.Run(WebsiteHosting_routing_rule_post_request_redirect)
ts.Run(WebsiteHosting_routing_rule_pre_request_redirect)
+ ts.Run(WebsiteHosting_routing_rule_prefix_and_error_redirect)
+ ts.Run(WebsiteHosting_routing_rule_no_match_serves_error_document)
ts.Run(WebsiteHosting_redirect_all_requests)
ts.Run(WebsiteHosting_index_document)
+ ts.Run(WebsiteHosting_index_error_document_and_routing_rules)
+ ts.Run(WebsiteHosting_get_cors_headers)
+ ts.Run(WebsiteHosting_head_cors_headers)
+ ts.Run(WebsiteHosting_options_preflight_access_granted)
+ ts.Run(WebsiteHosting_options_preflight_access_forbidden)
+ ts.Run(WebsiteHosting_options_preflight_missing_origin)
}
func TestPreflightOPTIONSEndpoint(ts *TestState) {
@@ -899,7 +909,6 @@ func TestFullFlow(ts *TestState) {
TestPutBucketWebsite(ts)
TestGetBucketWebsite(ts)
TestDeleteBucketWebsite(ts)
- TestWebsiteHosting(ts)
TestPreflightOPTIONSEndpoint(ts)
TestPutObjectLockConfiguration(ts)
TestGetObjectLockConfiguration(ts)
@@ -1802,7 +1811,14 @@ func GetIntTests() IntTests {
"PutBucketWebsite_empty_suffix": PutBucketWebsite_empty_suffix,
"PutBucketWebsite_suffix_with_slash": PutBucketWebsite_suffix_with_slash,
"PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol,
- "PutBucketWebsite_redirect_and_index": PutBucketWebsite_redirect_and_index,
+ "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules,
+ "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol,
+ "PutBucketWebsite_empty_error_document_key": PutBucketWebsite_empty_error_document_key,
+ "PutBucketWebsite_too_many_routing_rules": PutBucketWebsite_too_many_routing_rules,
+ "PutBucketWebsite_routing_rule_replace_key_and_prefix": PutBucketWebsite_routing_rule_replace_key_and_prefix,
+ "PutBucketWebsite_invalid_http_redirect_code": PutBucketWebsite_invalid_http_redirect_code,
+ "PutBucketWebsite_invalid_http_error_code": PutBucketWebsite_invalid_http_error_code,
+ "PutBucketWebsite_request_too_large": PutBucketWebsite_request_too_large,
"PutBucketWebsite_success": PutBucketWebsite_success,
"PutBucketWebsite_success_redirect_all": PutBucketWebsite_success_redirect_all,
"GetBucketWebsite_non_existing_bucket": GetBucketWebsite_non_existing_bucket,
@@ -1814,10 +1830,19 @@ func GetIntTests() IntTests {
"WebsiteHosting_error_document_served": WebsiteHosting_error_document_served,
"WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found,
"WebsiteHosting_no_error_document": WebsiteHosting_no_error_document,
+ "WebsiteHosting_private_object_and_error_document": WebsiteHosting_private_object_and_error_document,
"WebsiteHosting_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect,
"WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect,
+ "WebsiteHosting_routing_rule_prefix_and_error_redirect": WebsiteHosting_routing_rule_prefix_and_error_redirect,
+ "WebsiteHosting_routing_rule_no_match_serves_error_document": WebsiteHosting_routing_rule_no_match_serves_error_document,
"WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests,
"WebsiteHosting_index_document": WebsiteHosting_index_document,
+ "WebsiteHosting_index_error_document_and_routing_rules": WebsiteHosting_index_error_document_and_routing_rules,
+ "WebsiteHosting_get_cors_headers": WebsiteHosting_get_cors_headers,
+ "WebsiteHosting_head_cors_headers": WebsiteHosting_head_cors_headers,
+ "WebsiteHosting_options_preflight_access_granted": WebsiteHosting_options_preflight_access_granted,
+ "WebsiteHosting_options_preflight_access_forbidden": WebsiteHosting_options_preflight_access_forbidden,
+ "WebsiteHosting_options_preflight_missing_origin": WebsiteHosting_options_preflight_missing_origin,
"PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket,
"PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin,
"PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method,
diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go
index 6382f07a..9b7ad8e0 100644
--- a/tests/integration/s3conf.go
+++ b/tests/integration/s3conf.go
@@ -36,7 +36,9 @@ type S3Conf struct {
awsSecret string
awsRegion string
endpoint string
- websiteEndpoint string
+ websiteScheme string
+ websiteDomain string
+ websitePort string
hostStyle bool
checksumDisable bool
PartSize int64
@@ -65,6 +67,9 @@ func NewS3Conf(opts ...Option) *S3Conf {
customHTTPClient := &http.Client{
Transport: customTransport,
Timeout: shortTimeout,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
}
s.httpClient = customHTTPClient
@@ -86,8 +91,14 @@ func WithRegion(r string) Option {
func WithEndpoint(e string) Option {
return func(s *S3Conf) { s.endpoint = e }
}
-func WithWebsiteEndpoint(e string) Option {
- return func(s *S3Conf) { s.websiteEndpoint = e }
+func WithWebsiteScheme(scheme string) Option {
+ return func(s *S3Conf) { s.websiteScheme = scheme }
+}
+func WithWebsiteDomain(d string) Option {
+ return func(s *S3Conf) { s.websiteDomain = d }
+}
+func WithWebsitePort(p string) Option {
+ return func(s *S3Conf) { s.websitePort = p }
}
func WithDisableChecksum() Option {
return func(s *S3Conf) { s.checksumDisable = true }
diff --git a/tests/integration/utils.go b/tests/integration/utils.go
index 09a29b44..83b34c29 100644
--- a/tests/integration/utils.go
+++ b/tests/integration/utils.go
@@ -452,6 +452,147 @@ func checkHTTPResponseApiErr(resp *http.Response, expected s3err.S3Error) error
return compareS3ApiError(expected, &errResp)
}
+// websiteGet issues a plain HTTP GET to the dedicated website endpoint.
+// The bucket is resolved from the request URL host. No S3 signing is applied.
+func websiteGet(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) {
+ return websiteRequest(s, http.MethodGet, bucket, path, headers)
+}
+
+func websiteHead(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) {
+ return websiteRequest(s, http.MethodHead, bucket, path, headers)
+}
+
+func websiteOptions(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) {
+ return websiteRequest(s, http.MethodOptions, bucket, path, headers)
+}
+
+func websiteRequest(s *S3Conf, method, bucket, path string, headers map[string]string) (*http.Response, error) {
+ reqURL, err := websiteURL(s, bucket, path)
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequest(method, reqURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create website request: %w", err)
+ }
+ for key, val := range headers {
+ req.Header.Set(key, val)
+ }
+
+ return s.httpClient.Do(req)
+}
+
+func websiteHost(s *S3Conf, bucket string) string {
+ _, domain, port := websiteEndpointParts(s)
+
+ host := fmt.Sprintf("%s.%s", bucket, domain)
+ if port != "" {
+ host = fmt.Sprintf("%s:%s", host, port)
+ }
+ return host
+}
+
+func websiteURL(s *S3Conf, bucket, path string) (string, error) {
+ return websiteAbsoluteURL(s, websiteHost(s, bucket), path)
+}
+
+func websiteAbsoluteURL(s *S3Conf, host, path string) (string, error) {
+ scheme, _, _ := websiteEndpointParts(s)
+
+ rel, err := url.Parse("/" + strings.TrimLeft(path, "/"))
+ if err != nil {
+ return "", fmt.Errorf("parse website request path: %w", err)
+ }
+
+ return (&url.URL{
+ Scheme: scheme,
+ Host: host,
+ Path: rel.Path,
+ RawQuery: rel.RawQuery,
+ }).String(), nil
+}
+
+func websiteEndpointParts(s *S3Conf) (scheme, domain, port string) {
+ scheme = strings.ToLower(strings.TrimSpace(s.websiteScheme))
+ domain = strings.TrimSpace(s.websiteDomain)
+ port = strings.TrimPrefix(strings.TrimSpace(s.websitePort), ":")
+
+ return scheme, domain, port
+}
+
+func putBucketWebsiteConfig(client *s3.Client, bucket string, config *types.WebsiteConfiguration) error {
+ ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
+ _, err := client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
+ Bucket: &bucket,
+ WebsiteConfiguration: config,
+ })
+ cancel()
+ return err
+}
+
+func checkWebsiteResponse(resp *http.Response, expectedStatus int, expectedBody []byte) error {
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("read website response body: %w", err)
+ }
+
+ if resp.StatusCode != expectedStatus {
+ return fmt.Errorf("expected status %v, got %v; body: %s", expectedStatus, resp.StatusCode, body)
+ }
+
+ return compareBodySHA256(expectedBody, body)
+}
+
+func checkWebsiteErrorResponse(resp *http.Response, expected s3err.S3Error) error {
+ apiErr := expected.BaseError()
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("read website error body: %w", err)
+ }
+
+ if resp.StatusCode != apiErr.HTTPStatusCode {
+ return fmt.Errorf("expected status %v, got %v; body: %s", apiErr.HTTPStatusCode, resp.StatusCode, body)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != apiErr.Code {
+ return fmt.Errorf("expected x-amz-error-code %q, got %q", apiErr.Code, got)
+ }
+ if got := resp.Header.Get("x-amz-error-message"); got != apiErr.Description {
+ return fmt.Errorf("expected x-amz-error-message %q, got %q", apiErr.Description, got)
+ }
+ requestID := resp.Header.Get("x-amz-request-id")
+ if requestID == "" {
+ return fmt.Errorf("expected x-amz-request-id header")
+ }
+ hostID := resp.Header.Get("x-amz-id-2")
+ if hostID == "" {
+ return fmt.Errorf("expected x-amz-id-2 header")
+ }
+ if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "text/html") {
+ return fmt.Errorf("expected html Content-Type, got %q", got)
+ }
+ if methodErr, ok := expected.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 {
+ if got, want := resp.Header.Get("Allow"), methodErr.AllowedMethodsString(); got != want {
+ return fmt.Errorf("expected Allow header %q, got %q", want, got)
+ }
+ }
+
+ expectedBody := expected.HTMLBody(requestID, hostID)
+ return compareBodySHA256(expectedBody, body)
+}
+
+func compareBodySHA256(expected, actual []byte) error {
+ expectedSum := sha256.Sum256(expected)
+ actualSum := sha256.Sum256(actual)
+ if expectedSum != actualSum {
+ return fmt.Errorf("body checksum mismatch: expected sha256 %x, got %x; expected body %q, got %q",
+ expectedSum, actualSum, string(expected), string(actual))
+ }
+
+ return nil
+}
+
func compareS3ApiError(expected s3err.S3Error, received *APIErrorResponse) error {
apiErr := expected.BaseError()
if received == nil {
diff --git a/tests/test_rest_not_implemented.sh b/tests/test_rest_not_implemented.sh
index 2604add5..3366bc44 100755
--- a/tests/test_rest_not_implemented.sh
+++ b/tests/test_rest_not_implemented.sh
@@ -162,21 +162,6 @@ source ./tests/setup.sh
assert_success
}
-@test "REST - GetBucketWebsite" {
- run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "GET"
- assert_success
-}
-
-@test "REST - PutBucketWebsite" {
- run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "PUT"
- assert_success
-}
-
-@test "REST - DeleteBucketWebsite" {
- run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "DELETE"
- assert_success
-}
-
@test "REST - GetPublicAccessBlock" {
run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "publicAccessBlock=" "GET"
assert_success
diff --git a/tests/website-hosting-tests/dnsmasq.conf b/tests/website-hosting-tests/dnsmasq.conf
new file mode 100644
index 00000000..5d7054b9
--- /dev/null
+++ b/tests/website-hosting-tests/dnsmasq.conf
@@ -0,0 +1,2 @@
+address=/.dev/10.89.0.10
+no-resolv
diff --git a/tests/website-hosting-tests/docker-compose.yml b/tests/website-hosting-tests/docker-compose.yml
new file mode 100644
index 00000000..637cb207
--- /dev/null
+++ b/tests/website-hosting-tests/docker-compose.yml
@@ -0,0 +1,58 @@
+services:
+ dnsmasq:
+ image: strm/dnsmasq
+ container_name: website-dns-resolver
+ restart: on-failure
+ volumes:
+ - './dnsmasq.conf:/etc/dnsmasq.conf'
+ cap_add:
+ - NET_ADMIN
+ healthcheck:
+ test: 'if [ -z "$(netstat -nltu |grep \:53)" ]; then exit 1;else exit 0;fi'
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ networks:
+ devnet:
+ ipv4_address: 10.89.0.53
+
+ server:
+ build:
+ context: ../..
+ dockerfile: tests/host-style-tests/Dockerfile
+ depends_on:
+ dnsmasq:
+ condition: service_healthy
+ command: ["-a", "user", "-s", "pass", "--health", "/health", "--iam-dir", "/tmp/vgw", "--website", ":8080", "--website-domain", "dev", "--website-no-tls", "posix", "/tmp/vgw"]
+ dns:
+ - 10.89.0.53
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7070/health"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ networks:
+ devnet:
+ ipv4_address: 10.89.0.10
+
+ test:
+ build:
+ context: ../..
+ dockerfile: tests/host-style-tests/Dockerfile
+ depends_on:
+ server:
+ condition: service_healthy
+ dnsmasq:
+ condition: service_healthy
+ command: ["test", "-a", "user", "-s", "pass", "-e", "http://10.89.0.10:7070", "website-hosting", "--scheme", "http", "--domain", "dev", "--port", "8080"]
+ dns:
+ - 10.89.0.53
+ networks:
+ devnet:
+
+networks:
+ devnet:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 10.89.0.0/16
diff --git a/website/handler.go b/website/handler.go
index 463ceec7..506a6f2f 100644
--- a/website/handler.go
+++ b/website/handler.go
@@ -15,9 +15,8 @@
package website
import (
- "encoding/xml"
+ "errors"
"fmt"
- "html"
"io"
"net/http"
"strconv"
@@ -25,11 +24,25 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/gofiber/fiber/v2"
+ "github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/s3api/middlewares"
+ "github.com/versity/versitygw/s3api/utils"
+ "github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3response"
)
-// newHandler returns a fiber handler that serves static website content.
+var websiteAllowedMethods = []string{fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions}
+
+type websiteController struct {
+ be backend.Backend
+ domain string
+ domainSuffix string
+ applyCORS fiber.Handler
+}
+
+// newWebsiteController returns a controller that serves static website content.
// It resolves the bucket name from the Host header using the configured domain,
// fetches the website configuration, and serves objects accordingly.
//
@@ -40,95 +53,202 @@ import (
// Catch-all mode (--website-domain omitted or empty):
// - Host "blog.example.com" -> bucket "blog.example.com"
// - Host "mysite.org" -> bucket "mysite.org"
-func newHandler(be backend.Backend, domain string) fiber.Handler {
- // Pre-compute the domain suffix for subdomain extraction.
- // Given domain "example.com", we look for ".example.com" suffix.
- domainSuffix := "." + domain
+func newWebsiteController(be backend.Backend, domain string) *websiteController {
+ controller := &websiteController{
+ be: be,
+ domain: domain,
+ domainSuffix: "." + domain,
+ }
+ controller.applyCORS = middlewares.ApplyBucketCORS(be, controller.resolveBucket, "")
+ return controller
+}
- return func(ctx *fiber.Ctx) error {
- host := ctx.Hostname()
- if host == "" {
- return sendError(ctx, http.StatusBadRequest, "Bad Request", "Missing Host header")
+func (c *websiteController) Get(ctx *fiber.Ctx) error {
+ return c.serve(ctx, c.getObject)
+}
+
+func (c *websiteController) Head(ctx *fiber.Ctx) error {
+ return c.serve(ctx, c.headObject)
+}
+
+func (c *websiteController) Options(ctx *fiber.Ctx) error {
+ bucket, err := c.resolveBucket(ctx)
+ if err != nil {
+ return sendError(ctx, err)
+ }
+
+ origin := ctx.Get("Origin")
+ method := auth.CORSHTTPMethod(ctx.Get("Access-Control-Request-Method"))
+ headers := ctx.Get("Access-Control-Request-Headers")
+
+ if origin == "" {
+ debuglogger.Logf("origin is missing: %v", origin)
+ return sendError(ctx, s3err.GetAPIError(s3err.ErrMissingCORSOrigin))
+ }
+
+ if !method.IsValid() {
+ debuglogger.Logf("invalid cors method: %s", method)
+ return sendError(ctx, s3err.GetInvalidCORSMethodErr(method.String()))
+ }
+
+ parsedHeaders, err := auth.ParseCORSHeaders(headers)
+ if err != nil {
+ return sendError(ctx, err)
+ }
+
+ cors, err := c.be.GetBucketCors(ctx.Context(), bucket)
+ if err != nil {
+ debuglogger.Logf("failed to get bucket cors: %v", err)
+ if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)) {
+ err = s3err.GetAccessForbiddenErr(s3err.ErrCORSIsNotEnabled, http.MethodOptions, s3err.ResourceTypeBucket)
+ debuglogger.Logf("bucket cors is not set: %v", err)
}
+ return sendError(ctx, err)
+ }
- // Strip port from host if present
- if idx := strings.LastIndex(host, ":"); idx != -1 {
- // Be careful with IPv6: only strip if it's not inside brackets
- if !strings.Contains(host[idx:], "]") {
- host = host[:idx]
- }
- }
+ corsConfig, err := auth.ParseCORSOutput(cors)
+ if err != nil {
+ return sendError(ctx, err)
+ }
- // Resolve bucket name from host
- bucket := resolveBucket(host, domain, domainSuffix)
- if bucket == "" {
- return sendError(ctx, http.StatusForbidden, "Forbidden",
- fmt.Sprintf("No bucket could be resolved from host %q", html.EscapeString(ctx.Hostname())))
- }
+ allowConfig, err := corsConfig.IsAllowed(origin, method, parsedHeaders, s3err.ResourceTypeObject)
+ if err != nil {
+ debuglogger.Logf("cors access forbidden: %v", err)
+ return sendError(ctx, err)
+ }
- // Fetch website configuration
- data, err := be.GetBucketWebsite(ctx.Context(), bucket)
- if err != nil {
- return sendError(ctx, http.StatusNotFound, "Not Found",
- fmt.Sprintf("No website configuration for bucket %q", bucket))
- }
+ setCORSPreflightHeaders(ctx, allowConfig)
+ ctx.Status(http.StatusOK)
+ return nil
+}
- var config s3response.WebsiteConfiguration
- if xmlErr := xml.Unmarshal(data, &config); xmlErr != nil {
- return sendError(ctx, http.StatusInternalServerError, "Internal Server Error",
- "Invalid website configuration")
- }
+func registerWebsiteRoutes(app *fiber.App, be backend.Backend, domain string) {
+ controller := newWebsiteController(be, domain)
- key := strings.TrimPrefix(ctx.Path(), "/")
+ app.Head("*", controller.Head)
+ app.Get("*", controller.Get)
+ app.Options("*", controller.Options)
+ app.All("*", controller.MethodNotAllowed)
+}
- // Handle RedirectAllRequestsTo
- if config.RedirectAllRequestsTo != nil {
- return handleRedirectAll(ctx, config.RedirectAllRequestsTo, key)
- }
-
- // Evaluate pre-request routing rules
- if rule := config.MatchPreRequestRule(key); rule != nil {
- return applyRedirect(ctx, &rule.Redirect, rule.Condition, key)
- }
-
- // Rewrite directory-like keys to include index document suffix
- if config.IndexDocument != nil && config.IndexDocument.Suffix != "" {
- if key == "" || strings.HasSuffix(key, "/") {
- key = key + config.IndexDocument.Suffix
- }
- }
-
- // Fetch the object
- emptyRange := ""
- result, getErr := be.GetObject(ctx.Context(), &s3.GetObjectInput{
- Bucket: &bucket,
- Key: &key,
- Range: &emptyRange,
- })
- if getErr == nil && result.Body != nil {
- defer result.Body.Close()
- return serveObject(ctx, result, key)
- }
-
- // Object not found (or other error) — evaluate post-request routing rules
- httpErrCode := http.StatusNotFound
- errorCode := strconv.Itoa(httpErrCode)
-
- if rule := config.MatchPostRequestRule(key, errorCode); rule != nil {
- return applyRedirect(ctx, &rule.Redirect, rule.Condition, key)
- }
-
- // Serve error document if configured
- if config.ErrorDocument != nil && config.ErrorDocument.Key != "" {
- return serveErrorDocument(ctx, be, bucket, config.ErrorDocument.Key, httpErrCode)
- }
-
- return sendError(ctx, http.StatusNotFound, "Not Found",
- fmt.Sprintf("The specified key %q does not exist", key))
+func setCORSPreflightHeaders(ctx *fiber.Ctx, allowConfig *auth.CORSAllowanceConfig) {
+ ctx.Set("Access-Control-Allow-Origin", allowConfig.Origin)
+ ctx.Set("Access-Control-Allow-Methods", allowConfig.Methods)
+ ctx.Set("Access-Control-Expose-Headers", corsExposeHeaders(allowConfig.ExposedHeaders))
+ ctx.Set("Access-Control-Allow-Credentials", allowConfig.AllowCredentials)
+ ctx.Set("Access-Control-Allow-Headers", allowConfig.AllowHeaders)
+ ctx.Set("Vary", middlewares.VaryHdr)
+ if allowConfig.MaxAge != nil {
+ ctx.Set("Access-Control-Max-Age", strconv.Itoa(int(*allowConfig.MaxAge)))
}
}
-// resolveBucket extracts the bucket name from the host header.
+func corsExposeHeaders(exposed string) string {
+ exposed = strings.TrimSpace(exposed)
+ if exposed == "" {
+ return "ETag"
+ }
+ if exposed == "*" {
+ return exposed
+ }
+
+ for part := range strings.SplitSeq(exposed, ",") {
+ if strings.EqualFold(strings.TrimSpace(part), "ETag") {
+ return exposed
+ }
+ }
+
+ return exposed + ", ETag"
+}
+
+func (c *websiteController) MethodNotAllowed(ctx *fiber.Ctx) error {
+ return sendError(ctx, s3err.GetMethodNotAllowedErr(ctx.Method(), s3err.ResourceTypeObject, websiteAllowedMethods))
+}
+
+type websiteRequestInfo struct {
+ bucket string
+ config *s3response.WebsiteConfiguration
+ key string
+}
+
+type websiteObjectReader func(ctx *fiber.Ctx, bucket, key string) websiteResult
+
+func (c *websiteController) serve(ctx *fiber.Ctx, readObject websiteObjectReader) error {
+ req, err := c.resolveRequest(ctx)
+ if err != nil {
+ return sendError(ctx, err)
+ }
+
+ if err := c.applyCORS(ctx); err != nil {
+ return sendError(ctx, err)
+ }
+
+ if req.config.RedirectAllRequestsTo != nil {
+ return handleRedirectAll(ctx, req.config.RedirectAllRequestsTo, req.key)
+ }
+
+ if rule := req.config.MatchPrefetchRoutingRule(req.key); rule != nil {
+ return applyRedirect(ctx, &rule.Redirect, rule.Condition, req.key)
+ }
+
+ resolvedKey := resolveIndexKey(req.key, req.config)
+ result := readObject(ctx, req.bucket, resolvedKey)
+ if result.Err == nil {
+ return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject)
+ }
+ if result.StatusCode >= http.StatusInternalServerError {
+ return sendError(ctx, result.Err)
+ }
+
+ if rule := req.config.MatchPostErrorRoutingRule(req.key, result.StatusCode); rule != nil {
+ return applyRedirect(ctx, &rule.Redirect, rule.Condition, req.key)
+ }
+
+ return serveWebsiteResult(ctx, req.bucket, req.config, result, readObject)
+}
+
+func (c *websiteController) resolveRequest(ctx *fiber.Ctx) (*websiteRequestInfo, error) {
+ bucket, err := c.resolveBucket(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ key := strings.TrimPrefix(ctx.Path(), "/")
+ if err := validateWebsiteNames(bucket, key); err != nil {
+ return nil, err
+ }
+
+ data, err := c.be.GetBucketWebsite(ctx.Context(), bucket)
+ if err != nil {
+ return nil, err
+ }
+
+ config, err := s3response.ParseWebsiteConfigOutput(data)
+ if err != nil {
+ return nil, err
+ }
+
+ return &websiteRequestInfo{
+ bucket: bucket,
+ config: config,
+ key: key,
+ }, nil
+}
+
+func validateWebsiteNames(bucket, key string) error {
+ if !utils.IsValidBucketName(bucket) {
+ return s3err.GetBucketErr(s3err.ErrInvalidBucketName, bucket)
+ }
+ if key != "" && !utils.IsObjectNameValid(key) {
+ return s3err.GetAPIError(s3err.ErrBadRequest)
+ }
+
+ return nil
+}
+
+// resolveBucket extracts the bucket name from the request host header.
+//
+// It strips the port when present before applying website endpoint routing.
//
// When domain is set:
// - If host equals the domain exactly, the bucket IS the domain (apex).
@@ -137,36 +257,146 @@ func newHandler(be backend.Backend, domain string) fiber.Handler {
//
// When domain is empty (catch-all mode):
// - The full hostname is used as the bucket name.
-func resolveBucket(host, domain, domainSuffix string) string {
- if domain == "" {
- // Catch-all: the full hostname is the bucket name
- return host
+func (c *websiteController) resolveBucket(ctx *fiber.Ctx) (string, error) {
+ host := ctx.Hostname()
+ if host == "" {
+ return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest)
}
- if strings.EqualFold(host, domain) {
- return domain
+ // Strip port from host if present. Be careful with IPv6: only strip if the
+ // last colon is not inside brackets.
+ if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") {
+ host = host[:idx]
}
- lower := strings.ToLower(host)
- if strings.HasSuffix(lower, strings.ToLower(domainSuffix)) {
- sub := host[:len(host)-len(domainSuffix)]
- if sub != "" && !strings.Contains(sub, ".") {
- return sub
+ if c.domain == "" {
+ return host, nil
+ }
+
+ if strings.EqualFold(host, c.domain) {
+ return c.domain, nil
+ }
+
+ lowerHost := strings.ToLower(host)
+ lowerDomainSuffix := strings.ToLower(c.domainSuffix)
+ if strings.HasSuffix(lowerHost, lowerDomainSuffix) {
+ bucket := host[:len(host)-len(c.domainSuffix)]
+ if bucket != "" && !strings.Contains(bucket, ".") {
+ return bucket, nil
}
}
- return ""
+ return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest)
+}
+
+type websiteResult struct {
+ Key string
+ StatusCode int
+ Object websiteObject
+ Err error
+}
+
+type websiteObject struct {
+ Body io.ReadCloser
+ Headers map[string]*string
+ Metadata map[string]string
+}
+
+func resolveIndexKey(key string, config *s3response.WebsiteConfiguration) string {
+ if config.IndexDocument != nil && config.IndexDocument.Suffix != "" {
+ if key == "" || strings.HasSuffix(key, "/") {
+ return key + config.IndexDocument.Suffix
+ }
+ }
+
+ return key
+}
+
+func (c *websiteController) getObject(ctx *fiber.Ctx, bucket, key string) websiteResult {
+ if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.GetObjectAction, auth.PermissionRead, bucket, key); err != nil {
+ return websiteResult{
+ Key: key,
+ StatusCode: statusCodeFromError(err),
+ Err: err,
+ }
+ }
+
+ result, err := c.be.GetObject(ctx.Context(), &s3.GetObjectInput{
+ Bucket: &bucket,
+ Key: &key,
+ })
+ if err != nil {
+ return websiteResult{
+ Key: key,
+ StatusCode: statusCodeFromError(err),
+ Err: err,
+ }
+ }
+
+ return websiteResult{
+ Key: key,
+ StatusCode: http.StatusOK,
+ Object: websiteObject{
+ Body: result.Body,
+ Headers: getObjectHeaders(result),
+ Metadata: result.Metadata,
+ },
+ }
+}
+
+func (c *websiteController) headObject(ctx *fiber.Ctx, bucket, key string) websiteResult {
+ if err := auth.VerifyPublicAccess(ctx.Context(), c.be, auth.ListBucketAction, auth.PermissionRead, bucket, key); err != nil {
+ return websiteResult{
+ Key: key,
+ StatusCode: statusCodeFromError(err),
+ Err: err,
+ }
+ }
+
+ result, err := c.be.HeadObject(ctx.Context(), &s3.HeadObjectInput{
+ Bucket: &bucket,
+ Key: &key,
+ })
+ if err != nil {
+ return websiteResult{
+ Key: key,
+ StatusCode: statusCodeFromError(err),
+ Err: err,
+ }
+ }
+
+ return websiteResult{
+ Key: key,
+ StatusCode: http.StatusOK,
+ Object: websiteObject{
+ Headers: headObjectHeaders(result),
+ Metadata: result.Metadata,
+ },
+ }
+}
+
+func statusCodeFromError(err error) int {
+ var serr s3err.S3Error
+ if errors.As(err, &serr) {
+ return serr.StatusCode()
+ }
+
+ return http.StatusInternalServerError
}
// handleRedirectAll sends a 301 redirect for RedirectAllRequestsTo configuration.
func handleRedirectAll(ctx *fiber.Ctx, redirect *s3response.RedirectAllRequestsTo, key string) error {
protocol := redirect.Protocol
if protocol == "" {
- protocol = "https"
+ protocol = "http"
}
location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key)
+ if query := string(ctx.Request().URI().QueryString()); query != "" {
+ location += "?" + query
+ }
ctx.Set("Location", location)
+ _, _ = utils.EnsureRequestIDs(ctx)
return ctx.SendStatus(http.StatusMovedPermanently)
}
@@ -189,7 +419,7 @@ func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3r
key = redirect.ReplaceKeyPrefixWith + strings.TrimPrefix(originalKey, condition.KeyPrefixEquals)
}
- httpCode := http.StatusFound // 302 default
+ httpCode := http.StatusMovedPermanently
if redirect.HttpRedirectCode != "" {
if code, err := strconv.Atoi(redirect.HttpRedirectCode); err == nil {
httpCode = code
@@ -197,118 +427,112 @@ func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3r
}
location := fmt.Sprintf("%s://%s/%s", protocol, host, key)
+ if query := string(ctx.Request().URI().QueryString()); query != "" {
+ location += "?" + query
+ }
ctx.Set("Location", location)
return ctx.SendStatus(httpCode)
}
-// serveObject writes the S3 object content to the response.
-func serveObject(ctx *fiber.Ctx, result *s3.GetObjectOutput, key string) error {
- contentType := guessContentType(result, key)
- ctx.Set("Content-Type", contentType)
+func getObjectHeaders(result *s3.GetObjectOutput) map[string]*string {
+ return map[string]*string{
+ "ETag": result.ETag,
+ "accept-ranges": result.AcceptRanges,
+ "Cache-Control": result.CacheControl,
+ "Content-Disposition": result.ContentDisposition,
+ "Content-Encoding": result.ContentEncoding,
+ "Content-Language": result.ContentLanguage,
+ "Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength),
+ "Content-Range": result.ContentRange,
+ "Content-Type": result.ContentType,
+ "Expires": result.ExpiresString,
+ "Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat),
+ "x-amz-restore": result.Restore,
+ "x-amz-version-id": result.VersionId,
+ }
+}
- if result.ETag != nil {
- ctx.Set("ETag", *result.ETag)
+func headObjectHeaders(result *s3.HeadObjectOutput) map[string]*string {
+ return map[string]*string{
+ "ETag": result.ETag,
+ "accept-ranges": result.AcceptRanges,
+ "Cache-Control": result.CacheControl,
+ "Content-Disposition": result.ContentDisposition,
+ "Content-Encoding": result.ContentEncoding,
+ "Content-Language": result.ContentLanguage,
+ "Content-Length": utils.ConvertPtrToStringPtr(result.ContentLength),
+ "Content-Range": result.ContentRange,
+ "Content-Type": result.ContentType,
+ "Expires": result.ExpiresString,
+ "Last-Modified": utils.FormatDatePtrToString(result.LastModified, http.TimeFormat),
+ "x-amz-restore": result.Restore,
+ "x-amz-version-id": result.VersionId,
}
- if result.CacheControl != nil {
- ctx.Set("Cache-Control", *result.CacheControl)
- }
- if result.ContentEncoding != nil {
- ctx.Set("Content-Encoding", *result.ContentEncoding)
- }
- if result.ContentLanguage != nil {
- ctx.Set("Content-Language", *result.ContentLanguage)
- }
- if result.ContentLength != nil {
- ctx.Set("Content-Length", strconv.FormatInt(*result.ContentLength, 10))
- }
- if result.LastModified != nil {
- ctx.Set("Last-Modified", result.LastModified.UTC().Format(http.TimeFormat))
+}
+
+func serveWebsiteResult(ctx *fiber.Ctx, bucket string, config *s3response.WebsiteConfiguration, result websiteResult, readObject websiteObjectReader) error {
+ if result.Err == nil {
+ return serveObject(ctx, result.Object, http.StatusOK)
}
- _, err := io.Copy(ctx.Response().BodyWriter(), result.Body)
+ if config.ErrorDocument != nil && config.ErrorDocument.Key != "" {
+ return serveErrorDocument(ctx, readObject, bucket, config.ErrorDocument.Key, result.StatusCode)
+ }
+
+ return sendError(ctx, result.Err)
+}
+
+func serveObject(ctx *fiber.Ctx, object websiteObject, statusCode int) error {
+ ctx.Status(statusCode)
+ setWebsiteObjectHeaders(ctx, object)
+
+ if object.Body == nil {
+ return nil
+ }
+ defer object.Body.Close()
+
+ _, err := io.Copy(ctx.Response().BodyWriter(), object.Body)
if err != nil {
- return sendError(ctx, http.StatusInternalServerError, "Internal Server Error",
- "Failed to read object")
+ return sendError(ctx, err)
}
return nil
}
+func setWebsiteObjectHeaders(ctx *fiber.Ctx, object websiteObject) {
+ utils.SetMetaHeaders(ctx, object.Metadata)
+ for key, value := range object.Headers {
+ if value != nil && *value != "" {
+ ctx.Set(key, *value)
+ }
+ }
+}
+
// serveErrorDocument fetches and serves the configured error document.
-func serveErrorDocument(ctx *fiber.Ctx, be backend.Backend, bucket, errorDocKey string, statusCode int) error {
- emptyRange := ""
- result, err := be.GetObject(ctx.Context(), &s3.GetObjectInput{
- Bucket: &bucket,
- Key: &errorDocKey,
- Range: &emptyRange,
- })
- if err != nil {
- return sendError(ctx, statusCode, "Not Found", "The specified key does not exist")
- }
- if result.Body == nil {
- return sendError(ctx, statusCode, "Not Found", "The specified key does not exist")
- }
- defer result.Body.Close()
-
- contentType := guessContentType(result, errorDocKey)
- ctx.Set("Content-Type", contentType)
-
- ctx.Status(statusCode)
- _, writeErr := io.Copy(ctx.Response().BodyWriter(), result.Body)
- if writeErr != nil {
- return sendError(ctx, statusCode, "Not Found", "The specified key does not exist")
+func serveErrorDocument(ctx *fiber.Ctx, readObject websiteObjectReader, bucket, errorDocKey string, statusCode int) error {
+ result := readObject(ctx, bucket, errorDocKey)
+ if result.Err != nil {
+ return sendError(ctx, result.Err)
}
- return nil
-}
-
-// guessContentType returns the content type from the GetObject result, or
-// infers it from the key extension, defaulting to text/html.
-func guessContentType(result *s3.GetObjectOutput, key string) string {
- if result.ContentType != nil && *result.ContentType != "" {
- return *result.ContentType
- }
-
- // Simple extension-based inference for common web types
- switch {
- case strings.HasSuffix(key, ".html"), strings.HasSuffix(key, ".htm"):
- return "text/html; charset=utf-8"
- case strings.HasSuffix(key, ".css"):
- return "text/css; charset=utf-8"
- case strings.HasSuffix(key, ".js"):
- return "application/javascript"
- case strings.HasSuffix(key, ".json"):
- return "application/json"
- case strings.HasSuffix(key, ".xml"):
- return "application/xml"
- case strings.HasSuffix(key, ".svg"):
- return "image/svg+xml"
- case strings.HasSuffix(key, ".png"):
- return "image/png"
- case strings.HasSuffix(key, ".jpg"), strings.HasSuffix(key, ".jpeg"):
- return "image/jpeg"
- case strings.HasSuffix(key, ".gif"):
- return "image/gif"
- case strings.HasSuffix(key, ".ico"):
- return "image/x-icon"
- case strings.HasSuffix(key, ".txt"):
- return "text/plain; charset=utf-8"
- default:
- return "text/html; charset=utf-8"
- }
+ return serveObject(ctx, result.Object, statusCode)
}
// sendError sends a simple HTML error page.
-func sendError(ctx *fiber.Ctx, statusCode int, title, message string) error {
- ctx.Set("Content-Type", "text/html; charset=utf-8")
- ctx.Status(statusCode)
- body := fmt.Sprintf(`
-
-%d %s
-
-%d %s
-%s
-
-`, statusCode, title, statusCode, title, message)
- return ctx.SendString(body)
+func sendError(ctx *fiber.Ctx, err error) error {
+ requestId, hostId := utils.EnsureRequestIDs(ctx)
+ serr, ok := err.(s3err.S3Error)
+ if !ok {
+ debuglogger.InternalError(err)
+ serr = s3err.GetAPIError(s3err.ErrInternalError)
+ }
+
+ ctx.Response().Header.Set("x-amz-error-code", serr.BaseError().Code)
+ ctx.Response().Header.Set("x-amz-error-message", serr.BaseError().Description)
+ if methodErr, ok := serr.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 {
+ ctx.Response().Header.Set("Allow", methodErr.AllowedMethodsString())
+ }
+
+ ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8)
+ return ctx.Status(serr.StatusCode()).Send(serr.HTMLBody(requestId, hostId))
}
diff --git a/website/handler_test.go b/website/handler_test.go
new file mode 100644
index 00000000..486de5cc
--- /dev/null
+++ b/website/handler_test.go
@@ -0,0 +1,972 @@
+// Copyright 2026 Versity Software
+// This file is licensed under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package website
+
+import (
+ "context"
+ "encoding/json"
+ "encoding/xml"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/aws-sdk-go-v2/service/s3/types"
+ "github.com/gofiber/fiber/v2"
+ "github.com/versity/versitygw/auth"
+ "github.com/versity/versitygw/backend"
+ "github.com/versity/versitygw/s3err"
+ "github.com/versity/versitygw/s3response"
+)
+
+type websiteTestBackend struct {
+ backend.BackendUnsupported
+
+ websiteConfig []byte
+ corsConfig []byte
+ corsErr error
+ objects map[string]string
+ objectErrors map[string]error
+ public bool
+ calls []string
+}
+
+func (b *websiteTestBackend) record(call string) {
+ b.calls = append(b.calls, call)
+}
+
+func (b *websiteTestBackend) GetBucketWebsite(_ context.Context, _ string) ([]byte, error) {
+ b.record("GetBucketWebsite")
+ return b.websiteConfig, nil
+}
+
+func (b *websiteTestBackend) GetBucketCors(_ context.Context, _ string) ([]byte, error) {
+ b.record("GetBucketCors")
+ if b.corsErr != nil {
+ return nil, b.corsErr
+ }
+ if b.corsConfig == nil {
+ return nil, s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
+ }
+ return b.corsConfig, nil
+}
+
+func (b *websiteTestBackend) GetBucketPolicy(_ context.Context, _ string) ([]byte, error) {
+ b.record("GetBucketPolicy")
+ return nil, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)
+}
+
+func (b *websiteTestBackend) GetBucketAcl(_ context.Context, _ *s3.GetBucketAclInput) ([]byte, error) {
+ b.record("GetBucketAcl")
+ acl := auth.ACL{Owner: "owner"}
+ if b.public {
+ acl.Grantees = []auth.Grantee{
+ {
+ Permission: auth.PermissionRead,
+ Access: "all-users",
+ Type: types.TypeGroup,
+ },
+ }
+ }
+
+ data, err := json.Marshal(acl)
+ if err != nil {
+ return nil, err
+ }
+ return data, nil
+}
+
+func (b *websiteTestBackend) HeadObject(_ context.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) {
+ b.record("HeadObject")
+ if input == nil || input.Key == nil {
+ return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
+ }
+ if err, ok := b.objectErrors[*input.Key]; ok {
+ return nil, err
+ }
+ body, ok := b.objects[*input.Key]
+ if !ok {
+ return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
+ }
+
+ length := int64(len(body))
+ contentType := "text/html"
+ return &s3.HeadObjectOutput{
+ ContentLength: &length,
+ ContentType: &contentType,
+ }, nil
+}
+
+func (b *websiteTestBackend) GetObject(_ context.Context, input *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
+ b.record("GetObject")
+ if input == nil || input.Key == nil {
+ return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
+ }
+ if err, ok := b.objectErrors[*input.Key]; ok {
+ return nil, err
+ }
+ body, ok := b.objects[*input.Key]
+ if !ok {
+ return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
+ }
+
+ length := int64(len(body))
+ contentType := "text/html"
+ return &s3.GetObjectOutput{
+ Body: io.NopCloser(strings.NewReader(body)),
+ ContentLength: &length,
+ ContentType: &contentType,
+ }, nil
+}
+
+func TestWebsiteHandlerRoutingRuleOrder(t *testing.T) {
+ tests := []struct {
+ name string
+ rules []s3response.RoutingRule
+ wantStatus int
+ wantLocation string
+ }{
+ {
+ name: "key prefix rule before 404 rule wins",
+ rules: []s3response.RoutingRule{
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyPrefixWith: "new/",
+ HttpRedirectCode: "301",
+ },
+ },
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ HttpErrorCodeReturnedEquals: "404",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyWith: "error.html",
+ HttpRedirectCode: "302",
+ },
+ },
+ },
+ wantStatus: http.StatusMovedPermanently,
+ wantLocation: "http://site.test/new/missing.html",
+ },
+ {
+ name: "key prefix rule wins pre-fetch even when 404 rule comes first",
+ rules: []s3response.RoutingRule{
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ HttpErrorCodeReturnedEquals: "404",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyWith: "error.html",
+ HttpRedirectCode: "302",
+ },
+ },
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyPrefixWith: "new/",
+ HttpRedirectCode: "301",
+ },
+ },
+ },
+ wantStatus: http.StatusMovedPermanently,
+ wantLocation: "http://site.test/new/missing.html",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ RoutingRules: tt.rules,
+ }, nil, true)
+
+ resp := websiteRequest(t, be, "/old/missing.html")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != tt.wantStatus {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
+ }
+ if got := resp.Header.Get("Location"); got != tt.wantLocation {
+ t.Fatalf("Location = %q, want %q", got, tt.wantLocation)
+ }
+ if containsCall(be.calls, "GetObject") {
+ t.Fatal("GetObject was called for a redirect response")
+ }
+ })
+ }
+}
+
+func TestWebsiteHandlerRoutingRuleBothConditions(t *testing.T) {
+ config := s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ RoutingRules: []s3response.RoutingRule{
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ HttpErrorCodeReturnedEquals: "404",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyPrefixWith: "new/",
+ HttpRedirectCode: "302",
+ },
+ },
+ },
+ }
+
+ t.Run("missing object with matching prefix redirects", func(t *testing.T) {
+ be := newWebsiteTestBackend(t, config, nil, true)
+ resp := websiteRequest(t, be, "/old/missing.html")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
+ }
+ if got := resp.Header.Get("Location"); got != "http://site.test/new/missing.html" {
+ t.Fatalf("Location = %q", got)
+ }
+ })
+
+ t.Run("existing object with matching prefix does not redirect", func(t *testing.T) {
+ be := newWebsiteTestBackend(t, config, map[string]string{
+ "old/existing.html": "served",
+ }, true)
+ resp := websiteRequest(t, be, "/old/existing.html")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+ if got := readBody(t, resp); got != "served" {
+ t.Fatalf("body = %q, want %q", got, "served")
+ }
+ if got := resp.Header.Get("Location"); got != "" {
+ t.Fatalf("unexpected Location header %q", got)
+ }
+ })
+
+ t.Run("missing object with wrong prefix does not redirect", func(t *testing.T) {
+ be := newWebsiteTestBackend(t, config, nil, true)
+ resp := websiteRequest(t, be, "/other/missing.html")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNotFound)
+ }
+ if got := resp.Header.Get("Location"); got != "" {
+ t.Fatalf("unexpected Location header %q", got)
+ }
+ })
+}
+
+func TestWebsiteHandlerRedirectConstruction(t *testing.T) {
+ tests := []struct {
+ name string
+ rule s3response.RoutingRule
+ path string
+ wantLocation string
+ }{
+ {
+ name: "ReplaceKeyWith replaces full key",
+ rule: s3response.RoutingRule{
+ Condition: &s3response.RoutingRuleCondition{
+ HttpErrorCodeReturnedEquals: "404",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyWith: "error.html",
+ },
+ },
+ path: "/a/b/c.html",
+ wantLocation: "http://site.test/error.html",
+ },
+ {
+ name: "ReplaceKeyPrefixWith replaces matching prefix",
+ rule: s3response.RoutingRule{
+ Condition: &s3response.RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyPrefixWith: "new/",
+ },
+ },
+ path: "/old/a/b.html",
+ wantLocation: "http://site.test/new/a/b.html",
+ },
+ {
+ name: "HostName Protocol and query string are preserved",
+ rule: s3response.RoutingRule{
+ Condition: &s3response.RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ },
+ Redirect: s3response.Redirect{
+ HostName: "example.com",
+ Protocol: "https",
+ ReplaceKeyPrefixWith: "new/",
+ },
+ },
+ path: "/old/page.html?x=1&y=2",
+ wantLocation: "https://example.com/new/page.html?x=1&y=2",
+ },
+ {
+ name: "query string is preserved with current endpoint host",
+ rule: s3response.RoutingRule{
+ Condition: &s3response.RoutingRuleCondition{
+ KeyPrefixEquals: "old/",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyPrefixWith: "new/",
+ },
+ },
+ path: "/old/page.html?x=1&y=2",
+ wantLocation: "http://site.test/new/page.html?x=1&y=2",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ RoutingRules: []s3response.RoutingRule{tt.rule},
+ }, nil, true)
+
+ resp := websiteRequest(t, be, tt.path)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusMovedPermanently {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
+ }
+ if got := resp.Header.Get("Location"); got != tt.wantLocation {
+ t.Fatalf("Location = %q, want %q", got, tt.wantLocation)
+ }
+ })
+ }
+}
+
+func TestWebsiteHandlerPostErrorRoutingUsesOriginalKeyBeforeIndexExpansion(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ RoutingRules: []s3response.RoutingRule{
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ KeyPrefixEquals: "blog/",
+ HttpErrorCodeReturnedEquals: "404",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyPrefixWith: "archive/",
+ HttpRedirectCode: "302",
+ },
+ },
+ },
+ }, nil, true)
+
+ resp := websiteRequest(t, be, "/blog/")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
+ }
+ if got := resp.Header.Get("Location"); got != "http://site.test/archive/" {
+ t.Fatalf("Location = %q, want %q", got, "http://site.test/archive/")
+ }
+ if countCalls(be.calls, "GetObject") != 1 {
+ t.Fatalf("GetObject calls = %d, want 1; calls: %v", countCalls(be.calls, "GetObject"), be.calls)
+ }
+}
+
+func TestWebsiteHandlerObjectStore5xxBypassesRoutingAndErrorDocument(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ ErrorDocument: &s3response.ErrorDocument{Key: "error.html"},
+ RoutingRules: []s3response.RoutingRule{
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ HttpErrorCodeReturnedEquals: "500",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyWith: "elsewhere.html",
+ HttpRedirectCode: "302",
+ },
+ },
+ },
+ }, map[string]string{
+ "error.html": "custom error document",
+ }, true)
+ be.objectErrors = map[string]error{
+ "boom.html": s3err.GetAPIError(s3err.ErrInternalError),
+ }
+
+ resp := websiteRequest(t, be, "/boom.html")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusInternalServerError)
+ }
+ if got := resp.Header.Get("Location"); got != "" {
+ t.Fatalf("unexpected Location header %q", got)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != "InternalError" {
+ t.Fatalf("x-amz-error-code = %q, want %q", got, "InternalError")
+ }
+ if got := countCalls(be.calls, "GetObject"); got != 1 {
+ t.Fatalf("GetObject calls = %d, want 1; calls: %v", got, be.calls)
+ }
+}
+
+func TestWebsiteHandlerPublicAccessDeniedPreventsObjectReadAndCanRoute(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ RoutingRules: []s3response.RoutingRule{
+ {
+ Condition: &s3response.RoutingRuleCondition{
+ HttpErrorCodeReturnedEquals: "403",
+ },
+ Redirect: s3response.Redirect{
+ ReplaceKeyWith: "denied.html",
+ HttpRedirectCode: "302",
+ },
+ },
+ },
+ }, map[string]string{
+ "private.html": "secret",
+ }, false)
+
+ resp := websiteRequest(t, be, "/private.html")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusFound {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound)
+ }
+ if got := resp.Header.Get("Location"); got != "http://site.test/denied.html" {
+ t.Fatalf("Location = %q", got)
+ }
+ if containsCall(be.calls, "HeadObject") {
+ t.Fatal("HeadObject was called after public access was denied")
+ }
+ if containsCall(be.calls, "GetObject") {
+ t.Fatal("GetObject was called after public access was denied")
+ }
+}
+
+func TestWebsiteHandlerVerifiesPublicAccessBeforeGetObject(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, map[string]string{
+ "index.html": "home",
+ }, true)
+
+ resp := websiteRequest(t, be, "/")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+ if got := readBody(t, resp); got != "home" {
+ t.Fatalf("body = %q, want %q", got, "home")
+ }
+
+ verifyIdx := firstCallIndex(be.calls, "GetBucketAcl")
+ getObjectIdx := firstCallIndex(be.calls, "GetObject")
+ if verifyIdx == -1 {
+ t.Fatal("expected public access verification to read bucket ACL")
+ }
+ if getObjectIdx == -1 {
+ t.Fatal("expected GetObject call")
+ }
+ if verifyIdx > getObjectIdx {
+ t.Fatalf("GetObject happened before public access verification: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerHeadUsesHeadObjectAndReturnsHeadersOnly(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, map[string]string{
+ "index.html": "home",
+ }, true)
+
+ resp := websiteRequestWithMethod(t, be, http.MethodHead, "/")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+ if got := resp.Header.Get("Content-Length"); got != "4" {
+ t.Fatalf("Content-Length = %q, want %q", got, "4")
+ }
+ if got := resp.Header.Get("Content-Type"); got != "text/html" {
+ t.Fatalf("Content-Type = %q, want %q", got, "text/html")
+ }
+ if got := readBody(t, resp); got != "" {
+ t.Fatalf("body = %q, want empty body", got)
+ }
+ if containsCall(be.calls, "GetObject") {
+ t.Fatalf("GetObject was called for HEAD request: %v", be.calls)
+ }
+ if !containsCall(be.calls, "HeadObject") {
+ t.Fatalf("HeadObject was not called for HEAD request: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerGetValidatesBucketName(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, nil, true)
+
+ resp := websiteRequestWithHostAndHeaders(t, be, http.MethodGet, "bad_bucket", "/", nil)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != "InvalidBucketName" {
+ t.Fatalf("x-amz-error-code = %q", got)
+ }
+ if len(be.calls) != 0 {
+ t.Fatalf("invalid bucket should not call backend, got calls: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerHeadValidatesObjectName(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, nil, true)
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodHead, "/../../private.html", nil)
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != "400" {
+ t.Fatalf("x-amz-error-code = %q", got)
+ }
+ if len(be.calls) != 0 {
+ t.Fatalf("invalid object should not call backend, got calls: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerGetAppliesBucketCORS(t *testing.T) {
+ corsConfig, err := xml.Marshal(auth.CORSConfiguration{
+ Rules: []auth.CORSRule{
+ {
+ AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
+ AllowedMethods: []auth.CORSHTTPMethod{http.MethodGet, http.MethodHead},
+ ExposeHeaders: []auth.CORSHeader{"Content-Length"},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("marshal cors config: %v", err)
+ }
+
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, map[string]string{
+ "index.html": "home",
+ }, true)
+ be.corsConfig = corsConfig
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodGet, "/", map[string]string{
+ "Origin": "https://client.example",
+ })
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" {
+ t.Fatalf("Access-Control-Allow-Origin = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, HEAD" {
+ t.Fatalf("Access-Control-Allow-Methods = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag, x-amz-storage-class" {
+ t.Fatalf("Access-Control-Expose-Headers = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
+ t.Fatalf("Access-Control-Allow-Credentials = %q", got)
+ }
+ if got := resp.Header.Get("Vary"); got != "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" {
+ t.Fatalf("Vary = %q", got)
+ }
+ if got := readBody(t, resp); got != "home" {
+ t.Fatalf("body = %q, want %q", got, "home")
+ }
+ if !containsCall(be.calls, "GetBucketCors") {
+ t.Fatalf("GetBucketCors was not called: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerHeadAppliesBucketCORS(t *testing.T) {
+ corsConfig, err := xml.Marshal(auth.CORSConfiguration{
+ Rules: []auth.CORSRule{
+ {
+ AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
+ AllowedMethods: []auth.CORSHTTPMethod{http.MethodHead},
+ ExposeHeaders: []auth.CORSHeader{"Content-Length"},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("marshal cors config: %v", err)
+ }
+
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, map[string]string{
+ "index.html": "home",
+ }, true)
+ be.corsConfig = corsConfig
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodHead, "/", map[string]string{
+ "Origin": "https://client.example",
+ })
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" {
+ t.Fatalf("Access-Control-Allow-Origin = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "HEAD" {
+ t.Fatalf("Access-Control-Allow-Methods = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag, x-amz-storage-class" {
+ t.Fatalf("Access-Control-Expose-Headers = %q", got)
+ }
+ if got := readBody(t, resp); got != "" {
+ t.Fatalf("body = %q, want empty body", got)
+ }
+ if !containsCall(be.calls, "GetBucketCors") {
+ t.Fatalf("GetBucketCors was not called: %v", be.calls)
+ }
+ if containsCall(be.calls, "GetObject") {
+ t.Fatalf("GetObject was called for HEAD request: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerOptionsAccessGranted(t *testing.T) {
+ maxAge := int32(42)
+ corsConfig, err := xml.Marshal(auth.CORSConfiguration{
+ Rules: []auth.CORSRule{
+ {
+ AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
+ AllowedMethods: []auth.CORSHTTPMethod{http.MethodGet, http.MethodHead},
+ AllowedHeaders: []auth.CORSHeader{"Content-Type", "X-Amz-Date"},
+ ExposeHeaders: []auth.CORSHeader{"Content-Length"},
+ MaxAgeSeconds: &maxAge,
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("marshal cors config: %v", err)
+ }
+
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, map[string]string{
+ "index.html": "home",
+ }, true)
+ be.corsConfig = corsConfig
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/index.html", map[string]string{
+ "Origin": "https://client.example",
+ "Access-Control-Request-Method": http.MethodGet,
+ "Access-Control-Request-Headers": "content-type, X-Amz-Date",
+ })
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://client.example" {
+ t.Fatalf("Access-Control-Allow-Origin = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, HEAD" {
+ t.Fatalf("Access-Control-Allow-Methods = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "content-type, x-amz-date" {
+ t.Fatalf("Access-Control-Allow-Headers = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Expose-Headers"); got != "Content-Length, ETag" {
+ t.Fatalf("Access-Control-Expose-Headers = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Max-Age"); got != "42" {
+ t.Fatalf("Access-Control-Max-Age = %q", got)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
+ t.Fatalf("Access-Control-Allow-Credentials = %q", got)
+ }
+ if got := resp.Header.Get("Vary"); got != "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" {
+ t.Fatalf("Vary = %q", got)
+ }
+ if got := readBody(t, resp); got != "" {
+ t.Fatalf("body = %q, want empty body", got)
+ }
+ if !containsCall(be.calls, "GetBucketCors") {
+ t.Fatalf("GetBucketCors was not called: %v", be.calls)
+ }
+ for _, unexpected := range []string{"GetBucketWebsite", "GetObject", "HeadObject", "GetBucketAcl"} {
+ if containsCall(be.calls, unexpected) {
+ t.Fatalf("%s was called for OPTIONS request: %v", unexpected, be.calls)
+ }
+ }
+}
+
+func TestWebsiteHandlerOptionsMissingOrigin(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, nil, true)
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{
+ "Access-Control-Request-Method": http.MethodGet,
+ })
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" {
+ t.Fatalf("x-amz-error-code = %q", got)
+ }
+ if containsCall(be.calls, "GetBucketCors") {
+ t.Fatalf("GetBucketCors was called despite missing origin: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerOptionsInvalidRequestMethod(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, nil, true)
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{
+ "Origin": "https://client.example",
+ "Access-Control-Request-Method": http.MethodOptions,
+ })
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" {
+ t.Fatalf("x-amz-error-code = %q", got)
+ }
+ if containsCall(be.calls, "GetBucketCors") {
+ t.Fatalf("GetBucketCors was called despite invalid request method: %v", be.calls)
+ }
+}
+
+func TestWebsiteHandlerOptionsUnsetBucketCORS(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, nil, true)
+ be.corsErr = s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration)
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{
+ "Origin": "https://client.example",
+ "Access-Control-Request-Method": http.MethodGet,
+ })
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" {
+ t.Fatalf("x-amz-error-code = %q", got)
+ }
+ body := readBody(t, resp)
+ for _, want := range []string{
+ "Method: OPTIONS",
+ "ResourceType: BUCKET",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("body missing %q: %s", want, body)
+ }
+ }
+}
+
+func TestWebsiteHandlerOptionsAccessForbidden(t *testing.T) {
+ corsConfig, err := xml.Marshal(auth.CORSConfiguration{
+ Rules: []auth.CORSRule{
+ {
+ AllowedOrigins: []auth.CORSOrigin{"https://client.example"},
+ AllowedMethods: []auth.CORSHTTPMethod{http.MethodHead},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("marshal cors config: %v", err)
+ }
+
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, nil, true)
+ be.corsConfig = corsConfig
+
+ resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/index.html", map[string]string{
+ "Origin": "https://client.example",
+ "Access-Control-Request-Method": http.MethodGet,
+ })
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
+ }
+ if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" {
+ t.Fatalf("x-amz-error-code = %q", got)
+ }
+ body := readBody(t, resp)
+ for _, want := range []string{
+ "Method: OPTIONS",
+ "ResourceType: OBJECT",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("body missing %q: %s", want, body)
+ }
+ }
+}
+
+func TestWebsiteHandlerMethodNotAllowed(t *testing.T) {
+ be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{
+ IndexDocument: &s3response.IndexDocument{Suffix: "index.html"},
+ }, nil, true)
+
+ resp := websiteRequestWithMethod(t, be, http.MethodPut, "/some-key")
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed)
+ }
+ if got := resp.Header.Get("Allow"); got != "GET, HEAD, OPTIONS" {
+ t.Fatalf("Allow = %q, want %q", got, "GET, HEAD, OPTIONS")
+ }
+ if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
+ t.Fatalf("Content-Type = %q, want text/html", got)
+ }
+ if got := resp.Header.Get("Server"); got != "VERSITYGW" {
+ t.Fatalf("Server = %q, want %q", got, "VERSITYGW")
+ }
+
+ body := readBody(t, resp)
+ for _, want := range []string{
+ "Code: MethodNotAllowed",
+ "Method: PUT",
+ "ResourceType: OBJECT",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("method not allowed body missing %q: %s", want, body)
+ }
+ }
+ if containsCall(be.calls, "GetBucketWebsite") {
+ t.Fatalf("unmatched method should not load website config: %v", be.calls)
+ }
+}
+
+func newWebsiteTestBackend(t *testing.T, config s3response.WebsiteConfiguration, objects map[string]string, public bool) *websiteTestBackend {
+ t.Helper()
+
+ data, err := xml.Marshal(config)
+ if err != nil {
+ t.Fatalf("marshal website config: %v", err)
+ }
+ if objects == nil {
+ objects = map[string]string{}
+ }
+
+ return &websiteTestBackend{
+ websiteConfig: data,
+ objects: objects,
+ public: public,
+ }
+}
+
+func websiteRequest(t *testing.T, be backend.Backend, path string) *http.Response {
+ t.Helper()
+
+ return websiteRequestWithMethod(t, be, http.MethodGet, path)
+}
+
+func websiteRequestWithMethod(t *testing.T, be backend.Backend, method, path string) *http.Response {
+ t.Helper()
+
+ return websiteRequestWithHeaders(t, be, method, path, nil)
+}
+
+func websiteRequestWithHeaders(t *testing.T, be backend.Backend, method, path string, headers map[string]string) *http.Response {
+ t.Helper()
+
+ return websiteRequestWithHostAndHeaders(t, be, method, "site.test", path, headers)
+}
+
+func websiteRequestWithHostAndHeaders(t *testing.T, be backend.Backend, method, host, path string, headers map[string]string) *http.Response {
+ t.Helper()
+
+ app := fiber.New(fiber.Config{
+ ServerHeader: "VERSITYGW",
+ })
+ registerWebsiteRoutes(app, be, "")
+
+ req := httptest.NewRequest(method, path, nil)
+ req.Host = host
+ req.Header.Set("Host", host)
+ for key, value := range headers {
+ req.Header.Set(key, value)
+ }
+ resp, err := app.Test(req, -1)
+ if err != nil {
+ t.Fatalf("website request failed: %v", err)
+ }
+ return resp
+}
+
+func readBody(t *testing.T, resp *http.Response) string {
+ t.Helper()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read response body: %v", err)
+ }
+ return string(body)
+}
+
+func containsCall(calls []string, want string) bool {
+ return firstCallIndex(calls, want) != -1
+}
+
+func countCalls(calls []string, want string) int {
+ var count int
+ for _, call := range calls {
+ if call == want {
+ count++
+ }
+ }
+ return count
+}
+
+func firstCallIndex(calls []string, want string) int {
+ for i, call := range calls {
+ if call == want {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/website/server.go b/website/server.go
index 398d1d72..c9c18d88 100644
--- a/website/server.go
+++ b/website/server.go
@@ -17,11 +17,14 @@ package website
import (
"fmt"
"net"
+ "os"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/versity/versitygw/backend"
+ "github.com/versity/versitygw/debuglogger"
+ "github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
)
@@ -31,6 +34,7 @@ type Server struct {
CertStorage *utils.CertStorage
domain string
quiet bool
+ socketPerm os.FileMode
}
// Option sets various options for NewServer().
@@ -46,6 +50,13 @@ func WithTLS(cs *utils.CertStorage) Option {
return func(s *Server) { s.CertStorage = cs }
}
+// WithSocketPerm sets the file-mode permissions applied to file-backed UNIX
+// domain sockets after binding. It has no effect on TCP/IP or abstract
+// namespace sockets.
+func WithSocketPerm(perm os.FileMode) Option {
+ return func(s *Server) { s.socketPerm = perm }
+}
+
// NewServer creates a new static website hosting server.
// The domain parameter is the base domain for virtual-host routing:
// - Host "blog." resolves to bucket "blog"
@@ -83,8 +94,12 @@ func NewServer(be backend.Backend, domain string, opts ...Option) *Server {
}))
}
- // All requests go through the website handler
- app.Use(newHandler(be, domain))
+ // initialize the debug logger in debug mode
+ if debuglogger.IsDebugEnabled() {
+ app.Use(middlewares.DebugLogger())
+ }
+
+ registerWebsiteRoutes(app, be, domain)
return server
}
@@ -103,9 +118,9 @@ func (s *Server) ServeMultiPort(ports []string) error {
var err error
if s.CertStorage != nil {
- ln, err = utils.NewMultiAddrTLSListener(s.app.Config().Network, addrSpec, s.CertStorage.GetCertificate)
+ ln, err = utils.NewMultiAddrTLSListener(s.app.Config().Network, addrSpec, s.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: s.socketPerm})
} else {
- ln, err = utils.NewMultiAddrListener(s.app.Config().Network, addrSpec)
+ ln, err = utils.NewMultiAddrListener(s.app.Config().Network, addrSpec, utils.ListenerOptions{SocketPerm: s.socketPerm})
}
if err != nil {