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.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<IActionResult> 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);
try
{
await db.SaveChangesAsync();
await tx.CommitAsync();
}
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);
}

View file

@ -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)
{

View file

@ -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)

View file

@ -10,6 +10,7 @@
<PackageReference Include="App.Metrics.AspNetCore.All" Version="4.3.0" />
<PackageReference Include="App.Metrics.Prometheus" Version="4.3.0" />
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Hangfire.Core" Version="1.8.14" />
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.3" />

View file

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

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 MemberRegex =
new("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$", RegexOptions.IgnoreCase);
private static readonly string[] InvalidUsernames =
[
"..",
@ -24,6 +27,15 @@ public static class ValidationUtils
"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();