feat(backend): add more params to POST /users/@me/members
This commit is contained in:
parent
fb34464199
commit
a069d0ff15
6 changed files with 74 additions and 23 deletions
|
@ -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);
|
||||
|
||||
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);
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue