From a70078995b100aef502405860e48e97816437fc8 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 26 Sep 2024 20:15:04 +0200 Subject: [PATCH 1/3] feat(backend): add pride flag models --- .../Controllers/FlagsController.cs | 28 ++++ Foxnouns.Backend/Database/DatabaseContext.cs | 7 + .../Migrations/20240926180037_AddFlags.cs | 129 +++++++++++++++ .../DatabaseContextModelSnapshot.cs | 148 ++++++++++++++++++ Foxnouns.Backend/Database/Models/Member.cs | 2 + Foxnouns.Backend/Database/Models/PrideFlag.cs | 25 +++ Foxnouns.Backend/Database/Models/User.cs | 3 + .../Services/MemberRendererService.cs | 2 + .../Services/UserRendererService.cs | 2 + 9 files changed, 346 insertions(+) create mode 100644 Foxnouns.Backend/Controllers/FlagsController.cs create mode 100644 Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs create mode 100644 Foxnouns.Backend/Database/Models/PrideFlag.cs diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs new file mode 100644 index 0000000..b08dcef --- /dev/null +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -0,0 +1,28 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Middleware; +using Foxnouns.Backend.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Foxnouns.Backend.Controllers; + +[Route("/api/v2/users/@me/flags")] +public class FlagsController(DatabaseContext db, UserRendererService userRenderer) : ApiControllerBase +{ + [HttpGet] + [Authorize("identify")] + [ProducesResponseType>(statusCode: StatusCodes.Status200OK)] + public async Task GetFlagsAsync(CancellationToken ct = default) + { + var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct); + + return Ok(flags.Select(f => new PrideFlagResponse( + f.Id, userRenderer.ImageUrlFor(f), f.Name, f.Description))); + } + + private record PrideFlagResponse( + Snowflake Id, + string ImageUrl, + string Name, + string? Description); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index e907abd..81866fe 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -21,6 +21,10 @@ public class DatabaseContext : DbContext public DbSet Tokens { get; set; } public DbSet Applications { get; set; } public DbSet TemporaryKeys { get; set; } + + public DbSet PrideFlags { get; set; } + public DbSet UserFlags { get; set; } + public DbSet MemberFlags { get; set; } public DatabaseContext(Config config, ILoggerFactory? loggerFactory) { @@ -77,6 +81,9 @@ public class DatabaseContext : DbContext modelBuilder.Entity().Property(m => m.Names).HasColumnType("jsonb"); modelBuilder.Entity().Property(m => m.Pronouns).HasColumnType("jsonb"); + modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); + modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); + modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!) .HasName("find_free_user_sid"); diff --git a/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs b/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs new file mode 100644 index 0000000..ae59b0b --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20240926180037_AddFlags")] + public partial class AddFlags : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "pride_flags", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + user_id = table.Column(type: "bigint", nullable: false), + hash = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + description = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_pride_flags", x => x.id); + table.ForeignKey( + name: "fk_pride_flags_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "member_flags", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + member_id = table.Column(type: "bigint", nullable: false), + pride_flag_id = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_member_flags", x => x.id); + table.ForeignKey( + name: "fk_member_flags_members_member_id", + column: x => x.member_id, + principalTable: "members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_member_flags_pride_flags_pride_flag_id", + column: x => x.pride_flag_id, + principalTable: "pride_flags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_flags", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "bigint", nullable: false), + pride_flag_id = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_flags", x => x.id); + table.ForeignKey( + name: "fk_user_flags_pride_flags_pride_flag_id", + column: x => x.pride_flag_id, + principalTable: "pride_flags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_flags_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_member_flags_member_id", + table: "member_flags", + column: "member_id"); + + migrationBuilder.CreateIndex( + name: "ix_member_flags_pride_flag_id", + table: "member_flags", + column: "pride_flag_id"); + + migrationBuilder.CreateIndex( + name: "ix_pride_flags_user_id", + table: "pride_flags", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_flags_pride_flag_id", + table: "user_flags", + column: "pride_flag_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_flags_user_id", + table: "user_flags", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "member_flags"); + + migrationBuilder.DropTable( + name: "user_flags"); + + migrationBuilder.DropTable( + name: "pride_flags"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index dd457cf..e1e05c2 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -204,6 +204,68 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("members", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("member_id"); + + b.Property("PrideFlagId") + .HasColumnType("bigint") + .HasColumnName("pride_flag_id"); + + b.HasKey("Id") + .HasName("pk_member_flags"); + + b.HasIndex("MemberId") + .HasDatabaseName("ix_member_flags_member_id"); + + b.HasIndex("PrideFlagId") + .HasDatabaseName("ix_member_flags_pride_flag_id"); + + b.ToTable("member_flags", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hash"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_pride_flags"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_pride_flags_user_id"); + + b.ToTable("pride_flags", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => { b.Property("Id") @@ -391,6 +453,35 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("users", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrideFlagId") + .HasColumnType("bigint") + .HasColumnName("pride_flag_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_flags"); + + b.HasIndex("PrideFlagId") + .HasDatabaseName("ix_user_flags_pride_flag_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_flags_user_id"); + + b.ToTable("user_flags", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => { b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") @@ -422,6 +513,35 @@ namespace Foxnouns.Backend.Database.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Member", null) + .WithMany("ProfileFlags") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_flags_members_member_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") + .WithMany() + .HasForeignKey("PrideFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_flags_pride_flags_pride_flag_id"); + + b.Navigation("PrideFlag"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", null) + .WithMany("Flags") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pride_flags_users_user_id"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => { b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") @@ -443,11 +563,39 @@ namespace Foxnouns.Backend.Database.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") + .WithMany() + .HasForeignKey("PrideFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_flags_pride_flags_pride_flag_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", null) + .WithMany("ProfileFlags") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_flags_users_user_id"); + + b.Navigation("PrideFlag"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.Navigation("ProfileFlags"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => { b.Navigation("AuthMethods"); + b.Navigation("Flags"); + b.Navigation("Members"); + + b.Navigation("ProfileFlags"); }); #pragma warning restore 612, 618 } diff --git a/Foxnouns.Backend/Database/Models/Member.cs b/Foxnouns.Backend/Database/Models/Member.cs index d20d374..ceaf84a 100644 --- a/Foxnouns.Backend/Database/Models/Member.cs +++ b/Foxnouns.Backend/Database/Models/Member.cs @@ -14,6 +14,8 @@ public class Member : BaseModel public List Pronouns { get; set; } = []; public List Fields { get; set; } = []; + public List ProfileFlags { get; set; } = []; + public Snowflake UserId { get; init; } public User User { get; init; } = null!; } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Models/PrideFlag.cs b/Foxnouns.Backend/Database/Models/PrideFlag.cs new file mode 100644 index 0000000..b7f91cf --- /dev/null +++ b/Foxnouns.Backend/Database/Models/PrideFlag.cs @@ -0,0 +1,25 @@ +namespace Foxnouns.Backend.Database.Models; + +public class PrideFlag : BaseModel +{ + public required Snowflake UserId { get; init; } + public required string Hash { get; init; } + public required string Name { get; set; } + public string? Description { get; set; } +} + +public class UserFlag +{ + public long Id { get; init; } + public required Snowflake UserId { get; init; } + public required Snowflake PrideFlagId { get; init; } + public PrideFlag PrideFlag { get; init; } = null!; +} + +public class MemberFlag +{ + public long Id { get; init; } + public required Snowflake MemberId { get; init; } + public required Snowflake PrideFlagId { get; init; } + public PrideFlag PrideFlag { get; init; } = null!; +} \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 112e560..2328fbe 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -21,6 +21,9 @@ public class User : BaseModel public List Fields { get; set; } = []; public Dictionary CustomPreferences { get; set; } = []; + public List Flags { get; set; } = []; + public List ProfileFlags { get; set; } = []; + public UserRole Role { get; set; } = UserRole.User; public string? Password { get; set; } // Password may be null if the user doesn't authenticate with an email address diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs index 625e8b8..11416f0 100644 --- a/Foxnouns.Backend/Services/MemberRendererService.cs +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -47,6 +47,8 @@ public class MemberRendererService(DatabaseContext db, Config config) private string? AvatarUrlFor(User user) => user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; + private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; + public record PartialMember( Snowflake Id, string Sid, diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 0f8fe2e..688214d 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -58,6 +58,8 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe private string? AvatarUrlFor(User user) => user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; + + public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; public record UserResponse( Snowflake Id, From ff2ba1fb1bd77153fb14c6559bd3430e748d7f30 Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 26 Sep 2024 22:25:47 +0200 Subject: [PATCH 2/3] fix(backend): correctly hash images --- Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs | 9 +++++++-- Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs | 2 +- Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs index 7c39aa4..c3c3c04 100644 --- a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs @@ -23,7 +23,7 @@ public static class AvatarObjectExtensions CancellationToken ct = default) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); - public static async Task ConvertBase64UriToAvatar(this string uri) + public static async Task ConvertBase64UriToImage(this string uri, int size, bool crop) { if (!uri.StartsWith("data:image/")) throw new ArgumentException("Not a data URI", nameof(uri)); @@ -40,7 +40,12 @@ public static class AvatarObjectExtensions var image = Image.Load(rawImage); var processor = new ResizeProcessor( - new ResizeOptions { Size = new Size(512), Mode = ResizeMode.Crop, Position = AnchorPositionMode.Center }, + new ResizeOptions + { + Size = new Size(size), + Mode = crop ? ResizeMode.Crop : ResizeMode.Max, + Position = AnchorPositionMode.Center + }, image.Size ); diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs index 3beff48..7ed801a 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs @@ -31,7 +31,7 @@ public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageServic try { - var image = await newAvatar.ConvertBase64UriToAvatar(); + var image = await newAvatar.ConvertBase64UriToImage(size: 512, crop: true); var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower(); image.Seek(0, SeekOrigin.Begin); var prevHash = member.Avatar; diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs index d1abd42..44dd312 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs @@ -31,7 +31,7 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService try { - var image = await newAvatar.ConvertBase64UriToAvatar(); + var image = await newAvatar.ConvertBase64UriToImage(size: 512, crop: true); var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower(); image.Seek(0, SeekOrigin.Begin); var prevHash = user.Avatar; From 14e6e35cb7aa4b7ebc1754582ee1036d51438a5b Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 26 Sep 2024 22:26:40 +0200 Subject: [PATCH 3/3] feat(backend): add create flag endpoint and job --- .../Controllers/FlagsController.cs | 32 +++++++++-- .../Extensions/WebApplicationExtensions.cs | 3 +- Foxnouns.Backend/Jobs/CreateFlagInvocable.cs | 53 +++++++++++++++++++ Foxnouns.Backend/Jobs/Payloads.cs | 4 +- 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 Foxnouns.Backend/Jobs/CreateFlagInvocable.cs diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index b08dcef..7bf20e5 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -1,4 +1,7 @@ +using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Microsoft.AspNetCore.Mvc; @@ -7,7 +10,11 @@ using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/users/@me/flags")] -public class FlagsController(DatabaseContext db, UserRendererService userRenderer) : ApiControllerBase +public class FlagsController( + DatabaseContext db, + UserRendererService userRenderer, + ISnowflakeGenerator snowflakeGenerator, + IQueue queue) : ApiControllerBase { [HttpGet] [Authorize("identify")] @@ -16,10 +23,29 @@ public class FlagsController(DatabaseContext db, UserRendererService userRendere { var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct); - return Ok(flags.Select(f => new PrideFlagResponse( - f.Id, userRenderer.ImageUrlFor(f), f.Name, f.Description))); + return Ok(flags.Select(ToResponse)); } + [HttpPost] + [Authorize("user.update")] + [ProducesResponseType(statusCode: StatusCodes.Status202Accepted)] + public IActionResult CreateFlag([FromBody] CreateFlagRequest req) + { + var id = snowflakeGenerator.GenerateSnowflake(); + + queue.QueueInvocableWithPayload( + new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description)); + + return Accepted(new CreateFlagResponse(id, req.Name, req.Description)); + } + + public record CreateFlagRequest(string Name, string Image, string? Description); + + public record CreateFlagResponse(Snowflake Id, string Name, string? Description); + + private PrideFlagResponse ToResponse(PrideFlag flag) => + new(flag.Id, userRenderer.ImageUrlFor(flag), flag.Name, flag.Description); + private record PrideFlagResponse( Snowflake Id, string ImageUrl, diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index a5b2af6..e249fd7 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -99,7 +99,8 @@ public static class WebApplicationExtensions .AddHostedService() // Transient jobs .AddTransient() - .AddTransient(); + .AddTransient() + .AddTransient(); if (!config.Logging.EnableMetrics) services.AddHostedService(); diff --git a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs new file mode 100644 index 0000000..6f69794 --- /dev/null +++ b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; +using Coravel.Invocable; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Services; + +namespace Foxnouns.Backend.Jobs; + +public class CreateFlagInvocable(DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger) + : IInvocable, IInvocableWithPayload +{ + private readonly ILogger _logger = logger.ForContext(); + public required CreateFlagPayload Payload { get; set; } + + public async Task Invoke() + { + _logger.Information("Creating flag {FlagId} for user {UserId} with image data length {DataLength}", Payload.Id, + Payload.UserId, Payload.ImageData.Length); + + try + { + var image = await Payload.ImageData.ConvertBase64UriToImage(size: 256, crop: false); + image.Seek(0, SeekOrigin.Begin); + var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower(); + image.Seek(0, SeekOrigin.Begin); + + await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp"); + + var flag = new PrideFlag + { + Id = Payload.Id, + UserId = Payload.UserId, + Hash = hash, + Name = Payload.Name, + Description = Payload.Description + }; + db.Add(flag); + + await db.SaveChangesAsync(); + + _logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash); + } + catch (ArgumentException ae) + { + _logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", Payload.Id, ae.Message); + } + + throw new NotImplementedException(); + } + + private static string Path(string hash) => $"flags/{hash}.webp"; +} \ No newline at end of file diff --git a/Foxnouns.Backend/Jobs/Payloads.cs b/Foxnouns.Backend/Jobs/Payloads.cs index f28254a..672f6d6 100644 --- a/Foxnouns.Backend/Jobs/Payloads.cs +++ b/Foxnouns.Backend/Jobs/Payloads.cs @@ -2,4 +2,6 @@ using Foxnouns.Backend.Database; namespace Foxnouns.Backend.Jobs; -public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); \ No newline at end of file +public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); + +public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string Name, string ImageData, string? Description); \ No newline at end of file