From f0bf9ff6623ca4d829b8b350ca1203a1068d6d9a Mon Sep 17 00:00:00 2001 From: Suhaib <93185683+suhaib-mousa@users.noreply.github.com> Date: Sat, 25 Jan 2025 18:44:54 +0300 Subject: [PATCH] implement Bunny.net blob storage provider --- Directory.Packages.props | 1 + .../infrastructure/blob-storing/bunny.md | 63 +++++ .../infrastructure/blob-storing/index.md | 1 + framework/Volo.Abp.sln | 15 ++ .../FodyWeavers.xml | 3 + .../FodyWeavers.xsd | 30 +++ .../Volo.Abp.BlobStoring.Bunny.abppkg | 3 + ....Abp.BlobStoring.Bunny.abppkg.analyze.json | 68 ++++++ .../Volo.Abp.BlobStoring.Bunny.csproj | 24 ++ .../Bunny/AbpBunnyBlobStoringModule .cs | 11 + ...nnyBlobContainerConfigurationExtensions.cs | 24 ++ .../Bunny/BunnyBlobNamingNormalizer.cs | 51 ++++ .../BlobStoring/Bunny/BunnyBlobProvider.cs | 168 +++++++++++++ .../Bunny/BunnyBlobProviderConfiguration.cs | 40 +++ .../BunnyBlobProviderConfigurationNames.cs | 15 ++ .../Volo/Abp/BlobStoring/Bunny/BunnyClient.cs | 229 ++++++++++++++++++ .../Bunny/DefaultBunnyBlobNameCalculator.cs | 21 ++ .../Bunny/IBunnyBlobNameCalculator.cs | 6 + .../Volo.Abp.BlobStoring.Bunny.Tests.abppkg | 3 + .../Volo.Abp.BlobStoring.Bunny.Tests.csproj | 19 ++ .../Bunny/AbpBlobStoringBunnyTestBase.cs | 19 ++ .../Bunny/AbpBlobStoringBunnyTestModule.cs | 73 ++++++ .../Bunny/BunnyBlobContainer_Tests.cs | 1 + .../Bunny/BunnyBlobNameCalculator_Tests.cs | 56 +++++ ...BunnyBlobNamingNormalizerProvider_Tests.cs | 57 +++++ 25 files changed, 1001 insertions(+) create mode 100644 docs/en/framework/infrastructure/blob-storing/bunny.md create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClient.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs create mode 100644 framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg create mode 100644 framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj create mode 100644 framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs create mode 100644 framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs create mode 100644 framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs create mode 100644 framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs create mode 100644 framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 8cd134d1d8..eeee7a7ff3 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 0000000000..59aa1eb60c --- /dev/null +++ b/docs/en/framework/infrastructure/blob-storing/bunny.md @@ -0,0 +1,63 @@ +# 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. diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md index f95a90e410..676757ad80 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 c3ca5b63b2..ca471bb304 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 0000000000..1715698ccd --- /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 0000000000..ffa6fc4b78 --- /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 0000000000..f4bad072d2 --- /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 0000000000..b9e8bbba1b --- /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 0000000000..bd16c59713 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj @@ -0,0 +1,24 @@ + + + + + + + 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 0000000000..5c8e1c79b7 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs @@ -0,0 +1,11 @@ +using Volo.Abp.Modularity; + +namespace Volo.Abp.BlobStoring.Bunny; + +[DependsOn(typeof(AbpBlobStoringModule))] +public class AbpBlobStoringBunnyModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + } +} \ No newline at end of file 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 0000000000..03afe1c36d --- /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 0000000000..b478bac23e --- /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 static readonly 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 0000000000..596f977874 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs @@ -0,0 +1,168 @@ +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; } + + public BunnyBlobProvider( + IBunnyBlobNameCalculator bunnyBlobNameCalculator, + IBlobNormalizeNamingService blobNormalizeNamingService) + { + BunnyBlobNameCalculator = bunnyBlobNameCalculator; + BlobNormalizeNamingService = blobNormalizeNamingService; + } + + public override async Task SaveAsync(BlobProviderSaveArgs args) + { + var configuration = args.Configuration.GetBunnyConfiguration(); + var containerName = GetContainerName(args); + var blobName = BunnyBlobNameCalculator.Calculate(args); + + using var bunnyClient = GetBunnyClient(args); + + await ValidateContainerExistsAsync(bunnyClient, containerName, configuration); + + if (!args.OverrideExisting && await BlobExistsAsync(bunnyClient, 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 bunnyClient.UploadAsync(memoryStream, $"{containerName}/{blobName}"); + } + + public override async Task DeleteAsync(BlobProviderDeleteArgs args) + { + var blobName = BunnyBlobNameCalculator.Calculate(args); + var configuration = args.Configuration.GetBunnyConfiguration(); + var containerName = GetContainerName(args); + using var bunnyClient = GetBunnyClient(args); + if (!await BlobExistsAsync(bunnyClient, containerName, blobName)) + { + return false; + } + + try + { + await bunnyClient.DeleteObjectAsync($"{containerName}/{blobName}"); + return true; + } + catch (BunnyCDNStorageException ex) when (ex.Message.Contains("404")) + { + return false; + } + + } + + public override async Task ExistsAsync(BlobProviderExistsArgs args) + { + var blobName = BunnyBlobNameCalculator.Calculate(args); + var containerName = GetContainerName(args); + var configuration = args.Configuration.GetBunnyConfiguration(); + using var bunnyClient = GetBunnyClient(args); + + return await BlobExistsAsync(bunnyClient, containerName, blobName); + } + + public override async Task GetOrNullAsync(BlobProviderGetArgs args) + { + var blobName = BunnyBlobNameCalculator.Calculate(args); + var containerName = GetContainerName(args); + var configuration = args.Configuration.GetBunnyConfiguration(); + using var bunnyClient = GetBunnyClient(args); + + if (!await BlobExistsAsync(bunnyClient, containerName, blobName)) + { + return null; + } + + try + { + return await bunnyClient.DownloadObjectAsStreamAsync($"{containerName}/{blobName}"); + } + catch (WebException ex) when ((HttpStatusCode)ex.Status == HttpStatusCode.NotFound) + { + return null; + } + } + + protected virtual async Task BlobExistsAsync(BunnyClient bunnyClient, string containerName, string blobName) + { + try + { + // Combine container name and blob name to create the full path + var fullBlobPath = $"/{containerName}/{blobName}"; + + // Extract the directory path + var directoryPath = Path.GetDirectoryName(fullBlobPath)?.Replace('\\', '/') + "/"; + + if (string.IsNullOrWhiteSpace(directoryPath)) + { + throw new Exception("Invalid directory path generated from blob name."); + } + + var objects = await bunnyClient.GetStorageObjectsAsync(directoryPath); + + // Check if the specific blob exists in the returned objects + 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 BunnyClient GetBunnyClient(BlobProviderArgs args) + { + var containerName = GetContainerName(args); + var configuration = args.Configuration.GetBunnyConfiguration(); + if (configuration.Region.IsNullOrEmpty()) + { + return new BunnyClient(configuration.AccessKey, containerName); + } + return new BunnyClient(configuration.AccessKey, containerName, configuration.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( + BunnyClient client, + string containerName, + BunnyBlobProviderConfiguration configuration) + { + if (configuration.CreateContainerIfNotExists) + { + try + { + await client.EnsureStorageZoneExistsAsync(containerName); + } + catch (BunnyApiException ex) + { + throw new AbpException( + $"Failed to ensure Bunny storage zone '{containerName}' exists: {ex.Message}", + ex); + } + } + } +} \ No newline at end of file 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 0000000000..0897c7f2b8 --- /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 0000000000..09d79076a6 --- /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/BunnyClient.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClient.cs new file mode 100644 index 0000000000..cac007225b --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClient.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; +using BunnyCDN.Net.Storage; +using BunnyCDN.Net.Storage.Models; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class BunnyClient : IDisposable +{ + private readonly string _region; + private readonly string _storageZoneName; + private readonly HttpClient _httpClient; + + private static BunnyCDNStorage? BunnyCDNStorage = null; + + public BunnyClient(string accessKey, string storageZoneName, string region = "de", HttpMessageHandler? handler = null) + { + if (string.IsNullOrWhiteSpace(accessKey)) + { + throw new ArgumentException("Account access key must not be null or empty.", nameof(accessKey)); + } + _region = region; + _storageZoneName = storageZoneName; + _httpClient = handler != null ? new HttpClient(handler) : new HttpClient(); + _httpClient.Timeout = TimeSpan.FromSeconds(120); + _httpClient.DefaultRequestHeaders.Add("AccessKey", accessKey); + } + + public async Task DoesStorageZoneExistAsync(string zoneName) + { + if (string.IsNullOrWhiteSpace(zoneName)) + { + throw new ArgumentException("Zone name must not be null or empty.", nameof(zoneName)); + } + + try + { + var request = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.bunny.net/storagezone?page=0&perPage=1000&includeDeleted=false"), + Headers = { { "accept", "application/json" } } + }; + + using var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + var storageZones = JsonSerializer.Deserialize>(responseBody); + + return storageZones?.Any(zone => zone.Name!.Equals(zoneName, StringComparison.OrdinalIgnoreCase)) == true; + } + catch (HttpRequestException ex) + { + throw new BunnyApiException("Error occurred while checking storage zone existence.", ex); + } + catch (JsonException ex) + { + throw new BunnyApiException("Failed to parse the response from Bunny API.", ex); + } + } + + // Ensure the storage zone exists, creating it if not found, and return the zone + public async Task EnsureStorageZoneExistsAsync(string zoneName, string? originUrl = "") + { + try + { + if (!await DoesStorageZoneExistAsync(zoneName)) + { + return await CreateStorageZoneAsync(zoneName, originUrl); + } + + var request = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.bunny.net/storagezone?page=0&perPage=1000&includeDeleted=false"), + Headers = { { "accept", "application/json" } } + }; + + using var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var responseBody = await response.Content.ReadAsStringAsync(); + var storageZones = JsonSerializer.Deserialize>(responseBody); + + return storageZones?.FirstOrDefault(zone => zone.Name!.Equals(zoneName, StringComparison.OrdinalIgnoreCase)) + ?? throw new BunnyApiException($"Storage zone '{zoneName}' not found even though it should exist."); + } + catch (BunnyApiException ex) + { + throw new BunnyApiException($"Failed to ensure the existence of storage zone '{zoneName}'.", ex); + } + } + + public async Task UploadAsync(Stream stream, string path) => await (await GetBunnyCDNStorage()).UploadAsync(stream, path); + + public async Task DeleteObjectAsync(string path) => await (await GetBunnyCDNStorage()).DeleteObjectAsync(path); + + public async Task> GetStorageObjectsAsync(string path) => await (await GetBunnyCDNStorage()).GetStorageObjectsAsync(path); + + public async Task DownloadObjectAsStreamAsync(string path) => await (await GetBunnyCDNStorage()).DownloadObjectAsStreamAsync(path); + + public async Task DeleteStorageZoneAsync(string zoneName) + { + if (string.IsNullOrWhiteSpace(zoneName)) + { + throw new ArgumentException("Zone name must not be null or empty.", nameof(zoneName)); + } + var storageZone = (await GetStorageZoneAsync()); + + var request = new HttpRequestMessage + { + Method = HttpMethod.Delete, + RequestUri = new Uri($"https://api.bunny.net/storagezone/{storageZone.Id}") + }; + using (var response = await _httpClient.SendAsync(request)) + { + response.EnsureSuccessStatusCode(); + } + } + private async Task GetStorageZoneAsync() + { + var request = new HttpRequestMessage + { + Method = HttpMethod.Get, + RequestUri = new Uri("https://api.bunny.net/storagezone") + }; + + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var zones = JsonSerializer.Deserialize>(content); + + var targetZone = zones?.FirstOrDefault(z => + z.Name.Equals(_storageZoneName, StringComparison.OrdinalIgnoreCase)); + + return targetZone + ?? throw new AbpException( + $"Storage zone '{_storageZoneName}' is not found"); + } + private async Task CreateStorageZoneAsync(string zoneName, string? originUrl = "", int zoneTier = 0) + { + if (string.IsNullOrWhiteSpace(zoneName)) + { + throw new ArgumentException("Zone name must not be null or empty.", nameof(zoneName)); + } + + try + { + var payload = new Dictionary + { + { "ZoneTier", zoneTier }, + { "Name", zoneName }, + { "Region", _region } + }; + + // Add OriginUrl only if it is not empty + if (!string.IsNullOrEmpty(originUrl)) + { + payload.Add("OriginUrl", originUrl!); + } + + var request = new HttpRequestMessage + { + Method = HttpMethod.Post, + RequestUri = new Uri("https://api.bunny.net/storagezone"), + Headers = { { "accept", "application/json" } }, + Content = new StringContent(JsonSerializer.Serialize(payload)) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/json") } + } + }; + + using var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + throw new BunnyApiException($"Failed to create storage zone. Response: {responseBody}"); + } + + var createdZoneJson = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(createdZoneJson) + ?? throw new BunnyApiException("Failed to deserialize the created storage zone response."); + } + catch (HttpRequestException ex) + { + throw new BunnyApiException("Error occurred while creating storage zone.", ex); + } + catch (JsonException ex) + { + throw new BunnyApiException("Failed to parse the response for the created storage zone.", ex); + } + } + private async Task GetBunnyCDNStorage() + { + if (BunnyCDNStorage != null) { + return BunnyCDNStorage; + } + var storageZonePassword = (await GetStorageZoneAsync()).Password; + BunnyCDNStorage = new BunnyCDNStorage(_storageZoneName, storageZonePassword, _region); + + return BunnyCDNStorage; + } + + public void Dispose() => _httpClient?.Dispose(); + + public class StorageZone + { + public int Id { get; set; } + public string Password { get; set; } = null!; + public string Name { get; set; } = null!; + public string? Region { get; set; } = null!; + public bool Deleted { get; set; } + } +} + +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/DefaultBunnyBlobNameCalculator.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs new file mode 100644 index 0000000000..f06acb8c14 --- /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/IBunnyBlobNameCalculator.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs new file mode 100644 index 0000000000..34a18aca46 --- /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/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 0000000000..a686451fbc --- /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 0000000000..408f630fe2 --- /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 0000000000..4f37cfca91 --- /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 0000000000..3e79ad0faa --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +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; + }); + }); + }); + } + + public override void OnApplicationShutdown(ApplicationShutdownContext context) + { + AsyncHelper.RunSync(() => DeleteStorageZoneAsync(context)); + } + + private async Task DeleteStorageZoneAsync(ApplicationShutdownContext context) + { + var bunnyClient = new BunnyClient(_configuration.AccessKey, _configuration.ContainerName, _configuration.Region); + if (await bunnyClient.DoesStorageZoneExistAsync(_randomContainerName)) + { + await bunnyClient.DeleteStorageZoneAsync(_randomContainerName); + } + } +} 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 0000000000..3d4592cc97 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs @@ -0,0 +1 @@ +namespace Volo.Abp.BlobStoring.Bunny; 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 0000000000..fc3b2d8365 --- /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 0000000000..c4d3cd6878 --- /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"); + } +}