Compare commits

..

2 commits

17 changed files with 119 additions and 92 deletions

View file

@ -9,11 +9,13 @@ namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/v2/auth")] [Route("/api/v2/auth")]
public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger logger) : ApiControllerBase public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger logger) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<AuthController>();
[HttpPost("urls")] [HttpPost("urls")]
[ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)] [ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> UrlsAsync() public async Task<IActionResult> UrlsAsync()
{ {
logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}", _logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}",
config.DiscordAuth.Enabled, config.DiscordAuth.Enabled,
config.GoogleAuth.Enabled, config.GoogleAuth.Enabled,
config.TumblrAuth.Enabled); config.TumblrAuth.Enabled);

View file

@ -20,6 +20,8 @@ public class DiscordAuthController(
RemoteAuthService remoteAuthSvc, RemoteAuthService remoteAuthSvc,
UserRendererService userRendererSvc) : ApiControllerBase UserRendererService userRendererSvc) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
[HttpPost("callback")] [HttpPost("callback")]
// TODO: duplicating attribute doesn't work, find another way to mark both as possible response // TODO: duplicating attribute doesn't work, find another way to mark both as possible response
// leaving it here for documentation purposes // leaving it here for documentation purposes
@ -34,7 +36,7 @@ public class DiscordAuthController(
var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id); var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
if (user != null) return Ok(await GenerateUserTokenAsync(user)); if (user != null) return Ok(await GenerateUserTokenAsync(user));
logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, _logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username,
remoteUser.Id); remoteUser.Id);
var ticket = AuthUtils.RandomToken(); var ticket = AuthUtils.RandomToken();
@ -51,7 +53,7 @@ public class DiscordAuthController(
if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id))
{ {
logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account", _logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account",
remoteUser.Id); remoteUser.Id);
throw new FoxnounsError("Discord ticket was issued for user with existing link"); throw new FoxnounsError("Discord ticket was issued for user with existing link");
} }
@ -65,13 +67,13 @@ public class DiscordAuthController(
private async Task<AuthController.AuthResponse> GenerateUserTokenAsync(User user) private async Task<AuthController.AuthResponse> GenerateUserTokenAsync(User user)
{ {
var frontendApp = await db.GetFrontendApplicationAsync(); var frontendApp = await db.GetFrontendApplicationAsync();
logger.Debug("Logging user {Id} in with Discord", user.Id); _logger.Debug("Logging user {Id} in with Discord", user.Id);
var (tokenStr, token) = var (tokenStr, token) =
authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
db.Add(token); db.Add(token);
logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View file

@ -13,6 +13,8 @@ public class EmailAuthController(
IClock clock, IClock clock,
ILogger logger) : ApiControllerBase ILogger logger) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<EmailAuthController>();
[HttpPost("login")] [HttpPost("login")]
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req) public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req)
@ -23,13 +25,13 @@ public class EmailAuthController(
var frontendApp = await db.GetFrontendApplicationAsync(); var frontendApp = await db.GetFrontendApplicationAsync();
logger.Debug("Logging user {Id} in with email and password", user.Id); _logger.Debug("Logging user {Id} in with email and password", user.Id);
var (tokenStr, token) = var (tokenStr, token) =
authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
db.Add(token); db.Add(token);
logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id);
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View file

@ -14,11 +14,13 @@ public class DebugController(
IClock clock, IClock clock,
ILogger logger) : ApiControllerBase ILogger logger) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<DebugController>();
[HttpPost("users")] [HttpPost("users")]
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CreateUserAsync([FromBody] CreateUserRequest req) public async Task<IActionResult> CreateUserAsync([FromBody] CreateUserRequest req)
{ {
logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email); _logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email);
var user = await authSvc.CreateUserWithPasswordAsync(req.Username, req.Email, req.Password); var user = await authSvc.CreateUserWithPasswordAsync(req.Username, req.Email, req.Password);
var frontendApp = await db.GetFrontendApplicationAsync(); var frontendApp = await db.GetFrontendApplicationAsync();

View file

@ -49,7 +49,8 @@ public static class DatabaseQueryExtensions
throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef, Token? token) public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef,
Token? token)
{ {
var user = await context.ResolveUserAsync(userRef, token); var user = await context.ResolveUserAsync(userRef, token);
return await context.ResolveMemberAsync(user.Id, memberRef); return await context.ResolveMemberAsync(user.Id, memberRef);

View file

@ -39,7 +39,7 @@ public class User : BaseModel
public required string Tooltip { get; set; } public required string Tooltip { get; set; }
public bool Muted { get; set; } public bool Muted { get; set; }
public bool Favourite { get; set; } public bool Favourite { get; set; }
// This type is generally serialized directly, so the converter is applied here. // This type is generally serialized directly, so the converter is applied here.
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public PreferenceSize Size { get; set; } public PreferenceSize Size { get; set; }

View file

@ -5,6 +5,7 @@ using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Minio;
using NodaTime; using NodaTime;
using Prometheus; using Prometheus;
using Serilog; using Serilog;
@ -57,16 +58,6 @@ public static class WebApplicationExtensions
return config; return config;
} }
public static WebApplicationBuilder AddMetrics(this WebApplicationBuilder builder, Config config)
{
builder.Services.AddMetricServer(o => o.Port = config.Logging.MetricsPort)
.AddSingleton<MetricsCollectionService>();
if (!config.Logging.EnableMetrics)
builder.Services.AddHostedService<BackgroundMetricsCollectionService>();
return builder;
}
public static IConfigurationBuilder AddConfiguration(this IConfigurationBuilder builder) public static IConfigurationBuilder AddConfiguration(this IConfigurationBuilder builder)
{ {
var file = Environment.GetEnvironmentVariable("FOXNOUNS_CONFIG_FILE") ?? "config.ini"; var file = Environment.GetEnvironmentVariable("FOXNOUNS_CONFIG_FILE") ?? "config.ini";
@ -78,18 +69,40 @@ public static class WebApplicationExtensions
.AddEnvironmentVariables(); .AddEnvironmentVariables();
} }
public static IServiceCollection AddCustomServices(this IServiceCollection services) => services /// <summary>
.AddSingleton<IClock>(SystemClock.Instance) /// Adds required services to the IServiceCollection.
.AddSnowflakeGenerator() /// This should only add services that are not ASP.NET-related (i.e. no middleware).
.AddScoped<UserRendererService>() /// </summary>
.AddScoped<MemberRendererService>() public static IServiceCollection AddServices(this IServiceCollection services, Config config)
.AddScoped<AuthService>() {
.AddScoped<KeyCacheService>() services
.AddScoped<RemoteAuthService>() .AddQueue()
.AddScoped<ObjectStorageService>() .AddDbContext<DatabaseContext>()
// Transient jobs .AddMetricServer(o => o.Port = config.Logging.MetricsPort)
.AddTransient<MemberAvatarUpdateInvocable>() .AddMinio(c =>
.AddTransient<UserAvatarUpdateInvocable>(); c.WithEndpoint(config.Storage.Endpoint)
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
.Build())
.AddSingleton<MetricsCollectionService>()
.AddSingleton<IClock>(SystemClock.Instance)
.AddSnowflakeGenerator()
.AddScoped<UserRendererService>()
.AddScoped<MemberRendererService>()
.AddScoped<AuthService>()
.AddScoped<KeyCacheService>()
.AddScoped<RemoteAuthService>()
.AddScoped<ObjectStorageService>()
// Background services
.AddHostedService<PeriodicTasksService>()
// Transient jobs
.AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>();
if (!config.Logging.EnableMetrics)
services.AddHostedService<BackgroundMetricsCollectionService>();
return services;
}
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
.AddScoped<ErrorHandlerMiddleware>() .AddScoped<ErrorHandlerMiddleware>()

View file

@ -6,31 +6,31 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Coravel" Version="5.0.4" /> <PackageReference Include="Coravel" Version="5.0.4"/>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" /> <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Minio" Version="6.0.3" /> <PackageReference Include="Minio" Version="6.0.3"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/> <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NodaTime" Version="3.1.11"/> <PackageReference Include="NodaTime" Version="3.1.11"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3" /> <PackageReference Include="Npgsql.Json.NET" Version="8.0.3"/>
<PackageReference Include="prometheus-net" Version="8.2.1" /> <PackageReference Include="prometheus-net" Version="8.2.1"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0" /> <PackageReference Include="Sentry.AspNetCore" Version="4.9.0"/>
<PackageReference Include="Serilog" Version="4.0.1" /> <PackageReference Include="Serilog" Version="4.0.1"/>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/> <PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.5"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup> </ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation"> <Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">

View file

@ -15,7 +15,7 @@ public static class FoxnounsMetrics
public static readonly Gauge UsersActiveDayCount = public static readonly Gauge UsersActiveDayCount =
Metrics.CreateGauge("foxnouns_user_count_active_day", "Number of users active in the last day"); Metrics.CreateGauge("foxnouns_user_count_active_day", "Number of users active in the last day");
public static readonly Gauge MemberCount = public static readonly Gauge MemberCount =
Metrics.CreateGauge("foxnouns_member_count", "Number of total members"); Metrics.CreateGauge("foxnouns_member_count", "Number of total members");

View file

@ -1,2 +1,2 @@
global using ILogger = Serilog.ILogger; global using ILogger = Serilog.ILogger;
global using Log = Serilog.Log; global using Log = Serilog.Log;

View file

@ -21,7 +21,7 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService
private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar) private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar)
{ {
_logger.Debug("Updating avatar for user {MemberId}", id); _logger.Debug("Updating avatar for user {MemberId}", id);
var user = await db.Users.FindAsync(id); var user = await db.Users.FindAsync(id);
if (user == null) if (user == null)
{ {
@ -55,7 +55,7 @@ public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService
private async Task ClearUserAvatarAsync(Snowflake id) private async Task ClearUserAvatarAsync(Snowflake id)
{ {
_logger.Debug("Clearing avatar for user {MemberId}", id); _logger.Debug("Clearing avatar for user {MemberId}", id);
var user = await db.Users.FindAsync(id); var user = await db.Users.FindAsync(id);
if (user == null) if (user == null)
{ {

View file

@ -110,6 +110,7 @@ public record HttpApiError
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public required ErrorCode Code { get; init; } public required ErrorCode Code { get; init; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string? ErrorId { get; init; } public string? ErrorId { get; init; }

View file

@ -1,12 +1,9 @@
using Coravel;
using Foxnouns.Backend; using Foxnouns.Backend;
using Foxnouns.Backend.Database;
using Serilog; using Serilog;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Minio;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using Prometheus; using Prometheus;
@ -19,9 +16,7 @@ var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration(); var config = builder.AddConfiguration();
builder builder.AddSerilog();
.AddSerilog()
.AddMetrics(config);
builder.WebHost builder.WebHost
.UseSentry(opts => .UseSentry(opts =>
@ -64,16 +59,10 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings
}; };
builder.Services builder.Services
.AddQueue() .AddServices(config)
.AddDbContext<DatabaseContext>()
.AddCustomServices()
.AddCustomMiddleware() .AddCustomMiddleware()
.AddEndpointsApiExplorer() .AddEndpointsApiExplorer()
.AddSwaggerGen() .AddSwaggerGen();
.AddMinio(c =>
c.WithEndpoint(config.Storage.Endpoint)
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
.Build());
var app = builder.Build(); var app = builder.Build();
@ -97,23 +86,5 @@ app.Urls.Add(config.Address);
Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct => Metrics.DefaultRegistry.AddBeforeCollectCallback(async ct =>
await app.Services.GetRequiredService<MetricsCollectionService>().CollectMetricsAsync(ct)); await app.Services.GetRequiredService<MetricsCollectionService>().CollectMetricsAsync(ct));
// Fire off the periodic tasks loop in the background
_ = new Timer(_ =>
{
var __ = RunPeriodicTasksAsync();
}, null, TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1));
app.Run(); app.Run();
Log.CloseAndFlush(); Log.CloseAndFlush();
return;
async Task RunPeriodicTasksAsync()
{
await using var scope = app.Services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger>();
logger.Debug("Running periodic tasks");
var keyCacheSvc = scope.ServiceProvider.GetRequiredService<KeyCacheService>();
await keyCacheSvc.DeleteExpiredKeysAsync();
}

View file

@ -9,6 +9,8 @@ namespace Foxnouns.Backend.Services;
public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
{ {
private readonly ILogger _logger = logger.ForContext<KeyCacheService>();
public Task SetKeyAsync(string key, string value, Duration expireAfter) => public Task SetKeyAsync(string key, string value, Duration expireAfter) =>
SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter); SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter);
@ -37,10 +39,10 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
return value.Value; return value.Value;
} }
public async Task DeleteExpiredKeysAsync() public async Task DeleteExpiredKeysAsync(CancellationToken ct)
{ {
var count = await db.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()).ExecuteDeleteAsync(); var 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); if (count != 0) _logger.Information("Removed {Count} expired keys from the database", count);
} }
public Task SetKeyAsync<T>(string key, T obj, Duration expiresAt) where T : class => public Task SetKeyAsync<T>(string key, T obj, Duration expiresAt) where T : class =>

View file

@ -7,21 +7,25 @@ namespace Foxnouns.Backend.Services;
public class ObjectStorageService(ILogger logger, Config config, IMinioClient minio) public class ObjectStorageService(ILogger logger, Config config, IMinioClient minio)
{ {
private readonly ILogger _logger = logger.ForContext<ObjectStorageService>(); private readonly ILogger _logger = logger.ForContext<ObjectStorageService>();
public async Task RemoveObjectAsync(string path) public async Task RemoveObjectAsync(string path)
{ {
logger.Debug("Deleting object at path {Path}", path); _logger.Debug("Deleting object at path {Path}", path);
try try
{ {
await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path)); await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path));
} }
catch (InvalidObjectNameException) catch (InvalidObjectNameException)
{ {
// ignore non-existent objects
} }
} }
public async Task PutObjectAsync(string path, Stream data, string contentType) public async Task PutObjectAsync(string path, Stream data, string contentType)
{ {
_logger.Debug("Putting object at path {Path} with length {Length} and content type {ContentType}", path,
data.Length, contentType);
await minio.PutObjectAsync(new PutObjectArgs() await minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(config.Storage.Bucket) .WithBucket(config.Storage.Bucket)
.WithObject(path) .WithObject(path)

View file

@ -0,0 +1,26 @@
namespace Foxnouns.Backend.Services;
public class PeriodicTasksService(ILogger logger, IServiceProvider services) : BackgroundService
{
private readonly ILogger _logger = logger.ForContext<PeriodicTasksService>();
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(ct))
{
_logger.Debug("Collecting metrics");
await RunPeriodicTasksAsync(ct);
}
}
private async Task RunPeriodicTasksAsync(CancellationToken ct)
{
_logger.Debug("Running periodic tasks");
await using var scope = services.CreateAsyncScope();
var keyCacheSvc = scope.ServiceProvider.GetRequiredService<KeyCacheService>();
await keyCacheSvc.DeleteExpiredKeysAsync(ct);
}
}

View file

@ -120,7 +120,6 @@ public static class ValidationUtils
} }
private static readonly string[] DefaultStatusOptions = private static readonly string[] DefaultStatusOptions =
[ [
"favourite", "favourite",
@ -147,11 +146,13 @@ public static class ValidationUtils
{ {
case > Limits.FieldNameLimit: case > Limits.FieldNameLimit:
errors.Add(($"fields.{index}.name", errors.Add(($"fields.{index}.name",
ValidationError.LengthError("Field name is too long", 1, Limits.FieldNameLimit, field.Name.Length))); ValidationError.LengthError("Field name is too long", 1, Limits.FieldNameLimit,
field.Name.Length)));
break; break;
case < 1: case < 1:
errors.Add(($"fields.{index}.name", errors.Add(($"fields.{index}.name",
ValidationError.LengthError("Field name is too short", 1, Limits.FieldNameLimit, field.Name.Length))); ValidationError.LengthError("Field name is too short", 1, Limits.FieldNameLimit,
field.Name.Length)));
break; break;
} }