mirror of https://github.com/abpframework/abp.git
committed by
GitHub
29 changed files with 976 additions and 0 deletions
@ -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. |
|||
@ -0,0 +1,3 @@ |
|||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> |
|||
<ConfigureAwait ContinueOnCapturedContext="false" /> |
|||
</Weavers> |
|||
@ -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> |
|||
@ -0,0 +1,3 @@ |
|||
{ |
|||
"role": "lib.framework" |
|||
} |
|||
@ -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 |
|||
} |
|||
] |
|||
} |
|||
@ -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> |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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) |
|||
{ |
|||
|
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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"; |
|||
} |
|||
@ -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; } |
|||
} |
|||
@ -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}"; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
namespace Volo.Abp.BlobStoring.Bunny; |
|||
|
|||
public interface IBunnyBlobNameCalculator |
|||
{ |
|||
string Calculate(BlobProviderArgs args); |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
{ |
|||
"role": "lib.test" |
|||
} |
|||
@ -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> |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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; |
|||
}); |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
@ -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() |
|||
{ |
|||
|
|||
} |
|||
} |
|||
*/ |
|||
@ -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 |
|||
); |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue