feat: replace Hangfire with Coravel
This commit is contained in:
parent
ef221b2c45
commit
0aadc5fb47
19 changed files with 305 additions and 309 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@ bin/
|
|||
obj/
|
||||
.version
|
||||
config.ini
|
||||
*.DotSettings.user
|
||||
|
|
|
@ -14,7 +14,6 @@ public class Config
|
|||
|
||||
public LoggingConfig Logging { get; init; } = new();
|
||||
public DatabaseConfig Database { get; init; } = new();
|
||||
public JobsConfig Jobs { get; init; } = new();
|
||||
public StorageConfig Storage { get; init; } = new();
|
||||
public DiscordAuthConfig DiscordAuth { get; init; } = new();
|
||||
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
||||
|
@ -38,12 +37,6 @@ public class Config
|
|||
public int? MaxPoolSize { get; init; }
|
||||
}
|
||||
|
||||
public class JobsConfig
|
||||
{
|
||||
public string Redis { get; init; } = string.Empty;
|
||||
public int Workers { get; init; } = 5;
|
||||
}
|
||||
|
||||
public class StorageConfig
|
||||
{
|
||||
public string Endpoint { get; init; } = string.Empty;
|
||||
|
|
|
@ -19,7 +19,7 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger
|
|||
config.TumblrAuth.Enabled);
|
||||
var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync());
|
||||
string? discord = null;
|
||||
if (config.DiscordAuth.ClientId != null && config.DiscordAuth.ClientSecret != null)
|
||||
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
|
||||
discord =
|
||||
$"https://discord.com/oauth2/authorize?response_type=code" +
|
||||
$"&client_id={config.DiscordAuth.ClientId}&scope=identify" +
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
using Coravel.Queuing.Interfaces;
|
||||
using EntityFramework.Exceptions.Common;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Jobs;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Foxnouns.Backend.Services;
|
||||
|
@ -16,7 +18,8 @@ public class MembersController(
|
|||
DatabaseContext db,
|
||||
MemberRendererService memberRendererService,
|
||||
ISnowflakeGenerator snowflakeGenerator,
|
||||
AvatarUpdateJob avatarUpdate) : ApiControllerBase
|
||||
ObjectStorageService objectStorage,
|
||||
IQueue queue) : ApiControllerBase
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
||||
|
||||
|
@ -76,7 +79,9 @@ public class MembersController(
|
|||
throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name);
|
||||
}
|
||||
|
||||
if (req.Avatar != null) AvatarUpdateJob.QueueUpdateMemberAvatar(member.Id, req.Avatar);
|
||||
if (req.Avatar != null)
|
||||
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||
new AvatarUpdatePayload(member.Id, req.Avatar));
|
||||
|
||||
return Ok(memberRendererService.RenderMember(member, CurrentToken));
|
||||
}
|
||||
|
@ -96,7 +101,7 @@ public class MembersController(
|
|||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (member.Avatar != null) await avatarUpdate.DeleteMemberAvatar(member.Id, member.Avatar);
|
||||
if (member.Avatar != null) await objectStorage.DeleteMemberAvatarAsync(member.Id, member.Avatar);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using Coravel.Queuing.Interfaces;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Jobs;
|
||||
|
@ -14,7 +15,8 @@ namespace Foxnouns.Backend.Controllers;
|
|||
public class UsersController(
|
||||
DatabaseContext db,
|
||||
UserRendererService userRendererService,
|
||||
ISnowflakeGenerator snowflakeGenerator) : ApiControllerBase
|
||||
ISnowflakeGenerator snowflakeGenerator,
|
||||
IQueue queue) : ApiControllerBase
|
||||
{
|
||||
[HttpGet("{userRef}")]
|
||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
|
@ -70,7 +72,8 @@ public class UsersController(
|
|||
// (atomic operations are hard when combined with background jobs)
|
||||
// so it's in a separate block to the validation above.
|
||||
if (req.HasProperty(nameof(req.Avatar)))
|
||||
AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar);
|
||||
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
|
51
Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs
Normal file
51
Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs
Normal file
|
@ -0,0 +1,51 @@
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Jobs;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using SixLabors.ImageSharp.Processing.Processors.Transforms;
|
||||
|
||||
namespace Foxnouns.Backend.Extensions;
|
||||
|
||||
public static class AvatarObjectExtensions
|
||||
{
|
||||
private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"];
|
||||
|
||||
public static async Task
|
||||
DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) =>
|
||||
await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash));
|
||||
|
||||
public static async Task
|
||||
DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) =>
|
||||
await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash));
|
||||
|
||||
public static async Task<Stream> ConvertBase64UriToAvatar(this 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;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
using App.Metrics;
|
||||
using App.Metrics.AspNetCore;
|
||||
using App.Metrics.Formatters.Prometheus;
|
||||
using Coravel;
|
||||
using Coravel.Queuing.Interfaces;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Jobs;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
|
@ -93,6 +95,7 @@ public static class WebApplicationExtensions
|
|||
|
||||
return builder
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appSettings.json", true)
|
||||
.AddIniFile(file, optional: false, reloadOnChange: true)
|
||||
.AddEnvironmentVariables();
|
||||
}
|
||||
|
@ -105,8 +108,10 @@ public static class WebApplicationExtensions
|
|||
.AddScoped<AuthService>()
|
||||
.AddScoped<KeyCacheService>()
|
||||
.AddScoped<RemoteAuthService>()
|
||||
// Background job classes
|
||||
.AddTransient<AvatarUpdateJob>();
|
||||
.AddScoped<ObjectStorageService>()
|
||||
// Transient jobs
|
||||
.AddTransient<MemberAvatarUpdateInvocable>()
|
||||
.AddTransient<UserAvatarUpdateInvocable>();
|
||||
|
||||
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
||||
.AddScoped<ErrorHandlerMiddleware>()
|
||||
|
@ -122,6 +127,8 @@ public static class WebApplicationExtensions
|
|||
{
|
||||
await BuildInfo.ReadBuildInfo();
|
||||
|
||||
app.Services.ConfigureQueue().LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>());
|
||||
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<WebApplication>();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
|
|
|
@ -9,11 +9,9 @@
|
|||
<PackageReference Include="App.Metrics" Version="4.3.0" />
|
||||
<PackageReference Include="App.Metrics.AspNetCore.All" Version="4.3.0" />
|
||||
<PackageReference Include="App.Metrics.Prometheus" Version="4.3.0" />
|
||||
<PackageReference Include="Coravel" Version="5.0.4" />
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
|
||||
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
|
||||
<PackageReference Include="Hangfire.Core" Version="1.8.14" />
|
||||
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
|
||||
|
@ -28,7 +26,6 @@
|
|||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
|
||||
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3" />
|
||||
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0" />
|
||||
<PackageReference Include="Sentry.Hangfire" Version="4.9.0" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
|
|
|
@ -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";
|
||||
}
|
79
Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs
Normal file
79
Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs
Normal 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";
|
||||
}
|
5
Foxnouns.Backend/Jobs/Payloads.cs
Normal file
5
Foxnouns.Backend/Jobs/Payloads.cs
Normal file
|
@ -0,0 +1,5 @@
|
|||
using Foxnouns.Backend.Database;
|
||||
|
||||
namespace Foxnouns.Backend.Jobs;
|
||||
|
||||
public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar);
|
79
Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs
Normal file
79
Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs
Normal 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";
|
||||
}
|
|
@ -2,7 +2,6 @@ using System.Security.Cryptography;
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Hangfire.Dashboard;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
|
@ -64,33 +63,4 @@ public static class HttpContextExtensions
|
|||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AuthenticateAttribute : Attribute;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication filter for the Hangfire dashboard. Uses the cookie created by the frontend
|
||||
/// (and otherwise only read <i>by</i> the frontend) to only allow admins to use it.
|
||||
/// </summary>
|
||||
public class HangfireDashboardAuthorizationFilter(IServiceProvider services) : IDashboardAsyncAuthorizationFilter
|
||||
{
|
||||
public async Task<bool> AuthorizeAsync(DashboardContext context)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
var clock = scope.ServiceProvider.GetRequiredService<IClock>();
|
||||
|
||||
var httpContext = context.GetHttpContext();
|
||||
|
||||
if (!httpContext.Request.Cookies.TryGetValue("pronounscc-token", out var cookie)) return false;
|
||||
|
||||
if (!AuthUtils.TryFromBase64String(cookie!, out var rawToken)) return false;
|
||||
|
||||
var hash = SHA512.HashData(rawToken);
|
||||
var oauthToken = await db.Tokens
|
||||
.Include(t => t.Application)
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired);
|
||||
|
||||
return oauthToken?.User.Role == UserRole.Admin;
|
||||
}
|
||||
}
|
||||
public class AuthenticateAttribute : Attribute;
|
|
@ -1,18 +1,15 @@
|
|||
using Coravel;
|
||||
using Foxnouns.Backend;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Serilog;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Hangfire;
|
||||
using Hangfire.Redis.StackExchange;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Minio;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Sentry.Extensibility;
|
||||
using Sentry.Hangfire;
|
||||
|
||||
// Read version information from .version in the repository root
|
||||
await BuildInfo.ReadBuildInfo();
|
||||
|
@ -64,6 +61,7 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings
|
|||
};
|
||||
|
||||
builder.Services
|
||||
.AddQueue()
|
||||
.AddDbContext<DatabaseContext>()
|
||||
.AddCustomServices()
|
||||
.AddCustomMiddleware()
|
||||
|
@ -74,12 +72,6 @@ builder.Services
|
|||
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
|
||||
.Build());
|
||||
|
||||
builder.Services.AddHangfire(c => c.UseSentry().UseRedisStorage(config.Jobs.Redis, new RedisStorageOptions
|
||||
{
|
||||
Prefix = "foxnouns_"
|
||||
}))
|
||||
.AddHangfireServer(options => { options.WorkerCount = config.Jobs.Workers; });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
await app.Initialize(args);
|
||||
|
@ -95,12 +87,6 @@ app.UseCors();
|
|||
app.UseCustomMiddleware();
|
||||
app.MapControllers();
|
||||
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
AppPath = null,
|
||||
AsyncAuthorization = [new HangfireDashboardAuthorizationFilter(app.Services)]
|
||||
});
|
||||
|
||||
app.Urls.Clear();
|
||||
app.Urls.Add(config.Address);
|
||||
if (config.MetricsAddress != null) app.Urls.Add(config.MetricsAddress);
|
||||
|
|
33
Foxnouns.Backend/Services/ObjectStorageService.cs
Normal file
33
Foxnouns.Backend/Services/ObjectStorageService.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using Minio;
|
||||
using Minio.DataModel.Args;
|
||||
using Minio.Exceptions;
|
||||
|
||||
namespace Foxnouns.Backend.Services;
|
||||
|
||||
public class ObjectStorageService(ILogger logger, Config config, IMinioClient minio)
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<ObjectStorageService>();
|
||||
|
||||
public async Task RemoveObjectAsync(string path)
|
||||
{
|
||||
logger.Debug("Deleting object at path {Path}", path);
|
||||
try
|
||||
{
|
||||
await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path));
|
||||
}
|
||||
catch (InvalidObjectNameException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PutObjectAsync(string path, Stream data, string contentType)
|
||||
{
|
||||
await minio.PutObjectAsync(new PutObjectArgs()
|
||||
.WithBucket(config.Storage.Bucket)
|
||||
.WithObject(path)
|
||||
.WithObjectSize(data.Length)
|
||||
.WithStreamData(data)
|
||||
.WithContentType(contentType)
|
||||
);
|
||||
}
|
||||
}
|
9
Foxnouns.Backend/Utils/Limits.cs
Normal file
9
Foxnouns.Backend/Utils/Limits.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
namespace Foxnouns.Backend.Utils;
|
||||
|
||||
public static class Limits
|
||||
{
|
||||
public const int FieldLimit = 25;
|
||||
public const int FieldNameLimit = 100;
|
||||
public const int FieldEntryTextLimit = 100;
|
||||
public const int FieldEntriesLimit = 100;
|
||||
}
|
|
@ -57,11 +57,11 @@ public static class ValidationUtils
|
|||
|
||||
public static ValidationError? ValidateMemberName(string memberName)
|
||||
{
|
||||
if (!UsernameRegex.IsMatch(memberName))
|
||||
if (!MemberRegex.IsMatch(memberName))
|
||||
return memberName.Length switch
|
||||
{
|
||||
< 2 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
||||
> 40 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
|
||||
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
||||
> 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
|
||||
_ => ValidationError.GenericValidationError(
|
||||
"Member name cannot contain any of the following: " +
|
||||
" @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " +
|
||||
|
@ -119,10 +119,7 @@ public static class ValidationUtils
|
|||
};
|
||||
}
|
||||
|
||||
private const int FieldLimit = 25;
|
||||
private const int FieldNameLimit = 100;
|
||||
private const int FieldEntryTextLimit = 100;
|
||||
private const int FieldEntriesLimit = 100;
|
||||
|
||||
|
||||
private static readonly string[] DefaultStatusOptions =
|
||||
[
|
||||
|
@ -140,7 +137,7 @@ public static class ValidationUtils
|
|||
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
if (fields.Count > 25)
|
||||
errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, FieldLimit, fields.Count)));
|
||||
errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, Limits.FieldLimit, fields.Count)));
|
||||
// No overwhelming this function, thank you
|
||||
if (fields.Count > 100) return errors;
|
||||
|
||||
|
@ -148,13 +145,13 @@ public static class ValidationUtils
|
|||
{
|
||||
switch (field.Name.Length)
|
||||
{
|
||||
case > FieldNameLimit:
|
||||
case > Limits.FieldNameLimit:
|
||||
errors.Add(($"fields.{index}.name",
|
||||
ValidationError.LengthError("Field name is too long", 1, FieldNameLimit, field.Name.Length)));
|
||||
ValidationError.LengthError("Field name is too long", 1, Limits.FieldNameLimit, field.Name.Length)));
|
||||
break;
|
||||
case < 1:
|
||||
errors.Add(($"fields.{index}.name",
|
||||
ValidationError.LengthError("Field name is too short", 1, FieldNameLimit, field.Name.Length)));
|
||||
ValidationError.LengthError("Field name is too short", 1, Limits.FieldNameLimit, field.Name.Length)));
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -170,26 +167,26 @@ public static class ValidationUtils
|
|||
if (entries == null || entries.Length == 0) return [];
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
if (entries.Length > FieldEntriesLimit)
|
||||
if (entries.Length > Limits.FieldEntriesLimit)
|
||||
errors.Add(($"{errorPrefix}.entries",
|
||||
ValidationError.LengthError("Field has too many entries", 0, FieldEntriesLimit,
|
||||
ValidationError.LengthError("Field has too many entries", 0, Limits.FieldEntriesLimit,
|
||||
entries.Length)));
|
||||
|
||||
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
||||
if (entries.Length > FieldEntriesLimit + 50) return errors;
|
||||
if (entries.Length > Limits.FieldEntriesLimit + 50) return errors;
|
||||
|
||||
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
||||
{
|
||||
switch (entry.Value.Length)
|
||||
{
|
||||
case > FieldEntryTextLimit:
|
||||
case > Limits.FieldEntryTextLimit:
|
||||
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
|
||||
ValidationError.LengthError("Field value is too long", 1, FieldEntryTextLimit,
|
||||
ValidationError.LengthError("Field value is too long", 1, Limits.FieldEntryTextLimit,
|
||||
entry.Value.Length)));
|
||||
break;
|
||||
case < 1:
|
||||
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
|
||||
ValidationError.LengthError("Field value is too short", 1, FieldEntryTextLimit,
|
||||
ValidationError.LengthError("Field value is too short", 1, Limits.FieldEntryTextLimit,
|
||||
entry.Value.Length)));
|
||||
break;
|
||||
}
|
||||
|
|
7
Foxnouns.Backend/appSettings.json
Normal file
7
Foxnouns.Backend/appSettings.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"Coravel": {
|
||||
"Queue": {
|
||||
"ConsummationDelay": 1
|
||||
}
|
||||
}
|
||||
}
|
|
@ -31,12 +31,6 @@ Timeout = 5
|
|||
; The maximum number of open connections. Defaults to 50.
|
||||
MaxPoolSize = 50
|
||||
|
||||
[Jobs]
|
||||
; The connection string for the Redis server.
|
||||
Redis = localhost:6379
|
||||
; The number of workers to use for background jobs. Defaults to 5.
|
||||
Workers = 5
|
||||
|
||||
[Storage]
|
||||
Endpoint = <s3EndpointHere>
|
||||
AccessKey = <s3AccessKey>
|
||||
|
|
Loading…
Reference in a new issue