Compare commits
2 commits
2915893049
...
4a6b5f3b85
Author | SHA1 | Date | |
---|---|---|---|
4a6b5f3b85 | |||
0aadc5fb47 |
19 changed files with 305 additions and 309 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@ bin/
|
||||||
obj/
|
obj/
|
||||||
.version
|
.version
|
||||||
config.ini
|
config.ini
|
||||||
|
*.DotSettings.user
|
||||||
|
|
|
@ -14,7 +14,6 @@ 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();
|
||||||
|
@ -38,12 +37,6 @@ 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.ClientId != null && config.DiscordAuth.ClientSecret != null)
|
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not 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,6 +1,8 @@
|
||||||
|
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;
|
||||||
|
@ -16,7 +18,8 @@ public class MembersController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
MemberRendererService memberRendererService,
|
MemberRendererService memberRendererService,
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
AvatarUpdateJob avatarUpdate) : ApiControllerBase
|
ObjectStorageService objectStorage,
|
||||||
|
IQueue queue) : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
||||||
|
|
||||||
|
@ -76,7 +79,9 @@ 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) AvatarUpdateJob.QueueUpdateMemberAvatar(member.Id, req.Avatar);
|
if (req.Avatar != null)
|
||||||
|
queue.QueueInvocableWithPayload<MemberAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||||
|
new AvatarUpdatePayload(member.Id, req.Avatar));
|
||||||
|
|
||||||
return Ok(memberRendererService.RenderMember(member, CurrentToken));
|
return Ok(memberRendererService.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
@ -96,7 +101,7 @@ public class MembersController(
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
if (member.Avatar != null) await avatarUpdate.DeleteMemberAvatar(member.Id, member.Avatar);
|
if (member.Avatar != null) await objectStorage.DeleteMemberAvatarAsync(member.Id, member.Avatar);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
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;
|
||||||
|
@ -14,7 +15,8 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
public class UsersController(
|
public class UsersController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
UserRendererService userRendererService,
|
UserRendererService userRendererService,
|
||||||
ISnowflakeGenerator snowflakeGenerator) : ApiControllerBase
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
|
IQueue queue) : ApiControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
|
@ -65,7 +67,8 @@ 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)))
|
||||||
AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar);
|
queue.QueueInvocableWithPayload<UserAvatarUpdateInvocable, AvatarUpdatePayload>(
|
||||||
|
new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await tx.CommitAsync();
|
await tx.CommitAsync();
|
||||||
|
|
51
Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs
Normal file
51
Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
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,6 +1,8 @@
|
||||||
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;
|
||||||
|
@ -93,6 +95,7 @@ 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();
|
||||||
}
|
}
|
||||||
|
@ -105,8 +108,10 @@ public static class WebApplicationExtensions
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<KeyCacheService>()
|
.AddScoped<KeyCacheService>()
|
||||||
.AddScoped<RemoteAuthService>()
|
.AddScoped<RemoteAuthService>()
|
||||||
// Background job classes
|
.AddScoped<ObjectStorageService>()
|
||||||
.AddTransient<AvatarUpdateJob>();
|
// Transient jobs
|
||||||
|
.AddTransient<MemberAvatarUpdateInvocable>()
|
||||||
|
.AddTransient<UserAvatarUpdateInvocable>();
|
||||||
|
|
||||||
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
||||||
.AddScoped<ErrorHandlerMiddleware>()
|
.AddScoped<ErrorHandlerMiddleware>()
|
||||||
|
@ -122,6 +127,8 @@ 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,11 +9,9 @@
|
||||||
<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" />
|
||||||
|
@ -28,7 +26,6 @@
|
||||||
<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" />
|
||||||
|
|
|
@ -1,220 +0,0 @@
|
||||||
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";
|
|
||||||
}
|
|
79
Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs
Normal file
79
Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
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";
|
||||||
|
}
|
5
Foxnouns.Backend/Jobs/Payloads.cs
Normal file
5
Foxnouns.Backend/Jobs/Payloads.cs
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Jobs;
|
||||||
|
|
||||||
|
public record AvatarUpdatePayload(Snowflake Id, string? NewAvatar);
|
79
Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs
Normal file
79
Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
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,7 +2,6 @@ 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;
|
||||||
|
|
||||||
|
@ -64,33 +63,4 @@ 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,18 +1,15 @@
|
||||||
|
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();
|
||||||
|
@ -64,6 +61,7 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings
|
||||||
};
|
};
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
|
.AddQueue()
|
||||||
.AddDbContext<DatabaseContext>()
|
.AddDbContext<DatabaseContext>()
|
||||||
.AddCustomServices()
|
.AddCustomServices()
|
||||||
.AddCustomMiddleware()
|
.AddCustomMiddleware()
|
||||||
|
@ -74,12 +72,6 @@ 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);
|
||||||
|
@ -95,12 +87,6 @@ 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);
|
||||||
|
|
33
Foxnouns.Backend/Services/ObjectStorageService.cs
Normal file
33
Foxnouns.Backend/Services/ObjectStorageService.cs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
9
Foxnouns.Backend/Utils/Limits.cs
Normal file
9
Foxnouns.Backend/Utils/Limits.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
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 (!UsernameRegex.IsMatch(memberName))
|
if (!MemberRegex.IsMatch(memberName))
|
||||||
return memberName.Length switch
|
return memberName.Length switch
|
||||||
{
|
{
|
||||||
< 2 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
||||||
> 40 => ValidationError.LengthError("Name is too long", 1, 100, memberName.Length),
|
> 100 => 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,10 +119,7 @@ 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 =
|
||||||
[
|
[
|
||||||
|
@ -140,7 +137,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, FieldLimit, fields.Count)));
|
errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, Limits.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;
|
||||||
|
|
||||||
|
@ -148,13 +145,13 @@ public static class ValidationUtils
|
||||||
{
|
{
|
||||||
switch (field.Name.Length)
|
switch (field.Name.Length)
|
||||||
{
|
{
|
||||||
case > FieldNameLimit:
|
case > Limits.FieldNameLimit:
|
||||||
errors.Add(($"fields.{index}.name",
|
errors.Add(($"fields.{index}.name",
|
||||||
ValidationError.LengthError("Field name is too long", 1, 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, FieldNameLimit, field.Name.Length)));
|
ValidationError.LengthError("Field name is too short", 1, Limits.FieldNameLimit, field.Name.Length)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,26 +167,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 > FieldEntriesLimit)
|
if (entries.Length > Limits.FieldEntriesLimit)
|
||||||
errors.Add(($"{errorPrefix}.entries",
|
errors.Add(($"{errorPrefix}.entries",
|
||||||
ValidationError.LengthError("Field has too many entries", 0, FieldEntriesLimit,
|
ValidationError.LengthError("Field has too many entries", 0, Limits.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 > FieldEntriesLimit + 50) return errors;
|
if (entries.Length > Limits.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 > FieldEntryTextLimit:
|
case > Limits.FieldEntryTextLimit:
|
||||||
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
|
errors.Add(($"{errorPrefix}.entries.{entryIdx}.value",
|
||||||
ValidationError.LengthError("Field value is too long", 1, FieldEntryTextLimit,
|
ValidationError.LengthError("Field value is too long", 1, Limits.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, FieldEntryTextLimit,
|
ValidationError.LengthError("Field value is too short", 1, Limits.FieldEntryTextLimit,
|
||||||
entry.Value.Length)));
|
entry.Value.Length)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
7
Foxnouns.Backend/appSettings.json
Normal file
7
Foxnouns.Backend/appSettings.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"Coravel": {
|
||||||
|
"Queue": {
|
||||||
|
"ConsummationDelay": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,12 +31,6 @@ 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