Browse Source

implement Bunny.net blob storage provider

pull/22004/head
Suhaib 1 year ago
parent
commit
f0bf9ff662
  1. 1
      Directory.Packages.props
  2. 63
      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. 24
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj
  10. 11
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs
  11. 24
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs
  12. 51
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs
  13. 168
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs
  14. 40
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs
  15. 15
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs
  16. 229
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyClient.cs
  17. 21
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs
  18. 6
      framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs
  19. 3
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg
  20. 19
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj
  21. 19
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs
  22. 73
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs
  23. 1
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs
  24. 56
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs
  25. 57
      framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs

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.2" />

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

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

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

@ -0,0 +1,24 @@
<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" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BunnyCDN.Net.Storage" />
</ItemGroup>
</Project>

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

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

168
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<bool> 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<bool> 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<Stream?> 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<bool> 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);
}
}
}
}

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

229
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<bool> 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<List<StorageZone>>(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<StorageZone> 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<List<StorageZone>>(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<bool> DeleteObjectAsync(string path) => await (await GetBunnyCDNStorage()).DeleteObjectAsync(path);
public async Task<List<StorageObject>> GetStorageObjectsAsync(string path) => await (await GetBunnyCDNStorage()).GetStorageObjectsAsync(path);
public async Task<Stream> 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<StorageZone> 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<List<StorageZone>>(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<StorageZone> 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<string, object>
{
{ "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<StorageZone>(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<BunnyCDNStorage> 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) { }
}

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

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

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

73
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;
/// <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;
});
});
});
}
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);
}
}
}

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

@ -0,0 +1 @@
namespace Volo.Abp.BlobStoring.Bunny;

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");
}
}
Loading…
Cancel
Save