Merge pull request #2171 from versity/sis/static-website-hosting

Static website hosting
This commit is contained in:
Ben McClelland
2026-06-10 08:10:18 -07:00
committed by GitHub
87 changed files with 6175 additions and 378 deletions
@@ -0,0 +1,13 @@
name: website hosting tests
permissions: {}
on: pull_request
jobs:
build-and-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: run website hosting tests
run: make test-website-hosting
+9
View File
@@ -107,3 +107,12 @@ test-host-style:
COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \
exit $$status
# Run the static website hosting tests in docker containers
.PHONY: test-website-hosting
test-website-hosting:
@compose_file=tests/website-hosting-tests/docker-compose.yml; \
COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans >/dev/null 2>&1 || true; \
COMPOSE_MENU=false docker compose -f "$$compose_file" up --build --abort-on-container-exit --exit-code-from test; \
status=$$?; \
COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \
exit $$status
+6
View File
@@ -26,6 +26,12 @@ Get more details about the new (optional) WebGUI management/explorer here: [http
![admin-explorer](https://github.com/user-attachments/assets/e99db171-2c72-4d0f-8c8d-480a56e1c8a1)
### Static Website Hosting
Serve S3 buckets as static websites with index documents, custom error pages, and routing rules.
Enable a separate website endpoint with `--website :8090 --website-domain example.com` for virtual-host style routing (`blog.example.com` serves bucket `blog`, `example.com` serves bucket `example.com`).
When `--website-domain` is omitted, catch-all mode is used: the full hostname becomes the bucket name (name your buckets as FQDNs, e.g. `blog.example.com`).
See [Global Options](https://github.com/versity/versitygw/wiki/Global-Options) for all `--website-*` flags.
### News
Check out latest wiki articles: [https://github.com/versity/versitygw/wiki/Articles](https://github.com/versity/versitygw/wiki/Articles)
+2
View File
@@ -91,6 +91,7 @@ const (
GetAccelerateConfigurationAction Action = "s3:GetAccelerateConfiguration"
PutBucketWebsiteAction Action = "s3:PutBucketWebsite"
GetBucketWebsiteAction Action = "s3:GetBucketWebsite"
DeleteBucketWebsiteAction Action = "s3:DeleteBucketWebsite"
GetBucketPolicyStatusAction Action = "s3:GetBucketPolicyStatus"
GetBucketLocationAction Action = "s3:GetBucketLocation"
@@ -167,6 +168,7 @@ var supportedActionList = map[Action]struct{}{
GetAccelerateConfigurationAction: {},
PutBucketWebsiteAction: {},
GetBucketWebsiteAction: {},
DeleteBucketWebsiteAction: {},
GetBucketPolicyStatusAction: {},
GetBucketLocationAction: {},
AllActions: {},
+98 -41
View File
@@ -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)
}
+12
View File
@@ -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)
+53 -8
View File
@@ -436,7 +436,7 @@ func MarshalMpUploadMetadata(mpMeta MpUploadMetadata, base64Encode bool) ([]byte
return nil, fmt.Errorf("marshal mp metadata: %w", err)
}
compressed, err := compressMpUploadMetadata(mpMetaJSON)
compressed, err := CompressData(mpMetaJSON)
if err != nil {
return nil, fmt.Errorf("compress mp metadata: %w", err)
}
@@ -471,7 +471,43 @@ func UnmarshalMpUploadMetadata(data []byte, base64Decode bool) (MpUploadMetadata
return mpMeta, nil
}
func compressMpUploadMetadata(data []byte) ([]byte, error) {
// MarshalWebsiteConfig returns a compressed representation of a website
// configuration. When base64Encode is true, the compressed bytes are
// base64-encoded so they can be stored in azure string-only metadata values.
func MarshalWebsiteConfig(website []byte, base64Encode bool) ([]byte, error) {
compressed, err := CompressData(website)
if err != nil {
return nil, fmt.Errorf("compress website config: %w", err)
}
if !base64Encode {
return compressed, nil
}
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(compressed)))
base64.StdEncoding.Encode(encoded, compressed)
return encoded, nil
}
// UnmarshalWebsiteConfig decodes data produced by MarshalWebsiteConfig.
func UnmarshalWebsiteConfig(data []byte, base64Decode bool) ([]byte, error) {
if base64Decode {
compressed, err := base64.StdEncoding.DecodeString(string(data))
if err != nil {
return nil, fmt.Errorf("decode website config: %w", err)
}
data = compressed
}
website, err := DecompressData(data)
if err != nil {
return nil, fmt.Errorf("decompress website config: %w", err)
}
return website, nil
}
func CompressData(data []byte) ([]byte, error) {
var compressed bytes.Buffer
gz := gzip.NewWriter(&compressed)
if _, err := gz.Write(data); err != nil {
@@ -484,19 +520,28 @@ func compressMpUploadMetadata(data []byte) ([]byte, error) {
return compressed.Bytes(), nil
}
func unmarshalCompressedMpUploadMetadata(compressed []byte) (MpUploadMetadata, error) {
var mpMeta MpUploadMetadata
gz, err := gzip.NewReader(bytes.NewReader(compressed))
func DecompressData(data []byte) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return mpMeta, fmt.Errorf("decompress mp metadata: %w", err)
return nil, err
}
decompressed, err := io.ReadAll(gz)
closeErr := gz.Close()
if err != nil {
return mpMeta, fmt.Errorf("decompress mp metadata: %w", err)
return nil, err
}
if closeErr != nil {
return mpMeta, fmt.Errorf("decompress mp metadata: %w", closeErr)
return nil, 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 {
+47
View File
@@ -107,6 +107,53 @@ func TestUnmarshalMpUploadMetadataInvalid(t *testing.T) {
}
}
func TestWebsiteConfigRawGzipRoundTrip(t *testing.T) {
want := []byte(`<WebsiteConfiguration><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>`)
stored, err := MarshalWebsiteConfig(want, false)
if err != nil {
t.Fatalf("MarshalWebsiteConfig: %v", err)
}
if len(stored) < 2 || stored[0] != 0x1f || stored[1] != 0x8b {
t.Fatalf("stored website config should contain raw gzip payload: %q", stored)
}
got, err := UnmarshalWebsiteConfig(stored, false)
if err != nil {
t.Fatalf("UnmarshalWebsiteConfig: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("website config mismatch: got %q want %q", got, want)
}
}
func TestWebsiteConfigBase64RoundTrip(t *testing.T) {
want := []byte(`<WebsiteConfiguration><RedirectAllRequestsTo><HostName>example.com</HostName></RedirectAllRequestsTo></WebsiteConfiguration>`)
stored, err := MarshalWebsiteConfig(want, true)
if err != nil {
t.Fatalf("MarshalWebsiteConfig: %v", err)
}
if len(stored) >= 2 && stored[0] == 0x1f && stored[1] == 0x8b {
t.Fatalf("stored website config should not contain raw gzip bytes: %q", stored)
}
got, err := UnmarshalWebsiteConfig(stored, true)
if err != nil {
t.Fatalf("UnmarshalWebsiteConfig: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("website config mismatch: got %q want %q", got, want)
}
}
func TestUnmarshalWebsiteConfigInvalid(t *testing.T) {
_, err := UnmarshalWebsiteConfig([]byte("not-gzip"), false)
if err == nil {
t.Fatal("expected invalid website config error")
}
}
func TestParseCopySource(t *testing.T) {
tests := []struct {
name string
+202 -82
View File
@@ -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) {
+37 -8
View File
@@ -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
+1
View File
@@ -83,6 +83,7 @@ The `gateway.backend.type` value selects the storage backend. Use `gateway.backe
| **HTTPRoute** | `httpRoute.enabled=true` — Gateway API successor to Ingress for S3 API; also `admin.httpRoute.enabled=true` and `webui.httpRoute.enabled=true` to expose the admin API and/or WebUI |
| **Admin API** | `admin.enabled=true` — exposes a separate management API on `admin.port` (default `7071`) |
| **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints |
| **Website Hosting** | `website.enabled=true` — static website hosting endpoint on `website.port` (default `8090`); optionally set `website.domain` for virtual-host routing (e.g. `example.com`), or omit it for catch-all mode where the full hostname is the bucket name |
| **IAM** | `iam.enabled=true` — flat-file identity and access management stored alongside backend data |
| **Persistence** | `persistence.enabled=true` — provisions a PVC for backend data and IAM storage; defaults to `10Gi`, or uses a hostPath volume specified by `persistence.hostPath` |
| **NetworkPolicy** | `networkPolicy.enabled=true` — restricts ingress to selected pods/namespaces; allows all egress |
+16
View File
@@ -129,6 +129,17 @@ spec:
value: {{ .Values.webui.adminGateways | join "," | quote }}
{{- end }}
{{- end }}
# Website Hosting
{{- if .Values.website.enabled }}
- name: VGW_WEBSITE_PORT
value: ":{{ .Values.website.port }}"
- name: VGW_WEBSITE_DOMAIN
value: {{ .Values.website.domain | quote }}
{{- if .Values.website.noTls }}
- name: VGW_WEBSITE_NO_TLS
value: "true"
{{- end }}
{{- end }}
{{- if .Values.iam.enabled }}
# IAM settings
{{- if eq .Values.iam.type "internal" }}
@@ -173,6 +184,11 @@ spec:
containerPort: {{ .Values.webui.port }}
protocol: TCP
{{- end }}
{{- if .Values.website.enabled }}
- name: website
containerPort: {{ .Values.website.port }}
protocol: TCP
{{- end }}
readinessProbe:
httpGet:
path: "/_/health"
+6
View File
@@ -23,5 +23,11 @@ spec:
protocol: TCP
name: webui
{{- end }}
{{- if .Values.website.enabled }}
- port: {{ .Values.website.port }}
targetPort: website
protocol: TCP
name: website
{{- end }}
selector:
{{- include "versitygw.selectorLabels" . | nindent 4 }}
+18
View File
@@ -225,6 +225,24 @@ webui:
type: PathPrefix
value: /
# --- Website Hosting ---
website:
# Enable the static website hosting endpoint.
# Serves S3 buckets as static websites with index documents, custom error
# pages, and routing rules via a separate HTTP endpoint.
enabled: false
# The port the website endpoint listens on.
port: 8090
# Base domain for virtual-host routing. Optional.
# Host "blog.<domain>" serves bucket "blog"; host "<domain>" serves
# bucket "<domain>" (apex domain support).
# When empty, catch-all mode is used: the full hostname is the bucket
# name (name buckets as FQDNs, e.g. "blog.example.com").
domain: ""
# - example: domain: "example.com"
# Disable TLS for the website endpoint even when gateway TLS is enabled.
noTls: false
# --- IAM (Identity and Access Management) ---
iam:
enabled: false
+42
View File
@@ -91,6 +91,10 @@ var (
webuiAdminGateways []string
webuiPathPrefix string
webuiS3Prefix string
websitePorts []string
websiteDomain string
websiteCertFile, websiteKeyFile string
websiteNoTLS bool
disableACLs bool
mpMaxParts int
copyObjectThreshold int64
@@ -152,6 +156,7 @@ documentation can be found in the GitHub wiki.`,
webuiGateways = ctx.StringSlice("webui-gateways")
webuiAdminGateways = ctx.StringSlice("webui-admin-gateways")
webuiPathPrefix = ctx.String("webui-path-prefix")
websitePorts = ctx.StringSlice("website")
// Resolve relative UNIX socket paths to absolute before any backend
// (e.g. posix) can change the working directory via os.Chdir.
@@ -165,6 +170,9 @@ documentation can be found in the GitHub wiki.`,
if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil {
return err
}
if websitePorts, err = utils.AbsSocketPaths(websitePorts); err != nil {
return err
}
return nil
},
Action: func(ctx *cli.Context) error {
@@ -240,6 +248,35 @@ func initFlags() []cli.Flag {
EnvVars: []string{"VGW_WEBUI_S3_PREFIX"},
Destination: &webuiS3Prefix,
},
&cli.StringSliceFlag{
Name: "website",
Usage: "enable static website hosting endpoint on the specified listen address (e.g. ':8080'; same forms as --port; can be specified multiple times; requires --website-domain)",
EnvVars: []string{"VGW_WEBSITE_PORT"},
},
&cli.StringFlag{
Name: "website-domain",
Usage: "base domain for website virtual-host routing (e.g. 'example.com'); host 'blog.example.com' serves bucket 'blog', host 'example.com' serves bucket 'example.com'; when omitted the full hostname is used as the bucket name (catch-all mode, buckets named as FQDNs)",
EnvVars: []string{"VGW_WEBSITE_DOMAIN"},
Destination: &websiteDomain,
},
&cli.StringFlag{
Name: "website-cert",
Usage: "TLS cert file for website endpoint (defaults to --cert value when website is enabled)",
EnvVars: []string{"VGW_WEBSITE_CERT"},
Destination: &websiteCertFile,
},
&cli.StringFlag{
Name: "website-key",
Usage: "TLS key file for website endpoint (defaults to --key value when website is enabled)",
EnvVars: []string{"VGW_WEBSITE_KEY"},
Destination: &websiteKeyFile,
},
&cli.BoolFlag{
Name: "website-no-tls",
Usage: "disable TLS for website endpoint even if TLS is configured for the gateway",
EnvVars: []string{"VGW_WEBSITE_NO_TLS"},
Destination: &websiteNoTLS,
},
&cli.StringFlag{
Name: "access",
Usage: "root user access key",
@@ -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,
+79 -1
View File
@@ -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{
+182 -3
View File
@@ -42,6 +42,7 @@ import (
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3log"
"github.com/versity/versitygw/website"
"github.com/versity/versitygw/webui"
)
@@ -423,6 +424,29 @@ type Config struct {
// endpoint.
WebuiS3Prefix string
// Static website hosting endpoint
//
// WebsitePorts is the list of listening addresses for the static website
// hosting endpoint. Accepts the same formats as Ports. When empty, the
// website endpoint is disabled.
WebsitePorts []string
// WebsiteDomain is the base domain for website virtual-host routing. For
// example, host "blog.example.com" serves bucket "blog" when this is
// "example.com". When empty, the full request hostname is used as the
// bucket name.
WebsiteDomain string
// WebsiteCertFile is the path to the TLS certificate for the website
// endpoint. When empty and gateway TLS (CertFile/KeyFile) is configured,
// the website endpoint inherits those certs. Both WebsiteCertFile and
// WebsiteKeyFile must be provided together.
WebsiteCertFile string
// WebsiteKeyFile is the path to the TLS private key for the website
// endpoint.
WebsiteKeyFile string
// WebsiteNoTLS forces the website endpoint to use plain HTTP even when TLS
// certificates are available.
WebsiteNoTLS bool
// SigHup is an optional channel that signals the gateway to reload TLS
// certificates and rotate log files (equivalent to SIGHUP). When nil,
// this feature is disabled.
@@ -514,7 +538,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
fmt.Fprintf(os.Stderr, "WARNING: WebuiPorts is set but CORSAllowOrigin is not; defaulting to '*'; %s\n", suggestion)
}
if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts); err != nil {
if err := validatePortConflicts(cfg.Ports, cfg.AdminPorts, cfg.WebuiPorts, cfg.WebsitePorts); err != nil {
return err
}
@@ -882,6 +906,60 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
}, webOpts...)
}
var wsSrv *website.Server
wsTLSCert := ""
wsTLSKey := ""
if len(cfg.WebsitePorts) > 0 {
for _, addr := range cfg.WebsitePorts {
if utils.IsUnixSocketPath(addr) {
continue
}
_, wsPrt, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("website listen address must be in the form ':port' or 'host:port': %w", err)
}
wsPortNum, err := strconv.Atoi(wsPrt)
if err != nil {
return fmt.Errorf("website port must be a number: %w", err)
}
if wsPortNum < 0 || wsPortNum > 65535 {
return fmt.Errorf("website port must be between 0 and 65535")
}
}
var wsOpts []website.Option
if !cfg.WebsiteNoTLS {
wsTLSCert = cfg.WebsiteCertFile
wsTLSKey = cfg.WebsiteKeyFile
if wsTLSCert == "" && wsTLSKey == "" {
wsTLSCert = cfg.CertFile
wsTLSKey = cfg.KeyFile
}
if wsTLSCert != "" || wsTLSKey != "" {
if wsTLSCert == "" {
return fmt.Errorf("website TLS key specified without cert file")
}
if wsTLSKey == "" {
return fmt.Errorf("website TLS cert specified without key file")
}
cs := utils.NewCertStorage()
if err := cs.SetCertificate(wsTLSCert, wsTLSKey); err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
wsOpts = append(wsOpts, website.WithTLS(cs))
}
}
if cfg.Quiet {
wsOpts = append(wsOpts, website.WithQuiet())
}
if cfg.SocketPerm != "" {
wsOpts = append(wsOpts, website.WithSocketPerm(parsedSocketPerm))
}
wsSrv = website.NewServer(be, cfg.WebsiteDomain, wsOpts...)
}
if !cfg.Quiet {
cfg.printBanner()
}
@@ -893,6 +971,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if len(cfg.WebuiPorts) > 0 {
servers++
}
if len(cfg.WebsitePorts) > 0 {
servers++
}
c := make(chan error, servers)
go func() { c <- srv.ServeMultiPort(cfg.Ports) }()
@@ -902,6 +983,9 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
if len(cfg.WebuiPorts) > 0 {
go func() { c <- webSrv.ServeMultiPort(cfg.WebuiPorts) }()
}
if len(cfg.WebsitePorts) > 0 {
go func() { c <- wsSrv.ServeMultiPort(cfg.WebsitePorts) }()
}
// build a nil-safe sighup channel so the select below is always valid
var sigHup <-chan struct{}
@@ -957,6 +1041,14 @@ Loop:
fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey)
}
}
if len(cfg.WebsitePorts) > 0 && wsTLSCert != "" && wsTLSKey != "" {
reloadErr := wsSrv.CertStorage.SetCertificate(wsTLSCert, wsTLSKey)
if reloadErr != nil {
debuglogger.InternalError(fmt.Errorf("wsSrv cert reload failed: %w", reloadErr))
} else {
fmt.Printf("wsSrv cert reloaded (cert: %s, key: %s)\n", wsTLSCert, wsTLSKey)
}
}
}
}
saveErr := err
@@ -980,6 +1072,13 @@ Loop:
}
}
if wsSrv != nil {
err := wsSrv.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown website server: %v\n", err)
}
}
be.Shutdown()
err = iam.Shutdown()
@@ -1023,6 +1122,7 @@ func (cfg Config) printBanner() {
ssl := cfg.CertFile != "" || cfg.KeyFile != ""
admSSL := cfg.AdminCertFile != "" || cfg.AdminKeyFile != ""
webuiSsl := !cfg.WebuiNoTLS && (cfg.WebuiCertFile != "" || cfg.WebuiKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "")
websiteSsl := !cfg.WebsiteNoTLS && (cfg.WebsiteCertFile != "" || cfg.WebsiteKeyFile != "" || cfg.CertFile != "" || cfg.KeyFile != "")
if len(cfg.Ports) == 0 {
fmt.Fprintf(os.Stderr, "No ports specified\n")
@@ -1243,6 +1343,68 @@ func (cfg Config) printBanner() {
}
}
if len(cfg.WebsitePorts) > 0 {
var allWebsiteInterfaces []string
websiteInterfaceMap := make(map[string]bool)
for _, websiteAddr := range cfg.WebsitePorts {
if strings.TrimSpace(websiteAddr) == "" {
continue
}
if utils.IsUnixSocketPath(websiteAddr) {
if !websiteInterfaceMap[websiteAddr] {
websiteInterfaceMap[websiteAddr] = true
allWebsiteInterfaces = append(allWebsiteInterfaces, websiteAddr)
}
continue
}
websiteInterfaces, err := getMatchingIPs(websiteAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match website port local IP addresses for %s: %v\n", websiteAddr, err)
continue
}
_, websitePrt, err := net.SplitHostPort(websiteAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse website port %s: %v\n", websiteAddr, err)
continue
}
for _, ip := range websiteInterfaces {
key := net.JoinHostPort(ip, websitePrt)
if !websiteInterfaceMap[key] {
websiteInterfaceMap[key] = true
allWebsiteInterfaces = append(allWebsiteInterfaces, key)
}
}
}
if len(allWebsiteInterfaces) > 0 {
domainInfo := ""
if cfg.WebsiteDomain != "" {
domainInfo = fmt.Sprintf(" (domain: %s)", cfg.WebsiteDomain)
}
lines = append(lines,
centerText(""),
leftText("Website endpoint listening on:"+domainInfo),
)
for _, addrPort := range allWebsiteInterfaces {
if utils.IsUnixSocketPath(addrPort) {
lines = append(lines, leftText(" unix:"+addrPort))
continue
}
ip, prt, err := net.SplitHostPort(addrPort)
if err != nil {
continue
}
hostPort := net.JoinHostPort(ip, prt)
u := fmt.Sprintf("http://%s", hostPort)
if websiteSsl {
u = fmt.Sprintf("https://%s", hostPort)
}
lines = append(lines, leftText(" "+u))
}
}
}
fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
for _, line := range lines {
fmt.Printf("│%-*s│\n", columnWidth-2, line)
@@ -1436,13 +1598,13 @@ func sortGatewayURLs(urls []string) {
}
// validatePortConflicts checks for port conflicts across the S3 API, admin,
// and WebUI port lists before the servers are started.
// WebUI, and website port lists before the servers are started.
//
// A bare port spec (e.g. ":7071") binds to all interfaces and conflicts with
// any other spec on the same port number. Two identical "ip:port" specs are
// allowed and will be caught by the OS later. UNIX socket paths are checked
// for duplicate path conflicts only and never conflict with TCP specs.
func validatePortConflicts(ports, admPorts, webuiPorts []string) error {
func validatePortConflicts(ports, admPorts, webuiPorts, websitePorts []string) error {
type portSpec struct {
spec string
port string
@@ -1504,6 +1666,23 @@ func validatePortConflicts(ports, admPorts, webuiPorts []string) error {
})
}
for _, p := range websitePorts {
if utils.IsUnixSocketPath(p) {
allSpecs = append(allSpecs, portSpec{spec: p, port: p, isUnix: true, portType: "website"})
continue
}
_, port, err := net.SplitHostPort(p)
if err != nil {
continue
}
allSpecs = append(allSpecs, portSpec{
spec: p,
port: port,
isBare: strings.HasPrefix(p, ":"),
portType: "website",
})
}
for i, spec1 := range allSpecs {
for j, spec2 := range allSpecs {
if i >= j {
+35 -7
View File
@@ -20,12 +20,13 @@ import (
func TestValidatePortConflicts(t *testing.T) {
tests := []struct {
name string
ports []string
admPorts []string
webuiPorts []string
expectError bool
description string
name string
ports []string
admPorts []string
webuiPorts []string
websitePorts []string
expectError bool
description string
}{
{
name: "bare port conflict with bare port",
@@ -115,11 +116,38 @@ func TestValidatePortConflicts(t *testing.T) {
expectError: true,
description: "should fail: :8080 conflicts with 127.0.0.1:8080",
},
{
name: "website bare port conflict with s3 port",
ports: []string{"127.0.0.1:8080"},
admPorts: []string{},
webuiPorts: []string{},
websitePorts: []string{":8080"},
expectError: true,
description: "should fail: website bare :8080 conflicts with s3 127.0.0.1:8080",
},
{
name: "website no conflict",
ports: []string{":7070"},
admPorts: []string{":8080"},
webuiPorts: []string{":9090"},
websitePorts: []string{":8081"},
expectError: false,
description: "should pass: website uses a distinct port",
},
{
name: "duplicate website unix socket conflict",
ports: []string{"/tmp/versitygw.sock"},
admPorts: []string{},
webuiPorts: []string{},
websitePorts: []string{"/tmp/versitygw.sock"},
expectError: true,
description: "should fail: duplicate unix socket path conflicts across s3 and website",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts)
err := validatePortConflicts(tt.ports, tt.admPorts, tt.webuiPorts, tt.websitePorts)
if tt.expectError && err == nil {
t.Errorf("%s: expected error but got none", tt.description)
}
+47
View File
@@ -312,6 +312,53 @@ ROOT_SECRET_ACCESS_KEY=
# Example: VGW_WEBUI_ADMIN_GATEWAYS=https://admin.example.com,http://192.168.1.100:7080
#VGW_WEBUI_ADMIN_GATEWAYS=
###################
# Website Hosting #
###################
# VersityGW supports S3-compatible static website hosting on a dedicated
# endpoint, separate from the S3 API port. This mirrors how AWS serves
# websites on s3-website.<region>.amazonaws.com rather than s3.amazonaws.com.
#
# When enabled, the website endpoint serves bucket content as static websites
# using the PutBucketWebsite configuration (index documents, error documents,
# routing rules, and redirect-all). No S3 authentication is applied — the
# website endpoint is public, just like AWS S3 website hosting.
#
# Bucket resolution uses virtual-host-style routing based on the Host header:
# - With VGW_WEBSITE_DOMAIN=example.com:
# Host "blog.example.com" -> serves bucket "blog"
# Host "example.com" -> serves bucket "example.com" (apex)
# - Without VGW_WEBSITE_DOMAIN (catch-all mode):
# Host "blog.example.com" -> serves bucket "blog.example.com"
# Host "mysite.org" -> serves bucket "mysite.org"
# The VGW_WEBSITE_PORT option enables the website hosting endpoint on the
# specified listen address. The format is the same as VGW_PORT (e.g. ':8080',
# 'localhost:8080'). Multiple ports can be specified as a comma-separated list.
# When omitted, website hosting is disabled.
#VGW_WEBSITE_PORT=
# The VGW_WEBSITE_DOMAIN option sets the base domain for virtual-host bucket
# routing. For example, with domain "example.com", a request with Host header
# "blog.example.com" resolves to bucket "blog". When omitted, the full
# hostname from the Host header is used as the bucket name (catch-all mode),
# which is useful when buckets are named as FQDNs (e.g. "www.mysite.org").
#VGW_WEBSITE_DOMAIN=
# The VGW_WEBSITE_CERT and VGW_WEBSITE_KEY options specify TLS credentials
# for the website endpoint. When not set but TLS is configured for the gateway
# (VGW_CERT and VGW_KEY), the website endpoint inherits the gateway certificates.
# When neither is set, the website endpoint runs without TLS (HTTP only).
#VGW_WEBSITE_CERT=
#VGW_WEBSITE_KEY=
# The VGW_WEBSITE_NO_TLS option disables TLS for the website endpoint even
# when TLS certificates are configured for the gateway. Set to true to force
# the website endpoint to use HTTP. This is useful when TLS termination is
# handled by a reverse proxy or load balancer.
#VGW_WEBSITE_NO_TLS=false
#######################
# Debug / Diagnostics #
#######################
+156
View File
@@ -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 {
+4 -3
View File
@@ -49,9 +49,10 @@ const (
iso8601TimeFormatExtended = "Mon Jan _2 15:04:05 2006"
timefmt = "Mon, 02 Jan 2006 15:04:05 GMT"
maxXMLBodyLen = 4 * 1024 * 1024
minPartNumber = 1
maxPartNumber = 10000
maxXMLBodyLen = 4 * 1024 * 1024
minPartNumber = 1
maxPartNumber = 10000
maxWebsiteConfigurationBytes = 131072
defaultRegion = "us-east-1"
defaultContentType = "binary/octet-stream"
+36
View File
@@ -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)
+44
View File
@@ -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)
+33 -22
View File
@@ -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{
+64
View File
@@ -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)
@@ -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)
},
+1
View File
@@ -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,
+2
View File
@@ -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,
+1
View File
@@ -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,
+2
View File
@@ -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,
+11
View File
@@ -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,
+22
View File
@@ -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: &copySource,
@@ -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,
+12 -2
View File
@@ -28,21 +28,31 @@ import (
// Vary http response header is always the same below
var VaryHdr = "Origin, Access-Control-Request-Headers, Access-Control-Request-Method"
type BucketResolver func(ctx *fiber.Ctx) (string, error)
func BucketFromPath(ctx *fiber.Ctx) (string, error) {
return ctx.Params("bucket"), nil
}
// ApplyBucketCORS retreives the bucket CORS configuration,
// checks if origin and method meets the cors rules and
// adds the necessary response headers.
// CORS check is applied only when 'Origin' request header is present
func ApplyBucketCORS(be backend.Backend, fallbackOrigin string) fiber.Handler {
func ApplyBucketCORS(be backend.Backend, resolveBucket BucketResolver, fallbackOrigin string) fiber.Handler {
fallbackOrigin = strings.TrimSpace(fallbackOrigin)
return func(ctx *fiber.Ctx) error {
bucket := ctx.Params("bucket")
origin := ctx.Get("Origin")
// If neither Origin is present nor a fallback is configured, skip CORS entirely.
if origin == "" && fallbackOrigin == "" {
return nil
}
bucket, err := resolveBucket(ctx)
if err != nil {
return err
}
// if bucket cors is not set, skip the check
data, err := be.GetBucketCors(ctx.Context(), bucket)
if err != nil {
+61 -56
View File
@@ -185,6 +185,7 @@ func (sa *S3ApiRouter) Init() {
bucketRouter := sa.app.Group("/:bucket")
objectRouter := sa.app.Group("/:bucket/*")
applyBucketCORS := middlewares.ApplyBucketCORS(sa.be, middlewares.BucketFromPath, sa.corsAllowOrigin)
// PUT bucket operations
bucketRouter.Put("",
@@ -194,7 +195,7 @@ func (sa *S3ApiRouter) Init() {
metrics.ActionPutBucketTagging,
services,
middlewares.BucketObjectNameValidator(),
middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
applyBucketCORS,
middlewares.AuthorizePublicBucketAccess(sa.be, metrics.ActionPutBucketTagging, auth.PutBucketTaggingAction, auth.PermissionWrite, sa.region, false),
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
@@ -212,7 +213,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, false),
middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -226,7 +227,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, false),
middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -240,7 +241,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, true),
middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -254,7 +255,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, true, true),
middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -268,7 +269,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -282,7 +283,7 @@ func (sa *S3ApiRouter) Init() {
middlewares.VerifyPresignedV4Signature(sa.root, sa.iam, sa.region, false),
middlewares.VerifyV4Signature(sa.root, sa.iam, sa.region, false, true, false),
middlewares.VerifyChecksums(false, false, false),
middlewares.ApplyBucketCORS(sa.be, sa.corsAllowOrigin),
applyBucketCORS,
middlewares.ParseAcl(sa.be),
))
bucketRouter.Put("",
@@ -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),
+1
View File
@@ -40,6 +40,7 @@ const (
ContextKeyObjectPostResult ContextKey = "object-post-result"
ContextKeyRequestID ContextKey = "request-id"
ContextKeyHostID ContextKey = "host-id"
ContextKeyWebsiteConfig ContextKey = "website-config"
)
func (ck ContextKey) Set(ctx *fiber.Ctx, val any) {
+10
View File
@@ -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
+7
View File
@@ -43,6 +43,13 @@ func (e AccessForbiddenError) XMLBody(requestID, hostID string) []byte {
})
}
func (e AccessForbiddenError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Method", Value: e.Method},
ErrorField{Name: "ResourceType", Value: e.ResourceType},
)
}
func (e AccessForbiddenError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -43,6 +43,13 @@ func (e BadDigestError) XMLBody(requestID, hostID string) []byte {
})
}
func (e BadDigestError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "CalculatedDigest", Value: e.CalculatedDigest},
ErrorField{Name: "ExpectedDigest", Value: e.ExpectedDigest},
)
}
func (e BadDigestError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+6
View File
@@ -40,6 +40,12 @@ func (e BucketError) XMLBody(requestID, hostID string) []byte {
})
}
func (e BucketError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "BucketName", Value: e.BucketName},
)
}
func (e BucketError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -44,6 +44,13 @@ func (e ContentSHA256MismatchError) XMLBody(requestID, hostID string) []byte {
})
}
func (e ContentSHA256MismatchError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "ClientComputedContentSHA256", Value: e.ClientComputedContentSHA256},
ErrorField{Name: "S3ComputedContentSHA256", Value: e.S3ComputedContentSHA256},
)
}
func (e ContentSHA256MismatchError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -43,6 +43,13 @@ func (e EntityTooLargeError) XMLBody(requestID, hostID string) []byte {
})
}
func (e EntityTooLargeError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "ProposedSize", Value: e.ProposedSize},
ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed},
)
}
func (e EntityTooLargeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -43,6 +43,13 @@ func (e EntityTooSmallError) XMLBody(requestID, hostID string) []byte {
})
}
func (e EntityTooSmallError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "ProposedSize", Value: e.ProposedSize},
ErrorField{Name: "MinSizeAllowed", Value: e.MinSizeAllowed},
)
}
func (e EntityTooSmallError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+8
View File
@@ -46,6 +46,14 @@ func (e ExpiredPresignedURLError) XMLBody(requestID, hostID string) []byte {
})
}
func (e ExpiredPresignedURLError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "ServerTime", Value: e.ServerTime},
ErrorField{Name: "X-Amz-Expires", Value: e.XAmzExpires},
ErrorField{Name: "Expires", Value: e.Expires},
)
}
func (e ExpiredPresignedURLError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+6
View File
@@ -40,6 +40,12 @@ func (e InvalidAccessKeyIdError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidAccessKeyIdError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId},
)
}
func (e InvalidAccessKeyIdError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+17
View File
@@ -56,6 +56,8 @@ const (
InvalidArgCannedAcl
InvalidArgOnlyAws4HmacSha256
InvalidArgDateHeader
InvalidArgIndexDocumentSuffix
InvalidArgErrorDocumentKey
)
var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{
@@ -187,6 +189,14 @@ var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{
Description: "X-Amz-Date must be formated via ISO8601 Long format",
ArgumentName: "X-Amz-Date",
},
InvalidArgIndexDocumentSuffix: {
Description: "The IndexDocument Suffix is not well formed",
ArgumentName: "IndexDocument",
},
InvalidArgErrorDocumentKey: {
Description: "The ErrorDocument Key is not well formed",
ArgumentName: "ErrorDocument",
},
}
// InvalidArgumentError is returned when a request argument is invalid.
@@ -235,6 +245,13 @@ func (e InvalidArgumentError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidArgumentError) HTMLBody(requestID, hostID string) []byte {
return e.BaseError().encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "ArgumentName", Value: e.ArgumentName},
ErrorField{Name: "ArgumentValue", Value: e.ArgumentValue},
)
}
func GetInvalidArgumentErr(code InvalidArgErrorCode, value string) InvalidArgumentError {
err := invalidArgErrResponses[code]
err.ArgumentValue = value
+7
View File
@@ -43,6 +43,13 @@ func (e InvalidChunkSizeError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidChunkSizeError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Chunk", Value: e.Chunk},
ErrorField{Name: "BadChunkSize", Value: e.BadChunkSize},
)
}
func (e InvalidChunkSizeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+6
View File
@@ -40,6 +40,12 @@ func (e InvalidDigestError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidDigestError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Content-MD5", Value: e.ContentMD5},
)
}
func (e InvalidDigestError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
@@ -40,6 +40,12 @@ func (e InvalidLocationConstraintError) XMLBody(requestID, hostID string) []byte
})
}
func (e InvalidLocationConstraintError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "LocationConstraint", Value: e.LocationConstraint},
)
}
func (e InvalidLocationConstraintError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+8
View File
@@ -48,6 +48,14 @@ func (e InvalidPartError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidPartError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "UploadId", Value: e.UploadId},
ErrorField{Name: "PartNumber", Value: e.PartNumber},
ErrorField{Name: "ETag", Value: e.ETag},
)
}
func (e InvalidPartError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -44,6 +44,13 @@ func (e InvalidPartNumberRangeError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidPartNumberRangeError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "ActualPartCount", Value: e.ActualPartCount},
ErrorField{Name: "PartNumberRequested", Value: e.PartNumberRequested},
)
}
func (e InvalidPartNumberRangeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -43,6 +43,13 @@ func (e InvalidRangeError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidRangeError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "RangeRequested", Value: e.RangeRequested},
ErrorField{Name: "ActualObjectSize", Value: e.ActualObjectSize},
)
}
func (e InvalidRangeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -43,6 +43,13 @@ func (e InvalidTagError) XMLBody(requestID, hostID string) []byte {
})
}
func (e InvalidTagError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "TagKey", Value: e.TagKey},
ErrorField{Name: "TagValue", Value: e.TagValue},
)
}
func (e InvalidTagError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -43,6 +43,13 @@ func (e KeyTooLongError) XMLBody(requestID, hostID string) []byte {
})
}
func (e KeyTooLongError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Size", Value: e.Size},
ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed},
)
}
func (e KeyTooLongError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
@@ -0,0 +1,59 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package s3err
import "encoding/xml"
// MaxMessageLengthExceeded is returned when the request size exceeds the maximum limit
// Produces the <MaxMessageLengthBytes> field in the XML response.
type MaxMessageLengthExceeded struct {
APIError
MaxMessageLengthBytes int64
}
func (e MaxMessageLengthExceeded) XMLBody(requestID, hostID string) []byte {
return encodeResponse(struct {
XMLName xml.Name `xml:"Error"`
Code string
Message string
MaxMessageLengthBytes int64 `xml:",omitempty"`
RequestID string `xml:"RequestId,omitempty"`
HostID string `xml:"HostId,omitempty"`
}{
Code: e.Code,
Message: e.Description,
MaxMessageLengthBytes: e.MaxMessageLengthBytes,
RequestID: requestID,
HostID: hostID,
})
}
func (e MaxMessageLengthExceeded) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "MaxMessageLengthBytes", Value: e.MaxMessageLengthBytes},
)
}
func (e MaxMessageLengthExceeded) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
}
func GetMaxMessageLengthExceeded(maxMessageLengthBytes int64) MaxMessageLengthExceeded {
return MaxMessageLengthExceeded{
APIError: GetAPIError(ErrMaxMessageLengthExceeded),
MaxMessageLengthBytes: maxMessageLengthBytes,
}
}
+7
View File
@@ -43,6 +43,13 @@ func (e MetadataTooLargeError) XMLBody(requestID, hostID string) []byte {
})
}
func (e MetadataTooLargeError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Size", Value: e.Size},
ErrorField{Name: "MaxSizeAllowed", Value: e.MaxSizeAllowed},
)
}
func (e MetadataTooLargeError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -52,6 +52,13 @@ func (e MethodNotAllowedError) XMLBody(requestID, hostID string) []byte {
})
}
func (e MethodNotAllowedError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Method", Value: e.Method},
ErrorField{Name: "ResourceType", Value: e.ResourceType},
)
}
func (e MethodNotAllowedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+6
View File
@@ -40,6 +40,12 @@ func (e NoSuchUploadError) XMLBody(requestID, hostID string) []byte {
})
}
func (e NoSuchUploadError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "UploadId", Value: e.UploadId},
)
}
func (e NoSuchUploadError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -43,6 +43,13 @@ func (e NoSuchVersionError) XMLBody(requestID, hostID string) []byte {
})
}
func (e NoSuchVersionError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Key", Value: e.Key},
ErrorField{Name: "VersionId", Value: e.VersionId},
)
}
func (e NoSuchVersionError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+7
View File
@@ -52,6 +52,13 @@ func (e NotImplementedError) XMLBody(requestID, hostID string) []byte {
})
}
func (e NotImplementedError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Header", Value: e.Header},
ErrorField{Name: "additionalMessage", Value: e.AdditionalMessage},
)
}
func (e NotImplementedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+6
View File
@@ -52,6 +52,12 @@ func (e PreconditionFailedError) XMLBody(requestID, hostID string) []byte {
})
}
func (e PreconditionFailedError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Condition", Value: e.Condition},
)
}
func (e PreconditionFailedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+8
View File
@@ -48,6 +48,14 @@ func (e RequestTimeTooSkewedError) XMLBody(requestID, hostID string) []byte {
})
}
func (e RequestTimeTooSkewedError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "RequestTime", Value: e.RequestTime},
ErrorField{Name: "ServerTime", Value: e.ServerTime},
ErrorField{Name: "MaxAllowedSkewMilliseconds", Value: e.MaxAllowedSkewMilliseconds},
)
}
func (e RequestTimeTooSkewedError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+102
View File
@@ -18,6 +18,7 @@ import (
"bytes"
"encoding/xml"
"fmt"
"html"
"net/http"
"strings"
@@ -31,6 +32,7 @@ type S3Error interface {
StatusCode() int
BaseError() APIError
XMLBody(requestID, hostID string) []byte
HTMLBody(requestID, hostID string) []byte
}
// APIError structure
@@ -69,6 +71,10 @@ func (e APIError) XMLBody(requestID, hostID string) []byte {
})
}
func (e APIError) HTMLBody(requestID, hostID string) []byte {
return e.encodeHTMLResponse(requestID, hostID)
}
// ErrorCode type of error status.
type ErrorCode int
@@ -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("<html>\n")
builder.WriteString("<head><title>")
builder.WriteString(html.EscapeString(status))
builder.WriteString("</title></head>\n<body>\n<h1>")
builder.WriteString(html.EscapeString(status))
builder.WriteString("</h1>\n<ul>\n")
writeHTMLErrorField(builder, "Code", e.Code)
writeHTMLErrorField(builder, "Message", e.Description)
for _, field := range fields {
writeHTMLErrorField(builder, field.Name, field.Value)
}
writeHTMLErrorField(builder, "RequestId", requestID)
writeHTMLErrorField(builder, "HostId", hostID)
builder.WriteString("</ul>\n<hr/>\n</body>\n</html>\n")
return []byte(builder.String())
}
func writeHTMLErrorField(builder *strings.Builder, name string, value any) {
builder.WriteString("<li>")
builder.WriteString(html.EscapeString(name))
builder.WriteString(": ")
builder.WriteString(html.EscapeString(fmt.Sprint(value)))
builder.WriteString("</li>\n")
}
// Returns invalid checksum error with the provided header in the error description
func GetInvalidChecksumHeaderErr(header string) APIError {
return APIError{
@@ -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 (
+11
View File
@@ -55,6 +55,17 @@ func (e SignatureDoesNotMatchError) XMLBody(requestID, hostID string) []byte {
})
}
func (e SignatureDoesNotMatchError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "AWSAccessKeyId", Value: e.AWSAccessKeyId},
ErrorField{Name: "StringToSign", Value: e.StringToSign},
ErrorField{Name: "SignatureProvided", Value: e.SignatureProvided},
ErrorField{Name: "StringToSignBytes", Value: e.StringToSignBytes},
ErrorField{Name: "CanonicalRequest", Value: e.CanonicalRequest},
ErrorField{Name: "CanonicalRequestBytes", Value: e.CanonicalRequestBytes},
)
}
func (e SignatureDoesNotMatchError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+6
View File
@@ -44,6 +44,12 @@ func (e MalformedAuthError) XMLBody(requestID, hostID string) []byte {
})
}
func (e MalformedAuthError) HTMLBody(requestID, hostID string) []byte {
return e.APIError.encodeHTMLResponse(requestID, hostID,
ErrorField{Name: "Region", Value: e.Region},
)
}
func (e MalformedAuthError) Is(target error) bool {
t, ok := target.(APIError)
return ok && e.APIError == t
+294
View File
@@ -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
}
+310
View File
@@ -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)
}
})
}
}
+76 -29
View File
@@ -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 {
+29 -9
View File
@@ -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 {
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/versity/versitygw/s3err"
)
func DeleteBucketWebsite_non_existing_bucket(s *S3Conf) error {
testName := "DeleteBucketWebsite_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{
Bucket: getPtr("non-existing-bucket"),
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
})
}
func DeleteBucketWebsite_success(s *S3Conf) error {
testName := "DeleteBucketWebsite_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
deleteWebsite := func() error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{
Bucket: &bucket,
})
cancel()
return err
}
// should not return error when deleting unset website config
err := deleteWebsite()
if err != nil {
return err
}
// put a website config
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
Bucket: &bucket,
WebsiteConfiguration: &types.WebsiteConfiguration{
IndexDocument: &types.IndexDocument{
Suffix: getPtr("index.html"),
},
},
})
cancel()
if err != nil {
return err
}
// delete the website config
err = deleteWebsite()
if err != nil {
return err
}
// verify it's gone
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
Bucket: &bucket,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration))
})
}
+126
View File
@@ -0,0 +1,126 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package integration
import (
"context"
"fmt"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/versity/versitygw/s3err"
)
func GetBucketWebsite_non_existing_bucket(s *S3Conf) error {
testName := "GetBucketWebsite_non_existing_bucket"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
Bucket: getPtr("non-existing-bucket"),
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
})
}
func GetBucketWebsite_no_such_website_config(s *S3Conf) error {
testName := "GetBucketWebsite_no_such_website_config"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
Bucket: &bucket,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration))
})
}
func GetBucketWebsite_success(s *S3Conf) error {
testName := "GetBucketWebsite_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
Bucket: &bucket,
WebsiteConfiguration: &types.WebsiteConfiguration{
IndexDocument: &types.IndexDocument{
Suffix: getPtr("index.html"),
},
ErrorDocument: &types.ErrorDocument{
Key: getPtr("error.html"),
},
},
})
cancel()
if err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
Bucket: &bucket,
})
cancel()
if err != nil {
return err
}
if res.IndexDocument == nil || res.IndexDocument.Suffix == nil || *res.IndexDocument.Suffix != "index.html" {
return fmt.Errorf("expected IndexDocument.Suffix to be %q, got %v", "index.html", res.IndexDocument)
}
if res.ErrorDocument == nil || res.ErrorDocument.Key == nil || *res.ErrorDocument.Key != "error.html" {
return fmt.Errorf("expected ErrorDocument.Key to be %q, got %v", "error.html", res.ErrorDocument)
}
return nil
})
}
func GetBucketWebsite_success_redirect_all(s *S3Conf) error {
testName := "GetBucketWebsite_success_redirect_all"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
Bucket: &bucket,
WebsiteConfiguration: &types.WebsiteConfiguration{
RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
HostName: getPtr("example.com"),
Protocol: types.ProtocolHttps,
},
},
})
cancel()
if err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
Bucket: &bucket,
})
cancel()
if err != nil {
return err
}
if res.RedirectAllRequestsTo == nil || res.RedirectAllRequestsTo.HostName == nil || *res.RedirectAllRequestsTo.HostName != "example.com" {
return fmt.Errorf("expected RedirectAllRequestsTo.HostName to be %q, got %v", "example.com", res.RedirectAllRequestsTo)
}
if res.RedirectAllRequestsTo.Protocol != types.ProtocolHttps {
return fmt.Errorf("expected RedirectAllRequestsTo.Protocol to be %q, got %q", types.ProtocolHttps, res.RedirectAllRequestsTo.Protocol)
}
return nil
})
}
+16 -10
View File
@@ -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)
+16 -10
View File
@@ -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)
@@ -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 {
+39 -8
View File
@@ -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 {
+469
View File
@@ -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
})
}
+13
View File
@@ -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 {
+864
View File
@@ -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 := "<html><body>Custom Error Page</body></html>"
_, 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 := "<html><body>fallback error</body></html>"
_, 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 := "<html><body>Welcome</body></html>"
_, 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 := "<html><body>Docs Home</body></html>"
_, 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 := "<html><body>combined index</body></html>"
_, 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 := "<html><body>CORS GET</body></html>"
_, 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 := "<html><body>CORS HEAD</body></html>"
_, 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))
})
}
+103 -7
View File
@@ -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,
+15
View File
@@ -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 }
}
+153 -8
View File
@@ -452,6 +452,147 @@ func checkHTTPResponseApiErr(resp *http.Response, expected s3err.S3Error) error
return compareS3ApiError(expected, &errResp)
}
// websiteGet issues a plain HTTP GET to the dedicated website endpoint.
// The bucket is resolved from the request URL host. No S3 signing is applied.
func websiteGet(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) {
return websiteRequest(s, http.MethodGet, bucket, path, headers)
}
func websiteHead(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) {
return websiteRequest(s, http.MethodHead, bucket, path, headers)
}
func websiteOptions(s *S3Conf, bucket, path string, headers map[string]string) (*http.Response, error) {
return websiteRequest(s, http.MethodOptions, bucket, path, headers)
}
func websiteRequest(s *S3Conf, method, bucket, path string, headers map[string]string) (*http.Response, error) {
reqURL, err := websiteURL(s, bucket, path)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create website request: %w", err)
}
for key, val := range headers {
req.Header.Set(key, val)
}
return s.httpClient.Do(req)
}
func websiteHost(s *S3Conf, bucket string) string {
_, domain, port := websiteEndpointParts(s)
host := fmt.Sprintf("%s.%s", bucket, domain)
if port != "" {
host = fmt.Sprintf("%s:%s", host, port)
}
return host
}
func websiteURL(s *S3Conf, bucket, path string) (string, error) {
return websiteAbsoluteURL(s, websiteHost(s, bucket), path)
}
func websiteAbsoluteURL(s *S3Conf, host, path string) (string, error) {
scheme, _, _ := websiteEndpointParts(s)
rel, err := url.Parse("/" + strings.TrimLeft(path, "/"))
if err != nil {
return "", fmt.Errorf("parse website request path: %w", err)
}
return (&url.URL{
Scheme: scheme,
Host: host,
Path: rel.Path,
RawQuery: rel.RawQuery,
}).String(), nil
}
func websiteEndpointParts(s *S3Conf) (scheme, domain, port string) {
scheme = strings.ToLower(strings.TrimSpace(s.websiteScheme))
domain = strings.TrimSpace(s.websiteDomain)
port = strings.TrimPrefix(strings.TrimSpace(s.websitePort), ":")
return scheme, domain, port
}
func putBucketWebsiteConfig(client *s3.Client, bucket string, config *types.WebsiteConfiguration) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
Bucket: &bucket,
WebsiteConfiguration: config,
})
cancel()
return err
}
func checkWebsiteResponse(resp *http.Response, expectedStatus int, expectedBody []byte) error {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read website response body: %w", err)
}
if resp.StatusCode != expectedStatus {
return fmt.Errorf("expected status %v, got %v; body: %s", expectedStatus, resp.StatusCode, body)
}
return compareBodySHA256(expectedBody, body)
}
func checkWebsiteErrorResponse(resp *http.Response, expected s3err.S3Error) error {
apiErr := expected.BaseError()
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read website error body: %w", err)
}
if resp.StatusCode != apiErr.HTTPStatusCode {
return fmt.Errorf("expected status %v, got %v; body: %s", apiErr.HTTPStatusCode, resp.StatusCode, body)
}
if got := resp.Header.Get("x-amz-error-code"); got != apiErr.Code {
return fmt.Errorf("expected x-amz-error-code %q, got %q", apiErr.Code, got)
}
if got := resp.Header.Get("x-amz-error-message"); got != apiErr.Description {
return fmt.Errorf("expected x-amz-error-message %q, got %q", apiErr.Description, got)
}
requestID := resp.Header.Get("x-amz-request-id")
if requestID == "" {
return fmt.Errorf("expected x-amz-request-id header")
}
hostID := resp.Header.Get("x-amz-id-2")
if hostID == "" {
return fmt.Errorf("expected x-amz-id-2 header")
}
if got := resp.Header.Get("Content-Type"); !strings.Contains(got, "text/html") {
return fmt.Errorf("expected html Content-Type, got %q", got)
}
if methodErr, ok := expected.(s3err.MethodNotAllowedError); ok && len(methodErr.AllowedMethods) != 0 {
if got, want := resp.Header.Get("Allow"), methodErr.AllowedMethodsString(); got != want {
return fmt.Errorf("expected Allow header %q, got %q", want, got)
}
}
expectedBody := expected.HTMLBody(requestID, hostID)
return compareBodySHA256(expectedBody, body)
}
func compareBodySHA256(expected, actual []byte) error {
expectedSum := sha256.Sum256(expected)
actualSum := sha256.Sum256(actual)
if expectedSum != actualSum {
return fmt.Errorf("body checksum mismatch: expected sha256 %x, got %x; expected body %q, got %q",
expectedSum, actualSum, string(expected), string(actual))
}
return nil
}
func compareS3ApiError(expected s3err.S3Error, received *APIErrorResponse) error {
apiErr := expected.BaseError()
if received == nil {
@@ -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)
}
-15
View File
@@ -162,21 +162,6 @@ source ./tests/setup.sh
assert_success
}
@test "REST - GetBucketWebsite" {
run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "GET"
assert_success
}
@test "REST - PutBucketWebsite" {
run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "PUT"
assert_success
}
@test "REST - DeleteBucketWebsite" {
run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "website=" "DELETE"
assert_success
}
@test "REST - GetPublicAccessBlock" {
run test_not_implemented_expect_failure "$BUCKET_ONE_NAME" "publicAccessBlock=" "GET"
assert_success
+2
View File
@@ -0,0 +1,2 @@
address=/.dev/10.89.0.10
no-resolv
@@ -0,0 +1,58 @@
services:
dnsmasq:
image: strm/dnsmasq
container_name: website-dns-resolver
restart: on-failure
volumes:
- './dnsmasq.conf:/etc/dnsmasq.conf'
cap_add:
- NET_ADMIN
healthcheck:
test: 'if [ -z "$(netstat -nltu |grep \:53)" ]; then exit 1;else exit 0;fi'
interval: 2s
timeout: 2s
retries: 20
networks:
devnet:
ipv4_address: 10.89.0.53
server:
build:
context: ../..
dockerfile: tests/host-style-tests/Dockerfile
depends_on:
dnsmasq:
condition: service_healthy
command: ["-a", "user", "-s", "pass", "--health", "/health", "--iam-dir", "/tmp/vgw", "--website", ":8080", "--website-domain", "dev", "--website-no-tls", "posix", "/tmp/vgw"]
dns:
- 10.89.0.53
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7070/health"]
interval: 2s
timeout: 2s
retries: 20
networks:
devnet:
ipv4_address: 10.89.0.10
test:
build:
context: ../..
dockerfile: tests/host-style-tests/Dockerfile
depends_on:
server:
condition: service_healthy
dnsmasq:
condition: service_healthy
command: ["test", "-a", "user", "-s", "pass", "-e", "http://10.89.0.10:7070", "website-hosting", "--scheme", "http", "--domain", "dev", "--port", "8080"]
dns:
- 10.89.0.53
networks:
devnet:
networks:
devnet:
driver: bridge
ipam:
config:
- subnet: 10.89.0.0/16
+570
View File
@@ -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 ".<domain>", the bucket is the subdomain part.
// - Otherwise, no bucket can be resolved.
//
// When domain is empty (catch-all mode):
// - The full hostname is used as the bucket name.
func (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))
}
File diff suppressed because it is too large Load Diff
+145
View File
@@ -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.<domain>" resolves to bucket "blog"
// - Host "<domain>" (apex, no subdomain) resolves to bucket "<domain>"
func NewServer(be backend.Backend, domain string, opts ...Option) *Server {
app := fiber.New(fiber.Config{
AppName: "versitygw-website",
ServerHeader: "VERSITYGW",
DisableStartupMessage: true,
Network: fiber.NetworkTCP,
})
server := &Server{
app: app,
domain: domain,
}
for _, opt := range opts {
opt(server)
}
domainInfo := "catch-all"
if domain != "" {
domainInfo = "domain: " + domain
}
// Panic recovery
app.Use(recover.New())
// Request logging
if !server.quiet {
fmt.Printf("initializing website endpoint (%s)\n", domainInfo)
app.Use(logger.New(logger.Config{
Format: "${time} | website | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
}))
}
// 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()
}
+4
View File
@@ -981,6 +981,8 @@ under the License.
<li>• s3:GetBucketLocation</li>
<li>• s3:GetBucketPolicy</li>
<li>• s3:PutBucketTagging</li>
<li>• s3:PutBucketWebsite</li>
<li>• s3:GetBucketWebsite</li>
</ul>
</div>
<div>
@@ -4181,6 +4183,8 @@ under the License.
"s3:PutObject",
"s3:PutObjectRetention",
"s3:PutObjectTagging",
"s3:PutBucketWebsite",
"s3:GetBucketWebsite",
"s3:RestoreObject"
],
"Resource": [