mirror of
https://github.com/versity/versitygw.git
synced 2026-08-16 20:26:16 +00:00
Merge pull request #2171 from versity/sis/static-website-hosting
Static website hosting
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,12 @@ Get more details about the new (optional) WebGUI management/explorer here: [http
|
||||
|
||||

|
||||
|
||||
### Static Website Hosting
|
||||
Serve S3 buckets as static websites with index documents, custom error pages, and routing rules.
|
||||
Enable a separate website endpoint with `--website :8090 --website-domain example.com` for virtual-host style routing (`blog.example.com` serves bucket `blog`, `example.com` serves bucket `example.com`).
|
||||
When `--website-domain` is omitted, catch-all mode is used: the full hostname becomes the bucket name (name your buckets as FQDNs, e.g. `blog.example.com`).
|
||||
See [Global Options](https://github.com/versity/versitygw/wiki/Global-Options) for all `--website-*` flags.
|
||||
|
||||
### News
|
||||
Check out latest wiki articles: [https://github.com/versity/versitygw/wiki/Articles](https://github.com/versity/versitygw/wiki/Articles)
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ const (
|
||||
GetAccelerateConfigurationAction Action = "s3:GetAccelerateConfiguration"
|
||||
PutBucketWebsiteAction Action = "s3:PutBucketWebsite"
|
||||
GetBucketWebsiteAction Action = "s3:GetBucketWebsite"
|
||||
DeleteBucketWebsiteAction Action = "s3:DeleteBucketWebsite"
|
||||
GetBucketPolicyStatusAction Action = "s3:GetBucketPolicyStatus"
|
||||
GetBucketLocationAction Action = "s3:GetBucketLocation"
|
||||
|
||||
@@ -167,6 +168,7 @@ var supportedActionList = map[Action]struct{}{
|
||||
GetAccelerateConfigurationAction: {},
|
||||
PutBucketWebsiteAction: {},
|
||||
GetBucketWebsiteAction: {},
|
||||
DeleteBucketWebsiteAction: {},
|
||||
GetBucketPolicyStatusAction: {},
|
||||
GetBucketLocationAction: {},
|
||||
AllActions: {},
|
||||
|
||||
+98
-41
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -83,6 +83,7 @@ The `gateway.backend.type` value selects the storage backend. Use `gateway.backe
|
||||
| **HTTPRoute** | `httpRoute.enabled=true` — Gateway API successor to Ingress for S3 API; also `admin.httpRoute.enabled=true` and `webui.httpRoute.enabled=true` to expose the admin API and/or WebUI |
|
||||
| **Admin API** | `admin.enabled=true` — exposes a separate management API on `admin.port` (default `7071`) |
|
||||
| **WebUI** | `webui.enabled=true` — browser-based management UI on `webui.port` (default `8080`); set `webui.apiGateways` and `webui.adminGateways` to your externally reachable endpoints |
|
||||
| **Website Hosting** | `website.enabled=true` — static website hosting endpoint on `website.port` (default `8090`); optionally set `website.domain` for virtual-host routing (e.g. `example.com`), or omit it for catch-all mode where the full hostname is the bucket name |
|
||||
| **IAM** | `iam.enabled=true` — flat-file identity and access management stored alongside backend data |
|
||||
| **Persistence** | `persistence.enabled=true` — provisions a PVC for backend data and IAM storage; defaults to `10Gi`, or uses a hostPath volume specified by `persistence.hostPath` |
|
||||
| **NetworkPolicy** | `networkPolicy.enabled=true` — restricts ingress to selected pods/namespaces; allows all egress |
|
||||
|
||||
@@ -129,6 +129,17 @@ spec:
|
||||
value: {{ .Values.webui.adminGateways | join "," | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
# Website Hosting
|
||||
{{- if .Values.website.enabled }}
|
||||
- name: VGW_WEBSITE_PORT
|
||||
value: ":{{ .Values.website.port }}"
|
||||
- name: VGW_WEBSITE_DOMAIN
|
||||
value: {{ .Values.website.domain | quote }}
|
||||
{{- if .Values.website.noTls }}
|
||||
- name: VGW_WEBSITE_NO_TLS
|
||||
value: "true"
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if .Values.iam.enabled }}
|
||||
# IAM settings
|
||||
{{- if eq .Values.iam.type "internal" }}
|
||||
@@ -173,6 +184,11 @@ spec:
|
||||
containerPort: {{ .Values.webui.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
{{- if .Values.website.enabled }}
|
||||
- name: website
|
||||
containerPort: {{ .Values.website.port }}
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: "/_/health"
|
||||
|
||||
@@ -23,5 +23,11 @@ spec:
|
||||
protocol: TCP
|
||||
name: webui
|
||||
{{- end }}
|
||||
{{- if .Values.website.enabled }}
|
||||
- port: {{ .Values.website.port }}
|
||||
targetPort: website
|
||||
protocol: TCP
|
||||
name: website
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "versitygw.selectorLabels" . | nindent 4 }}
|
||||
|
||||
@@ -225,6 +225,24 @@ webui:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
|
||||
# --- Website Hosting ---
|
||||
website:
|
||||
# Enable the static website hosting endpoint.
|
||||
# Serves S3 buckets as static websites with index documents, custom error
|
||||
# pages, and routing rules via a separate HTTP endpoint.
|
||||
enabled: false
|
||||
# The port the website endpoint listens on.
|
||||
port: 8090
|
||||
# Base domain for virtual-host routing. Optional.
|
||||
# Host "blog.<domain>" serves bucket "blog"; host "<domain>" serves
|
||||
# bucket "<domain>" (apex domain support).
|
||||
# When empty, catch-all mode is used: the full hostname is the bucket
|
||||
# name (name buckets as FQDNs, e.g. "blog.example.com").
|
||||
domain: ""
|
||||
# - example: domain: "example.com"
|
||||
# Disable TLS for the website endpoint even when gateway TLS is enabled.
|
||||
noTls: false
|
||||
|
||||
# --- IAM (Identity and Access Management) ---
|
||||
iam:
|
||||
enabled: false
|
||||
|
||||
@@ -91,6 +91,10 @@ var (
|
||||
webuiAdminGateways []string
|
||||
webuiPathPrefix string
|
||||
webuiS3Prefix string
|
||||
websitePorts []string
|
||||
websiteDomain string
|
||||
websiteCertFile, websiteKeyFile string
|
||||
websiteNoTLS bool
|
||||
disableACLs bool
|
||||
mpMaxParts int
|
||||
copyObjectThreshold int64
|
||||
@@ -152,6 +156,7 @@ documentation can be found in the GitHub wiki.`,
|
||||
webuiGateways = ctx.StringSlice("webui-gateways")
|
||||
webuiAdminGateways = ctx.StringSlice("webui-admin-gateways")
|
||||
webuiPathPrefix = ctx.String("webui-path-prefix")
|
||||
websitePorts = ctx.StringSlice("website")
|
||||
|
||||
// Resolve relative UNIX socket paths to absolute before any backend
|
||||
// (e.g. posix) can change the working directory via os.Chdir.
|
||||
@@ -165,6 +170,9 @@ documentation can be found in the GitHub wiki.`,
|
||||
if webuiPorts, err = utils.AbsSocketPaths(webuiPorts); err != nil {
|
||||
return err
|
||||
}
|
||||
if websitePorts, err = utils.AbsSocketPaths(websitePorts); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
@@ -240,6 +248,35 @@ func initFlags() []cli.Flag {
|
||||
EnvVars: []string{"VGW_WEBUI_S3_PREFIX"},
|
||||
Destination: &webuiS3Prefix,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "website",
|
||||
Usage: "enable static website hosting endpoint on the specified listen address (e.g. ':8080'; same forms as --port; can be specified multiple times; requires --website-domain)",
|
||||
EnvVars: []string{"VGW_WEBSITE_PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "website-domain",
|
||||
Usage: "base domain for website virtual-host routing (e.g. 'example.com'); host 'blog.example.com' serves bucket 'blog', host 'example.com' serves bucket 'example.com'; when omitted the full hostname is used as the bucket name (catch-all mode, buckets named as FQDNs)",
|
||||
EnvVars: []string{"VGW_WEBSITE_DOMAIN"},
|
||||
Destination: &websiteDomain,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "website-cert",
|
||||
Usage: "TLS cert file for website endpoint (defaults to --cert value when website is enabled)",
|
||||
EnvVars: []string{"VGW_WEBSITE_CERT"},
|
||||
Destination: &websiteCertFile,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "website-key",
|
||||
Usage: "TLS key file for website endpoint (defaults to --key value when website is enabled)",
|
||||
EnvVars: []string{"VGW_WEBSITE_KEY"},
|
||||
Destination: &websiteKeyFile,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "website-no-tls",
|
||||
Usage: "disable TLS for website endpoint even if TLS is configured for the gateway",
|
||||
EnvVars: []string{"VGW_WEBSITE_NO_TLS"},
|
||||
Destination: &websiteNoTLS,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "access",
|
||||
Usage: "root user access key",
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 #
|
||||
#######################
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -494,6 +494,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
contentLanguage := ctx.Get("Content-Language")
|
||||
cacheControl := ctx.Get("Cache-Control")
|
||||
expires := ctx.Get("Expires")
|
||||
websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location")
|
||||
tagging := ctx.Get("x-amz-tagging")
|
||||
storageClass := ctx.Get("X-Amz-Storage-Class")
|
||||
legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold")
|
||||
@@ -578,6 +579,15 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
}, s3err.GetInvalidArgumentErr(s3err.InvalidArgTaggingDirective, string(taggingDirective))
|
||||
}
|
||||
|
||||
err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
checksumAlgorithm := types.ChecksumAlgorithm(ctx.Get("x-amz-checksum-algorithm"))
|
||||
err = utils.IsChecksumAlgorithmValid(checksumAlgorithm)
|
||||
if err != nil {
|
||||
@@ -618,6 +628,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
ContentLanguage: &contentLanguage,
|
||||
CacheControl: &cacheControl,
|
||||
Expires: &expires,
|
||||
WebsiteRedirectLocation: &websiteRedirectLocation,
|
||||
Tagging: &tagging,
|
||||
TaggingDirective: taggingDirective,
|
||||
CopySource: ©Source,
|
||||
@@ -665,6 +676,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
contentLanguage := ctx.Get("Content-Language")
|
||||
cacheControl := ctx.Get("Cache-Control")
|
||||
expires := ctx.Get("Expires")
|
||||
websiteRedirectLocation := ctx.Get("X-Amz-Website-Redirect-Location")
|
||||
tagging := ctx.Get("x-amz-tagging")
|
||||
legalHoldHdr := ctx.Get("X-Amz-Object-Lock-Legal-Hold")
|
||||
lockModeHdr := ctx.Get("X-Amz-Object-Lock-Mode")
|
||||
@@ -729,6 +741,15 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
}, err
|
||||
}
|
||||
|
||||
err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
MetaOpts: &MetaOptions{
|
||||
BucketOwner: parsedAcl.Owner,
|
||||
},
|
||||
}, err
|
||||
}
|
||||
|
||||
err = auth.CheckObjectAccess(ctx.Context(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, IsBucketPublic, c.be, true)
|
||||
if err != nil {
|
||||
return &Response{
|
||||
@@ -787,6 +808,7 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) {
|
||||
ContentLanguage: &contentLanguage,
|
||||
CacheControl: &cacheControl,
|
||||
Expires: &expires,
|
||||
WebsiteRedirectLocation: &websiteRedirectLocation,
|
||||
Metadata: metadata,
|
||||
Body: body,
|
||||
Tagging: &tagging,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func DeleteBucketWebsite_non_existing_bucket(s *S3Conf) error {
|
||||
testName := "DeleteBucketWebsite_non_existing_bucket"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{
|
||||
Bucket: getPtr("non-existing-bucket"),
|
||||
})
|
||||
cancel()
|
||||
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteBucketWebsite_success(s *S3Conf) error {
|
||||
testName := "DeleteBucketWebsite_success"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
deleteWebsite := func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := s3client.DeleteBucketWebsite(ctx, &s3.DeleteBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
|
||||
// should not return error when deleting unset website config
|
||||
err := deleteWebsite()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// put a website config
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
WebsiteConfiguration: &types.WebsiteConfiguration{
|
||||
IndexDocument: &types.IndexDocument{
|
||||
Suffix: getPtr("index.html"),
|
||||
},
|
||||
},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete the website config
|
||||
err = deleteWebsite()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// verify it's gone
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
cancel()
|
||||
|
||||
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func GetBucketWebsite_non_existing_bucket(s *S3Conf) error {
|
||||
testName := "GetBucketWebsite_non_existing_bucket"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
|
||||
Bucket: getPtr("non-existing-bucket"),
|
||||
})
|
||||
cancel()
|
||||
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchBucket))
|
||||
})
|
||||
}
|
||||
|
||||
func GetBucketWebsite_no_such_website_config(s *S3Conf) error {
|
||||
testName := "GetBucketWebsite_no_such_website_config"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
cancel()
|
||||
return checkApiErr(err, s3err.GetAPIError(s3err.ErrNoSuchWebsiteConfiguration))
|
||||
})
|
||||
}
|
||||
|
||||
func GetBucketWebsite_success(s *S3Conf) error {
|
||||
testName := "GetBucketWebsite_success"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
WebsiteConfiguration: &types.WebsiteConfiguration{
|
||||
IndexDocument: &types.IndexDocument{
|
||||
Suffix: getPtr("index.html"),
|
||||
},
|
||||
ErrorDocument: &types.ErrorDocument{
|
||||
Key: getPtr("error.html"),
|
||||
},
|
||||
},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.IndexDocument == nil || res.IndexDocument.Suffix == nil || *res.IndexDocument.Suffix != "index.html" {
|
||||
return fmt.Errorf("expected IndexDocument.Suffix to be %q, got %v", "index.html", res.IndexDocument)
|
||||
}
|
||||
if res.ErrorDocument == nil || res.ErrorDocument.Key == nil || *res.ErrorDocument.Key != "error.html" {
|
||||
return fmt.Errorf("expected ErrorDocument.Key to be %q, got %v", "error.html", res.ErrorDocument)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetBucketWebsite_success_redirect_all(s *S3Conf) error {
|
||||
testName := "GetBucketWebsite_success_redirect_all"
|
||||
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err := s3client.PutBucketWebsite(ctx, &s3.PutBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
WebsiteConfiguration: &types.WebsiteConfiguration{
|
||||
RedirectAllRequestsTo: &types.RedirectAllRequestsTo{
|
||||
HostName: getPtr("example.com"),
|
||||
Protocol: types.ProtocolHttps,
|
||||
},
|
||||
},
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
|
||||
res, err := s3client.GetBucketWebsite(ctx, &s3.GetBucketWebsiteInput{
|
||||
Bucket: &bucket,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if res.RedirectAllRequestsTo == nil || res.RedirectAllRequestsTo.HostName == nil || *res.RedirectAllRequestsTo.HostName != "example.com" {
|
||||
return fmt.Errorf("expected RedirectAllRequestsTo.HostName to be %q, got %v", "example.com", res.RedirectAllRequestsTo)
|
||||
}
|
||||
if res.RedirectAllRequestsTo.Protocol != types.ProtocolHttps {
|
||||
return fmt.Errorf("expected RedirectAllRequestsTo.Protocol to be %q, got %q", types.ProtocolHttps, res.RedirectAllRequestsTo.Protocol)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -981,6 +981,8 @@ under the License.
|
||||
<li>• s3:GetBucketLocation</li>
|
||||
<li>• s3:GetBucketPolicy</li>
|
||||
<li>• s3:PutBucketTagging</li>
|
||||
<li>• s3:PutBucketWebsite</li>
|
||||
<li>• s3:GetBucketWebsite</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -4181,6 +4183,8 @@ under the License.
|
||||
"s3:PutObject",
|
||||
"s3:PutObjectRetention",
|
||||
"s3:PutObjectTagging",
|
||||
"s3:PutBucketWebsite",
|
||||
"s3:GetBucketWebsite",
|
||||
"s3:RestoreObject"
|
||||
],
|
||||
"Resource": [
|
||||
|
||||
Reference in New Issue
Block a user