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;
|
||||||
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);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.SaveChangesAsync();
|
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));
|
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.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)
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue