diff --git a/.github/workflows/website-hosting-tests.yml b/.github/workflows/website-hosting-tests.yml new file mode 100644 index 00000000..448d1153 --- /dev/null +++ b/.github/workflows/website-hosting-tests.yml @@ -0,0 +1,13 @@ +name: website hosting tests +permissions: {} +on: pull_request + +jobs: + build-and-run: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: run website hosting tests + run: make test-website-hosting diff --git a/Makefile b/Makefile index 6da6d45f..0bf43207 100644 --- a/Makefile +++ b/Makefile @@ -107,3 +107,12 @@ test-host-style: COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \ exit $$status +# Run the static website hosting tests in docker containers +.PHONY: test-website-hosting +test-website-hosting: + @compose_file=tests/website-hosting-tests/docker-compose.yml; \ + COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans >/dev/null 2>&1 || true; \ + COMPOSE_MENU=false docker compose -f "$$compose_file" up --build --abort-on-container-exit --exit-code-from test; \ + status=$$?; \ + COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \ + exit $$status diff --git a/README.md b/README.md index ff3bc57a..90c2a8de 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ Get more details about the new (optional) WebGUI management/explorer here: [http ![admin-explorer](https://github.com/user-attachments/assets/e99db171-2c72-4d0f-8c8d-480a56e1c8a1) +### Static Website Hosting +Serve S3 buckets as static websites with index documents, custom error pages, and routing rules. +Enable a separate website endpoint with `--website :8090 --website-domain example.com` for virtual-host style routing (`blog.example.com` serves bucket `blog`, `example.com` serves bucket `example.com`). +When `--website-domain` is omitted, catch-all mode is used: the full hostname becomes the bucket name (name your buckets as FQDNs, e.g. `blog.example.com`). +See [Global Options](https://github.com/versity/versitygw/wiki/Global-Options) for all `--website-*` flags. + ### News Check out latest wiki articles: [https://github.com/versity/versitygw/wiki/Articles](https://github.com/versity/versitygw/wiki/Articles) diff --git a/auth/bucket_policy_actions.go b/auth/bucket_policy_actions.go index 393677de..34bb54d2 100644 --- a/auth/bucket_policy_actions.go +++ b/auth/bucket_policy_actions.go @@ -91,6 +91,7 @@ const ( GetAccelerateConfigurationAction Action = "s3:GetAccelerateConfiguration" PutBucketWebsiteAction Action = "s3:PutBucketWebsite" GetBucketWebsiteAction Action = "s3:GetBucketWebsite" + DeleteBucketWebsiteAction Action = "s3:DeleteBucketWebsite" GetBucketPolicyStatusAction Action = "s3:GetBucketPolicyStatus" GetBucketLocationAction Action = "s3:GetBucketLocation" @@ -167,6 +168,7 @@ var supportedActionList = map[Action]struct{}{ GetAccelerateConfigurationAction: {}, PutBucketWebsiteAction: {}, GetBucketWebsiteAction: {}, + DeleteBucketWebsiteAction: {}, GetBucketPolicyStatusAction: {}, GetBucketLocationAction: {}, AllActions: {}, diff --git a/backend/azure/azure.go b/backend/azure/azure.go index ad411f11..cdcb7a45 100644 --- a/backend/azure/azure.go +++ b/backend/azure/azure.go @@ -63,10 +63,12 @@ const ( keyTags key = "Tags" keyPolicy key = "Policy" keyCors key = "Cors" + keyWebsite key = "Website" keyBucketLock key = "Bucketlock" keyObjRetention key = "Objectretention" keyObjLegalHold key = "Objectlegalhold" keyExpires key = "Vgwexpires" + keyWebsiteRedirect key = "Vgwwebsiteredirect" onameAttr key = "Objname" onameAttrLower key = "objname" metaTmpMultipartPrefix key = ".sgwtmp" + "/multipart" @@ -83,17 +85,19 @@ const ( func (key) Table() map[string]struct{} { return map[string]struct{}{ - "acl": {}, - "ownership": {}, - "tags": {}, - "policy": {}, - "bucketlock": {}, - "objectretention": {}, - "vgwexpires": {}, - "objectlegalhold": {}, - "objname": {}, - ".sgwtmp/multipart": {}, - "mpmetadata": {}, + "acl": {}, + "ownership": {}, + "tags": {}, + "policy": {}, + "bucketlock": {}, + "website": {}, + "objectretention": {}, + "vgwexpires": {}, + "vgwwebsiteredirect": {}, + "objectlegalhold": {}, + "objname": {}, + ".sgwtmp/multipart": {}, + "mpmetadata": {}, } } @@ -370,6 +374,15 @@ func (az *Azure) PutObject(ctx context.Context, po s3response.PutObjectInput) (s metadata[string(keyExpires)] = po.Expires } } + if getString(po.WebsiteRedirectLocation) != "" { + if metadata == nil { + metadata = map[string]*string{ + string(keyWebsiteRedirect): po.WebsiteRedirectLocation, + } + } else { + metadata[string(keyWebsiteRedirect)] = po.WebsiteRedirectLocation + } + } opts := &blockblob.UploadStreamOptions{ Metadata: metadata, @@ -569,22 +582,23 @@ func (az *Azure) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.G } return &s3.GetObjectOutput{ - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: blobDownloadResponse.ContentLength, - ContentEncoding: blobDownloadResponse.ContentEncoding, - ContentType: blobDownloadResponse.ContentType, - ContentDisposition: blobDownloadResponse.ContentDisposition, - ContentLanguage: blobDownloadResponse.ContentLanguage, - CacheControl: blobDownloadResponse.CacheControl, - ExpiresString: blobDownloadResponse.Metadata[string(keyExpires)], - ETag: backend.GetPtrFromString(convertAzureEtag(blobDownloadResponse.ETag)), - LastModified: blobDownloadResponse.LastModified, - Metadata: parseAndFilterAzMetadata(blobDownloadResponse.Metadata), - TagCount: &tagcount, - ContentRange: contentRange, - Body: blobDownloadResponse.Body, - StorageClass: types.StorageClassStandard, - PartsCount: partsCount, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: blobDownloadResponse.ContentLength, + ContentEncoding: blobDownloadResponse.ContentEncoding, + ContentType: blobDownloadResponse.ContentType, + ContentDisposition: blobDownloadResponse.ContentDisposition, + ContentLanguage: blobDownloadResponse.ContentLanguage, + CacheControl: blobDownloadResponse.CacheControl, + ExpiresString: blobDownloadResponse.Metadata[string(keyExpires)], + WebsiteRedirectLocation: blobDownloadResponse.Metadata[string(keyWebsiteRedirect)], + ETag: backend.GetPtrFromString(convertAzureEtag(blobDownloadResponse.ETag)), + LastModified: blobDownloadResponse.LastModified, + Metadata: parseAndFilterAzMetadata(blobDownloadResponse.Metadata), + TagCount: &tagcount, + ContentRange: contentRange, + Body: blobDownloadResponse.Body, + StorageClass: types.StorageClassStandard, + PartsCount: partsCount, }, nil } @@ -668,20 +682,21 @@ func (az *Azure) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3 } result := &s3.HeadObjectOutput{ - ContentRange: contentRange, - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: &length, - PartsCount: partsCount, - ContentType: resp.ContentType, - ContentEncoding: resp.ContentEncoding, - ContentLanguage: resp.ContentLanguage, - ContentDisposition: resp.ContentDisposition, - CacheControl: resp.CacheControl, - ExpiresString: resp.Metadata[string(keyExpires)], - ETag: backend.GetPtrFromString(convertAzureEtag(resp.ETag)), - LastModified: resp.LastModified, - Metadata: parseAndFilterAzMetadata(resp.Metadata), - StorageClass: types.StorageClassStandard, + ContentRange: contentRange, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: &length, + PartsCount: partsCount, + ContentType: resp.ContentType, + ContentEncoding: resp.ContentEncoding, + ContentLanguage: resp.ContentLanguage, + ContentDisposition: resp.ContentDisposition, + CacheControl: resp.CacheControl, + ExpiresString: resp.Metadata[string(keyExpires)], + WebsiteRedirectLocation: resp.Metadata[string(keyWebsiteRedirect)], + ETag: backend.GetPtrFromString(convertAzureEtag(resp.ETag)), + LastModified: resp.LastModified, + Metadata: parseAndFilterAzMetadata(resp.Metadata), + StorageClass: types.StorageClassStandard, } status, ok := resp.Metadata[string(keyObjLegalHold)] @@ -1191,6 +1206,9 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu if getString(input.Expires) != "" { meta[string(keyExpires)] = *input.Expires } + if getString(input.WebsiteRedirectLocation) != "" { + meta[string(keyWebsiteRedirect)] = *input.WebsiteRedirectLocation + } // Set object metadata _, err = dstClient.SetMetadata(ctx, parseMetadata(meta), nil) if err != nil { @@ -1271,6 +1289,7 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu ContentLanguage: input.ContentLanguage, CacheControl: input.CacheControl, Expires: input.Expires, + WebsiteRedirectLocation: input.WebsiteRedirectLocation, Metadata: input.Metadata, ObjectLockRetainUntilDate: input.ObjectLockRetainUntilDate, ObjectLockMode: input.ObjectLockMode, @@ -1286,6 +1305,7 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu pInput.ContentLanguage = downloadResp.ContentLanguage pInput.ContentType = downloadResp.ContentType pInput.Metadata = parseAzMetadata(downloadResp.Metadata) + delete(pInput.Metadata, string(keyWebsiteRedirect)) } if input.TaggingDirective == types.TaggingDirectiveReplace { @@ -1395,6 +1415,9 @@ func (az *Azure) CreateMultipartUpload(ctx context.Context, input s3response.Cre if getString(input.Expires) != "" { meta[string(keyExpires)] = input.Expires } + if getString(input.WebsiteRedirectLocation) != "" { + meta[string(keyWebsiteRedirect)] = input.WebsiteRedirectLocation + } // parse object tags tags, err := backend.ParseObjectTags(getString(input.Tagging)) @@ -1994,6 +2017,40 @@ func (az *Azure) DeleteBucketCors(ctx context.Context, bucket string) error { return az.PutBucketCors(ctx, bucket, nil) } +func (az *Azure) PutBucketWebsite(ctx context.Context, bucket string, website []byte) error { + if website == nil { + return az.deleteContainerMetaData(ctx, bucket, string(keyWebsite)) + } + + encoded, err := backend.MarshalWebsiteConfig(website, true) + if err != nil { + return err + } + + return az.setContainerMetaData(ctx, bucket, string(keyWebsite), encoded) +} + +func (az *Azure) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, error) { + website, err := az.getContainerMetaData(ctx, bucket, string(keyWebsite)) + if err != nil { + return nil, err + } + if len(website) == 0 { + return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket) + } + + decoded, err := backend.UnmarshalWebsiteConfig(website, true) + if err != nil { + return nil, err + } + + return decoded, nil +} + +func (az *Azure) DeleteBucketWebsite(ctx context.Context, bucket string) error { + return az.PutBucketWebsite(ctx, bucket, nil) +} + func (az *Azure) PutObjectLockConfiguration(ctx context.Context, bucket string, config []byte) error { return az.setContainerMetaData(ctx, bucket, string(keyBucketLock), config) } diff --git a/backend/backend.go b/backend/backend.go index 021bca2e..9b992955 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -50,6 +50,9 @@ type Backend interface { PutBucketCors(_ context.Context, bucket string, cors []byte) error GetBucketCors(_ context.Context, bucket string) ([]byte, error) DeleteBucketCors(_ context.Context, bucket string) error + PutBucketWebsite(_ context.Context, bucket string, website []byte) error + GetBucketWebsite(_ context.Context, bucket string) ([]byte, error) + DeleteBucketWebsite(_ context.Context, bucket string) error // multipart operations CreateMultipartUpload(context.Context, s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error) @@ -166,6 +169,15 @@ func (BackendUnsupported) GetBucketCors(_ context.Context, bucket string) ([]byt func (BackendUnsupported) DeleteBucketCors(_ context.Context, bucket string) error { return s3err.GetAPIError(s3err.ErrNotImplemented) } +func (BackendUnsupported) PutBucketWebsite(_ context.Context, bucket string, website []byte) error { + return s3err.GetAPIError(s3err.ErrNotImplemented) +} +func (BackendUnsupported) GetBucketWebsite(_ context.Context, bucket string) ([]byte, error) { + return nil, s3err.GetAPIError(s3err.ErrNotImplemented) +} +func (BackendUnsupported) DeleteBucketWebsite(_ context.Context, bucket string) error { + return s3err.GetAPIError(s3err.ErrNotImplemented) +} func (BackendUnsupported) CreateMultipartUpload(context.Context, s3response.CreateMultipartUploadInput) (s3response.InitiateMultipartUploadResult, error) { return s3response.InitiateMultipartUploadResult{}, s3err.GetAPIError(s3err.ErrNotImplemented) diff --git a/backend/common.go b/backend/common.go index baa22307..01dfd40a 100644 --- a/backend/common.go +++ b/backend/common.go @@ -436,7 +436,7 @@ func MarshalMpUploadMetadata(mpMeta MpUploadMetadata, base64Encode bool) ([]byte return nil, fmt.Errorf("marshal mp metadata: %w", err) } - compressed, err := compressMpUploadMetadata(mpMetaJSON) + compressed, err := CompressData(mpMetaJSON) if err != nil { return nil, fmt.Errorf("compress mp metadata: %w", err) } @@ -471,7 +471,43 @@ func UnmarshalMpUploadMetadata(data []byte, base64Decode bool) (MpUploadMetadata return mpMeta, nil } -func compressMpUploadMetadata(data []byte) ([]byte, error) { +// MarshalWebsiteConfig returns a compressed representation of a website +// configuration. When base64Encode is true, the compressed bytes are +// base64-encoded so they can be stored in azure string-only metadata values. +func MarshalWebsiteConfig(website []byte, base64Encode bool) ([]byte, error) { + compressed, err := CompressData(website) + if err != nil { + return nil, fmt.Errorf("compress website config: %w", err) + } + + if !base64Encode { + return compressed, nil + } + + encoded := make([]byte, base64.StdEncoding.EncodedLen(len(compressed))) + base64.StdEncoding.Encode(encoded, compressed) + return encoded, nil +} + +// UnmarshalWebsiteConfig decodes data produced by MarshalWebsiteConfig. +func UnmarshalWebsiteConfig(data []byte, base64Decode bool) ([]byte, error) { + if base64Decode { + compressed, err := base64.StdEncoding.DecodeString(string(data)) + if err != nil { + return nil, fmt.Errorf("decode website config: %w", err) + } + data = compressed + } + + website, err := DecompressData(data) + if err != nil { + return nil, fmt.Errorf("decompress website config: %w", err) + } + + return website, nil +} + +func CompressData(data []byte) ([]byte, error) { var compressed bytes.Buffer gz := gzip.NewWriter(&compressed) if _, err := gz.Write(data); err != nil { @@ -484,19 +520,28 @@ func compressMpUploadMetadata(data []byte) ([]byte, error) { return compressed.Bytes(), nil } -func unmarshalCompressedMpUploadMetadata(compressed []byte) (MpUploadMetadata, error) { - var mpMeta MpUploadMetadata - gz, err := gzip.NewReader(bytes.NewReader(compressed)) +func DecompressData(data []byte) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { - return mpMeta, fmt.Errorf("decompress mp metadata: %w", err) + return nil, err } decompressed, err := io.ReadAll(gz) closeErr := gz.Close() if err != nil { - return mpMeta, fmt.Errorf("decompress mp metadata: %w", err) + return nil, err } if closeErr != nil { - return mpMeta, fmt.Errorf("decompress mp metadata: %w", closeErr) + return nil, closeErr + } + + return decompressed, nil +} + +func unmarshalCompressedMpUploadMetadata(compressed []byte) (MpUploadMetadata, error) { + var mpMeta MpUploadMetadata + decompressed, err := DecompressData(compressed) + if err != nil { + return mpMeta, fmt.Errorf("decompress mp metadata: %w", err) } if err := json.Unmarshal(decompressed, &mpMeta); err != nil { diff --git a/backend/common_test.go b/backend/common_test.go index fa69eb66..a0307471 100644 --- a/backend/common_test.go +++ b/backend/common_test.go @@ -107,6 +107,53 @@ func TestUnmarshalMpUploadMetadataInvalid(t *testing.T) { } } +func TestWebsiteConfigRawGzipRoundTrip(t *testing.T) { + want := []byte(`index.html`) + + stored, err := MarshalWebsiteConfig(want, false) + if err != nil { + t.Fatalf("MarshalWebsiteConfig: %v", err) + } + if len(stored) < 2 || stored[0] != 0x1f || stored[1] != 0x8b { + t.Fatalf("stored website config should contain raw gzip payload: %q", stored) + } + + got, err := UnmarshalWebsiteConfig(stored, false) + if err != nil { + t.Fatalf("UnmarshalWebsiteConfig: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("website config mismatch: got %q want %q", got, want) + } +} + +func TestWebsiteConfigBase64RoundTrip(t *testing.T) { + want := []byte(`example.com`) + + stored, err := MarshalWebsiteConfig(want, true) + if err != nil { + t.Fatalf("MarshalWebsiteConfig: %v", err) + } + if len(stored) >= 2 && stored[0] == 0x1f && stored[1] == 0x8b { + t.Fatalf("stored website config should not contain raw gzip bytes: %q", stored) + } + + got, err := UnmarshalWebsiteConfig(stored, true) + if err != nil { + t.Fatalf("UnmarshalWebsiteConfig: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("website config mismatch: got %q want %q", got, want) + } +} + +func TestUnmarshalWebsiteConfigInvalid(t *testing.T) { + _, err := UnmarshalWebsiteConfig([]byte("not-gzip"), false) + if err == nil { + t.Fatal("expected invalid website config error") + } +} + func TestParseCopySource(t *testing.T) { tests := []struct { name string diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 9bff308a..b8073463 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -122,6 +122,7 @@ const ( contentDispHdr = "content-disposition" cacheCtrlHdr = "cache-control" expiresHdr = "expires" + websiteRedirectHdr = "website-redirect-location" emptyMD5 = "\"d41d8cd98f00b204e9800998ecf8427e\"" aclkey = "acl" ownershipkey = "ownership" @@ -132,6 +133,7 @@ const ( objectRetentionKey = "object-retention" objectLegalHoldKey = "object-legal-hold" corskey = "cors" + websitekey = "website" versioningKey = "versioning" deleteMarkerKey = "delete-marker" versionIdKey = "version-id" @@ -1548,13 +1550,14 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create err = p.storeObjectMetaProperties(nil, bucket, filepath.Join(objdir, uploadID), metaProperties{ - ContentType: mpu.ContentType, - ContentEncoding: mpu.ContentEncoding, - ContentDisposition: mpu.ContentDisposition, - ContentLanguage: mpu.ContentLanguage, - CacheControl: mpu.CacheControl, - Expires: mpu.Expires, - Metadata: mpu.Metadata, + ContentType: mpu.ContentType, + ContentEncoding: mpu.ContentEncoding, + ContentDisposition: mpu.ContentDisposition, + ContentLanguage: mpu.ContentLanguage, + CacheControl: mpu.CacheControl, + Expires: mpu.Expires, + WebsiteRedirectLocation: mpu.WebsiteRedirectLocation, + Metadata: mpu.Metadata, }) if err != nil { // cleanup object if returning error @@ -2402,13 +2405,14 @@ func (p *Posix) checkUploadIDExists(bucket, object, uploadID string) ([32]byte, } type metaProperties struct { - ContentType *string - ContentEncoding *string - ContentDisposition *string - ContentLanguage *string - CacheControl *string - Expires *string - Metadata map[string]string + ContentType *string + ContentEncoding *string + ContentDisposition *string + ContentLanguage *string + CacheControl *string + Expires *string + WebsiteRedirectLocation *string + Metadata map[string]string } // loadObjectMetadata loads the given object metadata, if it fails to load @@ -2507,6 +2511,11 @@ func (p *Posix) loadObjectMetaProperties(f *os.File, bucket, object string, fi * result.Expires = backend.GetPtrFromString(string(b)) } + b, err = p.meta.RetrieveAttribute(f, bucket, object, websiteRedirectHdr) + if err == nil { + result.WebsiteRedirectLocation = backend.GetPtrFromString(string(b)) + } + result.Metadata = p.loadObjectMetadata(f, bucket, object) return result @@ -2586,6 +2595,12 @@ func (p *Posix) storeObjectMetaProperties(f *os.File, bucket, object string, m m return fmt.Errorf("set expires: %w", err) } } + if getString(m.WebsiteRedirectLocation) != "" { + err := p.meta.StoreAttribute(f, bucket, object, websiteRedirectHdr, []byte(*m.WebsiteRedirectLocation)) + if err != nil { + return fmt.Errorf("set website-redirect-location: %w", err) + } + } if m.Metadata != nil { err := p.storeObjectMetadata(f, bucket, object, m.Metadata) if err != nil { @@ -3681,6 +3696,14 @@ func (p *Posix) PutObjectWithPostFunc(ctx context.Context, po s3response.PutObje return s3response.PutObjectOutput{}, fmt.Errorf("set content-type attr: %w", err) } + if getString(po.WebsiteRedirectLocation) != "" { + err = p.meta.StoreAttribute(nil, *po.Bucket, *po.Key, websiteRedirectHdr, + []byte(*po.WebsiteRedirectLocation)) + if err != nil { + return s3response.PutObjectOutput{}, fmt.Errorf("set website-redirect-location attr: %w", err) + } + } + expectedSum := getEmptyChecksumValue(checksumAlgorithm) if checksumValue != "" && expectedSum != checksumValue { return s3response.PutObjectOutput{}, s3err.GetChecksumBadDigestErr(checksumAlgorithm) @@ -3878,13 +3901,14 @@ func (p *Posix) PutObjectWithPostFunc(ctx context.Context, po s3response.PutObje err = p.storeObjectMetaProperties(f.File(), *po.Bucket, *po.Key, metaProperties{ - ContentType: po.ContentType, - ContentEncoding: po.ContentEncoding, - ContentLanguage: po.ContentLanguage, - ContentDisposition: po.ContentDisposition, - CacheControl: po.CacheControl, - Expires: po.Expires, - Metadata: po.Metadata, + ContentType: po.ContentType, + ContentEncoding: po.ContentEncoding, + ContentLanguage: po.ContentLanguage, + ContentDisposition: po.ContentDisposition, + CacheControl: po.CacheControl, + Expires: po.Expires, + WebsiteRedirectLocation: po.WebsiteRedirectLocation, + Metadata: po.Metadata, }) if err != nil { return s3response.PutObjectOutput{}, err @@ -4582,32 +4606,33 @@ func (p *Posix) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.Ge var length int64 = 0 return &s3.GetObjectOutput{ - ChecksumCRC32: checksums.CRC32, - ChecksumCRC32C: checksums.CRC32C, - ChecksumSHA1: checksums.SHA1, - ChecksumSHA256: checksums.SHA256, - ChecksumCRC64NVME: checksums.CRC64NVME, - ChecksumSHA512: checksums.SHA512, - ChecksumMD5: checksums.MD5, - ChecksumXXHASH64: checksums.XXHASH64, - ChecksumXXHASH3: checksums.XXHASH3, - ChecksumXXHASH128: checksums.XXHASH128, - ChecksumType: checksums.Type, - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: &length, - ContentEncoding: objMeta.ContentEncoding, - ContentType: objMeta.ContentType, - ContentLanguage: objMeta.ContentLanguage, - ContentDisposition: objMeta.ContentDisposition, - CacheControl: objMeta.CacheControl, - ExpiresString: objMeta.Expires, - ETag: &etag, - LastModified: backend.GetTimePtr(fid.ModTime()), - Metadata: objMeta.Metadata, - TagCount: tagCount, - ContentRange: nil, - StorageClass: types.StorageClassStandard, - VersionId: &versionId, + ChecksumCRC32: checksums.CRC32, + ChecksumCRC32C: checksums.CRC32C, + ChecksumSHA1: checksums.SHA1, + ChecksumSHA256: checksums.SHA256, + ChecksumCRC64NVME: checksums.CRC64NVME, + ChecksumSHA512: checksums.SHA512, + ChecksumMD5: checksums.MD5, + ChecksumXXHASH64: checksums.XXHASH64, + ChecksumXXHASH3: checksums.XXHASH3, + ChecksumXXHASH128: checksums.XXHASH128, + ChecksumType: checksums.Type, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: &length, + ContentEncoding: objMeta.ContentEncoding, + ContentType: objMeta.ContentType, + ContentLanguage: objMeta.ContentLanguage, + ContentDisposition: objMeta.ContentDisposition, + CacheControl: objMeta.CacheControl, + ExpiresString: objMeta.Expires, + WebsiteRedirectLocation: objMeta.WebsiteRedirectLocation, + ETag: &etag, + LastModified: backend.GetTimePtr(fid.ModTime()), + Metadata: objMeta.Metadata, + TagCount: tagCount, + ContentRange: nil, + StorageClass: types.StorageClassStandard, + VersionId: &versionId, }, nil } @@ -4734,34 +4759,35 @@ func (p *Posix) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.Ge } return &s3.GetObjectOutput{ - AcceptRanges: backend.GetPtrFromString("bytes"), - ContentLength: &length, - ContentEncoding: objMeta.ContentEncoding, - ContentType: objMeta.ContentType, - ContentDisposition: objMeta.ContentDisposition, - ContentLanguage: objMeta.ContentLanguage, - CacheControl: objMeta.CacheControl, - ExpiresString: objMeta.Expires, - ETag: &etag, - LastModified: backend.GetTimePtr(fi.ModTime()), - Metadata: objMeta.Metadata, - TagCount: tagCount, - ContentRange: contentRange, - StorageClass: types.StorageClassStandard, - VersionId: &versionId, - Body: body, - ChecksumCRC32: checksums.CRC32, - ChecksumCRC32C: checksums.CRC32C, - ChecksumSHA1: checksums.SHA1, - ChecksumSHA256: checksums.SHA256, - ChecksumCRC64NVME: checksums.CRC64NVME, - ChecksumSHA512: checksums.SHA512, - ChecksumMD5: checksums.MD5, - ChecksumXXHASH64: checksums.XXHASH64, - ChecksumXXHASH3: checksums.XXHASH3, - ChecksumXXHASH128: checksums.XXHASH128, - ChecksumType: checksums.Type, - PartsCount: partsCount, + AcceptRanges: backend.GetPtrFromString("bytes"), + ContentLength: &length, + ContentEncoding: objMeta.ContentEncoding, + ContentType: objMeta.ContentType, + ContentDisposition: objMeta.ContentDisposition, + ContentLanguage: objMeta.ContentLanguage, + CacheControl: objMeta.CacheControl, + ExpiresString: objMeta.Expires, + WebsiteRedirectLocation: objMeta.WebsiteRedirectLocation, + ETag: &etag, + LastModified: backend.GetTimePtr(fi.ModTime()), + Metadata: objMeta.Metadata, + TagCount: tagCount, + ContentRange: contentRange, + StorageClass: types.StorageClassStandard, + VersionId: &versionId, + Body: body, + ChecksumCRC32: checksums.CRC32, + ChecksumCRC32C: checksums.CRC32C, + ChecksumSHA1: checksums.SHA1, + ChecksumSHA256: checksums.SHA256, + ChecksumCRC64NVME: checksums.CRC64NVME, + ChecksumSHA512: checksums.SHA512, + ChecksumMD5: checksums.MD5, + ChecksumXXHASH64: checksums.XXHASH64, + ChecksumXXHASH3: checksums.XXHASH3, + ChecksumXXHASH128: checksums.XXHASH128, + ChecksumType: checksums.Type, + PartsCount: partsCount, }, nil } @@ -5011,6 +5037,7 @@ func (p *Posix) HeadObject(ctx context.Context, input *s3.HeadObjectInput) (*s3. ContentLanguage: objMeta.ContentLanguage, CacheControl: objMeta.CacheControl, ExpiresString: objMeta.Expires, + WebsiteRedirectLocation: objMeta.WebsiteRedirectLocation, ETag: &etag, LastModified: backend.GetTimePtr(fi.ModTime()), Metadata: objMeta.Metadata, @@ -5325,17 +5352,26 @@ func (p *Posix) CopyObject(ctx context.Context, input s3response.CopyObjectInput // Store the provided object meta properties err = p.storeObjectMetaProperties(nil, dstBucket, dstObject, metaProperties{ - ContentType: input.ContentType, - ContentEncoding: input.ContentEncoding, - ContentLanguage: input.ContentLanguage, - ContentDisposition: input.ContentDisposition, - CacheControl: input.CacheControl, - Expires: input.Expires, - Metadata: input.Metadata, + ContentType: input.ContentType, + ContentEncoding: input.ContentEncoding, + ContentLanguage: input.ContentLanguage, + ContentDisposition: input.ContentDisposition, + CacheControl: input.CacheControl, + Expires: input.Expires, + WebsiteRedirectLocation: input.WebsiteRedirectLocation, + Metadata: input.Metadata, }) if err != nil { return s3response.CopyObjectOutput{}, err } + // explicitly delete the website redirect location, as if it's not + // provided as CopyObject input, it should not be copied + if getString(input.WebsiteRedirectLocation) == "" { + err := p.meta.DeleteAttribute(dstBucket, dstObject, websiteRedirectHdr) + if err != nil && !errors.Is(err, meta.ErrNoSuchKey) { + return s3response.CopyObjectOutput{}, fmt.Errorf("delete website-redirect-location: %w", err) + } + } if input.TaggingDirective == types.TaggingDirectiveReplace { tags, err := backend.ParseObjectTags(getString(input.Tagging)) @@ -5374,6 +5410,7 @@ func (p *Posix) CopyObject(ctx context.Context, input s3response.CopyObjectInput ContentLanguage: input.ContentLanguage, CacheControl: input.CacheControl, Expires: input.Expires, + WebsiteRedirectLocation: input.WebsiteRedirectLocation, Metadata: input.Metadata, ObjectLockRetainUntilDate: input.ObjectLockRetainUntilDate, ObjectLockMode: input.ObjectLockMode, @@ -6168,6 +6205,89 @@ func (p *Posix) DeleteBucketCors(ctx context.Context, bucket string) error { return p.PutBucketCors(ctx, bucket, nil) } +func (p *Posix) PutBucketWebsite(ctx context.Context, bucket string, website []byte) error { + release, err := p.acquireActionSlot(ctx) + if err != nil { + return err + } + defer release() + + if !p.isBucketValid(bucket) { + return s3err.GetAPIError(s3err.ErrInvalidBucketName) + } + _, err = os.Stat(bucket) + if errors.Is(err, fs.ErrNotExist) { + return s3err.GetAPIError(s3err.ErrNoSuchBucket) + } + if err != nil { + return fmt.Errorf("stat bucket: %w", err) + } + + if website == nil { + err = p.meta.DeleteAttribute(bucket, "", websitekey) + if err != nil && !errors.Is(err, meta.ErrNoSuchKey) { + return fmt.Errorf("remove website: %w", err) + } + + return nil + } + + // 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) + } + + return nil +} + +func (p *Posix) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, error) { + release, err := p.acquireActionSlot(ctx) + if err != nil { + return nil, err + } + defer release() + + if !p.isBucketValid(bucket) { + return nil, s3err.GetAPIError(s3err.ErrInvalidBucketName) + } + _, err = os.Stat(bucket) + if errors.Is(err, fs.ErrNotExist) { + return nil, s3err.GetAPIError(s3err.ErrNoSuchBucket) + } + if err != nil { + return nil, fmt.Errorf("stat bucket: %w", err) + } + + website, err := p.meta.RetrieveAttribute(nil, bucket, "", websitekey) + if errors.Is(err, meta.ErrNoSuchKey) { + return nil, s3err.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket) + } + if err != nil { + return nil, err + } + + 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 { + if !p.isBucketValid(bucket) { + return s3err.GetAPIError(s3err.ErrInvalidBucketName) + } + return p.PutBucketWebsite(ctx, bucket, nil) +} + func (p *Posix) isBucketObjectLockEnabled(bucket string) error { cfg, err := p.meta.RetrieveAttribute(nil, bucket, "", bucketLockKey) if errors.Is(err, fs.ErrNotExist) { diff --git a/backend/s3proxy/s3.go b/backend/s3proxy/s3.go index 85a7b84e..23e287e8 100644 --- a/backend/s3proxy/s3.go +++ b/backend/s3proxy/s3.go @@ -37,9 +37,10 @@ import ( type metaPrefix string const ( - metaPrefixAcl metaPrefix = "vgw-meta-acl-" - metaPrefixPolicy metaPrefix = "vgw-meta-policy-" - metaPrefixCors metaPrefix = "vgw-meta-cors-" + metaPrefixAcl metaPrefix = "vgw-meta-acl-" + metaPrefixPolicy metaPrefix = "vgw-meta-policy-" + metaPrefixCors metaPrefix = "vgw-meta-cors-" + metaPrefixWebsite metaPrefix = "vgw-meta-website-" ) type S3Proxy struct { @@ -1617,6 +1618,32 @@ func (s *S3Proxy) DeleteBucketCors(ctx context.Context, bucket string) error { return nil } +func (s *S3Proxy) PutBucketWebsite(ctx context.Context, bucket string, website []byte) error { + return handleError(s.putMetaBucketObj(ctx, bucket, website, metaPrefixWebsite)) +} + +func (s *S3Proxy) GetBucketWebsite(ctx context.Context, bucket string) ([]byte, error) { + data, err := s.getMetaBucketObjData(ctx, bucket, metaPrefixWebsite, false) + if err != nil { + return nil, handleError(err) + } + + return data, nil +} + +func (s *S3Proxy) DeleteBucketWebsite(ctx context.Context, bucket string) error { + key := getMetaKey(bucket, metaPrefixWebsite) + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &s.metaBucket, + Key: &key, + }) + if err != nil && !areErrSame(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) { + return handleError(err) + } + + return nil +} + func (s *S3Proxy) PutBucketPolicy(ctx context.Context, bucket string, policy []byte) error { return handleError(s.putMetaBucketObj(ctx, bucket, policy, metaPrefixPolicy)) } @@ -1735,7 +1762,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) @@ -1749,7 +1776,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 @@ -1766,15 +1793,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.GetBucketErr(s3err.ErrNoSuchWebsiteConfiguration, bucket) } return []byte{}, nil diff --git a/chart/README.md b/chart/README.md index 808cea93..2194b5f6 100644 --- a/chart/README.md +++ b/chart/README.md @@ -83,6 +83,7 @@ The `gateway.backend.type` value selects the storage backend. Use `gateway.backe | **HTTPRoute** | `httpRoute.enabled=true` — Gateway API successor to Ingress for S3 API; also `admin.httpRoute.enabled=true` and `webui.httpRoute.enabled=true` to expose the admin API and/or WebUI | | **Admin API** | `admin.enabled=true` — exposes a separate management API on `admin.port` (default `7071`) | | **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints | +| **Website Hosting** | `website.enabled=true` — static website hosting endpoint on `website.port` (default `8090`); optionally set `website.domain` for virtual-host routing (e.g. `example.com`), or omit it for catch-all mode where the full hostname is the bucket name | | **IAM** | `iam.enabled=true` — flat-file identity and access management stored alongside backend data | | **Persistence** | `persistence.enabled=true` — provisions a PVC for backend data and IAM storage; defaults to `10Gi`, or uses a hostPath volume specified by `persistence.hostPath` | | **NetworkPolicy** | `networkPolicy.enabled=true` — restricts ingress to selected pods/namespaces; allows all egress | diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index a25663b9..d69dd55b 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -129,6 +129,17 @@ spec: value: {{ .Values.webui.adminGateways | join "," | quote }} {{- end }} {{- end }} + # Website Hosting + {{- if .Values.website.enabled }} + - name: VGW_WEBSITE_PORT + value: ":{{ .Values.website.port }}" + - name: VGW_WEBSITE_DOMAIN + value: {{ .Values.website.domain | quote }} + {{- if .Values.website.noTls }} + - name: VGW_WEBSITE_NO_TLS + value: "true" + {{- end }} + {{- end }} {{- if .Values.iam.enabled }} # IAM settings {{- if eq .Values.iam.type "internal" }} @@ -173,6 +184,11 @@ spec: containerPort: {{ .Values.webui.port }} protocol: TCP {{- end }} + {{- if .Values.website.enabled }} + - name: website + containerPort: {{ .Values.website.port }} + protocol: TCP + {{- end }} readinessProbe: httpGet: path: "/_/health" diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml index c9b70532..ab80edf8 100644 --- a/chart/templates/service.yaml +++ b/chart/templates/service.yaml @@ -23,5 +23,11 @@ spec: protocol: TCP name: webui {{- end }} + {{- if .Values.website.enabled }} + - port: {{ .Values.website.port }} + targetPort: website + protocol: TCP + name: website + {{- end }} selector: {{- include "versitygw.selectorLabels" . | nindent 4 }} diff --git a/chart/values.yaml b/chart/values.yaml index 1e44d8b1..04b84b88 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -225,6 +225,24 @@ webui: type: PathPrefix value: / +# --- Website Hosting --- +website: + # Enable the static website hosting endpoint. + # Serves S3 buckets as static websites with index documents, custom error + # pages, and routing rules via a separate HTTP endpoint. + enabled: false + # The port the website endpoint listens on. + port: 8090 + # Base domain for virtual-host routing. Optional. + # Host "blog." serves bucket "blog"; host "" serves + # bucket "" (apex domain support). + # When empty, catch-all mode is used: the full hostname is the bucket + # name (name buckets as FQDNs, e.g. "blog.example.com"). + domain: "" + # - example: domain: "example.com" + # Disable TLS for the website endpoint even when gateway TLS is enabled. + noTls: false + # --- IAM (Identity and Access Management) --- iam: enabled: false diff --git a/cmd/versitygw/main.go b/cmd/versitygw/main.go index 8b2d8355..bed7ab0f 100644 --- a/cmd/versitygw/main.go +++ b/cmd/versitygw/main.go @@ -91,6 +91,10 @@ var ( webuiAdminGateways []string webuiPathPrefix string webuiS3Prefix string + websitePorts []string + websiteDomain string + websiteCertFile, websiteKeyFile string + websiteNoTLS bool disableACLs bool mpMaxParts int copyObjectThreshold int64 @@ -152,6 +156,7 @@ documentation can be found in the GitHub wiki.`, webuiGateways = ctx.StringSlice("webui-gateways") webuiAdminGateways = ctx.StringSlice("webui-admin-gateways") webuiPathPrefix = ctx.String("webui-path-prefix") + websitePorts = ctx.StringSlice("website") // Resolve relative UNIX socket paths to absolute before any backend // (e.g. posix) can change the working directory via os.Chdir. @@ -165,6 +170,9 @@ documentation can be found in the GitHub wiki.`, if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil { return err } + if websitePorts, err = utils.AbsSocketPaths(websitePorts); err != nil { + return err + } return nil }, Action: func(ctx *cli.Context) error { @@ -240,6 +248,35 @@ func initFlags() []cli.Flag { EnvVars: []string{"VGW_WEBUI_S3_PREFIX"}, Destination: &webuiS3Prefix, }, + &cli.StringSliceFlag{ + Name: "website", + Usage: "enable static website hosting endpoint on the specified listen address (e.g. ':8080'; same forms as --port; can be specified multiple times; requires --website-domain)", + EnvVars: []string{"VGW_WEBSITE_PORT"}, + }, + &cli.StringFlag{ + Name: "website-domain", + Usage: "base domain for website virtual-host routing (e.g. 'example.com'); host 'blog.example.com' serves bucket 'blog', host 'example.com' serves bucket 'example.com'; when omitted the full hostname is used as the bucket name (catch-all mode, buckets named as FQDNs)", + EnvVars: []string{"VGW_WEBSITE_DOMAIN"}, + Destination: &websiteDomain, + }, + &cli.StringFlag{ + Name: "website-cert", + Usage: "TLS cert file for website endpoint (defaults to --cert value when website is enabled)", + EnvVars: []string{"VGW_WEBSITE_CERT"}, + Destination: &websiteCertFile, + }, + &cli.StringFlag{ + Name: "website-key", + Usage: "TLS key file for website endpoint (defaults to --key value when website is enabled)", + EnvVars: []string{"VGW_WEBSITE_KEY"}, + Destination: &websiteKeyFile, + }, + &cli.BoolFlag{ + Name: "website-no-tls", + Usage: "disable TLS for website endpoint even if TLS is configured for the gateway", + EnvVars: []string{"VGW_WEBSITE_NO_TLS"}, + Destination: &websiteNoTLS, + }, &cli.StringFlag{ Name: "access", Usage: "root user access key", @@ -877,6 +914,11 @@ func runGateway(ctx context.Context, be backend.Backend) error { WebuiAdminGateways: webuiAdminGateways, WebuiPathPrefix: webuiPathPrefix, WebuiS3Prefix: webuiS3Prefix, + WebsitePorts: websitePorts, + WebsiteDomain: websiteDomain, + WebsiteCertFile: websiteCertFile, + WebsiteKeyFile: websiteKeyFile, + WebsiteNoTLS: websiteNoTLS, SigHup: sigHup, Version: Version, Build: Build, diff --git a/cmd/versitygw/test.go b/cmd/versitygw/test.go index 2c885e30..9bdffd8e 100644 --- a/cmd/versitygw/test.go +++ b/cmd/versitygw/test.go @@ -16,6 +16,7 @@ package main import ( "fmt" + "strings" "github.com/urfave/cli/v2" "github.com/versity/versitygw/tests/integration" @@ -25,6 +26,9 @@ var ( awsID string awsSecret string endpoint string + websiteSchemeTest string + websiteDomainTest string + websitePortTest string prefix string dstBucket string partSize int64 @@ -38,9 +42,9 @@ var ( checksumDisable bool versioningEnabled bool azureTests bool - sidecarTests bool tlsStatus bool parallel bool + sidecarTests bool ) func testCommand() *cli.Command { @@ -131,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", @@ -325,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{ diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go index 524700ea..ae01cbeb 100644 --- a/embedgw/embedgw.go +++ b/embedgw/embedgw.go @@ -42,6 +42,7 @@ import ( "github.com/versity/versitygw/s3api/utils" "github.com/versity/versitygw/s3event" "github.com/versity/versitygw/s3log" + "github.com/versity/versitygw/website" "github.com/versity/versitygw/webui" ) @@ -423,6 +424,29 @@ type Config struct { // endpoint. WebuiS3Prefix string + // Static website hosting endpoint + // + // WebsitePorts is the list of listening addresses for the static website + // hosting endpoint. Accepts the same formats as Ports. When empty, the + // website endpoint is disabled. + WebsitePorts []string + // WebsiteDomain is the base domain for website virtual-host routing. For + // example, host "blog.example.com" serves bucket "blog" when this is + // "example.com". When empty, the full request hostname is used as the + // bucket name. + WebsiteDomain string + // WebsiteCertFile is the path to the TLS certificate for the website + // endpoint. When empty and gateway TLS (CertFile/KeyFile) is configured, + // the website endpoint inherits those certs. Both WebsiteCertFile and + // WebsiteKeyFile must be provided together. + WebsiteCertFile string + // WebsiteKeyFile is the path to the TLS private key for the website + // endpoint. + WebsiteKeyFile string + // WebsiteNoTLS forces the website endpoint to use plain HTTP even when TLS + // certificates are available. + WebsiteNoTLS bool + // SigHup is an optional channel that signals the gateway to reload TLS // certificates and rotate log files (equivalent to SIGHUP). When nil, // this feature is disabled. @@ -514,7 +538,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; %s\n", suggestion) } - if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts); err != nil { + if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts, cfg.WebsitePorts); err != nil { return err } @@ -882,6 +906,60 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { }, webOpts...) } + var wsSrv *website.Server + wsTLSCert := "" + wsTLSKey := "" + if len(cfg.WebsitePorts) > 0 { + for _, addr := range cfg.WebsitePorts { + if utils.IsUnixSocketPath(addr) { + continue + } + _, wsPrt, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("website listen address must be in the form ':port' or 'host:port': %w", err) + } + wsPortNum, err := strconv.Atoi(wsPrt) + if err != nil { + return fmt.Errorf("website port must be a number: %w", err) + } + if wsPortNum < 0 || wsPortNum > 65535 { + return fmt.Errorf("website port must be between 0 and 65535") + } + } + + var wsOpts []website.Option + if !cfg.WebsiteNoTLS { + wsTLSCert = cfg.WebsiteCertFile + wsTLSKey = cfg.WebsiteKeyFile + if wsTLSCert == "" && wsTLSKey == "" { + wsTLSCert = cfg.CertFile + wsTLSKey = cfg.KeyFile + } + if wsTLSCert != "" || wsTLSKey != "" { + if wsTLSCert == "" { + return fmt.Errorf("website TLS key specified without cert file") + } + if wsTLSKey == "" { + return fmt.Errorf("website TLS cert specified without key file") + } + cs := utils.NewCertStorage() + if err := cs.SetCertificate(wsTLSCert, wsTLSKey); err != nil { + return fmt.Errorf("tls: load certs: %v", err) + } + wsOpts = append(wsOpts, website.WithTLS(cs)) + } + } + + if cfg.Quiet { + wsOpts = append(wsOpts, website.WithQuiet()) + } + if cfg.SocketPerm != "" { + wsOpts = append(wsOpts, website.WithSocketPerm(parsedSocketPerm)) + } + + wsSrv = website.NewServer(be, cfg.WebsiteDomain, wsOpts...) + } + if !cfg.Quiet { cfg.printBanner() } @@ -893,6 +971,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if len(cfg.WebuiPorts) > 0 { servers++ } + if len(cfg.WebsitePorts) > 0 { + servers++ + } c := make(chan error, servers) go func() { c <- srv.ServeMultiPort(cfg.Ports) }() @@ -902,6 +983,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error { if len(cfg.WebuiPorts) > 0 { go func() { c <- webSrv.ServeMultiPort(cfg.WebuiPorts) }() } + if len(cfg.WebsitePorts) > 0 { + go func() { c <- wsSrv.ServeMultiPort(cfg.WebsitePorts) }() + } // build a nil-safe sighup channel so the select below is always valid var sigHup <-chan struct{} @@ -957,6 +1041,14 @@ Loop: fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey) } } + if len(cfg.WebsitePorts) > 0 && wsTLSCert != "" && wsTLSKey != "" { + reloadErr := wsSrv.CertStorage.SetCertificate(wsTLSCert, wsTLSKey) + if reloadErr != nil { + debuglogger.InternalError(fmt.Errorf("wsSrv cert reload failed: %w", reloadErr)) + } else { + fmt.Printf("wsSrv cert reloaded (cert: %s, key: %s)\n", wsTLSCert, wsTLSKey) + } + } } } saveErr := err @@ -980,6 +1072,13 @@ Loop: } } + if wsSrv != nil { + err := wsSrv.Shutdown() + if err != nil { + fmt.Fprintf(os.Stderr, "shutdown website server: %v\n", err) + } + } + be.Shutdown() err = iam.Shutdown() @@ -1023,6 +1122,7 @@ func (cfg Config) printBanner() { ssl := cfg.CertFile != "" || cfg.KeyFile != "" admSSL := cfg.AdminCertFile != "" || cfg.AdminKeyFile != "" webuiSsl := !cfg.WebuiNoTLS && (cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "") + websiteSsl := !cfg.WebsiteNoTLS && (cfg.WebsiteCertFile != "" || cfg.WebsiteKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "") if len(cfg.Ports) == 0 { fmt.Fprintf(os.Stderr, "No ports specified\n") @@ -1243,6 +1343,68 @@ func (cfg Config) printBanner() { } } + if len(cfg.WebsitePorts) > 0 { + var allWebsiteInterfaces []string + websiteInterfaceMap := make(map[string]bool) + + for _, websiteAddr := range cfg.WebsitePorts { + if strings.TrimSpace(websiteAddr) == "" { + continue + } + if utils.IsUnixSocketPath(websiteAddr) { + if !websiteInterfaceMap[websiteAddr] { + websiteInterfaceMap[websiteAddr] = true + allWebsiteInterfaces = append(allWebsiteInterfaces, websiteAddr) + } + continue + } + websiteInterfaces, err := getMatchingIPs(websiteAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to match website port local IP addresses for %s: %v\n", websiteAddr, err) + continue + } + _, websitePrt, err := net.SplitHostPort(websiteAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to parse website port %s: %v\n", websiteAddr, err) + continue + } + for _, ip := range websiteInterfaces { + key := net.JoinHostPort(ip, websitePrt) + if !websiteInterfaceMap[key] { + websiteInterfaceMap[key] = true + allWebsiteInterfaces = append(allWebsiteInterfaces, key) + } + } + } + + if len(allWebsiteInterfaces) > 0 { + domainInfo := "" + if cfg.WebsiteDomain != "" { + domainInfo = fmt.Sprintf(" (domain: %s)", cfg.WebsiteDomain) + } + lines = append(lines, + centerText(""), + leftText("Website endpoint listening on:"+domainInfo), + ) + for _, addrPort := range allWebsiteInterfaces { + if utils.IsUnixSocketPath(addrPort) { + lines = append(lines, leftText(" unix:"+addrPort)) + continue + } + ip, prt, err := net.SplitHostPort(addrPort) + if err != nil { + continue + } + hostPort := net.JoinHostPort(ip, prt) + u := fmt.Sprintf("http://%s", hostPort) + if websiteSsl { + u = fmt.Sprintf("https://%s", hostPort) + } + lines = append(lines, leftText(" "+u)) + } + } + } + fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐") for _, line := range lines { fmt.Printf("│%-*s│\n", columnWidth-2, line) @@ -1436,13 +1598,13 @@ func sortGatewayURLs(urls []string) { } // validatePortConflicts checks for port conflicts across the S3 API, admin, -// and WebUI port lists before the servers are started. +// WebUI, and website port lists before the servers are started. // // A bare port spec (e.g. ":7071") binds to all interfaces and conflicts with // any other spec on the same port number. Two identical "ip:port" specs are // allowed and will be caught by the OS later. UNIX socket paths are checked // for duplicate path conflicts only and never conflict with TCP specs. -func validatePortConflicts(ports, admPorts, webuiPorts []string) error { +func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) error { type portSpec struct { spec string port string @@ -1504,6 +1666,23 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error { }) } + for _, p := range websitePorts { + if utils.IsUnixSocketPath(p) { + allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "website"}) + continue + } + _, port, err := net.SplitHostPort(p) + if err != nil { + continue + } + allSpecs = append(allSpecs, portSpec{ + spec: p, + port: port, + isBare: strings.HasPrefix(p, ":"), + portType: "website", + }) + } + for i, spec1 := range allSpecs { for j, spec2 := range allSpecs { if i >= j { diff --git a/embedgw/embedgw_test.go b/embedgw/embedgw_test.go index 781b14a7..a692cd7d 100644 --- a/embedgw/embedgw_test.go +++ b/embedgw/embedgw_test.go @@ -20,12 +20,13 @@ import ( func TestValidatePortConflicts(t *testing.T) { tests := []struct { - name string - ports []string - admPorts []string - webuiPorts []string - expectError bool - description string + name string + ports []string + admPorts []string + webuiPorts []string + websitePorts []string + expectError bool + description string }{ { name: "bare port conflict with bare port", @@ -115,11 +116,38 @@ func TestValidatePortConflicts(t *testing.T) { expectError: true, description: "should fail: :8080 conflicts with 127.0.0.1:8080", }, + { + name: "website bare port conflict with s3 port", + ports: []string{"127.0.0.1:8080"}, + admPorts: []string{}, + webuiPorts: []string{}, + websitePorts: []string{":8080"}, + expectError: true, + description: "should fail: website bare :8080 conflicts with s3 127.0.0.1:8080", + }, + { + name: "website no conflict", + ports: []string{":7070"}, + admPorts: []string{":8080"}, + webuiPorts: []string{":9090"}, + websitePorts: []string{":8081"}, + expectError: false, + description: "should pass: website uses a distinct port", + }, + { + name: "duplicate website unix socket conflict", + ports: []string{"/tmp/versitygw.sock"}, + admPorts: []string{}, + webuiPorts: []string{}, + websitePorts: []string{"/tmp/versitygw.sock"}, + expectError: true, + description: "should fail: duplicate unix socket path conflicts across s3 and website", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts) + err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts, tt.websitePorts) if tt.expectError && err == nil { t.Errorf("%s: expected error but got none", tt.description) } diff --git a/extra/example.conf b/extra/example.conf index caf869b2..8f2f6c1d 100644 --- a/extra/example.conf +++ b/extra/example.conf @@ -312,6 +312,53 @@ ROOT_SECRET_ACCESS_KEY= # Example: VGW_WEBUI_ADMIN_GATEWAYS=https://admin.example.com,http://192.168.1.100:7080 #VGW_WEBUI_ADMIN_GATEWAYS= +################### +# Website Hosting # +################### + +# VersityGW supports S3-compatible static website hosting on a dedicated +# endpoint, separate from the S3 API port. This mirrors how AWS serves +# websites on s3-website..amazonaws.com rather than s3.amazonaws.com. +# +# When enabled, the website endpoint serves bucket content as static websites +# using the PutBucketWebsite configuration (index documents, error documents, +# routing rules, and redirect-all). No S3 authentication is applied — the +# website endpoint is public, just like AWS S3 website hosting. +# +# Bucket resolution uses virtual-host-style routing based on the Host header: +# - With VGW_WEBSITE_DOMAIN=example.com: +# Host "blog.example.com" -> serves bucket "blog" +# Host "example.com" -> serves bucket "example.com" (apex) +# - Without VGW_WEBSITE_DOMAIN (catch-all mode): +# Host "blog.example.com" -> serves bucket "blog.example.com" +# Host "mysite.org" -> serves bucket "mysite.org" + +# The VGW_WEBSITE_PORT option enables the website hosting endpoint on the +# specified listen address. The format is the same as VGW_PORT (e.g. ':8080', +# 'localhost:8080'). Multiple ports can be specified as a comma-separated list. +# When omitted, website hosting is disabled. +#VGW_WEBSITE_PORT= + +# The VGW_WEBSITE_DOMAIN option sets the base domain for virtual-host bucket +# routing. For example, with domain "example.com", a request with Host header +# "blog.example.com" resolves to bucket "blog". When omitted, the full +# hostname from the Host header is used as the bucket name (catch-all mode), +# which is useful when buckets are named as FQDNs (e.g. "www.mysite.org"). +#VGW_WEBSITE_DOMAIN= + +# The VGW_WEBSITE_CERT and VGW_WEBSITE_KEY options specify TLS credentials +# for the website endpoint. When not set but TLS is configured for the gateway +# (VGW_CERT and VGW_KEY), the website endpoint inherits the gateway certificates. +# When neither is set, the website endpoint runs without TLS (HTTP only). +#VGW_WEBSITE_CERT= +#VGW_WEBSITE_KEY= + +# The VGW_WEBSITE_NO_TLS option disables TLS for the website endpoint even +# when TLS certificates are configured for the gateway. Set to true to force +# the website endpoint to use HTTP. This is useful when TLS termination is +# handled by a reverse proxy or load balancer. +#VGW_WEBSITE_NO_TLS=false + ####################### # Debug / Diagnostics # ####################### diff --git a/s3api/controllers/backend_moq_test.go b/s3api/controllers/backend_moq_test.go index 530df639..ba96058e 100644 --- a/s3api/controllers/backend_moq_test.go +++ b/s3api/controllers/backend_moq_test.go @@ -56,6 +56,9 @@ var _ backend.Backend = &BackendMock{} // DeleteBucketTaggingFunc: func(contextMoqParam context.Context, bucket string) error { // panic("mock out the DeleteBucketTagging method") // }, +// DeleteBucketWebsiteFunc: func(contextMoqParam context.Context, bucket string) error { +// panic("mock out the DeleteBucketWebsite method") +// }, // DeleteObjectFunc: func(contextMoqParam context.Context, deleteObjectInput *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) { // panic("mock out the DeleteObject method") // }, @@ -83,6 +86,9 @@ var _ backend.Backend = &BackendMock{} // GetBucketVersioningFunc: func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) { // panic("mock out the GetBucketVersioning method") // }, +// GetBucketWebsiteFunc: func(contextMoqParam context.Context, bucket string) ([]byte, error) { +// panic("mock out the GetBucketWebsite method") +// }, // GetObjectFunc: func(contextMoqParam context.Context, getObjectInput *s3.GetObjectInput) (*s3.GetObjectOutput, error) { // panic("mock out the GetObject method") // }, @@ -152,6 +158,9 @@ var _ backend.Backend = &BackendMock{} // PutBucketVersioningFunc: func(contextMoqParam context.Context, bucket string, status types.BucketVersioningStatus) error { // panic("mock out the PutBucketVersioning method") // }, +// PutBucketWebsiteFunc: func(contextMoqParam context.Context, bucket string, website []byte) error { +// panic("mock out the PutBucketWebsite method") +// }, // PutObjectFunc: func(contextMoqParam context.Context, putObjectInput s3response.PutObjectInput) (s3response.PutObjectOutput, error) { // panic("mock out the PutObject method") // }, @@ -228,6 +237,9 @@ type BackendMock struct { // DeleteBucketTaggingFunc mocks the DeleteBucketTagging method. DeleteBucketTaggingFunc func(contextMoqParam context.Context, bucket string) error + // DeleteBucketWebsiteFunc mocks the DeleteBucketWebsite method. + DeleteBucketWebsiteFunc func(contextMoqParam context.Context, bucket string) error + // DeleteObjectFunc mocks the DeleteObject method. DeleteObjectFunc func(contextMoqParam context.Context, deleteObjectInput *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) @@ -255,6 +267,9 @@ type BackendMock struct { // GetBucketVersioningFunc mocks the GetBucketVersioning method. GetBucketVersioningFunc func(contextMoqParam context.Context, bucket string) (s3response.GetBucketVersioningOutput, error) + // GetBucketWebsiteFunc mocks the GetBucketWebsite method. + GetBucketWebsiteFunc func(contextMoqParam context.Context, bucket string) ([]byte, error) + // GetObjectFunc mocks the GetObject method. GetObjectFunc func(contextMoqParam context.Context, getObjectInput *s3.GetObjectInput) (*s3.GetObjectOutput, error) @@ -324,6 +339,9 @@ type BackendMock struct { // PutBucketVersioningFunc mocks the PutBucketVersioning method. PutBucketVersioningFunc func(contextMoqParam context.Context, bucket string, status types.BucketVersioningStatus) error + // PutBucketWebsiteFunc mocks the PutBucketWebsite method. + PutBucketWebsiteFunc func(contextMoqParam context.Context, bucket string, website []byte) error + // PutObjectFunc mocks the PutObject method. PutObjectFunc func(contextMoqParam context.Context, putObjectInput s3response.PutObjectInput) (s3response.PutObjectOutput, error) @@ -443,6 +461,13 @@ type BackendMock struct { // Bucket is the bucket argument value. Bucket string } + // DeleteBucketWebsite holds details about calls to the DeleteBucketWebsite method. + DeleteBucketWebsite []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // Bucket is the bucket argument value. + Bucket string + } // DeleteObject holds details about calls to the DeleteObject method. DeleteObject []struct { // ContextMoqParam is the contextMoqParam argument value. @@ -510,6 +535,13 @@ type BackendMock struct { // Bucket is the bucket argument value. Bucket string } + // GetBucketWebsite holds details about calls to the GetBucketWebsite method. + GetBucketWebsite []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // Bucket is the bucket argument value. + Bucket string + } // GetObject holds details about calls to the GetObject method. GetObject []struct { // ContextMoqParam is the contextMoqParam argument value. @@ -693,6 +725,15 @@ type BackendMock struct { // Status is the status argument value. Status types.BucketVersioningStatus } + // PutBucketWebsite holds details about calls to the PutBucketWebsite method. + PutBucketWebsite []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context + // Bucket is the bucket argument value. + Bucket string + // Website is the website argument value. + Website []byte + } // PutObject holds details about calls to the PutObject method. PutObject []struct { // ContextMoqParam is the contextMoqParam argument value. @@ -801,6 +842,7 @@ type BackendMock struct { lockDeleteBucketOwnershipControls sync.RWMutex lockDeleteBucketPolicy sync.RWMutex lockDeleteBucketTagging sync.RWMutex + lockDeleteBucketWebsite sync.RWMutex lockDeleteObject sync.RWMutex lockDeleteObjectTagging sync.RWMutex lockDeleteObjects sync.RWMutex @@ -810,6 +852,7 @@ type BackendMock struct { lockGetBucketPolicy sync.RWMutex lockGetBucketTagging sync.RWMutex lockGetBucketVersioning sync.RWMutex + lockGetBucketWebsite sync.RWMutex lockGetObject sync.RWMutex lockGetObjectAcl sync.RWMutex lockGetObjectAttributes sync.RWMutex @@ -833,6 +876,7 @@ type BackendMock struct { lockPutBucketPolicy sync.RWMutex lockPutBucketTagging sync.RWMutex lockPutBucketVersioning sync.RWMutex + lockPutBucketWebsite sync.RWMutex lockPutObject sync.RWMutex lockPutObjectAcl sync.RWMutex lockPutObjectLegalHold sync.RWMutex @@ -1251,6 +1295,42 @@ func (mock *BackendMock) DeleteBucketTaggingCalls() []struct { return calls } +// DeleteBucketWebsite calls DeleteBucketWebsiteFunc. +func (mock *BackendMock) DeleteBucketWebsite(contextMoqParam context.Context, bucket string) error { + if mock.DeleteBucketWebsiteFunc == nil { + panic("BackendMock.DeleteBucketWebsiteFunc: method is nil but Backend.DeleteBucketWebsite was just called") + } + callInfo := struct { + ContextMoqParam context.Context + Bucket string + }{ + ContextMoqParam: contextMoqParam, + Bucket: bucket, + } + mock.lockDeleteBucketWebsite.Lock() + mock.calls.DeleteBucketWebsite = append(mock.calls.DeleteBucketWebsite, callInfo) + mock.lockDeleteBucketWebsite.Unlock() + return mock.DeleteBucketWebsiteFunc(contextMoqParam, bucket) +} + +// DeleteBucketWebsiteCalls gets all the calls that were made to DeleteBucketWebsite. +// Check the length with: +// +// len(mockedBackend.DeleteBucketWebsiteCalls()) +func (mock *BackendMock) DeleteBucketWebsiteCalls() []struct { + ContextMoqParam context.Context + Bucket string +} { + var calls []struct { + ContextMoqParam context.Context + Bucket string + } + mock.lockDeleteBucketWebsite.RLock() + calls = mock.calls.DeleteBucketWebsite + mock.lockDeleteBucketWebsite.RUnlock() + return calls +} + // DeleteObject calls DeleteObjectFunc. func (mock *BackendMock) DeleteObject(contextMoqParam context.Context, deleteObjectInput *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) { if mock.DeleteObjectFunc == nil { @@ -1583,6 +1663,42 @@ func (mock *BackendMock) GetBucketVersioningCalls() []struct { return calls } +// GetBucketWebsite calls GetBucketWebsiteFunc. +func (mock *BackendMock) GetBucketWebsite(contextMoqParam context.Context, bucket string) ([]byte, error) { + if mock.GetBucketWebsiteFunc == nil { + panic("BackendMock.GetBucketWebsiteFunc: method is nil but Backend.GetBucketWebsite was just called") + } + callInfo := struct { + ContextMoqParam context.Context + Bucket string + }{ + ContextMoqParam: contextMoqParam, + Bucket: bucket, + } + mock.lockGetBucketWebsite.Lock() + mock.calls.GetBucketWebsite = append(mock.calls.GetBucketWebsite, callInfo) + mock.lockGetBucketWebsite.Unlock() + return mock.GetBucketWebsiteFunc(contextMoqParam, bucket) +} + +// GetBucketWebsiteCalls gets all the calls that were made to GetBucketWebsite. +// Check the length with: +// +// len(mockedBackend.GetBucketWebsiteCalls()) +func (mock *BackendMock) GetBucketWebsiteCalls() []struct { + ContextMoqParam context.Context + Bucket string +} { + var calls []struct { + ContextMoqParam context.Context + Bucket string + } + mock.lockGetBucketWebsite.RLock() + calls = mock.calls.GetBucketWebsite + mock.lockGetBucketWebsite.RUnlock() + return calls +} + // GetObject calls GetObjectFunc. func (mock *BackendMock) GetObject(contextMoqParam context.Context, getObjectInput *s3.GetObjectInput) (*s3.GetObjectOutput, error) { if mock.GetObjectFunc == nil { @@ -2455,6 +2571,46 @@ func (mock *BackendMock) PutBucketVersioningCalls() []struct { return calls } +// PutBucketWebsite calls PutBucketWebsiteFunc. +func (mock *BackendMock) PutBucketWebsite(contextMoqParam context.Context, bucket string, website []byte) error { + if mock.PutBucketWebsiteFunc == nil { + panic("BackendMock.PutBucketWebsiteFunc: method is nil but Backend.PutBucketWebsite was just called") + } + callInfo := struct { + ContextMoqParam context.Context + Bucket string + Website []byte + }{ + ContextMoqParam: contextMoqParam, + Bucket: bucket, + Website: website, + } + mock.lockPutBucketWebsite.Lock() + mock.calls.PutBucketWebsite = append(mock.calls.PutBucketWebsite, callInfo) + mock.lockPutBucketWebsite.Unlock() + return mock.PutBucketWebsiteFunc(contextMoqParam, bucket, website) +} + +// PutBucketWebsiteCalls gets all the calls that were made to PutBucketWebsite. +// Check the length with: +// +// len(mockedBackend.PutBucketWebsiteCalls()) +func (mock *BackendMock) PutBucketWebsiteCalls() []struct { + ContextMoqParam context.Context + Bucket string + Website []byte +} { + var calls []struct { + ContextMoqParam context.Context + Bucket string + Website []byte + } + mock.lockPutBucketWebsite.RLock() + calls = mock.calls.PutBucketWebsite + mock.lockPutBucketWebsite.RUnlock() + return calls +} + // PutObject calls PutObjectFunc. func (mock *BackendMock) PutObject(contextMoqParam context.Context, putObjectInput s3response.PutObjectInput) (s3response.PutObjectOutput, error) { if mock.PutObjectFunc == nil { diff --git a/s3api/controllers/base.go b/s3api/controllers/base.go index 028f4e48..bbad90e4 100644 --- a/s3api/controllers/base.go +++ b/s3api/controllers/base.go @@ -49,9 +49,10 @@ const ( iso8601TimeFormatExtended = "Mon Jan _2 15:04:05 2006" timefmt = "Mon, 02 Jan 2006 15:04:05 GMT" - maxXMLBodyLen = 4 * 1024 * 1024 - minPartNumber = 1 - maxPartNumber = 10000 + maxXMLBodyLen = 4 * 1024 * 1024 + minPartNumber = 1 + maxPartNumber = 10000 + maxWebsiteConfigurationBytes = 131072 defaultRegion = "us-east-1" defaultContentType = "binary/octet-stream" diff --git a/s3api/controllers/bucket-delete.go b/s3api/controllers/bucket-delete.go index 564f293c..2ff9e7d1 100644 --- a/s3api/controllers/bucket-delete.go +++ b/s3api/controllers/bucket-delete.go @@ -162,6 +162,42 @@ func (c S3ApiController) DeleteBucketCors(ctx *fiber.Ctx) (*Response, error) { }, err } +func (c S3ApiController) DeleteBucketWebsite(ctx *fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx) + + err := auth.VerifyAccess(ctx.Context(), c.be, + auth.AccessOptions{ + Readonly: c.readonly, + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.DeleteBucketWebsiteAction}, + IsPublicRequest: IsBucketPublic, + DisableACL: c.disableACL, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.DeleteBucketWebsite(ctx.Context(), bucket) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + Status: http.StatusNoContent, + }, + }, err +} + func (c S3ApiController) DeleteBucket(ctx *fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) diff --git a/s3api/controllers/bucket-get.go b/s3api/controllers/bucket-get.go index 199333f8..b86fd9b3 100644 --- a/s3api/controllers/bucket-get.go +++ b/s3api/controllers/bucket-get.go @@ -206,6 +206,50 @@ func (c S3ApiController) GetBucketCors(ctx *fiber.Ctx) (*Response, error) { }, err } +func (c S3ApiController) GetBucketWebsite(ctx *fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + + err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ + Readonly: c.readonly, + Acl: parsedAcl, + AclPermission: auth.PermissionRead, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.GetBucketWebsiteAction}, + IsPublicRequest: isPublicBucket, + DisableACL: c.disableACL, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + data, err := c.be.GetBucketWebsite(ctx.Context(), bucket) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + output, err := s3response.ParseWebsiteConfigOutput(data) + return &Response{ + Data: output, + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + func (c S3ApiController) GetBucketPolicy(ctx *fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) diff --git a/s3api/controllers/bucket-post.go b/s3api/controllers/bucket-post.go index 1fd0a825..1015d8dd 100644 --- a/s3api/controllers/bucket-post.go +++ b/s3api/controllers/bucket-post.go @@ -111,6 +111,7 @@ func (c S3ApiController) POSTObject(ctx *fiber.Ctx) (*Response, error) { contentLanguage := parsed.Fields["content-language"] cacheControl := parsed.Fields["cache-control"] expires := parsed.Fields["expires"] + websiteRedirectLocation := parsed.Fields["x-amz-website-redirect-location"] key := parsed.Fields["key"] @@ -196,29 +197,39 @@ func (c S3ApiController) POSTObject(ctx *fiber.Ctx) (*Response, error) { }, err } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + res, err := c.be.PutObject(ctx.Context(), s3response.PutObjectInput{ - Bucket: &bucket, - Key: &key, - ContentType: &contentType, - ContentEncoding: &contentEncoding, - ContentDisposition: &contentDisposition, - ContentLanguage: &contentLanguage, - CacheControl: &cacheControl, - Expires: &expires, - Body: parsed.FileRdr, - ContentLength: &parsed.ContentLength, - Tagging: &tagging, - Metadata: metadata, - ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]), - ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]), - ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]), - ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]), - ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]), - ChecksumSHA512: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha512]), - ChecksumMD5: utils.GetStringPtr(checksums[types.ChecksumAlgorithmMd5]), - ChecksumXXHASH64: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash64]), - ChecksumXXHASH3: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash3]), - ChecksumXXHASH128: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash128]), + Bucket: &bucket, + Key: &key, + ContentType: &contentType, + ContentEncoding: &contentEncoding, + ContentDisposition: &contentDisposition, + ContentLanguage: &contentLanguage, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, + Body: parsed.FileRdr, + ContentLength: &parsed.ContentLength, + Tagging: &tagging, + Metadata: metadata, + ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]), + ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]), + ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]), + ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]), + ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]), + ChecksumSHA512: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha512]), + ChecksumMD5: utils.GetStringPtr(checksums[types.ChecksumAlgorithmMd5]), + ChecksumXXHASH64: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash64]), + ChecksumXXHASH3: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash3]), + ChecksumXXHASH128: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash128]), }) if err != nil { return &Response{ diff --git a/s3api/controllers/bucket-put.go b/s3api/controllers/bucket-put.go index 860fb015..6d1521b7 100644 --- a/s3api/controllers/bucket-put.go +++ b/s3api/controllers/bucket-put.go @@ -287,6 +287,70 @@ func (c S3ApiController) PutBucketCors(ctx *fiber.Ctx) (*Response, error) { }, err } +func (c S3ApiController) PutBucketWebsite(ctx *fiber.Ctx) (*Response, error) { + bucket := ctx.Params("bucket") + parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) + acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) + isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) + isPublicBucket := utils.ContextKeyPublicBucket.IsSet(ctx) + + err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ + Readonly: c.readonly, + Acl: parsedAcl, + AclPermission: auth.PermissionWrite, + IsRoot: isRoot, + Acc: acct, + Bucket: bucket, + Actions: []auth.Action{auth.PutBucketWebsiteAction}, + IsPublicRequest: isPublicBucket, + DisableACL: c.disableACL, + }) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + 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) + if err != nil { + debuglogger.Logf("invalid website config request body: %v", err) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetAPIError(s3err.ErrMalformedXML) + } + + err = websiteConfig.Validate() + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + + err = c.be.PutBucketWebsite(ctx.Context(), bucket, body) + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err +} + func (c S3ApiController) PutBucketPolicy(ctx *fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL) diff --git a/s3api/controllers/cors_default_origin_test.go b/s3api/controllers/cors_default_origin_test.go index 761aa551..a8771423 100644 --- a/s3api/controllers/cors_default_origin_test.go +++ b/s3api/controllers/cors_default_origin_test.go @@ -35,7 +35,7 @@ func TestApplyBucketCORS_FallbackOrigin_NoBucketCors_NoRequestOrigin(t *testing. app := fiber.New() app.Get("/:bucket/test", - middlewares.ApplyBucketCORS(mockedBackend, origin), + middlewares.ApplyBucketCORS(mockedBackend, middlewares.BucketFromPath, origin), func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }, @@ -71,7 +71,7 @@ func TestApplyBucketCORS_FallbackOrigin_NotAppliedWhenBucketCorsExists(t *testin app := fiber.New() app.Get("/:bucket/test", - middlewares.ApplyBucketCORS(mockedBackend, origin), + middlewares.ApplyBucketCORS(mockedBackend, middlewares.BucketFromPath, origin), func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }, diff --git a/s3api/controllers/object-get.go b/s3api/controllers/object-get.go index a7575807..07c312ab 100644 --- a/s3api/controllers/object-get.go +++ b/s3api/controllers/object-get.go @@ -551,6 +551,7 @@ func (c S3ApiController) GetObject(ctx *fiber.Ctx) (*Response, error) { "Content-Language": utils.ApplyOverride(res.ContentLanguage, responseOverrides["Content-Language"]), "Cache-Control": utils.ApplyOverride(res.CacheControl, responseOverrides["Cache-Control"]), "Expires": utils.ApplyOverride(res.ExpiresString, responseOverrides["Expires"]), + "x-amz-website-redirect-location": res.WebsiteRedirectLocation, "x-amz-checksum-crc32": res.ChecksumCRC32, "x-amz-checksum-crc64nvme": res.ChecksumCRC64NVME, "x-amz-checksum-crc32c": res.ChecksumCRC32C, diff --git a/s3api/controllers/object-get_test.go b/s3api/controllers/object-get_test.go index 2fdc417f..0808bbde 100644 --- a/s3api/controllers/object-get_test.go +++ b/s3api/controllers/object-get_test.go @@ -794,6 +794,7 @@ func TestS3ApiController_GetObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, @@ -844,6 +845,7 @@ func TestS3ApiController_GetObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, diff --git a/s3api/controllers/object-head.go b/s3api/controllers/object-head.go index 789c8e8f..de949adc 100644 --- a/s3api/controllers/object-head.go +++ b/s3api/controllers/object-head.go @@ -179,6 +179,7 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) { "Content-Length": utils.ConvertPtrToStringPtr(res.ContentLength), "Content-Type": utils.ApplyOverride(res.ContentType, responseOverrides["Content-Type"]), "Expires": utils.ApplyOverride(res.ExpiresString, responseOverrides["Expires"]), + "x-amz-website-redirect-location": res.WebsiteRedirectLocation, "ETag": res.ETag, "Last-Modified": utils.FormatDatePtrToString(res.LastModified, timefmt), "x-amz-restore": res.Restore, diff --git a/s3api/controllers/object-head_test.go b/s3api/controllers/object-head_test.go index ade3d4c4..067b51ec 100644 --- a/s3api/controllers/object-head_test.go +++ b/s3api/controllers/object-head_test.go @@ -184,6 +184,7 @@ func TestS3ApiController_HeadObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, @@ -233,6 +234,7 @@ func TestS3ApiController_HeadObject(t *testing.T) { "Content-Language": nil, "Cache-Control": nil, "Expires": nil, + "x-amz-website-redirect-location": nil, "x-amz-checksum-crc32": nil, "x-amz-checksum-crc64nvme": nil, "x-amz-checksum-crc32c": nil, diff --git a/s3api/controllers/object-post.go b/s3api/controllers/object-post.go index 9e767b7b..03180c96 100644 --- a/s3api/controllers/object-post.go +++ b/s3api/controllers/object-post.go @@ -154,6 +154,7 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error contentEncoding := ctx.Get("Content-Encoding") tagging := ctx.Get("X-Amz-Tagging") expires := ctx.Get("Expires") + websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location") legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold") lockModeHdr := ctx.Get("X-Amz-Object-Lock-Mode") objLockDate := ctx.Get("X-Amz-Object-Lock-Retain-Until-Date") @@ -203,6 +204,15 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error }, err } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + objLockState, err := utils.ParsObjectLockHdrs(ctx) if err != nil { return &Response{ @@ -232,6 +242,7 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error ContentLanguage: &contentLanguage, CacheControl: &cacheControl, Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, ObjectLockRetainUntilDate: &objLockState.RetainUntilDate, ObjectLockMode: objLockState.ObjectLockMode, ObjectLockLegalHoldStatus: objLockState.LegalHoldStatus, diff --git a/s3api/controllers/object-put.go b/s3api/controllers/object-put.go index d2335267..a66032a3 100644 --- a/s3api/controllers/object-put.go +++ b/s3api/controllers/object-put.go @@ -494,6 +494,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { contentLanguage := ctx.Get("Content-Language") cacheControl := ctx.Get("Cache-Control") expires := ctx.Get("Expires") + websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location") tagging := ctx.Get("x-amz-tagging") storageClass := ctx.Get("X-Amz-Storage-Class") legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold") @@ -578,6 +579,15 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { }, s3err.GetInvalidArgumentErr(s3err.InvalidArgTaggingDirective, string(taggingDirective)) } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + checksumAlgorithm := types.ChecksumAlgorithm(ctx.Get("x-amz-checksum-algorithm")) err = utils.IsChecksumAlgorithmValid(checksumAlgorithm) if err != nil { @@ -618,6 +628,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { ContentLanguage: &contentLanguage, CacheControl: &cacheControl, Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, Tagging: &tagging, TaggingDirective: taggingDirective, CopySource: ©Source, @@ -665,6 +676,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { contentLanguage := ctx.Get("Content-Language") cacheControl := ctx.Get("Cache-Control") expires := ctx.Get("Expires") + websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location") tagging := ctx.Get("x-amz-tagging") legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold") lockModeHdr := ctx.Get("X-Amz-Object-Lock-Mode") @@ -729,6 +741,15 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { }, err } + err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + err = auth.CheckObjectAccess(ctx.Context(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, IsBucketPublic, c.be, true) if err != nil { return &Response{ @@ -787,6 +808,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { ContentLanguage: &contentLanguage, CacheControl: &cacheControl, Expires: &expires, + WebsiteRedirectLocation: &websiteRedirectLocation, Metadata: metadata, Body: body, Tagging: &tagging, diff --git a/s3api/middlewares/apply-bucket-cors.go b/s3api/middlewares/apply-bucket-cors.go index 9a19543e..0f2c3a81 100644 --- a/s3api/middlewares/apply-bucket-cors.go +++ b/s3api/middlewares/apply-bucket-cors.go @@ -28,21 +28,31 @@ import ( // Vary http response header is always the same below var VaryHdr = "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" +type BucketResolver func(ctx *fiber.Ctx) (string, error) + +func BucketFromPath(ctx *fiber.Ctx) (string, error) { + return ctx.Params("bucket"), nil +} + // ApplyBucketCORS retreives the bucket CORS configuration, // checks if origin and method meets the cors rules and // adds the necessary response headers. // CORS check is applied only when 'Origin' request header is present -func ApplyBucketCORS(be backend.Backend, fallbackOrigin string) fiber.Handler { +func ApplyBucketCORS(be backend.Backend, resolveBucket BucketResolver, fallbackOrigin string) fiber.Handler { fallbackOrigin = strings.TrimSpace(fallbackOrigin) return func(ctx *fiber.Ctx) error { - bucket := ctx.Params("bucket") origin := ctx.Get("Origin") // If neither Origin is present nor a fallback is configured, skip CORS entirely. if origin == "" && fallbackOrigin == "" { return nil } + bucket, err := resolveBucket(ctx) + if err != nil { + return err + } + // if bucket cors is not set, skip the check data, err := be.GetBucketCors(ctx.Context(), bucket) if err != nil { diff --git a/s3api/router.go b/s3api/router.go index b1cfdd98..57e4352b 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -185,6 +185,7 @@ func (sa *S3ApiRouter) Init() { bucketRouter := sa.app.Group("/:bucket") objectRouter := sa.app.Group("/:bucket/*") + applyBucketCORS := middlewares.ApplyBucketCORS(sa.be, middlewares.BucketFromPath, sa.corsAllowOrigin) // PUT bucket operations bucketRouter.Put("", @@ -194,7 +195,7 @@ func (sa *S3ApiRouter) Init() { metrics.ActionPutBucketTagging, services, middlewares.BucketObjectNameValidator(), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), @@ -212,7 +213,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -226,7 +227,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -240,7 +241,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, true), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -254,7 +255,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, true, true), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -268,7 +269,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -282,7 +283,7 @@ func (sa *S3ApiRouter) Init() { middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), middlewares.VerifyChecksums(false, false, false), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.ParseAcl(sa.be), )) bucketRouter.Put("", @@ -444,13 +445,15 @@ func (sa *S3ApiRouter) Init() { bucketRouter.Put("", middlewares.MatchQueryArgs("website"), controllers.ProcessHandlers( - ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrNotImplemented)), + ctrl.PutBucketWebsite, metrics.ActionPutBucketWebsite, services, middlewares.BucketObjectNameValidator(), middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutBucketWebsite, auth.PutBucketWebsiteAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), + middlewares.VerifyChecksums(false, true, false), + applyBucketCORS, middlewares.ParseAcl(sa.be), ), ) @@ -464,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 @@ -489,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), )) @@ -516,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("", @@ -529,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("", @@ -542,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("", @@ -555,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("", @@ -665,13 +668,14 @@ func (sa *S3ApiRouter) Init() { bucketRouter.Delete("", middlewares.MatchQueryArgs("website"), controllers.ProcessHandlers( - ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrNotImplemented)), + ctrl.DeleteBucketWebsite, metrics.ActionDeleteBucketWebsite, services, middlewares.BucketObjectNameValidator(), - middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketWebsite, auth.PutBucketWebsiteAction, auth.PermissionWrite, sa.region, false), + middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionDeleteBucketWebsite, auth.DeleteBucketWebsiteAction, auth.PermissionWrite, sa.region, false), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false), + applyBucketCORS, middlewares.ParseAcl(sa.be), ), ) @@ -684,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), )) @@ -711,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), ), ) @@ -725,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("", @@ -738,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("", @@ -751,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("", @@ -764,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("", @@ -777,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("", @@ -790,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("", @@ -803,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("", @@ -816,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("", @@ -829,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("", @@ -842,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("", @@ -1056,13 +1060,14 @@ func (sa *S3ApiRouter) Init() { bucketRouter.Get("", middlewares.MatchQueryArgs("website"), controllers.ProcessHandlers( - ctrl.HandleErrorRoute(s3err.GetAPIError(s3err.ErrNotImplemented)), + ctrl.GetBucketWebsite, metrics.ActionGetBucketWebsite, services, middlewares.BucketObjectNameValidator(), 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), + applyBucketCORS, middlewares.ParseAcl(sa.be), ), ) @@ -1076,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("", @@ -1088,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), )) @@ -1117,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), )) @@ -1129,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), )) @@ -1155,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), )) @@ -1195,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("", @@ -1208,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("", @@ -1221,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("", @@ -1234,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("", @@ -1247,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("", @@ -1260,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("", @@ -1272,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), )) @@ -1300,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("", @@ -1313,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("", @@ -1325,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), )) @@ -1355,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("", @@ -1370,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("", @@ -1383,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("", @@ -1396,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), )) @@ -1408,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), @@ -1426,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("", @@ -1440,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("", @@ -1454,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("", @@ -1468,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("", @@ -1482,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), )) @@ -1519,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), @@ -1531,7 +1536,7 @@ func (sa *S3ApiRouter) Init() { metrics.ActionPutObject, services, middlewares.BucketObjectNameValidator(), - middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin), + applyBucketCORS, middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutObject, auth.PutObjectAction, auth.PermissionWrite, sa.region, true), middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, true), middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, true, true, false), diff --git a/s3api/utils/context-keys.go b/s3api/utils/context-keys.go index dd642efa..445531c3 100644 --- a/s3api/utils/context-keys.go +++ b/s3api/utils/context-keys.go @@ -40,6 +40,7 @@ const ( ContextKeyObjectPostResult ContextKey = "object-post-result" ContextKeyRequestID ContextKey = "request-id" ContextKeyHostID ContextKey = "host-id" + ContextKeyWebsiteConfig ContextKey = "website-config" ) func (ck ContextKey) Set(ctx *fiber.Ctx, val any) { diff --git a/s3api/utils/utils.go b/s3api/utils/utils.go index 2516a3cb..150b12e9 100644 --- a/s3api/utils/utils.go +++ b/s3api/utils/utils.go @@ -59,6 +59,16 @@ func SetBucketNameValidationStrict(strict bool) { // object metadata combined, excluded the 'x-amz-meta-' prefix const maxMetadataSize = 2048 +func ValidateWebsiteRedirectLocation(location string) error { + if location == "" || strings.HasPrefix(location, "http://") || + strings.HasPrefix(location, "https://") || strings.HasPrefix(location, "/") { + return nil + } + + debuglogger.Logf("invalid website redirect location: %q", location) + return s3err.GetAPIError(s3err.ErrInvalidRedirectLocation) +} + // GetUserMetaData extracts user metadata from headers with the "x-amz-meta-" prefix. // Keys are normalized to lowercase and duplicate headers are merged as // comma-separated values. The total metadata size is validated against the diff --git a/s3err/access-forbidden-error.go b/s3err/access-forbidden-error.go index 085c3d36..6abee327 100644 --- a/s3err/access-forbidden-error.go +++ b/s3err/access-forbidden-error.go @@ -43,6 +43,13 @@ func (e AccessForbiddenError) XMLBody(requestID, hostID string) []byte { }) } +func (e AccessForbiddenError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Method", Value: e.Method}, + ErrorField{Name: "ResourceType", Value: e.ResourceType}, + ) +} + func (e AccessForbiddenError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/bad-digest-error.go b/s3err/bad-digest-error.go index 1b209a4d..c095bd5a 100644 --- a/s3err/bad-digest-error.go +++ b/s3err/bad-digest-error.go @@ -43,6 +43,13 @@ func (e BadDigestError) XMLBody(requestID, hostID string) []byte { }) } +func (e BadDigestError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "CalculatedDigest", Value: e.CalculatedDigest}, + ErrorField{Name: "ExpectedDigest", Value: e.ExpectedDigest}, + ) +} + func (e BadDigestError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/bucket-error.go b/s3err/bucket-error.go index 0bbff389..37b4fef6 100644 --- a/s3err/bucket-error.go +++ b/s3err/bucket-error.go @@ -40,6 +40,12 @@ func (e BucketError) XMLBody(requestID, hostID string) []byte { }) } +func (e BucketError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "BucketName", Value: e.BucketName}, + ) +} + func (e BucketError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/content-sha256-mismatch-error.go b/s3err/content-sha256-mismatch-error.go index 22e9738f..1a3079ff 100644 --- a/s3err/content-sha256-mismatch-error.go +++ b/s3err/content-sha256-mismatch-error.go @@ -44,6 +44,13 @@ func (e ContentSHA256MismatchError) XMLBody(requestID, hostID string) []byte { }) } +func (e ContentSHA256MismatchError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ClientComputedContentSHA256", Value: e.ClientComputedContentSHA256}, + ErrorField{Name: "S3ComputedContentSHA256", Value: e.S3ComputedContentSHA256}, + ) +} + func (e ContentSHA256MismatchError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/entity-too-large-error.go b/s3err/entity-too-large-error.go index 24f40868..a6a4a96c 100644 --- a/s3err/entity-too-large-error.go +++ b/s3err/entity-too-large-error.go @@ -43,6 +43,13 @@ func (e EntityTooLargeError) XMLBody(requestID, hostID string) []byte { }) } +func (e EntityTooLargeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ProposedSize", Value: e.ProposedSize}, + ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed}, + ) +} + func (e EntityTooLargeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/entity-too-small-error.go b/s3err/entity-too-small-error.go index d1c522b0..7a230bb9 100644 --- a/s3err/entity-too-small-error.go +++ b/s3err/entity-too-small-error.go @@ -43,6 +43,13 @@ func (e EntityTooSmallError) XMLBody(requestID, hostID string) []byte { }) } +func (e EntityTooSmallError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ProposedSize", Value: e.ProposedSize}, + ErrorField{Name: "MinSizeAllowed", Value: e.MinSizeAllowed}, + ) +} + func (e EntityTooSmallError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/expired-presigned-url-error.go b/s3err/expired-presigned-url-error.go index f4406c86..95c4f815 100644 --- a/s3err/expired-presigned-url-error.go +++ b/s3err/expired-presigned-url-error.go @@ -46,6 +46,14 @@ func (e ExpiredPresignedURLError) XMLBody(requestID, hostID string) []byte { }) } +func (e ExpiredPresignedURLError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ServerTime", Value: e.ServerTime}, + ErrorField{Name: "X-Amz-Expires", Value: e.XAmzExpires}, + ErrorField{Name: "Expires", Value: e.Expires}, + ) +} + func (e ExpiredPresignedURLError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-access-key-id-error.go b/s3err/invalid-access-key-id-error.go index b70efa3c..8f910db7 100644 --- a/s3err/invalid-access-key-id-error.go +++ b/s3err/invalid-access-key-id-error.go @@ -40,6 +40,12 @@ func (e InvalidAccessKeyIdError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidAccessKeyIdError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId}, + ) +} + func (e InvalidAccessKeyIdError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-argument.go b/s3err/invalid-argument.go index ae14ca81..5e8d65b5 100644 --- a/s3err/invalid-argument.go +++ b/s3err/invalid-argument.go @@ -56,6 +56,8 @@ const ( InvalidArgCannedAcl InvalidArgOnlyAws4HmacSha256 InvalidArgDateHeader + InvalidArgIndexDocumentSuffix + InvalidArgErrorDocumentKey ) var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{ @@ -187,6 +189,14 @@ var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{ Description: "X-Amz-Date must be formated via ISO8601 Long format", ArgumentName: "X-Amz-Date", }, + InvalidArgIndexDocumentSuffix: { + Description: "The IndexDocument Suffix is not well formed", + ArgumentName: "IndexDocument", + }, + InvalidArgErrorDocumentKey: { + Description: "The ErrorDocument Key is not well formed", + ArgumentName: "ErrorDocument", + }, } // InvalidArgumentError is returned when a request argument is invalid. @@ -235,6 +245,13 @@ func (e InvalidArgumentError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidArgumentError) HTMLBody(requestID, hostID string) []byte { + return e.BaseError().encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ArgumentName", Value: e.ArgumentName}, + ErrorField{Name: "ArgumentValue", Value: e.ArgumentValue}, + ) +} + func GetInvalidArgumentErr(code InvalidArgErrorCode, value string) InvalidArgumentError { err := invalidArgErrResponses[code] err.ArgumentValue = value diff --git a/s3err/invalid-chunk-size-error.go b/s3err/invalid-chunk-size-error.go index 86eff473..7c147a4d 100644 --- a/s3err/invalid-chunk-size-error.go +++ b/s3err/invalid-chunk-size-error.go @@ -43,6 +43,13 @@ func (e InvalidChunkSizeError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidChunkSizeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Chunk", Value: e.Chunk}, + ErrorField{Name: "BadChunkSize", Value: e.BadChunkSize}, + ) +} + func (e InvalidChunkSizeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-digest-error.go b/s3err/invalid-digest-error.go index 537d0906..e7ca3113 100644 --- a/s3err/invalid-digest-error.go +++ b/s3err/invalid-digest-error.go @@ -40,6 +40,12 @@ func (e InvalidDigestError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidDigestError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Content-MD5", Value: e.ContentMD5}, + ) +} + func (e InvalidDigestError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-location-constraint-error.go b/s3err/invalid-location-constraint-error.go index 290d6428..bf27a5d7 100644 --- a/s3err/invalid-location-constraint-error.go +++ b/s3err/invalid-location-constraint-error.go @@ -40,6 +40,12 @@ func (e InvalidLocationConstraintError) XMLBody(requestID, hostID string) []byte }) } +func (e InvalidLocationConstraintError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "LocationConstraint", Value: e.LocationConstraint}, + ) +} + func (e InvalidLocationConstraintError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-part-error.go b/s3err/invalid-part-error.go index ed47cdcb..4cc7fc11 100644 --- a/s3err/invalid-part-error.go +++ b/s3err/invalid-part-error.go @@ -48,6 +48,14 @@ func (e InvalidPartError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidPartError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "UploadId", Value: e.UploadId}, + ErrorField{Name: "PartNumber", Value: e.PartNumber}, + ErrorField{Name: "ETag", Value: e.ETag}, + ) +} + func (e InvalidPartError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-part-number-range-error.go b/s3err/invalid-part-number-range-error.go index 5edd9fea..4cf7dbfc 100644 --- a/s3err/invalid-part-number-range-error.go +++ b/s3err/invalid-part-number-range-error.go @@ -44,6 +44,13 @@ func (e InvalidPartNumberRangeError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidPartNumberRangeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "ActualPartCount", Value: e.ActualPartCount}, + ErrorField{Name: "PartNumberRequested", Value: e.PartNumberRequested}, + ) +} + func (e InvalidPartNumberRangeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-range-error.go b/s3err/invalid-range-error.go index 37773768..bc23711d 100644 --- a/s3err/invalid-range-error.go +++ b/s3err/invalid-range-error.go @@ -43,6 +43,13 @@ func (e InvalidRangeError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidRangeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "RangeRequested", Value: e.RangeRequested}, + ErrorField{Name: "ActualObjectSize", Value: e.ActualObjectSize}, + ) +} + func (e InvalidRangeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/invalid-tag-error.go b/s3err/invalid-tag-error.go index 981c5183..2c481028 100644 --- a/s3err/invalid-tag-error.go +++ b/s3err/invalid-tag-error.go @@ -43,6 +43,13 @@ func (e InvalidTagError) XMLBody(requestID, hostID string) []byte { }) } +func (e InvalidTagError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "TagKey", Value: e.TagKey}, + ErrorField{Name: "TagValue", Value: e.TagValue}, + ) +} + func (e InvalidTagError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/key-too-long-error.go b/s3err/key-too-long-error.go index d95fdd8a..073d56f7 100644 --- a/s3err/key-too-long-error.go +++ b/s3err/key-too-long-error.go @@ -43,6 +43,13 @@ func (e KeyTooLongError) XMLBody(requestID, hostID string) []byte { }) } +func (e KeyTooLongError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Size", Value: e.Size}, + ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed}, + ) +} + func (e KeyTooLongError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/max-message-length-exceeded-error.go b/s3err/max-message-length-exceeded-error.go new file mode 100644 index 00000000..1c9447d4 --- /dev/null +++ b/s3err/max-message-length-exceeded-error.go @@ -0,0 +1,59 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package s3err + +import "encoding/xml" + +// MaxMessageLengthExceeded is returned when the request size exceeds the maximum limit +// Produces the field in the XML response. +type MaxMessageLengthExceeded struct { + APIError + MaxMessageLengthBytes int64 +} + +func (e MaxMessageLengthExceeded) XMLBody(requestID, hostID string) []byte { + return encodeResponse(struct { + XMLName xml.Name `xml:"Error"` + Code string + Message string + MaxMessageLengthBytes int64 `xml:",omitempty"` + RequestID string `xml:"RequestId,omitempty"` + HostID string `xml:"HostId,omitempty"` + }{ + Code: e.Code, + Message: e.Description, + MaxMessageLengthBytes: e.MaxMessageLengthBytes, + RequestID: requestID, + HostID: hostID, + }) +} + +func (e MaxMessageLengthExceeded) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "MaxMessageLengthBytes", Value: e.MaxMessageLengthBytes}, + ) +} + +func (e MaxMessageLengthExceeded) Is(target error) bool { + t, ok := target.(APIError) + return ok && e.APIError == t +} + +func GetMaxMessageLengthExceeded(maxMessageLengthBytes int64) MaxMessageLengthExceeded { + return MaxMessageLengthExceeded{ + APIError: GetAPIError(ErrMaxMessageLengthExceeded), + MaxMessageLengthBytes: maxMessageLengthBytes, + } +} diff --git a/s3err/metadata-too-large-error.go b/s3err/metadata-too-large-error.go index 02bc2692..59cd6e87 100644 --- a/s3err/metadata-too-large-error.go +++ b/s3err/metadata-too-large-error.go @@ -43,6 +43,13 @@ func (e MetadataTooLargeError) XMLBody(requestID, hostID string) []byte { }) } +func (e MetadataTooLargeError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Size", Value: e.Size}, + ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed}, + ) +} + func (e MetadataTooLargeError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/method-not-allowed-error.go b/s3err/method-not-allowed-error.go index 881c9acf..a0009f28 100644 --- a/s3err/method-not-allowed-error.go +++ b/s3err/method-not-allowed-error.go @@ -52,6 +52,13 @@ func (e MethodNotAllowedError) XMLBody(requestID, hostID string) []byte { }) } +func (e MethodNotAllowedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Method", Value: e.Method}, + ErrorField{Name: "ResourceType", Value: e.ResourceType}, + ) +} + func (e MethodNotAllowedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/no-such-upload-error.go b/s3err/no-such-upload-error.go index a1576a43..2d6d8452 100644 --- a/s3err/no-such-upload-error.go +++ b/s3err/no-such-upload-error.go @@ -40,6 +40,12 @@ func (e NoSuchUploadError) XMLBody(requestID, hostID string) []byte { }) } +func (e NoSuchUploadError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "UploadId", Value: e.UploadId}, + ) +} + func (e NoSuchUploadError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/no-such-version-error.go b/s3err/no-such-version-error.go index 8cd36e60..b1ed81c5 100644 --- a/s3err/no-such-version-error.go +++ b/s3err/no-such-version-error.go @@ -43,6 +43,13 @@ func (e NoSuchVersionError) XMLBody(requestID, hostID string) []byte { }) } +func (e NoSuchVersionError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Key", Value: e.Key}, + ErrorField{Name: "VersionId", Value: e.VersionId}, + ) +} + func (e NoSuchVersionError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/not-implemented-error.go b/s3err/not-implemented-error.go index 64569a69..f4618238 100644 --- a/s3err/not-implemented-error.go +++ b/s3err/not-implemented-error.go @@ -52,6 +52,13 @@ func (e NotImplementedError) XMLBody(requestID, hostID string) []byte { }) } +func (e NotImplementedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Header", Value: e.Header}, + ErrorField{Name: "additionalMessage", Value: e.AdditionalMessage}, + ) +} + func (e NotImplementedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/precondition-failed-error.go b/s3err/precondition-failed-error.go index 0e8fe3fb..f54b79d6 100644 --- a/s3err/precondition-failed-error.go +++ b/s3err/precondition-failed-error.go @@ -52,6 +52,12 @@ func (e PreconditionFailedError) XMLBody(requestID, hostID string) []byte { }) } +func (e PreconditionFailedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Condition", Value: e.Condition}, + ) +} + func (e PreconditionFailedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/request-time-too-skewed-error.go b/s3err/request-time-too-skewed-error.go index b40eb109..d5ba3785 100644 --- a/s3err/request-time-too-skewed-error.go +++ b/s3err/request-time-too-skewed-error.go @@ -48,6 +48,14 @@ func (e RequestTimeTooSkewedError) XMLBody(requestID, hostID string) []byte { }) } +func (e RequestTimeTooSkewedError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "RequestTime", Value: e.RequestTime}, + ErrorField{Name: "ServerTime", Value: e.ServerTime}, + ErrorField{Name: "MaxAllowedSkewMilliseconds", Value: e.MaxAllowedSkewMilliseconds}, + ) +} + func (e RequestTimeTooSkewedError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/s3err.go b/s3err/s3err.go index ddbec479..b994d23c 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -18,6 +18,7 @@ import ( "bytes" "encoding/xml" "fmt" + "html" "net/http" "strings" @@ -31,6 +32,7 @@ type S3Error interface { StatusCode() int BaseError() APIError XMLBody(requestID, hostID string) []byte + HTMLBody(requestID, hostID string) []byte } // APIError structure @@ -69,6 +71,10 @@ func (e APIError) XMLBody(requestID, hostID string) []byte { }) } +func (e APIError) HTMLBody(requestID, hostID string) []byte { + return e.encodeHTMLResponse(requestID, hostID) +} + // ErrorCode type of error status. type ErrorCode int @@ -165,6 +171,11 @@ const ( ErrCORSForbidden ErrMissingCORSOrigin ErrCORSIsNotEnabled + ErrNoSuchWebsiteConfiguration + ErrInvalidWebsiteRedirectProtocol + ErrInvalidRedirectLocation + ErrBothReplaceKeyAndPrefix + ErrMaxMessageLengthExceeded ErrNotModified ErrInvalidLocationConstraint ErrMalformedTrailer @@ -172,6 +183,7 @@ const ( ErrSlowDown ErrMetadataTooLarge ErrUnsupportedAuthorizationMechanism + ErrNoBucketInRequest // Non-AWS errors ErrExistingObjectIsDirectory @@ -639,6 +651,31 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "CORSResponse: CORS is not enabled for this bucket.", HTTPStatusCode: http.StatusForbidden, }, + ErrNoSuchWebsiteConfiguration: { + Code: "NoSuchWebsiteConfiguration", + Description: "The specified bucket does not have a website configuration", + HTTPStatusCode: http.StatusNotFound, + }, + ErrInvalidWebsiteRedirectProtocol: { + Code: "InvalidRequest", + Description: "Invalid protocol, protocol can be http or https. If not defined the protocol will be selected automatically.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrInvalidRedirectLocation: { + Code: "InvalidRedirectLocation", + Description: "The website redirect location must have a prefix of 'http://' or 'https://' or '/'.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrBothReplaceKeyAndPrefix: { + Code: "InvalidRequest", + Description: "You can only define ReplaceKeyPrefix or ReplaceKey but not both.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrMaxMessageLengthExceeded: { + Code: "MaxMessageLengthExceeded", + Description: "Your request was too big.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrNotModified: { Code: "NotModified", Description: "Not Modified", @@ -674,6 +711,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: { @@ -769,6 +811,42 @@ func encodeResponse(response any) []byte { return bytesBuffer.Bytes() } +type ErrorField struct { + Name string + Value any +} + +func (e APIError) encodeHTMLResponse(requestID, hostID string, fields ...ErrorField) []byte { + status := fmt.Sprintf("%d %s", e.HTTPStatusCode, http.StatusText(e.HTTPStatusCode)) + + builder := &strings.Builder{} + builder.WriteString("\n") + builder.WriteString("") + builder.WriteString(html.EscapeString(status)) + builder.WriteString("\n\n

") + builder.WriteString(html.EscapeString(status)) + builder.WriteString("

\n
    \n") + + 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("
\n
\n\n\n") + return []byte(builder.String()) +} + +func writeHTMLErrorField(builder *strings.Builder, name string, value any) { + builder.WriteString("
  • ") + builder.WriteString(html.EscapeString(name)) + builder.WriteString(": ") + builder.WriteString(html.EscapeString(fmt.Sprint(value))) + builder.WriteString("
  • \n") +} + // Returns invalid checksum error with the provided header in the error description func GetInvalidChecksumHeaderErr(header string) APIError { return APIError{ @@ -894,6 +972,30 @@ func GetCopySourceObjectTooLargeErr(limit int64) APIError { } } +func GetInvalidRedirectCodeErr(input int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The provided HTTP redirect code (%d) is not valid. Valid codes are 3XX except 300.", input), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetInvalidHTTPErrorCodeErr(input int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("The provided HTTP error code (%d) is not valid. Valid codes are 4XX or 5XX.", input), + HTTPStatusCode: http.StatusBadRequest, + } +} + +func GetWebsiteRoutingRulesLimitedErr(rules int) APIError { + return APIError{ + Code: "InvalidRequest", + Description: fmt.Sprintf("%d routing rules provided, the number of routing rules in a website configuration is limited to 50.", rules), + HTTPStatusCode: http.StatusBadRequest, + } +} + type ResourceType string const ( diff --git a/s3err/signature-does-not-match-error.go b/s3err/signature-does-not-match-error.go index 97cfeb96..51ec315f 100644 --- a/s3err/signature-does-not-match-error.go +++ b/s3err/signature-does-not-match-error.go @@ -55,6 +55,17 @@ func (e SignatureDoesNotMatchError) XMLBody(requestID, hostID string) []byte { }) } +func (e SignatureDoesNotMatchError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId}, + ErrorField{Name: "StringToSign", Value: e.StringToSign}, + ErrorField{Name: "SignatureProvided", Value: e.SignatureProvided}, + ErrorField{Name: "StringToSignBytes", Value: e.StringToSignBytes}, + ErrorField{Name: "CanonicalRequest", Value: e.CanonicalRequest}, + ErrorField{Name: "CanonicalRequestBytes", Value: e.CanonicalRequestBytes}, + ) +} + func (e SignatureDoesNotMatchError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3err/sigv4.go b/s3err/sigv4.go index a868089d..ebb25be3 100644 --- a/s3err/sigv4.go +++ b/s3err/sigv4.go @@ -44,6 +44,12 @@ func (e MalformedAuthError) XMLBody(requestID, hostID string) []byte { }) } +func (e MalformedAuthError) HTMLBody(requestID, hostID string) []byte { + return e.APIError.encodeHTMLResponse(requestID, hostID, + ErrorField{Name: "Region", Value: e.Region}, + ) +} + func (e MalformedAuthError) Is(target error) bool { t, ok := target.(APIError) return ok && e.APIError == t diff --git a/s3response/website.go b/s3response/website.go new file mode 100644 index 00000000..153de9a7 --- /dev/null +++ b/s3response/website.go @@ -0,0 +1,294 @@ +// 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 s3response + +import ( + "encoding/xml" + "fmt" + "strconv" + "strings" + + "github.com/versity/versitygw/debuglogger" + "github.com/versity/versitygw/s3err" +) + +const maxRoutingRules = 50 + +// WebsiteConfiguration represents the S3 bucket website configuration. +type WebsiteConfiguration struct { + XMLName xml.Name `xml:"WebsiteConfiguration"` + IndexDocument *IndexDocument `xml:"IndexDocument,omitempty"` + ErrorDocument *ErrorDocument `xml:"ErrorDocument,omitempty"` + RedirectAllRequestsTo *RedirectAllRequestsTo `xml:"RedirectAllRequestsTo,omitempty"` + RoutingRules []RoutingRule `xml:"RoutingRules>RoutingRule,omitempty"` +} + +// IndexDocument specifies the default object served for directory-like requests. +type IndexDocument struct { + Suffix string `xml:"Suffix"` +} + +// ErrorDocument specifies the object served when an error occurs. +type ErrorDocument struct { + Key string `xml:"Key"` +} + +// RedirectAllRequestsTo redirects all requests to another host. +type RedirectAllRequestsTo struct { + HostName string `xml:"HostName"` + Protocol string `xml:"Protocol,omitempty"` +} + +// RoutingRule specifies a redirect rule with an optional condition. +type RoutingRule struct { + Condition *RoutingRuleCondition `xml:"Condition,omitempty"` + Redirect *Redirect `xml:"Redirect"` +} + +// RoutingRuleCondition specifies when a routing rule applies. +type RoutingRuleCondition struct { + HttpErrorCodeReturnedEquals string `xml:"HttpErrorCodeReturnedEquals,omitempty"` + KeyPrefixEquals string `xml:"KeyPrefixEquals,omitempty"` +} + +// Redirect specifies where to redirect matching requests. +type Redirect struct { + HostName string `xml:"HostName,omitempty"` + HttpRedirectCode string `xml:"HttpRedirectCode,omitempty"` + Protocol string `xml:"Protocol,omitempty"` + ReplaceKeyPrefixWith string `xml:"ReplaceKeyPrefixWith,omitempty"` + ReplaceKeyWith string `xml:"ReplaceKeyWith,omitempty"` +} + +// Validate checks the website configuration for S3-compatible validity. +func (c *WebsiteConfiguration) Validate() error { + if c.RedirectAllRequestsTo != nil { + if c.IndexDocument != nil || c.ErrorDocument != nil || len(c.RoutingRules) > 0 { + debuglogger.Logf("website redirect conflicts with config") + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + if c.RedirectAllRequestsTo.HostName == "" { + debuglogger.Logf("website redirect hostname is empty") + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + if err := validateProtocol(c.RedirectAllRequestsTo.Protocol); err != nil { + return err + } + return nil + } + + if c.IndexDocument == nil { + debuglogger.Logf("website index document is missing") + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + if c.IndexDocument.Suffix == "" { + debuglogger.Logf("website index suffix is empty") + return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix) + } + if strings.Contains(c.IndexDocument.Suffix, "/") { + debuglogger.Logf("website index suffix contains slash") + return s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, c.IndexDocument.Suffix) + } + + if c.ErrorDocument != nil && c.ErrorDocument.Key == "" { + debuglogger.Logf("website error document key is empty") + return s3err.GetInvalidArgumentErr(s3err.InvalidArgErrorDocumentKey, "") + } + + if len(c.RoutingRules) > maxRoutingRules { + debuglogger.Logf("too many website routing rules: %d", len(c.RoutingRules)) + return s3err.GetWebsiteRoutingRulesLimitedErr(len(c.RoutingRules)) + } + + for _, rule := range c.RoutingRules { + if err := rule.Validate(); err != nil { + return err + } + } + + return nil +} + +// Validate checks a single routing rule for validity. +func (r *RoutingRule) Validate() error { + if err := r.Redirect.Validate(); err != nil { + return err + } + + if err := r.Condition.Validate(); err != nil { + return err + } + + return nil +} + +func (c *RoutingRuleCondition) Validate() error { + if c == nil { + return nil + } + + if c.HttpErrorCodeReturnedEquals == "" && c.KeyPrefixEquals == "" { + debuglogger.Logf("website routing rule condition is empty") + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + + return isValidHTTPCode(c.HttpErrorCodeReturnedEquals, validateErrorCode) +} + +func (r *Redirect) Validate() error { + if r == nil { + return nil + } + + if r.HostName == "" && + r.HttpRedirectCode == "" && + r.Protocol == "" && + r.ReplaceKeyPrefixWith == "" && + r.ReplaceKeyWith == "" { + debuglogger.Logf("website routing rule redirect is empty") + return s3err.GetAPIError(s3err.ErrMalformedXML) + } + + 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" { + debuglogger.Logf("invalid website redirect protocol: %q", protocol) + return s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol) + } + return nil +} + +// ParseWebsiteConfigOutput parses raw bytes into a WebsiteConfiguration. +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 +} + +// 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] + condition := rule.Condition + if condition == nil || + condition.KeyPrefixEquals == "" || + condition.HttpErrorCodeReturnedEquals != "" { + continue + } + + if condition.KeyPrefixEquals != "" && strings.HasPrefix(key, condition.KeyPrefixEquals) { + return rule + } + } + + return nil +} + +// 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] + condition := rule.Condition + if condition != nil && condition.HttpErrorCodeReturnedEquals == "" { + continue + } + + if condition.Matches(key, statusCode) { + return rule + } + } + + return nil +} + +// Matches reports whether all configured condition fields match. +func (c *RoutingRuleCondition) Matches(key string, statusCode int) bool { + if c == nil { + return true + } + + if c.KeyPrefixEquals != "" && !strings.HasPrefix(key, c.KeyPrefixEquals) { + return false + } + + if c.HttpErrorCodeReturnedEquals != "" && + strconv.Itoa(statusCode) != c.HttpErrorCodeReturnedEquals { + return false + } + + return true +} diff --git a/s3response/website_test.go b/s3response/website_test.go new file mode 100644 index 00000000..1f3c7aaa --- /dev/null +++ b/s3response/website_test.go @@ -0,0 +1,310 @@ +// 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 s3response + +import ( + "errors" + "testing" + + "github.com/versity/versitygw/s3err" +) + +func TestWebsiteConfiguration_Validate(t *testing.T) { + tests := []struct { + name string + config WebsiteConfiguration + wantErr bool + errCode string + }{ + { + name: "valid index document only", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + }, + }, + { + name: "valid index and error document", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + ErrorDocument: &ErrorDocument{Key: "error.html"}, + }, + }, + { + name: "valid redirect all requests", + config: WebsiteConfiguration{ + RedirectAllRequestsTo: &RedirectAllRequestsTo{ + HostName: "example.com", + Protocol: "https", + }, + }, + }, + { + name: "valid routing rules", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Condition: &RoutingRuleCondition{ + KeyPrefixEquals: "docs/", + }, + Redirect: &Redirect{ + ReplaceKeyPrefixWith: "documents/", + }, + }, + }, + }, + }, + { + name: "missing index document", + config: WebsiteConfiguration{}, + wantErr: true, + errCode: "MalformedXML", + }, + { + name: "empty index suffix", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: ""}, + }, + wantErr: true, + errCode: "InvalidArgument", + }, + { + name: "index suffix with slash", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "dir/index.html"}, + }, + wantErr: true, + errCode: "InvalidArgument", + }, + { + name: "redirect all with index document", + config: WebsiteConfiguration{ + RedirectAllRequestsTo: &RedirectAllRequestsTo{HostName: "example.com"}, + IndexDocument: &IndexDocument{Suffix: "index.html"}, + }, + wantErr: true, + errCode: "MalformedXML", + }, + { + name: "redirect all with empty hostname", + config: WebsiteConfiguration{ + RedirectAllRequestsTo: &RedirectAllRequestsTo{HostName: ""}, + }, + wantErr: true, + errCode: "MalformedXML", + }, + { + name: "redirect all with invalid protocol", + config: WebsiteConfiguration{ + RedirectAllRequestsTo: &RedirectAllRequestsTo{ + HostName: "example.com", + Protocol: "ftp", + }, + }, + wantErr: true, + errCode: "InvalidRequest", + }, + { + name: "routing rule with both replace key fields", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Redirect: &Redirect{ + ReplaceKeyWith: "newkey", + ReplaceKeyPrefixWith: "newprefix/", + }, + }, + }, + }, + wantErr: true, + errCode: "InvalidRequest", + }, + { + name: "routing rule with empty condition", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Condition: &RoutingRuleCondition{}, + Redirect: &Redirect{ + HostName: "example.com", + }, + }, + }, + }, + wantErr: true, + errCode: "MalformedXML", + }, + { + name: "routing rule with empty redirect", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Condition: &RoutingRuleCondition{ + KeyPrefixEquals: "docs/", + }, + Redirect: &Redirect{}, + }, + }, + }, + wantErr: true, + errCode: "MalformedXML", + }, + { + name: "routing rule with invalid redirect code", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Redirect: &Redirect{ + HttpRedirectCode: "200", + }, + }, + }, + }, + wantErr: true, + errCode: "InvalidRequest", + }, + { + name: "routing rule with valid redirect code", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Redirect: &Redirect{ + HttpRedirectCode: "301", + HostName: "example.com", + }, + }, + }, + }, + }, + { + name: "error document with empty key", + config: WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + ErrorDocument: &ErrorDocument{Key: ""}, + }, + wantErr: true, + errCode: "InvalidArgument", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + var apiErr s3err.S3Error + if !errors.As(err, &apiErr) { + t.Fatalf("expected S3 error, got %T: %v", err, err) + } + if apiErr.BaseError().Code != tt.errCode { + t.Errorf("expected error code %q, got %q", tt.errCode, apiErr.BaseError().Code) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + +func TestWebsiteConfiguration_MatchPrefetchRoutingRuleUsesPrefixOnlyRules(t *testing.T) { + config := WebsiteConfiguration{ + IndexDocument: &IndexDocument{Suffix: "index.html"}, + RoutingRules: []RoutingRule{ + { + Condition: &RoutingRuleCondition{ + HttpErrorCodeReturnedEquals: "404", + }, + Redirect: &Redirect{ + 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", + }, + }, + }, + } + + rule := config.MatchPrefetchRoutingRule("old/page.html") + if rule == nil { + t.Fatal("expected a matching rule, got nil") + } + if rule.Redirect.HostName != "prefix.example.com" { + t.Fatalf("expected prefix-only rule to match, got %q", rule.Redirect.HostName) + } +} + +func TestRoutingRuleCondition_MatchesUsesAndLogic(t *testing.T) { + condition := RoutingRuleCondition{ + KeyPrefixEquals: "old/", + HttpErrorCodeReturnedEquals: "404", + } + + tests := []struct { + name string + key string + statusCode int + want bool + }{ + { + name: "both match", + key: "old/missing.html", + statusCode: 404, + want: true, + }, + { + name: "prefix only", + key: "old/existing.html", + statusCode: 200, + want: false, + }, + { + name: "status only", + key: "other/missing.html", + statusCode: 404, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := condition.Matches(tt.key, tt.statusCode); got != tt.want { + t.Fatalf("Matches() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/tests/integration/CopyObject.go b/tests/integration/CopyObject.go index 0907b29f..90b781a7 100644 --- a/tests/integration/CopyObject.go +++ b/tests/integration/CopyObject.go @@ -666,21 +666,23 @@ func CopyObject_should_copy_meta_props(s *S3Conf) error { cType, cEnc, cDesp, cLang, cLength := "application/json", "base64", "test-desp", "us", int64(100) cacheControl, expires := "no-cache", time.Now().Add(time.Hour*10) + redirectLocation := "/source-redirect" meta := map[string]string{ "foo": "bar", "baz": "quxx", } _, err := putObjectWithData(cLength, &s3.PutObjectInput{ - Bucket: &bucket, - Key: &srcObj, - ContentDisposition: &cDesp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - ContentType: &cType, - CacheControl: &cacheControl, - Expires: &expires, - Metadata: meta, + Bucket: &bucket, + Key: &srcObj, + ContentDisposition: &cDesp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + ContentType: &cType, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, + Metadata: meta, }, s3client) if err != nil { return err @@ -697,7 +699,7 @@ func CopyObject_should_copy_meta_props(s *S3Conf) error { return err } - return checkObjectMetaProps(s3client, bucket, dstObj, ObjectMetaProps{ + if err := checkObjectMetaProps(s3client, bucket, dstObj, ObjectMetaProps{ ContentLength: cLength, ContentType: cType, ContentEncoding: cEnc, @@ -706,7 +708,24 @@ func CopyObject_should_copy_meta_props(s *S3Conf) error { CacheControl: cacheControl, ExpiresString: expires.UTC().Format(timefmt), Metadata: meta, + }); err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + out, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &dstObj, }) + cancel() + if err != nil { + return err + } + if got := getString(out.WebsiteRedirectLocation); got != "" { + return fmt.Errorf("expected WebsiteRedirectLocation not to be copied, got %v", got) + } + + return nil }) } @@ -736,6 +755,7 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error { cType, cEnc, cDesp, cLang := "application/binary", "hex", "desp", "mex" cacheControl, expires := "no-cache", time.Now().Add(time.Hour*10) + redirectLocation := "https://example.com/replaced" meta := map[string]string{ "foo": "bar", "baz": "quxx", @@ -744,17 +764,18 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error { ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) _, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{ - Bucket: &bucket, - Key: &dstObj, - CopySource: getPtr(bucket + "/" + srcObj), - MetadataDirective: types.MetadataDirectiveReplace, - ContentDisposition: &cDesp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - ContentType: &cType, - CacheControl: &cacheControl, - Expires: &expires, - Metadata: meta, + Bucket: &bucket, + Key: &dstObj, + CopySource: getPtr(bucket + "/" + srcObj), + MetadataDirective: types.MetadataDirectiveReplace, + ContentDisposition: &cDesp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + ContentType: &cType, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, + Metadata: meta, }) cancel() if err != nil { @@ -762,18 +783,44 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error { } return checkObjectMetaProps(s3client, bucket, dstObj, ObjectMetaProps{ - ContentLength: contentLength, - ContentType: cType, - ContentEncoding: cEnc, - ContentDisposition: cDesp, - ContentLanguage: cLang, - CacheControl: cacheControl, - ExpiresString: expires.UTC().Format(timefmt), - Metadata: meta, + ContentLength: contentLength, + ContentType: cType, + ContentEncoding: cEnc, + ContentDisposition: cDesp, + ContentLanguage: cLang, + CacheControl: cacheControl, + ExpiresString: expires.UTC().Format(timefmt), + WebsiteRedirectLocation: redirectLocation, + Metadata: meta, }) }) } +func CopyObject_invalid_website_redirect_location(s *S3Conf) error { + testName := "CopyObject_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + srcObj, dstObj := "source-object", "dest-object" + + _, err := putObjectWithData(100, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &srcObj, + }, s3client) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: &bucket, + Key: &dstObj, + CopySource: getPtr(bucket + "/" + srcObj), + WebsiteRedirectLocation: getPtr("ftp://example.com"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func CopyObject_default_content_type_with_replace_metadata(s *S3Conf) error { testName := "CopyObject_default_content_type_with_replace_metadata" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/CreateMultipartUpload.go b/tests/integration/CreateMultipartUpload.go index e95e2b37..b4771dc0 100644 --- a/tests/integration/CreateMultipartUpload.go +++ b/tests/integration/CreateMultipartUpload.go @@ -67,18 +67,20 @@ func CreateMultipartUpload_with_metadata(s *S3Conf) error { } cType, cEnc, cDesp, cLang := "application/text", "testenc", "testdesp", "sp" cacheControl, expires := "no-cache", time.Now().Add(time.Hour*5) + redirectLocation := "/multipart-redirect" ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) out, err := s3client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ - Bucket: &bucket, - Key: &obj, - Metadata: meta, - ContentType: &cType, - ContentEncoding: &cEnc, - ContentDisposition: &cDesp, - ContentLanguage: &cLang, - CacheControl: &cacheControl, - Expires: &expires, + Bucket: &bucket, + Key: &obj, + Metadata: meta, + ContentType: &cType, + ContentEncoding: &cEnc, + ContentDisposition: &cDesp, + ContentLanguage: &cLang, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, }) cancel() if err != nil { @@ -151,11 +153,29 @@ func CreateMultipartUpload_with_metadata(s *S3Conf) error { return fmt.Errorf("expected uploaded object content-encoding to be %v, instead got %v", expires.UTC().Format(timefmt), getString(resp.ExpiresString)) } + if getString(resp.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected uploaded object website redirect location to be %v, instead got %v", + redirectLocation, getString(resp.WebsiteRedirectLocation)) + } return nil }) } +func CreateMultipartUpload_invalid_website_redirect_location(s *S3Conf) error { + testName := "CreateMultipartUpload_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ + Bucket: &bucket, + Key: getPtr("my-obj"), + WebsiteRedirectLocation: getPtr("example.com"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func CreateMultipartUpload_with_object_lock(s *S3Conf) error { testName := "CreateMultipartUpload_with_object_lock" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/DeleteBucketWebsite.go b/tests/integration/DeleteBucketWebsite.go new file mode 100644 index 00000000..ad9e5ac2 --- /dev/null +++ b/tests/integration/DeleteBucketWebsite.go @@ -0,0 +1,85 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" +) + +func DeleteBucketWebsite_non_existing_bucket(s *S3Conf) error { + testName := "DeleteBucketWebsite_non_existing_bucket" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{ + Bucket: getPtr("non-existing-bucket"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)) + }) +} + +func DeleteBucketWebsite_success(s *S3Conf) error { + testName := "DeleteBucketWebsite_success" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + deleteWebsite := func() error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + return err + } + + // should not return error when deleting unset website config + err := deleteWebsite() + if err != nil { + return err + } + + // put a website config + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + // delete the website config + err = deleteWebsite() + if err != nil { + return err + } + + // verify it's gone + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)) + }) +} diff --git a/tests/integration/GetBucketWebsite.go b/tests/integration/GetBucketWebsite.go new file mode 100644 index 00000000..7dfdf194 --- /dev/null +++ b/tests/integration/GetBucketWebsite.go @@ -0,0 +1,126 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" +) + +func GetBucketWebsite_non_existing_bucket(s *S3Conf) error { + testName := "GetBucketWebsite_non_existing_bucket" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: getPtr("non-existing-bucket"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)) + }) +} + +func GetBucketWebsite_no_such_website_config(s *S3Conf) error { + testName := "GetBucketWebsite_no_such_website_config" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration)) + }) +} + +func GetBucketWebsite_success(s *S3Conf) error { + testName := "GetBucketWebsite_success" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + if err != nil { + return err + } + + if res.IndexDocument == nil || res.IndexDocument.Suffix == nil || *res.IndexDocument.Suffix != "index.html" { + return fmt.Errorf("expected IndexDocument.Suffix to be %q, got %v", "index.html", res.IndexDocument) + } + if res.ErrorDocument == nil || res.ErrorDocument.Key == nil || *res.ErrorDocument.Key != "error.html" { + return fmt.Errorf("expected ErrorDocument.Key to be %q, got %v", "error.html", res.ErrorDocument) + } + + return nil + }) +} + +func GetBucketWebsite_success_redirect_all(s *S3Conf) error { + testName := "GetBucketWebsite_success_redirect_all" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("example.com"), + Protocol: types.ProtocolHttps, + }, + }, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{ + Bucket: &bucket, + }) + cancel() + if err != nil { + return err + } + + if res.RedirectAllRequestsTo == nil || res.RedirectAllRequestsTo.HostName == nil || *res.RedirectAllRequestsTo.HostName != "example.com" { + return fmt.Errorf("expected RedirectAllRequestsTo.HostName to be %q, got %v", "example.com", res.RedirectAllRequestsTo) + } + if res.RedirectAllRequestsTo.Protocol != types.ProtocolHttps { + return fmt.Errorf("expected RedirectAllRequestsTo.Protocol to be %q, got %q", types.ProtocolHttps, res.RedirectAllRequestsTo.Protocol) + } + + return nil + }) +} diff --git a/tests/integration/GetObject.go b/tests/integration/GetObject.go index eb6def24..3a3ca7c0 100644 --- a/tests/integration/GetObject.go +++ b/tests/integration/GetObject.go @@ -574,22 +574,24 @@ func GetObject_success(s *S3Conf) error { dataLength, obj := int64(1234567), "my-obj" ctype, cDisp, cEnc, cLang := defaultContentType, "cont-desp", "json", "eng" cacheControl, expires := "cache-ctrl", time.Now().Add(time.Hour*2) + redirectLocation := "/get-object-redirect" meta := map[string]string{ "foo": "bar", "baz": "quxx", } r, err := putObjectWithData(dataLength, &s3.PutObjectInput{ - Bucket: &bucket, - Key: &obj, - ContentType: &ctype, - ContentDisposition: &cDisp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - Expires: &expires, - CacheControl: &cacheControl, - Metadata: meta, - Tagging: getPtr("key=value&key1=val1"), + Bucket: &bucket, + Key: &obj, + ContentType: &ctype, + ContentDisposition: &cDisp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + Expires: &expires, + CacheControl: &cacheControl, + WebsiteRedirectLocation: &redirectLocation, + Metadata: meta, + Tagging: getPtr("key=value&key1=val1"), }, s3client) if err != nil { return err @@ -632,6 +634,10 @@ func GetObject_success(s *S3Conf) error { return fmt.Errorf("expected Cache-Control %v, instead got %v", cacheControl, getString(out.CacheControl)) } + if getString(out.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %v, instead got %v", + redirectLocation, getString(out.WebsiteRedirectLocation)) + } if out.StorageClass != types.StorageClassStandard { return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass) diff --git a/tests/integration/HeadObject.go b/tests/integration/HeadObject.go index df30d5a4..6fa68af4 100644 --- a/tests/integration/HeadObject.go +++ b/tests/integration/HeadObject.go @@ -569,18 +569,20 @@ func HeadObject_success(s *S3Conf) error { } ctype, cDisp, cEnc, cLang := defaultContentType, "cont-desp", "json", "eng" cacheControl, expires := "cache-ctrl", time.Now().Add(time.Hour*2) + redirectLocation := "/head-object-redirect" _, err := putObjectWithData(dataLen, &s3.PutObjectInput{ - Bucket: &bucket, - Key: &obj, - Metadata: meta, - ContentType: &ctype, - ContentDisposition: &cDisp, - ContentEncoding: &cEnc, - ContentLanguage: &cLang, - CacheControl: &cacheControl, - Expires: &expires, - Tagging: getPtr("key=value"), + Bucket: &bucket, + Key: &obj, + Metadata: meta, + ContentType: &ctype, + ContentDisposition: &cDisp, + ContentEncoding: &cEnc, + ContentLanguage: &cLang, + CacheControl: &cacheControl, + Expires: &expires, + WebsiteRedirectLocation: &redirectLocation, + Tagging: getPtr("key=value"), }, s3client) if err != nil { return err @@ -631,6 +633,10 @@ func HeadObject_success(s *S3Conf) error { return fmt.Errorf("expected Cache-Control %v, instead got %v", cacheControl, getString(out.CacheControl)) } + if getString(out.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %v, instead got %v", + redirectLocation, getString(out.WebsiteRedirectLocation)) + } if out.StorageClass != types.StorageClassStandard { return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass) diff --git a/tests/integration/NotImplemented_actions.go b/tests/integration/NotImplemented_actions.go index 4beea7d6..24e9ff3a 100644 --- a/tests/integration/NotImplemented_actions.go +++ b/tests/integration/NotImplemented_actions.go @@ -655,53 +655,6 @@ func GetBucketAccelerateConfiguration_not_implemented(s *S3Conf) error { }) } -func PutBucketWebsite_not_implemented(s *S3Conf) error { - testName := "PutBucketWebsite_not_implemented" - return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.PutBucketWebsite(ctx, - &s3.PutBucketWebsiteInput{ - Bucket: &bucket, - WebsiteConfiguration: &types.WebsiteConfiguration{ - IndexDocument: &types.IndexDocument{ - Suffix: getPtr("suffix"), - }, - }, - }) - cancel() - - return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)) - }) -} - -func GetBucketWebsite_not_implemented(s *S3Conf) error { - testName := "GetBucketWebsite_not_implemented" - return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.GetBucketWebsite(ctx, - &s3.GetBucketWebsiteInput{ - Bucket: &bucket, - }) - cancel() - - return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)) - }) -} - -func DeleteBucketWebsite_not_implemented(s *S3Conf) error { - testName := "DeleteBucketWebsite_not_implemented" - return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { - ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) - _, err := s3client.DeleteBucketWebsite(ctx, - &s3.DeleteBucketWebsiteInput{ - Bucket: &bucket, - }) - cancel() - - return checkApiErr(err, s3err.GetAPIError(s3err.ErrNotImplemented)) - }) -} - func PutObjectAcl_not_implemented(s *S3Conf) error { testName := "PutObjectAcl_not_implemented" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/PostObject.go b/tests/integration/PostObject.go index d020ed6b..fcef5c0b 100644 --- a/tests/integration/PostObject.go +++ b/tests/integration/PostObject.go @@ -887,6 +887,7 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { cLanguage := "en-US" cDisposition := "inline" cEncoding := "gzip" + redirectLocation := "/post-object-redirect" resp, err := sendPostObject(PostRequestConfig{ bucket: bucket, @@ -900,18 +901,20 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { []any{"eq", "$Content-Encoding", cEncoding}, []any{"eq", "$Cache-Control", cacheControl}, []any{"eq", "$Expires", expires}, + []any{"eq", "$x-amz-website-redirect-location", redirectLocation}, []any{"eq", "$x-amz-meta-foo", "bar"}, []any{"eq", "$x-amz-meta-baz", "quxx"}, }, extraFields: map[string]string{ - "Content-Type": cType, - "Cache-Control": cacheControl, - "Expires": expires, - "Content-Language": cLanguage, - "Content-Disposition": cDisposition, - "Content-Encoding": cEncoding, - "x-amz-meta-foo": "bar", - "x-amz-meta-baz": "quxx", + "Content-Type": cType, + "Cache-Control": cacheControl, + "Expires": expires, + "Content-Language": cLanguage, + "Content-Disposition": cDisposition, + "Content-Encoding": cEncoding, + "x-amz-website-redirect-location": redirectLocation, + "x-amz-meta-foo": "bar", + "x-amz-meta-baz": "quxx", }, }) if err != nil { @@ -955,6 +958,10 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { return fmt.Errorf("expected Cache-Control %s, instead got %s", cacheControl, getString(out.CacheControl)) } + if getString(out.WebsiteRedirectLocation) != redirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %s, instead got %s", + redirectLocation, getString(out.WebsiteRedirectLocation)) + } expectedMeta := map[string]string{ "foo": "bar", @@ -969,6 +976,30 @@ func PostObject_success_with_meta_properties(s *S3Conf) error { }) } +func PostObject_invalid_website_redirect_location(s *S3Conf) error { + testName := "PostObject_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + redirectLocation := "ftp://example.com" + resp, err := sendPostObject(PostRequestConfig{ + bucket: bucket, + key: "test-object", + s3Conf: s, + fileContent: []byte("data"), + policyConditions: []any{ + []any{"eq", "$x-amz-website-redirect-location", redirectLocation}, + }, + extraFields: map[string]string{ + "x-amz-website-redirect-location": redirectLocation, + }, + }) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func PostObject_invalid_tagging(s *S3Conf) error { testName := "PostObject_invalid_tagging" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/PutBucketWebsite.go b/tests/integration/PutBucketWebsite.go new file mode 100644 index 00000000..1612866e --- /dev/null +++ b/tests/integration/PutBucketWebsite.go @@ -0,0 +1,469 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "context" + "fmt" + "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 { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: getPtr("non-existing-bucket"), + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket)) + }) +} + +func PutBucketWebsite_empty_suffix(s *S3Conf) error { + testName := "PutBucketWebsite_empty_suffix" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr(""), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, "")) + }) +} + +func PutBucketWebsite_suffix_with_slash(s *S3Conf) error { + testName := "PutBucketWebsite_suffix_with_slash" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("/index.html"), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetInvalidArgumentErr(s3err.InvalidArgIndexDocumentSuffix, "/index.html")) + }) +} + +func PutBucketWebsite_invalid_redirect_protocol(s *S3Conf) error { + testName := "PutBucketWebsite_invalid_redirect_protocol" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("example.com"), + Protocol: types.Protocol("ftp"), + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidWebsiteRedirectProtocol)) + }) +} + +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{ + 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.ErrInvalidWebsiteRedirectProtocol)) + }) +} + +func PutBucketWebsite_empty_routing_rule_condition(s *S3Conf) error { + testName := "PutBucketWebsite_empty_routing_rule_condition" + 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{ + { + Condition: &types.Condition{}, + Redirect: &types.Redirect{ + HostName: getPtr("example.com"), + }, + }, + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)) + }) +} + +func PutBucketWebsite_empty_routing_rule_redirect(s *S3Conf) error { + testName := "PutBucketWebsite_empty_routing_rule_redirect" + 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{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("docs/"), + }, + Redirect: &types.Redirect{}, + }, + }, + }, + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrMalformedXML)) + }) +} + +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)) + }) +} + +func PutBucketWebsite_success(s *S3Conf) error { + testName := "PutBucketWebsite_success" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + }, + }) + cancel() + if err != nil { + return err + } + + return nil + }) +} + +func PutBucketWebsite_success_redirect_all(s *S3Conf) error { + testName := "PutBucketWebsite_success_redirect_all" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("example.com"), + Protocol: types.ProtocolHttps, + }, + }, + }) + cancel() + if err != nil { + return err + } + + return nil + }) +} diff --git a/tests/integration/PutObject.go b/tests/integration/PutObject.go index c0eb48da..f27db548 100644 --- a/tests/integration/PutObject.go +++ b/tests/integration/PutObject.go @@ -629,6 +629,19 @@ func PutObject_with_metadata(s *S3Conf) error { }) } +func PutObject_invalid_website_redirect_location(s *S3Conf) error { + testName := "PutObject_invalid_website_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-obj" + _, err := putObjectWithData(10, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &obj, + WebsiteRedirectLocation: getPtr("ftp://example.com"), + }, s3client) + return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidRedirectLocation)) + }) +} + func PutObject_checksum_algorithm_and_header_mismatch(s *S3Conf) error { testName := "PutObject_checksum_algorithm_and_header_mismatch" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/WebsiteHosting.go b/tests/integration/WebsiteHosting.go new file mode 100644 index 00000000..3fde8464 --- /dev/null +++ b/tests/integration/WebsiteHosting.go @@ -0,0 +1,864 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "fmt" + "net/http" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/versity/versitygw/s3err" +) + +// WebsiteHosting_error_document_served tests that a missing website object +// serves the configured error document while preserving the original 404 status. +func WebsiteHosting_error_document_served(s *S3Conf) error { + testName := "WebsiteHosting_error_document_served" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + errorContent := "Custom Error Page" + _, err = putObjectWithData(int64(len(errorContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(errorContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "nonexistent-key", nil) + if err != nil { + return err + } + + if got := resp.Header.Get("Content-Type"); got != "text/html" { + return fmt.Errorf("expected text/html Content-Type, got %q", got) + } + return checkWebsiteResponse(resp, http.StatusNotFound, []byte(errorContent)) + }) +} + +// WebsiteHosting_error_document_not_found tests that a missing configured +// error document returns the complete website NoSuchKey error response. +func WebsiteHosting_error_document_not_found(s *S3Conf) error { + testName := "WebsiteHosting_error_document_not_found" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "nonexistent-key", nil) + if err != nil { + return err + } + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoSuchKey)) + }) +} + +// WebsiteHosting_no_error_document tests that a website bucket without an +// error document returns the complete website NoSuchKey error response. +func WebsiteHosting_no_error_document(s *S3Conf) error { + testName := "WebsiteHosting_no_error_document" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "nonexistent-key", nil) + if err != nil { + return err + } + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoSuchKey)) + }) +} + +// WebsiteHosting_no_bucket_in_request_location tests that website endpoint +// requests that cannot resolve a bucket still include a useful Location header. +func WebsiteHosting_no_bucket_in_request_location(s *S3Conf) error { + testName := "WebsiteHosting_no_bucket_in_request_location" + return actionHandlerNoSetup(s, testName, func(_ *s3.Client, _ string) error { + _, domain, port := websiteEndpointParts(s) + badHost := "nested.bucket." + domain + baseHost := domain + if port != "" { + badHost = fmt.Sprintf("%s:%s", badHost, port) + baseHost = fmt.Sprintf("%s:%s", baseHost, port) + } + + reqURL, err := websiteAbsoluteURL(s, badHost, "/") + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return fmt.Errorf("failed to create website request: %w", err) + } + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + wantLocation, err := websiteAbsoluteURL(s, baseHost, "/") + if err != nil { + return err + } + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) + } + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrNoBucketInRequest)) + }) +} + +// WebsiteHosting_private_object_and_error_document tests that website hosting +// does not serve either the requested object or the configured error document +// unless public object access has been granted. +func WebsiteHosting_private_object_and_error_document(s *S3Conf) error { + testName := "WebsiteHosting_private_object_and_error_document" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + }) + if err != nil { + return err + } + privateError := "private error" + _, err = putObjectWithData(int64(len(privateError)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(privateError), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "private.html", nil) + if err != nil { + return err + } + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrAccessDenied)) + }) +} + +// WebsiteHosting_routing_rule_post_request_redirect tests that a post-request +// routing rule matching a 404 issues a redirect instead of serving an error. +func WebsiteHosting_routing_rule_post_request_redirect(s *S3Conf) error { + testName := "WebsiteHosting_routing_rule_post_request_redirect" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + HostName: getPtr("fallback.example.com"), + ReplaceKeyWith: getPtr("not-found"), + HttpRedirectCode: getPtr("302"), + }, + }, + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "missing-page", nil) + if err != nil { + return err + } + + wantLocation, err := websiteAbsoluteURL(s, "fallback.example.com", "not-found") + if err != nil { + return err + } + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) + } + return checkWebsiteResponse(resp, http.StatusFound, nil) + }) +} + +// WebsiteHosting_routing_rule_pre_request_redirect tests that a key-prefix +// routing rule redirects before public access or object existence is checked. +func WebsiteHosting_routing_rule_pre_request_redirect(s *S3Conf) error { + testName := "WebsiteHosting_routing_rule_pre_request_redirect" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("old-docs/"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("new-docs/"), + HttpRedirectCode: getPtr("301"), + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "old-docs/page.html", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + wantLocation, err := websiteURL(s, bucket, "new-docs/page.html") + if err != nil { + return err + } + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) + } + return checkWebsiteResponse(resp, http.StatusMovedPermanently, nil) + }) +} + +// WebsiteHosting_routing_rule_prefix_and_error_redirect tests a routing rule +// with both KeyPrefixEquals and HttpErrorCodeReturnedEquals conditions. +func WebsiteHosting_routing_rule_prefix_and_error_redirect(s *S3Conf) error { + testName := "WebsiteHosting_routing_rule_prefix_and_error_redirect" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("old/"), + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("archived/"), + HttpRedirectCode: getPtr("307"), + }, + }, + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "old/missing.html?ref=1", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + wantLocation, err := websiteURL(s, bucket, "archived/missing.html?ref=1") + if err != nil { + return err + } + if got := resp.Header.Get("Location"); got != wantLocation { + return fmt.Errorf("expected Location %q, got %q", wantLocation, got) + } + return checkWebsiteResponse(resp, http.StatusTemporaryRedirect, nil) + }) +} + +// WebsiteHosting_routing_rule_no_match_serves_error_document tests that routing +// rules which do not match fall back to the configured error document. +func WebsiteHosting_routing_rule_no_match_serves_error_document(s *S3Conf) error { + testName := "WebsiteHosting_routing_rule_no_match_serves_error_document" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("docs/"), + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("archive/"), + }, + }, + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + errorContent := "fallback error" + _, err = putObjectWithData(int64(len(errorContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(errorContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "images/missing.png", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkWebsiteResponse(resp, http.StatusNotFound, []byte(errorContent)) + }) +} + +// WebsiteHosting_redirect_all_requests tests RedirectAllRequestsTo, including +// path and query preservation, without requiring public object access. +func WebsiteHosting_redirect_all_requests(s *S3Conf) error { + testName := "WebsiteHosting_redirect_all_requests" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + RedirectAllRequestsTo: &types.RedirectAllRequestsTo{ + HostName: getPtr("www.example.com"), + Protocol: types.ProtocolHttps, + }, + }) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "any/path/here?tracking=1", nil) + if err != nil { + return err + } + defer resp.Body.Close() + + if got, want := resp.Header.Get("Location"), "https://www.example.com/any/path/here?tracking=1"; got != want { + return fmt.Errorf("expected Location %q, got %q", want, got) + } + return checkWebsiteResponse(resp, http.StatusMovedPermanently, nil) + }) +} + +// WebsiteHosting_object_redirect_location tests that an object-level website +// redirect emits a 301 with the stored Location after object fetch succeeds. +func WebsiteHosting_object_redirect_location(s *S3Conf) error { + testName := "WebsiteHosting_object_redirect_location" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + redirectLocation := "/new-page.html" + objectBody := "old" + _, err = putObjectWithData(int64(len(objectBody)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("old-page.html"), + Body: strings.NewReader(objectBody), + ContentType: getPtr("text/html"), + WebsiteRedirectLocation: &redirectLocation, + }, s3client) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "old-page.html", nil) + if err != nil { + return err + } + + if got := resp.Header.Get("Location"); got != redirectLocation { + return fmt.Errorf("expected Location %q, got %q", redirectLocation, got) + } + return checkWebsiteResponse(resp, http.StatusMovedPermanently, nil) + }) +} + +// WebsiteHosting_index_document tests root and directory-style index document +// resolution through the website endpoint. +func WebsiteHosting_index_document(s *S3Conf) error { + testName := "WebsiteHosting_index_document" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + indexContent := "Welcome" + _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("index.html"), + Body: strings.NewReader(indexContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + docsContent := "Docs Home" + _, err = putObjectWithData(int64(len(docsContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("docs/index.html"), + Body: strings.NewReader(docsContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + for _, test := range []struct { + path string + body string + }{ + {"/", indexContent}, + {"docs/", docsContent}, + } { + resp, err := websiteGet(s, bucket, test.path, nil) + if err != nil { + return err + } + + err = checkWebsiteResponse(resp, http.StatusOK, []byte(test.body)) + resp.Body.Close() + if err != nil { + return fmt.Errorf("%s: %w", test.path, err) + } + } + + return nil + }) +} + +// WebsiteHosting_index_error_document_and_routing_rules covers a combined +// website configuration with index, error document, pre-rule, and post-rule. +func WebsiteHosting_index_error_document_and_routing_rules(s *S3Conf) error { + testName := "WebsiteHosting_index_error_document_and_routing_rules" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + ErrorDocument: &types.ErrorDocument{ + Key: getPtr("error.html"), + }, + RoutingRules: []types.RoutingRule{ + { + Condition: &types.Condition{ + KeyPrefixEquals: getPtr("legacy/"), + }, + Redirect: &types.Redirect{ + ReplaceKeyPrefixWith: getPtr("docs/"), + HttpRedirectCode: getPtr("301"), + }, + }, + { + Condition: &types.Condition{ + HttpErrorCodeReturnedEquals: getPtr("404"), + }, + Redirect: &types.Redirect{ + HostName: getPtr("fallback.example.com"), + ReplaceKeyWith: getPtr("missing"), + HttpRedirectCode: getPtr("302"), + }, + }, + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + indexContent := "combined index" + _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("index.html"), + Body: strings.NewReader(indexContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + combinedError := "combined error" + _, err = putObjectWithData(int64(len(combinedError)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("error.html"), + Body: strings.NewReader(combinedError), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + indexResp, err := websiteGet(s, bucket, "/", nil) + if err != nil { + return err + } + if err := checkWebsiteResponse(indexResp, http.StatusOK, []byte(indexContent)); err != nil { + return err + } + indexResp.Body.Close() + + preResp, err := websiteGet(s, bucket, "legacy/page.html", nil) + if err != nil { + return err + } + wantPreLocation, err := websiteURL(s, bucket, "docs/page.html") + if err != nil { + preResp.Body.Close() + return err + } + if got := preResp.Header.Get("Location"); got != wantPreLocation { + preResp.Body.Close() + return fmt.Errorf("expected pre-rule Location %q, got %q", wantPreLocation, got) + } + if err := checkWebsiteResponse(preResp, http.StatusMovedPermanently, nil); err != nil { + return err + } + + postResp, err := websiteGet(s, bucket, "unknown.html", nil) + if err != nil { + return err + } + wantPostLocation, err := websiteAbsoluteURL(s, "fallback.example.com", "missing") + if err != nil { + postResp.Body.Close() + return err + } + if got := postResp.Header.Get("Location"); got != wantPostLocation { + postResp.Body.Close() + return fmt.Errorf("expected post-rule Location %q, got %q", wantPostLocation, got) + } + if err := checkWebsiteResponse(postResp, http.StatusFound, nil); err != nil { + return err + } + + return nil + }) +} + +func WebsiteHosting_options_preflight_access_granted(s *S3Conf) error { + testName := "WebsiteHosting_options_preflight_access_granted" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"https://client.example"}, + AllowedMethods: []string{http.MethodGet, http.MethodHead}, + AllowedHeaders: []string{"Content-Type", "X-Amz-Date"}, + ExposeHeaders: []string{"Content-Length"}, + MaxAgeSeconds: getPtr(int32(42)), + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteOptions(s, bucket, "index.html", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + "Access-Control-Request-Headers": "content-type, X-Amz-Date", + }) + if err != nil { + return err + } + + corsHeaders, err := extractCORSHeaders(resp) + if err != nil { + return err + } + if err := comparePreflightResult(&PreflightResult{ + Origin: "https://client.example", + Methods: "GET, HEAD", + AllowHeaders: "content-type, x-amz-date", + ExposeHeaders: "Content-Length", + MaxAge: "42", + AllowCredentials: "true", + Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + }, corsHeaders); err != nil { + return err + } + + return checkWebsiteResponse(resp, http.StatusOK, nil) + }) +} + +func WebsiteHosting_get_cors_headers(s *S3Conf) error { + testName := "WebsiteHosting_get_cors_headers" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + indexContent := "CORS GET" + _, err = putObjectWithData(int64(len(indexContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("index.html"), + Body: strings.NewReader(indexContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + maxAge := int32(42) + err = putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"https://client.example"}, + AllowedMethods: []string{http.MethodGet, http.MethodHead}, + ExposeHeaders: []string{"Content-Length"}, + MaxAgeSeconds: &maxAge, + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteGet(s, bucket, "/", map[string]string{ + "Origin": "https://client.example", + }) + if err != nil { + return err + } + + corsHeaders, err := extractCORSHeaders(resp) + if err != nil { + resp.Body.Close() + return err + } + if err := comparePreflightResult(&PreflightResult{ + Origin: "https://client.example", + Methods: "GET, HEAD", + ExposeHeaders: "Content-Length, ETag, x-amz-storage-class", + MaxAge: "42", + AllowCredentials: "true", + Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + }, corsHeaders); err != nil { + resp.Body.Close() + return err + } + + return checkWebsiteResponse(resp, http.StatusOK, []byte(indexContent)) + }) +} + +func WebsiteHosting_head_cors_headers(s *S3Conf) error { + testName := "WebsiteHosting_head_cors_headers" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketWebsiteConfig(s3client, bucket, &types.WebsiteConfiguration{ + IndexDocument: &types.IndexDocument{ + Suffix: getPtr("index.html"), + }, + }) + if err != nil { + return err + } + if err := grantPublicBucketPolicy(s3client, bucket, policyTypeObject); err != nil { + return err + } + + headContent := "CORS HEAD" + _, err = putObjectWithData(int64(len(headContent)), &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("head.html"), + Body: strings.NewReader(headContent), + ContentType: getPtr("text/html"), + }, s3client) + if err != nil { + return err + } + + err = putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{http.MethodHead}, + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteHead(s, bucket, "head.html", map[string]string{ + "Origin": "https://client.example", + }) + if err != nil { + return err + } + + corsHeaders, err := extractCORSHeaders(resp) + if err != nil { + resp.Body.Close() + return err + } + if err := comparePreflightResult(&PreflightResult{ + Origin: "*", + Methods: "HEAD", + ExposeHeaders: "ETag, x-amz-storage-class", + AllowCredentials: "false", + Vary: "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + }, corsHeaders); err != nil { + resp.Body.Close() + return err + } + + return checkWebsiteResponse(resp, http.StatusOK, nil) + }) +} + +func WebsiteHosting_options_preflight_access_forbidden(s *S3Conf) error { + testName := "WebsiteHosting_options_preflight_access_forbidden" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + err := putBucketCors(s3client, &s3.PutBucketCorsInput{ + Bucket: &bucket, + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedOrigins: []string{"https://client.example"}, + AllowedMethods: []string{http.MethodHead}, + }, + }, + }, + }) + if err != nil { + return err + } + + resp, err := websiteOptions(s, bucket, "index.html", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + }) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkWebsiteErrorResponse(resp, + s3err.GetAccessForbiddenErr(s3err.ErrCORSForbidden, http.MethodOptions, s3err.ResourceTypeObject)) + }) +} + +func WebsiteHosting_options_preflight_missing_origin(s *S3Conf) error { + testName := "WebsiteHosting_options_preflight_missing_origin" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + resp, err := websiteOptions(s, bucket, "index.html", map[string]string{ + "Access-Control-Request-Method": http.MethodGet, + }) + if err != nil { + return err + } + defer resp.Body.Close() + + return checkWebsiteErrorResponse(resp, s3err.GetAPIError(s3err.ErrMissingCORSOrigin)) + }) +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index fe8b19d5..9dc93c73 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -203,6 +203,7 @@ func TestPutObject(ts *TestState) { ts.Run(PutObject_invalid_object_names) ts.Run(PutObject_object_acl_not_supported) ts.Run(PutObject_long_metadata) + ts.Run(PutObject_invalid_website_redirect_location) } func TestHeadObject(ts *TestState) { @@ -370,6 +371,7 @@ func TestCopyObject(ts *TestState) { ts.Run(CopyObject_non_existing_dir_object) ts.Run(CopyObject_should_copy_meta_props) ts.Run(CopyObject_should_replace_meta_props) + ts.Run(CopyObject_invalid_website_redirect_location) ts.Run(CopyObject_default_content_type_with_replace_metadata) ts.Run(CopyObject_missing_bucket_lock) ts.Run(CopyObject_invalid_legal_hold) @@ -420,6 +422,7 @@ func TestCreateMultipartUpload(ts *TestState) { ts.Run(CreateMultipartUpload_non_existing_bucket) ts.Run(CreateMultipartUpload_long_metadata) ts.Run(CreateMultipartUpload_with_metadata) + ts.Run(CreateMultipartUpload_invalid_website_redirect_location) ts.Run(CreateMultipartUpload_with_tagging) ts.Run(CreateMultipartUpload_with_object_lock) ts.Run(CreateMultipartUpload_with_object_lock_not_enabled) @@ -663,6 +666,58 @@ func TestDeleteBucketCors(ts *TestState) { ts.Run(DeleteBucketCors_success) } +func TestPutBucketWebsite(ts *TestState) { + ts.Run(PutBucketWebsite_non_existing_bucket) + ts.Run(PutBucketWebsite_empty_suffix) + ts.Run(PutBucketWebsite_suffix_with_slash) + ts.Run(PutBucketWebsite_invalid_redirect_protocol) + ts.Run(PutBucketWebsite_redirectAll_index_error_routingRules) + ts.Run(PutBucketWebsite_invalid_routing_rule_protocol) + ts.Run(PutBucketWebsite_empty_routing_rule_condition) + ts.Run(PutBucketWebsite_empty_routing_rule_redirect) + 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) +} + +func TestGetBucketWebsite(ts *TestState) { + ts.Run(GetBucketWebsite_non_existing_bucket) + ts.Run(GetBucketWebsite_no_such_website_config) + ts.Run(GetBucketWebsite_success) + ts.Run(GetBucketWebsite_success_redirect_all) +} + +func TestDeleteBucketWebsite(ts *TestState) { + ts.Run(DeleteBucketWebsite_non_existing_bucket) + ts.Run(DeleteBucketWebsite_success) +} + +func TestWebsiteHosting(ts *TestState) { + ts.Run(WebsiteHosting_error_document_served) + ts.Run(WebsiteHosting_error_document_not_found) + ts.Run(WebsiteHosting_no_error_document) + ts.Run(WebsiteHosting_no_bucket_in_request_location) + 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_object_redirect_location) + 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) { ts.Run(PreflightOPTIONS_non_existing_bucket) ts.Run(PreflightOPTIONS_missing_origin) @@ -788,10 +843,6 @@ func TestNotImplementedActions(ts *TestState) { // bucket acceleration actions ts.Run(PutBucketAccelerateConfiguration_not_implemented) ts.Run(GetBucketAccelerateConfiguration_not_implemented) - // bucket website actions - ts.Run(PutBucketWebsite_not_implemented) - ts.Run(GetBucketWebsite_not_implemented) - ts.Run(DeleteBucketWebsite_not_implemented) // object acl actions ts.Run(PutObjectAcl_not_implemented) ts.Run(GetObjectAcl_not_implemented) @@ -862,6 +913,9 @@ func TestFullFlow(ts *TestState) { TestPutBucketCors(ts) TestGetBucketCors(ts) TestDeleteBucketCors(ts) + TestPutBucketWebsite(ts) + TestGetBucketWebsite(ts) + TestDeleteBucketWebsite(ts) TestPreflightOPTIONSEndpoint(ts) TestPutObjectLockConfiguration(ts) TestGetObjectLockConfiguration(ts) @@ -1218,6 +1272,7 @@ func TestPostObject(ts *TestState) { ts.Run(PostObject_success_status_201) ts.Run(PostObject_should_ignore_anything_after_file) ts.Run(PostObject_success_with_meta_properties) + ts.Run(PostObject_invalid_website_redirect_location) ts.Run(PostObject_invalid_tagging) ts.Run(PostObject_success_with_tagging) ts.Run(PostObject_success_double_dash_boundary) @@ -1349,6 +1404,7 @@ func GetIntTests() IntTests { "PutObject_should_combine_metadata": PutObject_should_combine_metadata, "PutObject_long_metadata": PutObject_long_metadata, "PutObject_with_metadata": PutObject_with_metadata, + "PutObject_invalid_website_redirect_location": PutObject_invalid_website_redirect_location, "PutObject_invalid_credentials": PutObject_invalid_credentials, "PutObject_checksum_algorithm_and_header_mismatch": PutObject_checksum_algorithm_and_header_mismatch, "PutObject_multiple_checksum_headers": PutObject_multiple_checksum_headers, @@ -1553,6 +1609,7 @@ func GetIntTests() IntTests { "CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object, "CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props, "CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props, + "CopyObject_invalid_website_redirect_location": CopyObject_invalid_website_redirect_location, "CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata, "CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock, "CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold, @@ -1587,6 +1644,7 @@ func GetIntTests() IntTests { "CreateMultipartUpload_non_existing_bucket": CreateMultipartUpload_non_existing_bucket, "CreateMultipartUpload_long_metadata": CreateMultipartUpload_long_metadata, "CreateMultipartUpload_with_metadata": CreateMultipartUpload_with_metadata, + "CreateMultipartUpload_invalid_website_redirect_location": CreateMultipartUpload_invalid_website_redirect_location, "CreateMultipartUpload_with_tagging": CreateMultipartUpload_with_tagging, "CreateMultipartUpload_with_object_lock": CreateMultipartUpload_with_object_lock, "CreateMultipartUpload_with_object_lock_not_enabled": CreateMultipartUpload_with_object_lock_not_enabled, @@ -1760,6 +1818,46 @@ func GetIntTests() IntTests { "DeleteBucketCors_non_existing_bucket": DeleteBucketCors_non_existing_bucket, "DeleteBucketCors_success": DeleteBucketCors_success, "PutBucketCors_success": PutBucketCors_success, + "PutBucketWebsite_non_existing_bucket": PutBucketWebsite_non_existing_bucket, + "PutBucketWebsite_empty_suffix": PutBucketWebsite_empty_suffix, + "PutBucketWebsite_suffix_with_slash": PutBucketWebsite_suffix_with_slash, + "PutBucketWebsite_invalid_redirect_protocol": PutBucketWebsite_invalid_redirect_protocol, + "PutBucketWebsite_redirectAll_index_error_routingRules": PutBucketWebsite_redirectAll_index_error_routingRules, + "PutBucketWebsite_invalid_routing_rule_protocol": PutBucketWebsite_invalid_routing_rule_protocol, + "PutBucketWebsite_empty_routing_rule_condition": PutBucketWebsite_empty_routing_rule_condition, + "PutBucketWebsite_empty_routing_rule_redirect": PutBucketWebsite_empty_routing_rule_redirect, + "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, + "GetBucketWebsite_no_such_website_config": GetBucketWebsite_no_such_website_config, + "GetBucketWebsite_success": GetBucketWebsite_success, + "GetBucketWebsite_success_redirect_all": GetBucketWebsite_success_redirect_all, + "DeleteBucketWebsite_non_existing_bucket": DeleteBucketWebsite_non_existing_bucket, + "DeleteBucketWebsite_success": DeleteBucketWebsite_success, + "WebsiteHosting_error_document_served": WebsiteHosting_error_document_served, + "WebsiteHosting_error_document_not_found": WebsiteHosting_error_document_not_found, + "WebsiteHosting_no_error_document": WebsiteHosting_no_error_document, + "WebsiteHosting_no_bucket_in_request_location": WebsiteHosting_no_bucket_in_request_location, + "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_object_redirect_location": WebsiteHosting_object_redirect_location, + "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, @@ -1846,9 +1944,6 @@ func GetIntTests() IntTests { "GetBucketNotificationConfiguratio_not_implemented": GetBucketNotificationConfiguratio_not_implemented, "PutBucketAccelerateConfiguration_not_implemented": PutBucketAccelerateConfiguration_not_implemented, "GetBucketAccelerateConfiguration_not_implemented": GetBucketAccelerateConfiguration_not_implemented, - "PutBucketWebsite_not_implemented": PutBucketWebsite_not_implemented, - "GetBucketWebsite_not_implemented": GetBucketWebsite_not_implemented, - "DeleteBucketWebsite_not_implemented": DeleteBucketWebsite_not_implemented, "PutObjectAcl_not_implemented": PutObjectAcl_not_implemented, "GetObjectAcl_not_implemented": GetObjectAcl_not_implemented, "WORMProtection_bucket_object_lock_configuration_compliance_mode": WORMProtection_bucket_object_lock_configuration_compliance_mode, @@ -2067,6 +2162,7 @@ func GetIntTests() IntTests { "PostObject_success_status_201": PostObject_success_status_201, "PostObject_should_ignore_anything_after_file": PostObject_should_ignore_anything_after_file, "PostObject_success_with_meta_properties": PostObject_success_with_meta_properties, + "PostObject_invalid_website_redirect_location": PostObject_invalid_website_redirect_location, "PostObject_invalid_tagging": PostObject_invalid_tagging, "PostObject_success_with_tagging": PostObject_success_with_tagging, "PostObject_invalid_checksum_value": PostObject_invalid_checksum_value, diff --git a/tests/integration/s3conf.go b/tests/integration/s3conf.go index e49b892b..9b7ad8e0 100644 --- a/tests/integration/s3conf.go +++ b/tests/integration/s3conf.go @@ -36,6 +36,9 @@ type S3Conf struct { awsSecret string awsRegion string endpoint string + websiteScheme string + websiteDomain string + websitePort string hostStyle bool checksumDisable bool PartSize int64 @@ -64,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 @@ -85,6 +91,15 @@ func WithRegion(r string) Option { func WithEndpoint(e string) Option { return func(s *S3Conf) { s.endpoint = e } } +func WithWebsiteScheme(scheme string) Option { + return func(s *S3Conf) { s.websiteScheme = scheme } +} +func WithWebsiteDomain(d string) Option { + return func(s *S3Conf) { s.websiteDomain = d } +} +func WithWebsitePort(p string) Option { + return func(s *S3Conf) { s.websitePort = p } +} func WithDisableChecksum() Option { return func(s *S3Conf) { s.checksumDisable = true } } diff --git a/tests/integration/utils.go b/tests/integration/utils.go index 09a29b44..2a224342 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -452,6 +452,147 @@ func checkHTTPResponseApiErr(resp *http.Response, expected s3err.S3Error) error return compareS3ApiError(expected, &errResp) } +// websiteGet issues a plain HTTP GET to the dedicated website endpoint. +// The bucket is resolved from the request URL host. No S3 signing is applied. +func websiteGet(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) { + return websiteRequest(s, http.MethodGet, bucket, path, headers) +} + +func websiteHead(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) { + return websiteRequest(s, http.MethodHead, bucket, path, headers) +} + +func websiteOptions(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) { + return websiteRequest(s, http.MethodOptions, bucket, path, headers) +} + +func websiteRequest(s *S3Conf, method, bucket, path string, headers map[string]string) (*http.Response, error) { + reqURL, err := websiteURL(s, bucket, path) + if err != nil { + return nil, err + } + req, err := http.NewRequest(method, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create website request: %w", err) + } + for key, val := range headers { + req.Header.Set(key, val) + } + + return s.httpClient.Do(req) +} + +func websiteHost(s *S3Conf, bucket string) string { + _, domain, port := websiteEndpointParts(s) + + host := fmt.Sprintf("%s.%s", bucket, domain) + if port != "" { + host = fmt.Sprintf("%s:%s", host, port) + } + return host +} + +func websiteURL(s *S3Conf, bucket, path string) (string, error) { + return websiteAbsoluteURL(s, websiteHost(s, bucket), path) +} + +func websiteAbsoluteURL(s *S3Conf, host, path string) (string, error) { + scheme, _, _ := websiteEndpointParts(s) + + rel, err := url.Parse("/" + strings.TrimLeft(path, "/")) + if err != nil { + return "", fmt.Errorf("parse website request path: %w", err) + } + + return (&url.URL{ + Scheme: scheme, + Host: host, + Path: rel.Path, + RawQuery: rel.RawQuery, + }).String(), nil +} + +func websiteEndpointParts(s *S3Conf) (scheme, domain, port string) { + scheme = strings.ToLower(strings.TrimSpace(s.websiteScheme)) + domain = strings.TrimSpace(s.websiteDomain) + port = strings.TrimPrefix(strings.TrimSpace(s.websitePort), ":") + + return scheme, domain, port +} + +func putBucketWebsiteConfig(client *s3.Client, bucket string, config *types.WebsiteConfiguration) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{ + Bucket: &bucket, + WebsiteConfiguration: config, + }) + cancel() + return err +} + +func checkWebsiteResponse(resp *http.Response, expectedStatus int, expectedBody []byte) error { + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read website response body: %w", err) + } + + if resp.StatusCode != expectedStatus { + return fmt.Errorf("expected status %v, got %v; body: %s", expectedStatus, resp.StatusCode, body) + } + + return compareBodySHA256(expectedBody, body) +} + +func checkWebsiteErrorResponse(resp *http.Response, expected s3err.S3Error) error { + apiErr := expected.BaseError() + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read website error body: %w", err) + } + + if resp.StatusCode != apiErr.HTTPStatusCode { + return fmt.Errorf("expected status %v, got %v; body: %s", apiErr.HTTPStatusCode, resp.StatusCode, body) + } + if got := resp.Header.Get("x-amz-error-code"); got != apiErr.Code { + return fmt.Errorf("expected x-amz-error-code %q, got %q", apiErr.Code, got) + } + if got := resp.Header.Get("x-amz-error-message"); got != apiErr.Description { + return fmt.Errorf("expected x-amz-error-message %q, got %q", apiErr.Description, got) + } + requestID := resp.Header.Get("x-amz-request-id") + if requestID == "" { + return fmt.Errorf("expected x-amz-request-id header") + } + hostID := resp.Header.Get("x-amz-id-2") + if hostID == "" { + return fmt.Errorf("expected x-amz-id-2 header") + } + if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "text/html") { + return fmt.Errorf("expected html Content-Type, got %q", got) + } + if methodErr, ok := expected.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 { + if got, want := resp.Header.Get("Allow"), methodErr.AllowedMethodsString(); got != want { + return fmt.Errorf("expected Allow header %q, got %q", want, got) + } + } + + expectedBody := expected.HTMLBody(requestID, hostID) + return compareBodySHA256(expectedBody, body) +} + +func compareBodySHA256(expected, actual []byte) error { + expectedSum := sha256.Sum256(expected) + actualSum := sha256.Sum256(actual) + if expectedSum != actualSum { + return fmt.Errorf("body checksum mismatch: expected sha256 %x, got %x; expected body %q, got %q", + expectedSum, actualSum, string(expected), string(actual)) + } + + return nil +} + func compareS3ApiError(expected s3err.S3Error, received *APIErrorResponse) error { apiErr := expected.BaseError() if received == nil { @@ -1995,14 +2136,15 @@ func compareDelMarkers(d1, d2 []types.DeleteMarkerEntry) bool { } type ObjectMetaProps struct { - ContentLength int64 - ContentType string - ContentEncoding string - ContentDisposition string - ContentLanguage string - CacheControl string - ExpiresString string - Metadata map[string]string + ContentLength int64 + ContentType string + ContentEncoding string + ContentDisposition string + ContentLanguage string + CacheControl string + ExpiresString string + WebsiteRedirectLocation string + Metadata map[string]string } func checkObjectMetaProps(client *s3.Client, bucket, object string, o ObjectMetaProps) error { @@ -2045,6 +2187,9 @@ func checkObjectMetaProps(client *s3.Client, bucket, object string, o ObjectMeta if o.ExpiresString != "" && getString(out.ExpiresString) != o.ExpiresString { return fmt.Errorf("expected Expires %v, instead got %v", o.ExpiresString, getString(out.ExpiresString)) } + if o.WebsiteRedirectLocation != "" && getString(out.WebsiteRedirectLocation) != o.WebsiteRedirectLocation { + return fmt.Errorf("expected WebsiteRedirectLocation %v, instead got %v", o.WebsiteRedirectLocation, getString(out.WebsiteRedirectLocation)) + } if out.StorageClass != types.StorageClassStandard { return fmt.Errorf("expected the storage class to be %v, instead got %v", types.StorageClassStandard, out.StorageClass) } diff --git a/tests/test_rest_not_implemented.sh b/tests/test_rest_not_implemented.sh index 2604add5..3366bc44 100755 --- a/tests/test_rest_not_implemented.sh +++ b/tests/test_rest_not_implemented.sh @@ -162,21 +162,6 @@ source ./tests/setup.sh assert_success } -@test "REST - GetBucketWebsite" { - run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "GET" - assert_success -} - -@test "REST - PutBucketWebsite" { - run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "PUT" - assert_success -} - -@test "REST - DeleteBucketWebsite" { - run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "DELETE" - assert_success -} - @test "REST - GetPublicAccessBlock" { run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "publicAccessBlock=" "GET" assert_success diff --git a/tests/website-hosting-tests/dnsmasq.conf b/tests/website-hosting-tests/dnsmasq.conf new file mode 100644 index 00000000..5d7054b9 --- /dev/null +++ b/tests/website-hosting-tests/dnsmasq.conf @@ -0,0 +1,2 @@ +address=/.dev/10.89.0.10 +no-resolv diff --git a/tests/website-hosting-tests/docker-compose.yml b/tests/website-hosting-tests/docker-compose.yml new file mode 100644 index 00000000..637cb207 --- /dev/null +++ b/tests/website-hosting-tests/docker-compose.yml @@ -0,0 +1,58 @@ +services: + dnsmasq: + image: strm/dnsmasq + container_name: website-dns-resolver + restart: on-failure + volumes: + - './dnsmasq.conf:/etc/dnsmasq.conf' + cap_add: + - NET_ADMIN + healthcheck: + test: 'if [ -z "$(netstat -nltu |grep \:53)" ]; then exit 1;else exit 0;fi' + interval: 2s + timeout: 2s + retries: 20 + networks: + devnet: + ipv4_address: 10.89.0.53 + + server: + build: + context: ../.. + dockerfile: tests/host-style-tests/Dockerfile + depends_on: + dnsmasq: + condition: service_healthy + command: ["-a", "user", "-s", "pass", "--health", "/health", "--iam-dir", "/tmp/vgw", "--website", ":8080", "--website-domain", "dev", "--website-no-tls", "posix", "/tmp/vgw"] + dns: + - 10.89.0.53 + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7070/health"] + interval: 2s + timeout: 2s + retries: 20 + networks: + devnet: + ipv4_address: 10.89.0.10 + + test: + build: + context: ../.. + dockerfile: tests/host-style-tests/Dockerfile + depends_on: + server: + condition: service_healthy + dnsmasq: + condition: service_healthy + command: ["test", "-a", "user", "-s", "pass", "-e", "http://10.89.0.10:7070", "website-hosting", "--scheme", "http", "--domain", "dev", "--port", "8080"] + dns: + - 10.89.0.53 + networks: + devnet: + +networks: + devnet: + driver: bridge + ipam: + config: + - subnet: 10.89.0.0/16 diff --git a/website/handler.go b/website/handler.go new file mode 100644 index 00000000..98c69141 --- /dev/null +++ b/website/handler.go @@ -0,0 +1,570 @@ +// 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 ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "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" +) + +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. +// +// Virtual-host routing with --website-domain example.com: +// - Host "blog.example.com" -> bucket "blog" +// - Host "example.com" -> bucket "example.com" (apex) +// +// Catch-all mode (--website-domain omitted or empty): +// - Host "blog.example.com" -> bucket "blog.example.com" +// - Host "mysite.org" -> bucket "mysite.org" +func newWebsiteController(be backend.Backend, domain string) *websiteController { + controller := &websiteController{ + be: be, + domain: domain, + domainSuffix: "." + domain, + } + controller.applyCORS = middlewares.ApplyBucketCORS(be, controller.resolveBucket, "") + return controller +} + +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) + } + + corsConfig, err := auth.ParseCORSOutput(cors) + if err != nil { + return sendError(ctx, err) + } + + allowConfig, err := corsConfig.IsAllowed(origin, method, parsedHeaders, s3err.ResourceTypeObject) + if err != nil { + debuglogger.Logf("cors access forbidden: %v", err) + return sendError(ctx, err) + } + + setCORSPreflightHeaders(ctx, allowConfig) + ctx.Status(http.StatusOK) + return nil +} + +func registerWebsiteRoutes(app *fiber.App, be backend.Backend, domain string) { + controller := newWebsiteController(be, domain) + + app.Head("*", controller.Head) + app.Get("*", controller.Get) + app.Options("*", controller.Options) + app.All("*", controller.MethodNotAllowed) +} + +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", 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))) + } +} + +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 + } + + fmt.Println(bucket) + + 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). +// - If host ends with ".", the bucket is the subdomain part. +// - Otherwise, no bucket can be resolved. +// +// When domain is empty (catch-all mode): +// - The full hostname is used as the bucket name. +func (c *websiteController) resolveBucket(ctx *fiber.Ctx) (string, error) { + host := ctx.Hostname() + if host == "" { + ctx.Set("Location", c.noBucketLocation(ctx, host)) + return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest) + } + + // Strip port from host if present. Be careful with IPv6: only strip if the + // last colon is not inside brackets. + host = stripHostPort(host) + + 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 + } + } + + ctx.Set("Location", c.noBucketLocation(ctx, ctx.Hostname())) + return "", s3err.GetAPIError(s3err.ErrNoBucketInRequest) +} + +func (c *websiteController) noBucketLocation(ctx *fiber.Ctx, host string) string { + locationHost := c.domain + if locationHost == "" { + locationHost = stripHostPort(host) + } + if locationHost == "" { + return "/" + } + if c.domain != "" { + if port := hostPort(host); port != "" { + locationHost += ":" + port + } + } + + return fmt.Sprintf("%s://%s/", ctx.Protocol(), locationHost) +} + +func stripHostPort(host string) string { + if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") { + return host[:idx] + } + + return host +} + +func hostPort(host string) string { + if idx := strings.LastIndex(host, ":"); idx != -1 && !strings.Contains(host[idx:], "]") { + return host[idx+1:] + } + + return "" +} + +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 + WebsiteRedirectLocation *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, + WebsiteRedirectLocation: result.WebsiteRedirectLocation, + }, + } +} + +func (c *websiteController) headObject(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.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, + WebsiteRedirectLocation: result.WebsiteRedirectLocation, + }, + } +} + +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 = ctx.Protocol() + } + + location := fmt.Sprintf("%s://%s/%s", protocol, redirect.HostName, key) + if query := string(ctx.Request().URI().QueryString()); query != "" { + location += "?" + query + } + return sendRedirect(ctx, http.StatusMovedPermanently, location) +} + +// applyRedirect constructs and sends a redirect response from a routing rule. +func applyRedirect(ctx *fiber.Ctx, redirect *s3response.Redirect, condition *s3response.RoutingRuleCondition, originalKey string) error { + protocol := redirect.Protocol + if protocol == "" { + protocol = ctx.Protocol() + } + + host := redirect.HostName + if host == "" { + host = ctx.Hostname() + } + + key := originalKey + if redirect.ReplaceKeyWith != "" { + key = redirect.ReplaceKeyWith + } else if redirect.ReplaceKeyPrefixWith != "" && condition != nil && condition.KeyPrefixEquals != "" { + key = redirect.ReplaceKeyPrefixWith + strings.TrimPrefix(originalKey, condition.KeyPrefixEquals) + } + + httpCode := http.StatusMovedPermanently + if redirect.HttpRedirectCode != "" { + if code, err := strconv.Atoi(redirect.HttpRedirectCode); err == nil { + httpCode = code + } + } + + location := fmt.Sprintf("%s://%s/%s", protocol, host, key) + if query := string(ctx.Request().URI().QueryString()); query != "" { + location += "?" + query + } + return sendRedirect(ctx, httpCode, location) +} + +func sendRedirect(ctx *fiber.Ctx, statusCode int, location string) error { + ctx.Set("Location", location) + _, _ = utils.EnsureRequestIDs(ctx) + ctx.Status(statusCode) + return nil +} + +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, + } +} + +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, + } +} + +func serveWebsiteResult(ctx *fiber.Ctx, bucket string, config *s3response.WebsiteConfiguration, result websiteResult, readObject websiteObjectReader) error { + if result.Err == nil { + // Precedence: RedirectAllRequestsTo, pre-fetch routing rules, object + // redirect metadata, then post-error routing/error documents. + if location := backend.GetStringFromPtr(result.Object.WebsiteRedirectLocation); location != "" { + if result.Object.Body != nil { + _ = result.Object.Body.Close() + } + return sendRedirect(ctx, http.StatusMovedPermanently, location) + } + return serveObject(ctx, result.Object, http.StatusOK) + } + + 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, 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, readObject websiteObjectReader, bucket, errorDocKey string, statusCode int) error { + result := readObject(ctx, bucket, errorDocKey) + if result.Err != nil { + return sendError(ctx, result.Err) + } + + return serveObject(ctx, result.Object, statusCode) +} + +// sendError sends a simple HTML error page. +func sendError(ctx *fiber.Ctx, err error) error { + requestId, hostId := utils.EnsureRequestIDs(ctx) + serr, ok := err.(s3err.S3Error) + if !ok { + debuglogger.InternalError(err) + serr = s3err.GetAPIError(s3err.ErrInternalError) + } + + ctx.Response().Header.Set("x-amz-error-code", serr.BaseError().Code) + ctx.Response().Header.Set("x-amz-error-message", serr.BaseError().Description) + if methodErr, ok := serr.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 { + ctx.Response().Header.Set("Allow", methodErr.AllowedMethodsString()) + } + + ctx.Response().Header.SetContentType(fiber.MIMETextHTMLCharsetUTF8) + return ctx.Status(serr.StatusCode()).Send(serr.HTMLBody(requestId, hostId)) +} diff --git a/website/handler_test.go b/website/handler_test.go new file mode 100644 index 00000000..85a519b0 --- /dev/null +++ b/website/handler_test.go @@ -0,0 +1,1068 @@ +// 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 + objectRedirects 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" + redirectLocation := redirectPtr(b.objectRedirects[*input.Key]) + return &s3.HeadObjectOutput{ + ContentLength: &length, + ContentType: &contentType, + WebsiteRedirectLocation: redirectLocation, + }, 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" + redirectLocation := redirectPtr(b.objectRedirects[*input.Key]) + return &s3.GetObjectOutput{ + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: &length, + ContentType: &contentType, + WebsiteRedirectLocation: redirectLocation, + }, nil +} + +func redirectPtr(location string) *string { + if location == "" { + return nil + } + return &location +} + +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 TestWebsiteHandlerObjectRedirectLocation(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, map[string]string{ + "old.html": "old", + }, true) + be.objectRedirects["old.html"] = "/new.html" + + resp := websiteRequest(t, be, "/old.html") + 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 != "/new.html" { + t.Fatalf("Location = %q, want %q", got, "/new.html") + } + if got := readBody(t, resp); got != "" { + t.Fatalf("body = %q, want empty body", got) + } +} + +func TestWebsiteHandlerPrefetchRoutingPrecedesObjectRedirect(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + RoutingRules: []s3response.RoutingRule{ + { + Condition: &s3response.RoutingRuleCondition{ + KeyPrefixEquals: "old/", + }, + Redirect: &s3response.Redirect{ + ReplaceKeyPrefixWith: "new/", + HttpRedirectCode: "302", + }, + }, + }, + }, map[string]string{ + "old/page.html": "old", + }, true) + be.objectRedirects["old/page.html"] = "/object-redirect.html" + + resp := websiteRequest(t, be, "/old/page.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/page.html" { + t.Fatalf("Location = %q", got) + } + if containsCall(be.calls, "GetObject") { + t.Fatal("GetObject was called before pre-fetch routing completed") + } +} + +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 TestWebsiteHandlerNoBucketInRequestSetsLocation(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithDomainHostAndHeaders(t, be, "site.test", http.MethodGet, "wrong.test:8080", "/", nil) + 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 != "http://site.test:8080/" { + t.Fatalf("Location = %q, want %q", got, "http://site.test:8080/") + } + if got := resp.Header.Get("x-amz-error-code"); got != "WebsiteRedirect" { + t.Fatalf("x-amz-error-code = %q, want %q", got, "WebsiteRedirect") + } + if len(be.calls) != 0 { + t.Fatalf("request without 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" { + t.Fatalf("Access-Control-Expose-Headers = %q", got) + } + if got := resp.Header.Get("Access-Control-Max-Age"); got != "42" { + t.Fatalf("Access-Control-Max-Age = %q", got) + } + if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("Access-Control-Allow-Credentials = %q", got) + } + if got := resp.Header.Get("Vary"); got != "Origin, Access-Control-Request-Headers, Access-Control-Request-Method" { + t.Fatalf("Vary = %q", got) + } + if got := readBody(t, resp); got != "" { + t.Fatalf("body = %q, want empty body", got) + } + if !containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was not called: %v", be.calls) + } + for _, unexpected := range []string{"GetBucketWebsite", "GetObject", "HeadObject", "GetBucketAcl"} { + if containsCall(be.calls, unexpected) { + t.Fatalf("%s was called for OPTIONS request: %v", unexpected, be.calls) + } + } +} + +func TestWebsiteHandlerOptionsMissingOrigin(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{ + "Access-Control-Request-Method": http.MethodGet, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" { + t.Fatalf("x-amz-error-code = %q", got) + } + if containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was called despite missing origin: %v", be.calls) + } +} + +func TestWebsiteHandlerOptionsInvalidRequestMethod(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodOptions, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + if got := resp.Header.Get("x-amz-error-code"); got != "BadRequest" { + t.Fatalf("x-amz-error-code = %q", got) + } + if containsCall(be.calls, "GetBucketCors") { + t.Fatalf("GetBucketCors was called despite invalid request method: %v", be.calls) + } +} + +func TestWebsiteHandlerOptionsUnsetBucketCORS(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + be.corsErr = s3err.GetAPIError(s3err.ErrNoSuchCORSConfiguration) + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) + } + if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" { + t.Fatalf("x-amz-error-code = %q", got) + } + body := readBody(t, resp) + for _, want := range []string{ + "
  • Method: OPTIONS
  • ", + "
  • ResourceType: BUCKET
  • ", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q: %s", want, body) + } + } +} + +func TestWebsiteHandlerOptionsAccessForbidden(t *testing.T) { + corsConfig, err := xml.Marshal(auth.CORSConfiguration{ + Rules: []auth.CORSRule{ + { + AllowedOrigins: []auth.CORSOrigin{"https://client.example"}, + AllowedMethods: []auth.CORSHTTPMethod{http.MethodHead}, + }, + }, + }) + if err != nil { + t.Fatalf("marshal cors config: %v", err) + } + + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + be.corsConfig = corsConfig + + resp := websiteRequestWithHeaders(t, be, http.MethodOptions, "/index.html", map[string]string{ + "Origin": "https://client.example", + "Access-Control-Request-Method": http.MethodGet, + }) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) + } + if got := resp.Header.Get("x-amz-error-code"); got != "AccessForbidden" { + t.Fatalf("x-amz-error-code = %q", got) + } + body := readBody(t, resp) + for _, want := range []string{ + "
  • Method: OPTIONS
  • ", + "
  • ResourceType: OBJECT
  • ", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q: %s", want, body) + } + } +} + +func TestWebsiteHandlerMethodNotAllowed(t *testing.T) { + be := newWebsiteTestBackend(t, s3response.WebsiteConfiguration{ + IndexDocument: &s3response.IndexDocument{Suffix: "index.html"}, + }, nil, true) + + resp := websiteRequestWithMethod(t, be, http.MethodPut, "/some-key") + defer resp.Body.Close() + + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed) + } + if got := resp.Header.Get("Allow"); got != "GET, HEAD, OPTIONS" { + t.Fatalf("Allow = %q, want %q", got, "GET, HEAD, OPTIONS") + } + if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", got) + } + if got := resp.Header.Get("Server"); got != "VERSITYGW" { + t.Fatalf("Server = %q, want %q", got, "VERSITYGW") + } + + body := readBody(t, resp) + for _, want := range []string{ + "
  • Code: MethodNotAllowed
  • ", + "
  • Method: PUT
  • ", + "
  • ResourceType: OBJECT
  • ", + } { + if !strings.Contains(body, want) { + t.Fatalf("method not allowed body missing %q: %s", want, body) + } + } + if containsCall(be.calls, "GetBucketWebsite") { + t.Fatalf("unmatched method should not load website config: %v", be.calls) + } +} + +func newWebsiteTestBackend(t *testing.T, config s3response.WebsiteConfiguration, objects map[string]string, public bool) *websiteTestBackend { + t.Helper() + + data, err := xml.Marshal(config) + if err != nil { + t.Fatalf("marshal website config: %v", err) + } + if objects == nil { + objects = map[string]string{} + } + + return &websiteTestBackend{ + websiteConfig: data, + objects: objects, + objectRedirects: map[string]string{}, + 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() + + return websiteRequestWithDomainHostAndHeaders(t, be, "", method, host, path, headers) +} + +func websiteRequestWithDomainHostAndHeaders(t *testing.T, be backend.Backend, domain, method, host, path string, headers map[string]string) *http.Response { + t.Helper() + + app := fiber.New(fiber.Config{ + ServerHeader: "VERSITYGW", + }) + registerWebsiteRoutes(app, be, domain) + + req := httptest.NewRequest(method, path, nil) + req.Host = host + req.Header.Set("Host", host) + for key, value := range headers { + req.Header.Set(key, value) + } + resp, err := app.Test(req, -1) + if err != nil { + t.Fatalf("website request failed: %v", err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + return string(body) +} + +func containsCall(calls []string, want string) bool { + return firstCallIndex(calls, want) != -1 +} + +func countCalls(calls []string, want string) int { + var count int + for _, call := range calls { + if call == want { + count++ + } + } + return count +} + +func firstCallIndex(calls []string, want string) int { + for i, call := range calls { + if call == want { + return i + } + } + return -1 +} diff --git a/website/server.go b/website/server.go new file mode 100644 index 00000000..c9c18d88 --- /dev/null +++ b/website/server.go @@ -0,0 +1,145 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package website + +import ( + "fmt" + "net" + "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" +) + +// Server is the static website hosting endpoint. +type Server struct { + app *fiber.App + CertStorage *utils.CertStorage + domain string + quiet bool + socketPerm os.FileMode +} + +// Option sets various options for NewServer(). +type Option func(*Server) + +// WithQuiet silences default logging output. +func WithQuiet() Option { + return func(s *Server) { s.quiet = true } +} + +// WithTLS sets TLS credentials. +func WithTLS(cs *utils.CertStorage) Option { + return func(s *Server) { s.CertStorage = cs } +} + +// WithSocketPerm sets the file-mode permissions applied to file-backed UNIX +// domain sockets after binding. It has no effect on TCP/IP or abstract +// namespace sockets. +func WithSocketPerm(perm os.FileMode) Option { + return func(s *Server) { s.socketPerm = perm } +} + +// NewServer creates a new static website hosting server. +// The domain parameter is the base domain for virtual-host routing: +// - Host "blog." resolves to bucket "blog" +// - Host "" (apex, no subdomain) resolves to bucket "" +func NewServer(be backend.Backend, domain string, opts ...Option) *Server { + app := fiber.New(fiber.Config{ + AppName: "versitygw-website", + ServerHeader: "VERSITYGW", + DisableStartupMessage: true, + Network: fiber.NetworkTCP, + }) + + server := &Server{ + app: app, + domain: domain, + } + + for _, opt := range opts { + opt(server) + } + + domainInfo := "catch-all" + if domain != "" { + domainInfo = "domain: " + domain + } + + // Panic recovery + app.Use(recover.New()) + + // Request logging + if !server.quiet { + fmt.Printf("initializing website endpoint (%s)\n", domainInfo) + app.Use(logger.New(logger.Config{ + Format: "${time} | website | ${status} | ${latency} | ${ip} | ${method} | ${path}\n", + })) + } + + // initialize the debug logger in debug mode + if debuglogger.IsDebugEnabled() { + app.Use(middlewares.DebugLogger()) + } + + registerWebsiteRoutes(app, be, domain) + + return server +} + +// ServeMultiPort creates listeners for multiple address specifications and serves +// on all of them simultaneously. +func (s *Server) ServeMultiPort(ports []string) error { + if len(ports) == 0 { + return fmt.Errorf("no addresses specified") + } + + var listeners []net.Listener + + for _, addrSpec := range ports { + var ln net.Listener + var err error + + if s.CertStorage != nil { + ln, err = utils.NewMultiAddrTLSListener(s.app.Config().Network, addrSpec, s.CertStorage.GetCertificate, utils.ListenerOptions{SocketPerm: s.socketPerm}) + } else { + ln, err = utils.NewMultiAddrListener(s.app.Config().Network, addrSpec, utils.ListenerOptions{SocketPerm: s.socketPerm}) + } + + if err != nil { + return fmt.Errorf("failed to bind website listener %s: %w", addrSpec, err) + } + + listeners = append(listeners, ln) + } + + if len(listeners) == 0 { + return fmt.Errorf("failed to create any website listeners") + } + + finalListener := utils.NewMultiListener(listeners...) + + return s.app.Listener(finalListener) +} + +// Shutdown gracefully shuts down the server. +func (s *Server) Shutdown() error { + return s.app.Shutdown() +} diff --git a/webui/web/explorer.html b/webui/web/explorer.html index b541f708..c2bb4df2 100644 --- a/webui/web/explorer.html +++ b/webui/web/explorer.html @@ -981,6 +981,8 @@ under the License.
  • • s3:GetBucketLocation
  • • s3:GetBucketPolicy
  • • s3:PutBucketTagging
  • +
  • • s3:PutBucketWebsite
  • +
  • • s3:GetBucketWebsite
  • @@ -4181,6 +4183,8 @@ under the License. "s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectTagging", + "s3:PutBucketWebsite", + "s3:GetBucketWebsite", "s3:RestoreObject" ], "Resource": [