diff --git a/Directory.Packages.props b/Directory.Packages.props index 251b2cf00d4..9c944083b95 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/docs/en/framework/infrastructure/blob-storing/bunny.md b/docs/en/framework/infrastructure/blob-storing/bunny.md new file mode 100644 index 00000000000..4c5fb5ef0f1 --- /dev/null +++ b/docs/en/framework/infrastructure/blob-storing/bunny.md @@ -0,0 +1,64 @@ +# BLOB Storing Bunny Provider + +BLOB Storing Bunny Provider can store BLOBs in [bunny.net Storage](https://bunny.net/storage/). + +> Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. This document only covers how to configure containers to use a Bunny BLOB as the storage provider. + +## Installation + +Use the ABP CLI to add [Volo.Abp.BlobStoring.Bunny](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Bunny) NuGet package to your project: + +* Install the [ABP CLI](../../../cli) if you haven't installed before. +* Open a command line (terminal) in the directory of the `.csproj` file you want to add the `Volo.Abp.BlobStoring.Bunny` package. +* Run `abp add-package Volo.Abp.BlobStoring.Bunny` command. + +If you want to do it manually, install the [Volo.Abp.BlobStoring.Bunny](https://www.nuget.org/packages/Volo.Abp.BlobStoring.Bunny) NuGet package to your project and add `[DependsOn(typeof(AbpBlobStoringBunnyModule))]` to the [ABP module](../../architecture/modularity/basics.md) class inside your project. + +## Configuration + +Configuration is done in the `ConfigureServices` method of your [module](../../architecture/modularity/basics.md) class, as explained in the [BLOB Storing document](../blob-storing). + +**Example: Configure to use the Bunny storage provider by default** + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseBunny(Bunny => + { + Bunny.AccessKey = "your Bunny account access key"; + Bunny.Region = "the code of the main storage zone region"; // "de" is the default value + Bunny.ContainerName = "your bunny storage zone name"; + Bunny.CreateContainerIfNotExists = true; + }); + }); +}); + +```` + +> See the [BLOB Storing document](../blob-storing) to learn how to configure this provider for a specific container. + +### Options + +* **AccessKey** (string): Bunny Account Access Key. [Where do I find my Access key?](https://support.bunny.net/hc/en-us/articles/360012168840-Where-do-I-find-my-API-key) +* **Region** (string?): The code of the main storage zone region (Possible values: DE, NY, LA, SG). +* **ContainerName** (string): You can specify the container name in Bunny. If this is not specified, it uses the name of the BLOB container defined with the `BlobContainerName` attribute (see the [BLOB storing document](../blob-storing)). Please note that Bunny has some **rules for naming containers**: + * Storage Zone names must be a globaly unique. + * Storage Zone names must be between **4** and **64** characters long. + * Storage Zone names can consist only of **lowercase** letters, numbers, and hyphens (-). +* **CreateContainerIfNotExists** (bool): Default value is `false`, If a container does not exist in Bunny, `BunnyBlobProvider` will try to create it. + +## Bunny Blob Name Calculator + +Bunny Blob Provider organizes BLOB name and implements some conventions. The full name of a BLOB is determined by the following rules by default: + +* Appends `host` string if [current tenant](../../architecture/multi-tenancy) is `null` (or multi-tenancy is disabled for the container - see the [BLOB Storing document](../blob-storing) to learn how to disable multi-tenancy for a container). +* Appends `tenants/` string if current tenant is not `null`. +* Appends the BLOB name. + +## Other Services + +* `BunnyBlobProvider` is the main service that implements the Bunny BLOB storage provider, if you want to override/replace it via [dependency injection](../../fundamentals/dependency-injection.md) (don't replace `IBlobProvider` interface, but replace `BunnyBlobProvider` class). +* `IBunnyBlobNameCalculator` is used to calculate the full BLOB name (that is explained above). It is implemented by the `DefaultBunnyBlobNameCalculator` by default. +* `IBunnyClientFactory` is implemented by `DefaultBunnyClientFactory` by default. You can override/replace it,if you want customize. diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md index f95a90e4107..676757ad805 100644 --- a/docs/en/framework/infrastructure/blob-storing/index.md +++ b/docs/en/framework/infrastructure/blob-storing/index.md @@ -23,6 +23,7 @@ The ABP has already the following storage provider implementations: * [Minio](./minio.md): Stores BLOBs on the [MinIO Object storage](https://min.io/). * [Aws](./aws.md): Stores BLOBs on the [Amazon Simple Storage Service](https://aws.amazon.com/s3/). * [Google](./google.md): Stores BLOBs on the [Google Cloud Storage](https://cloud.google.com/storage). +* [Bunny](./bunny.md): Stores BLOBs on the [Bunny.net Storage](https://bunny.net/storage/). More providers will be implemented by the time. You can [request](https://github.com/abpframework/abp/issues/new) it for your favorite provider or [create it yourself](./custom-provider.md) and [contribute](../../../contribution) to the ABP. diff --git a/framework/Volo.Abp.sln b/framework/Volo.Abp.sln index c3ca5b63b25..ca471bb304e 100644 --- a/framework/Volo.Abp.sln +++ b/framework/Volo.Abp.sln @@ -470,6 +470,7 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling", "src\Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling\Volo.Abp.AspNetCore.Components.WebAssembly.Theming.Bundling.csproj", "{2F9BA650-395C-4BE0-8CCB-9978E753562A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.AspNetCore.Components.MauiBlazor.Theming.Bundling", "src\Volo.Abp.AspNetCore.Components.MauiBlazor.Theming.Bundling\Volo.Abp.AspNetCore.Components.MauiBlazor.Theming.Bundling.csproj", "{7ADB6D92-82CC-4A2A-8BCF-FC6C6308796D}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Google", "src\Volo.Abp.BlobStoring.Google\Volo.Abp.BlobStoring.Google.csproj", "{DEEB5200-BBF9-464D-9B7E-8FC035A27E94}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Google.Tests", "test\Volo.Abp.BlobStoring.Google.Tests\Volo.Abp.BlobStoring.Google.Tests.csproj", "{40FB8907-9CF7-44D0-8B5F-538AC6DAF8B9}" @@ -480,6 +481,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.Sms.TencentCloud", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.Sms.TencentCloud.Tests", "test\Volo.Abp.Sms.TencenCloud.Tests\Volo.Abp.Sms.TencentCloud.Tests.csproj", "{C753DDD6-5699-45F8-8669-08CE0BB816DE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Bunny", "src\Volo.Abp.BlobStoring.Bunny\Volo.Abp.BlobStoring.Bunny.csproj", "{1BBCBA72-CDB6-4882-96EE-D4CD149433A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volo.Abp.BlobStoring.Bunny.Tests", "test\Volo.Abp.BlobStoring.Bunny.Tests\Volo.Abp.BlobStoring.Bunny.Tests.csproj", "{BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1434,6 +1439,14 @@ Global {C753DDD6-5699-45F8-8669-08CE0BB816DE}.Debug|Any CPU.Build.0 = Debug|Any CPU {C753DDD6-5699-45F8-8669-08CE0BB816DE}.Release|Any CPU.ActiveCfg = Release|Any CPU {C753DDD6-5699-45F8-8669-08CE0BB816DE}.Release|Any CPU.Build.0 = Release|Any CPU + {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BBCBA72-CDB6-4882-96EE-D4CD149433A2}.Release|Any CPU.Build.0 = Release|Any CPU + {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1676,6 +1689,8 @@ Global {E50739A7-5E2F-4EB5-AEA9-554115CB9613} = {447C8A77-E5F0-4538-8687-7383196D04EA} {BE7109C5-7368-4688-8557-4A15D3F4776A} = {5DF0E140-0513-4D0D-BE2E-3D4D85CD70E6} {C753DDD6-5699-45F8-8669-08CE0BB816DE} = {447C8A77-E5F0-4538-8687-7383196D04EA} + {1BBCBA72-CDB6-4882-96EE-D4CD149433A2} = {5DF0E140-0513-4D0D-BE2E-3D4D85CD70E6} + {BC4BB2D6-DFD8-4190-AAC3-32C0A7A8E915} = {447C8A77-E5F0-4538-8687-7383196D04EA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {BB97ECF4-9A84-433F-A80B-2A3285BDD1D5} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml new file mode 100644 index 00000000000..1715698ccd2 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd new file mode 100644 index 00000000000..ffa6fc4b782 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg new file mode 100644 index 00000000000..f4bad072d26 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg @@ -0,0 +1,3 @@ +{ + "role": "lib.framework" +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json new file mode 100644 index 00000000000..b9e8bbba1b8 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.abppkg.analyze.json @@ -0,0 +1,68 @@ +{ + "name": "Volo.Abp.BlobStoring.Bunny", + "hash": "", + "contents": [ + { + "namespace": "Volo.Abp.BlobStoring.Bunny", + "dependsOnModules": [ + { + "declaringAssemblyName": "Volo.Abp.BlobStoring", + "namespace": "Volo.Abp.BlobStoring", + "name": "AbpBlobStoringModule" + }, + { + "declaringAssemblyName": "Volo.Abp.Caching", + "namespace": "Volo.Abp.Caching", + "name": "AbpCachingModule" + } + ], + "implementingInterfaces": [ + { + "name": "IAbpModule", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IAbpModule" + }, + { + "name": "IOnPreApplicationInitialization", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IOnPreApplicationInitialization" + }, + { + "name": "IOnApplicationInitialization", + "namespace": "Volo.Abp", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.IOnApplicationInitialization" + }, + { + "name": "IOnPostApplicationInitialization", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IOnPostApplicationInitialization" + }, + { + "name": "IOnApplicationShutdown", + "namespace": "Volo.Abp", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.IOnApplicationShutdown" + }, + { + "name": "IPreConfigureServices", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IPreConfigureServices" + }, + { + "name": "IPostConfigureServices", + "namespace": "Volo.Abp.Modularity", + "declaringAssemblyName": "Volo.Abp.Core", + "fullName": "Volo.Abp.Modularity.IPostConfigureServices" + } + ], + "contentType": "abpModule", + "name": "AbpBlobStoringBunnyModule", + "summary": null + } + ] +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj new file mode 100644 index 00000000000..2cd9c74ac63 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo.Abp.BlobStoring.Bunny.csproj @@ -0,0 +1,26 @@ + + + + + + + netstandard2.0;netstandard2.1;net8.0;net9.0 + enable + Nullable + false + false + false + + + + + + + + + + + + + + diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs new file mode 100644 index 00000000000..3fe814f2f0c --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/AbpBunnyBlobStoringModule .cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Caching; +using Volo.Abp.Modularity; + +namespace Volo.Abp.BlobStoring.Bunny; + +[DependsOn( + typeof(AbpBlobStoringModule), + typeof(AbpCachingModule))] +public class AbpBlobStoringBunnyModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddHttpClient(); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs new file mode 100644 index 00000000000..1e10d4caa26 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyApiException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class BunnyApiException : Exception +{ + public BunnyApiException(string message) + : base(message) + { + + } + + public BunnyApiException(string message, Exception innerException) + : base(message, innerException) + { + + } +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs new file mode 100644 index 00000000000..03afe1c36dd --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainerConfigurationExtensions.cs @@ -0,0 +1,24 @@ +using System; + +namespace Volo.Abp.BlobStoring.Bunny; + +public static class BunnyBlobContainerConfigurationExtensions +{ + public static BunnyBlobProviderConfiguration GetBunnyConfiguration( + this BlobContainerConfiguration containerConfiguration) + { + return new BunnyBlobProviderConfiguration(containerConfiguration); + } + + public static BlobContainerConfiguration UseBunny( + this BlobContainerConfiguration containerConfiguration, + Action bunnyConfigureAction) + { + containerConfiguration.ProviderType = typeof(BunnyBlobProvider); + containerConfiguration.NamingNormalizers.TryAdd(); + + bunnyConfigureAction(new BunnyBlobProviderConfiguration(containerConfiguration)); + + return containerConfiguration; + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs new file mode 100644 index 00000000000..74c436e2e9f --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobNamingNormalizer.cs @@ -0,0 +1,51 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Localization; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class BunnyBlobNamingNormalizer : IBlobNamingNormalizer, ITransientDependency +{ + private 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; + } + } +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs new file mode 100644 index 00000000000..6c06bc3f95e --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProvider.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using BunnyCDN.Net.Storage; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class BunnyBlobProvider : BlobProviderBase, ITransientDependency +{ + protected IBunnyBlobNameCalculator BunnyBlobNameCalculator { get; } + protected IBlobNormalizeNamingService BlobNormalizeNamingService { get; } + protected IBunnyClientFactory BunnyClientFactory { get; } + + public BunnyBlobProvider( + IBunnyBlobNameCalculator bunnyBlobNameCalculator, + IBlobNormalizeNamingService blobNormalizeNamingService, + IBunnyClientFactory bunnyClientFactory) + { + BunnyBlobNameCalculator = bunnyBlobNameCalculator; + BlobNormalizeNamingService = blobNormalizeNamingService; + BunnyClientFactory = bunnyClientFactory; + } + + public async override Task SaveAsync(BlobProviderSaveArgs args) + { + var configuration = args.Configuration.GetBunnyConfiguration(); + var containerName = GetContainerName(args); + var blobName = BunnyBlobNameCalculator.Calculate(args); + + await ValidateContainerExistsAsync(containerName, configuration); + + var bunnyStorage = await GetBunnyCDNStorageAsync(args); + + if (!args.OverrideExisting && await BlobExistsAsync(bunnyStorage, containerName, blobName)) + { + throw new BlobAlreadyExistsException( + $"Blob '{args.BlobName}' already exists in container '{containerName}'. " + + $"Set {nameof(args.OverrideExisting)} to true to overwrite."); + } + + using var memoryStream = new MemoryStream(); + await args.BlobStream.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + await bunnyStorage.UploadAsync(memoryStream, $"{containerName}/{blobName}"); + } + + public async override Task 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 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 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 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 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); + } + } +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs new file mode 100644 index 00000000000..0897c7f2b86 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfiguration.cs @@ -0,0 +1,40 @@ +namespace Volo.Abp.BlobStoring.Bunny; + +public class BunnyBlobProviderConfiguration +{ + public string? Region { + get => _containerConfiguration.GetConfigurationOrDefault(BunnyBlobProviderConfigurationNames.Region, "de"); + set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.Region, value); + } + + /// + /// This name may only contain lowercase letters, numbers, and hyphens. (no spaces) + /// The name must also be between 4 and 64 characters long. + /// The name must be globaly unique + /// If this parameter is not specified, the ContainerName of the will be used. + /// + public string? ContainerName { + get => _containerConfiguration.GetConfigurationOrDefault(BunnyBlobProviderConfigurationNames.ContainerName); + set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.ContainerName, value); + } + + /// + /// Default value: false. + /// + public bool CreateContainerIfNotExists { + get => _containerConfiguration.GetConfigurationOrDefault(BunnyBlobProviderConfigurationNames.CreateContainerIfNotExists, false); + set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.CreateContainerIfNotExists, value); + } + + public string AccessKey { + get => _containerConfiguration.GetConfiguration(BunnyBlobProviderConfigurationNames.AccessKey); + set => _containerConfiguration.SetConfiguration(BunnyBlobProviderConfigurationNames.AccessKey, value); + } + + private readonly BlobContainerConfiguration _containerConfiguration; + + public BunnyBlobProviderConfiguration(BlobContainerConfiguration containerConfiguration) + { + _containerConfiguration = containerConfiguration; + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs new file mode 100644 index 00000000000..09d79076a63 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyBlobProviderConfigurationNames.cs @@ -0,0 +1,15 @@ +namespace Volo.Abp.BlobStoring.Bunny; + +public static class BunnyBlobProviderConfigurationNames +{ + // The primary region for the storage zone (e.g., DE, NY, etc.) + public const string Region = "Bunny.Region"; + + // The name of the storage zone + public const string ContainerName = "Bunny.ContainerName"; + + // The API access key for the bunny.net account + public const string AccessKey = "Bunny.AccessKey"; + + public const string CreateContainerIfNotExists = "Bunny.CreateContainerIfNotExists"; +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs new file mode 100644 index 00000000000..e7185319274 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/BunnyStorageZoneModel.cs @@ -0,0 +1,17 @@ +using System; + +namespace Volo.Abp.BlobStoring.Bunny; + +[Serializable] +public class BunnyStorageZoneModel +{ + public int Id { get; set; } + + public string Password { get; set; } = null!; + + public string Name { get; set; } = null!; + + public string? Region { get; set; } + + public bool Deleted { get; set; } +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs new file mode 100644 index 00000000000..f06acb8c142 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNameCalculator.cs @@ -0,0 +1,21 @@ +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class DefaultBunnyBlobNameCalculator : IBunnyBlobNameCalculator, ITransientDependency +{ + protected ICurrentTenant CurrentTenant { get; } + + public DefaultBunnyBlobNameCalculator(ICurrentTenant currentTenant) + { + CurrentTenant = currentTenant; + } + + public virtual string Calculate(BlobProviderArgs args) + { + return CurrentTenant.Id == null + ? $"host/{args.BlobName}" + : $"tenants/{CurrentTenant.Id.Value.ToString("D")}/{args.BlobName}"; + } +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs new file mode 100644 index 00000000000..d22cff0e0a8 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/DefaultBunnyClientFactory.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using BunnyCDN.Net.Storage; +using Microsoft.Extensions.Caching.Distributed; +using Volo.Abp.Caching; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class DefaultBunnyClientFactory : IBunnyClientFactory, ITransientDependency +{ + private readonly IDistributedCache _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 cache, + IStringEncryptionService stringEncryptionService) + { + _cache = cache; + _httpClientFactory = httpClient; + _stringEncryptionService = stringEncryptionService; + } + + public virtual async Task CreateAsync(string accessKey, string containerName, string region = "de") + { + var cacheKey = $"{CacheKeyPrefix}{containerName}"; + var storageZoneInfo = await _cache.GetOrAddAsync( + cacheKey, + async () => { + var result = await GetStorageZoneAsync(accessKey, containerName); + if (result == null) + { + throw new AbpException($"Storage zone '{containerName}' not found"); + } + + // 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 CreateStorageZoneAsync( + string accessKey, + string containerName, + string region) + { + using (var client = _httpClientFactory.CreateClient("BunnyApiClient")) + { + client.DefaultRequestHeaders.Add("AccessKey", accessKey); + + var payload = new Dictionary + { + { "Name", containerName }, + { "Region", region }, + { "ZoneTier", 0 } + }; + + var content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); + + var response = await client.PostAsync( + "https://api.bunny.net/storagezone", + content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new AbpException( + $"Failed to create storage zone '{containerName}'. " + + $"Status: {response.StatusCode}, Error: {errorContent}"); + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var createdZone = JsonSerializer.Deserialize(responseContent); + + if (createdZone == null) + { + throw new AbpException($"Failed to deserialize the created storage zone response for '{containerName}'"); + } + + return createdZone; + } + } + + protected virtual async Task GetStorageZoneAsync(string accessKey, string containerName) + { + using (var client = _httpClientFactory.CreateClient("BunnyApiClient")) + { + client.DefaultRequestHeaders.Add("AccessKey", accessKey); + var response = await client.GetAsync("https://api.bunny.net/storagezone"); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + var zones = JsonSerializer.Deserialize(content); + + return zones?.FirstOrDefault(x => x.Name.Equals(containerName, StringComparison.OrdinalIgnoreCase) && !x.Deleted); + } + } +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs new file mode 100644 index 00000000000..34a18aca469 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyBlobNameCalculator.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.BlobStoring.Bunny; + +public interface IBunnyBlobNameCalculator +{ + string Calculate(BlobProviderArgs args); +} diff --git a/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs new file mode 100644 index 00000000000..7e5db5ed41b --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring.Bunny/Volo/Abp/BlobStoring/Bunny/IBunnyClientFactory.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using BunnyCDN.Net.Storage; + +namespace Volo.Abp.BlobStoring.Bunny; + +public interface IBunnyClientFactory +{ + Task CreateAsync(string accessKey, string containerName, string region = "de"); + + Task EnsureStorageZoneExistsAsync(string accessKey, string containerName, string region = "de", bool createIfNotExists = false); +} diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg new file mode 100644 index 00000000000..a686451fbc0 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.abppkg @@ -0,0 +1,3 @@ +{ + "role": "lib.test" +} \ No newline at end of file diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj new file mode 100644 index 00000000000..408f630fe29 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo.Abp.BlobStoring.Bunny.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + net9.0 + + 9f0d2c00-80c1-435b-bfab-2c39c8249091 + + + + + + + + + + + diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs new file mode 100644 index 00000000000..4f37cfca91e --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestBase.cs @@ -0,0 +1,19 @@ +using Volo.Abp.Testing; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class AbpBlobStoringBunnyTestCommonBase : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } +} + +public class AbpBlobStoringBunnyTestBase : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs new file mode 100644 index 00000000000..73066291ed6 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/AbpBlobStoringBunnyTestModule.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute.Extensions; +using Volo.Abp.Modularity; +using Volo.Abp.Threading; + +namespace Volo.Abp.BlobStoring.Bunny; + +/// +/// This module will not try to connect to Bunny. +/// +[DependsOn( + typeof(AbpBlobStoringBunnyModule), + typeof(AbpBlobStoringTestModule) +)] +public class AbpBlobStoringBunnyTestCommonModule : AbpModule +{ +} + +[DependsOn( + typeof(AbpBlobStoringBunnyTestCommonModule) +)] +public class AbpBlobStoringBunnyTestModule : AbpModule +{ + private const string UserSecretsId = "9f0d2c00-80c1-435b-bfab-2c39c8249091"; + + private readonly string _randomContainerName = "abp-bunny-test-container-" + Guid.NewGuid().ToString("N"); + + private BunnyBlobProviderConfiguration _configuration; + + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.ReplaceConfiguration(ConfigurationHelper.BuildConfiguration(builderAction: builder => + { + builder.AddUserSecrets(UserSecretsId); + })); + + var configuration = context.Services.GetConfiguration(); + var accessKey = configuration["Bunny:AccessKey"]; + var region = configuration["Bunny:Region"]; + + Configure(options => + { + options.Containers.ConfigureAll((containerName, containerConfiguration) => + { + containerConfiguration.UseBunny(bunny => + { + bunny.AccessKey = accessKey; + bunny.Region = region; + bunny.CreateContainerIfNotExists = true; + bunny.ContainerName = _randomContainerName; + + _configuration = bunny; + }); + }); + }); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs new file mode 100644 index 00000000000..84b255e4c55 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobContainer_Tests.cs @@ -0,0 +1,12 @@ +namespace Volo.Abp.BlobStoring.Bunny; + +/* +//Please set the correct connection string in secrets.json and continue the test. +public class BunnyBlobContainer_Tests : BlobContainer_Tests +{ + public BunnyBlobContainer_Tests() + { + + } +} +*/ diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs new file mode 100644 index 00000000000..fc3b2d8365e --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/BunnyBlobNameCalculator_Tests.cs @@ -0,0 +1,56 @@ +using System; +using Shouldly; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class BunnyBlobNameCalculatorTests : AbpBlobStoringBunnyTestCommonBase +{ + private readonly IBunnyBlobNameCalculator _calculator; + private readonly ICurrentTenant _currentTenant; + + private const string BunnyContainerName = "/"; + private const string BunnySeparator = "/"; + + public BunnyBlobNameCalculatorTests() + { + _calculator = GetRequiredService(); + _currentTenant = GetRequiredService(); + } + + [Fact] + public void Default_Settings() + { + _calculator.Calculate( + GetArgs("my-container", "my-blob") + ).ShouldBe($"host{BunnySeparator}my-blob"); + } + + [Fact] + public void Default_Settings_With_TenantId() + { + var tenantId = Guid.NewGuid(); + + using (_currentTenant.Change(tenantId)) + { + _calculator.Calculate( + GetArgs("my-container", "my-blob") + ).ShouldBe($"tenants{BunnySeparator}{tenantId:D}{BunnySeparator}my-blob"); + } + } + + private static BlobProviderArgs GetArgs( + string containerName, + string blobName) + { + return new BlobProviderGetArgs( + containerName, + new BlobContainerConfiguration().UseBunny(x => + { + x.ContainerName = containerName; + }), + blobName + ); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs new file mode 100644 index 00000000000..c4d3cd68782 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Bunny.Tests/Volo/Abp/BlobStoring/Bunny/DefaultBunnyBlobNamingNormalizerProvider_Tests.cs @@ -0,0 +1,57 @@ +using Shouldly; +using Xunit; + +namespace Volo.Abp.BlobStoring.Bunny; + +public class DefaultBunnyBlobNamingNormalizerProviderTests : AbpBlobStoringBunnyTestCommonBase +{ + private readonly IBlobNamingNormalizer _blobNamingNormalizer; + + public DefaultBunnyBlobNamingNormalizerProviderTests() + { + _blobNamingNormalizer = GetRequiredService(); + } + + [Fact] + public void NormalizeContainerName_Lowercase() + { + var filename = "ThisIsMyContainerName"; + filename = _blobNamingNormalizer.NormalizeContainerName(filename); + filename.ShouldBe("thisismycontainername"); + } + + [Fact] + public void NormalizeContainerName_Only_Letters_Numbers_Dash_Dots() + { + var filename = ",./this-i,/s-my-c,/ont,/ai+*/=!@#$n^&*er.name+/"; + filename = _blobNamingNormalizer.NormalizeContainerName(filename); + filename.ShouldBe("this-is-my-containername"); + } + + [Fact] + public void NormalizeContainerName_Min_Length() + { + var filename = "a"; + Assert.Throws(()=> + { + filename = _blobNamingNormalizer.NormalizeContainerName(filename); + }); + } + + [Fact] + public void NormalizeContainerName_Max_Length() + { + var longName = new string('a', 65); // 65 characters + var exception = Assert.Throws(() => + _blobNamingNormalizer.NormalizeContainerName(longName) + ); + } + + [Fact] + public void NormalizeContainerName_Dots() + { + var filename = ".this..is.-.my.container....name."; + filename = _blobNamingNormalizer.NormalizeContainerName(filename); + filename.ShouldBe("thisis-mycontainername"); + } +} diff --git a/nupkg/common.ps1 b/nupkg/common.ps1 index 4233ce7b594..e60f3745666 100644 --- a/nupkg/common.ps1 +++ b/nupkg/common.ps1 @@ -155,6 +155,7 @@ $projects = ( "framework/src/Volo.Abp.BlobStoring.Minio", "framework/src/Volo.Abp.BlobStoring.Aws", "framework/src/Volo.Abp.BlobStoring.Google", + "framework/src/Volo.Abp.BlobStoring.Bunny", "framework/src/Volo.Abp.Caching", "framework/src/Volo.Abp.Caching.StackExchangeRedis", "framework/src/Volo.Abp.Castle.Core",