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",