feat(backend): return unlisted status in partial member for authenticated users

This commit is contained in:
sam 2024-09-25 19:48:05 +02:00
parent bb649d1d72
commit f81ae97821
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
15 changed files with 68 additions and 53 deletions

View file

@ -60,7 +60,8 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
{ {
if (endpoint.RoutePattern.RawText == null) continue; if (endpoint.RoutePattern.RawText == null) continue;
var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText), new RouteValueDictionary()); var templateMatcher = new TemplateMatcher(TemplateParser.Parse(endpoint.RoutePattern.RawText),
new RouteValueDictionary());
if (!templateMatcher.TryMatch(url, new())) continue; if (!templateMatcher.TryMatch(url, new())) continue;
var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>(); var httpMethodAttribute = endpoint.Metadata.GetMetadata<HttpMethodAttribute>();
if (httpMethodAttribute != null && if (httpMethodAttribute != null &&

View file

@ -42,7 +42,8 @@ public class MembersController(
[HttpPost("/api/v2/users/@me/members")] [HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)] [ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
[Authorize("member.create")] [Authorize("member.create")]
public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req, CancellationToken ct = default) public async Task<IActionResult> CreateMemberAsync([FromBody] CreateMemberRequest req,
CancellationToken ct = default)
{ {
ValidationUtils.Validate([ ValidationUtils.Validate([
("name", ValidationUtils.ValidateMemberName(req.Name)), ("name", ValidationUtils.ValidateMemberName(req.Name)),

View file

@ -104,7 +104,8 @@ public class UsersController(
[HttpPatch("@me/custom-preferences")] [HttpPatch("@me/custom-preferences")]
[Authorize("user.update")] [Authorize("user.update")]
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)] [ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req, CancellationToken ct = default) public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> req,
CancellationToken ct = default)
{ {
ValidationUtils.Validate(ValidateCustomPreferences(req)); ValidationUtils.Validate(ValidateCustomPreferences(req));
@ -194,7 +195,8 @@ public class UsersController(
[HttpPatch("@me/settings")] [HttpPatch("@me/settings")]
[Authorize("user.read_hidden", "user.update")] [Authorize("user.read_hidden", "user.update")]
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateUserSettingsAsync([FromBody] UpdateUserSettingsRequest req, CancellationToken ct = default) public async Task<IActionResult> UpdateUserSettingsAsync([FromBody] UpdateUserSettingsRequest req,
CancellationToken ct = default)
{ {
var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);

View file

@ -14,11 +14,13 @@ public static class AvatarObjectExtensions
private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"];
public static async Task public static async Task
DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) => DeleteMemberAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash,
CancellationToken ct = default) =>
await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct); await objectStorageService.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task public static async Task
DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash, CancellationToken ct = default) => DeleteUserAvatarAsync(this ObjectStorageService objectStorageService, Snowflake id, string hash,
CancellationToken ct = default) =>
await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); await objectStorageService.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct);
public static async Task<Stream> ConvertBase64UriToAvatar(this string uri) public static async Task<Stream> ConvertBase64UriToAvatar(this string uri)

View file

@ -8,34 +8,34 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Coravel" Version="5.0.4" /> <PackageReference Include="Coravel" Version="5.0.4"/>
<PackageReference Include="Coravel.Mailer" Version="5.0.1" /> <PackageReference Include="Coravel.Mailer" Version="5.0.1"/>
<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="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0" /> <PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Minio" Version="6.0.3" /> <PackageReference Include="Minio" Version="6.0.3"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NodaTime" Version="3.1.11" /> <PackageReference Include="NodaTime" Version="3.1.11"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3" /> <PackageReference Include="Npgsql.Json.NET" Version="8.0.3"/>
<PackageReference Include="prometheus-net" Version="8.2.1" /> <PackageReference Include="prometheus-net" Version="8.2.1"/>
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0" /> <PackageReference Include="Sentry.AspNetCore" Version="4.9.0"/>
<PackageReference Include="Serilog" Version="4.0.1" /> <PackageReference Include="Serilog" Version="4.0.1"/>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.5"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" /> <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
</ItemGroup> </ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation"> <Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
@ -44,12 +44,12 @@
</Target> </Target>
<ItemGroup> <ItemGroup>
<EmbeddedResource Watch="false" Include="..\.version" LogicalName="version" /> <EmbeddedResource Watch="false" Include="..\.version" LogicalName="version"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\.dockerignore"> <Content Include="..\.dockerignore">
<Link>.dockerignore</Link> <Link>.dockerignore</Link>
</Content> </Content>
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -2,7 +2,8 @@ using Coravel.Mailer.Mail;
namespace Foxnouns.Backend.Mailables; namespace Foxnouns.Backend.Mailables;
public class AccountCreationMailable(Config config, AccountCreationMailableView view) : Mailable<AccountCreationMailableView> public class AccountCreationMailable(Config config, AccountCreationMailableView view)
: Mailable<AccountCreationMailableView>
{ {
public override void Build() public override void Build()
{ {

View file

@ -11,6 +11,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token) public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token)
{ {
var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read"); var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read");
var renderUnlisted = token != null && token.UserId == user.Id && token.HasScope("user.read_hidden");
var canReadMemberList = !user.ListHidden || canReadHiddenMembers; var canReadMemberList = !user.ListHidden || canReadHiddenMembers;
IEnumerable<Member> members = canReadMemberList IEnumerable<Member> members = canReadMemberList
@ -20,7 +21,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
.ToListAsync() .ToListAsync()
: []; : [];
if (!canReadHiddenMembers) members = members.Where(m => !m.Unlisted); if (!canReadHiddenMembers) members = members.Where(m => !m.Unlisted);
return members.Select(RenderPartialMember); return members.Select(m => RenderPartialMember(m, renderUnlisted));
} }
public MemberResponse RenderMember(Member member, Token? token) public MemberResponse RenderMember(Member member, Token? token)
@ -34,10 +35,11 @@ public class MemberRendererService(DatabaseContext db, Config config)
} }
private UserRendererService.PartialUser RenderPartialUser(User user) => private UserRendererService.PartialUser RenderPartialUser(User user) =>
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user)); new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences);
public PartialMember RenderPartialMember(Member member) => new(member.Id, member.Name, public PartialMember RenderPartialMember(Member member, bool renderUnlisted = false) => new(member.Id, member.Name,
member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns); member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns,
renderUnlisted ? member.Unlisted : null);
private string? AvatarUrlFor(Member member) => private string? AvatarUrlFor(Member member) =>
member.Avatar != null ? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp" : null; member.Avatar != null ? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp" : null;
@ -52,7 +54,9 @@ public class MemberRendererService(DatabaseContext db, Config config)
string? Bio, string? Bio,
string? AvatarUrl, string? AvatarUrl,
IEnumerable<FieldEntry> Names, IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns); IEnumerable<Pronoun> Pronouns,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
bool? Unlisted);
public record MemberResponse( public record MemberResponse(
Snowflake Id, Snowflake Id,

View file

@ -39,7 +39,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
return new UserResponse( return new UserResponse(
user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links, user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links,
user.Names, user.Pronouns, user.Fields, user.CustomPreferences, user.Names, user.Pronouns, user.Fields, user.CustomPreferences,
renderMembers ? members.Select(memberRenderer.RenderPartialMember) : null, renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null,
renderAuthMethods renderAuthMethods
? authMethods.Select(a => new AuthenticationMethodResponse( ? authMethods.Select(a => new AuthenticationMethodResponse(
a.Id, a.AuthType, a.RemoteId, a.Id, a.AuthType, a.RemoteId,
@ -52,7 +52,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
} }
public PartialUser RenderPartialUser(User user) => public PartialUser RenderPartialUser(User user) =>
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user)); new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences);
private string? AvatarUrlFor(User user) => private string? AvatarUrlFor(User user) =>
user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null;
@ -94,6 +94,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
Snowflake Id, Snowflake Id,
string Username, string Username,
string? DisplayName, string? DisplayName,
string? AvatarUrl string? AvatarUrl,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences
); );
} }

View file

@ -156,7 +156,8 @@ public static class ValidationUtils
break; break;
} }
errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}.entries")).ToList(); errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}.entries"))
.ToList();
} }
return errors; return errors;
@ -238,12 +239,14 @@ public static class ValidationUtils
{ {
case > Limits.FieldEntryTextLimit: case > Limits.FieldEntryTextLimit:
errors.Add(($"{errorPrefix}.{entryIdx}.value", errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Pronoun display text is too long", 1, Limits.FieldEntryTextLimit, ValidationError.LengthError("Pronoun display text is too long", 1,
Limits.FieldEntryTextLimit,
entry.Value.Length))); entry.Value.Length)));
break; break;
case < 1: case < 1:
errors.Add(($"{errorPrefix}.{entryIdx}.value", errors.Add(($"{errorPrefix}.{entryIdx}.value",
ValidationError.LengthError("Pronoun display text is too short", 1, Limits.FieldEntryTextLimit, ValidationError.LengthError("Pronoun display text is too short", 1,
Limits.FieldEntryTextLimit,
entry.Value.Length))); entry.Value.Length)));
break; break;
} }

View file