Compare commits

..

No commits in common. "4a6b5f3b853e511c24a05b62bac17512c4b8db2a" and "2915893049bfcc4a11a90037019269179c03edab" have entirely different histories.

19 changed files with 309 additions and 305 deletions

1
.gitignore vendored
View file

@ -2,4 +2,3 @@ bin/
obj/
.version
config.ini
*.DotSettings.user

View file

@ -14,6 +14,7 @@ public class Config
public LoggingConfig Logging { get; init; } = new();
public DatabaseConfig Database { get; init; } = new();
public JobsConfig Jobs { get; init; } = new();
public StorageConfig Storage { get; init; } = new();
public DiscordAuthConfig DiscordAuth { get; init; } = new();
public GoogleAuthConfig GoogleAuth { get; init; } = new();
@ -37,6 +38,12 @@ public class Config
public int? MaxPoolSize { get; init; }
}
public class JobsConfig
{
public string Redis { get; init; } = string.Empty;
public int Workers { get; init; } = 5;
}
public class StorageConfig
{
public string Endpoint { get; init; } = string.Empty;

View file

@ -19,7 +19,7 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger
config.TumblrAuth.Enabled);
var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync());
string? discord = null;
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
if (config.DiscordAuth.ClientId != null && config.DiscordAuth.ClientSecret != null)
discord =
$"https://discord.com/oauth2/authorize?response_type=code" +
$"&client_id={config.DiscordAuth.ClientId}&scope=identify" +

View file

@ -1,8 +1,6 @@
using Coravel.Queuing.Interfaces;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
@ -18,8 +16,7 @@ public class MembersController(
DatabaseContext db,
MemberRendererService memberRendererService,
ISnowflakeGenerator snowflakeGenerator,
ObjectStorageService objectStorage,
IQueue queue) : ApiControllerBase
AvatarUpdateJob avatarUpdate) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<MembersController>();
@ -79,9 +76,7 @@ public class MembersController(
throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name);
}
if (req.Avatar != null)
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
new AvatarUpdatePayload(member.Id, req.Avatar));
if (req.Avatar != null) AvatarUpdateJob.QueueUpdateMemberAvatar(member.Id, req.Avatar);
return Ok(memberRendererService.RenderMember(member, CurrentToken));
}
@ -101,7 +96,7 @@ public class MembersController(
await db.SaveChangesAsync();
if (member.Avatar != null) await objectStorage.DeleteMemberAvatarAsync(member.Id, member.Avatar);
if (member.Avatar != null) await avatarUpdate.DeleteMemberAvatar(member.Id, member.Avatar);
return NoContent();
}

View file

@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Jobs;
@ -15,8 +14,7 @@ namespace Foxnouns.Backend.Controllers;
public class UsersController(
DatabaseContext db,
UserRendererService userRendererService,
ISnowflakeGenerator snowflakeGenerator,
IQueue queue) : ApiControllerBase
ISnowflakeGenerator snowflakeGenerator) : ApiControllerBase
{
[HttpGet("{userRef}")]
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
@ -67,8 +65,7 @@ public class UsersController(
// (atomic operations are hard when combined with background jobs)
// 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));
AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar);
await db.SaveChangesAsync();
await tx.CommitAsync();

View file

@ -1,51 +0,0 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
namespace Foxnouns.Backend.Extensions;
public static class AvatarObjectExtensions
{
private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"];
public static async Task
DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) =>
await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash));
public static async Task
DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) =>
await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash));
public static async Task<Stream> ConvertBase64UriToAvatar(this string uri)
{
if (!uri.StartsWith("data:image/"))
throw new ArgumentException("Not a data URI", nameof(uri));
var split = uri.Remove(0, "data:".Length).Split(";base64,");
var contentType = split[0];
var encoded = split[1];
if (!ValidContentTypes.Contains(contentType))
throw new ArgumentException("Invalid content type for image", nameof(uri));
if (!AuthUtils.TryFromBase64String(encoded, out var rawImage))
throw new ArgumentException("Invalid base64 string", nameof(uri));
var image = Image.Load(rawImage);
var processor = new ResizeProcessor(
new ResizeOptions { Size = new Size(512), Mode = ResizeMode.Crop, Position = AnchorPositionMode.Center },
image.Size
);
image.Mutate(x => x.ApplyProcessor(processor));
var stream = new MemoryStream(64 * 1024);
await image.SaveAsync(stream, new WebpEncoder { Quality = 95, NearLossless = false });
return stream;
}
}

View file

@ -1,8 +1,6 @@
using App.Metrics;
using App.Metrics.AspNetCore;
using App.Metrics.Formatters.Prometheus;
using Coravel;
using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
@ -95,7 +93,6 @@ public static class WebApplicationExtensions
return builder
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appSettings.json", true)
.AddIniFile(file, optional: false, reloadOnChange: true)
.AddEnvironmentVariables();
}
@ -108,10 +105,8 @@ public static class WebApplicationExtensions
.AddScoped<AuthService>()
.AddScoped<KeyCacheService>()
.AddScoped<RemoteAuthService>()
.AddScoped<ObjectStorageService>()
// Transient jobs
.AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>();
// Background job classes
.AddTransient<AvatarUpdateJob>();
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
.AddScoped<ErrorHandlerMiddleware>()
@ -127,8 +122,6 @@ public static class WebApplicationExtensions
{
await BuildInfo.ReadBuildInfo();
app.Services.ConfigureQueue().LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>());
await using var scope = app.Services.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<WebApplication>();
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();

View file

@ -9,9 +9,11 @@
<PackageReference Include="App.Metrics" Version="4.3.0" />
<PackageReference Include="App.Metrics.AspNetCore.All" Version="4.3.0" />
<PackageReference Include="App.Metrics.Prometheus" Version="4.3.0" />
<PackageReference Include="Coravel" Version="5.0.4" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Hangfire.Core" Version="1.8.14" />
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.3" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
@ -26,6 +28,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3" />
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0" />
<PackageReference Include="Sentry.Hangfire" Version="4.9.0" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />

View file

@ -0,0 +1,220 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Utils;
using Hangfire;
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Transforms;
namespace Foxnouns.Backend.Jobs;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global", Justification = "Hangfire jobs need to be public")]
public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config config, ILogger logger)
{
private readonly string[] _validContentTypes = ["image/png", "image/webp", "image/jpeg"];
public static void QueueUpdateUserAvatar(Snowflake id, string? newAvatar)
{
if (newAvatar != null)
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.UpdateUserAvatar(id, newAvatar));
else
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.ClearUserAvatar(id));
}
public static void QueueUpdateMemberAvatar(Snowflake id, string? newAvatar)
{
if (newAvatar != null)
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.UpdateMemberAvatar(id, newAvatar));
else
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.ClearMemberAvatar(id));
}
public async Task UpdateUserAvatar(Snowflake id, string newAvatar)
{
var user = await db.Users.FindAsync(id);
if (user == null)
{
logger.Warning("Update avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
try
{
var image = await ConvertAvatar(newAvatar);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = user.Avatar;
await minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(UserAvatarPath(id, hash))
.WithObjectSize(image.Length)
.WithStreamData(image)
.WithContentType("image/webp")
);
user.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(UserAvatarPath(id, prevHash))
);
logger.Information("Updated avatar for user {UserId}", id);
}
catch (ArgumentException ae)
{
logger.Warning("Invalid data URI for new avatar for user {UserId}: {Reason}", id, ae.Message);
}
}
public async Task ClearUserAvatar(Snowflake id)
{
var user = await db.Users.FindAsync(id);
if (user == null)
{
logger.Warning("Clear avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
if (user.Avatar == null)
{
logger.Warning("Clear avatar job queued for {UserId} with null avatar", id);
return;
}
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(UserAvatarPath(user.Id, user.Avatar))
);
user.Avatar = null;
await db.SaveChangesAsync();
}
public async Task UpdateMemberAvatar(Snowflake id, string newAvatar)
{
var member = await db.Members.FindAsync(id);
if (member == null)
{
logger.Warning("Update avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
try
{
var image = await ConvertAvatar(newAvatar);
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = member.Avatar;
await minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(MemberAvatarPath(id, hash))
.WithObjectSize(image.Length)
.WithStreamData(image)
.WithContentType("image/webp")
);
member.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(MemberAvatarPath(id, prevHash))
);
logger.Information("Updated avatar for member {MemberId}", id);
}
catch (ArgumentException ae)
{
logger.Warning("Invalid data URI for new avatar for member {MemberId}: {Reason}", id, ae.Message);
}
}
public async Task ClearMemberAvatar(Snowflake id)
{
var member = await db.Members.FindAsync(id);
if (member == null)
{
logger.Warning("Clear avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
if (member.Avatar == null)
{
logger.Warning("Clear avatar job queued for {MemberId} with null avatar", id);
return;
}
await minio.RemoveObjectAsync(new RemoveObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(MemberAvatarPath(member.Id, member.Avatar))
);
member.Avatar = null;
await db.SaveChangesAsync();
}
/// <summary>
/// Deletes a member's avatar. This should only be used when a member is in the process of being deleted, otherwise,
/// <see cref="QueueUpdateMemberAvatar" /> with a <c>null</c> avatar should be used instead.
/// </summary>
public async Task DeleteMemberAvatar(Snowflake id, string hash) => await DeleteAvatar(MemberAvatarPath(id, hash));
/// <summary>
/// Deletes a user's avatar. This should only be used when a user is in the process of being deleted, otherwise,
/// <see cref="QueueUpdateUserAvatar" /> with a <c>null</c> avatar should be used instead.
/// </summary>
public async Task DeleteUserAvatar(Snowflake id, string hash) => await DeleteAvatar(UserAvatarPath(id, hash));
private async Task DeleteAvatar(string path)
{
logger.Debug("Deleting avatar at path {Path}", path);
try
{
await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path));
}
catch (InvalidObjectNameException)
{
}
}
private async Task<Stream> ConvertAvatar(string uri)
{
if (!uri.StartsWith("data:image/"))
throw new ArgumentException("Not a data URI", nameof(uri));
var split = uri.Remove(0, "data:".Length).Split(";base64,");
var contentType = split[0];
var encoded = split[1];
if (!_validContentTypes.Contains(contentType))
throw new ArgumentException("Invalid content type for image", nameof(uri));
if (!AuthUtils.TryFromBase64String(encoded, out var rawImage))
throw new ArgumentException("Invalid base64 string", nameof(uri));
var image = Image.Load(rawImage);
var processor = new ResizeProcessor(
new ResizeOptions { Size = new Size(512), Mode = ResizeMode.Crop, Position = AnchorPositionMode.Center },
image.Size
);
image.Mutate(x => x.ApplyProcessor(processor));
var stream = new MemoryStream(64 * 1024);
await image.SaveAsync(stream, new WebpEncoder { Quality = 95, NearLossless = false });
return stream;
}
private static string UserAvatarPath(Snowflake id, string hash) => $"users/{id}/avatars/{hash}.webp";
private static string MemberAvatarPath(Snowflake id, string hash) => $"members/{id}/avatars/{hash}.webp";
}

View file

@ -1,79 +0,0 @@
using System.Security.Cryptography;
using Coravel.Invocable;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Jobs;
public class MemberAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger)
: IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
public async Task Invoke()
{
if (Payload.NewAvatar != null) await UpdateMemberAvatarAsync(Payload.Id, Payload.NewAvatar);
else await ClearMemberAvatarAsync(Payload.Id);
}
private async Task UpdateMemberAvatarAsync(Snowflake id, string newAvatar)
{
_logger.Debug("Updating avatar for member {MemberId}", id);
var member = await db.Members.FindAsync(id);
if (member == null)
{
_logger.Warning("Update avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
try
{
var image = await newAvatar.ConvertBase64UriToAvatar();
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = member.Avatar;
await objectStorage.PutObjectAsync(Path(id, hash), image, "image/webp");
member.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await objectStorage.RemoveObjectAsync(Path(id, prevHash));
_logger.Information("Updated avatar for member {MemberId}", id);
}
catch (ArgumentException ae)
{
_logger.Warning("Invalid data URI for new avatar for member {MemberId}: {Reason}", id, ae.Message);
}
}
private async Task ClearMemberAvatarAsync(Snowflake id)
{
_logger.Debug("Clearing avatar for member {MemberId}", id);
var member = await db.Members.FindAsync(id);
if (member == null)
{
_logger.Warning("Clear avatar job queued for {MemberId} but no member with that ID exists", id);
return;
}
if (member.Avatar == null)
{
_logger.Warning("Clear avatar job queued for {MemberId} with null avatar", id);
return;
}
await objectStorage.RemoveObjectAsync(Path(member.Id, member.Avatar));
member.Avatar = null;
await db.SaveChangesAsync();
}
public static string Path(Snowflake id, string hash) => $"members/{id}/avatars/{hash}.webp";
}

View file

@ -1,5 +0,0 @@
using Foxnouns.Backend.Database;
namespace Foxnouns.Backend.Jobs;
public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar);

View file

@ -1,79 +0,0 @@
using System.Security.Cryptography;
using Coravel.Invocable;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services;
namespace Foxnouns.Backend.Jobs;
public class UserAvatarUpdateInvocable(DatabaseContext db, ObjectStorageService objectStorage, ILogger logger)
: IInvocable, IInvocableWithPayload<AvatarUpdatePayload>
{
private readonly ILogger _logger = logger.ForContext<UserAvatarUpdateInvocable>();
public required AvatarUpdatePayload Payload { get; set; }
public async Task Invoke()
{
if (Payload.NewAvatar != null) await UpdateUserAvatarAsync(Payload.Id, Payload.NewAvatar);
else await ClearUserAvatarAsync(Payload.Id);
}
private async Task UpdateUserAvatarAsync(Snowflake id, string newAvatar)
{
_logger.Debug("Updating avatar for user {MemberId}", id);
var user = await db.Users.FindAsync(id);
if (user == null)
{
_logger.Warning("Update avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
try
{
var image = await newAvatar.ConvertBase64UriToAvatar();
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
image.Seek(0, SeekOrigin.Begin);
var prevHash = user.Avatar;
await objectStorage.PutObjectAsync(Path(id, hash), image, "image/webp");
user.Avatar = hash;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
await objectStorage.RemoveObjectAsync(Path(id, prevHash));
_logger.Information("Updated avatar for user {UserId}", id);
}
catch (ArgumentException ae)
{
_logger.Warning("Invalid data URI for new avatar for user {UserId}: {Reason}", id, ae.Message);
}
}
private async Task ClearUserAvatarAsync(Snowflake id)
{
_logger.Debug("Clearing avatar for user {MemberId}", id);
var user = await db.Users.FindAsync(id);
if (user == null)
{
_logger.Warning("Clear avatar job queued for {UserId} but no user with that ID exists", id);
return;
}
if (user.Avatar == null)
{
_logger.Warning("Clear avatar job queued for {UserId} with null avatar", id);
return;
}
await objectStorage.RemoveObjectAsync(Path(user.Id, user.Avatar));
user.Avatar = null;
await db.SaveChangesAsync();
}
public static string Path(Snowflake id, string hash) => $"users/{id}/avatars/{hash}.webp";
}

View file

@ -2,6 +2,7 @@ using System.Security.Cryptography;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
using Hangfire.Dashboard;
using Microsoft.EntityFrameworkCore;
using NodaTime;
@ -63,4 +64,33 @@ public static class HttpContextExtensions
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticateAttribute : Attribute;
public class AuthenticateAttribute : Attribute;
/// <summary>
/// Authentication filter for the Hangfire dashboard. Uses the cookie created by the frontend
/// (and otherwise only read <i>by</i> the frontend) to only allow admins to use it.
/// </summary>
public class HangfireDashboardAuthorizationFilter(IServiceProvider services) : IDashboardAsyncAuthorizationFilter
{
public async Task<bool> AuthorizeAsync(DashboardContext context)
{
await using var scope = services.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
var clock = scope.ServiceProvider.GetRequiredService<IClock>();
var httpContext = context.GetHttpContext();
if (!httpContext.Request.Cookies.TryGetValue("pronounscc-token", out var cookie)) return false;
if (!AuthUtils.TryFromBase64String(cookie!, out var rawToken)) return false;
var hash = SHA512.HashData(rawToken);
var oauthToken = await db.Tokens
.Include(t => t.Application)
.Include(t => t.User)
.FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired);
return oauthToken?.User.Role == UserRole.Admin;
}
}

View file

@ -1,15 +1,18 @@
using Coravel;
using Foxnouns.Backend;
using Foxnouns.Backend.Database;
using Serilog;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Hangfire;
using Hangfire.Redis.StackExchange;
using Microsoft.AspNetCore.Mvc;
using Minio;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Sentry.Extensibility;
using Sentry.Hangfire;
// Read version information from .version in the repository root
await BuildInfo.ReadBuildInfo();
@ -61,7 +64,6 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings
};
builder.Services
.AddQueue()
.AddDbContext<DatabaseContext>()
.AddCustomServices()
.AddCustomMiddleware()
@ -72,6 +74,12 @@ builder.Services
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
.Build());
builder.Services.AddHangfire(c => c.UseSentry().UseRedisStorage(config.Jobs.Redis, new RedisStorageOptions
{
Prefix = "foxnouns_"
}))
.AddHangfireServer(options => { options.WorkerCount = config.Jobs.Workers; });
var app = builder.Build();
await app.Initialize(args);
@ -87,6 +95,12 @@ app.UseCors();
app.UseCustomMiddleware();
app.MapControllers();
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
AppPath = null,
AsyncAuthorization = [new HangfireDashboardAuthorizationFilter(app.Services)]
});
app.Urls.Clear();
app.Urls.Add(config.Address);
if (config.MetricsAddress != null) app.Urls.Add(config.MetricsAddress);

View file

@ -1,33 +0,0 @@
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
namespace Foxnouns.Backend.Services;
public class ObjectStorageService(ILogger logger, Config config, IMinioClient minio)
{
private readonly ILogger _logger = logger.ForContext<ObjectStorageService>();
public async Task RemoveObjectAsync(string path)
{
logger.Debug("Deleting object at path {Path}", path);
try
{
await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path));
}
catch (InvalidObjectNameException)
{
}
}
public async Task PutObjectAsync(string path, Stream data, string contentType)
{
await minio.PutObjectAsync(new PutObjectArgs()
.WithBucket(config.Storage.Bucket)
.WithObject(path)
.WithObjectSize(data.Length)
.WithStreamData(data)
.WithContentType(contentType)
);
}
}

View file

@ -1,9 +0,0 @@
namespace Foxnouns.Backend.Utils;
public static class Limits
{
public const int FieldLimit = 25;
public const int FieldNameLimit = 100;
public const int FieldEntryTextLimit = 100;
public const int FieldEntriesLimit = 100;
}

View file

@ -57,11 +57,11 @@ public static class ValidationUtils
public static ValidationError? ValidateMemberName(string memberName)
{
if (!MemberRegex.IsMatch(memberName))
if (!UsernameRegex.IsMatch(memberName))
return memberName.Length switch
{
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
> 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
< 2 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
> 40 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
_ => ValidationError.GenericValidationError(
"Member name cannot contain any of the following: " +
" @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " +
@ -119,7 +119,10 @@ public static class ValidationUtils
};
}
private const int FieldLimit = 25;
private const int FieldNameLimit = 100;
private const int FieldEntryTextLimit = 100;
private const int FieldEntriesLimit = 100;
private static readonly string[] DefaultStatusOptions =
[
@ -137,7 +140,7 @@ public static class ValidationUtils
var errors = new List<(string, ValidationError?)>();
if (fields.Count > 25)
errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, Limits.FieldLimit, fields.Count)));
errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, FieldLimit, fields.Count)));
// No overwhelming this function, thank you
if (fields.Count > 100) return errors;
@ -145,13 +148,13 @@ public static class ValidationUtils
{
switch (field.Name.Length)
{
case > Limits.FieldNameLimit:
case > FieldNameLimit:
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, FieldNameLimit, field.Name.Length)));
break;
case < 1:
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, FieldNameLimit, field.Name.Length)));
break;
}
@ -167,26 +170,26 @@ public static class ValidationUtils
if (entries == null || entries.Length == 0) return [];
var errors = new List<(string, ValidationError?)>();
if (entries.Length > Limits.FieldEntriesLimit)
if (entries.Length > FieldEntriesLimit)
errors.Add(($"{errorPrefix}.entries",
ValidationError.LengthError("Field has too many entries", 0, Limits.FieldEntriesLimit,
ValidationError.LengthError("Field has too many entries", 0, FieldEntriesLimit,
entries.Length)));
// Same as above, no overwhelming this function with a ridiculous amount of entries
if (entries.Length > Limits.FieldEntriesLimit + 50) return errors;
if (entries.Length > FieldEntriesLimit + 50) return errors;
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
{
switch (entry.Value.Length)
{
case > Limits.FieldEntryTextLimit:
case > FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
ValidationError.LengthError("Field value is too long", 1, Limits.FieldEntryTextLimit,
ValidationError.LengthError("Field value is too long", 1, FieldEntryTextLimit,
entry.Value.Length)));
break;
case < 1:
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
ValidationError.LengthError("Field value is too short", 1, Limits.FieldEntryTextLimit,
ValidationError.LengthError("Field value is too short", 1, FieldEntryTextLimit,
entry.Value.Length)));
break;
}

View file

@ -1,7 +0,0 @@
{
"Coravel": {
"Queue": {
"ConsummationDelay": 1
}
}
}

View file

@ -31,6 +31,12 @@ Timeout = 5
; The maximum number of open connections. Defaults to 50.
MaxPoolSize = 50
[Jobs]
; The connection string for the Redis server.
Redis = localhost:6379
; The number of workers to use for background jobs. Defaults to 5.
Workers = 5
[Storage]
Endpoint = <s3EndpointHere>
AccessKey = <s3AccessKey>