Browse Source

Merge pull request #22004 from suhaib-mousa/features/blob-storing-bunny

feat(blob-storing): implement Bunny.net blob storage provider
pull/22072/head
maliming 2 years ago
committed by GitHub
parent
commit
e0c43188d2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      Directory.Packages.props
  2. 64
      docs/en/framework/infrastructure/blob-storing/bunny.md
  3. 1
      docs/en/framework/infrastructure/blob-storing/index.md
  4. 15
      framework/Volo.Abp.sln
  5. 3
      framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml
  6. 30
      framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd
  7. 3
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg
  8. 68
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json
  9. 26
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj
  10. 16
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs
  11. 18
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs
  12. 24
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs
  13. 51
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs
  14. 167
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs
  15. 40
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs
  16. 15
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs
  17. 17
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs
  18. 21
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs
  19. 152
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs
  20. 6
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs
  21. 11
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs
  22. 3
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg
  23. 19
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj
  24. 19
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs
  25. 60
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs
  26. 12
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs
  27. 56
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs
  28. 57
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs
  29. 1
      nupkg/common.ps1

1
Directory.Packages.props

@ -15,6 +15,7 @@
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="AWSSDK.S3" Version="3.7.410.9" />
<PackageVersion Include="AWSSDK.SecurityToken" Version="3.7.401.16" />
<PackageVersion Include="BunnyCDN.Net.Storage" Version="1.0.4" />
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.18.1" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.22.1" />
<PackageVersion Include="Blazorise" Version="1.7.3" />

64
docs/en/framework/infrastructure/blob-storing/bunny.md

@ -0,0 +1,64 @@
# BLOB Storing Bunny Provider
BLOB Storing Bunny Provider can store BLOBs in [bunny.net Storage](https://bunny.net/storage/).
> Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. This document only covers how to configure containers to use a Bunny BLOB as the storage provider.
## Installation
Use the ABP CLI to add [Volo.Abp.BlobStoring.Bunny](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Bunny) NuGet package to your project:
* Install the [ABP CLI](../../../cli) if you haven't installed before.
* Open a command line (terminal) in the directory of the `.csproj` file you want to add the `Volo.Abp.BlobStoring.Bunny` package.
* Run `abp add-package Volo.Abp.BlobStoring.Bunny` command.
If you want to do it manually, install the [Volo.Abp.BlobStoring.Bunny](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Bunny) NuGet package to your project and add `[DependsOn(typeof(AbpBlobStoringBunnyModule))]` to the [ABP module](../../architecture/modularity/basics.md) class inside your project.
## Configuration
Configuration is done in the `ConfigureServices` method of your [module](../../architecture/modularity/basics.md) class, as explained in the [BLOB Storing document](../blob-storing).
**Example: Configure to use the Bunny storage provider by default**
````csharp
Configure<AbpBlobStoringOptions>(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/<tenant-id>` string if current tenant is not `null`.
* Appends the BLOB name.
## Other Services
* `BunnyBlobProvider` is the main service that implements the Bunny BLOB storage provider, if you want to override/replace it via [dependency injection](../../fundamentals/dependency-injection.md) (don't replace `IBlobProvider` interface, but replace `BunnyBlobProvider` class).
* `IBunnyBlobNameCalculator` is used to calculate the full BLOB name (that is explained above). It is implemented by the `DefaultBunnyBlobNameCalculator` by default.
* `IBunnyClientFactory` is implemented by `DefaultBunnyClientFactory` by default. You can override/replace it,if you want customize.

1
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.

15
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}

3
framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait ContinueOnCapturedContext="false" />
</Weavers>

30
framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="ConfigureAwait" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:attribute name="ContinueOnCapturedContext" type="xs:boolean" />
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

3
framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg

@ -0,0 +1,3 @@
{
"role": "lib.framework"
}

68
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
}
]
}

26
framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\configureawait.props" />
<Import Project="..\..\..\common.props" />
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0</TargetFrameworks>
<Nullable>enable</Nullable>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<RootNamespace />
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Volo.Abp.BlobStoring\Volo.Abp.BlobStoring.csproj" />
<ProjectReference Include="..\Volo.Abp.Caching\Volo.Abp.Caching.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BunnyCDN.Net.Storage" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
</Project>

16
framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs

@ -0,0 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Caching;
using Volo.Abp.Modularity;
namespace Volo.Abp.BlobStoring.Bunny;
[DependsOn(
typeof(AbpBlobStoringModule),
typeof(AbpCachingModule))]
public class AbpBlobStoringBunnyModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddHttpClient();
}
}

18
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)
{
}
}

24
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<BunnyBlobProviderConfiguration> bunnyConfigureAction)
{
containerConfiguration.ProviderType = typeof(BunnyBlobProvider);
containerConfiguration.NamingNormalizers.TryAdd<BunnyBlobNamingNormalizer>();
bunnyConfigureAction(new BunnyBlobProviderConfiguration(containerConfiguration));
return containerConfiguration;
}
}

51
framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs

@ -0,0 +1,51 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Localization;
namespace Volo.Abp.BlobStoring.Bunny;
public class BunnyBlobNamingNormalizer : IBlobNamingNormalizer, ITransientDependency
{
private readonly static Regex ValidCharactersRegex =
new Regex(@"^[a-z0-9-]*$", RegexOptions.Compiled);
private const int MinLength = 4;
private const int MaxLength = 64;
public virtual string NormalizeBlobName(string blobName) => blobName;
public virtual string NormalizeContainerName(string containerName)
{
Check.NotNullOrWhiteSpace(containerName, nameof(containerName));
using (CultureHelper.Use(CultureInfo.InvariantCulture))
{
// Trim whitespace and convert to lowercase
var normalizedName = containerName
.Trim()
.ToLowerInvariant();
// Remove any invalid characters
normalizedName = Regex.Replace(normalizedName, "[^a-z0-9-]", string.Empty);
// Validate structure
if (!ValidCharactersRegex.IsMatch(normalizedName))
{
throw new AbpException(
$"Container name contains invalid characters: {containerName}. " +
"Only lowercase letters, numbers, and hyphens are allowed.");
}
// Validate length
if (normalizedName.Length < MinLength || normalizedName.Length > MaxLength)
{
throw new AbpException(
$"Container name must be between {MinLength} and {MaxLength} characters. " +
$"Current length: {normalizedName.Length}");
}
return normalizedName;
}
}
}

167
framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs

@ -0,0 +1,167 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using BunnyCDN.Net.Storage;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Bunny;
public class BunnyBlobProvider : BlobProviderBase, ITransientDependency
{
protected IBunnyBlobNameCalculator BunnyBlobNameCalculator { get; }
protected IBlobNormalizeNamingService BlobNormalizeNamingService { get; }
protected IBunnyClientFactory BunnyClientFactory { get; }
public BunnyBlobProvider(
IBunnyBlobNameCalculator bunnyBlobNameCalculator,
IBlobNormalizeNamingService blobNormalizeNamingService,
IBunnyClientFactory bunnyClientFactory)
{
BunnyBlobNameCalculator = bunnyBlobNameCalculator;
BlobNormalizeNamingService = blobNormalizeNamingService;
BunnyClientFactory = bunnyClientFactory;
}
public async override Task SaveAsync(BlobProviderSaveArgs args)
{
var configuration = args.Configuration.GetBunnyConfiguration();
var containerName = GetContainerName(args);
var blobName = BunnyBlobNameCalculator.Calculate(args);
await ValidateContainerExistsAsync(containerName, configuration);
var bunnyStorage = await GetBunnyCDNStorageAsync(args);
if (!args.OverrideExisting && await BlobExistsAsync(bunnyStorage, containerName, blobName))
{
throw new BlobAlreadyExistsException(
$"Blob '{args.BlobName}' already exists in container '{containerName}'. " +
$"Set {nameof(args.OverrideExisting)} to true to overwrite.");
}
using var memoryStream = new MemoryStream();
await args.BlobStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
await bunnyStorage.UploadAsync(memoryStream, $"{containerName}/{blobName}");
}
public async override Task<bool> DeleteAsync(BlobProviderDeleteArgs args)
{
var blobName = BunnyBlobNameCalculator.Calculate(args);
var containerName = GetContainerName(args);
var bunnyStorage = await GetBunnyCDNStorageAsync(args);
if (!await BlobExistsAsync(bunnyStorage, containerName, blobName))
{
return false;
}
try
{
return await bunnyStorage.DeleteObjectAsync($"{containerName}/{blobName}");
}
catch (BunnyCDNStorageException ex) when (ex.Message.Contains("404"))
{
return false;
}
}
public async override Task<bool> ExistsAsync(BlobProviderExistsArgs args)
{
var blobName = BunnyBlobNameCalculator.Calculate(args);
var containerName = GetContainerName(args);
var bunnyStorage = await GetBunnyCDNStorageAsync(args);
return await BlobExistsAsync(bunnyStorage, containerName, blobName);
}
public async override Task<Stream?> GetOrNullAsync(BlobProviderGetArgs args)
{
var blobName = BunnyBlobNameCalculator.Calculate(args);
var containerName = GetContainerName(args);
var bunnyStorage = await GetBunnyCDNStorageAsync(args);
if (!await BlobExistsAsync(bunnyStorage, containerName, blobName))
{
return null;
}
try
{
return await bunnyStorage.DownloadObjectAsStreamAsync($"{containerName}/{blobName}");
}
catch (WebException ex) when ((HttpStatusCode)ex.Status == HttpStatusCode.NotFound)
{
return null;
}
}
protected virtual async Task<bool> BlobExistsAsync(BunnyCDNStorage bunnyStorage, string containerName, string blobName)
{
try
{
var fullBlobPath = $"/{containerName}/{blobName}";
var directoryPath = Path.GetDirectoryName(fullBlobPath)?.Replace('\\', '/') + "/";
if (string.IsNullOrWhiteSpace(directoryPath))
{
throw new Exception("Invalid directory path generated from blob name.");
}
var objects = await bunnyStorage.GetStorageObjectsAsync(directoryPath);
return objects?.Any(o => o.FullPath == fullBlobPath) == true;
}
catch (BunnyCDNStorageException ex) when (ex.Message.Contains("404"))
{
return false;
}
catch (Exception ex)
{
throw new Exception($"Error while checking blob existence: {ex.Message}", ex);
}
}
protected virtual async Task<BunnyCDNStorage> GetBunnyCDNStorageAsync(BlobProviderArgs args)
{
var configuration = args.Configuration.GetBunnyConfiguration();
var containerName = GetContainerName(args);
var region = configuration.Region ?? "de";
return await BunnyClientFactory.CreateAsync(
configuration.AccessKey,
containerName,
region);
}
protected virtual string GetContainerName(BlobProviderArgs args)
{
var configuration = args.Configuration.GetBunnyConfiguration();
return configuration.ContainerName.IsNullOrWhiteSpace()
? args.ContainerName
: BlobNormalizeNamingService.NormalizeContainerName(args.Configuration, configuration.ContainerName!);
}
protected virtual async Task ValidateContainerExistsAsync(
string containerName,
BunnyBlobProviderConfiguration configuration
)
{
try
{
await BunnyClientFactory.EnsureStorageZoneExistsAsync(
configuration.AccessKey,
containerName,
configuration.Region ?? "de",
configuration.CreateContainerIfNotExists);
}
catch (Exception ex)
{
throw new AbpException(
$"Failed to validate storage zone '{containerName}': {ex.Message}",
ex);
}
}
}

40
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);
}
/// <summary>
/// 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 <see cref="BlobProviderArgs"/> will be used.
/// </summary>
public string? ContainerName {
get => _containerConfiguration.GetConfigurationOrDefault<string>(BunnyBlobProviderConfigurationNames.ContainerName);
set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.ContainerName, value);
}
/// <summary>
/// Default value: false.
/// </summary>
public bool CreateContainerIfNotExists {
get => _containerConfiguration.GetConfigurationOrDefault(BunnyBlobProviderConfigurationNames.CreateContainerIfNotExists, false);
set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.CreateContainerIfNotExists, value);
}
public string AccessKey {
get => _containerConfiguration.GetConfiguration<string>(BunnyBlobProviderConfigurationNames.AccessKey);
set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.AccessKey, value);
}
private readonly BlobContainerConfiguration _containerConfiguration;
public BunnyBlobProviderConfiguration(BlobContainerConfiguration containerConfiguration)
{
_containerConfiguration = containerConfiguration;
}
}

15
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";
}

17
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; }
}

21
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}";
}
}

152
framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs

@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using BunnyCDN.Net.Storage;
using Microsoft.Extensions.Caching.Distributed;
using Volo.Abp.Caching;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Security.Encryption;
namespace Volo.Abp.BlobStoring.Bunny;
public class DefaultBunnyClientFactory : IBunnyClientFactory, ITransientDependency
{
private readonly IDistributedCache<BunnyStorageZoneModel> _cache;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IStringEncryptionService _stringEncryptionService;
private const string CacheKeyPrefix = "BunnyStorageZone:";
private readonly static TimeSpan CacheDuration = TimeSpan.FromHours(12);
public DefaultBunnyClientFactory(
IHttpClientFactory httpClient,
IDistributedCache<BunnyStorageZoneModel> cache,
IStringEncryptionService stringEncryptionService)
{
_cache = cache;
_httpClientFactory = httpClient;
_stringEncryptionService = stringEncryptionService;
}
public virtual async Task<BunnyCDNStorage> CreateAsync(string accessKey, string containerName, string region = "de")
{
var cacheKey = $"{CacheKeyPrefix}{containerName}";
var storageZoneInfo = await _cache.GetOrAddAsync(
cacheKey,
async () => {
var result = await GetStorageZoneAsync(accessKey, containerName);
if (result == null)
{
throw new AbpException($"Storage zone '{containerName}' not found");
}
// Encrypt the sensitive password before caching
result.Password = _stringEncryptionService.Encrypt(result.Password!)!;
return result;
},
() => new DistributedCacheEntryOptions
{
AbsoluteExpiration = DateTimeOffset.Now.Add(CacheDuration)
}
);
if (storageZoneInfo == null)
{
throw new AbpException($"Could not retrieve storage zone information for container '{containerName}'");
}
// Decrypt the password before using it
var decryptedPassword = _stringEncryptionService.Decrypt(storageZoneInfo.Password);
return new BunnyCDNStorage(containerName, decryptedPassword, region);
}
public virtual async Task EnsureStorageZoneExistsAsync(
string accessKey,
string containerName,
string region = "de",
bool createIfNotExists = false)
{
var storageZone = await GetStorageZoneAsync(accessKey, containerName);
if (storageZone == null)
{
if (!createIfNotExists)
{
throw new AbpException(
$"Storage zone '{containerName}' does not exist. " +
"Set createIfNotExists to true to create it automatically.");
}
await CreateStorageZoneAsync(accessKey, containerName, region);
// Clear the cache to force a refresh of the storage zone info
var cacheKey = $"{CacheKeyPrefix}{containerName}";
await _cache.RemoveAsync(cacheKey);
}
}
protected virtual async Task<BunnyStorageZoneModel> CreateStorageZoneAsync(
string accessKey,
string containerName,
string region)
{
using (var client = _httpClientFactory.CreateClient("BunnyApiClient"))
{
client.DefaultRequestHeaders.Add("AccessKey", accessKey);
var payload = new Dictionary<string, object>
{
{ "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<BunnyStorageZoneModel>(responseContent);
if (createdZone == null)
{
throw new AbpException($"Failed to deserialize the created storage zone response for '{containerName}'");
}
return createdZone;
}
}
protected virtual async Task<BunnyStorageZoneModel?> 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<BunnyStorageZoneModel[]>(content);
return zones?.FirstOrDefault(x => x.Name.Equals(containerName, StringComparison.OrdinalIgnoreCase) && !x.Deleted);
}
}
}

6
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);
}

11
framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs

@ -0,0 +1,11 @@
using System.Threading.Tasks;
using BunnyCDN.Net.Storage;
namespace Volo.Abp.BlobStoring.Bunny;
public interface IBunnyClientFactory
{
Task<BunnyCDNStorage> CreateAsync(string accessKey, string containerName, string region = "de");
Task EnsureStorageZoneExistsAsync(string accessKey, string containerName, string region = "de", bool createIfNotExists = false);
}

3
framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg

@ -0,0 +1,3 @@
{
"role": "lib.test"
}

19
framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.test.props" />
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace />
<UserSecretsId>9f0d2c00-80c1-435b-bfab-2c39c8249091</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Volo.Abp.BlobStoring.Bunny\Volo.Abp.BlobStoring.Bunny.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" />
<ProjectReference Include="..\AbpTestBase\AbpTestBase.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<ProjectReference Include="..\Volo.Abp.BlobStoring.Tests\Volo.Abp.BlobStoring.Tests.csproj" />
</ItemGroup>
</Project>

19
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<AbpBlobStoringBunnyTestCommonModule>
{
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
{
options.UseAutofac();
}
}
public class AbpBlobStoringBunnyTestBase : AbpIntegratedTest<AbpBlobStoringBunnyTestModule>
{
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
{
options.UseAutofac();
}
}

60
framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs

@ -0,0 +1,60 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute.Extensions;
using Volo.Abp.Modularity;
using Volo.Abp.Threading;
namespace Volo.Abp.BlobStoring.Bunny;
/// <summary>
/// This module will not try to connect to Bunny.
/// </summary>
[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<AbpBlobStoringOptions>(options =>
{
options.Containers.ConfigureAll((containerName, containerConfiguration) =>
{
containerConfiguration.UseBunny(bunny =>
{
bunny.AccessKey = accessKey;
bunny.Region = region;
bunny.CreateContainerIfNotExists = true;
bunny.ContainerName = _randomContainerName;
_configuration = bunny;
});
});
});
}
}

12
framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs

@ -0,0 +1,12 @@
namespace Volo.Abp.BlobStoring.Bunny;
/*
//Please set the correct connection string in secrets.json and continue the test.
public class BunnyBlobContainer_Tests : BlobContainer_Tests<AbpBlobStoringBunnyTestModule>
{
public BunnyBlobContainer_Tests()
{
}
}
*/

56
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<IBunnyBlobNameCalculator>();
_currentTenant = GetRequiredService<ICurrentTenant>();
}
[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
);
}
}

57
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<IBlobNamingNormalizer>();
}
[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<AbpException>(()=>
{
filename = _blobNamingNormalizer.NormalizeContainerName(filename);
});
}
[Fact]
public void NormalizeContainerName_Max_Length()
{
var longName = new string('a', 65); // 65 characters
var exception = Assert.Throws<AbpException>(() =>
_blobNamingNormalizer.NormalizeContainerName(longName)
);
}
[Fact]
public void NormalizeContainerName_Dots()
{
var filename = ".this..is.-.my.container....name.";
filename = _blobNamingNormalizer.NormalizeContainerName(filename);
filename.ShouldBe("thisis-mycontainername");
}
}

1
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",

Loading…
Cancel
Save