Compare commits
No commits in common. "4a6b5f3b853e511c24a05b62bac17512c4b8db2a" and "2915893049bfcc4a11a90037019269179c03edab" have entirely different histories.
4a6b5f3b85
...
2915893049
19 changed files with 309 additions and 305 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,4 +2,3 @@ bin/
|
||||||
obj/
|
obj/
|
||||||
.version
|
.version
|
||||||
config.ini
|
config.ini
|
||||||
*.DotSettings.user
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ public class Config
|
||||||
|
|
||||||
public LoggingConfig Logging { get; init; } = new();
|
public LoggingConfig Logging { get; init; } = new();
|
||||||
public DatabaseConfig Database { get; init; } = new();
|
public DatabaseConfig Database { get; init; } = new();
|
||||||
|
public JobsConfig Jobs { get; init; } = new();
|
||||||
public StorageConfig Storage { get; init; } = new();
|
public StorageConfig Storage { get; init; } = new();
|
||||||
public DiscordAuthConfig DiscordAuth { get; init; } = new();
|
public DiscordAuthConfig DiscordAuth { get; init; } = new();
|
||||||
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
||||||
|
@ -37,6 +38,12 @@ public class Config
|
||||||
public int? MaxPoolSize { get; init; }
|
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 class StorageConfig
|
||||||
{
|
{
|
||||||
public string Endpoint { get; init; } = string.Empty;
|
public string Endpoint { get; init; } = string.Empty;
|
||||||
|
|
|
@ -19,7 +19,7 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger
|
||||||
config.TumblrAuth.Enabled);
|
config.TumblrAuth.Enabled);
|
||||||
var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync());
|
var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync());
|
||||||
string? discord = null;
|
string? discord = null;
|
||||||
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
|
if (config.DiscordAuth.ClientId != null && config.DiscordAuth.ClientSecret != null)
|
||||||
discord =
|
discord =
|
||||||
$"https://discord.com/oauth2/authorize?response_type=code" +
|
$"https://discord.com/oauth2/authorize?response_type=code" +
|
||||||
$"&client_id={config.DiscordAuth.ClientId}&scope=identify" +
|
$"&client_id={config.DiscordAuth.ClientId}&scope=identify" +
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
using Coravel.Queuing.Interfaces;
|
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
@ -18,8 +16,7 @@ public class MembersController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
MemberRendererService memberRendererService,
|
MemberRendererService memberRendererService,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
ObjectStorageService objectStorage,
|
AvatarUpdateJob avatarUpdate) : ApiControllerBase
|
||||||
IQueue queue) : ApiControllerBase
|
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
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);
|
throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.Avatar != null)
|
if (req.Avatar != null) AvatarUpdateJob.QueueUpdateMemberAvatar(member.Id, req.Avatar);
|
||||||
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
|
||||||
new AvatarUpdatePayload(member.Id, req.Avatar));
|
|
||||||
|
|
||||||
return Ok(memberRendererService.RenderMember(member, CurrentToken));
|
return Ok(memberRendererService.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
@ -101,7 +96,7 @@ public class MembersController(
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Coravel.Queuing.Interfaces;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
|
@ -15,8 +14,7 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
public class UsersController(
|
public class UsersController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
UserRendererService userRendererService,
|
UserRendererService userRendererService,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator) : ApiControllerBase
|
||||||
IQueue queue) : ApiControllerBase
|
|
||||||
{
|
{
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
|
@ -67,8 +65,7 @@ public class UsersController(
|
||||||
// (atomic operations are hard when combined with background jobs)
|
// (atomic operations are hard when combined with background jobs)
|
||||||
// so it's in a separate block to the validation above.
|
// so it's in a separate block to the validation above.
|
||||||
if (req.HasProperty(nameof(req.Avatar)))
|
if (req.HasProperty(nameof(req.Avatar)))
|
||||||
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar);
|
||||||
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +1,6 @@
|
||||||
using App.Metrics;
|
using App.Metrics;
|
||||||
using App.Metrics.AspNetCore;
|
using App.Metrics.AspNetCore;
|
||||||
using App.Metrics.Formatters.Prometheus;
|
using App.Metrics.Formatters.Prometheus;
|
||||||
using Coravel;
|
|
||||||
using Coravel.Queuing.Interfaces;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
|
@ -95,7 +93,6 @@ public static class WebApplicationExtensions
|
||||||
|
|
||||||
return builder
|
return builder
|
||||||
.SetBasePath(Directory.GetCurrentDirectory())
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
.AddJsonFile("appSettings.json", true)
|
|
||||||
.AddIniFile(file, optional: false, reloadOnChange: true)
|
.AddIniFile(file, optional: false, reloadOnChange: true)
|
||||||
.AddEnvironmentVariables();
|
.AddEnvironmentVariables();
|
||||||
}
|
}
|
||||||
|
@ -108,10 +105,8 @@ public static class WebApplicationExtensions
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<KeyCacheService>()
|
.AddScoped<KeyCacheService>()
|
||||||
.AddScoped<RemoteAuthService>()
|
.AddScoped<RemoteAuthService>()
|
||||||
.AddScoped<ObjectStorageService>()
|
// Background job classes
|
||||||
// Transient jobs
|
.AddTransient<AvatarUpdateJob>();
|
||||||
.AddTransient<MemberAvatarUpdateInvocable>()
|
|
||||||
.AddTransient<UserAvatarUpdateInvocable>();
|
|
||||||
|
|
||||||
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
||||||
.AddScoped<ErrorHandlerMiddleware>()
|
.AddScoped<ErrorHandlerMiddleware>()
|
||||||
|
@ -127,8 +122,6 @@ public static class WebApplicationExtensions
|
||||||
{
|
{
|
||||||
await BuildInfo.ReadBuildInfo();
|
await BuildInfo.ReadBuildInfo();
|
||||||
|
|
||||||
app.Services.ConfigureQueue().LogQueuedTaskProgress(app.Services.GetRequiredService<ILogger<IQueue>>());
|
|
||||||
|
|
||||||
await using var scope = app.Services.CreateAsyncScope();
|
await using var scope = app.Services.CreateAsyncScope();
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<WebApplication>();
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<WebApplication>();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||||
|
|
|
@ -9,9 +9,11 @@
|
||||||
<PackageReference Include="App.Metrics" Version="4.3.0" />
|
<PackageReference Include="App.Metrics" Version="4.3.0" />
|
||||||
<PackageReference Include="App.Metrics.AspNetCore.All" 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="App.Metrics.Prometheus" Version="4.3.0" />
|
||||||
<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="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.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" />
|
||||||
|
@ -26,6 +28,7 @@
|
||||||
<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="Sentry.AspNetCore" Version="4.9.0" />
|
<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" 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" />
|
||||||
|
|
220
Foxnouns.Backend/Jobs/AvatarUpdateJob.cs
Normal file
220
Foxnouns.Backend/Jobs/AvatarUpdateJob.cs
Normal 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";
|
||||||
|
}
|
|
@ -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";
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
using Foxnouns.Backend.Database;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
|
||||||
|
|
||||||
public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar);
|
|
|
@ -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";
|
|
||||||
}
|
|
|
@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Hangfire.Dashboard;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
|
@ -63,4 +64,33 @@ public static class HttpContextExtensions
|
||||||
}
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
[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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,18 @@
|
||||||
using Coravel;
|
|
||||||
using Foxnouns.Backend;
|
using Foxnouns.Backend;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.Redis.StackExchange;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Minio;
|
using Minio;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Serialization;
|
using Newtonsoft.Json.Serialization;
|
||||||
using Sentry.Extensibility;
|
using Sentry.Extensibility;
|
||||||
|
using Sentry.Hangfire;
|
||||||
|
|
||||||
// Read version information from .version in the repository root
|
// Read version information from .version in the repository root
|
||||||
await BuildInfo.ReadBuildInfo();
|
await BuildInfo.ReadBuildInfo();
|
||||||
|
@ -61,7 +64,6 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddQueue()
|
|
||||||
.AddDbContext<DatabaseContext>()
|
.AddDbContext<DatabaseContext>()
|
||||||
.AddCustomServices()
|
.AddCustomServices()
|
||||||
.AddCustomMiddleware()
|
.AddCustomMiddleware()
|
||||||
|
@ -72,6 +74,12 @@ builder.Services
|
||||||
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
|
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
|
||||||
.Build());
|
.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();
|
var app = builder.Build();
|
||||||
|
|
||||||
await app.Initialize(args);
|
await app.Initialize(args);
|
||||||
|
@ -87,6 +95,12 @@ app.UseCors();
|
||||||
app.UseCustomMiddleware();
|
app.UseCustomMiddleware();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
|
{
|
||||||
|
AppPath = null,
|
||||||
|
AsyncAuthorization = [new HangfireDashboardAuthorizationFilter(app.Services)]
|
||||||
|
});
|
||||||
|
|
||||||
app.Urls.Clear();
|
app.Urls.Clear();
|
||||||
app.Urls.Add(config.Address);
|
app.Urls.Add(config.Address);
|
||||||
if (config.MetricsAddress != null) app.Urls.Add(config.MetricsAddress);
|
if (config.MetricsAddress != null) app.Urls.Add(config.MetricsAddress);
|
||||||
|
|
|
@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -57,11 +57,11 @@ public static class ValidationUtils
|
||||||
|
|
||||||
public static ValidationError? ValidateMemberName(string memberName)
|
public static ValidationError? ValidateMemberName(string memberName)
|
||||||
{
|
{
|
||||||
if (!MemberRegex.IsMatch(memberName))
|
if (!UsernameRegex.IsMatch(memberName))
|
||||||
return memberName.Length switch
|
return memberName.Length switch
|
||||||
{
|
{
|
||||||
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
< 2 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
||||||
> 100 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
|
> 40 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
|
||||||
_ => ValidationError.GenericValidationError(
|
_ => ValidationError.GenericValidationError(
|
||||||
"Member name cannot contain any of the following: " +
|
"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 =
|
private static readonly string[] DefaultStatusOptions =
|
||||||
[
|
[
|
||||||
|
@ -137,7 +140,7 @@ public static class ValidationUtils
|
||||||
|
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
if (fields.Count > 25)
|
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
|
// No overwhelming this function, thank you
|
||||||
if (fields.Count > 100) return errors;
|
if (fields.Count > 100) return errors;
|
||||||
|
|
||||||
|
@ -145,13 +148,13 @@ public static class ValidationUtils
|
||||||
{
|
{
|
||||||
switch (field.Name.Length)
|
switch (field.Name.Length)
|
||||||
{
|
{
|
||||||
case > Limits.FieldNameLimit:
|
case > 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, 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, FieldNameLimit, field.Name.Length)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,26 +170,26 @@ public static class ValidationUtils
|
||||||
if (entries == null || entries.Length == 0) return [];
|
if (entries == null || entries.Length == 0) return [];
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
|
|
||||||
if (entries.Length > Limits.FieldEntriesLimit)
|
if (entries.Length > FieldEntriesLimit)
|
||||||
errors.Add(($"{errorPrefix}.entries",
|
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)));
|
entries.Length)));
|
||||||
|
|
||||||
// Same as above, no overwhelming this function with a ridiculous amount of entries
|
// 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)))
|
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
|
||||||
{
|
{
|
||||||
switch (entry.Value.Length)
|
switch (entry.Value.Length)
|
||||||
{
|
{
|
||||||
case > Limits.FieldEntryTextLimit:
|
case > FieldEntryTextLimit:
|
||||||
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
|
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)));
|
entry.Value.Length)));
|
||||||
break;
|
break;
|
||||||
case < 1:
|
case < 1:
|
||||||
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
|
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)));
|
entry.Value.Length)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"Coravel": {
|
|
||||||
"Queue": {
|
|
||||||
"ConsummationDelay": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -31,6 +31,12 @@ Timeout = 5
|
||||||
; The maximum number of open connections. Defaults to 50.
|
; The maximum number of open connections. Defaults to 50.
|
||||||
MaxPoolSize = 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]
|
[Storage]
|
||||||
Endpoint = <s3EndpointHere>
|
Endpoint = <s3EndpointHere>
|
||||||
AccessKey = <s3AccessKey>
|
AccessKey = <s3AccessKey>
|
||||||
|
|
Loading…
Reference in a new issue