diff --git a/README.md b/README.md index ff3bc57a..90c2a8de 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ Get more details about the new (optional) WebGUI management/explorer here: [http ![admin-explorer](https://github.com/user-attachments/assets/e99db171-2c72-4d0f-8c8d-480a56e1c8a1) +### Static Website Hosting +Serve S3 buckets as static websites with index documents, custom error pages, and routing rules. +Enable a separate website endpoint with `--website :8090 --website-domain example.com` for virtual-host style routing (`blog.example.com` serves bucket `blog`, `example.com` serves bucket `example.com`). +When `--website-domain` is omitted, catch-all mode is used: the full hostname becomes the bucket name (name your buckets as FQDNs, e.g. `blog.example.com`). +See [Global Options](https://github.com/versity/versitygw/wiki/Global-Options) for all `--website-*` flags. + ### News Check out latest wiki articles: [https://github.com/versity/versitygw/wiki/Articles](https://github.com/versity/versitygw/wiki/Articles) diff --git a/auth/bucket_policy_actions.go b/auth/bucket_policy_actions.go index 393677de..34bb54d2 100644 --- a/auth/bucket_policy_actions.go +++ b/auth/bucket_policy_actions.go @@ -91,6 +91,7 @@ const ( GetAccelerateConfigurationAction Action = "s3:GetAccelerateConfiguration" PutBucketWebsiteAction Action = "s3:PutBucketWebsite" GetBucketWebsiteAction Action = "s3:GetBucketWebsite" + DeleteBucketWebsiteAction Action = "s3:DeleteBucketWebsite" GetBucketPolicyStatusAction Action = "s3:GetBucketPolicyStatus" GetBucketLocationAction Action = "s3:GetBucketLocation" @@ -167,6 +168,7 @@ var supportedActionList = map[Action]struct{}{ GetAccelerateConfigurationAction: {}, PutBucketWebsiteAction: {}, GetBucketWebsiteAction: {}, + DeleteBucketWebsiteAction: {}, GetBucketPolicyStatusAction: {}, GetBucketLocationAction: {}, AllActions: {}, diff --git a/chart/README.md b/chart/README.md index 808cea93..2194b5f6 100644 --- a/chart/README.md +++ b/chart/README.md @@ -83,6 +83,7 @@ The `gateway.backend.type` value selects the storage backend. Use `gateway.backe | **HTTPRoute** | `httpRoute.enabled=true` — Gateway API successor to Ingress for S3 API; also `admin.httpRoute.enabled=true` and `webui.httpRoute.enabled=true` to expose the admin API and/or WebUI | | **Admin API** | `admin.enabled=true` — exposes a separate management API on `admin.port` (default `7071`) | | **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints | +| **Website Hosting** | `website.enabled=true` — static website hosting endpoint on `website.port` (default `8090`); optionally set `website.domain` for virtual-host routing (e.g. `example.com`), or omit it for catch-all mode where the full hostname is the bucket name | | **IAM** | `iam.enabled=true` — flat-file identity and access management stored alongside backend data | | **Persistence** | `persistence.enabled=true` — provisions a PVC for backend data and IAM storage; defaults to `10Gi`, or uses a hostPath volume specified by `persistence.hostPath` | | **NetworkPolicy** | `networkPolicy.enabled=true` — restricts ingress to selected pods/namespaces; allows all egress | diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index a25663b9..d69dd55b 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -129,6 +129,17 @@ spec: value: {{ .Values.webui.adminGateways | join "," | quote }} {{- end }} {{- end }} + # Website Hosting + {{- if .Values.website.enabled }} + - name: VGW_WEBSITE_PORT + value: ":{{ .Values.website.port }}" + - name: VGW_WEBSITE_DOMAIN + value: {{ .Values.website.domain | quote }} + {{- if .Values.website.noTls }} + - name: VGW_WEBSITE_NO_TLS + value: "true" + {{- end }} + {{- end }} {{- if .Values.iam.enabled }} # IAM settings {{- if eq .Values.iam.type "internal" }} @@ -173,6 +184,11 @@ spec: containerPort: {{ .Values.webui.port }} protocol: TCP {{- end }} + {{- if .Values.website.enabled }} + - name: website + containerPort: {{ .Values.website.port }} + protocol: TCP + {{- end }} readinessProbe: httpGet: path: "/_/health" diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml index c9b70532..ab80edf8 100644 --- a/chart/templates/service.yaml +++ b/chart/templates/service.yaml @@ -23,5 +23,11 @@ spec: protocol: TCP name: webui {{- end }} + {{- if .Values.website.enabled }} + - port: {{ .Values.website.port }} + targetPort: website + protocol: TCP + name: website + {{- end }} selector: {{- include "versitygw.selectorLabels" . | nindent 4 }} diff --git a/chart/values.yaml b/chart/values.yaml index 1e44d8b1..04b84b88 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -225,6 +225,24 @@ webui: type: PathPrefix value: / +# --- Website Hosting --- +website: + # Enable the static website hosting endpoint. + # Serves S3 buckets as static websites with index documents, custom error + # pages, and routing rules via a separate HTTP endpoint. + enabled: false + # The port the website endpoint listens on. + port: 8090 + # Base domain for virtual-host routing. Optional. + # Host "blog." serves bucket "blog"; host "" serves + # bucket "" (apex domain support). + # When empty, catch-all mode is used: the full hostname is the bucket + # name (name buckets as FQDNs, e.g. "blog.example.com"). + domain: "" + # - example: domain: "example.com" + # Disable TLS for the website endpoint even when gateway TLS is enabled. + noTls: false + # --- IAM (Identity and Access Management) --- iam: enabled: false diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 8b2d8355..c1f62ce6 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -91,6 +91,10 @@ var ( webuiAdminGateways []string webuiPathPrefix string webuiS3Prefix string + websitePorts []string + websiteDomain string + websiteCertFile, websiteKeyFile string + websiteNoTLS bool disableACLs bool mpMaxParts int copyObjectThreshold int64 @@ -152,6 +156,7 @@ documentation can be found in the GitHub wiki.`, webuiGateways = ctx.StringSlice("webui-gateways") webuiAdminGateways = ctx.StringSlice("webui-admin-gateways") webuiPathPrefix = ctx.String("webui-path-prefix") + websitePorts = ctx.StringSlice("website") // Resolve relative UNIX socket paths to absolute before any backend // (e.g. posix) can change the working directory via os.Chdir. @@ -165,6 +170,9 @@ documentation can be found in the GitHub wiki.`, if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil { return err } + if websitePorts, err = utils.AbsSocketPaths(websitePorts); err != nil { + return err + } return nil }, Action: func(ctx *cli.Context) error { @@ -240,6 +248,35 @@ func initFlags() []cli.Flag { EnvVars: []string{"VGW_WEBUI_S3_PREFIX"}, Destination: &webuiS3Prefix, }, + &cli.StringSliceFlag{ + Name: "website", + Usage: "enable static website hosting endpoint on the specified listen address (e.g. ':8080'; same forms as --port; can be specified multiple times; requires --website-domain)", + EnvVars: []string{"VGW_WEBSITE_PORT"}, + }, + &cli.StringFlag{ + Name: "website-domain", + Usage: "base domain for website virtual-host routing (e.g. 'example.com'); host 'blog.example.com' serves bucket 'blog', host 'example.com' serves bucket 'example.com'; when omitted the full hostname is used as the bucket name (catch-all mode, buckets named as FQDNs)", + EnvVars: []string{"VGW_WEBSITE_DOMAIN"}, + Destination: &websiteDomain, + }, + &cli.StringFlag{ + Name: "website-cert", + Usage: "TLS cert file for website endpoint (defaults to --cert value when website is enabled)", + EnvVars: []string{"VGW_WEBSITE_CERT"}, + Destination: &websiteCertFile, + }, + &cli.StringFlag{ + Name: "website-key", + Usage: "TLS key file for website endpoint (defaults to --key value when website is enabled)", + EnvVars: []string{"VGW_WEBSITE_KEY"}, + Destination: &websiteKeyFile, + }, + &cli.BoolFlag{ + Name: "website-no-tls", + Usage: "disable TLS for website endpoint even if TLS is configured for the gateway", + EnvVars: []string{"VGW_WEBSITE_NO_TLS"}, + Destination: &websiteNoTLS, + }, &cli.StringFlag{ Name: "access", Usage: "root user access key", diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 2c885e30..3f33d680 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -22,25 +22,26 @@ import ( ) var ( - awsID string - awsSecret string - endpoint 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 - sidecarTests bool - tlsStatus bool - parallel bool + 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 ) func testCommand() *cli.Command { @@ -76,6 +77,13 @@ 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", @@ -334,6 +342,9 @@ 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()) } @@ -380,6 +391,9 @@ 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/extra/example.conf b/extra/example.conf index caf869b2..8f2f6c1d 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -312,6 +312,53 @@ ROOT_SECRET_ACCESS_KEY= # Example: VGW_WEBUI_ADMIN_GATEWAYS=https://admin.example.com,http://192.168.1.100:7080 #VGW_WEBUI_ADMIN_GATEWAYS= +################### +# Website Hosting # +################### + +# VersityGW supports S3-compatible static website hosting on a dedicated +# endpoint, separate from the S3 API port. This mirrors how AWS serves +# websites on s3-website..amazonaws.com rather than s3.amazonaws.com. +# +# When enabled, the website endpoint serves bucket content as static websites +# using the PutBucketWebsite configuration (index documents, error documents, +# routing rules, and redirect-all). No S3 authentication is applied — the +# website endpoint is public, just like AWS S3 website hosting. +# +# Bucket resolution uses virtual-host-style routing based on the Host header: +# - With VGW_WEBSITE_DOMAIN=example.com: +# Host "blog.example.com" -> serves bucket "blog" +# Host "example.com" -> serves bucket "example.com" (apex) +# - Without VGW_WEBSITE_DOMAIN (catch-all mode): +# Host "blog.example.com" -> serves bucket "blog.example.com" +# Host "mysite.org" -> serves bucket "mysite.org" + +# The VGW_WEBSITE_PORT option enables the website hosting endpoint on the +# specified listen address. The format is the same as VGW_PORT (e.g. ':8080', +# 'localhost:8080'). Multiple ports can be specified as a comma-separated list. +# When omitted, website hosting is disabled. +#VGW_WEBSITE_PORT= + +# The VGW_WEBSITE_DOMAIN option sets the base domain for virtual-host bucket +# routing. For example, with domain "example.com", a request with Host header +# "blog.example.com" resolves to bucket "blog". When omitted, the full +# hostname from the Host header is used as the bucket name (catch-all mode), +# which is useful when buckets are named as FQDNs (e.g. "www.mysite.org"). +#VGW_WEBSITE_DOMAIN= + +# The VGW_WEBSITE_CERT and VGW_WEBSITE_KEY options specify TLS credentials +# for the website endpoint. When not set but TLS is configured for the gateway +# (VGW_CERT and VGW_KEY), the website endpoint inherits the gateway certificates. +# When neither is set, the website endpoint runs without TLS (HTTP only). +#VGW_WEBSITE_CERT= +#VGW_WEBSITE_KEY= + +# The VGW_WEBSITE_NO_TLS option disables TLS for the website endpoint even +# when TLS certificates are configured for the gateway. Set to true to force +# the website endpoint to use HTTP. This is useful when TLS termination is +# handled by a reverse proxy or load balancer. +#VGW_WEBSITE_NO_TLS=false + ####################### # Debug / Diagnostics # ####################### diff --git a/s3api/controllers/bucket-delete.go b/s3api/controllers/bucket-delete.go index 6d517ef3..441f5273 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.PutBucketWebsiteAction, + Action: auth.DeleteBucketWebsiteAction, IsPublicRequest: IsBucketPublic, DisableACL: c.disableACL, }) diff --git a/s3api/middlewares/website.go b/s3api/middlewares/website.go deleted file mode 100644 index 90c0db0a..00000000 --- a/s3api/middlewares/website.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright 2023 Versity Software -// This file is licensed under the Apache License, Version 2.0 -// (the "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -package middlewares - -import ( - "encoding/xml" - "fmt" - "net/http" - "strings" - - "github.com/gofiber/fiber/v2" - "github.com/versity/versitygw/backend" - "github.com/versity/versitygw/s3api/utils" - "github.com/versity/versitygw/s3response" -) - -// ResolveWebsiteIndex rewrites directory-like object keys to include the -// configured IndexDocument suffix when website hosting is enabled for the -// bucket. It also handles RedirectAllRequestsTo by returning a 301 redirect. -// -// This middleware should be placed in the GetObject handler chain before -// authentication and the controller. -func ResolveWebsiteIndex(be backend.Backend) fiber.Handler { - return func(ctx *fiber.Ctx) error { - if utils.ContextKeySkip.IsSet(ctx) { - return ctx.Next() - } - - bucket := ctx.Params("bucket") - if bucket == "" { - return ctx.Next() - } - - key := ctx.Params("*1") - - // Only process directory-like keys (empty or ending with /) - if key != "" && !strings.HasSuffix(key, "/") { - return ctx.Next() - } - - // Reject path traversal attempts - if strings.Contains(key, "..") { - return ctx.Next() - } - - data, err := be.GetBucketWebsite(ctx.Context(), bucket) - if err != nil { - // No website config: pass through to normal handling - return ctx.Next() - } - - var config s3response.WebsiteConfiguration - if xmlErr := xml.Unmarshal(data, &config); xmlErr != nil { - return ctx.Next() - } - - // Handle RedirectAllRequestsTo - if config.RedirectAllRequestsTo != nil { - return redirectAll(ctx, config.RedirectAllRequestsTo, key) - } - - // Rewrite directory-like keys to include index document suffix - if config.IndexDocument != nil && config.IndexDocument.Suffix != "" { - newKey := key + config.IndexDocument.Suffix - newPath := fmt.Sprintf("/%s/%s", bucket, newKey) - ctx.Request().URI().SetPath(newPath) - } - - return ctx.Next() - } -} - -func redirectAll(ctx *fiber.Ctx, redirect *s3response.RedirectAllRequestsTo, key string) error { - protocol := redirect.Protocol - if protocol == "" { - protocol = "https" - } - - location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key) - ctx.Set("Location", location) - return ctx.SendStatus(http.StatusMovedPermanently) -} diff --git a/s3api/router.go b/s3api/router.go index b735ebbc..e4b32432 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -451,6 +451,8 @@ func (sa *S3ApiRouter) Init() { middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutBucketWebsite, auth.PutBucketWebsiteAction, 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.VerifyChecksums(false, true, false), + middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), middlewares.ParseAcl(sa.be), ), ) @@ -669,9 +671,10 @@ func (sa *S3ApiRouter) Init() { metrics.ActionDeleteBucketWebsite, services, middlewares.BucketObjectNameValidator(), - middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketWebsite, auth.PutBucketWebsiteAction, auth.PermissionWrite, sa.region, false), + 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), middlewares.ParseAcl(sa.be), ), ) @@ -1063,6 +1066,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), middlewares.ParseAcl(sa.be), ), ) @@ -1152,7 +1156,6 @@ func (sa *S3ApiRouter) Init() { metrics.ActionHeadObject, services, middlewares.BucketObjectNameValidator(), - middlewares.ResolveWebsiteIndex(sa.be), middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionHeadObject, auth.GetObjectAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, false, false), @@ -1270,7 +1273,6 @@ func (sa *S3ApiRouter) Init() { metrics.ActionGetObject, services, middlewares.BucketObjectNameValidator(), - middlewares.ResolveWebsiteIndex(sa.be), middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionGetObject, auth.GetObjectAction, auth.PermissionRead, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), diff --git a/s3api/utils/context-keys.go b/s3api/utils/context-keys.go index dd642efa..445531c3 100644 --- a/s3api/utils/context-keys.go +++ b/s3api/utils/context-keys.go @@ -40,6 +40,7 @@ const ( ContextKeyObjectPostResult ContextKey = "object-post-result" ContextKeyRequestID ContextKey = "request-id" ContextKeyHostID ContextKey = "host-id" + ContextKeyWebsiteConfig ContextKey = "website-config" ) func (ck ContextKey) Set(ctx *fiber.Ctx, val any) { diff --git a/s3err/s3err.go b/s3err/s3err.go index 3be9fdc7..ac3fe57c 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -649,8 +649,8 @@ var errorCodeResponse = map[ErrorCode]APIError{ HTTPStatusCode: http.StatusNotFound, }, ErrInvalidWebsiteConfiguration: { - Code: "InvalidRequest", - Description: "The website configuration is not valid.", + Code: "MalformedXML", + Description: "The XML you provided was not well-formed or did not validate against our published schema.", HTTPStatusCode: http.StatusBadRequest, }, ErrInvalidWebsiteSuffix: { diff --git a/s3response/website.go b/s3response/website.go index 85d2bf24..16884e38 100644 --- a/s3response/website.go +++ b/s3response/website.go @@ -1,4 +1,4 @@ -// Copyright 2023 Versity Software +// 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 @@ -149,3 +149,50 @@ func ParseWebsiteConfigOutput(data []byte) (*WebsiteConfiguration, error) { 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 { + for i := range c.RoutingRules { + rule := &c.RoutingRules[i] + + if rule.Condition != nil && rule.Condition.HttpErrorCodeReturnedEquals != "" { + // This is a post-request rule, skip it + continue + } + + if rule.Condition == nil || strings.HasPrefix(key, rule.Condition.KeyPrefixEquals) { + return rule + } + } + + 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 { + for i := range c.RoutingRules { + rule := &c.RoutingRules[i] + + if rule.Condition == nil || rule.Condition.HttpErrorCodeReturnedEquals == "" { + // Not a post-request rule + continue + } + + if rule.Condition.HttpErrorCodeReturnedEquals != httpErrorCode { + continue + } + + if rule.Condition.KeyPrefixEquals != "" && !strings.HasPrefix(key, rule.Condition.KeyPrefixEquals) { + continue + } + + return rule + } + + return nil +} diff --git a/s3response/website_test.go b/s3response/website_test.go index a8f6b4fb..b644a154 100644 --- a/s3response/website_test.go +++ b/s3response/website_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 Versity Software +// 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 @@ -272,3 +272,316 @@ func TestParseWebsiteConfigOutput_InvalidXML(t *testing.T) { 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: "no routing rules", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + }, + key: "docs/page.html", + wantNil: 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: "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", + }, + } + + 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) + } + }) + } +} diff --git a/tests/integration/DeleteBucketWebsite.go b/tests/integration/DeleteBucketWebsite.go new file mode 100644 index 00000000..ad9e5ac2 --- /dev/null +++ b/tests/integration/DeleteBucketWebsite.go @@ -0,0 +1,85 @@ +// 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 integration + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" +) + +func DeleteBucketWebsite_non_existing_bucket(s *S3Conf) error { + testName := "DeleteBucketWebsite_non_existing_bucket" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{ + Bucket: getPtr("non-existing-bucket"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)) + }) +} + +func DeleteBucketWebsite_success(s *S3Conf) error { + testName := "DeleteBucketWebsite_success" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + deleteWebsite := func() error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + return err + } + + // should not return error when deleting unset website config + err := deleteWebsite() + if err != nil { + return err + } + + // put a website config + 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"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + // delete the website config + err = deleteWebsite() + if err != nil { + return err + } + + // verify it's gone + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)) + }) +} diff --git a/tests/integration/GetBucketWebsite.go b/tests/integration/GetBucketWebsite.go new file mode 100644 index 00000000..7dfdf194 --- /dev/null +++ b/tests/integration/GetBucketWebsite.go @@ -0,0 +1,126 @@ +// 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 integration + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" +) + +func GetBucketWebsite_non_existing_bucket(s *S3Conf) error { + testName := "GetBucketWebsite_non_existing_bucket" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: getPtr("non-existing-bucket"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)) + }) +} + +func GetBucketWebsite_no_such_website_config(s *S3Conf) error { + testName := "GetBucketWebsite_no_such_website_config" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)) + }) +} + +func GetBucketWebsite_success(s *S3Conf) error { + testName := "GetBucketWebsite_success" + 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("error.html"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + if err != nil { + return err + } + + if res.IndexDocument == nil || res.IndexDocument.Suffix == nil || *res.IndexDocument.Suffix != "index.html" { + return fmt.Errorf("expected IndexDocument.Suffix to be %q, got %v", "index.html", res.IndexDocument) + } + if res.ErrorDocument == nil || res.ErrorDocument.Key == nil || *res.ErrorDocument.Key != "error.html" { + return fmt.Errorf("expected ErrorDocument.Key to be %q, got %v", "error.html", res.ErrorDocument) + } + + return nil + }) +} + +func GetBucketWebsite_success_redirect_all(s *S3Conf) error { + testName := "GetBucketWebsite_success_redirect_all" + 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"), + Protocol: types.ProtocolHttps, + }, + }, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + if err != nil { + return err + } + + if res.RedirectAllRequestsTo == nil || res.RedirectAllRequestsTo.HostName == nil || *res.RedirectAllRequestsTo.HostName != "example.com" { + return fmt.Errorf("expected RedirectAllRequestsTo.HostName to be %q, got %v", "example.com", res.RedirectAllRequestsTo) + } + if res.RedirectAllRequestsTo.Protocol != types.ProtocolHttps { + return fmt.Errorf("expected RedirectAllRequestsTo.Protocol to be %q, got %q", types.ProtocolHttps, res.RedirectAllRequestsTo.Protocol) + } + + return nil + }) +} diff --git a/tests/integration/NotImplemented_actions.go b/tests/integration/NotImplemented_actions.go index 4beea7d6..24e9ff3a 100644 --- a/tests/integration/NotImplemented_actions.go +++ b/tests/integration/NotImplemented_actions.go @@ -655,53 +655,6 @@ func GetBucketAccelerateConfiguration_not_implemented(s *S3Conf) error { }) } -func PutBucketWebsite_not_implemented(s *S3Conf) error { - testName := "PutBucketWebsite_not_implemented" - 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("suffix"), - }, - }, - }) - cancel() - - return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)) - }) -} - -func GetBucketWebsite_not_implemented(s *S3Conf) error { - testName := "GetBucketWebsite_not_implemented" - return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.GetBucketWebsite(ctx, - &s3.GetBucketWebsiteInput{ - Bucket: &bucket, - }) - cancel() - - return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)) - }) -} - -func DeleteBucketWebsite_not_implemented(s *S3Conf) error { - testName := "DeleteBucketWebsite_not_implemented" - return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.DeleteBucketWebsite(ctx, - &s3.DeleteBucketWebsiteInput{ - Bucket: &bucket, - }) - cancel() - - return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)) - }) -} - func PutObjectAcl_not_implemented(s *S3Conf) error { testName := "PutObjectAcl_not_implemented" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/PutBucketWebsite.go b/tests/integration/PutBucketWebsite.go new file mode 100644 index 00000000..11e97f38 --- /dev/null +++ b/tests/integration/PutBucketWebsite.go @@ -0,0 +1,158 @@ +// 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 integration + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" +) + +func PutBucketWebsite_non_existing_bucket(s *S3Conf) error { + testName := "PutBucketWebsite_non_existing_bucket" + 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: getPtr("non-existing-bucket"), + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)) + }) +} + +func PutBucketWebsite_empty_suffix(s *S3Conf) error { + testName := "PutBucketWebsite_empty_suffix" + 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(""), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)) + }) +} + +func PutBucketWebsite_suffix_with_slash(s *S3Conf) error { + testName := "PutBucketWebsite_suffix_with_slash" + 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"), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteSuffix)) + }) +} + +func PutBucketWebsite_invalid_redirect_protocol(s *S3Conf) error { + testName := "PutBucketWebsite_invalid_redirect_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"), + Protocol: types.Protocol("ftp"), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)) + }) +} + +func PutBucketWebsite_redirect_and_index(s *S3Conf) error { + testName := "PutBucketWebsite_redirect_and_index" + 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"), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteConfiguration)) + }) +} + +func PutBucketWebsite_success(s *S3Conf) error { + testName := "PutBucketWebsite_success" + 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("error.html"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + return nil + }) +} + +func PutBucketWebsite_success_redirect_all(s *S3Conf) error { + testName := "PutBucketWebsite_success_redirect_all" + 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"), + Protocol: types.ProtocolHttps, + }, + }, + }) + cancel() + if err != nil { + return err + } + + return nil + }) +} diff --git a/tests/integration/WebsiteHosting.go b/tests/integration/WebsiteHosting.go new file mode 100644 index 00000000..453a02b2 --- /dev/null +++ b/tests/integration/WebsiteHosting.go @@ -0,0 +1,413 @@ +// 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 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" +) + +// 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. +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"), + }, + }, + }) + cancel() + if 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{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(errorContent), + ContentType: getPtr("text/html"), + }) + cancel() + 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) + if err != nil { + return err + } + + if string(body) != errorContent { + return fmt.Errorf("expected error document content %q, got %q", errorContent, string(body)) + } + + return nil + }) +} + +// WebsiteHosting_error_document_not_found tests that when the configured +// error document itself does not exist, a 404 error page is returned. +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"), + }, + }, + }) + cancel() + 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 + }) +} + +// WebsiteHosting_no_error_document tests that when website is enabled +// but no error document is configured, a 404 error page is returned. +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"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + // Request a non-existing key - should get 404 + 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 + }) +} + +// 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. +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"), + }, + }, + }, + }, + }) + cancel() + if err != nil { + return err + } + + // Request a non-existing key via the website endpoint + resp, err := websiteGet(s.websiteEndpoint, bucket, "missing-page") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + return fmt.Errorf("expected status 302, got %v", resp.StatusCode) + } + + 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 + }) +} + +// 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. +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"), + }, + }, + }, + }, + }) + 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") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMovedPermanently { + return fmt.Errorf("expected status 301, got %v", resp.StatusCode) + } + + location := resp.Header.Get("Location") + if location == "" { + return fmt.Errorf("expected Location header, got none") + } + + // 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 + }) +} + +// WebsiteHosting_redirect_all_requests tests the RedirectAllRequestsTo +// configuration, which should redirect any request to the specified host. +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, + }, + }, + }) + cancel() + if err != nil { + return err + } + + // Request any path via the website endpoint + resp, err := websiteGet(s.websiteEndpoint, bucket, "any/path/here") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMovedPermanently { + return fmt.Errorf("expected status 301, got %v", resp.StatusCode) + } + + 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 + }) +} + +// WebsiteHosting_index_document tests that requesting a directory-like +// path on a website-enabled bucket serves the index document. +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"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + // Upload index document at root + indexContent := "Welcome" + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("index.html"), + Body: strings.NewReader(indexContent), + ContentType: getPtr("text/html"), + }) + cancel() + 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() + + 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)) + } + + return nil + }) +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index fe8b19d5..323b20fe 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -14,6 +14,8 @@ package integration +import "fmt" + func TestAuthentication(ts *TestState) { ts.Run(Authentication_invalid_auth_header) ts.Run(Authentication_unsupported_signature_version) @@ -663,6 +665,42 @@ func TestDeleteBucketCors(ts *TestState) { ts.Run(DeleteBucketCors_success) } +func TestPutBucketWebsite(ts *TestState) { + ts.Run(PutBucketWebsite_non_existing_bucket) + 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_success) + ts.Run(PutBucketWebsite_success_redirect_all) +} + +func TestGetBucketWebsite(ts *TestState) { + ts.Run(GetBucketWebsite_non_existing_bucket) + ts.Run(GetBucketWebsite_no_such_website_config) + ts.Run(GetBucketWebsite_success) + ts.Run(GetBucketWebsite_success_redirect_all) +} + +func TestDeleteBucketWebsite(ts *TestState) { + ts.Run(DeleteBucketWebsite_non_existing_bucket) + ts.Run(DeleteBucketWebsite_success) +} + +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_routing_rule_post_request_redirect) + ts.Run(WebsiteHosting_routing_rule_pre_request_redirect) + ts.Run(WebsiteHosting_redirect_all_requests) + ts.Run(WebsiteHosting_index_document) +} + func TestPreflightOPTIONSEndpoint(ts *TestState) { ts.Run(PreflightOPTIONS_non_existing_bucket) ts.Run(PreflightOPTIONS_missing_origin) @@ -788,10 +826,6 @@ func TestNotImplementedActions(ts *TestState) { // bucket acceleration actions ts.Run(PutBucketAccelerateConfiguration_not_implemented) ts.Run(GetBucketAccelerateConfiguration_not_implemented) - // bucket website actions - ts.Run(PutBucketWebsite_not_implemented) - ts.Run(GetBucketWebsite_not_implemented) - ts.Run(DeleteBucketWebsite_not_implemented) // object acl actions ts.Run(PutObjectAcl_not_implemented) ts.Run(GetObjectAcl_not_implemented) @@ -862,6 +896,10 @@ func TestFullFlow(ts *TestState) { TestPutBucketCors(ts) TestGetBucketCors(ts) TestDeleteBucketCors(ts) + TestPutBucketWebsite(ts) + TestGetBucketWebsite(ts) + TestDeleteBucketWebsite(ts) + TestWebsiteHosting(ts) TestPreflightOPTIONSEndpoint(ts) TestPutObjectLockConfiguration(ts) TestGetObjectLockConfiguration(ts) @@ -1760,6 +1798,26 @@ func GetIntTests() IntTests { "DeleteBucketCors_non_existing_bucket": DeleteBucketCors_non_existing_bucket, "DeleteBucketCors_success": DeleteBucketCors_success, "PutBucketCors_success": PutBucketCors_success, + "PutBucketWebsite_non_existing_bucket": PutBucketWebsite_non_existing_bucket, + "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_success": PutBucketWebsite_success, + "PutBucketWebsite_success_redirect_all": PutBucketWebsite_success_redirect_all, + "GetBucketWebsite_non_existing_bucket": GetBucketWebsite_non_existing_bucket, + "GetBucketWebsite_no_such_website_config": GetBucketWebsite_no_such_website_config, + "GetBucketWebsite_success": GetBucketWebsite_success, + "GetBucketWebsite_success_redirect_all": GetBucketWebsite_success_redirect_all, + "DeleteBucketWebsite_non_existing_bucket": DeleteBucketWebsite_non_existing_bucket, + "DeleteBucketWebsite_success": DeleteBucketWebsite_success, + "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_routing_rule_post_request_redirect": WebsiteHosting_routing_rule_post_request_redirect, + "WebsiteHosting_routing_rule_pre_request_redirect": WebsiteHosting_routing_rule_pre_request_redirect, + "WebsiteHosting_redirect_all_requests": WebsiteHosting_redirect_all_requests, + "WebsiteHosting_index_document": WebsiteHosting_index_document, "PreflightOPTIONS_non_existing_bucket": PreflightOPTIONS_non_existing_bucket, "PreflightOPTIONS_missing_origin": PreflightOPTIONS_missing_origin, "PreflightOPTIONS_invalid_request_method": PreflightOPTIONS_invalid_request_method, @@ -1846,9 +1904,6 @@ func GetIntTests() IntTests { "GetBucketNotificationConfiguratio_not_implemented": GetBucketNotificationConfiguratio_not_implemented, "PutBucketAccelerateConfiguration_not_implemented": PutBucketAccelerateConfiguration_not_implemented, "GetBucketAccelerateConfiguration_not_implemented": GetBucketAccelerateConfiguration_not_implemented, - "PutBucketWebsite_not_implemented": PutBucketWebsite_not_implemented, - "GetBucketWebsite_not_implemented": GetBucketWebsite_not_implemented, - "DeleteBucketWebsite_not_implemented": DeleteBucketWebsite_not_implemented, "PutObjectAcl_not_implemented": PutObjectAcl_not_implemented, "GetObjectAcl_not_implemented": GetObjectAcl_not_implemented, "WORMProtection_bucket_object_lock_configuration_compliance_mode": WORMProtection_bucket_object_lock_configuration_compliance_mode, diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go index e49b892b..6382f07a 100644 --- a/tests/integration/s3conf.go +++ b/tests/integration/s3conf.go @@ -36,6 +36,7 @@ type S3Conf struct { awsSecret string awsRegion string endpoint string + websiteEndpoint string hostStyle bool checksumDisable bool PartSize int64 @@ -85,6 +86,9 @@ 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 WithDisableChecksum() Option { return func(s *S3Conf) { s.checksumDisable = true } } diff --git a/website/handler.go b/website/handler.go new file mode 100644 index 00000000..463ceec7 --- /dev/null +++ b/website/handler.go @@ -0,0 +1,314 @@ +// 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 ( + "encoding/xml" + "fmt" + "html" + "io" + "net/http" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/gofiber/fiber/v2" + "github.com/versity/versitygw/backend" + "github.com/versity/versitygw/s3response" +) + +// newHandler returns a fiber handler 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. +// +// Virtual-host routing with --website-domain example.com: +// - Host "blog.example.com" -> bucket "blog" +// - Host "example.com" -> bucket "example.com" (apex) +// +// 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 + + return func(ctx *fiber.Ctx) error { + host := ctx.Hostname() + if host == "" { + return sendError(ctx, http.StatusBadRequest, "Bad Request", "Missing Host header") + } + + // 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] + } + } + + // 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()))) + } + + // 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)) + } + + var config s3response.WebsiteConfiguration + if xmlErr := xml.Unmarshal(data, &config); xmlErr != nil { + return sendError(ctx, http.StatusInternalServerError, "Internal Server Error", + "Invalid website configuration") + } + + key := strings.TrimPrefix(ctx.Path(), "/") + + // 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)) + } +} + +// resolveBucket extracts the bucket name from the host header. +// +// When domain is set: +// - If host equals the domain exactly, the bucket IS the domain (apex). +// - If host ends with ".", the bucket is the subdomain part. +// - Otherwise, no bucket can be resolved. +// +// 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 + } + + if strings.EqualFold(host, domain) { + return domain + } + + lower := strings.ToLower(host) + if strings.HasSuffix(lower, strings.ToLower(domainSuffix)) { + sub := host[:len(host)-len(domainSuffix)] + if sub != "" && !strings.Contains(sub, ".") { + return sub + } + } + + return "" +} + +// 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" + } + + location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key) + ctx.Set("Location", location) + return ctx.SendStatus(http.StatusMovedPermanently) +} + +// applyRedirect constructs and sends a redirect response from a routing rule. +func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3response.RoutingRuleCondition, originalKey string) error { + protocol := redirect.Protocol + if protocol == "" { + protocol = ctx.Protocol() + } + + host := redirect.HostName + if host == "" { + host = ctx.Hostname() + } + + key := originalKey + if redirect.ReplaceKeyWith != "" { + key = redirect.ReplaceKeyWith + } else if redirect.ReplaceKeyPrefixWith != "" && condition != nil && condition.KeyPrefixEquals != "" { + key = redirect.ReplaceKeyPrefixWith + strings.TrimPrefix(originalKey, condition.KeyPrefixEquals) + } + + httpCode := http.StatusFound // 302 default + if redirect.HttpRedirectCode != "" { + if code, err := strconv.Atoi(redirect.HttpRedirectCode); err == nil { + httpCode = code + } + } + + location := fmt.Sprintf("%s://%s/%s", protocol, host, key) + 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) + + if result.ETag != nil { + ctx.Set("ETag", *result.ETag) + } + 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)) + } + + _, err := io.Copy(ctx.Response().BodyWriter(), result.Body) + if err != nil { + return sendError(ctx, http.StatusInternalServerError, "Internal Server Error", + "Failed to read object") + } + + return nil +} + +// 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") + } + + 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" + } +} + +// 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) +} diff --git a/website/server.go b/website/server.go new file mode 100644 index 00000000..398d1d72 --- /dev/null +++ b/website/server.go @@ -0,0 +1,130 @@ +// 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 ( + "fmt" + "net" + + "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/s3api/utils" +) + +// Server is the static website hosting endpoint. +type Server struct { + app *fiber.App + CertStorage *utils.CertStorage + domain string + quiet bool +} + +// Option sets various options for NewServer(). +type Option func(*Server) + +// WithQuiet silences default logging output. +func WithQuiet() Option { + return func(s *Server) { s.quiet = true } +} + +// WithTLS sets TLS credentials. +func WithTLS(cs *utils.CertStorage) Option { + return func(s *Server) { s.CertStorage = cs } +} + +// 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" +// - Host "" (apex, no subdomain) resolves to bucket "" +func NewServer(be backend.Backend, domain string, opts ...Option) *Server { + app := fiber.New(fiber.Config{ + AppName: "versitygw-website", + ServerHeader: "VERSITYGW", + DisableStartupMessage: true, + Network: fiber.NetworkTCP, + }) + + server := &Server{ + app: app, + domain: domain, + } + + for _, opt := range opts { + opt(server) + } + + domainInfo := "catch-all" + if domain != "" { + domainInfo = "domain: " + domain + } + + // Panic recovery + app.Use(recover.New()) + + // Request logging + if !server.quiet { + fmt.Printf("initializing website endpoint (%s)\n", domainInfo) + app.Use(logger.New(logger.Config{ + Format: "${time} | website | ${status} | ${latency} | ${ip} | ${method} | ${path}\n", + })) + } + + // All requests go through the website handler + app.Use(newHandler(be, domain)) + + return server +} + +// ServeMultiPort creates listeners for multiple address specifications and serves +// on all of them simultaneously. +func (s *Server) ServeMultiPort(ports []string) error { + if len(ports) == 0 { + return fmt.Errorf("no addresses specified") + } + + var listeners []net.Listener + + for _, addrSpec := range ports { + var ln net.Listener + var err error + + if s.CertStorage != nil { + ln, err = utils.NewMultiAddrTLSListener(s.app.Config().Network, addrSpec, s.CertStorage.GetCertificate) + } else { + ln, err = utils.NewMultiAddrListener(s.app.Config().Network, addrSpec) + } + + if err != nil { + return fmt.Errorf("failed to bind website listener %s: %w", addrSpec, err) + } + + listeners = append(listeners, ln) + } + + if len(listeners) == 0 { + return fmt.Errorf("failed to create any website listeners") + } + + finalListener := utils.NewMultiListener(listeners...) + + return s.app.Listener(finalListener) +} + +// Shutdown gracefully shuts down the server. +func (s *Server) Shutdown() error { + return s.app.Shutdown() +} diff --git a/webui/web/explorer.html b/webui/web/explorer.html index b541f708..c2bb4df2 100644 --- a/webui/web/explorer.html +++ b/webui/web/explorer.html @@ -981,6 +981,8 @@ under the License.
  • • s3:GetBucketLocation
  • • s3:GetBucketPolicy
  • • s3:PutBucketTagging
  • +
  • • s3:PutBucketWebsite
  • +
  • • s3:GetBucketWebsite
  • @@ -4181,6 +4183,8 @@ under the License. "s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectTagging", + "s3:PutBucketWebsite", + "s3:GetBucketWebsite", "s3:RestoreObject" ], "Resource": [