diff --git a/Directory.Packages.props b/Directory.Packages.props
index 251b2cf00d4..9c944083b95 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,6 +15,7 @@
+
diff --git a/docs/en/framework/infrastructure/blob-storing/bunny.md b/docs/en/framework/infrastructure/blob-storing/bunny.md
new file mode 100644
index 00000000000..4c5fb5ef0f1
--- /dev/null
+++ b/docs/en/framework/infrastructure/blob-storing/bunny.md
@@ -0,0 +1,64 @@
+# BLOB Storing Bunny Provider
+
+BLOB Storing Bunny Provider can store BLOBs in [bunny.net Storage](https://bunny.net/storage/).
+
+> 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 Bunny BLOB as the storage provider.
+
+## Installation
+
+Use the ABP CLI to add [Volo.Abp.BlobStoring.Bunny](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Bunny) NuGet package to your project:
+
+* Install the [ABP CLI](../../../cli) if you haven't installed before.
+* Open a command line (terminal) in the directory of the `.csproj` file you want to add the `Volo.Abp.BlobStoring.Bunny` package.
+* Run `abp add-package Volo.Abp.BlobStoring.Bunny` command.
+
+If you want to do it manually, install the [Volo.Abp.BlobStoring.Bunny](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Bunny) NuGet package to your project and add `[DependsOn(typeof(AbpBlobStoringBunnyModule))]` to the [ABP module](../../architecture/modularity/basics.md) class inside your project.
+
+## Configuration
+
+Configuration is done in the `ConfigureServices` method of your [module](../../architecture/modularity/basics.md) class, as explained in the [BLOB Storing document](../blob-storing).
+
+**Example: Configure to use the Bunny storage provider by default**
+
+````csharp
+Configure(options =>
+{
+ options.Containers.ConfigureDefault(container =>
+ {
+ container.UseBunny(Bunny =>
+ {
+ Bunny.AccessKey = "your Bunny account access key";
+ Bunny.Region = "the code of the main storage zone region"; // "de" is the default value
+ Bunny.ContainerName = "your bunny storage zone name";
+ Bunny.CreateContainerIfNotExists = true;
+ });
+ });
+});
+
+````
+
+> See the [BLOB Storing document](../blob-storing) to learn how to configure this provider for a specific container.
+
+### Options
+
+* **AccessKey** (string): Bunny Account Access Key. [Where do I find my Access key?](https://support.bunny.net/hc/en-us/articles/360012168840-Where-do-I-find-my-API-key)
+* **Region** (string?): The code of the main storage zone region (Possible values: DE, NY, LA, SG).
+* **ContainerName** (string): You can specify the container name in Bunny. 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 Bunny has some **rules for naming containers**:
+ * Storage Zone names must be a globaly unique.
+ * Storage Zone names must be between **4** and **64** characters long.
+ * Storage Zone names can consist only of **lowercase** letters, numbers, and hyphens (-).
+* **CreateContainerIfNotExists** (bool): Default value is `false`, If a container does not exist in Bunny, `BunnyBlobProvider` will try to create it.
+
+## Bunny Blob Name Calculator
+
+Bunny Blob Provider organizes BLOB name and implements some conventions. The full name of a BLOB is determined by the following rules by default:
+
+* Appends `host` string if [current tenant](../../architecture/multi-tenancy) is `null` (or multi-tenancy is disabled for the container - see the [BLOB Storing document](../blob-storing) to learn how to disable multi-tenancy for a container).
+* Appends `tenants/` string if current tenant is not `null`.
+* Appends the BLOB name.
+
+## Other Services
+
+* `BunnyBlobProvider` is the main service that implements the Bunny BLOB storage provider, if you want to override/replace it via [dependency injection](../../fundamentals/dependency-injection.md) (don't replace `IBlobProvider` interface, but replace `BunnyBlobProvider` class).
+* `IBunnyBlobNameCalculator` is used to calculate the full BLOB name (that is explained above). It is implemented by the `DefaultBunnyBlobNameCalculator` by default.
+* `IBunnyClientFactory` is implemented by `DefaultBunnyClientFactory` by default. You can override/replace it,if you want customize.
diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md
index f95a90e4107..676757ad805 100644
--- a/docs/en/framework/infrastructure/blob-storing/index.md
+++ b/docs/en/framework/infrastructure/blob-storing/index.md
@@ -23,6 +23,7 @@ The ABP has already the following storage provider implementations:
* [Minio](./minio.md): Stores BLOBs on the [MinIO Object storage](https://min.io/).
* [Aws](./aws.md): Stores BLOBs on the [Amazon Simple Storage Service](https://aws.amazon.com/s3/).
* [Google](./google.md): Stores BLOBs on the [Google Cloud Storage](https://cloud.google.com/storage).
+* [Bunny](./bunny.md): Stores BLOBs on the [Bunny.net Storage](https://bunny.net/storage/).
More providers will be implemented by the time. You can [request](https://github.com/abpframework/abp/issues/new) it for your favorite provider or [create it yourself](./custom-provider.md) and [contribute](../../../contribution) to the ABP.
diff --git a/framework/Volo.Abp.sln b/framework/Volo.Abp.sln
index c3ca5b63b25..ca471bb304e 100644
--- a/framework/Volo.Abp.sln
+++ b/framework/Volo.Abp.sln
@@ -470,6 +470,7 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling", "src\Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling\Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling.csproj", "{2F9BA650-395C-4BE0-8CCB-9978E753562A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.AspNetCore.Components.MauiBlazor.Theming.Bundling", "src\Volo.Abp.AspNetCore.Components.MauiBlazor.Theming.Bundling\Volo.Abp.AspNetCore.Components.MauiBlazor.Theming.Bundling.csproj", "{7ADB6D92-82CC-4A2A-8BCF-FC6C6308796D}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Google", "src\Volo.Abp.BlobStoring.Google\Volo.Abp.BlobStoring.Google.csproj", "{DEEB5200-BBF9-464D-9B7E-8FC035A27E94}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Google.Tests", "test\Volo.Abp.BlobStoring.Google.Tests\Volo.Abp.BlobStoring.Google.Tests.csproj", "{40FB8907-9CF7-44D0-8B5F-538AC6DAF8B9}"
@@ -480,6 +481,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.Sms.TencentCloud",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.Sms.TencentCloud.Tests", "test\Volo.Abp.Sms.TencenCloud.Tests\Volo.Abp.Sms.TencentCloud.Tests.csproj", "{C753DDD6-5699-45F8-8669-08CE0BB816DE}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Bunny", "src\Volo.Abp.BlobStoring.Bunny\Volo.Abp.BlobStoring.Bunny.csproj", "{1BBCBA72-CDB6-4882-96EE-D4CD149433A2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Bunny.Tests", "test\Volo.Abp.BlobStoring.Bunny.Tests\Volo.Abp.BlobStoring.Bunny.Tests.csproj", "{BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1434,6 +1439,14 @@ Global
{C753DDD6-5699-45F8-8669-08CE0BB816DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C753DDD6-5699-45F8-8669-08CE0BB816DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C753DDD6-5699-45F8-8669-08CE0BB816DE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1676,6 +1689,8 @@ Global
{E50739A7-5E2F-4EB5-AEA9-554115CB9613} = {447C8A77-E5F0-4538-8687-7383196D04EA}
{BE7109C5-7368-4688-8557-4A15D3F4776A} = {5DF0E140-0513-4D0D-BE2E-3D4D85CD70E6}
{C753DDD6-5699-45F8-8669-08CE0BB816DE} = {447C8A77-E5F0-4538-8687-7383196D04EA}
+ {1BBCBA72-CDB6-4882-96EE-D4CD149433A2} = {5DF0E140-0513-4D0D-BE2E-3D4D85CD70E6}
+ {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915} = {447C8A77-E5F0-4538-8687-7383196D04EA}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BB97ECF4-9A84-433F-A80B-2A3285BDD1D5}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml
new file mode 100644
index 00000000000..1715698ccd2
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd
new file mode 100644
index 00000000000..ffa6fc4b782
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.
+
+
+
+
+ A comma-separated list of error codes that can be safely ignored in assembly verification.
+
+
+
+
+ 'false' to turn off automatic generation of the XML Schema file.
+
+
+
+
+
\ No newline at end of file
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg
new file mode 100644
index 00000000000..f4bad072d26
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg
@@ -0,0 +1,3 @@
+{
+ "role": "lib.framework"
+}
\ No newline at end of file
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json
new file mode 100644
index 00000000000..b9e8bbba1b8
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json
@@ -0,0 +1,68 @@
+{
+ "name": "Volo.Abp.BlobStoring.Bunny",
+ "hash": "",
+ "contents": [
+ {
+ "namespace": "Volo.Abp.BlobStoring.Bunny",
+ "dependsOnModules": [
+ {
+ "declaringAssemblyName": "Volo.Abp.BlobStoring",
+ "namespace": "Volo.Abp.BlobStoring",
+ "name": "AbpBlobStoringModule"
+ },
+ {
+ "declaringAssemblyName": "Volo.Abp.Caching",
+ "namespace": "Volo.Abp.Caching",
+ "name": "AbpCachingModule"
+ }
+ ],
+ "implementingInterfaces": [
+ {
+ "name": "IAbpModule",
+ "namespace": "Volo.Abp.Modularity",
+ "declaringAssemblyName": "Volo.Abp.Core",
+ "fullName": "Volo.Abp.Modularity.IAbpModule"
+ },
+ {
+ "name": "IOnPreApplicationInitialization",
+ "namespace": "Volo.Abp.Modularity",
+ "declaringAssemblyName": "Volo.Abp.Core",
+ "fullName": "Volo.Abp.Modularity.IOnPreApplicationInitialization"
+ },
+ {
+ "name": "IOnApplicationInitialization",
+ "namespace": "Volo.Abp",
+ "declaringAssemblyName": "Volo.Abp.Core",
+ "fullName": "Volo.Abp.IOnApplicationInitialization"
+ },
+ {
+ "name": "IOnPostApplicationInitialization",
+ "namespace": "Volo.Abp.Modularity",
+ "declaringAssemblyName": "Volo.Abp.Core",
+ "fullName": "Volo.Abp.Modularity.IOnPostApplicationInitialization"
+ },
+ {
+ "name": "IOnApplicationShutdown",
+ "namespace": "Volo.Abp",
+ "declaringAssemblyName": "Volo.Abp.Core",
+ "fullName": "Volo.Abp.IOnApplicationShutdown"
+ },
+ {
+ "name": "IPreConfigureServices",
+ "namespace": "Volo.Abp.Modularity",
+ "declaringAssemblyName": "Volo.Abp.Core",
+ "fullName": "Volo.Abp.Modularity.IPreConfigureServices"
+ },
+ {
+ "name": "IPostConfigureServices",
+ "namespace": "Volo.Abp.Modularity",
+ "declaringAssemblyName": "Volo.Abp.Core",
+ "fullName": "Volo.Abp.Modularity.IPostConfigureServices"
+ }
+ ],
+ "contentType": "abpModule",
+ "name": "AbpBlobStoringBunnyModule",
+ "summary": null
+ }
+ ]
+}
\ No newline at end of file
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj
new file mode 100644
index 00000000000..2cd9c74ac63
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+ netstandard2.0;netstandard2.1;net8.0;net9.0
+ enable
+ Nullable
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs
new file mode 100644
index 00000000000..3fe814f2f0c
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs
@@ -0,0 +1,16 @@
+using Microsoft.Extensions.DependencyInjection;
+using Volo.Abp.Caching;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+[DependsOn(
+ typeof(AbpBlobStoringModule),
+ typeof(AbpCachingModule))]
+public class AbpBlobStoringBunnyModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddHttpClient();
+ }
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs
new file mode 100644
index 00000000000..1e10d4caa26
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class BunnyApiException : Exception
+{
+ public BunnyApiException(string message)
+ : base(message)
+ {
+
+ }
+
+ public BunnyApiException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+
+ }
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs
new file mode 100644
index 00000000000..03afe1c36dd
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public static class BunnyBlobContainerConfigurationExtensions
+{
+ public static BunnyBlobProviderConfiguration GetBunnyConfiguration(
+ this BlobContainerConfiguration containerConfiguration)
+ {
+ return new BunnyBlobProviderConfiguration(containerConfiguration);
+ }
+
+ public static BlobContainerConfiguration UseBunny(
+ this BlobContainerConfiguration containerConfiguration,
+ Action bunnyConfigureAction)
+ {
+ containerConfiguration.ProviderType = typeof(BunnyBlobProvider);
+ containerConfiguration.NamingNormalizers.TryAdd();
+
+ bunnyConfigureAction(new BunnyBlobProviderConfiguration(containerConfiguration));
+
+ return containerConfiguration;
+ }
+}
\ No newline at end of file
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs
new file mode 100644
index 00000000000..74c436e2e9f
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs
@@ -0,0 +1,51 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.Localization;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class BunnyBlobNamingNormalizer : IBlobNamingNormalizer, ITransientDependency
+{
+ private readonly static Regex ValidCharactersRegex =
+ new Regex(@"^[a-z0-9-]*$", RegexOptions.Compiled);
+
+ private const int MinLength = 4;
+ private const int MaxLength = 64;
+
+ public virtual string NormalizeBlobName(string blobName) => blobName;
+
+ public virtual string NormalizeContainerName(string containerName)
+ {
+ Check.NotNullOrWhiteSpace(containerName, nameof(containerName));
+
+ using (CultureHelper.Use(CultureInfo.InvariantCulture))
+ {
+ // Trim whitespace and convert to lowercase
+ var normalizedName = containerName
+ .Trim()
+ .ToLowerInvariant();
+
+ // Remove any invalid characters
+ normalizedName = Regex.Replace(normalizedName, "[^a-z0-9-]", string.Empty);
+
+ // Validate structure
+ if (!ValidCharactersRegex.IsMatch(normalizedName))
+ {
+ throw new AbpException(
+ $"Container name contains invalid characters: {containerName}. " +
+ "Only lowercase letters, numbers, and hyphens are allowed.");
+ }
+
+ // Validate length
+ if (normalizedName.Length < MinLength || normalizedName.Length > MaxLength)
+ {
+ throw new AbpException(
+ $"Container name must be between {MinLength} and {MaxLength} characters. " +
+ $"Current length: {normalizedName.Length}");
+ }
+
+ return normalizedName;
+ }
+ }
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs
new file mode 100644
index 00000000000..6c06bc3f95e
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs
@@ -0,0 +1,167 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using BunnyCDN.Net.Storage;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class BunnyBlobProvider : BlobProviderBase, ITransientDependency
+{
+ protected IBunnyBlobNameCalculator BunnyBlobNameCalculator { get; }
+ protected IBlobNormalizeNamingService BlobNormalizeNamingService { get; }
+ protected IBunnyClientFactory BunnyClientFactory { get; }
+
+ public BunnyBlobProvider(
+ IBunnyBlobNameCalculator bunnyBlobNameCalculator,
+ IBlobNormalizeNamingService blobNormalizeNamingService,
+ IBunnyClientFactory bunnyClientFactory)
+ {
+ BunnyBlobNameCalculator = bunnyBlobNameCalculator;
+ BlobNormalizeNamingService = blobNormalizeNamingService;
+ BunnyClientFactory = bunnyClientFactory;
+ }
+
+ public async override Task SaveAsync(BlobProviderSaveArgs args)
+ {
+ var configuration = args.Configuration.GetBunnyConfiguration();
+ var containerName = GetContainerName(args);
+ var blobName = BunnyBlobNameCalculator.Calculate(args);
+
+ await ValidateContainerExistsAsync(containerName, configuration);
+
+ var bunnyStorage = await GetBunnyCDNStorageAsync(args);
+
+ if (!args.OverrideExisting && await BlobExistsAsync(bunnyStorage, containerName, blobName))
+ {
+ throw new BlobAlreadyExistsException(
+ $"Blob '{args.BlobName}' already exists in container '{containerName}'. " +
+ $"Set {nameof(args.OverrideExisting)} to true to overwrite.");
+ }
+
+ using var memoryStream = new MemoryStream();
+ await args.BlobStream.CopyToAsync(memoryStream);
+ memoryStream.Position = 0;
+
+ await bunnyStorage.UploadAsync(memoryStream, $"{containerName}/{blobName}");
+ }
+
+ public async override Task DeleteAsync(BlobProviderDeleteArgs args)
+ {
+ var blobName = BunnyBlobNameCalculator.Calculate(args);
+ var containerName = GetContainerName(args);
+ var bunnyStorage = await GetBunnyCDNStorageAsync(args);
+
+ if (!await BlobExistsAsync(bunnyStorage, containerName, blobName))
+ {
+ return false;
+ }
+
+ try
+ {
+ return await bunnyStorage.DeleteObjectAsync($"{containerName}/{blobName}");
+ }
+ catch (BunnyCDNStorageException ex) when (ex.Message.Contains("404"))
+ {
+ return false;
+ }
+ }
+
+ public async override Task ExistsAsync(BlobProviderExistsArgs args)
+ {
+ var blobName = BunnyBlobNameCalculator.Calculate(args);
+ var containerName = GetContainerName(args);
+ var bunnyStorage = await GetBunnyCDNStorageAsync(args);
+
+ return await BlobExistsAsync(bunnyStorage, containerName, blobName);
+ }
+
+ public async override Task GetOrNullAsync(BlobProviderGetArgs args)
+ {
+ var blobName = BunnyBlobNameCalculator.Calculate(args);
+ var containerName = GetContainerName(args);
+ var bunnyStorage = await GetBunnyCDNStorageAsync(args);
+
+ if (!await BlobExistsAsync(bunnyStorage, containerName, blobName))
+ {
+ return null;
+ }
+
+ try
+ {
+ return await bunnyStorage.DownloadObjectAsStreamAsync($"{containerName}/{blobName}");
+ }
+ catch (WebException ex) when ((HttpStatusCode)ex.Status == HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+ }
+
+ protected virtual async Task BlobExistsAsync(BunnyCDNStorage bunnyStorage, string containerName, string blobName)
+ {
+ try
+ {
+ var fullBlobPath = $"/{containerName}/{blobName}";
+ var directoryPath = Path.GetDirectoryName(fullBlobPath)?.Replace('\\', '/') + "/";
+
+ if (string.IsNullOrWhiteSpace(directoryPath))
+ {
+ throw new Exception("Invalid directory path generated from blob name.");
+ }
+
+ var objects = await bunnyStorage.GetStorageObjectsAsync(directoryPath);
+ return objects?.Any(o => o.FullPath == fullBlobPath) == true;
+ }
+ catch (BunnyCDNStorageException ex) when (ex.Message.Contains("404"))
+ {
+ return false;
+ }
+ catch (Exception ex)
+ {
+ throw new Exception($"Error while checking blob existence: {ex.Message}", ex);
+ }
+ }
+
+ protected virtual async Task GetBunnyCDNStorageAsync(BlobProviderArgs args)
+ {
+ var configuration = args.Configuration.GetBunnyConfiguration();
+ var containerName = GetContainerName(args);
+ var region = configuration.Region ?? "de";
+
+ return await BunnyClientFactory.CreateAsync(
+ configuration.AccessKey,
+ containerName,
+ region);
+ }
+
+ protected virtual string GetContainerName(BlobProviderArgs args)
+ {
+ var configuration = args.Configuration.GetBunnyConfiguration();
+ return configuration.ContainerName.IsNullOrWhiteSpace()
+ ? args.ContainerName
+ : BlobNormalizeNamingService.NormalizeContainerName(args.Configuration, configuration.ContainerName!);
+ }
+
+ protected virtual async Task ValidateContainerExistsAsync(
+ string containerName,
+ BunnyBlobProviderConfiguration configuration
+ )
+ {
+ try
+ {
+ await BunnyClientFactory.EnsureStorageZoneExistsAsync(
+ configuration.AccessKey,
+ containerName,
+ configuration.Region ?? "de",
+ configuration.CreateContainerIfNotExists);
+ }
+ catch (Exception ex)
+ {
+ throw new AbpException(
+ $"Failed to validate storage zone '{containerName}': {ex.Message}",
+ ex);
+ }
+ }
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs
new file mode 100644
index 00000000000..0897c7f2b86
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs
@@ -0,0 +1,40 @@
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class BunnyBlobProviderConfiguration
+{
+ public string? Region {
+ get => _containerConfiguration.GetConfigurationOrDefault(BunnyBlobProviderConfigurationNames.Region, "de");
+ set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.Region, value);
+ }
+
+ ///
+ /// This name may only contain lowercase letters, numbers, and hyphens. (no spaces)
+ /// The name must also be between 4 and 64 characters long.
+ /// The name must be globaly unique
+ /// If this parameter is not specified, the ContainerName of the will be used.
+ ///
+ public string? ContainerName {
+ get => _containerConfiguration.GetConfigurationOrDefault(BunnyBlobProviderConfigurationNames.ContainerName);
+ set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.ContainerName, value);
+ }
+
+ ///
+ /// Default value: false.
+ ///
+ public bool CreateContainerIfNotExists {
+ get => _containerConfiguration.GetConfigurationOrDefault(BunnyBlobProviderConfigurationNames.CreateContainerIfNotExists, false);
+ set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.CreateContainerIfNotExists, value);
+ }
+
+ public string AccessKey {
+ get => _containerConfiguration.GetConfiguration(BunnyBlobProviderConfigurationNames.AccessKey);
+ set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.AccessKey, value);
+ }
+
+ private readonly BlobContainerConfiguration _containerConfiguration;
+
+ public BunnyBlobProviderConfiguration(BlobContainerConfiguration containerConfiguration)
+ {
+ _containerConfiguration = containerConfiguration;
+ }
+}
\ No newline at end of file
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs
new file mode 100644
index 00000000000..09d79076a63
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs
@@ -0,0 +1,15 @@
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public static class BunnyBlobProviderConfigurationNames
+{
+ // The primary region for the storage zone (e.g., DE, NY, etc.)
+ public const string Region = "Bunny.Region";
+
+ // The name of the storage zone
+ public const string ContainerName = "Bunny.ContainerName";
+
+ // The API access key for the bunny.net account
+ public const string AccessKey = "Bunny.AccessKey";
+
+ public const string CreateContainerIfNotExists = "Bunny.CreateContainerIfNotExists";
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs
new file mode 100644
index 00000000000..e7185319274
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs
@@ -0,0 +1,17 @@
+using System;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+[Serializable]
+public class BunnyStorageZoneModel
+{
+ public int Id { get; set; }
+
+ public string Password { get; set; } = null!;
+
+ public string Name { get; set; } = null!;
+
+ public string? Region { get; set; }
+
+ public bool Deleted { get; set; }
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs
new file mode 100644
index 00000000000..f06acb8c142
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs
@@ -0,0 +1,21 @@
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.MultiTenancy;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class DefaultBunnyBlobNameCalculator : IBunnyBlobNameCalculator, ITransientDependency
+{
+ protected ICurrentTenant CurrentTenant { get; }
+
+ public DefaultBunnyBlobNameCalculator(ICurrentTenant currentTenant)
+ {
+ CurrentTenant = currentTenant;
+ }
+
+ public virtual string Calculate(BlobProviderArgs args)
+ {
+ return CurrentTenant.Id == null
+ ? $"host/{args.BlobName}"
+ : $"tenants/{CurrentTenant.Id.Value.ToString("D")}/{args.BlobName}";
+ }
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs
new file mode 100644
index 00000000000..d22cff0e0a8
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using BunnyCDN.Net.Storage;
+using Microsoft.Extensions.Caching.Distributed;
+using Volo.Abp.Caching;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.Security.Encryption;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class DefaultBunnyClientFactory : IBunnyClientFactory, ITransientDependency
+{
+ private readonly IDistributedCache _cache;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IStringEncryptionService _stringEncryptionService;
+
+ private const string CacheKeyPrefix = "BunnyStorageZone:";
+ private readonly static TimeSpan CacheDuration = TimeSpan.FromHours(12);
+
+ public DefaultBunnyClientFactory(
+ IHttpClientFactory httpClient,
+ IDistributedCache cache,
+ IStringEncryptionService stringEncryptionService)
+ {
+ _cache = cache;
+ _httpClientFactory = httpClient;
+ _stringEncryptionService = stringEncryptionService;
+ }
+
+ public virtual async Task CreateAsync(string accessKey, string containerName, string region = "de")
+ {
+ var cacheKey = $"{CacheKeyPrefix}{containerName}";
+ var storageZoneInfo = await _cache.GetOrAddAsync(
+ cacheKey,
+ async () => {
+ var result = await GetStorageZoneAsync(accessKey, containerName);
+ if (result == null)
+ {
+ throw new AbpException($"Storage zone '{containerName}' not found");
+ }
+
+ // Encrypt the sensitive password before caching
+ result.Password = _stringEncryptionService.Encrypt(result.Password!)!;
+ return result;
+ },
+ () => new DistributedCacheEntryOptions
+ {
+ AbsoluteExpiration = DateTimeOffset.Now.Add(CacheDuration)
+ }
+ );
+
+ if (storageZoneInfo == null)
+ {
+ throw new AbpException($"Could not retrieve storage zone information for container '{containerName}'");
+ }
+
+ // Decrypt the password before using it
+ var decryptedPassword = _stringEncryptionService.Decrypt(storageZoneInfo.Password);
+
+ return new BunnyCDNStorage(containerName, decryptedPassword, region);
+ }
+
+ public virtual async Task EnsureStorageZoneExistsAsync(
+ string accessKey,
+ string containerName,
+ string region = "de",
+ bool createIfNotExists = false)
+ {
+ var storageZone = await GetStorageZoneAsync(accessKey, containerName);
+
+ if (storageZone == null)
+ {
+ if (!createIfNotExists)
+ {
+ throw new AbpException(
+ $"Storage zone '{containerName}' does not exist. " +
+ "Set createIfNotExists to true to create it automatically.");
+ }
+
+ await CreateStorageZoneAsync(accessKey, containerName, region);
+
+ // Clear the cache to force a refresh of the storage zone info
+ var cacheKey = $"{CacheKeyPrefix}{containerName}";
+ await _cache.RemoveAsync(cacheKey);
+ }
+ }
+
+ protected virtual async Task CreateStorageZoneAsync(
+ string accessKey,
+ string containerName,
+ string region)
+ {
+ using (var client = _httpClientFactory.CreateClient("BunnyApiClient"))
+ {
+ client.DefaultRequestHeaders.Add("AccessKey", accessKey);
+
+ var payload = new Dictionary
+ {
+ { "Name", containerName },
+ { "Region", region },
+ { "ZoneTier", 0 }
+ };
+
+ var content = new StringContent(
+ JsonSerializer.Serialize(payload),
+ Encoding.UTF8,
+ "application/json");
+
+ var response = await client.PostAsync(
+ "https://api.bunny.net/storagezone",
+ content);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ var errorContent = await response.Content.ReadAsStringAsync();
+ throw new AbpException(
+ $"Failed to create storage zone '{containerName}'. " +
+ $"Status: {response.StatusCode}, Error: {errorContent}");
+ }
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+ var createdZone = JsonSerializer.Deserialize(responseContent);
+
+ if (createdZone == null)
+ {
+ throw new AbpException($"Failed to deserialize the created storage zone response for '{containerName}'");
+ }
+
+ return createdZone;
+ }
+ }
+
+ protected virtual async Task GetStorageZoneAsync(string accessKey, string containerName)
+ {
+ using (var client = _httpClientFactory.CreateClient("BunnyApiClient"))
+ {
+ client.DefaultRequestHeaders.Add("AccessKey", accessKey);
+ var response = await client.GetAsync("https://api.bunny.net/storagezone");
+ response.EnsureSuccessStatusCode();
+
+ var content = await response.Content.ReadAsStringAsync();
+ var zones = JsonSerializer.Deserialize(content);
+
+ return zones?.FirstOrDefault(x => x.Name.Equals(containerName, StringComparison.OrdinalIgnoreCase) && !x.Deleted);
+ }
+ }
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs
new file mode 100644
index 00000000000..34a18aca469
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs
@@ -0,0 +1,6 @@
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public interface IBunnyBlobNameCalculator
+{
+ string Calculate(BlobProviderArgs args);
+}
diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs
new file mode 100644
index 00000000000..7e5db5ed41b
--- /dev/null
+++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs
@@ -0,0 +1,11 @@
+using System.Threading.Tasks;
+using BunnyCDN.Net.Storage;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public interface IBunnyClientFactory
+{
+ Task CreateAsync(string accessKey, string containerName, string region = "de");
+
+ Task EnsureStorageZoneExistsAsync(string accessKey, string containerName, string region = "de", bool createIfNotExists = false);
+}
diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg
new file mode 100644
index 00000000000..a686451fbc0
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg
@@ -0,0 +1,3 @@
+{
+ "role": "lib.test"
+}
\ No newline at end of file
diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj
new file mode 100644
index 00000000000..408f630fe29
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+
+
+ net9.0
+
+ 9f0d2c00-80c1-435b-bfab-2c39c8249091
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs
new file mode 100644
index 00000000000..4f37cfca91e
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs
@@ -0,0 +1,19 @@
+using Volo.Abp.Testing;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class AbpBlobStoringBunnyTestCommonBase : AbpIntegratedTest
+{
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+}
+
+public class AbpBlobStoringBunnyTestBase : AbpIntegratedTest
+{
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+}
diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs
new file mode 100644
index 00000000000..73066291ed6
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute.Extensions;
+using Volo.Abp.Modularity;
+using Volo.Abp.Threading;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+///
+/// This module will not try to connect to Bunny.
+///
+[DependsOn(
+ typeof(AbpBlobStoringBunnyModule),
+ typeof(AbpBlobStoringTestModule)
+)]
+public class AbpBlobStoringBunnyTestCommonModule : AbpModule
+{
+}
+
+[DependsOn(
+ typeof(AbpBlobStoringBunnyTestCommonModule)
+)]
+public class AbpBlobStoringBunnyTestModule : AbpModule
+{
+ private const string UserSecretsId = "9f0d2c00-80c1-435b-bfab-2c39c8249091";
+
+ private readonly string _randomContainerName = "abp-bunny-test-container-" + Guid.NewGuid().ToString("N");
+
+ private BunnyBlobProviderConfiguration _configuration;
+
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.ReplaceConfiguration(ConfigurationHelper.BuildConfiguration(builderAction: builder =>
+ {
+ builder.AddUserSecrets(UserSecretsId);
+ }));
+
+ var configuration = context.Services.GetConfiguration();
+ var accessKey = configuration["Bunny:AccessKey"];
+ var region = configuration["Bunny:Region"];
+
+ Configure(options =>
+ {
+ options.Containers.ConfigureAll((containerName, containerConfiguration) =>
+ {
+ containerConfiguration.UseBunny(bunny =>
+ {
+ bunny.AccessKey = accessKey;
+ bunny.Region = region;
+ bunny.CreateContainerIfNotExists = true;
+ bunny.ContainerName = _randomContainerName;
+
+ _configuration = bunny;
+ });
+ });
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs
new file mode 100644
index 00000000000..84b255e4c55
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs
@@ -0,0 +1,12 @@
+namespace Volo.Abp.BlobStoring.Bunny;
+
+/*
+//Please set the correct connection string in secrets.json and continue the test.
+public class BunnyBlobContainer_Tests : BlobContainer_Tests
+{
+ public BunnyBlobContainer_Tests()
+ {
+
+ }
+}
+*/
diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs
new file mode 100644
index 00000000000..fc3b2d8365e
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs
@@ -0,0 +1,56 @@
+using System;
+using Shouldly;
+using Volo.Abp.MultiTenancy;
+using Xunit;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class BunnyBlobNameCalculatorTests : AbpBlobStoringBunnyTestCommonBase
+{
+ private readonly IBunnyBlobNameCalculator _calculator;
+ private readonly ICurrentTenant _currentTenant;
+
+ private const string BunnyContainerName = "/";
+ private const string BunnySeparator = "/";
+
+ public BunnyBlobNameCalculatorTests()
+ {
+ _calculator = GetRequiredService();
+ _currentTenant = GetRequiredService();
+ }
+
+ [Fact]
+ public void Default_Settings()
+ {
+ _calculator.Calculate(
+ GetArgs("my-container", "my-blob")
+ ).ShouldBe($"host{BunnySeparator}my-blob");
+ }
+
+ [Fact]
+ public void Default_Settings_With_TenantId()
+ {
+ var tenantId = Guid.NewGuid();
+
+ using (_currentTenant.Change(tenantId))
+ {
+ _calculator.Calculate(
+ GetArgs("my-container", "my-blob")
+ ).ShouldBe($"tenants{BunnySeparator}{tenantId:D}{BunnySeparator}my-blob");
+ }
+ }
+
+ private static BlobProviderArgs GetArgs(
+ string containerName,
+ string blobName)
+ {
+ return new BlobProviderGetArgs(
+ containerName,
+ new BlobContainerConfiguration().UseBunny(x =>
+ {
+ x.ContainerName = containerName;
+ }),
+ blobName
+ );
+ }
+}
diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs
new file mode 100644
index 00000000000..c4d3cd68782
--- /dev/null
+++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs
@@ -0,0 +1,57 @@
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.BlobStoring.Bunny;
+
+public class DefaultBunnyBlobNamingNormalizerProviderTests : AbpBlobStoringBunnyTestCommonBase
+{
+ private readonly IBlobNamingNormalizer _blobNamingNormalizer;
+
+ public DefaultBunnyBlobNamingNormalizerProviderTests()
+ {
+ _blobNamingNormalizer = GetRequiredService();
+ }
+
+ [Fact]
+ public void NormalizeContainerName_Lowercase()
+ {
+ var filename = "ThisIsMyContainerName";
+ filename = _blobNamingNormalizer.NormalizeContainerName(filename);
+ filename.ShouldBe("thisismycontainername");
+ }
+
+ [Fact]
+ public void NormalizeContainerName_Only_Letters_Numbers_Dash_Dots()
+ {
+ var filename = ",./this-i,/s-my-c,/ont,/ai+*/=!@#$n^&*er.name+/";
+ filename = _blobNamingNormalizer.NormalizeContainerName(filename);
+ filename.ShouldBe("this-is-my-containername");
+ }
+
+ [Fact]
+ public void NormalizeContainerName_Min_Length()
+ {
+ var filename = "a";
+ Assert.Throws(()=>
+ {
+ filename = _blobNamingNormalizer.NormalizeContainerName(filename);
+ });
+ }
+
+ [Fact]
+ public void NormalizeContainerName_Max_Length()
+ {
+ var longName = new string('a', 65); // 65 characters
+ var exception = Assert.Throws(() =>
+ _blobNamingNormalizer.NormalizeContainerName(longName)
+ );
+ }
+
+ [Fact]
+ public void NormalizeContainerName_Dots()
+ {
+ var filename = ".this..is.-.my.container....name.";
+ filename = _blobNamingNormalizer.NormalizeContainerName(filename);
+ filename.ShouldBe("thisis-mycontainername");
+ }
+}
diff --git a/nupkg/common.ps1 b/nupkg/common.ps1
index 4233ce7b594..e60f3745666 100644
--- a/nupkg/common.ps1
+++ b/nupkg/common.ps1
@@ -155,6 +155,7 @@ $projects = (
"framework/src/Volo.Abp.BlobStoring.Minio",
"framework/src/Volo.Abp.BlobStoring.Aws",
"framework/src/Volo.Abp.BlobStoring.Google",
+ "framework/src/Volo.Abp.BlobStoring.Bunny",
"framework/src/Volo.Abp.Caching",
"framework/src/Volo.Abp.Caching.StackExchangeRedis",
"framework/src/Volo.Abp.Castle.Core",