Add website integration tests and remove NotImplemented stubs

Replace PutBucketWebsite, GetBucketWebsite, DeleteBucketWebsite
NotImplemented test stubs with comprehensive integration tests covering:
- non-existing bucket errors
- validation (empty suffix, suffix with slash, invalid protocol, mutual
  exclusion of RedirectAllRequestsTo and IndexDocument)
- successful put/get round-trips for both index+error and redirect-all configs
- delete idempotency and verification

Signed-off-by: Marc Singer <marc@singer.gg>

Add error document serving, routing rules, and integration tests

Implement Features 1 and 2 of S3 static website hosting:

- WebsiteErrorDocument controller wrapper intercepts 4xx errors on
  website-enabled buckets and serves the configured error document or
  evaluates post-request routing rules (error code match redirects)
- ResolveWebsiteIndex middleware now caches parsed WebsiteConfiguration
  in context, handles RedirectAllRequestsTo, evaluates pre-request
  routing rules (key prefix match redirects), and rewrites directory
  keys for index document
- MatchPreRequestRule and MatchPostRequestRule methods on
  WebsiteConfiguration for routing rule evaluation
- 14 unit tests for routing rule matching
- 7 integration tests covering error document, routing rules,
  redirect-all, and index document behavior

Signed-off-by: Marc Singer <marc@singer.gg>

Add separate website hosting endpoint with virtual-host routing

Signed-off-by: Marc Singer <marc@singer.gg>

Support catch-all mode for website endpoint when --website-domain is omitted

Signed-off-by: Marc Singer <marc@singer.gg>
This commit is contained in:
Marc Singer
2026-06-10 12:41:51 +04:00
committed by niksis02
parent ac5c3b4a86
commit 375c2764d5
26 changed files with 1833 additions and 175 deletions
+6
View File
@@ -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)
+2
View File
@@ -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: {},
+1
View File
@@ -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 |
+16
View File
@@ -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"
+6
View File
@@ -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 }}
+18
View File
@@ -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.<domain>" serves bucket "blog"; host "<domain>" serves
# bucket "<domain>" (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
+37
View File
@@ -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",
+33 -19
View File
@@ -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())
}
+47
View File
@@ -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.<region>.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 #
#######################
+1 -1
View File
@@ -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,
})
-94
View File
@@ -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)
}
+5 -3
View File
@@ -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),
+1
View File
@@ -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) {
+2 -2
View File
@@ -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: {
+48 -1
View File
@@ -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
}
+314 -1
View File
@@ -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)
}
})
}
}
+85
View File
@@ -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))
})
}
+126
View File
@@ -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
})
}
@@ -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 {
+158
View File
@@ -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
})
}
+413
View File
@@ -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 := "<html><body>Custom Error Page</body></html>"
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 := "<html><body>Welcome</body></html>"
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
})
}
+62 -7
View File
@@ -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,
+4
View File
@@ -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 }
}
+314
View File
@@ -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 ".<domain>", 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(`<!DOCTYPE html>
<html>
<head><title>%d %s</title></head>
<body>
<h1>%d %s</h1>
<p>%s</p>
</body>
</html>`, statusCode, title, statusCode, title, message)
return ctx.SendString(body)
}
+130
View File
@@ -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.<domain>" resolves to bucket "blog"
// - Host "<domain>" (apex, no subdomain) resolves to bucket "<domain>"
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()
}
+4
View File
@@ -981,6 +981,8 @@ under the License.
<li>• s3:GetBucketLocation</li>
<li>• s3:GetBucketPolicy</li>
<li>• s3:PutBucketTagging</li>
<li>• s3:PutBucketWebsite</li>
<li>• s3:GetBucketWebsite</li>
</ul>
</div>
<div>
@@ -4181,6 +4183,8 @@ under the License.
"s3:PutObject",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutBucketWebsite",
"s3:GetBucketWebsite",
"s3:RestoreObject"
],
"Resource": [