Compare commits

..

No commits in common. "7759225428cc2540a5d292f5d67bd3606adc957a" and "7d6d4631b81168bf67df25c34d4d8cf2750a9942" have entirely different histories.

25 changed files with 271 additions and 277 deletions

View file

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

View file

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

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

View file

@ -12,6 +12,7 @@
//
// 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;
@ -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<ExportsController>();
@ -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<CreateDataExportInvocable, CreateDataExportPayload>(
new CreateDataExportPayload(CurrentUser.Id)
);
return NoContent();
}
}

View file

@ -12,6 +12,7 @@
//
// 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;
@ -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<CreateFlagInvocable, CreateFlagPayload>(
new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image)
);
return Accepted(userRenderer.RenderPrideFlag(flag));
}

View file

@ -12,6 +12,7 @@
//
// 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;
@ -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<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
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<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(member.Id, req.Avatar)
);
}
try

View file

@ -12,6 +12,7 @@
//
// 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;
@ -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<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)
);
}
try

View file

@ -64,6 +64,7 @@ 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!;
@ -86,6 +87,7 @@ 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

@ -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
{
/// <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.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<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

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

View file

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

View file

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

View file

@ -12,9 +12,6 @@
<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"/>
@ -45,7 +42,6 @@
<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 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<CreateDataExportPayload>
{
private static readonly HttpClient Client = new();
private readonly ILogger _logger = logger.ForContext<CreateDataExportJob>();
private readonly ILogger _logger = logger.ForContext<CreateDataExportInvocable>();
public required CreateDataExportPayload Payload { get; set; }
public static void Enqueue(Snowflake userId)
{
BackgroundJob.Enqueue<CreateDataExportJob>(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;
}

View file

@ -12,53 +12,49 @@
//
// 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 CreateFlagJob(
public class CreateFlagInvocable(
DatabaseContext db,
ObjectStorageService objectStorageService,
ILogger logger
)
) : IInvocable, IInvocableWithPayload<CreateFlagPayload>
{
private readonly ILogger _logger = logger.ForContext<CreateFlagJob>();
private readonly ILogger _logger = logger.ForContext<CreateFlagInvocable>();
public required CreateFlagPayload Payload { get; set; }
public static void Enqueue(CreateFlagPayload payload)
{
BackgroundJob.Enqueue<CreateFlagJob>(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();

View file

@ -12,33 +12,29 @@
//
// 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 MemberAvatarUpdateJob(
public class MemberAvatarUpdateInvocable(
DatabaseContext db,
ObjectStorageService objectStorageService,
ILogger logger
)
) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{
private readonly ILogger _logger = logger.ForContext<MemberAvatarUpdateJob>();
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
public static void Enqueue(AvatarUpdatePayload payload)
public async Task Invoke()
{
BackgroundJob.Enqueue<MemberAvatarUpdateJob>(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)

View file

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

View file

@ -12,33 +12,29 @@
//
// 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 UserAvatarUpdateJob(
public class UserAvatarUpdateInvocable(
DatabaseContext db,
ObjectStorageService objectStorageService,
ILogger logger
)
) : IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateJob>();
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
public static void Enqueue(AvatarUpdatePayload payload)
public async Task Invoke()
{
BackgroundJob.Enqueue<UserAvatarUpdateJob>(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)

View file

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

View file

@ -23,11 +23,8 @@ 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,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<KeyCacheService>();
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<string?> 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<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 Task DeleteExpiredKeysAsync(CancellationToken ct) => Task.CompletedTask;
if (delete)
await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
public async Task SetKeyAsync<T>(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<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
)
where T : class
{
string value = JsonConvert.SerializeObject(obj);
await SetKeyAsync(key, value, expiresAt);
await SetKeyAsync(key, value, expires, ct);
}
public async Task<T?> GetKeyAsync<T>(string key, bool delete = false)
public async Task<T?> GetKeyAsync<T>(
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<T>(value);
}
}

View file

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

View file

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