diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index b48a2c4..d1e6df5 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -55,7 +55,6 @@ 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 39d3b11..0d95eb2 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()); + string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); 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 8024ee6..bdf4b9a 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); + string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null, ct); // 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 0442386..7f40625 100644 --- a/Foxnouns.Backend/Controllers/ExportsController.cs +++ b/Foxnouns.Backend/Controllers/ExportsController.cs @@ -12,6 +12,7 @@ // // 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; @@ -27,8 +28,13 @@ namespace Foxnouns.Backend.Controllers; [Authorize("identify")] [Limit(UsableByDeletedUsers = true)] [ApiExplorerSettings(IgnoreApi = true)] -public class ExportsController(ILogger logger, Config config, IClock clock, DatabaseContext db) - : ApiControllerBase +public class ExportsController( + ILogger logger, + Config config, + IClock clock, + DatabaseContext db, + IQueue queue +) : ApiControllerBase { private static readonly Duration MinimumTimeBetween = Duration.FromDays(1); private readonly ILogger _logger = logger.ForContext(); @@ -74,7 +80,10 @@ public class ExportsController(ILogger logger, Config config, IClock clock, Data throw new ApiError.BadRequest("You can't request a new data export so soon."); } - CreateDataExportJob.Enqueue(CurrentUser.Id); + queue.QueueInvocableWithPayload( + new CreateDataExportPayload(CurrentUser.Id) + ); + return NoContent(); } } diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index bed022a..e976072 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -12,6 +12,7 @@ // // 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; @@ -29,7 +30,8 @@ namespace Foxnouns.Backend.Controllers; public class FlagsController( DatabaseContext db, UserRendererService userRenderer, - ISnowflakeGenerator snowflakeGenerator + ISnowflakeGenerator snowflakeGenerator, + IQueue queue ) : ApiControllerBase { [HttpGet] @@ -72,7 +74,10 @@ public class FlagsController( db.Add(flag); await db.SaveChangesAsync(); - CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image)); + queue.QueueInvocableWithPayload( + 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 bc35f62..dbea99c 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -12,6 +12,7 @@ // // 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; @@ -36,6 +37,7 @@ public class MembersController( MemberRendererService memberRenderer, ISnowflakeGenerator snowflakeGenerator, ObjectStorageService objectStorageService, + IQueue queue, IClock clock, ValidationService validationService, Config config @@ -137,7 +139,9 @@ public class MembersController( if (req.Avatar != null) { - MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar)); + queue.QueueInvocableWithPayload( + new AvatarUpdatePayload(member.Id, req.Avatar) + ); } return Ok(memberRenderer.RenderMember(member, CurrentToken)); @@ -235,7 +239,9 @@ public class MembersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) { - MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar)); + queue.QueueInvocableWithPayload( + new AvatarUpdatePayload(member.Id, req.Avatar) + ); } try diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 787ff66..f7e3115 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -12,6 +12,7 @@ // // 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; @@ -33,6 +34,7 @@ public class UsersController( ILogger logger, UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, + IQueue queue, IClock clock, ValidationService validationService ) : ApiControllerBase @@ -175,7 +177,9 @@ public class UsersController( // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) { - UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); + queue.QueueInvocableWithPayload( + new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar) + ); } try diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index c9120f3..ae620f2 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -64,6 +64,7 @@ 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!; @@ -86,6 +87,7 @@ 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 deleted file mode 100644 index 27a8ada..0000000 --- a/Foxnouns.Backend/Database/Migrations/20250304155708_RemoveTemporaryKeys.cs +++ /dev/null @@ -1,55 +0,0 @@ -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 922a599..6b4f4d4 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.2") + .HasAnnotation("ProductVersion", "9.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore"); @@ -479,6 +479,39 @@ 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 new file mode 100644 index 0000000..f83e515 --- /dev/null +++ b/Foxnouns.Backend/Database/Models/TemporaryKey.cs @@ -0,0 +1,25 @@ +// 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 2d3108b..db0797c 100644 --- a/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs @@ -33,20 +33,24 @@ public static class ImageObjectExtensions Snowflake id, string hash, CancellationToken ct = default - ) => await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateJob.Path(id, hash), ct); + ) => + await objectStorageService.RemoveObjectAsync( + MemberAvatarUpdateInvocable.Path(id, hash), + ct + ); public static async Task DeleteUserAvatarAsync( this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default - ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateJob.Path(id, hash), ct); + ) => await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); public static async Task DeleteFlagAsync( this ObjectStorageService objectStorageService, string hash, CancellationToken ct = default - ) => await objectStorageService.RemoveObjectAsync(CreateFlagJob.Path(hash), ct); + ) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.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 a4fb444..615cc3d 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -23,19 +23,23 @@ namespace Foxnouns.Backend.Extensions; public static class KeyCacheExtensions { - public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheService) + public static async Task GenerateAuthStateAsync( + this KeyCacheService keyCacheService, + CancellationToken ct = default + ) { string state = AuthUtils.RandomToken(); - await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); + await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); return state; } public static async Task ValidateAuthStateAsync( this KeyCacheService keyCacheService, - string state + string state, + CancellationToken ct = default ) { - string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}"); + string? val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); } @@ -43,55 +47,63 @@ public static class KeyCacheExtensions public static async Task GenerateRegisterEmailStateAsync( this KeyCacheService keyCacheService, string email, - Snowflake? userId = null + Snowflake? userId = null, + CancellationToken ct = default ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"email_state:{state}", new RegisterEmailState(email, userId), - Duration.FromDays(1) + Duration.FromDays(1), + ct ); return state; } public static async Task GetRegisterEmailStateAsync( this KeyCacheService keyCacheService, - string state - ) => await keyCacheService.GetKeyAsync($"email_state:{state}"); + string state, + CancellationToken ct = default + ) => await keyCacheService.GetKeyAsync($"email_state:{state}", ct: ct); public static async Task GenerateAddExtraAccountStateAsync( this KeyCacheService keyCacheService, AuthType authType, Snowflake userId, - string? instance = null + string? instance = null, + CancellationToken ct = default ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"add_account:{state}", new AddExtraAccountState(authType, userId, instance), - Duration.FromDays(1) + Duration.FromDays(1), + ct ); return state; } public static async Task GetAddExtraAccountStateAsync( this KeyCacheService keyCacheService, - string state - ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true); + string state, + CancellationToken ct = default + ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true, ct); public static async Task GenerateForgotPasswordStateAsync( this KeyCacheService keyCacheService, string email, - Snowflake userId + Snowflake userId, + CancellationToken ct = default ) { string state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"forgot_password:{state}", new ForgotPasswordState(email, userId), - Duration.FromHours(1) + Duration.FromHours(1), + ct ); return state; } @@ -99,8 +111,14 @@ public static class KeyCacheExtensions public static async Task GetForgotPasswordStateAsync( this KeyCacheService keyCacheService, string state, - bool delete = true - ) => await keyCacheService.GetKeyAsync($"forgot_password:{state}", delete); + bool delete = true, + CancellationToken ct = default + ) => + await keyCacheService.GetKeyAsync( + $"forgot_password:{state}", + delete, + ct + ); } public record RegisterEmailState( diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 8db7a1b..07394f2 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -51,12 +51,9 @@ 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) @@ -115,12 +112,12 @@ public static class WebApplicationExtensions .AddSnowflakeGenerator() .AddSingleton() .AddSingleton() - .AddSingleton() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -129,10 +126,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 a8c21fb..c30f2b9 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -12,9 +12,6 @@ - - - @@ -45,7 +42,6 @@ - diff --git a/Foxnouns.Backend/Jobs/CreateDataExportJob.cs b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs similarity index 93% rename from Foxnouns.Backend/Jobs/CreateDataExportJob.cs rename to Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs index 3662e33..becd858 100644 --- a/Foxnouns.Backend/Jobs/CreateDataExportJob.cs +++ b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.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 CreateDataExportJob( +public class CreateDataExportInvocable( DatabaseContext db, IClock clock, UserRendererService userRenderer, @@ -34,41 +34,37 @@ public class CreateDataExportJob( ObjectStorageService objectStorageService, ISnowflakeGenerator snowflakeGenerator, ILogger logger -) +) : IInvocable, IInvocableWithPayload { private static readonly HttpClient Client = new(); - private readonly ILogger _logger = logger.ForContext(); + private readonly ILogger _logger = logger.ForContext(); + public required CreateDataExportPayload Payload { get; set; } - public static void Enqueue(Snowflake userId) - { - BackgroundJob.Enqueue(j => j.InvokeAsync(userId)); - } - - public async Task InvokeAsync(Snowflake userId) + public async Task Invoke() { try { - await InvokeAsyncInner(userId); + await InvokeAsync(); } catch (Exception e) { - _logger.Error(e, "Error generating data export for user {UserId}", userId); + _logger.Error(e, "Error generating data export for user {UserId}", Payload.UserId); } } - private async Task InvokeAsyncInner(Snowflake userId) + private async Task InvokeAsync() { User? user = await db .Users.Include(u => u.AuthMethods) .Include(u => u.Flags) .Include(u => u.ProfileFlags) .AsSplitQuery() - .FirstOrDefaultAsync(u => u.Id == userId); + .FirstOrDefaultAsync(u => u.Id == Payload.UserId); if (user == null) { _logger.Warning( "Received create data export request for user {UserId} but no such user exists, deleted or otherwise. Ignoring request", - userId + Payload.UserId ); return; } diff --git a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs index e40bfa4..1b8905b 100644 --- a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs @@ -12,53 +12,49 @@ // // 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 CreateFlagJob( +public class CreateFlagInvocable( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) +) : IInvocable, IInvocableWithPayload { - private readonly ILogger _logger = logger.ForContext(); + private readonly ILogger _logger = logger.ForContext(); + public required CreateFlagPayload Payload { get; set; } - public static void Enqueue(CreateFlagPayload payload) - { - BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); - } - - public async Task InvokeAsync(CreateFlagPayload payload) + public async Task Invoke() { _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 ); @@ -72,7 +68,7 @@ public class CreateFlagJob( } 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/MemberAvatarUpdateJob.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs similarity index 86% rename from Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs rename to Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs index 907dfc4..01ec9e3 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs @@ -12,33 +12,29 @@ // // 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 MemberAvatarUpdateJob( +public class MemberAvatarUpdateInvocable( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) +) : IInvocable, IInvocableWithPayload { - private readonly ILogger _logger = logger.ForContext(); + private readonly ILogger _logger = logger.ForContext(); + public required AvatarUpdatePayload Payload { get; set; } - public static void Enqueue(AvatarUpdatePayload payload) + public async Task Invoke() { - BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); - } - - public async Task InvokeAsync(AvatarUpdatePayload payload) - { - if (payload.NewAvatar != null) - await UpdateMemberAvatarAsync(payload.Id, payload.NewAvatar); + 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 1f76ea2..374a5b7 100644 --- a/Foxnouns.Backend/Jobs/Payloads.cs +++ b/Foxnouns.Backend/Jobs/Payloads.cs @@ -19,3 +19,5 @@ 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/UserAvatarUpdateJob.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs similarity index 88% rename from Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs rename to Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs index 1ab446c..862d0da 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs @@ -12,33 +12,29 @@ // // 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 UserAvatarUpdateJob( +public class UserAvatarUpdateInvocable( DatabaseContext db, ObjectStorageService objectStorageService, ILogger logger -) +) : IInvocable, IInvocableWithPayload { - private readonly ILogger _logger = logger.ForContext(); + private readonly ILogger _logger = logger.ForContext(); + public required AvatarUpdatePayload Payload { get; set; } - public static void Enqueue(AvatarUpdatePayload payload) + public async Task Invoke() { - BackgroundJob.Enqueue(j => j.InvokeAsync(payload)); - } - - public async Task InvokeAsync(AvatarUpdatePayload payload) - { - if (payload.NewAvatar != null) - await UpdateUserAvatarAsync(payload.Id, payload.NewAvatar); + 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 b5bc338..0f1d9f1 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -19,8 +19,6 @@ 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; @@ -75,18 +73,6 @@ builder ); }); -builder - .Services.AddHangfire( - (services, c) => - { - c.UseRedisStorage( - services.GetRequiredService().Multiplexer, - new RedisStorageOptions { Prefix = "foxnouns_net:" } - ); - } - ) - .AddHangfireServer(); - builder.Services.AddOpenApi( "v2", options => @@ -123,7 +109,6 @@ 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/EmailRateLimiter.cs b/Foxnouns.Backend/Services/EmailRateLimiter.cs index cc2dbb4..3a1a81a 100644 --- a/Foxnouns.Backend/Services/EmailRateLimiter.cs +++ b/Foxnouns.Backend/Services/EmailRateLimiter.cs @@ -23,11 +23,8 @@ public class EmailRateLimiter { private readonly ConcurrentDictionary _limiters = new(); - private readonly FixedWindowRateLimiterOptions _limiterOptions = new() - { - Window = TimeSpan.FromHours(2), - PermitLimit = 3, - }; + private readonly FixedWindowRateLimiterOptions _limiterOptions = + new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 }; private RateLimiter GetLimiter(string bucket) => _limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions)); diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 1ad825f..0163516 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -17,42 +17,94 @@ using Foxnouns.Backend.Database.Models; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using NodaTime; -using StackExchange.Redis; namespace Foxnouns.Backend.Services; -public class KeyCacheService(Config config) +public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) { - public ConnectionMultiplexer Multiplexer { get; } = - // ConnectionMultiplexer.Connect(config.Database.Redis); - ConnectionMultiplexer.Connect("127.0.0.1:6379"); + private readonly ILogger _logger = logger.ForContext(); - public async Task SetKeyAsync(string key, string value, Duration expireAfter) => - await Multiplexer - .GetDatabase() - .StringSetAsync(key, value, expiry: expireAfter.ToTimeSpan()); + public Task SetKeyAsync( + string key, + string value, + Duration expireAfter, + CancellationToken ct = default + ) => SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); - public async Task GetKeyAsync(string key, bool delete = false) => - delete - ? await Multiplexer.GetDatabase().StringGetDeleteAsync(key) - : await Multiplexer.GetDatabase().StringGetAsync(key); + 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 DeleteKeyAsync(string key) => - await Multiplexer.GetDatabase().KeyDeleteAsync(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 Task DeleteExpiredKeysAsync(CancellationToken ct) => Task.CompletedTask; + if (delete) + await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); - public async Task SetKeyAsync(string key, T obj, Duration expiresAt) + 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 + ) where T : class { string value = JsonConvert.SerializeObject(obj); - await SetKeyAsync(key, value, expiresAt); + await SetKeyAsync(key, value, expires, ct); } - public async Task GetKeyAsync(string key, bool delete = false) + public async Task GetKeyAsync( + string key, + bool delete = false, + CancellationToken ct = default + ) where T : class { - string? value = await GetKeyAsync(key, delete); + string? value = await GetKeyAsync(key, delete, ct); return value == null ? default : JsonConvert.DeserializeObject(value); } } diff --git a/Foxnouns.Backend/Services/ModerationService.cs b/Foxnouns.Backend/Services/ModerationService.cs index 30d99ed..4e2afe6 100644 --- a/Foxnouns.Backend/Services/ModerationService.cs +++ b/Foxnouns.Backend/Services/ModerationService.cs @@ -27,6 +27,7 @@ public class ModerationService( ILogger logger, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator, + IQueue queue, IClock clock ) { @@ -180,7 +181,9 @@ public class ModerationService( target.CustomPreferences = []; target.ProfileFlags = []; - UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(target.Id, null)); + queue.QueueInvocableWithPayload( + new AvatarUpdatePayload(target.Id, null) + ); // TODO: also clear member profiles? @@ -261,9 +264,10 @@ public class ModerationService( targetMember.DisplayName = null; break; case FieldsToClear.Avatar: - MemberAvatarUpdateJob.Enqueue( - new AvatarUpdatePayload(targetMember.Id, null) - ); + queue.QueueInvocableWithPayload< + MemberAvatarUpdateInvocable, + AvatarUpdatePayload + >(new AvatarUpdatePayload(targetMember.Id, null)); break; case FieldsToClear.Bio: targetMember.Bio = null; @@ -302,7 +306,10 @@ public class ModerationService( targetUser.DisplayName = null; break; case FieldsToClear.Avatar: - UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(targetUser.Id, null)); + queue.QueueInvocableWithPayload< + UserAvatarUpdateInvocable, + AvatarUpdatePayload + >(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 3a7aec6..e3799c6 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -46,37 +46,6 @@ "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, )", @@ -309,16 +278,6 @@ "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, )", @@ -358,33 +317,6 @@ "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", @@ -752,14 +684,6 @@ "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",