diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index d1e6df5..b48a2c4 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -55,6 +55,7 @@ public class Config public bool? EnablePooling { get; init; } public int? Timeout { get; init; } public int? MaxPoolSize { get; init; } + public string Redis { get; init; } = string.Empty; } public class StorageConfig diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 0d95eb2..39d3b11 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -46,7 +46,7 @@ public class AuthController( config.GoogleAuth.Enabled, config.TumblrAuth.Enabled ); - string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); + string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync()); string? discord = null; string? google = null; string? tumblr = null; diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index bdf4b9a..8024ee6 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -56,7 +56,7 @@ public class EmailAuthController( if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); - string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null, ct); + string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null); // If there's already a user with that email address, pretend we sent an email but actually ignore it if ( diff --git a/Foxnouns.Backend/Controllers/ExportsController.cs b/Foxnouns.Backend/Controllers/ExportsController.cs index 7f40625..0442386 100644 --- a/Foxnouns.Backend/Controllers/ExportsController.cs +++ b/Foxnouns.Backend/Controllers/ExportsController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto; @@ -28,13 +27,8 @@ namespace Foxnouns.Backend.Controllers; [Authorize("identify")] [Limit(UsableByDeletedUsers = true)] [ApiExplorerSettings(IgnoreApi = true)] -public class ExportsController( - ILogger logger, - Config config, - IClock clock, - DatabaseContext db, - IQueue queue -) : ApiControllerBase +public class ExportsController(ILogger logger, Config config, IClock clock, DatabaseContext db) + : ApiControllerBase { private static readonly Duration MinimumTimeBetween = Duration.FromDays(1); private readonly ILogger _logger = logger.ForContext(); @@ -80,10 +74,7 @@ public class ExportsController( throw new ApiError.BadRequest("You can't request a new data export so soon."); } - queue.QueueInvocableWithPayload( - new CreateDataExportPayload(CurrentUser.Id) - ); - + CreateDataExportJob.Enqueue(CurrentUser.Id); return NoContent(); } } diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index e976072..bed022a 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Dto; @@ -30,8 +29,7 @@ namespace Foxnouns.Backend.Controllers; public class FlagsController( DatabaseContext db, UserRendererService userRenderer, - ISnowflakeGenerator snowflakeGenerator, - IQueue queue + ISnowflakeGenerator snowflakeGenerator ) : ApiControllerBase { [HttpGet] @@ -74,10 +72,7 @@ public class FlagsController( db.Add(flag); await db.SaveChangesAsync(); - queue.QueueInvocableWithPayload( - new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image) - ); - + CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image)); return Accepted(userRenderer.RenderPrideFlag(flag)); } diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index dbea99c..bc35f62 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; @@ -37,7 +36,6 @@ public class MembersController( MemberRendererService memberRenderer, ISnowflakeGenerator snowflakeGenerator, ObjectStorageService objectStorageService, - IQueue queue, IClock clock, ValidationService validationService, Config config @@ -139,9 +137,7 @@ public class MembersController( if (req.Avatar != null) { - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(member.Id, req.Avatar) - ); + MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar)); } return Ok(memberRenderer.RenderMember(member, CurrentToken)); @@ -239,9 +235,7 @@ public class MembersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) { - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(member.Id, req.Avatar) - ); + MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar)); } try diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index f7e3115..787ff66 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -12,7 +12,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; @@ -34,7 +33,6 @@ public class UsersController( ILogger logger, UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, - IQueue queue, IClock clock, ValidationService validationService ) : ApiControllerBase @@ -177,9 +175,7 @@ public class UsersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) { - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar) - ); + UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); } try diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index ae620f2..c9120f3 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -64,7 +64,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet FediverseApplications { get; init; } = null!; public DbSet Tokens { get; init; } = null!; public DbSet Applications { get; init; } = null!; - public DbSet TemporaryKeys { get; init; } = null!; public DbSet DataExports { get; init; } = null!; public DbSet PrideFlags { get; init; } = null!; @@ -87,7 +86,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) modelBuilder.Entity().HasIndex(u => u.Sid).IsUnique(); modelBuilder.Entity().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity().HasIndex(m => m.Sid).IsUnique(); - modelBuilder.Entity().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); // Two indexes on auth_methods, one for fediverse auth and one for all other types. diff --git a/Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs b/Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs new file mode 100644 index 0000000..27a8ada --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20250304155708_RemoveTemporaryKeys")] + public partial class RemoveTemporaryKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "temporary_keys"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "temporary_keys", + columns: table => new + { + id = table + .Column(type: "bigint", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + expires = table.Column( + type: "timestamp with time zone", + nullable: false + ), + key = table.Column(type: "text", nullable: false), + value = table.Column(type: "text", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("pk_temporary_keys", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_temporary_keys_key", + table: "temporary_keys", + column: "key", + unique: true + ); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 6b4f4d4..922a599 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -19,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("ProductVersion", "9.0.2") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); @@ -479,39 +479,6 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("reports", (string)null); }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Expires") - .HasColumnType("timestamp with time zone") - .HasColumnName("expires"); - - b.Property("Key") - .IsRequired() - .HasColumnType("text") - .HasColumnName("key"); - - b.Property("Value") - .IsRequired() - .HasColumnType("text") - .HasColumnName("value"); - - b.HasKey("Id") - .HasName("pk_temporary_keys"); - - b.HasIndex("Key") - .IsUnique() - .HasDatabaseName("ix_temporary_keys_key"); - - b.ToTable("temporary_keys", (string)null); - }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => { b.Property("Id") diff --git a/Foxnouns.Backend/Database/Models/TemporaryKey.cs b/Foxnouns.Backend/Database/Models/TemporaryKey.cs deleted file mode 100644 index f83e515..0000000 --- a/Foxnouns.Backend/Database/Models/TemporaryKey.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -using NodaTime; - -namespace Foxnouns.Backend.Database.Models; - -public class TemporaryKey -{ - public long Id { get; init; } - public required string Key { get; init; } - public required string Value { get; set; } - public Instant Expires { get; init; } -} diff --git a/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs b/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs index db0797c..2d3108b 100644 --- a/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs @@ -33,24 +33,20 @@ public static class ImageObjectExtensions Snowflake id, string hash, CancellationToken ct = default - ) => - await objectStorageService.RemoveObjectAsync( - MemberAvatarUpdateInvocable.Path(id, hash), - ct - ); + ) => await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateJob.Path(id, hash), ct); public static async Task DeleteUserAvatarAsync( this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default - ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); + ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateJob.Path(id, hash), ct); public static async Task DeleteFlagAsync( this ObjectStorageService objectStorageService, string hash, CancellationToken ct = default - ) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); + ) => await objectStorageService.RemoveObjectAsync(CreateFlagJob.Path(hash), ct); public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage( string uri, diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 615cc3d..a4fb444 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -23,23 +23,19 @@ namespace Foxnouns.Backend.Extensions; public static class KeyCacheExtensions { - public static async Task GenerateAuthStateAsync( - this KeyCacheService keyCacheService, - CancellationToken ct = default - ) + public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheService) { string state = AuthUtils.RandomToken(); - await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); + await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); return state; } public static async Task ValidateAuthStateAsync( this KeyCacheService keyCacheService, - string state, - CancellationToken ct = default + string state ) { - string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct); + string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}"); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); } @@ -47,63 +43,55 @@ public static class KeyCacheExtensions public static async Task GenerateRegisterEmailStateAsync( this KeyCacheService keyCacheService, string email, - Snowflake? userId = null, - CancellationToken ct = default + Snowflake? userId = null ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"email_state:{state}", new RegisterEmailState(email, userId), - Duration.FromDays(1), - ct + Duration.FromDays(1) ); return state; } public static async Task GetRegisterEmailStateAsync( this KeyCacheService keyCacheService, - string state, - CancellationToken ct = default - ) => await keyCacheService.GetKeyAsync($"email_state:{state}", ct: ct); + string state + ) => await keyCacheService.GetKeyAsync($"email_state:{state}"); public static async Task GenerateAddExtraAccountStateAsync( this KeyCacheService keyCacheService, AuthType authType, Snowflake userId, - string? instance = null, - CancellationToken ct = default + string? instance = null ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"add_account:{state}", new AddExtraAccountState(authType, userId, instance), - Duration.FromDays(1), - ct + Duration.FromDays(1) ); return state; } public static async Task GetAddExtraAccountStateAsync( this KeyCacheService keyCacheService, - string state, - CancellationToken ct = default - ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true, ct); + string state + ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true); public static async Task GenerateForgotPasswordStateAsync( this KeyCacheService keyCacheService, string email, - Snowflake userId, - CancellationToken ct = default + Snowflake userId ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"forgot_password:{state}", new ForgotPasswordState(email, userId), - Duration.FromHours(1), - ct + Duration.FromHours(1) ); return state; } @@ -111,14 +99,8 @@ public static class KeyCacheExtensions public static async Task GetForgotPasswordStateAsync( this KeyCacheService keyCacheService, string state, - bool delete = true, - CancellationToken ct = default - ) => - await keyCacheService.GetKeyAsync( - $"forgot_password:{state}", - delete, - ct - ); + bool delete = true + ) => await keyCacheService.GetKeyAsync($"forgot_password:{state}", delete); } public record RegisterEmailState( diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 07394f2..8db7a1b 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -51,9 +51,12 @@ public static class WebApplicationExtensions "Microsoft.EntityFrameworkCore.Database.Command", config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal ) + // These spam the output even on INF level .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) + // Hangfire's debug-level logs are extremely spammy for no reason + .MinimumLevel.Override("Hangfire", LogEventLevel.Information) .WriteTo.Console(theme: AnsiConsoleTheme.Sixteen); if (config.Logging.SeqLogUrl != null) @@ -112,12 +115,12 @@ public static class WebApplicationExtensions .AddSnowflakeGenerator() .AddSingleton() .AddSingleton() + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() - .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -126,10 +129,10 @@ public static class WebApplicationExtensions // Background services .AddHostedService() // Transient jobs - .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() // Legacy services .AddScoped() .AddScoped(); diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index c30f2b9..a8c21fb 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -12,6 +12,9 @@ + + + @@ -42,6 +45,7 @@ + diff --git a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs b/Foxnouns.Backend/Jobs/CreateDataExportJob.cs similarity index 93% rename from Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs rename to Foxnouns.Backend/Jobs/CreateDataExportJob.cs index becd858..3662e33 100644 --- a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateDataExportJob.cs @@ -14,11 +14,11 @@ // along with this program. If not, see . using System.IO.Compression; using System.Net; -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; +using Hangfire; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; @@ -26,7 +26,7 @@ using NodaTime.Text; namespace Foxnouns.Backend.Jobs; -public class CreateDataExportInvocable( +public class CreateDataExportJob( DatabaseContext db, IClock clock, UserRendererService userRenderer, @@ -34,37 +34,41 @@ public class CreateDataExportInvocable( ObjectStorageService objectStorageService, ISnowflakeGenerator snowflakeGenerator, ILogger logger -) : IInvocable, IInvocableWithPayload +) { private static readonly HttpClient Client = new(); - private readonly ILogger _logger = logger.ForContext(); - public required CreateDataExportPayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(Snowflake userId) + { + BackgroundJob.Enqueue(j => j.InvokeAsync(userId)); + } + + public async Task InvokeAsync(Snowflake userId) { try { - await InvokeAsync(); + await InvokeAsyncInner(userId); } catch (Exception e) { - _logger.Error(e, "Error generating data export for user {UserId}", Payload.UserId); + _logger.Error(e, "Error generating data export for user {UserId}", userId); } } - private async Task InvokeAsync() + private async Task InvokeAsyncInner(Snowflake userId) { User? user = await db .Users.Include(u => u.AuthMethods) .Include(u => u.Flags) .Include(u => u.ProfileFlags) .AsSplitQuery() - .FirstOrDefaultAsync(u => u.Id == Payload.UserId); + .FirstOrDefaultAsync(u => u.Id == userId); if (user == null) { _logger.Warning( "Received create data export request for user {UserId} but no such user exists, deleted or otherwise. Ignoring request", - Payload.UserId + userId ); return; } diff --git a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs index 1b8905b..e40bfa4 100644 --- a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs @@ -12,49 +12,53 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Hangfire; using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Jobs; -public class CreateFlagInvocable( +public class CreateFlagJob( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) : IInvocable, IInvocableWithPayload +) { - private readonly ILogger _logger = logger.ForContext(); - public required CreateFlagPayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(CreateFlagPayload payload) + { + BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); + } + + public async Task InvokeAsync(CreateFlagPayload payload) { _logger.Information( "Creating flag {FlagId} for user {UserId} with image data length {DataLength}", - Payload.Id, - Payload.UserId, - Payload.ImageData.Length + payload.Id, + payload.UserId, + payload.ImageData.Length ); try { PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => - f.Id == Payload.Id && f.UserId == Payload.UserId + f.Id == payload.Id && f.UserId == payload.UserId ); if (flag == null) { _logger.Warning( "Got a flag create job for {FlagId} but it doesn't exist, aborting", - Payload.Id + payload.Id ); return; } (string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage( - Payload.ImageData, + payload.ImageData, 256, false ); @@ -68,7 +72,7 @@ public class CreateFlagInvocable( } catch (ArgumentException ae) { - _logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", Payload.Id, ae.Message); + _logger.Warning("Invalid data URI for flag {FlagId}: {Reason}", payload.Id, ae.Message); } throw new NotImplementedException(); diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs similarity index 86% rename from Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs rename to Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs index 01ec9e3..907dfc4 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs @@ -12,29 +12,33 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Hangfire; namespace Foxnouns.Backend.Jobs; -public class MemberAvatarUpdateInvocable( +public class MemberAvatarUpdateJob( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) : IInvocable, IInvocableWithPayload +) { - private readonly ILogger _logger = logger.ForContext(); - public required AvatarUpdatePayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(AvatarUpdatePayload payload) { - if (Payload.NewAvatar != null) - await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar); + BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); + } + + public async Task InvokeAsync(AvatarUpdatePayload payload) + { + if (payload.NewAvatar != null) + await UpdateMemberAvatarAsync(payload.Id, payload.NewAvatar); else - await ClearMemberAvatarAsync(Payload.Id); + await ClearMemberAvatarAsync(payload.Id); } private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar) diff --git a/Foxnouns.Backend/Jobs/Payloads.cs b/Foxnouns.Backend/Jobs/Payloads.cs index 374a5b7..1f76ea2 100644 --- a/Foxnouns.Backend/Jobs/Payloads.cs +++ b/Foxnouns.Backend/Jobs/Payloads.cs @@ -19,5 +19,3 @@ namespace Foxnouns.Backend.Jobs; public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar); public record CreateFlagPayload(Snowflake Id, Snowflake UserId, string ImageData); - -public record CreateDataExportPayload(Snowflake UserId); diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs similarity index 88% rename from Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs rename to Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs index 862d0da..1ab446c 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs @@ -12,29 +12,33 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -using Coravel.Invocable; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Hangfire; namespace Foxnouns.Backend.Jobs; -public class UserAvatarUpdateInvocable( +public class UserAvatarUpdateJob( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) : IInvocable, IInvocableWithPayload +) { - private readonly ILogger _logger = logger.ForContext(); - public required AvatarUpdatePayload Payload { get; set; } + private readonly ILogger _logger = logger.ForContext(); - public async Task Invoke() + public static void Enqueue(AvatarUpdatePayload payload) { - if (Payload.NewAvatar != null) - await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar); + BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); + } + + public async Task InvokeAsync(AvatarUpdatePayload payload) + { + if (payload.NewAvatar != null) + await UpdateUserAvatarAsync(payload.Id, payload.NewAvatar); else - await ClearUserAvatarAsync(Payload.Id); + await ClearUserAvatarAsync(payload.Id); } private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar) diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 0f1d9f1..b5bc338 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -19,6 +19,8 @@ using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils.OpenApi; +using Hangfire; +using Hangfire.Redis.StackExchange; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; @@ -73,6 +75,18 @@ builder ); }); +builder + .Services.AddHangfire( + (services, c) => + { + c.UseRedisStorage( + services.GetRequiredService().Multiplexer, + new RedisStorageOptions { Prefix = "foxnouns_net:" } + ); + } + ) + .AddHangfireServer(); + builder.Services.AddOpenApi( "v2", options => @@ -109,6 +123,7 @@ if (config.Logging.SentryTracing) app.UseCors(); app.UseCustomMiddleware(); app.MapControllers(); +app.UseHangfireDashboard(); // TODO: I can't figure out why this doesn't work yet // TODO: Manually write API docs in the meantime diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 0163516..1ad825f 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -17,94 +17,42 @@ using Foxnouns.Backend.Database.Models; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; +using StackExchange.Redis; namespace Foxnouns.Backend.Services; -public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) +public class KeyCacheService(Config config) { - private readonly ILogger _logger = logger.ForContext(); + public ConnectionMultiplexer Multiplexer { get; } = + // ConnectionMultiplexer.Connect(config.Database.Redis); + ConnectionMultiplexer.Connect("127.0.0.1:6379"); - public Task SetKeyAsync( - string key, - string value, - Duration expireAfter, - CancellationToken ct = default - ) => SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); + public async Task SetKeyAsync(string key, string value, Duration expireAfter) => + await Multiplexer + .GetDatabase() + .StringSetAsync(key, value, expiry: expireAfter.ToTimeSpan()); - public async Task SetKeyAsync( - string key, - string value, - Instant expires, - CancellationToken ct = default - ) - { - db.TemporaryKeys.Add( - new TemporaryKey - { - Expires = expires, - Key = key, - Value = value, - } - ); - await db.SaveChangesAsync(ct); - } + public async Task GetKeyAsync(string key, bool delete = false) => + delete + ? await Multiplexer.GetDatabase().StringGetDeleteAsync(key) + : await Multiplexer.GetDatabase().StringGetAsync(key); - public async Task GetKeyAsync( - string key, - bool delete = false, - CancellationToken ct = default - ) - { - TemporaryKey? value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct); - if (value == null) - return null; + public async Task DeleteKeyAsync(string key) => + await Multiplexer.GetDatabase().KeyDeleteAsync(key); - if (delete) - await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); + public Task DeleteExpiredKeysAsync(CancellationToken ct) => Task.CompletedTask; - return value.Value; - } - - public async Task DeleteKeyAsync(string key, CancellationToken ct = default) => - await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); - - public async Task DeleteExpiredKeysAsync(CancellationToken ct) - { - int count = await db - .TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()) - .ExecuteDeleteAsync(ct); - if (count != 0) - _logger.Information("Removed {Count} expired keys from the database", count); - } - - public Task SetKeyAsync( - string key, - T obj, - Duration expiresAt, - CancellationToken ct = default - ) - where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); - - public async Task SetKeyAsync( - string key, - T obj, - Instant expires, - CancellationToken ct = default - ) + public async Task SetKeyAsync(string key, T obj, Duration expiresAt) where T : class { string value = JsonConvert.SerializeObject(obj); - await SetKeyAsync(key, value, expires, ct); + await SetKeyAsync(key, value, expiresAt); } - public async Task GetKeyAsync( - string key, - bool delete = false, - CancellationToken ct = default - ) + public async Task GetKeyAsync(string key, bool delete = false) where T : class { - string? value = await GetKeyAsync(key, delete, ct); + string? value = await GetKeyAsync(key, delete); return value == null ? default : JsonConvert.DeserializeObject(value); } } diff --git a/Foxnouns.Backend/Services/ModerationService.cs b/Foxnouns.Backend/Services/ModerationService.cs index 4e2afe6..30d99ed 100644 --- a/Foxnouns.Backend/Services/ModerationService.cs +++ b/Foxnouns.Backend/Services/ModerationService.cs @@ -27,7 +27,6 @@ public class ModerationService( ILogger logger, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator, - IQueue queue, IClock clock ) { @@ -181,9 +180,7 @@ public class ModerationService( target.CustomPreferences = []; target.ProfileFlags = []; - queue.QueueInvocableWithPayload( - new AvatarUpdatePayload(target.Id, null) - ); + UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(target.Id, null)); // TODO: also clear member profiles? @@ -264,10 +261,9 @@ public class ModerationService( targetMember.DisplayName = null; break; case FieldsToClear.Avatar: - queue.QueueInvocableWithPayload< - MemberAvatarUpdateInvocable, - AvatarUpdatePayload - >(new AvatarUpdatePayload(targetMember.Id, null)); + MemberAvatarUpdateJob.Enqueue( + new AvatarUpdatePayload(targetMember.Id, null) + ); break; case FieldsToClear.Bio: targetMember.Bio = null; @@ -306,10 +302,7 @@ public class ModerationService( targetUser.DisplayName = null; break; case FieldsToClear.Avatar: - queue.QueueInvocableWithPayload< - UserAvatarUpdateInvocable, - AvatarUpdatePayload - >(new AvatarUpdatePayload(targetUser.Id, null)); + UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(targetUser.Id, null)); break; case FieldsToClear.Bio: targetUser.Bio = null; diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index e3799c6..3a7aec6 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -46,6 +46,37 @@ "Npgsql": "8.0.3" } }, + "Hangfire": { + "type": "Direct", + "requested": "[1.8.18, )", + "resolved": "1.8.18", + "contentHash": "EY+UqMHTOQAtdjeJf3jlnj8MpENyDPTpA6OHMncucVlkaongZjrx+gCN4bgma7vD3BNHqfQ7irYrfE5p1DOBEQ==", + "dependencies": { + "Hangfire.AspNetCore": "[1.8.18]", + "Hangfire.Core": "[1.8.18]", + "Hangfire.SqlServer": "[1.8.18]" + } + }, + "Hangfire.Core": { + "type": "Direct", + "requested": "[1.8.18, )", + "resolved": "1.8.18", + "contentHash": "oNAkV8QQoYg5+vM2M024NBk49EhTO2BmKDLuQaKNew23RpH9OUGtKDl1KldBdDJrD8TMFzjhWCArol3igd2i2w==", + "dependencies": { + "Newtonsoft.Json": "11.0.1" + } + }, + "Hangfire.Redis.StackExchange": { + "type": "Direct", + "requested": "[1.9.4, )", + "resolved": "1.9.4", + "contentHash": "rB4eGf4+hFhdnrN3//2O39JGuy1ThIKL3oTdVI2F3HqmSaSD9Cixl2xmMAqGJMld39Ke7eoP9sxbxnpVnYW66g==", + "dependencies": { + "Hangfire.Core": "1.8.7", + "Newtonsoft.Json": "13.0.3", + "StackExchange.Redis": "2.7.10" + } + }, "Humanizer.Core": { "type": "Direct", "requested": "[2.14.1, )", @@ -278,6 +309,16 @@ "resolved": "3.1.6", "contentHash": "dHQ5jugF9v+5/LCVHCWVzaaIL6WOehqJy6eju/0VFYFPEj2WtqkGPoEV9EVQP83dHsdoqYaTuWpZdwAd37UwfA==" }, + "StackExchange.Redis": { + "type": "Direct", + "requested": "[2.8.24, )", + "resolved": "2.8.24", + "contentHash": "GWllmsFAtLyhm4C47cOCipGxyEi1NQWTFUHXnJ8hiHOsK/bH3T5eLkWPVW+LRL6jDiB3g3izW3YEHgLuPoJSyA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, "System.Text.Json": { "type": "Direct", "requested": "[9.0.2, )", @@ -317,6 +358,33 @@ "Microsoft.EntityFrameworkCore.Relational": "8.0.0" } }, + "Hangfire.AspNetCore": { + "type": "Transitive", + "resolved": "1.8.18", + "contentHash": "5D6Do0qgoAnakvh4KnKwhIoUzFU84Z0sCYMB+Sit+ygkpL1P6JGYDcd/9vDBcfr5K3JqBxD4Zh2IK2LOXuuiaw==", + "dependencies": { + "Hangfire.NetCore": "[1.8.18]" + } + }, + "Hangfire.NetCore": { + "type": "Transitive", + "resolved": "1.8.18", + "contentHash": "3KAV9AZ1nqQHC54qR4buNEEKRmQJfq+lODtZxUk5cdi68lV8+9K2f4H1/mIfDlPpgjPFjEfCobNoi2+TIpKySw==", + "dependencies": { + "Hangfire.Core": "[1.8.18]", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.0.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.0" + } + }, + "Hangfire.SqlServer": { + "type": "Transitive", + "resolved": "1.8.18", + "contentHash": "yBfI2ygYfN/31rOrahfOFHee1mwTrG0ppsmK9awCS0mAr2GEaB9eyYqg/lURgZy8AA8UVJVs5nLHa2hc1pDAVQ==", + "dependencies": { + "Hangfire.Core": "[1.8.18]" + } + }, "MailKit": { "type": "Transitive", "resolved": "4.8.0", @@ -684,6 +752,14 @@ "Npgsql": "9.0.2" } }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==", + "dependencies": { + "System.IO.Pipelines": "5.0.1" + } + }, "Sentry": { "type": "Transitive", "resolved": "5.2.0",