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 1/5] 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"); + } +} From 6d70e78cbd630721e85151ea0b4ddbfef19300cd Mon Sep 17 00:00:00 2001 From: Suhaib <93185683+suhaib-mousa@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:04:56 +0300 Subject: [PATCH 2/5] dynamic password retrieval via a distributed cache in BunnyClientFactory --- .../Volo.Abp.BlobStoring.Bunny.csproj | 4 +- .../Bunny/AbpBunnyBlobStoringModule .cs | 10 +- .../BlobStoring/Bunny/BunnyBlobProvider.cs | 97 ++++---- .../Volo/Abp/BlobStoring/Bunny/BunnyClient.cs | 229 ------------------ .../BlobStoring/Bunny/BunnyClientFactory.cs | 153 ++++++++++++ .../BlobStoring/Bunny/IBunnyClientFactory.cs | 10 + 6 files changed, 222 insertions(+), 281 deletions(-) delete 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/BunnyClientFactory.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs 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 index bd16c59713..2cd9c74ac6 100644 --- a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj @@ -15,10 +15,12 @@ - + + + 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 index 5c8e1c79b7..49e97e861b 100644 --- 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 @@ -1,11 +1,17 @@ -using Volo.Abp.Modularity; +using System; +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Caching; +using Volo.Abp.Modularity; namespace Volo.Abp.BlobStoring.Bunny; -[DependsOn(typeof(AbpBlobStoringModule))] +[DependsOn( + typeof(AbpBlobStoringModule), + typeof(AbpCachingModule))] public class AbpBlobStoringBunnyModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { + context.Services.AddHttpClient(); } } \ No newline at end of file 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 index 596f977874..b154ab2160 100644 --- 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 @@ -12,13 +12,16 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency { protected IBunnyBlobNameCalculator BunnyBlobNameCalculator { get; } protected IBlobNormalizeNamingService BlobNormalizeNamingService { get; } + protected IBunnyClientFactory BunnyClientFactory { get; } public BunnyBlobProvider( IBunnyBlobNameCalculator bunnyBlobNameCalculator, - IBlobNormalizeNamingService blobNormalizeNamingService) + IBlobNormalizeNamingService blobNormalizeNamingService, + IBunnyClientFactory bunnyClientFactory) { BunnyBlobNameCalculator = bunnyBlobNameCalculator; BlobNormalizeNamingService = blobNormalizeNamingService; + BunnyClientFactory = bunnyClientFactory; } public override async Task SaveAsync(BlobProviderSaveArgs args) @@ -27,14 +30,14 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency var containerName = GetContainerName(args); var blobName = BunnyBlobNameCalculator.Calculate(args); - using var bunnyClient = GetBunnyClient(args); - - await ValidateContainerExistsAsync(bunnyClient, containerName, configuration); + await ValidateContainerExistsAsync(containerName, configuration); - if (!args.OverrideExisting && await BlobExistsAsync(bunnyClient, containerName, blobName)) + var bunnyStorage = await GetBunnyCDNStorageAsync(args); + + if (!args.OverrideExisting && await BlobExistsAsync(bunnyStorage, containerName, blobName)) { throw new BlobAlreadyExistsException( - $"BLOB '{args.BlobName}' already exists in container '{containerName}'. " + + $"Blob '{args.BlobName}' already exists in container '{containerName}'. " + $"Set {nameof(args.OverrideExisting)} to true to overwrite."); } @@ -42,57 +45,53 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency await args.BlobStream.CopyToAsync(memoryStream); memoryStream.Position = 0; - await bunnyClient.UploadAsync(memoryStream, $"{containerName}/{blobName}"); + await bunnyStorage.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)) + var bunnyStorage = await GetBunnyCDNStorageAsync(args); + + if (!await BlobExistsAsync(bunnyStorage, containerName, blobName)) { return false; } try { - await bunnyClient.DeleteObjectAsync($"{containerName}/{blobName}"); - return true; + return await bunnyStorage.DeleteObjectAsync($"{containerName}/{blobName}"); } 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); + var bunnyStorage = await GetBunnyCDNStorageAsync(args); - return await BlobExistsAsync(bunnyClient, containerName, blobName); + return await BlobExistsAsync(bunnyStorage, 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); + var bunnyStorage = await GetBunnyCDNStorageAsync(args); - if (!await BlobExistsAsync(bunnyClient, containerName, blobName)) + if (!await BlobExistsAsync(bunnyStorage, containerName, blobName)) { return null; } try { - return await bunnyClient.DownloadObjectAsStreamAsync($"{containerName}/{blobName}"); + return await bunnyStorage.DownloadObjectAsStreamAsync($"{containerName}/{blobName}"); } catch (WebException ex) when ((HttpStatusCode)ex.Status == HttpStatusCode.NotFound) { @@ -100,14 +99,11 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency } } - protected virtual async Task BlobExistsAsync(BunnyClient bunnyClient, string containerName, string blobName) + protected virtual async Task BlobExistsAsync(BunnyCDNStorage bunnyStorage, 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)) @@ -115,9 +111,7 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency 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 + var objects = await bunnyStorage.GetStorageObjectsAsync(directoryPath); return objects?.Any(o => o.FullPath == fullBlobPath) == true; } catch (BunnyCDNStorageException ex) when (ex.Message.Contains("404")) @@ -129,16 +123,19 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency throw new Exception($"Error while checking blob existence: {ex.Message}", ex); } } - protected virtual BunnyClient GetBunnyClient(BlobProviderArgs args) + + protected virtual async Task GetBunnyCDNStorageAsync(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); + 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(); @@ -146,23 +143,25 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency ? args.ContainerName : BlobNormalizeNamingService.NormalizeContainerName(args.Configuration, configuration.ContainerName!); } + protected virtual async Task ValidateContainerExistsAsync( - BunnyClient client, - string containerName, - BunnyBlobProviderConfiguration configuration) + string containerName, + BunnyBlobProviderConfiguration configuration + ) { - if (configuration.CreateContainerIfNotExists) + try { - try - { - await client.EnsureStorageZoneExistsAsync(containerName); - } - catch (BunnyApiException ex) - { - throw new AbpException( - $"Failed to ensure Bunny storage zone '{containerName}' exists: {ex.Message}", - ex); - } + 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); } } -} \ No newline at end of file +} 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 deleted file mode 100644 index cac007225b..0000000000 --- a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClient.cs +++ /dev/null @@ -1,229 +0,0 @@ -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/BunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClientFactory.cs new file mode 100644 index 0000000000..e4fb3d8fd5 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClientFactory.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +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; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class BunnyClientFactory : IBunnyClientFactory, ITransientDependency +{ + private readonly IDistributedCache _cache; + private readonly IHttpClientFactory _httpClientFactory; + private const string CacheKeyPrefix = "BunnyStorageZone:"; + private static TimeSpan CacheDuration = TimeSpan.FromHours(12); + + public BunnyClientFactory( + IHttpClientFactory httpClient, + IDistributedCache cache) + { + _cache = cache; + _httpClientFactory = httpClient; + } + + public 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"); + } + return result; + }, + () => new DistributedCacheEntryOptions + { + AbsoluteExpiration = DateTimeOffset.Now.Add(CacheDuration) + } + ); + + if (storageZoneInfo == null) + { + throw new AbpException($"Could not retrieve storage zone information for container '{containerName}'"); + } + + return new BunnyCDNStorage(containerName, storageZoneInfo.Password, region); + } + + public 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); + } + } + + private 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; + } + + private 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 Array.Find(zones!, z => + z.Name.Equals(containerName, StringComparison.OrdinalIgnoreCase) && !z.Deleted); + } + + [Serializable] + 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; } + 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/IBunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs new file mode 100644 index 0000000000..dce2c5b6f0 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs @@ -0,0 +1,10 @@ +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); +} From 14fda53d069ddbbed4a163dff03fd80b089e1966 Mon Sep 17 00:00:00 2001 From: Suhaib <93185683+suhaib-mousa@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:24:46 +0300 Subject: [PATCH 3/5] encrypt the sensitive password before caching --- .../infrastructure/blob-storing/bunny.md | 1 + ...actory.cs => DefaultBunnyClientFactory.cs} | 27 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) rename framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/{BunnyClientFactory.cs => DefaultBunnyClientFactory.cs} (80%) diff --git a/docs/en/framework/infrastructure/blob-storing/bunny.md b/docs/en/framework/infrastructure/blob-storing/bunny.md index 59aa1eb60c..4c5fb5ef0f 100644 --- a/docs/en/framework/infrastructure/blob-storing/bunny.md +++ b/docs/en/framework/infrastructure/blob-storing/bunny.md @@ -61,3 +61,4 @@ Bunny Blob Provider organizes BLOB name and implements some conventions. The ful * `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/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs similarity index 80% rename from framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClientFactory.cs rename to framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs index e4fb3d8fd5..ae60a25e01 100644 --- a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClientFactory.cs +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs @@ -8,25 +8,30 @@ 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 BunnyClientFactory : IBunnyClientFactory, ITransientDependency +public class DefaultBunnyClientFactory : IBunnyClientFactory, ITransientDependency { private readonly IDistributedCache _cache; private readonly IHttpClientFactory _httpClientFactory; + private readonly IStringEncryptionService _stringEncryptionService; + private const string CacheKeyPrefix = "BunnyStorageZone:"; private static TimeSpan CacheDuration = TimeSpan.FromHours(12); - public BunnyClientFactory( + public DefaultBunnyClientFactory( IHttpClientFactory httpClient, - IDistributedCache cache) + IDistributedCache cache, + IStringEncryptionService stringEncryptionService) { _cache = cache; _httpClientFactory = httpClient; + _stringEncryptionService = stringEncryptionService; } - public async Task CreateAsync(string accessKey, string containerName, string region = "de") + public virtual async Task CreateAsync(string accessKey, string containerName, string region = "de") { var cacheKey = $"{CacheKeyPrefix}{containerName}"; var storageZoneInfo = await _cache.GetOrAddAsync( @@ -37,6 +42,9 @@ public class BunnyClientFactory : IBunnyClientFactory, ITransientDependency { throw new AbpException($"Storage zone '{containerName}' not found"); } + + // Encrypt the sensitive password before caching + result.Password = _stringEncryptionService.Encrypt(result.Password!)!; return result; }, () => new DistributedCacheEntryOptions @@ -50,10 +58,13 @@ public class BunnyClientFactory : IBunnyClientFactory, ITransientDependency throw new AbpException($"Could not retrieve storage zone information for container '{containerName}'"); } - return new BunnyCDNStorage(containerName, storageZoneInfo.Password, region); + // Decrypt the password before using it + var decryptedPassword = _stringEncryptionService.Decrypt(storageZoneInfo.Password); + + return new BunnyCDNStorage(containerName, decryptedPassword, region); } - public async Task EnsureStorageZoneExistsAsync( + public virtual async Task EnsureStorageZoneExistsAsync( string accessKey, string containerName, string region = "de", @@ -78,7 +89,7 @@ public class BunnyClientFactory : IBunnyClientFactory, ITransientDependency } } - private async Task CreateStorageZoneAsync( + protected virtual async Task CreateStorageZoneAsync( string accessKey, string containerName, string region) @@ -122,7 +133,7 @@ public class BunnyClientFactory : IBunnyClientFactory, ITransientDependency return createdZone; } - private async Task GetStorageZoneAsync(string accessKey, string containerName) + protected virtual async Task GetStorageZoneAsync(string accessKey, string containerName) { using var _client = _httpClientFactory.CreateClient("BunnyApiClient"); _client.DefaultRequestHeaders.Add("AccessKey", accessKey); From 5f38b486c625c61abd3aa5a5ccbca313bb2b1a49 Mon Sep 17 00:00:00 2001 From: Suhaib <93185683+suhaib-mousa@users.noreply.github.com> Date: Mon, 27 Jan 2025 20:46:21 +0300 Subject: [PATCH 4/5] fix unit test --- .../Bunny/AbpBlobStoringBunnyTestModule.cs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) 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 index 3e79ad0faa..73066291ed 100644 --- 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 @@ -2,6 +2,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using NSubstitute.Extensions; using Volo.Abp.Modularity; using Volo.Abp.Threading; @@ -56,18 +57,4 @@ public class AbpBlobStoringBunnyTestModule : AbpModule }); }); } - - 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); - } - } } From 41e3d350318faf3b073c821e73f72e12efcf6548 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 4 Feb 2025 11:00:28 +0800 Subject: [PATCH 5/5] Refactor code. --- .../Bunny/AbpBunnyBlobStoringModule .cs | 7 +- .../BlobStoring/Bunny/BunnyApiException.cs | 18 +++ .../Bunny/BunnyBlobNamingNormalizer.cs | 2 +- .../BlobStoring/Bunny/BunnyBlobProvider.cs | 8 +- .../Bunny/BunnyStorageZoneModel.cs | 17 +++ .../Bunny/DefaultBunnyClientFactory.cs | 104 ++++++++---------- .../BlobStoring/Bunny/IBunnyClientFactory.cs | 1 + .../Bunny/BunnyBlobContainer_Tests.cs | 11 ++ nupkg/common.ps1 | 1 + 9 files changed, 102 insertions(+), 67 deletions(-) create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs create mode 100644 framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs 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 index 49e97e861b..3fe814f2f0 100644 --- 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 @@ -1,12 +1,11 @@ -using System; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Caching; using Volo.Abp.Modularity; namespace Volo.Abp.BlobStoring.Bunny; [DependsOn( - typeof(AbpBlobStoringModule), + typeof(AbpBlobStoringModule), typeof(AbpCachingModule))] public class AbpBlobStoringBunnyModule : AbpModule { @@ -14,4 +13,4 @@ public class AbpBlobStoringBunnyModule : AbpModule { context.Services.AddHttpClient(); } -} \ No newline at end of file +} 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 0000000000..1e10d4caa2 --- /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/BunnyBlobNamingNormalizer.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs index b478bac23e..74c436e2e9 100644 --- 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 @@ -7,7 +7,7 @@ namespace Volo.Abp.BlobStoring.Bunny; public class BunnyBlobNamingNormalizer : IBlobNamingNormalizer, ITransientDependency { - private static readonly Regex ValidCharactersRegex = + private readonly static Regex ValidCharactersRegex = new Regex(@"^[a-z0-9-]*$", RegexOptions.Compiled); private const int MinLength = 4; 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 index b154ab2160..6c06bc3f95 100644 --- 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 @@ -24,7 +24,7 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency BunnyClientFactory = bunnyClientFactory; } - public override async Task SaveAsync(BlobProviderSaveArgs args) + public async override Task SaveAsync(BlobProviderSaveArgs args) { var configuration = args.Configuration.GetBunnyConfiguration(); var containerName = GetContainerName(args); @@ -48,7 +48,7 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency await bunnyStorage.UploadAsync(memoryStream, $"{containerName}/{blobName}"); } - public override async Task DeleteAsync(BlobProviderDeleteArgs args) + public async override Task DeleteAsync(BlobProviderDeleteArgs args) { var blobName = BunnyBlobNameCalculator.Calculate(args); var containerName = GetContainerName(args); @@ -69,7 +69,7 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency } } - public override async Task ExistsAsync(BlobProviderExistsArgs args) + public async override Task ExistsAsync(BlobProviderExistsArgs args) { var blobName = BunnyBlobNameCalculator.Calculate(args); var containerName = GetContainerName(args); @@ -78,7 +78,7 @@ public class BunnyBlobProvider : BlobProviderBase, ITransientDependency return await BlobExistsAsync(bunnyStorage, containerName, blobName); } - public override async Task GetOrNullAsync(BlobProviderGetArgs args) + public async override Task GetOrNullAsync(BlobProviderGetArgs args) { var blobName = BunnyBlobNameCalculator.Calculate(args); var containerName = GetContainerName(args); 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 0000000000..e718531927 --- /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/DefaultBunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs index ae60a25e01..d22cff0e0a 100644 --- 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 @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -14,16 +15,16 @@ namespace Volo.Abp.BlobStoring.Bunny; public class DefaultBunnyClientFactory : IBunnyClientFactory, ITransientDependency { - private readonly IDistributedCache _cache; + private readonly IDistributedCache _cache; private readonly IHttpClientFactory _httpClientFactory; private readonly IStringEncryptionService _stringEncryptionService; private const string CacheKeyPrefix = "BunnyStorageZone:"; - private static TimeSpan CacheDuration = TimeSpan.FromHours(12); + private readonly static TimeSpan CacheDuration = TimeSpan.FromHours(12); public DefaultBunnyClientFactory( IHttpClientFactory httpClient, - IDistributedCache cache, + IDistributedCache cache, IStringEncryptionService stringEncryptionService) { _cache = cache; @@ -89,76 +90,63 @@ public class DefaultBunnyClientFactory : IBunnyClientFactory, ITransientDependen } } - protected virtual async Task CreateStorageZoneAsync( + 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 + using (var client = _httpClientFactory.CreateClient("BunnyApiClient")) { - { "Name", containerName }, - { "Region", region }, - { "ZoneTier", 0 } - }; + client.DefaultRequestHeaders.Add("AccessKey", accessKey); - var content = new StringContent( - JsonSerializer.Serialize(payload), - Encoding.UTF8, - "application/json"); + var payload = new Dictionary + { + { "Name", containerName }, + { "Region", region }, + { "ZoneTier", 0 } + }; - var response = await _client.PostAsync( - "https://api.bunny.net/storagezone", - content); + var content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(); - throw new AbpException( - $"Failed to create storage zone '{containerName}'. " + - $"Status: {response.StatusCode}, Error: {errorContent}"); - } + 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); + 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}'"); - } + if (createdZone == null) + { + throw new AbpException($"Failed to deserialize the created storage zone response for '{containerName}'"); + } - return createdZone; + return createdZone; + } } - protected virtual async Task GetStorageZoneAsync(string accessKey, string containerName) + 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); + using (var client = _httpClientFactory.CreateClient("BunnyApiClient")) + { + client.DefaultRequestHeaders.Add("AccessKey", accessKey); + var response = await client.GetAsync("https://api.bunny.net/storagezone"); + response.EnsureSuccessStatusCode(); - return Array.Find(zones!, z => - z.Name.Equals(containerName, StringComparison.OrdinalIgnoreCase) && !z.Deleted); - } + var content = await response.Content.ReadAsStringAsync(); + var zones = JsonSerializer.Deserialize(content); - [Serializable] - 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; } - public bool Deleted { get; set; } + return zones?.FirstOrDefault(x => x.Name.Equals(containerName, StringComparison.OrdinalIgnoreCase) && !x.Deleted); + } } } -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/IBunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs index dce2c5b6f0..7e5db5ed41 100644 --- 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 @@ -6,5 +6,6 @@ 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/BunnyBlobContainer_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs index 3d4592cc97..84b255e4c5 100644 --- 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 @@ -1 +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/nupkg/common.ps1 b/nupkg/common.ps1 index 4233ce7b59..e60f374566 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",