diff --git a/src/Infrastructure/AzureBlob/AzureBlobStorage.cs b/src/Infrastructure/AzureBlob/AzureBlobStorage.cs index 53b4342..fab5301 100644 --- a/src/Infrastructure/AzureBlob/AzureBlobStorage.cs +++ b/src/Infrastructure/AzureBlob/AzureBlobStorage.cs @@ -50,8 +50,8 @@ public class AzureBlobStorage : IBlobStorage if (!ContentTypes.IsAllowed(contentType, stream)) { _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."); + $"Blob storage: Unsupported file type {contentType}."); + throw new InvalidOperationException("Unsupported file type."); } try diff --git a/src/Infrastructure/AzureBlob/ContentTypes.cs b/src/Infrastructure/AzureBlob/ContentTypes.cs index 2637fc9..e7f2da3 100644 --- a/src/Infrastructure/AzureBlob/ContentTypes.cs +++ b/src/Infrastructure/AzureBlob/ContentTypes.cs @@ -1,12 +1,15 @@ -namespace Hutopy.Infrastructure.AzureBlob; +using System.Text; + +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"; + private static string TextHtml = "text/html"; - public static HashSet AllowedContentTypes = new HashSet { ImagePng, ImageJpeg, ImageJpg }; + public static HashSet AllowedContentTypes = new HashSet { ImagePng, ImageJpeg, ImageJpg, TextHtml }; public static bool IsAllowed(string contentType, Stream fileStream) { @@ -15,7 +18,7 @@ public static class ContentTypes private static bool IsValidFileType(Stream fileStream) { - byte[] buffer = new byte[4]; + byte[] buffer = new byte[512]; fileStream.Read(buffer, 0, buffer.Length); fileStream.Position = 0; @@ -30,6 +33,13 @@ public static class ContentTypes { return true; } + + // Check for HTML content by looking for "" or "" tags + string content = Encoding.UTF8.GetString(buffer); + if (content.Contains("")) + { + return true; + } return false; } diff --git a/src/Web/Features/Contents/Data/Content.cs b/src/Web/Features/Contents/Data/Content.cs index f782698..fcccd4b 100644 --- a/src/Web/Features/Contents/Data/Content.cs +++ b/src/Web/Features/Contents/Data/Content.cs @@ -11,7 +11,8 @@ public class Content public Guid? DeletedBy { get; set; } public DateTimeOffset? DeletedAt { get; set; } [MaxLength(128)] public required string Title { get; set; } - [MaxLength(2048)] public required string Description { get; set; } + [MaxLength(2048)] public string Description { get; set; } = ""; + public string? HtmlFileUrl { get; set; } = ""; public IList Reactions { get; set; } = new List(); public string[]? Urls { get; init; } } diff --git a/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs b/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs new file mode 100644 index 0000000..22a055c --- /dev/null +++ b/src/Web/Features/Contents/Handlers/CreateContentFromHtml.cs @@ -0,0 +1,116 @@ +using System.Text; +using Hutopy.Application.AzureBlobStorage.Constants; +using Hutopy.Application.Common.Interfaces; +using Hutopy.Web.Common; +using Hutopy.Web.Features.Contents.Data; +using Hutopy.Web.Features.Contents.Handlers.Models; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record PostContentFromHtmlRequest( + Guid Id, + Guid CreatorId, + string Title, + string HtmlContent + ); + +[PublicAPI] +public sealed class PostContentFromHtmlRequestValidator : Validator +{ + public PostContentFromHtmlRequestValidator() + { + 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"); + } +} + +public sealed class PostContentHtml( + IBlobStorage blobStorage, + ContentDbContext context) + : Endpoint +{ + public override void Configure() + { + Post("/api/contents/html"); + Options(o => o.WithTags("Contents")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + PostContentFromHtmlRequest req, + CancellationToken ct) + { + var htmlFileUrl = await SaveHtmlContentAsHtmlFileAsync( + req.CreatorId, + req.Id, + req.HtmlContent, + ct); + + await context.Contents.AddAsync( + new Content + { + Id = req.Id, + CreatedBy = User.GetUserId(), + Title = req.Title, + HtmlFileUrl = htmlFileUrl + }, + ct); + + await context.SaveChangesAsync(ct); + + var content = await context + .Contents + .Select(c => new ContentModel + { + Id = c.Id, + CreatedBy = c.CreatedBy, + CreatedByName = c.Creator!.Name, + CreatedByPortraitUrl = c.Creator.Images.Logo, + CreatedAt = c.CreatedAt, + DeletedBy = c.DeletedBy, + DeletedAt = c.DeletedAt, + Title = c.Title, + Description = c.Description, + Urls = c.Urls, + HtmlFileUrl = htmlFileUrl + }) + .SingleOrDefaultAsync( + c => c.Id == req.Id, + cancellationToken: ct); + + await SendOkAsync(content, ct); + } + + private async Task SaveHtmlContentAsHtmlFileAsync( + Guid creatorId, + Guid contentId, + string htmlContent, + CancellationToken ct = default) + { + var fileName = $"{contentId}.html"; + var filePath = $"{creatorId}/{SubDirectoryNames.Contents}/{fileName}"; + + // Convert the HTML string into a stream + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(htmlContent)); + + // Upload the stream as an HTML file + var url = await blobStorage.UploadFileAsync( + ContainerNames.Creators, + filePath, + stream, + "text/html", + ct: ct); + + return url; + } +} diff --git a/src/Web/Features/Contents/Handlers/GetContent.cs b/src/Web/Features/Contents/Handlers/GetContent.cs index fddba07..5bae8b6 100644 --- a/src/Web/Features/Contents/Handlers/GetContent.cs +++ b/src/Web/Features/Contents/Handlers/GetContent.cs @@ -40,6 +40,7 @@ public class GetContent( Title = c.Title, Description = c.Description, Urls = c.Urls, + HtmlFileUrl = c.HtmlFileUrl ?? "", Reactions = c.Reactions.Select(x => new ReactionModel { Reaction = x.Reaction.FromEnum(), UserId = x.UserId, UserName = x.UserName diff --git a/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs b/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs index 9689808..5a48d0c 100644 --- a/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs +++ b/src/Web/Features/Contents/Handlers/GetContentsByCreator.cs @@ -51,6 +51,7 @@ public class GetContentsByCreatorHandler( Title = c.Title, Description = c.Description, Urls = c.Urls, + HtmlFileUrl = c.HtmlFileUrl ?? "", Reactions = c.Reactions.Select(x => new ReactionModel { Reaction = x.Reaction.FromEnum(), diff --git a/src/Web/Features/Contents/Handlers/InsertImage.cs b/src/Web/Features/Contents/Handlers/InsertImage.cs new file mode 100644 index 0000000..5db7d5c --- /dev/null +++ b/src/Web/Features/Contents/Handlers/InsertImage.cs @@ -0,0 +1,84 @@ +using Hutopy.Application.AzureBlobStorage.Constants; +using Hutopy.Application.Common.Interfaces; + +namespace Hutopy.Web.Features.Contents.Handlers; + +[PublicAPI] +public record InsertImagesRequest( + Guid Id, + Guid CreatorId, + IFormFileCollection? Files + ); + +[PublicAPI] +public sealed class InsertImagesRequestValidator : Validator +{ + public InsertImagesRequestValidator() + { + 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"); + } +} + +public sealed class InsertImages(IBlobStorage blobStorage) : Endpoint +{ + public override void Configure() + { + Post("/api/content/insert-image/"); + Options(o => o.WithTags("Contents")); + AllowFileUploads(); + } + + public override async Task HandleAsync( + InsertImagesRequest req, + CancellationToken ct) + { + var urls = new List(); + if (req.Files is not null) + { + 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 SendOkAsync(urls, ct); + } + + private async Task SaveFileAsync( + Guid creatorId, + Guid contentId, + IFormFile file, + CancellationToken ct = default) + { + var url = await blobStorage.UploadFileAsync( + ContainerNames.Creators, + $"{creatorId}/{SubDirectoryNames.Contents}/{contentId}/{file.FileName}", + file.OpenReadStream(), + file.ContentType, + ct: ct); + + return url; + } +} diff --git a/src/Web/Features/Contents/Handlers/Models/ContentModel.cs b/src/Web/Features/Contents/Handlers/Models/ContentModel.cs index d91b23f..e053c53 100644 --- a/src/Web/Features/Contents/Handlers/Models/ContentModel.cs +++ b/src/Web/Features/Contents/Handlers/Models/ContentModel.cs @@ -12,6 +12,7 @@ public class ContentModel public DateTimeOffset? DeletedAt { get; init; } public required string Title { get; init; } public required string Description { get; init; } + public string HtmlFileUrl { get; init; } = ""; public required string[]? Urls { get; init; } public IList? Reactions { get; set; } = new List(); } diff --git a/src/Web/Features/Contents/Migrations/20241012183354_AddHtmlFileUrl.Designer.cs b/src/Web/Features/Contents/Migrations/20241012183354_AddHtmlFileUrl.Designer.cs new file mode 100644 index 0000000..40fc57e --- /dev/null +++ b/src/Web/Features/Contents/Migrations/20241012183354_AddHtmlFileUrl.Designer.cs @@ -0,0 +1,313 @@ +// +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("20241012183354_AddHtmlFileUrl")] + partial class AddHtmlFileUrl + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("HtmlFileUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Urls") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy"); + + b.ToTable("Contents", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.HasKey("Id"); + + b.ToTable("Creators", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b => + { + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CreatedBy", "CreatorId"); + + b.HasIndex("CreatorId"); + + b.ToTable("Subscriptions", "Content"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Content", b => + { + b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Hutopy.Web.Features.Contents.Data.ContentReaction", "Reactions", b1 => + { + b1.Property("ContentId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Reaction") + .HasColumnType("integer"); + + b1.Property("UserId") + .HasColumnType("uuid"); + + b1.Property("UserName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b1.HasKey("ContentId", "Id"); + + b1.ToTable("ContentReactions", "Content"); + + b1.WithOwner() + .HasForeignKey("ContentId"); + }); + + b.Navigation("Creator"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b => + { + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Colors", "Colors", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Background") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Error") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnBackground") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnError") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnPrimary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnSecondary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("OnSurface") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Primary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Secondary") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.Property("Surface") + .IsRequired() + .HasMaxLength(9) + .HasColumnType("character varying(9)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Colors", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Images", "Images", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("Banner") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("Logo") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Images", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.OwnsOne("Hutopy.Web.Features.Contents.Data.Socials", "Socials", b1 => + { + b1.Property("CreatorId") + .HasColumnType("uuid"); + + b1.Property("FacebookUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("InstagramUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("LinkedInUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("RedditUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("TikTokUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("WebsiteUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("XUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.Property("YoutubeUrl") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b1.HasKey("CreatorId"); + + b1.ToTable("Socials", "Content"); + + b1.WithOwner() + .HasForeignKey("CreatorId"); + }); + + b.Navigation("Colors") + .IsRequired(); + + b.Navigation("Images") + .IsRequired(); + + b.Navigation("Socials") + .IsRequired(); + }); + + modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Subscription", b => + { + b.HasOne("Hutopy.Web.Features.Contents.Data.Creator", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Web/Features/Contents/Migrations/20241012183354_AddHtmlFileUrl.cs b/src/Web/Features/Contents/Migrations/20241012183354_AddHtmlFileUrl.cs new file mode 100644 index 0000000..25593d2 --- /dev/null +++ b/src/Web/Features/Contents/Migrations/20241012183354_AddHtmlFileUrl.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Hutopy.Web.Features.Contents.Migrations +{ + /// + public partial class AddHtmlFileUrl : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HtmlFileUrl", + schema: "Content", + table: "Contents", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HtmlFileUrl", + schema: "Content", + table: "Contents"); + } + } +} diff --git a/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs b/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs index f67224a..c38d421 100644 --- a/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs +++ b/src/Web/Features/Contents/Migrations/ContentDbContextModelSnapshot.cs @@ -48,6 +48,9 @@ namespace Hutopy.Web.Features.Contents.Migrations .HasMaxLength(2048) .HasColumnType("character varying(2048)"); + b.Property("HtmlFileUrl") + .HasColumnType("text"); + b.Property("Title") .IsRequired() .HasMaxLength(128)