mirror of https://github.com/abpframework/abp.git
25 changed files with 1001 additions and 0 deletions
@ -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. |
|||
@ -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,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> |
|||
@ -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) |
|||
{ |
|||
} |
|||
} |
|||
@ -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 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; |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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,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) { } |
|||
} |
|||
@ -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,6 @@ |
|||
namespace Volo.Abp.BlobStoring.Bunny; |
|||
|
|||
public interface IBunnyBlobNameCalculator |
|||
{ |
|||
string Calculate(BlobProviderArgs args); |
|||
} |
|||
@ -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,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); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
namespace Volo.Abp.BlobStoring.Bunny; |
|||
@ -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