mirror of
https://github.com/versity/versitygw.git
synced 2026-08-19 13:46:21 +00:00
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:
@@ -26,6 +26,12 @@ Get more details about the new (optional) WebGUI management/explorer here: [http
|
||||
|
||||

|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 #
|
||||
#######################
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user