From 24c27154d9d2ed0499640aa5e56a31c5ff6304d2 Mon Sep 17 00:00:00 2001 From: enisn Date: Mon, 26 May 2025 15:15:41 +0300 Subject: [PATCH 1/9] Add ServiceURL configuration for S3-compatible APIs - Introduced a new property `ServiceURL` in `AwsBlobProviderConfiguration` to allow custom service URLs for S3-compatible APIs like MinIO and DigitalOcean Spaces. - Updated `AwsBlobProviderConfigurationNames` to include the new `ServiceURL` constant. - Modified `DefaultAmazonS3ClientFactory` to utilize the `ServiceURL` when creating the S3 client configuration, enabling support for custom endpoints. --- .../Aws/AwsBlobProviderConfiguration.cs | 9 +++++++ .../Aws/AwsBlobProviderConfigurationNames.cs | 1 + .../Aws/DefaultAmazonS3ClientFactory.cs | 27 +++++++++++++++---- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs index 1b31d176c8..77919b80ef 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs @@ -62,6 +62,15 @@ public class AwsBlobProviderConfiguration set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, Check.NotNull(value, nameof(value))); } + /// + /// Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces). + /// If not specified, the default AWS S3 service URL will be used based on the region. + /// + public string? ServiceURL { + get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.ServiceURL); + set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.ServiceURL, value); + } + /// /// This name may only contain lowercase letters, numbers, and hyphens, and must begin with a letter or a number. /// Each hyphen must be preceded and followed by a non-hyphen character. diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs index d1b775c812..b065441462 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs @@ -14,6 +14,7 @@ public static class AwsBlobProviderConfigurationNames public const string Name = "Aws.Name"; public const string Policy = "Aws.Policy"; public const string Region = "Aws.Region"; + public const string ServiceURL = "Aws.ServiceURL"; public const string ContainerName = "Aws.ContainerName"; public const string CreateContainerIfNotExists = "Aws.CreateContainerIfNotExists"; } diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs index dee0bf5096..dc2a7fe56b 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs @@ -31,30 +31,47 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe AwsBlobProviderConfiguration configuration) { var region = RegionEndpoint.GetBySystemName(configuration.Region); + var clientConfig = CreateS3ClientConfig(configuration, region); if (configuration.UseCredentials) { var awsCredentials = GetAwsCredentials(configuration); return awsCredentials == null - ? new AmazonS3Client(region) - : new AmazonS3Client(awsCredentials, region); + ? new AmazonS3Client(clientConfig) + : new AmazonS3Client(awsCredentials, clientConfig); } if (configuration.UseTemporaryCredentials) { - return new AmazonS3Client(await GetTemporaryCredentialsAsync(configuration), region); + return new AmazonS3Client(await GetTemporaryCredentialsAsync(configuration), clientConfig); } if (configuration.UseTemporaryFederatedCredentials) { return new AmazonS3Client(await GetTemporaryFederatedCredentialsAsync(configuration), - region); + clientConfig); } Check.NotNullOrWhiteSpace(configuration.AccessKeyId, nameof(configuration.AccessKeyId)); Check.NotNullOrWhiteSpace(configuration.SecretAccessKey, nameof(configuration.SecretAccessKey)); - return new AmazonS3Client(configuration.AccessKeyId, configuration.SecretAccessKey, region); + return new AmazonS3Client(configuration.AccessKeyId, configuration.SecretAccessKey, clientConfig); + } + + protected virtual AmazonS3Config CreateS3ClientConfig(AwsBlobProviderConfiguration configuration, RegionEndpoint region) + { + var clientConfig = new AmazonS3Config + { + RegionEndpoint = region + }; + + if (!configuration.ServiceURL.IsNullOrWhiteSpace()) + { + clientConfig.ServiceURL = configuration.ServiceURL; + clientConfig.ForcePathStyle = true; // Required for most S3-compatible services + } + + return clientConfig; } protected virtual AWSCredentials? GetAwsCredentials( From 0f46fb4417cb6829af356723f75a3ef19b4ef458 Mon Sep 17 00:00:00 2001 From: enisn Date: Mon, 26 May 2025 15:16:17 +0300 Subject: [PATCH 2/9] Add unit tests for AwsBlobProviderConfiguration and DefaultAmazonS3ClientFactory - Created `AwsBlobProviderConfiguration_Tests` to validate ServiceURL setting and retrieval. - Implemented `DefaultAmazonS3ClientFactory_Tests` to ensure S3 client creation with and without custom ServiceURL. - Tests cover scenarios for both S3-compatible services and default AWS S3 behavior. --- .../Aws/AwsBlobProviderConfiguration_Tests.cs | 52 +++++++++++++++ .../Aws/DefaultAmazonS3ClientFactory_Tests.cs | 64 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs create mode 100644 framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs new file mode 100644 index 0000000000..2e95ed8291 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs @@ -0,0 +1,52 @@ +using Shouldly; +using Xunit; + +namespace Volo.Abp.BlobStoring.Aws; + +public class AwsBlobProviderConfiguration_Tests : AbpBlobStoringAwsTestCommonBase +{ + [Fact] + public void Should_Set_And_Get_ServiceURL() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + const string serviceUrl = "https://minio.example.com:9000"; + + // Act + awsConfiguration.ServiceURL = serviceUrl; + + // Assert + awsConfiguration.ServiceURL.ShouldBe(serviceUrl); + } + + [Fact] + public void Should_Return_Null_When_ServiceURL_Not_Set() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + + // Act & Assert + awsConfiguration.ServiceURL.ShouldBeNull(); + } + + [Fact] + public void Should_Configure_ServiceURL_Using_UseAws_Extension() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + const string serviceUrl = "https://spaces.digitalocean.com"; + + // Act + containerConfiguration.UseAws(config => + { + config.ServiceURL = serviceUrl; + config.Region = "us-east-1"; + }); + + // Assert + var awsConfig = containerConfiguration.GetAwsConfiguration(); + awsConfig.ServiceURL.ShouldBe(serviceUrl); + } +} \ No newline at end of file diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs new file mode 100644 index 0000000000..cf5b973ee0 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs @@ -0,0 +1,64 @@ +using System.Threading.Tasks; +using Amazon.S3; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Volo.Abp.BlobStoring.Aws; + +public class DefaultAmazonS3ClientFactory_Tests : AbpBlobStoringAwsTestBase +{ + private readonly IAmazonS3ClientFactory _amazonS3ClientFactory; + + public DefaultAmazonS3ClientFactory_Tests() + { + _amazonS3ClientFactory = GetRequiredService(); + } + + [Fact] + public async Task Should_Create_S3Client_With_Custom_ServiceURL() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + const string serviceUrl = "https://minio.example.com:9000"; + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + Region = "us-east-1", + ServiceURL = serviceUrl + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + s3Client.Config.ServiceURL.ShouldBe(serviceUrl + "/"); // AWS SDK automatically appends trailing slash + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); // Should be enabled for S3-compatible services + } + + [Fact] + public async Task Should_Create_S3Client_Without_Custom_ServiceURL() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + Region = "us-east-1" + // ServiceURL not set + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + s3Client.Config.ServiceURL.ShouldBeNull(); // Should use default AWS S3 service + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeFalse(); // Should be false for AWS S3 + } +} \ No newline at end of file From 6acf535ef8bde4192a9aac2686d40cfb70ace710 Mon Sep 17 00:00:00 2001 From: enisn Date: Mon, 26 May 2025 15:23:00 +0300 Subject: [PATCH 3/9] Enhance AWS Blob Storing Documentation for S3-Compatible Services - Updated the AWS provider documentation to clarify support for S3-compatible storage services, including MinIO, DigitalOcean Spaces, and others. - Added details on configuring the `ServiceURL` property for S3-compatible APIs. - Included example configurations for MinIO, DigitalOcean Spaces, and Wasabi to assist users in setting up their environments. --- .../infrastructure/blob-storing/aws.md | 70 ++++++++++++++++++- .../infrastructure/blob-storing/index.md | 14 ++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/docs/en/framework/infrastructure/blob-storing/aws.md b/docs/en/framework/infrastructure/blob-storing/aws.md index 4eac484e92..514becab5e 100644 --- a/docs/en/framework/infrastructure/blob-storing/aws.md +++ b/docs/en/framework/infrastructure/blob-storing/aws.md @@ -1,6 +1,6 @@ # BLOB Storing Aws Provider -BLOB Storing Aws Provider can store BLOBs in [Amazon Simple Storage Service](https://aws.amazon.com/s3/). +BLOB Storing Aws Provider can store BLOBs in [Amazon Simple Storage Service](https://aws.amazon.com/s3/) and **S3-compatible storage services** like MinIO, DigitalOcean Spaces, Cloudflare R2, and others. > Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. This document only covers how to configure containers to use a Aws BLOB as the storage provider. @@ -35,6 +35,7 @@ Configure(options => Aws.ProfileName = "the name of the profile to get credentials from"; Aws.ProfilesLocation = "the path to the aws credentials file to look at"; Aws.Region = "the system name of the service"; + Aws.ServiceURL = "custom service URL for S3-compatible APIs (optional)"; Aws.Name = "the name of the federated user"; Aws.Policy = "policy"; Aws.DurationSeconds = "expiration date"; @@ -58,6 +59,7 @@ Configure(options => * **ProfileName** (string): The [name of the profile](https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-creds.html) to get credentials from. * **ProfilesLocation** (string): The path to the aws credentials file to look at. * **Region** (string): The system name of the service. +* **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). * **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy. * **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using sub-accounts operated OSS,if the value is 0. * **ContainerName** (string): You can specify the container name in Aws. If this is not specified, it uses the name of the BLOB container defined with the `BlobContainerName` attribute (see the [BLOB storing document](../blob-storing)). Please note that Aws has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html): @@ -70,6 +72,72 @@ Configure(options => * Buckets used with Amazon S3 Transfer Acceleration can't have dots (.) in their names. For more information about transfer acceleration, see Amazon S3 Transfer Acceleration. * **CreateContainerIfNotExists** (bool): Default value is `false`, If a container does not exist in Aws, `AwsBlobProvider` will try to create it. +## S3-Compatible Services + +The AWS provider supports S3-compatible storage services by configuring the `ServiceURL` property. Here are some examples: + +### MinIO Configuration + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseAws(aws => + { + aws.AccessKeyId = "your-minio-access-key"; + aws.SecretAccessKey = "your-minio-secret-key"; + aws.ServiceURL = "https://minio.example.com:9000"; + aws.Region = "us-east-1"; // MinIO region (can be any valid region) + aws.ContainerName = "my-bucket"; + aws.CreateContainerIfNotExists = true; + }); + }); +}); +```` + +### DigitalOcean Spaces Configuration + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseAws(aws => + { + aws.AccessKeyId = "your-spaces-access-key"; + aws.SecretAccessKey = "your-spaces-secret-key"; + aws.ServiceURL = "https://nyc3.digitaloceanspaces.com"; + aws.Region = "us-east-1"; // DigitalOcean Spaces region + aws.ContainerName = "my-space"; + aws.CreateContainerIfNotExists = true; + }); + }); +}); +```` + +### Wasabi Configuration + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseAws(aws => + { + aws.AccessKeyId = "your-wasabi-access-key"; + aws.SecretAccessKey = "your-wasabi-secret-key"; + aws.ServiceURL = "https://s3.us-east-1.wasabisys.com"; + aws.Region = "us-east-1"; + aws.ContainerName = "my-bucket"; + aws.CreateContainerIfNotExists = true; + }); + }); +}); +```` + +> **Note**: When using S3-compatible services, the provider automatically enables path-style requests which are required by most S3-compatible implementations. + ## Aws Blob Name Calculator Aws Blob Provider organizes BLOB name and implements some conventions. The full name of a BLOB is determined by the following rules by default: diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md index 676757ad80..75514bff4f 100644 --- a/docs/en/framework/infrastructure/blob-storing/index.md +++ b/docs/en/framework/infrastructure/blob-storing/index.md @@ -29,6 +29,20 @@ More providers will be implemented by the time. You can [request](https://github Multiple providers **can be used together** by the help of the **container system**, where each container can uses a different provider. +### S3 Compatibility + +The [AWS provider](./aws.md) supports not only Amazon S3 but also **S3-compatible APIs** from various cloud providers and self-hosted solutions. This means you can use the same AWS provider to connect to: + +* **Amazon S3** - The original AWS S3 service +* **MinIO** - Self-hosted S3-compatible object storage +* **Cloudflare R2** - Cloudflare's S3-compatible object storage +* **DigitalOcean Spaces** - DigitalOcean's S3-compatible object storage +* **Wasabi** - S3-compatible cloud storage +* **Backblaze B2** - S3-compatible cloud storage +* **Any other S3-compatible storage** - Including private cloud solutions + +To use S3-compatible services, simply configure the `ServiceURL` property in the AWS provider configuration to point to your S3-compatible endpoint. The provider will automatically handle the necessary protocol adjustments for compatibility. + > BLOB storing system can not work unless you **configure a storage provider**. Refer to the linked documents for the storage provider configurations. ## Installation From 898a3134bb969cc56f02fa0f2a17f039212d0956 Mon Sep 17 00:00:00 2001 From: enisn Date: Mon, 26 May 2025 15:27:25 +0300 Subject: [PATCH 4/9] Add CloudFlare R2 example documentation --- docs/en/framework/infrastructure/blob-storing/aws.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/en/framework/infrastructure/blob-storing/aws.md b/docs/en/framework/infrastructure/blob-storing/aws.md index 514becab5e..1475e5beb4 100644 --- a/docs/en/framework/infrastructure/blob-storing/aws.md +++ b/docs/en/framework/infrastructure/blob-storing/aws.md @@ -116,7 +116,7 @@ Configure(options => }); ```` -### Wasabi Configuration +### Cloudflare R2 Configuration ````csharp Configure(options => @@ -125,10 +125,10 @@ Configure(options => { container.UseAws(aws => { - aws.AccessKeyId = "your-wasabi-access-key"; - aws.SecretAccessKey = "your-wasabi-secret-key"; - aws.ServiceURL = "https://s3.us-east-1.wasabisys.com"; - aws.Region = "us-east-1"; + aws.AccessKeyId = "your-r2-access-key"; + aws.SecretAccessKey = "your-r2-secret-key"; + aws.ServiceURL = "https://your-account-id.r2.cloudflarestorage.com"; + aws.Region = "auto"; // Cloudflare R2 uses 'auto' as region aws.ContainerName = "my-bucket"; aws.CreateContainerIfNotExists = true; }); From 4f2a1410467be81e14a778a2e7a75a922d5cd789 Mon Sep 17 00:00:00 2001 From: enisn Date: Mon, 26 May 2025 16:25:37 +0300 Subject: [PATCH 5/9] Make region optional for some S3 compatible APIs --- .../Aws/AwsBlobProviderConfiguration.cs | 6 +-- .../Aws/DefaultAmazonS3ClientFactory.cs | 15 +++++--- .../Aws/AbpBlobStoringAwsTestModule.cs | 38 +++++++++++++------ .../Aws/DefaultAmazonS3ClientFactory_Tests.cs | 23 +++++++++++ 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs index 77919b80ef..e5a8a853d7 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs @@ -57,9 +57,9 @@ public class AwsBlobProviderConfiguration set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Policy, value); } - public string Region { - get => _containerConfiguration.GetConfiguration(AwsBlobProviderConfigurationNames.Region); - set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, Check.NotNull(value, nameof(value))); + public string? Region { + get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.Region); + set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, value); } /// diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs index dc2a7fe56b..1572adca7c 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs @@ -30,7 +30,9 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe public virtual async Task GetAmazonS3Client( AwsBlobProviderConfiguration configuration) { - var region = RegionEndpoint.GetBySystemName(configuration.Region); + var region = !configuration.Region.IsNullOrWhiteSpace() + ? RegionEndpoint.GetBySystemName(configuration.Region) + : null; var clientConfig = CreateS3ClientConfig(configuration, region); if (configuration.UseCredentials) @@ -58,12 +60,15 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe return new AmazonS3Client(configuration.AccessKeyId, configuration.SecretAccessKey, clientConfig); } - protected virtual AmazonS3Config CreateS3ClientConfig(AwsBlobProviderConfiguration configuration, RegionEndpoint region) + protected virtual AmazonS3Config CreateS3ClientConfig(AwsBlobProviderConfiguration configuration, RegionEndpoint? region) { - var clientConfig = new AmazonS3Config + var clientConfig = new AmazonS3Config(); + + // Set region only if it's provided (for AWS S3) + if (region != null) { - RegionEndpoint = region - }; + clientConfig.RegionEndpoint = region; + } if (!configuration.ServiceURL.IsNullOrWhiteSpace()) { diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs index 6edd0fc669..984a66bbc1 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs @@ -69,23 +69,39 @@ public class AbpBlobStoringAwsTestModule : AbpModule private async Task DeleteBucketAsync(ApplicationShutdownContext context) { - var amazonS3Client = await context.ServiceProvider.GetRequiredService() - .GetAmazonS3Client(_configuration); + // Skip bucket deletion if configuration is not properly set (e.g., in unit tests) + if (_configuration == null || + string.IsNullOrWhiteSpace(_configuration.AccessKeyId) || + string.IsNullOrWhiteSpace(_configuration.SecretAccessKey) || + (string.IsNullOrWhiteSpace(_configuration.Region) && string.IsNullOrWhiteSpace(_configuration.ServiceURL))) + { + return; + } - if (await AmazonS3Util.DoesS3BucketExistV2Async(amazonS3Client, _randomContainerName)) + try { - var blobs = await amazonS3Client.ListObjectsAsync(_randomContainerName); + var amazonS3Client = await context.ServiceProvider.GetRequiredService() + .GetAmazonS3Client(_configuration); - if (blobs.S3Objects.Any()) + if (await AmazonS3Util.DoesS3BucketExistV2Async(amazonS3Client, _randomContainerName)) { - await amazonS3Client.DeleteObjectsAsync(new DeleteObjectsRequest + var blobs = await amazonS3Client.ListObjectsAsync(_randomContainerName); + + if (blobs.S3Objects.Any()) { - BucketName = _randomContainerName, - Objects = blobs.S3Objects.Select(o => new KeyVersion { Key = o.Key }).ToList() - }); - } + await amazonS3Client.DeleteObjectsAsync(new DeleteObjectsRequest + { + BucketName = _randomContainerName, + Objects = blobs.S3Objects.Select(o => new KeyVersion { Key = o.Key }).ToList() + }); + } - await amazonS3Client.DeleteBucketAsync(_randomContainerName); + await amazonS3Client.DeleteBucketAsync(_randomContainerName); + } + } + catch + { + // Ignore errors during test cleanup } } } diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs index cf5b973ee0..5f7539de9e 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs @@ -61,4 +61,27 @@ public class DefaultAmazonS3ClientFactory_Tests : AbpBlobStoringAwsTestBase s3Client.Config.ServiceURL.ShouldBeNull(); // Should use default AWS S3 service ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeFalse(); // Should be false for AWS S3 } + + [Fact] + public async Task Should_Create_S3Client_Without_Region_For_S3Compatible_Services() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + ServiceURL = "https://minio.example.com:9000" + // Region not set - should work for S3-compatible services + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + s3Client.Config.ServiceURL.ShouldBe("https://minio.example.com:9000/"); // AWS SDK automatically appends trailing slash + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); // Should be enabled for S3-compatible services + } } \ No newline at end of file From c40f4538d9ce0257d4c1b94149913c5fa841827f Mon Sep 17 00:00:00 2001 From: enisn Date: Mon, 26 May 2025 16:43:05 +0300 Subject: [PATCH 6/9] Configure `ResponseChecksumValidation` hwne third party S3 providers used --- .../Aws/DefaultAmazonS3ClientFactory.cs | 5 ++++ .../Aws/DefaultAmazonS3ClientFactory_Tests.cs | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs index 1572adca7c..0d4575d832 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs @@ -74,6 +74,11 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe { clientConfig.ServiceURL = configuration.ServiceURL; clientConfig.ForcePathStyle = true; // Required for most S3-compatible services + + // Set checksum properties for S3-compatible services (e.g., Cloudflare R2) + // These settings help with compatibility issues in non-AWS S3 services + clientConfig.RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED; + clientConfig.ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED; } return clientConfig; diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs index 5f7539de9e..fbd51dd038 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs @@ -84,4 +84,33 @@ public class DefaultAmazonS3ClientFactory_Tests : AbpBlobStoringAwsTestBase s3Client.Config.ServiceURL.ShouldBe("https://minio.example.com:9000/"); // AWS SDK automatically appends trailing slash ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); // Should be enabled for S3-compatible services } + + [Fact] + public async Task Should_Set_Checksum_Properties_For_S3Compatible_Services() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + ServiceURL = "https://r2.cloudflarestorage.com", + Region = "auto" + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + var config = (AmazonS3Config)s3Client.Config; + config.ServiceURL.ShouldBe("https://r2.cloudflarestorage.com/"); + config.ForcePathStyle.ShouldBeTrue(); + + // Verify checksum properties are set for S3-compatible services (required for Cloudflare R2) + // We just verify they are not null/default, indicating they have been set + config.RequestChecksumCalculation.ShouldNotBe(default); + config.ResponseChecksumValidation.ShouldNotBe(default); + } } \ No newline at end of file From 9bce9e4b468087790c67ba35154838824c8d6022 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 1 Jun 2026 13:34:44 +0800 Subject: [PATCH 7/9] Support Cloudflare R2 in AWS BLOB provider - Add DisablePayloadSigning configuration to send UNSIGNED-PAYLOAD instead of streaming chunked signature, required by R2 - Make CreateS3ClientConfig async to allow subclasses to do I/O - Document trailing-slash behavior of ServiceURL - Extend test module to use external bucket with per-run prefix cleanup - Document DisablePayloadSigning in aws.md and link from index.md --- .../infrastructure/blob-storing/aws.md | 6 +- .../infrastructure/blob-storing/index.md | 2 +- .../Abp/BlobStoring/Aws/AwsBlobProvider.cs | 3 +- .../Aws/AwsBlobProviderConfiguration.cs | 15 ++- .../Aws/AwsBlobProviderConfigurationNames.cs | 1 + .../Aws/DefaultAmazonS3ClientFactory.cs | 14 +- .../Aws/AbpBlobStoringAwsTestModule.cs | 127 +++++++++++++++--- .../BlobStoring/Aws/AwsBlobContainer_Tests.cs | 26 +++- .../Aws/AwsBlobProviderConfiguration_Tests.cs | 22 ++- 9 files changed, 184 insertions(+), 32 deletions(-) diff --git a/docs/en/framework/infrastructure/blob-storing/aws.md b/docs/en/framework/infrastructure/blob-storing/aws.md index 1475e5beb4..d623e3a330 100644 --- a/docs/en/framework/infrastructure/blob-storing/aws.md +++ b/docs/en/framework/infrastructure/blob-storing/aws.md @@ -59,7 +59,8 @@ Configure(options => * **ProfileName** (string): The [name of the profile](https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-creds.html) to get credentials from. * **ProfilesLocation** (string): The path to the aws credentials file to look at. * **Region** (string): The system name of the service. -* **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). +* **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). The AWS SDK automatically appends a trailing slash to the configured value. +* **DisablePayloadSigning** (bool): Default `false`. When set to `true`, the provider sends `x-amz-content-sha256: UNSIGNED-PAYLOAD` on `PutObject` requests instead of the streaming chunked signature (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) that the AWS SDK v4 uses by default. Required for Cloudflare R2 and other S3-compatible services that do not implement streaming signing. The endpoint must be HTTPS when this option is enabled. Leave as `false` for real AWS S3. * **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy. * **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using sub-accounts operated OSS,if the value is 0. * **ContainerName** (string): You can specify the container name in Aws. If this is not specified, it uses the name of the BLOB container defined with the `BlobContainerName` attribute (see the [BLOB storing document](../blob-storing)). Please note that Aws has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html): @@ -129,6 +130,7 @@ Configure(options => aws.SecretAccessKey = "your-r2-secret-key"; aws.ServiceURL = "https://your-account-id.r2.cloudflarestorage.com"; aws.Region = "auto"; // Cloudflare R2 uses 'auto' as region + aws.DisablePayloadSigning = true; // R2 does not implement streaming chunked payload signing aws.ContainerName = "my-bucket"; aws.CreateContainerIfNotExists = true; }); @@ -138,6 +140,8 @@ Configure(options => > **Note**: When using S3-compatible services, the provider automatically enables path-style requests which are required by most S3-compatible implementations. +> **Note on `DisablePayloadSigning`**: AWS SDK v4 sends `PutObject` requests with `x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD`. Cloudflare R2 (and some other S3-compatible services) return `501 NotImplemented` for this signing mode. Setting `DisablePayloadSigning = true` switches to `UNSIGNED-PAYLOAD`, which these services accept. The endpoint must be HTTPS. Leave it `false` for real AWS S3. + ## Aws Blob Name Calculator Aws Blob Provider organizes BLOB name and implements some conventions. The full name of a BLOB is determined by the following rules by default: diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md index 75514bff4f..6010e2a98b 100644 --- a/docs/en/framework/infrastructure/blob-storing/index.md +++ b/docs/en/framework/infrastructure/blob-storing/index.md @@ -41,7 +41,7 @@ The [AWS provider](./aws.md) supports not only Amazon S3 but also **S3-compatibl * **Backblaze B2** - S3-compatible cloud storage * **Any other S3-compatible storage** - Including private cloud solutions -To use S3-compatible services, simply configure the `ServiceURL` property in the AWS provider configuration to point to your S3-compatible endpoint. The provider will automatically handle the necessary protocol adjustments for compatibility. +To use S3-compatible services, configure the `ServiceURL` property in the AWS provider configuration to point to your S3-compatible endpoint. Some services (e.g., Cloudflare R2) also require `DisablePayloadSigning = true` because they do not implement the streaming chunked payload signing that AWS SDK v4 uses by default. See the [AWS provider document](aws.md) for full configuration examples. > BLOB storing system can not work unless you **configure a storage provider**. Refer to the linked documents for the storage provider configurations. diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs index 7c520da471..b81d722bae 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs @@ -47,7 +47,8 @@ public class AwsBlobProvider : BlobProviderBase, ITransientDependency { BucketName = containerName, Key = blobName, - InputStream = args.BlobStream + InputStream = args.BlobStream, + DisablePayloadSigning = configuration.DisablePayloadSigning }); } } diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs index e5a8a853d7..6f27ee2835 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs @@ -63,14 +63,27 @@ public class AwsBlobProviderConfiguration } /// - /// Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces). + /// Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). /// If not specified, the default AWS S3 service URL will be used based on the region. + /// Note: The AWS SDK automatically appends a trailing slash to the configured value. /// public string? ServiceURL { get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.ServiceURL); set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.ServiceURL, value); } + /// + /// When true, payload signing is disabled on PutObject (and similar) requests so the SDK sends + /// x-amz-content-sha256: UNSIGNED-PAYLOAD instead of the streaming chunked signature + /// (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) that AWS SDK v4 uses by default. Required for + /// Cloudflare R2 and other S3-compatible services that do not implement streaming signing. + /// Default: false (keep AWS SDK defaults for real AWS S3). + /// + public bool DisablePayloadSigning { + get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.DisablePayloadSigning, false); + set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.DisablePayloadSigning, value); + } + /// /// This name may only contain lowercase letters, numbers, and hyphens, and must begin with a letter or a number. /// Each hyphen must be preceded and followed by a non-hyphen character. diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs index b065441462..b30d6813ff 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs @@ -15,6 +15,7 @@ public static class AwsBlobProviderConfigurationNames public const string Policy = "Aws.Policy"; public const string Region = "Aws.Region"; public const string ServiceURL = "Aws.ServiceURL"; + public const string DisablePayloadSigning = "Aws.DisablePayloadSigning"; public const string ContainerName = "Aws.ContainerName"; public const string CreateContainerIfNotExists = "Aws.CreateContainerIfNotExists"; } diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs index 0d4575d832..8e8ffa4e3a 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs @@ -30,10 +30,10 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe public virtual async Task GetAmazonS3Client( AwsBlobProviderConfiguration configuration) { - var region = !configuration.Region.IsNullOrWhiteSpace() + var region = !configuration.Region.IsNullOrWhiteSpace() ? RegionEndpoint.GetBySystemName(configuration.Region) : null; - var clientConfig = CreateS3ClientConfig(configuration, region); + var clientConfig = await CreateS3ClientConfigAsync(configuration, region); if (configuration.UseCredentials) { @@ -60,11 +60,10 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe return new AmazonS3Client(configuration.AccessKeyId, configuration.SecretAccessKey, clientConfig); } - protected virtual AmazonS3Config CreateS3ClientConfig(AwsBlobProviderConfiguration configuration, RegionEndpoint? region) + protected virtual Task CreateS3ClientConfigAsync(AwsBlobProviderConfiguration configuration, RegionEndpoint? region) { var clientConfig = new AmazonS3Config(); - // Set region only if it's provided (for AWS S3) if (region != null) { clientConfig.RegionEndpoint = region; @@ -73,15 +72,12 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe if (!configuration.ServiceURL.IsNullOrWhiteSpace()) { clientConfig.ServiceURL = configuration.ServiceURL; - clientConfig.ForcePathStyle = true; // Required for most S3-compatible services - - // Set checksum properties for S3-compatible services (e.g., Cloudflare R2) - // These settings help with compatibility issues in non-AWS S3 services + clientConfig.ForcePathStyle = true; clientConfig.RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED; clientConfig.ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED; } - return clientConfig; + return Task.FromResult(clientConfig); } protected virtual AWSCredentials? GetAwsCredentials( diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs index 984a66bbc1..8b9b1e3485 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs @@ -1,11 +1,15 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Util; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; using Volo.Abp.Threading; namespace Volo.Abp.BlobStoring.Aws; @@ -28,9 +32,19 @@ public class AbpBlobStoringAwsTestModule : AbpModule { private const string UserSecretsId = "9f0d2c00-80c1-435b-bfab-2c39c8249091"; - private readonly string _randomContainerName = "abp-aws-test-container-" + Guid.NewGuid().ToString("N"); + private readonly string _runId = Guid.NewGuid().ToString("N"); + private readonly string _randomContainerName; + private readonly string _externalRunPrefix; private AwsBlobProviderConfiguration _configuration; + private string _actualContainerName; + private bool _externalContainer; + + public AbpBlobStoringAwsTestModule() + { + _randomContainerName = "abp-aws-test-container-" + _runId; + _externalRunPrefix = "abp-aws-test-run-" + _runId + "/"; + } public override void ConfigureServices(ServiceConfigurationContext context) { @@ -43,6 +57,20 @@ public class AbpBlobStoringAwsTestModule : AbpModule var accessKeyId = configuration["Aws:AccessKeyId"]; var secretAccessKey = configuration["Aws:SecretAccessKey"]; var region = configuration["Aws:Region"]; + var serviceUrl = configuration["Aws:ServiceURL"]; + var disablePayloadSigning = bool.TryParse(configuration["Aws:DisablePayloadSigning"], out var dps) && dps; + var configuredContainerName = configuration["Aws:ContainerName"]; + var createContainerIfNotExists = !bool.TryParse(configuration["Aws:CreateContainerIfNotExists"], out var cci) || cci; + + _externalContainer = !string.IsNullOrWhiteSpace(configuredContainerName); + _actualContainerName = _externalContainer ? configuredContainerName : _randomContainerName; + + if (_externalContainer) + { + var prefix = _externalRunPrefix; + context.Services.Replace(ServiceDescriptor.Transient(sp => + new TestRunPrefixedAwsBlobNameCalculator(sp.GetRequiredService(), prefix))); + } Configure(options => { @@ -53,8 +81,10 @@ public class AbpBlobStoringAwsTestModule : AbpModule aws.AccessKeyId = accessKeyId; aws.SecretAccessKey = secretAccessKey; aws.Region = region; - aws.CreateContainerIfNotExists = true; - aws.ContainerName = _randomContainerName; + aws.ServiceURL = serviceUrl; + aws.DisablePayloadSigning = disablePayloadSigning; + aws.CreateContainerIfNotExists = createContainerIfNotExists; + aws.ContainerName = _actualContainerName; _configuration = aws; }); @@ -64,14 +94,13 @@ public class AbpBlobStoringAwsTestModule : AbpModule public override void OnApplicationShutdown(ApplicationShutdownContext context) { - AsyncHelper.RunSync(() => DeleteBucketAsync(context)); + AsyncHelper.RunSync(() => CleanupAsync(context)); } - private async Task DeleteBucketAsync(ApplicationShutdownContext context) + private async Task CleanupAsync(ApplicationShutdownContext context) { - // Skip bucket deletion if configuration is not properly set (e.g., in unit tests) - if (_configuration == null || - string.IsNullOrWhiteSpace(_configuration.AccessKeyId) || + if (_configuration == null || + string.IsNullOrWhiteSpace(_configuration.AccessKeyId) || string.IsNullOrWhiteSpace(_configuration.SecretAccessKey) || (string.IsNullOrWhiteSpace(_configuration.Region) && string.IsNullOrWhiteSpace(_configuration.ServiceURL))) { @@ -83,25 +112,89 @@ public class AbpBlobStoringAwsTestModule : AbpModule var amazonS3Client = await context.ServiceProvider.GetRequiredService() .GetAmazonS3Client(_configuration); - if (await AmazonS3Util.DoesS3BucketExistV2Async(amazonS3Client, _randomContainerName)) + if (!await AmazonS3Util.DoesS3BucketExistV2Async(amazonS3Client, _actualContainerName)) + { + return; + } + + await DeleteObjectsAsync(amazonS3Client, _externalContainer ? _externalRunPrefix : null); + + if (!_externalContainer) + { + await amazonS3Client.DeleteBucketAsync(_actualContainerName); + } + } + catch + { + // Ignore errors during test cleanup + } + } + + private async Task DeleteObjectsAsync(AmazonS3Client client, string prefix) + { + string continuationToken = null; + do + { + var listResponse = await client.ListObjectsV2Async(new ListObjectsV2Request { - var blobs = await amazonS3Client.ListObjectsAsync(_randomContainerName); + BucketName = _actualContainerName, + Prefix = prefix, + ContinuationToken = continuationToken, + MaxKeys = 1000 + }); - if (blobs.S3Objects.Any()) + if (listResponse.S3Objects.Any()) + { + foreach (var batch in Chunk(listResponse.S3Objects, 1000)) { - await amazonS3Client.DeleteObjectsAsync(new DeleteObjectsRequest + await client.DeleteObjectsAsync(new DeleteObjectsRequest { - BucketName = _randomContainerName, - Objects = blobs.S3Objects.Select(o => new KeyVersion { Key = o.Key }).ToList() + BucketName = _actualContainerName, + Objects = batch.Select(o => new KeyVersion { Key = o.Key }).ToList() }); } + } + + continuationToken = listResponse.IsTruncated == true ? listResponse.NextContinuationToken : null; + } while (continuationToken != null); + } - await amazonS3Client.DeleteBucketAsync(_randomContainerName); + private static IEnumerable> Chunk(IEnumerable source, int size) + { + var bucket = new List(size); + foreach (var item in source) + { + bucket.Add(item); + if (bucket.Count == size) + { + yield return bucket; + bucket = new List(size); } } - catch + if (bucket.Count > 0) { - // Ignore errors during test cleanup + yield return bucket; } } } + +internal sealed class TestRunPrefixedAwsBlobNameCalculator : IAwsBlobNameCalculator +{ + private readonly ICurrentTenant _currentTenant; + private readonly string _runPrefix; + + public TestRunPrefixedAwsBlobNameCalculator(ICurrentTenant currentTenant, string runPrefix) + { + _currentTenant = currentTenant; + _runPrefix = runPrefix; + } + + public string Calculate(BlobProviderArgs args) + { + var baseName = _currentTenant.Id == null + ? $"host/{args.BlobName}" + : $"tenants/{_currentTenant.Id.Value:D}/{args.BlobName}"; + + return _runPrefix + baseName; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs index db1aef2be2..a750bb5678 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs @@ -1 +1,25 @@ -namespace Volo.Abp.BlobStoring.Aws; +namespace Volo.Abp.BlobStoring.Aws; + +/* + To run this integration test, populate user secrets (id `9f0d2c00-80c1-435b-bfab-2c39c8249091`) + with credentials for real AWS S3 or an S3-compatible service (Cloudflare R2, MinIO, etc.): + + { + "Aws:AccessKeyId": "...", + "Aws:SecretAccessKey": "...", + "Aws:Region": "us-east-1", // "auto" for Cloudflare R2 + "Aws:ServiceURL": "", // e.g. https://.r2.cloudflarestorage.com for R2, http://localhost:9000 for MinIO + "Aws:DisablePayloadSigning": "false", // set "true" for R2 (requires HTTPS); leave "false" for MinIO and AWS S3 + "Aws:ContainerName": "", // leave empty to let the test create a unique bucket; set to a pre-existing bucket for services that disallow bucket creation + "Aws:CreateContainerIfNotExists": "true" // set "false" when using an existing bucket + } + + When `ContainerName` is supplied, the test prefixes every object with `abp-aws-test-run-{guid}/` + so cleanup deletes only what this run created (the bucket itself is left untouched). + + Then uncomment the class below: + +public class AwsBlobContainer_Tests : BlobContainer_Tests +{ +} +*/ diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs index 2e95ed8291..a48c7cb6be 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs @@ -49,4 +49,24 @@ public class AwsBlobProviderConfiguration_Tests : AbpBlobStoringAwsTestCommonBas var awsConfig = containerConfiguration.GetAwsConfiguration(); awsConfig.ServiceURL.ShouldBe(serviceUrl); } -} \ No newline at end of file + + [Fact] + public void Should_Default_DisablePayloadSigning_To_False() + { + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + + awsConfiguration.DisablePayloadSigning.ShouldBeFalse(); + } + + [Fact] + public void Should_Set_And_Get_DisablePayloadSigning() + { + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + + awsConfiguration.DisablePayloadSigning = true; + + awsConfiguration.DisablePayloadSigning.ShouldBeTrue(); + } +} From e8bcb11f326ab706260875cc89403b9a484de005 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 1 Jun 2026 13:54:43 +0800 Subject: [PATCH 8/9] Address Copilot review on AWS S3-compatible provider - Skip container wiring in test module when AWS credentials are absent - Dispose AmazonS3Client in test cleanup - Validate Region or ServiceURL early in DefaultAmazonS3ClientFactory - Normalize trailing slash in ServiceURL test assertions - Clarify Region and ServiceURL coupling in XML docs and aws.md --- .../infrastructure/blob-storing/aws.md | 2 +- .../Aws/AwsBlobProviderConfiguration.cs | 14 +++++++++---- .../Aws/DefaultAmazonS3ClientFactory.cs | 6 ++++++ .../Aws/AbpBlobStoringAwsTestModule.cs | 11 +++++++++- .../Aws/DefaultAmazonS3ClientFactory_Tests.cs | 20 +++++++++---------- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docs/en/framework/infrastructure/blob-storing/aws.md b/docs/en/framework/infrastructure/blob-storing/aws.md index cc7274c99b..b419521bed 100644 --- a/docs/en/framework/infrastructure/blob-storing/aws.md +++ b/docs/en/framework/infrastructure/blob-storing/aws.md @@ -65,7 +65,7 @@ Configure(options => * **UseTemporaryFederatedCredentials** (bool): Use [federated user temporary credentials](https://docs.aws.amazon.com/AmazonS3/latest/dev/AuthUsingTempFederationToken.html) to access AWS services, default : `false`. * **ProfileName** (string): The [name of the profile](https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-creds.html) to get credentials from. * **ProfilesLocation** (string): The path to the aws credentials file to look at. -* **Region** (string): The system name of the service. +* **Region** (string): The system name of the AWS region (e.g., `us-east-1`). **Required** for real AWS S3. Optional when `ServiceURL` is configured for an S3-compatible service; some services accept any value (or `auto` for Cloudflare R2). * **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). The AWS SDK automatically appends a trailing slash to the configured value. * **DisablePayloadSigning** (bool): Default `false`. When set to `true`, the provider sends `x-amz-content-sha256: UNSIGNED-PAYLOAD` on `PutObject` requests instead of the streaming chunked signature (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) that the AWS SDK v4 uses by default. Required for Cloudflare R2 and other S3-compatible services that do not implement streaming signing. The endpoint must be HTTPS when this option is enabled. Leave as `false` for real AWS S3. * **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy. diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs index 6f27ee2835..fff08dc42b 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs @@ -57,6 +57,11 @@ public class AwsBlobProviderConfiguration set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Policy, value); } + /// + /// The system name of the AWS region (e.g., us-east-1). Required for real AWS S3. + /// Optional when is configured for an S3-compatible service; + /// some services accept any value (or auto for Cloudflare R2). + /// public string? Region { get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.Region); set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, value); @@ -64,8 +69,8 @@ public class AwsBlobProviderConfiguration /// /// Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). - /// If not specified, the default AWS S3 service URL will be used based on the region. - /// Note: The AWS SDK automatically appends a trailing slash to the configured value. + /// When omitted, becomes required and the default AWS S3 endpoint for that + /// region is used. The AWS SDK automatically appends a trailing slash to the configured value. /// public string? ServiceURL { get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.ServiceURL); @@ -73,11 +78,12 @@ public class AwsBlobProviderConfiguration } /// - /// When true, payload signing is disabled on PutObject (and similar) requests so the SDK sends + /// When true, payload signing is disabled on PutObject upload requests so the SDK sends /// x-amz-content-sha256: UNSIGNED-PAYLOAD instead of the streaming chunked signature /// (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) that AWS SDK v4 uses by default. Required for /// Cloudflare R2 and other S3-compatible services that do not implement streaming signing. - /// Default: false (keep AWS SDK defaults for real AWS S3). + /// The endpoint must be HTTPS when this option is enabled (AWS SDK rejects unsigned payloads + /// over HTTP). Default: false (keep AWS SDK defaults for real AWS S3 and MinIO). /// public bool DisablePayloadSigning { get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.DisablePayloadSigning, false); diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs index 8e8ffa4e3a..7d3d0b3869 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs @@ -30,6 +30,12 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe public virtual async Task GetAmazonS3Client( AwsBlobProviderConfiguration configuration) { + if (configuration.Region.IsNullOrWhiteSpace() && configuration.ServiceURL.IsNullOrWhiteSpace()) + { + throw new AbpException( + $"Either {nameof(AwsBlobProviderConfiguration.Region)} or {nameof(AwsBlobProviderConfiguration.ServiceURL)} must be configured on {nameof(AwsBlobProviderConfiguration)}."); + } + var region = !configuration.Region.IsNullOrWhiteSpace() ? RegionEndpoint.GetBySystemName(configuration.Region) : null; diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs index 8b9b1e3485..8ef4000e61 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs @@ -56,6 +56,15 @@ public class AbpBlobStoringAwsTestModule : AbpModule var configuration = context.Services.GetConfiguration(); var accessKeyId = configuration["Aws:AccessKeyId"]; var secretAccessKey = configuration["Aws:SecretAccessKey"]; + + // No credentials configured (e.g., CI without user secrets) → skip container wiring. + // `BlobContainerConfiguration.SetConfiguration` rejects nulls, so any attempt to resolve + // a container with null values from configuration would throw at runtime. + if (string.IsNullOrEmpty(accessKeyId) || string.IsNullOrEmpty(secretAccessKey)) + { + return; + } + var region = configuration["Aws:Region"]; var serviceUrl = configuration["Aws:ServiceURL"]; var disablePayloadSigning = bool.TryParse(configuration["Aws:DisablePayloadSigning"], out var dps) && dps; @@ -109,7 +118,7 @@ public class AbpBlobStoringAwsTestModule : AbpModule try { - var amazonS3Client = await context.ServiceProvider.GetRequiredService() + using var amazonS3Client = await context.ServiceProvider.GetRequiredService() .GetAmazonS3Client(_configuration); if (!await AmazonS3Util.DoesS3BucketExistV2Async(amazonS3Client, _actualContainerName)) diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs index fbd51dd038..e4886f602b 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Amazon.Runtime; using Amazon.S3; using Microsoft.Extensions.DependencyInjection; using Shouldly; @@ -35,8 +36,8 @@ public class DefaultAmazonS3ClientFactory_Tests : AbpBlobStoringAwsTestBase // Assert s3Client.ShouldNotBeNull(); - s3Client.Config.ServiceURL.ShouldBe(serviceUrl + "/"); // AWS SDK automatically appends trailing slash - ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); // Should be enabled for S3-compatible services + s3Client.Config.ServiceURL?.TrimEnd('/').ShouldBe(serviceUrl); + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); } [Fact] @@ -81,8 +82,8 @@ public class DefaultAmazonS3ClientFactory_Tests : AbpBlobStoringAwsTestBase // Assert s3Client.ShouldNotBeNull(); - s3Client.Config.ServiceURL.ShouldBe("https://minio.example.com:9000/"); // AWS SDK automatically appends trailing slash - ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); // Should be enabled for S3-compatible services + s3Client.Config.ServiceURL?.TrimEnd('/').ShouldBe("https://minio.example.com:9000"); + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); } [Fact] @@ -105,12 +106,9 @@ public class DefaultAmazonS3ClientFactory_Tests : AbpBlobStoringAwsTestBase // Assert s3Client.ShouldNotBeNull(); var config = (AmazonS3Config)s3Client.Config; - config.ServiceURL.ShouldBe("https://r2.cloudflarestorage.com/"); + config.ServiceURL?.TrimEnd('/').ShouldBe("https://r2.cloudflarestorage.com"); config.ForcePathStyle.ShouldBeTrue(); - - // Verify checksum properties are set for S3-compatible services (required for Cloudflare R2) - // We just verify they are not null/default, indicating they have been set - config.RequestChecksumCalculation.ShouldNotBe(default); - config.ResponseChecksumValidation.ShouldNotBe(default); + config.RequestChecksumCalculation.ShouldBe(RequestChecksumCalculation.WHEN_REQUIRED); + config.ResponseChecksumValidation.ShouldBe(ResponseChecksumValidation.WHEN_REQUIRED); } -} \ No newline at end of file +} From fd3dc1fc95be25331dcf423c64734f4aff224de9 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 1 Jun 2026 14:09:04 +0800 Subject: [PATCH 9/9] Handle null in Region/ServiceURL setters and tighten nullable hints - Clear configuration entry when Region or ServiceURL is set to null - Annotate DeleteObjectsAsync prefix and continuationToken as nullable - Replace vague Region placeholder in aws.md example --- .../infrastructure/blob-storing/aws.md | 2 +- .../Aws/AwsBlobProviderConfiguration.cs | 20 +++++++++++++++++-- .../Aws/AbpBlobStoringAwsTestModule.cs | 4 ++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/en/framework/infrastructure/blob-storing/aws.md b/docs/en/framework/infrastructure/blob-storing/aws.md index b419521bed..1ee55bbb74 100644 --- a/docs/en/framework/infrastructure/blob-storing/aws.md +++ b/docs/en/framework/infrastructure/blob-storing/aws.md @@ -41,7 +41,7 @@ Configure(options => Aws.UseTemporaryFederatedCredentials = "set true to use temporary federated credentials"; Aws.ProfileName = "the name of the profile to get credentials from"; Aws.ProfilesLocation = "the path to the aws credentials file to look at"; - Aws.Region = "the system name of the service"; + Aws.Region = "the AWS region system name, e.g. us-east-1"; Aws.ServiceURL = "custom service URL for S3-compatible APIs (optional)"; Aws.Name = "the name of the federated user"; Aws.Policy = "policy"; diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs index fff08dc42b..fa318bdcb8 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs @@ -64,7 +64,15 @@ public class AwsBlobProviderConfiguration /// public string? Region { get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.Region); - set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, value); + set + { + if (value == null) + { + _containerConfiguration.ClearConfiguration(AwsBlobProviderConfigurationNames.Region); + return; + } + _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, value); + } } /// @@ -74,7 +82,15 @@ public class AwsBlobProviderConfiguration /// public string? ServiceURL { get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.ServiceURL); - set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.ServiceURL, value); + set + { + if (value == null) + { + _containerConfiguration.ClearConfiguration(AwsBlobProviderConfigurationNames.ServiceURL); + return; + } + _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.ServiceURL, value); + } } /// diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs index 8ef4000e61..bd7b07c40c 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs @@ -139,9 +139,9 @@ public class AbpBlobStoringAwsTestModule : AbpModule } } - private async Task DeleteObjectsAsync(AmazonS3Client client, string prefix) + private async Task DeleteObjectsAsync(AmazonS3Client client, string? prefix) { - string continuationToken = null; + string? continuationToken = null; do { var listResponse = await client.ListObjectsV2Async(new ListObjectsV2Request