Adds multiple media urls for content

This commit is contained in:
Jonathan Bourdon
2024-07-31 17:38:58 -04:00
parent 042fd53463
commit bbcc7a8a33
19 changed files with 319 additions and 111 deletions

View File

@@ -8,7 +8,7 @@
<PackageVersion Include="AutoMapper" Version="13.0.1" /> <PackageVersion Include="AutoMapper" Version="13.0.1" />
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.0" /> <PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.0" />
<PackageVersion Include="Azure.Identity" Version="1.11.0" /> <PackageVersion Include="Azure.Identity" Version="1.11.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.20.0" /> <PackageVersion Include="Azure.Storage.Blobs" Version="12.21.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.0" /> <PackageVersion Include="coverlet.collector" Version="6.0.0" />
<PackageVersion Include="FastEndpoints" Version="5.26.0" /> <PackageVersion Include="FastEndpoints" Version="5.26.0" />
<PackageVersion Include="FluentAssertions" Version="6.12.0" /> <PackageVersion Include="FluentAssertions" Version="6.12.0" />

View File

@@ -3,4 +3,5 @@
public static class ContainerNames public static class ContainerNames
{ {
public static string Users = "users"; public static string Users = "users";
public static string Creators = "creators";
} }

View File

@@ -3,5 +3,5 @@
public static class SubDirectoryNames public static class SubDirectoryNames
{ {
public static string Profile = "profile"; public static string Profile = "profile";
public static string Posts = "posts"; public static string Contents = "contents";
} }

View File

@@ -2,6 +2,6 @@
public interface IAzureBlobStorageService public interface IAzureBlobStorageService
{ {
Task<string> UploadFileAsync(string containerName, string blobName, Stream fileStream, string contentType); Task<string> UploadFileAsync(string containerName, string blobName, MemoryStream memoryStream, string contentType, CancellationToken ct = default);
Task<MemoryStream> DownloadFileAsync(string containerName, string blobName); Task<MemoryStream> DownloadFileAsync(string containerName, string blobName, CancellationToken ct = default);
} }

View File

@@ -10,7 +10,7 @@ namespace Hutopy.Application.Users.Commands;
/// </summary> /// </summary>
public class UploadBannerPictureCommand : IRequest<IResult> public class UploadBannerPictureCommand : IRequest<IResult>
{ {
public required Stream BannerPicture { get; init; } public required MemoryStream BannerPicture { get; init; }
public string BannerPictureUrl { get; init; } = string.Empty; public string BannerPictureUrl { get; init; } = string.Empty;
} }
@@ -32,7 +32,12 @@ public class UploadBannerPictureCommandHandler(IHttpContextAccessor contextAcces
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}"; var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.BannerPicture}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.BannerPicture, contentType); var url = await azureBlobStorageService.UploadFileAsync(
ContainerNames.Users,
blobName,
request.BannerPicture,
contentType,
cancellationToken);
await identityService.UpdateCurrentUserBannerPictureUrlAsync(url); await identityService.UpdateCurrentUserBannerPictureUrlAsync(url);

View File

@@ -10,13 +10,13 @@ namespace Hutopy.Application.Users.Commands;
/// </summary> /// </summary>
public class UploadProfilePictureCommand : IRequest<IResult> public class UploadProfilePictureCommand : IRequest<IResult>
{ {
public required Stream ProfilePicture { get; init; } public required MemoryStream ProfilePicture { get; init; }
public string ProfilePictureUrl { get; init; } = string.Empty; public string ProfilePictureUrl { get; init; } = string.Empty;
} }
public class UploadProfilePictureCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadProfilePictureCommand, IResult> public class UploadProfilePictureCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadProfilePictureCommand, IResult>
{ {
public async Task<IResult> Handle(UploadProfilePictureCommand request, CancellationToken cancellationToken) public async Task<IResult> Handle(UploadProfilePictureCommand request, CancellationToken ct)
{ {
// If an url to the picture is provided, use it right away and don't upload anything. // If an url to the picture is provided, use it right away and don't upload anything.
if (!string.IsNullOrEmpty(request.ProfilePictureUrl)) if (!string.IsNullOrEmpty(request.ProfilePictureUrl))
@@ -32,7 +32,12 @@ public class UploadProfilePictureCommandHandler(IHttpContextAccessor contextAcce
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}"; var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.ProfilePicture, contentType); var url = await azureBlobStorageService.UploadFileAsync(
ContainerNames.Users,
blobName,
request.ProfilePicture,
contentType,
ct);
await identityService.UpdateCurrentUserProfilePictureUrlAsync(url); await identityService.UpdateCurrentUserProfilePictureUrlAsync(url);

View File

@@ -10,14 +10,17 @@ namespace Hutopy.Application.Users.Commands;
/// </summary> /// </summary>
public class UploadWebsiteIconCommand : IRequest<IResult> public class UploadWebsiteIconCommand : IRequest<IResult>
{ {
public required Stream WebsiteIcon { get; init; } public required MemoryStream WebsiteIcon { get; init; }
public string WebsitePictureUrl { get; init; } = string.Empty; public string WebsitePictureUrl { get; init; } = string.Empty;
} }
public class UploadWebsiteIconCommandHandler(IHttpContextAccessor contextAccessor, IIdentityService identityService, IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadWebsiteIconCommand, IResult> public class UploadWebsiteIconCommandHandler(
IHttpContextAccessor contextAccessor,
IIdentityService identityService,
IAzureBlobStorageService azureBlobStorageService) : IRequestHandler<UploadWebsiteIconCommand, IResult>
{ {
public async Task<IResult> Handle(UploadWebsiteIconCommand request, CancellationToken cancellationToken) public async Task<IResult> Handle(UploadWebsiteIconCommand request, CancellationToken ct)
{ {
// If an url to the picture is provided, use it right away and don't upload anything. // If an url to the picture is provided, use it right away and don't upload anything.
if (!string.IsNullOrEmpty(request.WebsitePictureUrl)) if (!string.IsNullOrEmpty(request.WebsitePictureUrl))
@@ -25,19 +28,23 @@ public class UploadWebsiteIconCommandHandler(IHttpContextAccessor contextAccesso
await identityService.UpdateCurrentUserWebsiteIconUrlAsync(request.WebsitePictureUrl); await identityService.UpdateCurrentUserWebsiteIconUrlAsync(request.WebsitePictureUrl);
return Results.Ok(request.WebsitePictureUrl); return Results.Ok(request.WebsitePictureUrl);
} }
var contentType = contextAccessor.EnsureContentType(); var contentType = contextAccessor.EnsureContentType();
var identityUser = await identityService.GetCurrentUserAsync(); var identityUser = await identityService.GetCurrentUserAsync();
var currentUserId = new Guid(identityUser?.Id ?? "").ToString(); var currentUserId = new Guid(identityUser?.Id ?? "").ToString();
var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.WebsiteIcon}"; var blobName = $"{currentUserId}/{SubDirectoryNames.Profile}/{CommonFileNames.WebsiteIcon}";
var url = await azureBlobStorageService.UploadFileAsync(ContainerNames.Users, blobName, request.WebsiteIcon, contentType); var url = await azureBlobStorageService.UploadFileAsync(
ContainerNames.Users,
blobName,
request.WebsiteIcon,
contentType,
ct);
await identityService.UpdateCurrentUserWebsiteIconUrlAsync(url); await identityService.UpdateCurrentUserWebsiteIconUrlAsync(url);
return Results.Ok(request.WebsitePictureUrl); return Results.Ok(request.WebsitePictureUrl);
} }
} }

View File

@@ -11,7 +11,7 @@ public class AzureBlobStorageService : IAzureBlobStorageService
{ {
private readonly BlobServiceClient _blobServiceClient; private readonly BlobServiceClient _blobServiceClient;
private readonly ILogger<AzureBlobStorageService> _logger; private readonly ILogger<AzureBlobStorageService> _logger;
private readonly long _maxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes private readonly long _maxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes
public AzureBlobStorageService(IConfiguration configuration, ILogger<AzureBlobStorageService> logger) public AzureBlobStorageService(IConfiguration configuration, ILogger<AzureBlobStorageService> logger)
{ {
@@ -25,63 +25,73 @@ public class AzureBlobStorageService : IAzureBlobStorageService
/// </summary> /// </summary>
/// <param name="blobName">The blob name (path within the container, include the file name).</param> /// <param name="blobName">The blob name (path within the container, include the file name).</param>
/// <param name="containerName">The name of the container where the file is stored.</param> /// <param name="containerName">The name of the container where the file is stored.</param>
/// <param name="fileStream">The stream.</param> /// <param name="memoryStream">The memory stream containing the image.</param>
/// <param name="contentType">The content type.</param> /// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns> /// <returns></returns>
public async Task<string> UploadFileAsync(string containerName, string blobName, Stream fileStream, string contentType) public async Task<string> UploadFileAsync(string containerName, string blobName, MemoryStream memoryStream,
string contentType, CancellationToken ct = default)
{ {
// Read the file stream into a memory stream to determine the length // Read the file stream into a memory stream to determine the length
// WATCH FOR MEMORY USAGE USING THE MEMORY STREAM. // WATCH FOR MEMORY USAGE USING THE MEMORY STREAM.
var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
memoryStream.Position = 0; memoryStream.Position = 0;
// Check if the file size exceeds the maximum upload size // Check if the file size exceeds the maximum upload size
if (memoryStream.Length > _maxUploadSize) if (memoryStream.Length > _maxUploadSize)
{ {
_logger.LogInformation($"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes."); _logger.LogError(
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {_maxUploadSize} bytes."); $"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 // Validate content type
if (!ContentTypes.IsAllowed(contentType, memoryStream)) if (!ContentTypes.IsAllowed(contentType, memoryStream))
{ {
_logger.LogInformation($"Blob storage: Unsupported file type {contentType}. Only PNG and JPEG are allowed."); _logger.LogError(
$"Blob storage: Unsupported file type {contentType}. Only PNG and JPEG are allowed.");
throw new InvalidOperationException("Unsupported file type. Only PNG and JPEG are allowed."); throw new InvalidOperationException("Unsupported file type. Only PNG and JPEG are allowed.");
} }
try try
{ {
// Get a reference to a container // Get a reference to a container
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName); var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Create the container if it does not exist // Create the container if it does not exist
await containerClient.CreateIfNotExistsAsync(); await containerClient.CreateIfNotExistsAsync(
PublicAccessType.Blob,
cancellationToken: ct);
// Get a reference to a blob // Get a reference to a blob
var blobClient = containerClient.GetBlobClient(blobName); var blobClient = containerClient.GetBlobClient(blobName);
// Define the BlobHttpHeaders to include the content type // Define the BlobHttpHeaders to include the content type
var blobHttpHeaders = new BlobHttpHeaders var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType };
{
ContentType = contentType
};
// Upload the file // Upload the file
var response = await blobClient.UploadAsync(memoryStream, new BlobUploadOptions var response = await blobClient.UploadAsync(
{ memoryStream,
HttpHeaders = blobHttpHeaders new BlobUploadOptions { HttpHeaders = blobHttpHeaders },
}); ct);
var fileUri = blobClient.Uri.ToString(); var fileUri = blobClient.Uri.ToString();
_logger.LogInformation( _logger.LogInformation(
$"Blob storage: Status [ {response.GetRawResponse().Status.ToString()} ] " + """
$"Uploaded [ {blobName} ] to the container [ {containerName} ] " + Blob storage: Status [ {ResponseStatus} ]
$"with contentType [ {contentType} ] " + Uploaded [ {BlobName} ] to the container [ {ContainerName} ]
$"with a length of [ {memoryStream.Length} bytes ]" + with contentType [ {ContentType} ]
$"with the uri [ {fileUri} ]" with a length of [ {StreamLength} bytes ]
); with the uri [ {FileUri} ]
""",
response.GetRawResponse().Status.ToString(),
blobName,
containerName,
contentType,
memoryStream.Length,
fileUri
);
// Return the URI of the uploaded blob // Return the URI of the uploaded blob
return fileUri; return fileUri;
@@ -89,7 +99,7 @@ public class AzureBlobStorageService : IAzureBlobStorageService
catch (RequestFailedException ex) catch (RequestFailedException ex)
{ {
_logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}"); _logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}");
throw new Exception("Error uploading file to Azure Blob Storage", ex); throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -99,25 +109,27 @@ public class AzureBlobStorageService : IAzureBlobStorageService
} }
/// <summary> /// <summary>
/// Download a file to microsoft azure blob storage. /// Download a file to microsoft's azure blob storage.
/// </summary> /// </summary>
/// <param name="blobName">The blob name (path within the container).</param> /// <param name="blobName">The blob name (path within the container).</param>
/// <param name="containerName">The name of the container where the file is stored. (users)</param> /// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns> /// <returns></returns>
public async Task<MemoryStream> DownloadFileAsync(string containerName, string blobName) public async Task<MemoryStream> DownloadFileAsync(string containerName, string blobName,
CancellationToken ct = default)
{ {
try try
{ {
// Get a reference to a container // Get a reference to a container
var containerClient = _blobServiceClient.GetBlobContainerClient(containerName); var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
// Get a reference to a blob // Get a reference to a blob
var blobClient = containerClient.GetBlobClient(blobName); var blobClient = containerClient.GetBlobClient(blobName);
// Download the blob to a stream // Download the blob to a stream
BlobDownloadInfo download = await blobClient.DownloadAsync(); BlobDownloadInfo download = await blobClient.DownloadAsync(ct);
MemoryStream memoryStream = new(); MemoryStream memoryStream = new();
await download.Content.CopyToAsync(memoryStream); await download.Content.CopyToAsync(memoryStream, ct);
memoryStream.Position = 0; // Ensure the stream is at the beginning memoryStream.Position = 0; // Ensure the stream is at the beginning
return memoryStream; return memoryStream;
@@ -125,12 +137,12 @@ public class AzureBlobStorageService : IAzureBlobStorageService
catch (RequestFailedException ex) catch (RequestFailedException ex)
{ {
_logger.LogError($"Azure Storage request failed: {ex.Message}"); _logger.LogError($"Azure Storage request failed: {ex.Message}");
throw new Exception("Error downloading file from Azure Blob Storage", ex); throw;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError($"An error occurred: {ex.Message}"); _logger.LogError($"An error occurred: {ex.Message}");
throw new ApplicationException("Error downloading file from Azure Blob Storage.", ex); throw;
} }
} }
} }

View File

@@ -4,7 +4,7 @@
<AssemblyName>Hutopy.Infrastructure</AssemblyName> <AssemblyName>Hutopy.Infrastructure</AssemblyName>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.21.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" /> <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />

View File

@@ -66,7 +66,7 @@ namespace Hutopy.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("FutureCreators"); b.ToTable("FutureCreators", (string)null);
}); });
modelBuilder.Entity("Hutopy.Domain.Entities.UserTransaction", b => modelBuilder.Entity("Hutopy.Domain.Entities.UserTransaction", b =>
@@ -141,7 +141,7 @@ namespace Hutopy.Infrastructure.Migrations
b.HasIndex("ApplicationUserId"); b.HasIndex("ApplicationUserId");
b.ToTable("UserTransactions"); b.ToTable("UserTransactions", (string)null);
}); });
modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b => modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b =>
@@ -395,7 +395,7 @@ namespace Hutopy.Infrastructure.Migrations
modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b => modelBuilder.Entity("Hutopy.Infrastructure.Identity.ApplicationUser", b =>
{ {
b.OwnsOne("Hutopy.Infrastructure.Identity.OwnedEntities.ProfileColors", "ProfileColors", b1 => b.OwnsOne("Hutopy.Infrastructure.Identity.ApplicationUser.ProfileColors#Hutopy.Infrastructure.Identity.OwnedEntities.ProfileColors", "ProfileColors", b1 =>
{ {
b1.Property<string>("ApplicationUserId") b1.Property<string>("ApplicationUserId")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
@@ -424,7 +424,7 @@ namespace Hutopy.Infrastructure.Migrations
.HasForeignKey("ApplicationUserId"); .HasForeignKey("ApplicationUserId");
}); });
b.OwnsOne("Hutopy.Infrastructure.Identity.OwnedEntities.SocialNetworks", "SocialNetworks", b1 => b.OwnsOne("Hutopy.Infrastructure.Identity.ApplicationUser.SocialNetworks#Hutopy.Infrastructure.Identity.OwnedEntities.SocialNetworks", "SocialNetworks", b1 =>
{ {
b1.Property<string>("ApplicationUserId") b1.Property<string>("ApplicationUserId")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
@@ -469,7 +469,7 @@ namespace Hutopy.Infrastructure.Migrations
.HasForeignKey("ApplicationUserId"); .HasForeignKey("ApplicationUserId");
}); });
b.OwnsOne("Hutopy.Infrastructure.Identity.OwnedEntities.StoredDataUrls", "StoredDataUrls", b1 => b.OwnsOne("Hutopy.Infrastructure.Identity.ApplicationUser.StoredDataUrls#Hutopy.Infrastructure.Identity.OwnedEntities.StoredDataUrls", "StoredDataUrls", b1 =>
{ {
b1.Property<string>("ApplicationUserId") b1.Property<string>("ApplicationUserId")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");

View File

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

View File

@@ -6,7 +6,7 @@ public class Content
public Guid CreatedBy { get; init; } public Guid CreatedBy { get; init; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public string? Title { get; init; } public string Title { get; set; }
public string? Description { get; init; } public string Description { get; set; }
public string? Uri { get; init; } public string[]? Urls { get; init; }
} }

View File

@@ -0,0 +1,114 @@
using System.Collections.Concurrent;
using FastEndpoints;
using FluentValidation;
using Hutopy.Application.AzureBlobStorage.Constants;
using Hutopy.Application.Common.Interfaces;
using Hutopy.Web.Common;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
public record PostContentRequest(
Guid Id,
Guid CreatorId,
string Title,
string Description,
IFormFileCollection Files);
public sealed class PostContentRequestValidator : Validator<PostContentRequest>
{
public PostContentRequestValidator()
{
RuleFor(r => r.Id)
.NotNull().WithMessage("You should specify the Id")
.NotEmpty().WithMessage("You should specify a valid/not empty Id");
RuleFor(r => r.CreatorId)
.NotNull().WithMessage("You should specify the CreatorId")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorId");
RuleFor(r => r.Title)
.NotNull().WithMessage("You should specify the Title")
.NotEmpty().WithMessage("You should specify a valid/not empty Title");
RuleFor(r => r.Description)
.NotNull().WithMessage("You should specify the Description")
.NotEmpty().WithMessage("You should specify a valid/not empty Description");
}
}
public sealed class PostContent(
IAzureBlobStorageService blobStorage,
ContentDbContext context)
: Endpoint<PostContentRequest>
{
public override void Configure()
{
Post("/api/contents");
Options(o => o.WithTags("Contents"));
AllowFileUploads();
}
public override async Task HandleAsync(
PostContentRequest req,
CancellationToken ct)
{
var urls = new ConcurrentBag<string>();
await Parallel.ForEachAsync(
req.Files,
ct,
async (file, ict) =>
{
try
{
var contentUrl = await SaveFileAsync(
req.CreatorId,
req.Id,
file,
ict);
urls.Add(contentUrl);
}
catch (Exception ex)
{
Logger.LogError("{ErrorMessage}", ex.Message);
}
});
await context.Contents.AddAsync(
new()
{
Id = req.Id,
CreatedBy = User.GetUserId(),
Title = req.Title,
Description = req.Description,
Urls = urls.ToArray()
},
ct);
await context.SaveChangesAsync(ct);
await SendOkAsync(ct);
}
private async Task<string> SaveFileAsync(
Guid creatorId,
Guid contentId,
IFormFile file,
CancellationToken ct = default)
{
var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream, ct);
// TODO: I would like us to use ContainerNames.Creators but it seems we are missing configurations @jbourdon
var url = await blobStorage.UploadFileAsync(
ContainerNames.Users,
$"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}",
memoryStream,
file.ContentType,
ct: ct);
return url;
}
}

View File

@@ -3,11 +3,11 @@ using FluentValidation;
using Hutopy.Application.Common.Interfaces; using Hutopy.Application.Common.Interfaces;
using Hutopy.Application.Common.Models; using Hutopy.Application.Common.Models;
namespace Hutopy.Web.Features.Creators.Handlers; namespace Hutopy.Web.Features.Contents.Handlers;
public sealed class GetCreatorByAliasRequest public sealed class GetCreatorByAliasRequest
{ {
public string CreatorAlias { get; set; } public string CreatorAlias { get; init; }
} }
public sealed class GetCreatorByAliasRequestValidator public sealed class GetCreatorByAliasRequestValidator
@@ -16,7 +16,8 @@ public sealed class GetCreatorByAliasRequestValidator
public GetCreatorByAliasRequestValidator() public GetCreatorByAliasRequestValidator()
{ {
RuleFor(r => r.CreatorAlias) RuleFor(r => r.CreatorAlias)
.NotNull().WithMessage("You must specify a creator-alias"); .NotNull().WithMessage("You should specify the CreatorAlias")
.NotEmpty().WithMessage("You should specify a valid/not empty CreatorAlias");
} }
} }

View File

@@ -1,39 +0,0 @@
using FastEndpoints;
using Hutopy.Web.Common;
using Hutopy.Web.Features.Contents.Data;
namespace Hutopy.Web.Features.Contents.Handlers;
public record struct PostContentRequest(
string? Title,
string? Description,
string? Uri);
public class PostContent(
ContentDbContext context)
: Endpoint<PostContentRequest>
{
public override void Configure()
{
Post("/api/contents");
Options( o => o.WithTags("Contents"));
}
public override async Task HandleAsync(
PostContentRequest req,
CancellationToken ct)
{
await context.Contents.AddAsync(
new Content
{
Id = GuidHelper.GenerateUuidV7(),
CreatedBy = User.GetUserId(),
Title = req.Title,
Description = req.Description,
Uri = req.Uri
},
ct);
await context.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,59 @@
// <auto-generated />
using System;
using Hutopy.Web.Features.Contents.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Hutopy.Web.Features.Contents.Migrations
{
[DbContext(typeof(ContentDbContext))]
[Migration("20240725022229_AddMultipleMediaUrlsToContent")]
partial class AddMultipleMediaUrlsToContent
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("Content")
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.ToTable("Contents", "Content");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hutopy.Web.Features.Contents.Migrations
{
/// <inheritdoc />
public partial class AddMultipleMediaUrlsToContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Uri",
schema: "Content",
table: "Contents");
migrationBuilder.AddColumn<string[]>(
name: "Urls",
schema: "Content",
table: "Contents",
type: "text[]",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Urls",
schema: "Content",
table: "Contents");
migrationBuilder.AddColumn<string>(
name: "Uri",
schema: "Content",
table: "Contents",
type: "text",
nullable: true);
}
}
}

View File

@@ -23,7 +23,7 @@ namespace Hutopy.Web.Contents.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Hutopy.Web.Contents.Data.Content", b => modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
@@ -43,8 +43,8 @@ namespace Hutopy.Web.Contents.Migrations
b.Property<string>("Title") b.Property<string>("Title")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Uri") b.Property<string[]>("Urls")
.HasColumnType("text"); .HasColumnType("text[]");
b.HasKey("Id"); b.HasKey("Id");

View File

@@ -12,6 +12,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
<PackageReference Include="Azure.Identity" /> <PackageReference Include="Azure.Identity" />
<PackageReference Include="FastEndpoints" /> <PackageReference Include="FastEndpoints" />