feat: replace Hangfire with Coravel

This commit is contained in:
sam 2024-09-03 16:29:51 +02:00
parent ef221b2c45
commit 0aadc5fb47
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
19 changed files with 305 additions and 309 deletions

View file

@ -1,220 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Utils;
using Hangfire;
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
namespace Foxnouns.Backend.Jobs;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global", Justification = "Hangfire jobs need to be public")]
public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config config, ILogger logger)
{
private readonly string[] _validContentTypes = ["image/png", "image/webp", "image/jpeg"];
public static void QueueUpdateUserAvatar(Snowflake id, string? newAvatar)
{
if (newAvatar != null)
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.UpdateUserAvatar(id, newAvatar));
else
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.ClearUserAvatar(id));
}
public static void QueueUpdateMemberAvatar(Snowflake id, string? newAvatar)
{
if (newAvatar != null)
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.UpdateMemberAvatar(id, newAvatar));
else
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.ClearMemberAvatar(id));
}
public async Task UpdateUserAvatar(Snowflake id, string newAvatar)
{
var user = await db.Users.FindAsync(id);
if (user == null)
{
logger.Warning("Update avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
try
{
var image = await ConvertAvatar(newAvatar);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = user.Avatar;
await minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(UserAvatarPath(id, hash))
.WithObjectSize(image.Length)
.WithStreamData(image)
.WithContentType("image/webp")
);
user.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(UserAvatarPath(id, prevHash))
);
logger.Information("Updated avatar for user {UserId}", id);
}
catch (ArgumentException ae)
{
logger.Warning("Invalid data URI for new avatar for user {UserId}: {Reason}", id, ae.Message);
}
}
public async Task ClearUserAvatar(Snowflake id)
{
var user = await db.Users.FindAsync(id);
if (user == null)
{
logger.Warning("Clear avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
if (user.Avatar == null)
{
logger.Warning("Clear avatar job queued for {UserId} with null avatar", id);
return;
}
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(UserAvatarPath(user.Id, user.Avatar))
);
user.Avatar = null;
await db.SaveChangesAsync();
}
public async Task UpdateMemberAvatar(Snowflake id, string newAvatar)
{
var member = await db.Members.FindAsync(id);
if (member == null)
{
logger.Warning("Update avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
try
{
var image = await ConvertAvatar(newAvatar);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = member.Avatar;
await minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(MemberAvatarPath(id, hash))
.WithObjectSize(image.Length)
.WithStreamData(image)
.WithContentType("image/webp")
);
member.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(MemberAvatarPath(id, prevHash))
);
logger.Information("Updated avatar for member {MemberId}", id);
}
catch (ArgumentException ae)
{
logger.Warning("Invalid data URI for new avatar for member {MemberId}: {Reason}", id, ae.Message);
}
}
public async Task ClearMemberAvatar(Snowflake id)
{
var member = await db.Members.FindAsync(id);
if (member == null)
{
logger.Warning("Clear avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
if (member.Avatar == null)
{
logger.Warning("Clear avatar job queued for {MemberId} with null avatar", id);
return;
}
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(MemberAvatarPath(member.Id, member.Avatar))
);
member.Avatar = null;
await db.SaveChangesAsync();
}
/// <summary>
/// Deletes a member's avatar. This should only be used when a member is in the process of being deleted, otherwise,
/// <see cref="QueueUpdateMemberAvatar" /> with a <c>null</c> avatar should be used instead.
/// </summary>
public async Task DeleteMemberAvatar(Snowflake id, string hash) => await DeleteAvatar(MemberAvatarPath(id, hash));
/// <summary>
/// Deletes a user's avatar. This should only be used when a user is in the process of being deleted, otherwise,
/// <see cref="QueueUpdateUserAvatar" /> with a <c>null</c> avatar should be used instead.
/// </summary>
public async Task DeleteUserAvatar(Snowflake id, string hash) => await DeleteAvatar(UserAvatarPath(id, hash));
private async Task DeleteAvatar(string path)
{
logger.Debug("Deleting avatar at path {Path}", path);
try
{
await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path));
}
catch (InvalidObjectNameException)
{
}
}
private async Task<Stream> ConvertAvatar(string uri)
{
if (!uri.StartsWith("data:image/"))
throw new ArgumentException("Not a data URI", nameof(uri));
var split = uri.Remove(0, "data:".Length).Split(";base64,");
var contentType = split[0];
var encoded = split[1];
if (!_validContentTypes.Contains(contentType))
throw new ArgumentException("Invalid content type for image", nameof(uri));
if (!AuthUtils.TryFromBase64String(encoded, out var rawImage))
throw new ArgumentException("Invalid base64 string", nameof(uri));
var image = Image.Load(rawImage);
var processor = new ResizeProcessor(
new ResizeOptions { Size = new Size(512), Mode = ResizeMode.Crop, Position = AnchorPositionMode.Center },
image.Size
);
image.Mutate(x => x.ApplyProcessor(processor));
var stream = new MemoryStream(64 * 1024);
await image.SaveAsync(stream, new WebpEncoder { Quality = 95, NearLossless = false });
return stream;
}
private static string UserAvatarPath(Snowflake id, string hash) => $"users/{id}/avatars/{hash}.webp";
private static string MemberAvatarPath(Snowflake id, string hash) => $"members/{id}/avatars/{hash}.webp";
}

View file

@ -0,0 +1,79 @@
using System.Security.Cryptography;
using Coravel.Invocable;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Jobs;
public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger)
: IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
public async Task Invoke()
{
if (Payload.NewAvatar != null) await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar);
else await ClearMemberAvatarAsync(Payload.Id);
}
private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar)
{
_logger.Debug("Updating avatar for member {MemberId}", id);
var member = await db.Members.FindAsync(id);
if (member == null)
{
_logger.Warning("Update avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
try
{
var image = await newAvatar.ConvertBase64UriToAvatar();
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = member.Avatar;
await objectStorage.PutObjectAsync(Path(id, hash), image, "image/webp");
member.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await objectStorage.RemoveObjectAsync(Path(id, prevHash));
_logger.Information("Updated avatar for member {MemberId}", id);
}
catch (ArgumentException ae)
{
_logger.Warning("Invalid data URI for new avatar for member {MemberId}: {Reason}", id, ae.Message);
}
}
private async Task ClearMemberAvatarAsync(Snowflake id)
{
_logger.Debug("Clearing avatar for member {MemberId}", id);
var member = await db.Members.FindAsync(id);
if (member == null)
{
_logger.Warning("Clear avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
if (member.Avatar == null)
{
_logger.Warning("Clear avatar job queued for {MemberId} with null avatar", id);
return;
}
await objectStorage.RemoveObjectAsync(Path(member.Id, member.Avatar));
member.Avatar = null;
await db.SaveChangesAsync();
}
public static string Path(Snowflake id, string hash) => $"members/{id}/avatars/{hash}.webp";
}

View file

@ -0,0 +1,5 @@
using Foxnouns.Backend.Database;
namespace Foxnouns.Backend.Jobs;
public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar);

View file

@ -0,0 +1,79 @@
using System.Security.Cryptography;
using Coravel.Invocable;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Jobs;
public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger)
: IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
public async Task Invoke()
{
if (Payload.NewAvatar != null) await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar);
else await ClearUserAvatarAsync(Payload.Id);
}
private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar)
{
_logger.Debug("Updating avatar for user {MemberId}", id);
var user = await db.Users.FindAsync(id);
if (user == null)
{
_logger.Warning("Update avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
try
{
var image = await newAvatar.ConvertBase64UriToAvatar();
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = user.Avatar;
await objectStorage.PutObjectAsync(Path(id, hash), image, "image/webp");
user.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await objectStorage.RemoveObjectAsync(Path(id, prevHash));
_logger.Information("Updated avatar for user {UserId}", id);
}
catch (ArgumentException ae)
{
_logger.Warning("Invalid data URI for new avatar for user {UserId}: {Reason}", id, ae.Message);
}
}
private async Task ClearUserAvatarAsync(Snowflake id)
{
_logger.Debug("Clearing avatar for user {MemberId}", id);
var user = await db.Users.FindAsync(id);
if (user == null)
{
_logger.Warning("Clear avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
if (user.Avatar == null)
{
_logger.Warning("Clear avatar job queued for {UserId} with null avatar", id);
return;
}
await objectStorage.RemoveObjectAsync(Path(user.Id, user.Avatar));
user.Avatar = null;
await db.SaveChangesAsync();
}
public static string Path(Snowflake id, string hash) => $"users/{id}/avatars/{hash}.webp";
}