From ab88511f222a1d11c13351231842c67a7c115028 Mon Sep 17 00:00:00 2001 From: Dominic Villemure Date: Sun, 23 Jun 2024 20:24:15 -0400 Subject: [PATCH] #67 added blob storage service + endpoints for profile picture. WIP --- Directory.Packages.props | 1 + .../Constants/CommonFileNames.cs | 6 + .../Constants/ContainerNames.cs | 6 + .../Constants/SubDirectoryNames.cs | 7 + .../Interfaces/IAzureBlobStorageService.cs | 7 + .../Users/Commands/UploadProfilePicture.cs | 25 ++++ .../GetCurrentUserProfilePicture.cs | 23 +++ .../Users/Queries/GetCurrentUser/UserDto.cs | 1 - .../AzureBlob/AzureBlobStorageService.cs | 74 ++++++++++ .../AzureBlob/BlobStructure.txt | 33 +++++ src/Infrastructure/DependencyInjection.cs | 7 + src/Infrastructure/Infrastructure.csproj | 1 + src/Web/Endpoints/GetMyUser.cs | 8 +- src/Web/Endpoints/Users.cs | 10 ++ src/Web/wwwroot/api/specification.json | 131 ++++++++++++++++++ 15 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 src/Application/AzureBlobStorage/Constants/CommonFileNames.cs create mode 100644 src/Application/AzureBlobStorage/Constants/ContainerNames.cs create mode 100644 src/Application/AzureBlobStorage/Constants/SubDirectoryNames.cs create mode 100644 src/Application/Common/Interfaces/IAzureBlobStorageService.cs create mode 100644 src/Application/Users/Commands/UploadProfilePicture.cs create mode 100644 src/Application/Users/Queries/GetCurrentUser/GetCurrentUserProfilePicture.cs create mode 100644 src/Infrastructure/AzureBlob/AzureBlobStorageService.cs create mode 100644 src/Infrastructure/AzureBlob/BlobStructure.txt diff --git a/Directory.Packages.props b/Directory.Packages.props index f4732ee..a2b4869 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/src/Application/AzureBlobStorage/Constants/CommonFileNames.cs b/src/Application/AzureBlobStorage/Constants/CommonFileNames.cs new file mode 100644 index 0000000..7eabb9a --- /dev/null +++ b/src/Application/AzureBlobStorage/Constants/CommonFileNames.cs @@ -0,0 +1,6 @@ +namespace Hutopy.Application.AzureBlobStorage.Constants; + +public static class CommonFileNames +{ + public static string ProfilePicture = "profilePicture"; +} diff --git a/src/Application/AzureBlobStorage/Constants/ContainerNames.cs b/src/Application/AzureBlobStorage/Constants/ContainerNames.cs new file mode 100644 index 0000000..5bab776 --- /dev/null +++ b/src/Application/AzureBlobStorage/Constants/ContainerNames.cs @@ -0,0 +1,6 @@ +namespace Hutopy.Application.AzureBlobStorage.Constants; + +public static class ContainerNames +{ + public static string Users = "users"; +} diff --git a/src/Application/AzureBlobStorage/Constants/SubDirectoryNames.cs b/src/Application/AzureBlobStorage/Constants/SubDirectoryNames.cs new file mode 100644 index 0000000..6ee43ea --- /dev/null +++ b/src/Application/AzureBlobStorage/Constants/SubDirectoryNames.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Application.AzureBlobStorage.Constants; + +public static class SubDirectoryNames +{ + public static string Profile = "profile"; + public static string Posts = "posts"; +} diff --git a/src/Application/Common/Interfaces/IAzureBlobStorageService.cs b/src/Application/Common/Interfaces/IAzureBlobStorageService.cs new file mode 100644 index 0000000..c8bdd20 --- /dev/null +++ b/src/Application/Common/Interfaces/IAzureBlobStorageService.cs @@ -0,0 +1,7 @@ +namespace Hutopy.Application.Common.Interfaces; + +public interface IAzureBlobStorageService +{ + Task UploadFileAsync(string containerName, string blobName, Stream fileStream); + Task DownloadFileAsync(string containerName, string blobName); +} diff --git a/src/Application/Users/Commands/UploadProfilePicture.cs b/src/Application/Users/Commands/UploadProfilePicture.cs new file mode 100644 index 0000000..08746d9 --- /dev/null +++ b/src/Application/Users/Commands/UploadProfilePicture.cs @@ -0,0 +1,25 @@ +using Hutopy.Application.AzureBlobStorage.Constants; +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.Users.Commands; + +public class UploadProfilePictureCommand : IRequest +{ + public required Stream ProfilePicture { get; init; } +} + +public class UploadProfilePictureCommandHandler(IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler +{ + public async Task Handle(UploadProfilePictureCommand request, CancellationToken cancellationToken) + { + var identityUser = await identityService.GetCurrentUserAsync(); + var currentUserId = new Guid(identityUser?.Id ?? "").ToString(); + + var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}"; + + var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.ProfilePicture); + + return url; + } +} + diff --git a/src/Application/Users/Queries/GetCurrentUser/GetCurrentUserProfilePicture.cs b/src/Application/Users/Queries/GetCurrentUser/GetCurrentUserProfilePicture.cs new file mode 100644 index 0000000..cb4e6b4 --- /dev/null +++ b/src/Application/Users/Queries/GetCurrentUser/GetCurrentUserProfilePicture.cs @@ -0,0 +1,23 @@ +using Hutopy.Application.AzureBlobStorage.Constants; +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Application.Users.Queries.GetCurrentUser; + +public record GetCurrentUserProfilePictureQuery : IRequest; + +public class GetCurrentUserProfilePictureQueryHandler( + IIdentityService identityService, + IAzureBlobStorageService azureBlobStorageService + ) + : IRequestHandler +{ + public async Task Handle(GetCurrentUserProfilePictureQuery request, CancellationToken cancellationToken) + { + var identityUser = await identityService.GetCurrentUserAsync(); + var currentUserId = new Guid(identityUser?.Id ?? ""); + + var blobName = $"{currentUserId.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}"; + + return await azureBlobStorageService.DownloadFileAsync(ContainerNames.Users, blobName); + } +} diff --git a/src/Application/Users/Queries/GetCurrentUser/UserDto.cs b/src/Application/Users/Queries/GetCurrentUser/UserDto.cs index 0e2af25..c7d121d 100644 --- a/src/Application/Users/Queries/GetCurrentUser/UserDto.cs +++ b/src/Application/Users/Queries/GetCurrentUser/UserDto.cs @@ -8,7 +8,6 @@ public class UserDto public string UserName { get; init; } = String.Empty; public List UserTransactions { get; init; } = []; public IList UserRoles { get; init; } = []; - public required decimal TotalBalance { get; init; } } diff --git a/src/Infrastructure/AzureBlob/AzureBlobStorageService.cs b/src/Infrastructure/AzureBlob/AzureBlobStorageService.cs new file mode 100644 index 0000000..f54f96b --- /dev/null +++ b/src/Infrastructure/AzureBlob/AzureBlobStorageService.cs @@ -0,0 +1,74 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Hutopy.Application.Common.Interfaces; +using Microsoft.Extensions.Configuration; + +namespace Hutopy.Infrastructure.AzureBlob; + +public class AzureBlobStorageService : IAzureBlobStorageService +{ + private readonly BlobServiceClient _blobServiceClient; + + public AzureBlobStorageService(IConfiguration configuration) + { + var connectionString = configuration["Azure-Blob-Connection-String"] ?? ""; + _blobServiceClient = new BlobServiceClient(connectionString); + + } + + /// + /// Upload a file to microsoft azure blob storage. + /// + /// The blob name (path within the container, include the file name). + /// The name of the container where the file is stored. + /// The stream. + /// + public async Task UploadFileAsync(string containerName, string blobName, Stream fileStream) + { + // Get a reference to a container + var containerClient = _blobServiceClient.GetBlobContainerClient(containerName); + + // Create the container if it does not exist + await containerClient.CreateIfNotExistsAsync(); + + // Get a reference to a blob + var blobClient = containerClient.GetBlobClient(blobName); + + // Upload the file + await blobClient.UploadAsync(fileStream, true); + + // Return the URI of the uploaded blob + return blobClient.Uri.ToString(); + } + + /// + /// Download a file to microsoft azure blob storage. + /// + /// The blob name (path within the container). + /// The name of the container where the file is stored. (users) + /// + public async Task DownloadFileAsync(string containerName, string blobName) + { + // Get a reference to a container + var containerClient = _blobServiceClient.GetBlobContainerClient(containerName); + + // Get a reference to a blob + var blobClient = containerClient.GetBlobClient(blobName); + + try + { + // Download the blob to a stream + BlobDownloadInfo download = await blobClient.DownloadAsync(); + MemoryStream memoryStream = new(); + await download.Content.CopyToAsync(memoryStream); + memoryStream.Position = 0; // Ensure the stream is at the beginning + + return memoryStream; + } + catch (Exception ex) + { + // Log and handle the exception as needed + throw new ApplicationException("Error downloading file from Azure Blob Storage.", ex); + } + } +} diff --git a/src/Infrastructure/AzureBlob/BlobStructure.txt b/src/Infrastructure/AzureBlob/BlobStructure.txt new file mode 100644 index 0000000..5410c95 --- /dev/null +++ b/src/Infrastructure/AzureBlob/BlobStructure.txt @@ -0,0 +1,33 @@ +users/ +│ +├── userId1/ +│ ├── profile/ +│ │ └── profilePicture.jpg +│ │ └── data.json +│ │ +│ ├── posts/ +│ │ ├── post1/ +│ │ │ ├── image1.jpg +│ │ │ ├── video1.mp4 +│ │ │ └── audio1.mp3 +│ │ ├── post2/ +│ │ │ ├── image2.jpg +│ │ │ └── video2.mp4 +│ │ └── ... +│ +├── userId2/ +│ ├── profile/ +│ │ └── profilePicture.jpg +│ │ └── data.json +│ │ +│ ├── posts/ +│ │ ├── post1/ +│ │ │ ├── image1.jpg +│ │ │ ├── video1.mp4 +│ │ │ └── audio1.mp3 +│ │ ├── post2/ +│ │ │ ├── image2.jpg +│ │ │ └── video2.mp4 +│ │ └── ... +│ +└── ... diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 42ca0c3..e03ce4c 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -1,5 +1,6 @@ using Hutopy.Application.Common.Interfaces; using Hutopy.Domain.Constants; +using Hutopy.Infrastructure.AzureBlob; using Hutopy.Infrastructure.Data; using Hutopy.Infrastructure.Data.Interceptors; using Hutopy.Infrastructure.Identity; @@ -63,8 +64,14 @@ public static class DependencyInjection .AddSignInManager>() .AddDefaultTokenProviders(); + // Singleton services services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + + // Scoped services services.AddScoped(); + + // Transient services services.AddTransient(); services.AddAuthorization(options => diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 108c958..dd3cb64 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -4,6 +4,7 @@ Hutopy.Infrastructure + diff --git a/src/Web/Endpoints/GetMyUser.cs b/src/Web/Endpoints/GetMyUser.cs index feb5b50..444cbe9 100644 --- a/src/Web/Endpoints/GetMyUser.cs +++ b/src/Web/Endpoints/GetMyUser.cs @@ -9,11 +9,17 @@ public class GetMyUser : EndpointGroupBase { app.MapGroup(this) .RequireAuthorization() - .MapGet(GetCurrentUser); + .MapGet(GetCurrentUser) + .MapGet(GetCurrentUserProfilePicture, "profile-picture"); } private static async Task GetCurrentUser(ISender sender, [AsParameters] GetCurrentUserQuery query) { return await sender.Send(query); } + + private static async Task GetCurrentUserProfilePicture(ISender sender, [AsParameters] GetCurrentUserProfilePictureQuery query) + { + return await sender.Send(query); + } } diff --git a/src/Web/Endpoints/Users.cs b/src/Web/Endpoints/Users.cs index 39f784a..b2a15c5 100644 --- a/src/Web/Endpoints/Users.cs +++ b/src/Web/Endpoints/Users.cs @@ -10,6 +10,7 @@ public class Users : EndpointGroupBase app.MapGroup(this) .MapPost(CreateUser) .MapPost(Login, "/login") + .MapPost(UploadProfilePicture, "/upload-profile-picture") .MapGet(GetMinimalUser); } @@ -27,4 +28,13 @@ public class Users : EndpointGroupBase { return await sender.Send(command); } + + private static async Task UploadProfilePicture(ISender sender, Stream stream) + { + var command = new UploadProfilePictureCommand + { + ProfilePicture = stream + }; + return await sender.Send(command); + } } diff --git a/src/Web/wwwroot/api/specification.json b/src/Web/wwwroot/api/specification.json index 93ac3dc..bf1f109 100644 --- a/src/Web/wwwroot/api/specification.json +++ b/src/Web/wwwroot/api/specification.json @@ -31,6 +31,56 @@ ] } }, + "/api/GetMyUser/profile-picture": { + "get": { + "tags": [ + "GetMyUser" + ], + "operationId": "GetCurrentUserProfilePicture", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/api/GetMyUser/profile-picture-2": { + "patch": { + "tags": [ + "GetMyUser" + ], + "operationId": "PatchApiGetMyUserProfilePicture2", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Stream" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, "/api/JoinUs": { "get": { "tags": [ @@ -313,6 +363,40 @@ } } }, + "/api/Users/upload-profile-picture": { + "post": { + "tags": [ + "Users" + ], + "operationId": "UploadProfilePicture", + "requestBody": { + "x-name": "stream", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary", + "nullable": false + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/api/WeatherForecasts": { "get": { "tags": [ @@ -448,6 +532,53 @@ } } }, + "Stream": { + "allOf": [ + { + "$ref": "#/components/schemas/MarshalByRefObject" + }, + { + "type": "object", + "x-abstract": true, + "additionalProperties": false, + "properties": { + "canRead": { + "type": "boolean" + }, + "canWrite": { + "type": "boolean" + }, + "canSeek": { + "type": "boolean" + }, + "canTimeout": { + "type": "boolean" + }, + "length": { + "type": "integer", + "format": "int64" + }, + "position": { + "type": "integer", + "format": "int64" + }, + "readTimeout": { + "type": "integer", + "format": "int32" + }, + "writeTimeout": { + "type": "integer", + "format": "int32" + } + } + } + ] + }, + "MarshalByRefObject": { + "type": "object", + "x-abstract": true, + "additionalProperties": false + }, "PaginatedListOfFutureCreatorListDto": { "type": "object", "additionalProperties": false,