From 1625c5963eaf7810edc67cb1171a6a18adf8a893 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Mon, 8 Jun 2026 22:31:41 +0400 Subject: [PATCH] feat: improve static website hosting support Enhances the static website hosting implementation with more complete S3-compatible behavior across request handling, backend storage, validation, CORS, and errors. Adds dedicated website endpoint handling for GET, HEAD, and OPTIONS requests, including index document resolution, error document serving, redirect-all support, pre-fetch and post-error routing rules, query string preservation in redirects, public access checks before object reads, and method-not-allowed responses. Improves error handling for website responses by returning S3-compatible HTML error bodies with request IDs, host IDs, x-amz-error-code, x-amz-error-message, and specialized error fields. This also fixes website-related validation errors to return more accurate S3-style error codes and messages, including invalid redirect protocols, invalid HTTP redirect/error codes, conflicting routing rule replacements, routing rule limits, and oversized website configuration requests. Adds website CORS support for GET, HEAD, and OPTIONS preflight requests, including bucket CORS lookup through website host bucket resolution, allowed origin/method/header validation, exposed header handling, ETag exposure, Vary headers, max-age handling, and CORS access-denied responses. Adds debug logging around website configuration parsing, validation failures, CORS checks, backend lookup failures, and internal website error paths to make failures easier to diagnose. Adds compressed website configuration storage so larger configs fit backend metadata limits, including gzip storage for POSIX extended attributes and base64-encoded compressed metadata for Azure. Also adds Azure PutBucketWebsite, GetBucketWebsite, and DeleteBucketWebsite support. Adds and expands test coverage for website config validation, S3-compatible HTML error bodies, website routing behavior, public access enforcement, HEAD behavior, CORS handling, PutBucketWebsite limits, and end-to-end website hosting through a Docker-based dnsmasq test setup and CI workflow. --- .github/workflows/website-hosting-tests.yml | 13 + Makefile | 9 + backend/azure/azure.go | 36 + backend/common.go | 61 +- backend/common_test.go | 47 + backend/posix/posix.go | 18 +- backend/s3proxy/s3.go | 12 +- cmd/versitygw/main.go | 5 + cmd/versitygw/test.go | 130 ++- embedgw/embedgw.go | 185 +++- embedgw/embedgw_test.go | 42 +- s3api/controllers/base.go | 7 +- s3api/controllers/bucket-delete.go | 2 +- s3api/controllers/bucket-get.go | 2 +- s3api/controllers/bucket-put.go | 10 +- s3api/controllers/cors_default_origin_test.go | 4 +- s3api/middlewares/apply-bucket-cors.go | 14 +- s3api/router.go | 111 +- s3err/access-forbidden-error.go | 7 + s3err/bad-digest-error.go | 7 + s3err/bucket-error.go | 6 + s3err/content-sha256-mismatch-error.go | 7 + s3err/entity-too-large-error.go | 7 + s3err/entity-too-small-error.go | 7 + s3err/expired-presigned-url-error.go | 8 + s3err/invalid-access-key-id-error.go | 6 + s3err/invalid-argument.go | 17 + s3err/invalid-chunk-size-error.go | 7 + s3err/invalid-digest-error.go | 6 + s3err/invalid-location-constraint-error.go | 6 + s3err/invalid-part-error.go | 8 + s3err/invalid-part-number-range-error.go | 7 + s3err/invalid-range-error.go | 7 + s3err/invalid-tag-error.go | 7 + s3err/key-too-long-error.go | 7 + s3err/max-message-length-exceeded-error.go | 59 ++ s3err/metadata-too-large-error.go | 7 + s3err/method-not-allowed-error.go | 7 + s3err/no-such-upload-error.go | 6 + s3err/no-such-version-error.go | 7 + s3err/not-implemented-error.go | 7 + s3err/precondition-failed-error.go | 6 + s3err/request-time-too-skewed-error.go | 8 + s3err/s3err.go | 96 +- s3err/signature-does-not-match-error.go | 11 + s3err/sigv4.go | 6 + s3response/website.go | 166 ++- s3response/website_test.go | 437 ++------ tests/integration/PutBucketWebsite.go | 279 ++++- tests/integration/WebsiteHosting.go | 873 +++++++++++----- tests/integration/group-tests.go | 43 +- tests/integration/s3conf.go | 17 +- tests/integration/utils.go | 141 +++ tests/test_rest_not_implemented.sh | 15 - tests/website-hosting-tests/dnsmasq.conf | 2 + .../website-hosting-tests/docker-compose.yml | 58 ++ website/handler.go | 602 +++++++---- website/handler_test.go | 972 ++++++++++++++++++ website/server.go | 23 +- 59 files changed, 3649 insertions(+), 1034 deletions(-) create mode 100644 .github/workflows/website-hosting-tests.yml create mode 100644 s3err/max-message-length-exceeded-error.go create mode 100644 tests/website-hosting-tests/dnsmasq.conf create mode 100644 tests/website-hosting-tests/docker-compose.yml create mode 100644 website/handler_test.go 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
\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 {