From a069d0ff1553b93e565204c3842ef600da8822e1 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 14 Jul 2024 21:25:23 +0200 Subject: [PATCH] feat(backend): add more params to POST /users/@me/members --- .../Controllers/MembersController.cs | 40 ++++++++++++------- Foxnouns.Backend/Database/DatabaseContext.cs | 8 +++- .../Extensions/WebApplicationExtensions.cs | 2 +- Foxnouns.Backend/Foxnouns.Backend.csproj | 1 + Foxnouns.Backend/Jobs/AvatarUpdateJob.cs | 10 +++-- Foxnouns.Backend/Utils/ValidationUtils.cs | 36 ++++++++++++++++- 6 files changed, 74 insertions(+), 23 deletions(-) diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index c189937..3aa06a9 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -1,9 +1,11 @@ +using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Controllers; @@ -37,32 +39,40 @@ public class MembersController( [Authorize("member.create")] public async Task CreateMemberAsync([FromBody] CreateMemberRequest req) { - await using var tx = await db.Database.BeginTransactionAsync(); - - // "Translation of the 'string.Equals' overload with a 'StringComparison' parameter is not supported." - // Member names are case-insensitive, so we need to compare the lowercase forms of both. -#pragma warning disable CA1862 - 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); - } + ValidationUtils.Validate([ + ("name", ValidationUtils.ValidateMemberName(req.Name)), + ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), + ("bio", ValidationUtils.ValidateBio(req.Bio)), + ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)) + ]); var member = new Member { Id = snowflakeGenerator.GenerateSnowflake(), + User = CurrentUser!, Name = req.Name, - User = CurrentUser! + DisplayName = req.DisplayName, + Bio = req.Bio, + Unlisted = req.Unlisted ?? false }; db.Add(member); _logger.Debug("Creating member {MemberName} ({Id}) for {UserId}", member.Name, member.Id, CurrentUser!.Id); - await db.SaveChangesAsync(); - await tx.CommitAsync(); + try + { + 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)); } - public record CreateMemberRequest(string Name); + public record CreateMemberRequest(string Name, string? DisplayName, string? Bio, string? Avatar, bool? Unlisted); } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index 5c54ab5..cd4ae8b 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -1,3 +1,4 @@ +using EntityFramework.Exceptions.PostgreSQL; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Microsoft.EntityFrameworkCore; @@ -36,10 +37,13 @@ public class DatabaseContext : DbContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder - .ConfigureWarnings(c => c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) + .ConfigureWarnings(c => + c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning) + .Ignore(CoreEventId.SaveChangesFailed)) .UseNpgsql(_dataSource, o => o.UseNodaTime()) .UseSnakeCaseNamingConvention() - .UseLoggerFactory(_loggerFactory); + .UseLoggerFactory(_loggerFactory) + .UseExceptionProcessor(); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index bcd29f4..d1ee8fc 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -29,7 +29,7 @@ public static class WebApplicationExtensions // Serilog doesn't disable the built-in logs, so we do it here. .MinimumLevel.Override("Microsoft", LogEventLevel.Information) .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.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index b43e2df..359d65c 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -10,6 +10,7 @@ + diff --git a/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs b/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs index 6404591..139a384 100644 --- a/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs +++ b/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs @@ -25,9 +25,13 @@ public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config conf BackgroundJob.Enqueue(job => job.ClearUserAvatar(id)); } - public static void QueueUpdateMemberAvatar(Snowflake id, string? newAvatar) => - BackgroundJob.Enqueue(job => - newAvatar != null ? job.UpdateMemberAvatar(id, newAvatar) : job.ClearMemberAvatar(id)); + public static void QueueUpdateMemberAvatar(Snowflake id, string? newAvatar) + { + if (newAvatar != null) + BackgroundJob.Enqueue(job => job.UpdateMemberAvatar(id, newAvatar)); + else + BackgroundJob.Enqueue(job => job.ClearMemberAvatar(id)); + } public async Task UpdateUserAvatar(Snowflake id, string newAvatar) { diff --git a/Foxnouns.Backend/Utils/ValidationUtils.cs b/Foxnouns.Backend/Utils/ValidationUtils.cs index a00d4d1..64c0c53 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.cs @@ -9,6 +9,9 @@ public static class ValidationUtils { 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 = [ "..", @@ -23,7 +26,16 @@ public static class ValidationUtils "pronouns.cc", "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) { if (!UsernameRegex.IsMatch(username)) @@ -32,7 +44,8 @@ public static class ValidationUtils < 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length), > 40 => ValidationError.LengthError("Username is too long", 2, 40, username.Length), _ => 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))) @@ -40,6 +53,25 @@ public static class ValidationUtils 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) { errors = errors.Where(e => e.Item2 != null).ToList();