Skip to content

Commit

Permalink
feat(templates): add support for cloud based blob storages in Boilerp…
Browse files Browse the repository at this point in the history
…late #7891 (#7892)
  • Loading branch information
ysmoradi committed Jun 30, 2024
1 parent 849f805 commit 345d432
Show file tree
Hide file tree
Showing 8 changed files with 88 additions and 49 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/admin-sample.cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
cd src/Templates/Boilerplate && dotnet build -c Release
dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
dotnet new install Bit.Boilerplate.0.0.0.nupkg
cd ../../../ && dotnet new bit-bp --name AdminPanel --database SqlServer --sample Admin --appInsights --serverUrl adminpanel.bitplatform.dev
cd ../../../ && dotnet new bit-bp --name AdminPanel --database SqlServer --sample Admin --appInsights --serverUrl adminpanel.bitplatform.dev --filesStorage AzureBlobStorage
- name: Update appsettings.json api server address
uses: devops-actions/variable-substitution@v1.2
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/todo-sample.cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
cd src/Templates/Boilerplate && dotnet build -c Release
dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
dotnet new install Bit.Boilerplate.0.0.0.nupkg
cd ../../../ && dotnet new bit-bp --name TodoSample --database SqlServer --sample Todo --appInsights --serverUrl todo.bitplatform.dev
cd ../../../ && dotnet new bit-bp --name TodoSample --database SqlServer --sample Todo --appInsights --serverUrl todo.bitplatform.dev --filesStorage AzureBlobStorage
- name: Update appsettings.json api server address
uses: devops-actions/variable-substitution@v1.2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,32 @@
},
{
"choice": "Other",
"description": "Other"
"description": "You can install and configure any database supported by ef core (https://learn.microsoft.com/en-us/ef/core/providers)"
}
],
"description": "Boilerplate's API uses EF Core. You can use SQL Server, SQLite, or any other EF Core provider."
},
"filesStorage": {
"displayName": "Files storage",
"type": "parameter",
"datatype": "choice",
"defaultValue": "Local",
"choices": [
{
"choice": "Local",
"description": "Use either the local App_Data folder or the /container_volume for Docker containers."
},
{
"choice": "AzureBlobStorage",
"description": "Azure blob storage"
},
{
"choice": "Other",
"description": "You can install and configure any storage supported by fluent storage (https://github.com/robinrodricks/FluentStorage/wiki/Blob-Storage)"
}
],
"description": "Boilerplate's API uses Fluent Storage. You can use any other fluent storage provider (https://github.com/robinrodricks/FluentStorage/wiki/Blob-Storage)"
},
"captcha": {
"displayName": "Captcha",
"type": "parameter",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public class IdentitySettings : IdentityOptions

[Required]
public string Audience { get; set; } = default!;

/// <summary>
/// To either confirm and/or change email
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
<PackageReference Include="Bit.SourceGenerators" Version="8.10.0-pre-03" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.6" />
<PackageReference Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" />
<PackageReference Include="QRCoder" Version="1.5.1" />
</ItemGroup>

<ItemGroup>
Expand All @@ -20,8 +19,11 @@
</ItemGroup>

<ItemGroup Condition=" '$(api)' == 'true' OR '$(api)' == '' ">
<PackageReference Include="QRCoder" Version="1.6.0" />
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="13.9.1" />
<PackageReference Include="FluentEmail.Smtp" Version="3.0.2" />
<PackageReference Include="FluentStorage" Version="5.4.3" />
<PackageReference Condition=" '$(filesStorage)' == 'AzureBlobStorage' OR '$(filesStorage)' == '' " Include="FluentStorage.Azure.Blobs" Version="5.2.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
Expand All @@ -38,7 +40,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.Twitter" Version="8.0.6" />
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="8.1.0" />
<PackageReference Include="Riok.Mapperly" Version="3.6.0" />
<PackageReference Include="Twilio" Version="7.2.0" />
<PackageReference Include="Twilio" Version="7.2.1" />

<Using Include="Microsoft.EntityFrameworkCore.Migrations" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using Boilerplate.Server.Models.Identity;
using Microsoft.AspNetCore.StaticFiles;
using Boilerplate.Server.Models.Identity;
using FluentStorage.Blobs;
using ImageMagick;
using Microsoft.AspNetCore.StaticFiles;
using SystemFile = System.IO.File;

namespace Boilerplate.Server.Controllers;

Expand All @@ -15,6 +15,8 @@ public partial class AttachmentController : AppControllerBase

[AutoInject] private IContentTypeProvider contentTypeProvider = default!;

[AutoInject] private IBlobStorage blobStorage = default!;

[HttpPost]
[RequestSizeLimit(11 * 1024 * 1024 /*11MB*/)]
public async Task UploadProfileImage(IFormFile? file, CancellationToken cancellationToken)
Expand All @@ -30,29 +32,22 @@ public async Task UploadProfileImage(IFormFile? file, CancellationToken cancella
throw new ResourceNotFoundException();

var destFileName = $"{userId}_{file.FileName}";

var userProfileImagesDir = Path.Combine(IsRunningInsideDocker() ? "/container_volume" : Directory.GetCurrentDirectory(), AppSettings.UserProfileImagesDir);

var destFilePath = Path.Combine(userProfileImagesDir, destFileName);
var destFilePath = $"{AppSettings.UserProfileImagesDir}{destFileName}";

await using (var requestStream = file.OpenReadStream())
{
Directory.CreateDirectory(userProfileImagesDir);

await using var fileStream = SystemFile.Create(destFilePath);

await requestStream.CopyToAsync(fileStream, cancellationToken);
await blobStorage.WriteAsync(destFilePath, requestStream, cancellationToken: cancellationToken);
}

if (user.ProfileImageName is not null)
{
try
{
var oldFilePath = Path.Combine(userProfileImagesDir, user.ProfileImageName);
var oldFilePath = $"{AppSettings.UserProfileImagesDir}{user.ProfileImageName}";

if (SystemFile.Exists(oldFilePath))
if (await blobStorage.ExistsAsync(oldFilePath, cancellationToken))
{
SystemFile.Delete(oldFilePath);
await blobStorage.DeleteAsync(oldFilePath, cancellationToken);
}
}
catch
Expand All @@ -62,17 +57,18 @@ await using (var requestStream = file.OpenReadStream())
}

destFileName = destFileName.Replace(Path.GetExtension(destFileName), "_256.webp");
var resizedFilePath = Path.Combine(userProfileImagesDir, destFileName);
var resizedFilePath = $"{AppSettings.UserProfileImagesDir}{destFileName}";

try
{
using MagickImage sourceImage = new(destFilePath);
await using var destFileStream = await blobStorage.OpenReadAsync(destFilePath, cancellationToken);
using MagickImage sourceImage = new(destFileStream);

MagickGeometry resizedImageSize = new(256, 256);

sourceImage.Resize(resizedImageSize);

sourceImage.Write(resizedFilePath, MagickFormat.WebP);
await blobStorage.WriteAsync(resizedFilePath, sourceImage.ToByteArray(MagickFormat.WebP), cancellationToken: cancellationToken);

user.ProfileImageName = destFileName;

Expand All @@ -82,19 +78,18 @@ await using (var requestStream = file.OpenReadStream())
}
catch
{
if (SystemFile.Exists(resizedFilePath))
SystemFile.Delete(resizedFilePath);
await blobStorage.DeleteAsync(resizedFilePath, cancellationToken);

throw;
}
finally
{
SystemFile.Delete(destFilePath);
await blobStorage.DeleteAsync(destFilePath, cancellationToken);
}
}

[HttpDelete]
public async Task RemoveProfileImage()
public async Task RemoveProfileImage(CancellationToken cancellationToken)
{
var userId = User.GetUserId();

Expand All @@ -103,11 +98,9 @@ public async Task RemoveProfileImage()
if (user?.ProfileImageName is null)
throw new ResourceNotFoundException();

var userProfileImageDirPath = Path.Combine(IsRunningInsideDocker() ? "/container_volume" : Directory.GetCurrentDirectory(), AppSettings.UserProfileImagesDir);
var filePath = $"{AppSettings.UserProfileImagesDir}{user.ProfileImageName}";

var filePath = Path.Combine(userProfileImageDirPath, user.ProfileImageName);

if (SystemFile.Exists(filePath) is false)
if (await blobStorage.ExistsAsync(filePath, cancellationToken) is false)
throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserImageCouldNotBeFound)]);

user.ProfileImageName = null;
Expand All @@ -116,11 +109,11 @@ public async Task RemoveProfileImage()
if (!result.Succeeded)
throw new ResourceValidationException(result.Errors.Select(err => new LocalizedString(err.Code, err.Description)).ToArray());

SystemFile.Delete(filePath);
await blobStorage.DeleteAsync(filePath, cancellationToken);
}

[HttpGet]
public async Task<IActionResult> GetProfileImage()
public async Task<IActionResult> GetProfileImage(CancellationToken cancellationToken)
{
var userId = User.GetUserId();

Expand All @@ -129,24 +122,16 @@ public async Task<IActionResult> GetProfileImage()
if (user?.ProfileImageName is null)
throw new ResourceNotFoundException();

var userProfileImageDirPath = Path.Combine(IsRunningInsideDocker() ? "/container_volume" : Directory.GetCurrentDirectory(), AppSettings.UserProfileImagesDir);

var filePath = Path.Combine(userProfileImageDirPath, user.ProfileImageName);
var filePath = $"{AppSettings.UserProfileImagesDir}{user.ProfileImageName}";

if (SystemFile.Exists(filePath) is false)
if (await blobStorage.ExistsAsync(filePath, cancellationToken) is false)
return new EmptyResult();

if (contentTypeProvider.TryGetContentType(filePath, out var contentType) is false)
{
throw new InvalidOperationException();
}

return PhysicalFile(Path.Combine(webHostEnvironment.ContentRootPath, filePath),
contentType, enableRangeProcessing: true);
}

private bool IsRunningInsideDocker()
{
return Directory.Exists("/container_volume"); // It's supposed to be a mounted volume named /container_volume
return File(await blobStorage.OpenReadAsync(filePath, cancellationToken), contentType, enableRangeProcessing: true);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
//+:cnd:noEmit
using System.IO.Compression;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Boilerplate.Client.Web;
using Boilerplate.Server.Services;
Expand All @@ -14,6 +13,8 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.AspNetCore.DataProtection;
using FluentStorage;
using FluentStorage.Blobs;
using Twilio;
//#endif

Expand Down Expand Up @@ -96,6 +97,8 @@ private static void ConfigureServices(this WebApplicationBuilder builder)
{
});
//#else
throw new NotImplementedException("Install and configure any database supported by ef core (https://learn.microsoft.com/en-us/ef/core/providers)");
//#endif
});

Expand Down Expand Up @@ -157,6 +160,31 @@ private static void ConfigureServices(this WebApplicationBuilder builder)
TwilioClient.Init(appSettings.Sms.AccountSid, appSettings.Sms.AuthToken);
}

//#if (filesStorage == "Local")
services.TryAddSingleton(sp =>
{
var isRunningInsideDocker = Directory.Exists("/container_volume"); // It's supposed to be a mounted volume named /container_volume
var attachmentsDirPath = Path.Combine(isRunningInsideDocker ? "/container_volume" : Directory.GetCurrentDirectory(), "App_Data");
Directory.CreateDirectory(attachmentsDirPath);
var connectionString = $"disk://path={attachmentsDirPath}";
return StorageFactory.Blobs.FromConnectionString(connectionString);
});
//#elif (filesStorage == "AzureBlobStorage")
services.TryAddSingleton(sp =>
{
var azureBlobStorageSasUrl = configuration.GetConnectionString("AzureBlobStorageSasUrl");
return (IBlobStorage)(azureBlobStorageSasUrl is "emulator"
? StorageFactory.Blobs.AzureBlobStorageWithLocalEmulator()
: StorageFactory.Blobs.AzureBlobStorageWithSas(azureBlobStorageSasUrl));
});
//#else
services.TryAddSingleton<IBlobStorage>(sp =>
{
// Note that FluentStorage.AWS can be used with any S3 compatible S3 implementation such as Digital Ocean's Spaces Object Storage.
throw new NotImplementedException("Install and configure any storage supported by fluent storage (https://github.com/robinrodricks/FluentStorage/wiki/Blob-Storage)");
});
//#endif

//#endif

AddBlazor(builder);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
//#elif (database == "Sqlite")
"SqliteConnectionString": "Data Source=App_Data/BoilerplateDb.db;", // To debug inside docker, change launchSettings.json
//#elif (database == "PostgreSQL")
"PostgreSQLConnectionString": "User ID=postgres;Password=123456;Host=localhost;Database=BoilerplateDb;"
"PostgreSQLConnectionString": "User ID=postgres;Password=123456;Host=localhost;Database=BoilerplateDb;",
//#endif
//#if (filesStorage == "AzureBlobStorage")
"AzureBlobStorageSasUrl": "emulator" // https://learn.microsoft.com/en-us/azure/ai-services/translator/document-translation/how-to-guides/create-sas-tokens?tabs=blobs#create-sas-tokens-in-the-azure-portal
//#endif
},
"DataProtectionCertificatePassword": "P@ssw0rdP@ssw0rd", // It can also be configured using: dotnet user-secrets set "DataProtectionCertificatePassword" "P@ssw0rdP@ssw0rd"
Expand Down Expand Up @@ -44,9 +47,9 @@
"AccountSid": null, /* Twilio */
"AutoToken": null
},
"UserProfileImagesDir": "App_Data/attachments/profiles/",
"UserProfileImagesDir": "attachments/profiles/",
//#if (captcha == "reCaptcha")
"GoogleRecaptchaSecretKey": "6LdMKr4pAAAAANvngWNam_nlHzEDJ2t6SfV6L_DS"
"GoogleRecaptchaSecretKey": "6LdMKr4pAAAAANvngWNam_nlHzEDJ2t6SfV6L_DS",
//#endif
},
"Authentication": {
Expand Down

0 comments on commit 345d432

Please sign in to comment.