Compare commits

...

2 commits

Author SHA1 Message Date
sam
7759225428
refactor(backend): replace coravel with hangfire for background jobs
for *some reason*, coravel locks a persistent job queue behind a
paywall. this means that if the server ever crashes, all pending jobs
are lost. this is... not good, so we're switching to hangfire for that
instead.

coravel is still used for emails, though.

BREAKING CHANGE: Foxnouns.NET now requires Redis to work. the EFCore
storage for hangfire doesn't work well enough, unfortunately.
2025-03-04 17:03:39 +01:00
sam
cd24196cd1
chore(backend): format 2025-02-28 16:53:53 +01:00
25 changed files with 277 additions and 271 deletions

View file

@ -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

View file

@ -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;

View file

@ -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 (

View file

@ -12,7 +12,6 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<ExportsController>();
@ -80,10 +74,7 @@ public class ExportsController(
throw new ApiError.BadRequest("You can't request a new data export so soon.");
}
queue.QueueInvocableWithPayload<CreateDataExportInvocable, CreateDataExportPayload>(
new CreateDataExportPayload(CurrentUser.Id)
);
CreateDataExportJob.Enqueue(CurrentUser.Id);
return NoContent();
}
}

View file

@ -12,7 +12,6 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<CreateFlagInvocable, CreateFlagPayload>(
new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image)
);
CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image));
return Accepted(userRenderer.RenderPrideFlag(flag));
}

View file

@ -12,7 +12,6 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
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<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(member.Id, req.Avatar)
);
MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
}
try

View file

@ -12,7 +12,6 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)
);
UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
}
try

View file

@ -64,7 +64,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
public DbSet<FediverseApplication> FediverseApplications { get; init; } = null!;
public DbSet<Token> Tokens { get; init; } = null!;
public DbSet<Application> Applications { get; init; } = null!;
public DbSet<TemporaryKey> TemporaryKeys { get; init; } = null!;
public DbSet<DataExport> DataExports { get; init; } = null!;
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
@ -87,7 +86,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder.Entity<User>().HasIndex(u => u.Sid).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
// Two indexes on auth_methods, one for fediverse auth and one for all other types.

View file

@ -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
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250304155708_RemoveTemporaryKeys")]
public partial class RemoveTemporaryKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "temporary_keys");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "temporary_keys",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
expires = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
key = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(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
);
}
}
}

View file

@ -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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("Expires")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("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<long>("Id")

View file

@ -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 <https://www.gnu.org/licenses/>.
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; }
}

View file

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

View file

@ -23,23 +23,19 @@ namespace Foxnouns.Backend.Extensions;
public static class KeyCacheExtensions
{
public static async Task<string> GenerateAuthStateAsync(
this KeyCacheService keyCacheService,
CancellationToken ct = default
)
public static async Task<string> 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<string> 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<RegisterEmailState?> GetRegisterEmailStateAsync(
this KeyCacheService keyCacheService,
string state,
CancellationToken ct = default
) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", ct: ct);
string state
) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}");
public static async Task<string> 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<AddExtraAccountState?> GetAddExtraAccountStateAsync(
this KeyCacheService keyCacheService,
string state,
CancellationToken ct = default
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct);
string state
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true);
public static async Task<string> 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<ForgotPasswordState?> GetForgotPasswordStateAsync(
this KeyCacheService keyCacheService,
string state,
bool delete = true,
CancellationToken ct = default
) =>
await keyCacheService.GetKeyAsync<ForgotPasswordState>(
$"forgot_password:{state}",
delete,
ct
);
bool delete = true
) => await keyCacheService.GetKeyAsync<ForgotPasswordState>($"forgot_password:{state}", delete);
}
public record RegisterEmailState(

View file

@ -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<MailService>()
.AddSingleton<EmailRateLimiter>()
.AddSingleton<KeyCacheService>()
.AddScoped<UserRendererService>()
.AddScoped<MemberRendererService>()
.AddScoped<ModerationRendererService>()
.AddScoped<ModerationService>()
.AddScoped<AuthService>()
.AddScoped<KeyCacheService>()
.AddScoped<RemoteAuthService>()
.AddScoped<FediverseAuthService>()
.AddScoped<ObjectStorageService>()
@ -126,10 +129,10 @@ public static class WebApplicationExtensions
// Background services
.AddHostedService<PeriodicTasksService>()
// Transient jobs
.AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>()
.AddTransient<CreateFlagInvocable>()
.AddTransient<CreateDataExportInvocable>()
.AddTransient<UserAvatarUpdateJob>()
.AddTransient<MemberAvatarUpdateJob>()
.AddTransient<CreateDataExportJob>()
.AddTransient<CreateFlagJob>()
// Legacy services
.AddScoped<UsersV1Service>()
.AddScoped<MembersV1Service>();

View file

@ -12,6 +12,9 @@
<PackageReference Include="Coravel.Mailer" Version="7.1.0"/>
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.3"/>
<PackageReference Include="Hangfire" Version="1.8.18"/>
<PackageReference Include="Hangfire.Core" Version="1.8.18"/>
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.4"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="9.0.2"/>
@ -42,6 +45,7 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.24"/>
<PackageReference Include="System.Text.Json" Version="9.0.2"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageReference Include="Yort.Xid.Net" Version="2.0.1"/>

View file

@ -14,11 +14,11 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<CreateDataExportPayload>
)
{
private static readonly HttpClient Client = new();
private readonly ILogger _logger = logger.ForContext<CreateDataExportInvocable>();
public required CreateDataExportPayload Payload { get; set; }
private readonly ILogger _logger = logger.ForContext<CreateDataExportJob>();
public async Task Invoke()
public static void Enqueue(Snowflake userId)
{
BackgroundJob.Enqueue<CreateDataExportJob>(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;
}

View file

@ -12,49 +12,53 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<CreateFlagPayload>
)
{
private readonly ILogger _logger = logger.ForContext<CreateFlagInvocable>();
public required CreateFlagPayload Payload { get; set; }
private readonly ILogger _logger = logger.ForContext<CreateFlagJob>();
public async Task Invoke()
public static void Enqueue(CreateFlagPayload payload)
{
BackgroundJob.Enqueue<CreateFlagJob>(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();

View file

@ -12,29 +12,33 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<AvatarUpdatePayload>
)
{
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
private readonly ILogger _logger = logger.ForContext<MemberAvatarUpdateJob>();
public async Task Invoke()
public static void Enqueue(AvatarUpdatePayload payload)
{
if (Payload.NewAvatar != null)
await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar);
BackgroundJob.Enqueue<MemberAvatarUpdateJob>(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)

View file

@ -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);

View file

@ -12,29 +12,33 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<AvatarUpdatePayload>
)
{
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateJob>();
public async Task Invoke()
public static void Enqueue(AvatarUpdatePayload payload)
{
if (Payload.NewAvatar != null)
await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar);
BackgroundJob.Enqueue<UserAvatarUpdateJob>(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)

View file

@ -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<KeyCacheService>().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

View file

@ -23,8 +23,11 @@ public class EmailRateLimiter
{
private readonly ConcurrentDictionary<string, RateLimiter> _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));

View file

@ -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<KeyCacheService>();
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<string?> GetKeyAsync(string key, bool delete = false) =>
delete
? await Multiplexer.GetDatabase().StringGetDeleteAsync(key)
: await Multiplexer.GetDatabase().StringGetAsync(key);
public async Task<string?> 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<T>(
string key,
T obj,
Duration expiresAt,
CancellationToken ct = default
)
where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct);
public async Task SetKeyAsync<T>(
string key,
T obj,
Instant expires,
CancellationToken ct = default
)
public async Task SetKeyAsync<T>(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<T?> GetKeyAsync<T>(
string key,
bool delete = false,
CancellationToken ct = default
)
public async Task<T?> GetKeyAsync<T>(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<T>(value);
}
}

View file

@ -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<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
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;

View file

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