feat(backend): add more params to POST /users/@me/members

This commit is contained in:
sam 2024-07-14 21:25:23 +02:00
parent fb34464199
commit a069d0ff15
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
6 changed files with 74 additions and 23 deletions

View file

@ -1,9 +1,11 @@
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.Jobs;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
@ -37,32 +39,40 @@ public class MembersController(
[Authorize("member.create")] [Authorize("member.create")]
public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req) public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req)
{ {
await using var tx = await db.Database.BeginTransactionAsync(); ValidationUtils.Validate([
("name", ValidationUtils.ValidateMemberName(req.Name)),
// "Translation of the 'string.Equals' overload with a 'StringComparison' parameter is not supported." ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)),
// Member names are case-insensitive, so we need to compare the lowercase forms of both. ("bio", ValidationUtils.ValidateBio(req.Bio)),
#pragma warning disable CA1862 ("avatar", ValidationUtils.ValidateAvatar(req.Avatar))
if (await db.Members.AnyAsync(m => m.UserId == CurrentUser!.Id && m.Name.ToLower() == req.Name.ToLower())) ]);
#pragma warning restore CA1862
{
throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name);
}
var member = new Member var member = new Member
{ {
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
User = CurrentUser!,
Name = req.Name, Name = req.Name,
User = CurrentUser! DisplayName = req.DisplayName,
Bio = req.Bio,
Unlisted = req.Unlisted ?? false
}; };
db.Add(member); db.Add(member);
_logger.Debug("Creating member {MemberName} ({Id}) for {UserId}", member.Name, member.Id, CurrentUser!.Id); _logger.Debug("Creating member {MemberName} ({Id}) for {UserId}", member.Name, member.Id, CurrentUser!.Id);
await db.SaveChangesAsync(); try
await tx.CommitAsync(); {
await db.SaveChangesAsync();
}
catch (UniqueConstraintException)
{
_logger.Debug("Could not create member {Id} due to name conflict", member.Id);
throw new ApiError.BadRequest("A member with that name already exists", "name", req.Name);
}
if (req.Avatar != null) AvatarUpdateJob.QueueUpdateMemberAvatar(member.Id, req.Avatar);
return Ok(memberRendererService.RenderMember(member, CurrentToken)); return Ok(memberRendererService.RenderMember(member, CurrentToken));
} }
public record CreateMemberRequest(string Name); public record CreateMemberRequest(string Name, string? DisplayName, string? Bio, string? Avatar, bool? Unlisted);
} }

View file

@ -1,3 +1,4 @@
using EntityFramework.Exceptions.PostgreSQL;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -36,10 +37,13 @@ public class DatabaseContext : DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder => optionsBuilder
.ConfigureWarnings(c => c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) .ConfigureWarnings(c =>
c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)
.Ignore(CoreEventId.SaveChangesFailed))
.UseNpgsql(_dataSource, o => o.UseNodaTime()) .UseNpgsql(_dataSource, o => o.UseNodaTime())
.UseSnakeCaseNamingConvention() .UseSnakeCaseNamingConvention()
.UseLoggerFactory(_loggerFactory); .UseLoggerFactory(_loggerFactory)
.UseExceptionProcessor();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{ {

View file

@ -29,7 +29,7 @@ public static class WebApplicationExtensions
// Serilog doesn't disable the built-in logs, so we do it here. // Serilog doesn't disable the built-in logs, so we do it here.
.MinimumLevel.Override("Microsoft", LogEventLevel.Information) .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command",
config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Warning) config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Fatal)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)

View file

@ -10,6 +10,7 @@
<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="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="Hangfire.AspNetCore" Version="1.8.14" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Hangfire.Core" Version="1.8.14" /> <PackageReference Include="Hangfire.Core" Version="1.8.14" />
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.3" /> <PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.3" />

View file

@ -25,9 +25,13 @@ public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config conf
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.ClearUserAvatar(id)); BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.ClearUserAvatar(id));
} }
public static void QueueUpdateMemberAvatar(Snowflake id, string? newAvatar) => public static void QueueUpdateMemberAvatar(Snowflake id, string? newAvatar)
BackgroundJob.Enqueue<AvatarUpdateJob>(job => {
newAvatar != null ? job.UpdateMemberAvatar(id, newAvatar) : job.ClearMemberAvatar(id)); 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) public async Task UpdateUserAvatar(Snowflake id, string newAvatar)
{ {

View file

@ -9,6 +9,9 @@ public static class ValidationUtils
{ {
private static readonly Regex UsernameRegex = new("^[\\w-.]{2,40}$", RegexOptions.IgnoreCase); private static readonly Regex UsernameRegex = new("^[\\w-.]{2,40}$", RegexOptions.IgnoreCase);
private static readonly Regex MemberRegex =
new("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$", RegexOptions.IgnoreCase);
private static readonly string[] InvalidUsernames = private static readonly string[] InvalidUsernames =
[ [
"..", "..",
@ -24,6 +27,15 @@ public static class ValidationUtils
"pronounscc" "pronounscc"
]; ];
private static readonly string[] InvalidMemberNames =
[
// these break routing outright
".",
"..",
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
"edit"
];
public static ValidationError? ValidateUsername(string username) public static ValidationError? ValidateUsername(string username)
{ {
if (!UsernameRegex.IsMatch(username)) if (!UsernameRegex.IsMatch(username))
@ -32,7 +44,8 @@ public static class ValidationUtils
< 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length), < 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length),
> 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length), > 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length),
_ => ValidationError.GenericValidationError( _ => ValidationError.GenericValidationError(
"Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods", username) "Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods",
username)
}; };
if (InvalidUsernames.Any(u => string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase))) if (InvalidUsernames.Any(u => string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase)))
@ -40,6 +53,25 @@ public static class ValidationUtils
return null; return null;
} }
public static ValidationError? ValidateMemberName(string memberName)
{
if (!UsernameRegex.IsMatch(memberName))
return memberName.Length switch
{
< 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: " +
" @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , " +
"and cannot be one or two periods",
memberName)
};
if (InvalidMemberNames.Any(u => string.Equals(u, memberName, StringComparison.InvariantCultureIgnoreCase)))
return ValidationError.GenericValidationError("Name is not allowed", memberName);
return null;
}
public static void Validate(IEnumerable<(string, ValidationError?)> errors) public static void Validate(IEnumerable<(string, ValidationError?)> errors)
{ {
errors = errors.Where(e => e.Item2 != null).ToList(); errors = errors.Where(e => e.Item2 != null).ToList();