Merged PR 138: ContentEditor into main

ContentEditor possibility to upload image in html post
This commit is contained in:
Dominic Villemure
2024-10-13 15:00:51 +00:00
11 changed files with 566 additions and 6 deletions

View File

@@ -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

View File

@@ -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<string> AllowedContentTypes = new HashSet<string> { ImagePng, ImageJpeg, ImageJpg };
public static HashSet<string> AllowedContentTypes = new HashSet<string> { 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 "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer);
if (content.Contains("<!DOCTYPE html>"))
{
return true;
}
return false;
}

View File

@@ -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<ContentReaction> Reactions { get; set; } = new List<ContentReaction>();
public string[]? Urls { get; init; }
}

View File

@@ -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<PostContentFromHtmlRequest>
{
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<PostContentFromHtmlRequest>
{
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<string> 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;
}
}

View File

@@ -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

View File

@@ -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(),

View File

@@ -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<InsertImagesRequest>
{
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<InsertImagesRequest>
{
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<string>();
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<string> 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;
}
}

View File

@@ -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<ReactionModel>? Reactions { get; set; } = new List<ReactionModel>();
}

View File

@@ -0,0 +1,313 @@
// <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("20241012183354_AddHtmlFileUrl")]
partial class AddHtmlFileUrl
{
/// <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<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string[]>("Urls")
.HasColumnType("text[]");
b.HasKey("Id");
b.HasIndex("CreatedBy");
b.ToTable("Contents", "Content");
});
modelBuilder.Entity("Hutopy.Web.Features.Contents.Data.Creator", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("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<Guid>("CreatedBy")
.HasColumnType("uuid");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("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<Guid>("ContentId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<int>("Reaction")
.HasColumnType("integer");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Background")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Error")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnBackground")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnError")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnPrimary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSecondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("OnSurface")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Primary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("Secondary")
.IsRequired()
.HasMaxLength(9)
.HasColumnType("character varying(9)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("Banner")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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<Guid>("CreatorId")
.HasColumnType("uuid");
b1.Property<string>("FacebookUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("InstagramUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("LinkedInUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("RedditUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("TikTokUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("WebsiteUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("XUrl")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b1.Property<string>("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
}
}
}

View File

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

View File

@@ -48,6 +48,9 @@ namespace Hutopy.Web.Features.Contents.Migrations
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("HtmlFileUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(128)