feat: improve static website hosting support

Enhances the static website hosting implementation with more complete S3-compatible behavior across request handling, backend storage, validation, CORS, and errors.

Adds dedicated website endpoint handling for GET, HEAD, and OPTIONS requests, including index document resolution, error document serving, redirect-all support, pre-fetch and post-error routing rules, query string preservation in redirects, public access checks before object reads, and method-not-allowed responses.

Improves error handling for website responses by returning S3-compatible HTML error bodies with request IDs, host IDs, x-amz-error-code, x-amz-error-message, and specialized error fields. This also fixes website-related validation errors to return more accurate S3-style error codes and messages, including invalid redirect protocols, invalid HTTP redirect/error codes, conflicting routing rule replacements, routing rule limits, and oversized website configuration requests.

Adds website CORS support for GET, HEAD, and OPTIONS preflight requests, including bucket CORS lookup through website host bucket resolution, allowed origin/method/header validation, exposed header handling, ETag exposure, Vary headers, max-age handling, and CORS access-denied responses.

Adds debug logging around website configuration parsing, validation failures, CORS checks, backend lookup failures, and internal website error paths to make failures easier to diagnose.

Adds compressed website configuration storage so larger configs fit backend metadata limits, including gzip storage for POSIX extended attributes and base64-encoded compressed metadata for Azure. Also adds Azure PutBucketWebsite, GetBucketWebsite, and DeleteBucketWebsite support.

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