Compare commits
2 commits
54ec469cd9
...
6c9d1c328b
Author | SHA1 | Date | |
---|---|---|---|
6c9d1c328b | |||
fb324e7576 |
17 changed files with 119 additions and 92 deletions
|
@ -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);
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
global using ILogger = Serilog.ILogger;
|
global using ILogger = Serilog.ILogger;
|
||||||
global using Log = Serilog.Log;
|
global using Log = Serilog.Log;
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
|
@ -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 =>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
26
Foxnouns.Backend/Services/PeriodicTasksService.cs
Normal file
26
Foxnouns.Backend/Services/PeriodicTasksService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue