Merged PR 99: Add-logs-for-blob-storage-calls and verify file type and size into master

This commit is contained in:
Dominic Villemure
2024-07-20 16:49:33 +00:00
16 changed files with 210 additions and 83 deletions

View File

@@ -36,6 +36,7 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageVersion Include="MinimalApis.Extensions" Version="0.11.0" />
<PackageVersion Include="Moq" Version="4.20.69" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.0.3" />

View File

@@ -11,6 +11,7 @@
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Google.Apis.Oauth2.v2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="MinimalApis.Extensions" />
</ItemGroup>
<ItemGroup>

View File

@@ -2,7 +2,7 @@
public static class CommonFileNames
{
public static string ProfilePicture = "profilePicture.png";
public static string BannerPicture = "bannerPicture.png";
public static string WebsiteIcon = "websiteIcon.png";
public static string ProfilePicture = "profilePicture";
public static string BannerPicture = "bannerPicture";
public static string WebsiteIcon = "websiteIcon";
}

View File

@@ -1,6 +0,0 @@
namespace Hutopy.Application.AzureBlobStorage.Constants;
public static class ContentTypes
{
public static string ImagePng = "image/png";
}

View File

@@ -31,6 +31,11 @@ public class Result<T>(
{
return Value ?? default(T)!;
}
public string GetErrorsAsString()
{
return Errors.Length == 0 ? string.Empty : string.Join(", ", Errors);
}
public static Result<T> Success(T value)
{

View File

@@ -1,7 +1,8 @@
using Hutopy.Application.Common.Interfaces;
using Microsoft.AspNetCore.Http;
namespace Hutopy.Application.Users.Commands;
public record CreateUserCommand : IRequest<Guid>
public record CreateUserCommand : IRequest<IResult>
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
@@ -10,7 +11,7 @@ public record CreateUserCommand : IRequest<Guid>
public required string Password { get; init; }
}
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, Guid>
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, IResult>
{
private readonly IApplicationDbContext _context;
private readonly IIdentityService _identityService;
@@ -21,7 +22,7 @@ public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, Guid>
_identityService = identityService;
}
public async Task<Guid> Handle(CreateUserCommand request, CancellationToken cancellationToken)
public async Task<IResult> Handle(CreateUserCommand request, CancellationToken cancellationToken)
{
await _identityService.CreateUserAsync(request.EmailAddress, request.UserName, request.FirstName, request.LastName, request.Password);
@@ -29,6 +30,6 @@ public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, Guid>
await _context.SaveChangesAsync(cancellationToken);
return new Guid(user?.Id ?? string.Empty);
return Results.Ok(new Guid(user?.Id ?? string.Empty));
}
}

View File

@@ -2,10 +2,11 @@ using System.ComponentModel.DataAnnotations.Schema;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Models;
using Hutopy.Application.Users.Models;
using Microsoft.AspNetCore.Http;
namespace Hutopy.Application.Users.Commands;
public class UpdateCurrentUserCommand : IRequest<string>
public class UpdateCurrentUserCommand : IRequest<IResult>
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
@@ -32,13 +33,13 @@ public class UpdateCurrentUserCommand : IRequest<string>
}
public class UpdateCurrentUserCommandHandler(IApplicationDbContext context, IIdentityService identityService, IMapper mapper) :
IRequestHandler<UpdateCurrentUserCommand, string>
IRequestHandler<UpdateCurrentUserCommand, IResult>
{
public async Task<string> Handle(UpdateCurrentUserCommand request, CancellationToken cancellationToken)
public async Task<IResult> Handle(UpdateCurrentUserCommand request, CancellationToken cancellationToken)
{
var identityUser = await identityService.GetCurrentUserAsync();
if (identityUser?.Id is null) return string.Empty;
if (identityUser?.Id is null) return Results.Problem("Current user not found.");
var userModel = mapper.Map<UserModel>(request);
userModel.Id = identityUser.Id;
@@ -46,8 +47,8 @@ public class UpdateCurrentUserCommandHandler(IApplicationDbContext context, IIde
var result = await identityService.UpdateCurrentUserAsync(userModel);
await context.SaveChangesAsync(cancellationToken);
return result.GetValueOrDefault();
return result.Succeeded ? Results.Ok(result.GetValueOrDefault()) : Results.Problem(result.GetErrorsAsString());
}
}

View File

@@ -1,37 +1,42 @@
using Hutopy.Application.AzureBlobStorage.Constants;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Utils;
using Microsoft.AspNetCore.Http;
namespace Hutopy.Application.Users.Commands;
/// <summary>
/// Upload a banner picture. If the user has the url already, set the BannerPictureUrl in the user only without upload.
/// </summary>
public class UploadBannerPictureCommand : IRequest<string>
public class UploadBannerPictureCommand : IRequest<IResult>
{
public required Stream BannerPicture { get; init; }
public string BannerPictureUrl { get; init; } = string.Empty;
}
public class UploadBannerPictureCommandHandler(IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadBannerPictureCommand, string>
public class UploadBannerPictureCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadBannerPictureCommand, IResult>
{
public async Task<string> Handle(UploadBannerPictureCommand request, CancellationToken cancellationToken)
public async Task<IResult> Handle(UploadBannerPictureCommand request, CancellationToken cancellationToken)
{
// If an url to the picture is provided, use it right away and don't upload anything.
if (!string.IsNullOrEmpty(request.BannerPictureUrl))
{
await identityService.UpdateCurrentUserBannerPictureUrlAsync(request.BannerPictureUrl);
return request.BannerPictureUrl;
return Results.Ok(request.BannerPictureUrl);
}
var contentType = contextAccessor.EnsureContentType();
var identityUser = await identityService.GetCurrentUserAsync();
var currentUserId = new Guid(identityUser?.Id ?? "").ToString();
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.BannerPicture, ContentTypes.ImagePng);
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.BannerPicture, contentType);
await identityService.UpdateCurrentUserBannerPictureUrlAsync(url);
return url;
return Results.Ok(url);
}
}

View File

@@ -1,37 +1,42 @@
using Hutopy.Application.AzureBlobStorage.Constants;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Utils;
using Microsoft.AspNetCore.Http;
namespace Hutopy.Application.Users.Commands;
/// <summary>
/// Upload a profile picture. If the user has the url already, set the ProfilePictureUrl in the user only without upload.
/// </summary>
public class UploadProfilePictureCommand : IRequest<string>
public class UploadProfilePictureCommand : IRequest<IResult>
{
public required Stream ProfilePicture { get; init; }
public string ProfilePictureUrl { get; init; } = string.Empty;
}
public class UploadProfilePictureCommandHandler(IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadProfilePictureCommand, string>
public class UploadProfilePictureCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadProfilePictureCommand, IResult>
{
public async Task<string> Handle(UploadProfilePictureCommand request, CancellationToken cancellationToken)
public async Task<IResult> Handle(UploadProfilePictureCommand request, CancellationToken cancellationToken)
{
// If an url to the picture is provided, use it right away and don't upload anything.
if (!string.IsNullOrEmpty(request.ProfilePictureUrl))
{
await identityService.UpdateCurrentUserProfilePictureUrlAsync(request.ProfilePictureUrl);
return request.ProfilePictureUrl;
return Results.Ok(request.ProfilePictureUrl);
}
var contentType = contextAccessor.EnsureContentType();
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, ContentTypes.ImagePng);
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.ProfilePicture, contentType);
await identityService.UpdateCurrentUserProfilePictureUrlAsync(url);
return url;
return Results.Ok(url);
}
}

View File

@@ -1,38 +1,43 @@
using Hutopy.Application.AzureBlobStorage.Constants;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Utils;
using Microsoft.AspNetCore.Http;
namespace Hutopy.Application.Users.Commands;
/// <summary>
/// Upload a website icon. If the user has the url already, set the WebsitePictureUrl in the user only without upload.
/// </summary>
public class UploadWebsiteIconCommand : IRequest<string>
public class UploadWebsiteIconCommand : IRequest<IResult>
{
public required Stream WebsiteIcon { get; init; }
public string WebsitePictureUrl { get; init; } = string.Empty;
}
public class UploadWebsiteIconCommandHandler(IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadWebsiteIconCommand, string>
public class UploadWebsiteIconCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadWebsiteIconCommand, IResult>
{
public async Task<string> Handle(UploadWebsiteIconCommand request, CancellationToken cancellationToken)
public async Task<IResult> Handle(UploadWebsiteIconCommand request, CancellationToken cancellationToken)
{
// If an url to the picture is provided, use it right away and don't upload anything.
if (!string.IsNullOrEmpty(request.WebsitePictureUrl))
{
await identityService.UpdateCurrentUserWebsiteIconUrlAsync(request.WebsitePictureUrl);
return request.WebsitePictureUrl;
return Results.Ok(request.WebsitePictureUrl);
}
var contentType = contextAccessor.EnsureContentType();
var identityUser = await identityService.GetCurrentUserAsync();
var currentUserId = new Guid(identityUser?.Id ?? "").ToString();
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.WebsiteIcon}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.WebsiteIcon, ContentTypes.ImagePng);
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.WebsiteIcon, contentType);
await identityService.UpdateCurrentUserWebsiteIconUrlAsync(url);
return url;
return Results.Ok(request.WebsitePictureUrl);
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Http;
namespace Hutopy.Application.Utils;
public static class HttpContextAccessorExtensions
{
public static HttpContext EnsureHttpContext(this IHttpContextAccessor httpContextAccessor)
{
if (httpContextAccessor.HttpContext == null)
{
throw new InvalidOperationException("HttpContext is null.");
}
return httpContextAccessor.HttpContext;
}
public static string EnsureContentType(this IHttpContextAccessor httpContextAccessor)
{
var httpContext = EnsureHttpContext(httpContextAccessor);
var contentType = httpContext.Request.ContentType;
if (string.IsNullOrEmpty(contentType))
{
throw new InvalidOperationException("Content-Type header is missing.");
}
return contentType;
}
}

View File

@@ -1,22 +1,23 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Hutopy.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Hutopy.Infrastructure.AzureBlob;
public class AzureBlobStorageService : IAzureBlobStorageService
{
private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureBlobStorageService> _logger;
private readonly long _maxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
public AzureBlobStorageService(IConfiguration configuration)
public AzureBlobStorageService(IConfiguration configuration, ILogger<AzureBlobStorageService> logger)
{
_logger = logger;
var connectionString = configuration["Azure-Blob-Connection-String"] ?? "";
_blobServiceClient = new BlobServiceClient(connectionString);
}
/// <summary>
@@ -29,29 +30,72 @@ public class AzureBlobStorageService : IAzureBlobStorageService
/// <returns></returns>
public async Task<string> UploadFileAsync(string containerName, string blobName, Stream fileStream, string contentType)
{
// Get a reference to a container
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Create the container if it does not exist
await containerClient.CreateIfNotExistsAsync();
// Read the file stream into a memory stream to determine the length
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
// Get a reference to a blob
var blobClient = containerClient.GetBlobClient(blobName);
// Define the BlobHttpHeaders to include the content type
var blobHttpHeaders = new BlobHttpHeaders
// Check if the file size exceeds the maximum upload size
if (memoryStream.Length > _maxUploadSize)
{
ContentType = contentType
};
_logger.LogInformation($"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes.");
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes.");
}
// Validate content type
if (!ContentTypes.IsAllowed(contentType, memoryStream))
{
_logger.LogInformation($"Blob storage: Unsupported file type {contentType}. Only PNG and JPEG are allowed.");
throw new InvalidOperationException("Unsupported file type. Only PNG and JPEG are allowed.");
}
try
{
// Get a reference to a container
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Upload the file
await blobClient.UploadAsync(fileStream, new BlobUploadOptions
// Create the container if it does not exist
await containerClient.CreateIfNotExistsAsync();
// Get a reference to a blob
var blobClient = containerClient.GetBlobClient(blobName);
// Define the BlobHttpHeaders to include the content type
var blobHttpHeaders = new BlobHttpHeaders
{
ContentType = contentType
};
// Upload the file
var response = await blobClient.UploadAsync(memoryStream, new BlobUploadOptions
{
HttpHeaders = blobHttpHeaders
});
var fileUri = blobClient.Uri.ToString();
_logger.LogInformation(
$"Blob storage: Status [ {response.GetRawResponse().Status.ToString()} ] " +
$"Uploaded [ {blobName} ] to the container [ {containerName} ] " +
$"with contentType [ {contentType} ] " +
$"with a length of [ {memoryStream.Length} bytes ]" +
$"with the uri [ {fileUri} ]"
);
// Return the URI of the uploaded blob
return fileUri;
}
catch (RequestFailedException ex)
{
HttpHeaders = blobHttpHeaders
});
// Return the URI of the uploaded blob
return blobClient.Uri.ToString();
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
throw new Exception("Error uploading file to Azure Blob Storage", ex);
}
catch (Exception ex)
{
_logger.LogError($"Blob storage: An error occurred: {ex.Message}");
throw;
}
}
/// <summary>
@@ -62,14 +106,14 @@ public class AzureBlobStorageService : IAzureBlobStorageService
/// <returns></returns>
public async Task<MemoryStream> 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
{
// Get a reference to a container
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Get a reference to a blob
var blobClient = containerClient.GetBlobClient(blobName);
// Download the blob to a stream
BlobDownloadInfo download = await blobClient.DownloadAsync();
MemoryStream memoryStream = new();
@@ -78,9 +122,14 @@ public class AzureBlobStorageService : IAzureBlobStorageService
return memoryStream;
}
catch (RequestFailedException ex)
{
_logger.LogError($"Azure Storage request failed: {ex.Message}");
throw new Exception("Error downloading file from Azure Blob Storage", ex);
}
catch (Exception ex)
{
// Log and handle the exception as needed
_logger.LogError($"An error occurred: {ex.Message}");
throw new ApplicationException("Error downloading file from Azure Blob Storage.", ex);
}
}

View File

@@ -0,0 +1,36 @@
namespace Hutopy.Infrastructure.AzureBlob;
public static class ContentTypes
{
private static string ImagePng = "image/png";
private static string ImageJpeg = "image/jpeg";
private static string ImageJpg = "image/jpg";
public static HashSet<string> AllowedContentTypes = new HashSet<string> { ImagePng, ImageJpeg, ImageJpg };
public static bool IsAllowed(string contentType, Stream fileStream)
{
return IsValidFileType(fileStream) && AllowedContentTypes.Contains(contentType);
}
private static bool IsValidFileType(Stream fileStream)
{
byte[] buffer = new byte[4];
fileStream.Read(buffer, 0, buffer.Length);
fileStream.Position = 0;
// PNG file signature: 89 50 4E 47 (in hex)
if (buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47)
{
return true;
}
// JPEG file signature: FF D8 FF (in hex)
if (buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF)
{
return true;
}
return false;
}
}

View File

@@ -1 +1,4 @@
global using Ardalis.GuardClauses;
global using System;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;

View File

@@ -14,24 +14,24 @@ public class UpdateMyUser : EndpointGroupBase
.MapPatch("/profile", UpdateCurrentUser);
}
private static async Task<string> UpdateCurrentUser(ISender sender, UpdateCurrentUserCommand command)
private static async Task<IResult> UpdateCurrentUser(ISender sender, UpdateCurrentUserCommand command)
{
return await sender.Send(command);
}
private static async Task<string> UpdateCurrentUserProfilePicture(ISender sender, Stream stream, string url = "")
private static async Task<IResult> UpdateCurrentUserProfilePicture(ISender sender, Stream stream, string url = "")
{
var command = new UploadProfilePictureCommand { ProfilePicture = stream, ProfilePictureUrl = url};
return await sender.Send(command);
}
private static async Task<string> UpdateCurrentUserBannerPicture(ISender sender, Stream stream, string url = "")
private static async Task<IResult> UpdateCurrentUserBannerPicture(ISender sender, Stream stream, string url = "")
{
var command = new UploadBannerPictureCommand { BannerPicture = stream, BannerPictureUrl = url};
return await sender.Send(command);
}
private static async Task<string> UpdateCurrentUserWebsiteIcon(ISender sender, Stream stream, string url = "")
private static async Task<IResult> UpdateCurrentUserWebsiteIcon(ISender sender, Stream stream, string url = "")
{
var command = new UploadWebsiteIconCommand { WebsiteIcon = stream, WebsitePictureUrl = url};
return await sender.Send(command);

View File

@@ -10,11 +10,10 @@ public class Users : EndpointGroupBase
app.MapGroup(this)
.MapPost(CreateUser)
.MapPost(Login, "/login")
.MapPost(UploadProfilePicture, "/upload-profile-picture")
.MapGet(GetUser);
}
private static async Task<Guid> CreateUser(ISender sender, CreateUserCommand command)
private static async Task<IResult> CreateUser(ISender sender, CreateUserCommand command)
{
return await sender.Send(command);
}
@@ -28,11 +27,4 @@ public class Users : EndpointGroupBase
{
return await sender.Send(command);
}
private static async Task<string> UploadProfilePicture(ISender sender, Stream stream)
{
var command = new UploadProfilePictureCommand { ProfilePicture = stream };
return await sender.Send(command);
}
}