feat(backend): add member GET endpoints, POST /users/@me/members endpoint

This commit is contained in:
sam 2024-07-13 19:38:40 +02:00
parent 16f230b97d
commit e7ec0e6661
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
10 changed files with 152 additions and 55 deletions

View file

@ -3,6 +3,8 @@
## Scopes ## Scopes
- `identify`: `@me` will refer to token user (always granted) - `identify`: `@me` will refer to token user (always granted)
- `user.read_hidden`: can read non-privileged hidden information such as timezone,
whether the member list is hidden, and whether a member is unlisted.
- `user.read_privileged`: can read privileged information such as authentication methods - `user.read_privileged`: can read privileged information such as authentication methods
- `user.update`: can update the user's profile. - `user.update`: can update the user's profile.
**cannot** update anything locked behind `user.read_privileged` **cannot** update anything locked behind `user.read_privileged`
@ -12,15 +14,16 @@
## Meta ## Meta
- [ ] GET `/meta`: gets stats and server information - [x] GET `/meta`: gets stats and server information
## Users ## Users
- [ ] GET `/users/{userRef}`: views current user. - [x] GET `/users/{userRef}`: views current user.
`identify` required to use `@me` as user reference. `identify` required to use `@me` as user reference.
`user.read_hidden` required to view timezone and other hidden non-privileged data.
`user.read_privileged` required to view authentication methods. `user.read_privileged` required to view authentication methods.
`member.read` required to view unlisted members. `member.read` required to view unlisted members.
- [ ] PATCH `/users/@me`: updates current user. `user.update` required. - [x] PATCH `/users/@me`: updates current user. `user.update` required.
- [ ] DELETE `/users/@me`: deletes current user. `*` required - [ ] DELETE `/users/@me`: deletes current user. `*` required
- [ ] POST `/users/@me/export`: queues new data export. `*` required - [ ] POST `/users/@me/export`: queues new data export. `*` required
- [ ] GET `/users/@me/export`: gets latest data export. `*` required - [ ] GET `/users/@me/export`: gets latest data export. `*` required
@ -32,14 +35,13 @@
## Members ## Members
- [ ] GET `/users/{userRef}/members`: gets list of a user's members. - [x] GET `/users/{userRef}/members`: gets list of a user's members.
if the user's member list is hidden, if the user's member list is hidden,
and it is not the authenticated user (or the token doesn't have the `member.read` scope) and it is not the authenticated user (or the token doesn't have the `member.read` scope)
returns an empty array. returns an empty array.
- [ ] GET `/users/{userRef}/members/{memberRef}`: gets a single member. - [x] GET `/users/{userRef}/members/{memberRef}`: gets a single member.
will always return a member if it exists, even if the member is unlisted. will always return a member if it exists, even if the member is unlisted.
- [ ] POST `/users/@me/members`: creates a new member. `member.create` required - [ ] POST `/users/@me/members`: creates a new member. `member.create` required
- [ ] PATCH `/users/@me/members/{memberRef}`: edits a member. `member.update` required - [ ] PATCH `/users/@me/members/{memberRef}`: edits a member. `member.update` required
- [ ] DELETE `/users/@me/members/{memberRef}`: deletes a member. `member.update` required - [ ] DELETE `/users/@me/members/{memberRef}`: deletes a member. `member.update` required
- [ ] POST `/users/@me/members/{memberRef}/reroll`: rerolls a member's short ID. `member.update` required. - [ ] POST `/users/@me/members/{memberRef}/reroll`: rerolls a member's short ID. `member.update` required.
-

View file

@ -0,0 +1,68 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/users/{userRef}/members")]
public class MembersController(
ILogger logger,
DatabaseContext db,
MemberRendererService memberRendererService,
ISnowflakeGenerator snowflakeGenerator) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<MembersController>();
[HttpGet]
[ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetMembersAsync(string userRef)
{
var user = await db.ResolveUserAsync(userRef, CurrentToken);
return Ok(await memberRendererService.RenderUserMembersAsync(user, CurrentToken));
}
[HttpGet("{memberRef}")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetMemberAsync(string userRef, string memberRef)
{
var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken);
return Ok(memberRendererService.RenderMember(member, CurrentToken));
}
[HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
[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");
}
var member = new Member
{
Id = snowflakeGenerator.GenerateSnowflake(),
Name = req.Name,
User = CurrentUser!
};
db.Add(member);
_logger.Debug("Creating member {MemberName} ({Id}) for {UserId}", member.Name, member.Id, CurrentUser!.Id);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Ok(memberRendererService.RenderMember(member, CurrentToken));
}
public record CreateMemberRequest(string Name);
}

View file

@ -11,7 +11,7 @@ public class MetaController(DatabaseContext db, IClock clock) : ApiControllerBas
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
[HttpGet] [HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MetaResponse))] [ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetMeta() public async Task<IActionResult> GetMeta()
{ {
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();

View file

@ -16,16 +16,7 @@ public class UsersController(DatabaseContext db, UserRendererService userRendere
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetUserAsync(string userRef) public async Task<IActionResult> GetUserAsync(string userRef)
{ {
var user = await db.ResolveUserAsync(userRef); var user = await db.ResolveUserAsync(userRef, CurrentToken);
return await GetUserInnerAsync(user);
}
[HttpGet("@me")]
[Authorize("identify")]
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetMeAsync()
{
var user = await db.ResolveUserAsync(CurrentUser!.Id);
return await GetUserInnerAsync(user); return await GetUserInnerAsync(user);
} }

View file

@ -9,8 +9,11 @@ namespace Foxnouns.Backend.Database;
public static class DatabaseQueryExtensions public static class DatabaseQueryExtensions
{ {
public static async Task<User> ResolveUserAsync(this DatabaseContext context, string userRef) public static async Task<User> ResolveUserAsync(this DatabaseContext context, string userRef, Token? token)
{ {
if (userRef == "@me" && token != null)
return await context.Users.FirstAsync(u => u.Id == token.UserId);
User? user; User? user;
if (Snowflake.TryParse(userRef, out var snowflake)) if (Snowflake.TryParse(userRef, out var snowflake))
{ {
@ -46,9 +49,9 @@ public static class DatabaseQueryExtensions
throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef) public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef, Token? token)
{ {
var user = await context.ResolveUserAsync(userRef); var user = await context.ResolveUserAsync(userRef, token);
return await context.ResolveMemberAsync(user.Id, memberRef); return await context.ResolveMemberAsync(user.Id, memberRef);
} }
@ -92,33 +95,4 @@ public static class DatabaseQueryExtensions
await context.SaveChangesAsync(); await context.SaveChangesAsync();
return app; return app;
} }
public static Task SetKeyAsync(this DatabaseContext context, string key, string value, Duration expireAfter) =>
context.SetKeyAsync(key, value, SystemClock.Instance.GetCurrentInstant() + expireAfter);
public static async Task SetKeyAsync(this DatabaseContext context, string key, string value, Instant expires)
{
context.TemporaryKeys.Add(new TemporaryKey
{
Expires = expires,
Key = key,
Value = value,
});
await context.SaveChangesAsync();
}
public static async Task<string?> GetKeyAsync(this DatabaseContext context, string key,
bool delete = false)
{
var value = await context.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key);
if (value == null) return null;
if (delete)
{
await context.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync();
await context.SaveChangesAsync();
}
return value.Value;
}
} }

View file

@ -8,7 +8,7 @@ using NodaTime;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services;
public class AuthService(ILogger logger, IClock clock, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator) public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator)
{ {
private readonly PasswordHasher<User> _passwordHasher = new(); private readonly PasswordHasher<User> _passwordHasher = new();
@ -140,7 +140,7 @@ public class AuthService(ILogger logger, IClock clock, DatabaseContext db, ISnow
private static (string, byte[]) GenerateToken() private static (string, byte[]) GenerateToken()
{ {
var token = AuthUtils.RandomToken(48); var token = AuthUtils.RandomToken();
var hash = SHA512.HashData(Convert.FromBase64String(token)); var hash = SHA512.HashData(Convert.FromBase64String(token));
return (token, hash); return (token, hash);

View file

@ -10,7 +10,7 @@ namespace Foxnouns.Backend.Services;
public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
{ {
public Task SetKeyAsync(string key, string value, Duration expireAfter) => public Task SetKeyAsync(string key, string value, Duration expireAfter) =>
db.SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter); SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter);
public async Task SetKeyAsync(string key, string value, Instant expires) public async Task SetKeyAsync(string key, string value, Instant expires)
{ {

View file

@ -1,16 +1,50 @@
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services;
public class MemberRendererService(DatabaseContext db, Config config) public class MemberRendererService(DatabaseContext db, Config config)
{ {
public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token)
{
var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read");
var canReadMemberList = !user.ListHidden || canReadHiddenMembers;
IEnumerable<Member> members = canReadMemberList
? await db.Members
.Where(m => m.UserId == user.Id)
.OrderBy(m => m.Name)
.ToListAsync()
: [];
if (!canReadHiddenMembers) members = members.Where(m => !m.Unlisted);
return members.Select(RenderPartialMember);
}
public MemberResponse RenderMember(Member member, Token? token)
{
var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden");
return new MemberResponse(
member.Id, member.Name, member.DisplayName, member.Bio,
AvatarUrlFor(member), member.Links, member.Names, member.Pronouns, member.Fields,
RenderPartialUser(member.User), renderUnlisted ? member.Unlisted : null);
}
private UserRendererService.PartialUser RenderPartialUser(User user) =>
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user));
public PartialMember RenderPartialMember(Member member) => new(member.Id, member.Name, public PartialMember RenderPartialMember(Member member) => 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);
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;
private string? AvatarUrlFor(User user) =>
user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null;
public record PartialMember( public record PartialMember(
Snowflake Id, Snowflake Id,
string Name, string Name,
@ -19,4 +53,18 @@ public class MemberRendererService(DatabaseContext db, Config config)
string? AvatarUrl, string? AvatarUrl,
IEnumerable<FieldEntry> Names, IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns); IEnumerable<Pronoun> Pronouns);
public record MemberResponse(
Snowflake Id,
string Name,
string? DisplayName,
string? Bio,
string? AvatarUrl,
string[] Links,
IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns,
IEnumerable<Field> Fields,
UserRendererService.PartialUser User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
bool? Unlisted);
} }

View file

@ -16,6 +16,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
{ {
var isSelfUser = selfUser?.Id == user.Id; var isSelfUser = selfUser?.Id == user.Id;
var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser; var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser;
var tokenHidden = token.HasScope("user.read_hidden") && isSelfUser;
var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser; var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser;
renderMembers = renderMembers && renderMembers = renderMembers &&
@ -45,10 +46,14 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
a.RemoteUsername, a.FediverseApplication?.Domain a.RemoteUsername, a.FediverseApplication?.Domain
)) ))
: null, : null,
tokenPrivileged ? user.LastActive : null tokenHidden ? user.ListHidden : null,
tokenHidden ? user.LastActive : null
); );
} }
public PartialUser RenderPartialUser(User user) =>
new(user.Id, user.Username, user.DisplayName, AvatarUrlFor(user));
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;
@ -68,6 +73,8 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<AuthenticationMethodResponse>? AuthMethods, IEnumerable<AuthenticationMethodResponse>? AuthMethods,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
bool? MemberListHidden,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
Instant? LastActive Instant? LastActive
); );
@ -81,4 +88,11 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? FediverseInstance string? FediverseInstance
); );
public record PartialUser(
Snowflake Id,
string Username,
string? DisplayName,
string? AvatarUrl
);
} }

View file

@ -10,7 +10,7 @@ public static class AuthUtils
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"]; private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
public static readonly string[] UserScopes = public static readonly string[] UserScopes =
["user.read_privileged", "user.update"]; ["user.read_hidden", "user.read_privileged", "user.update"];
public static readonly string[] MemberScopes = ["member.read", "member.update", "member.create"]; public static readonly string[] MemberScopes = ["member.read", "member.update", "member.create"];
@ -73,7 +73,7 @@ public static class AuthUtils
bytes = Convert.FromBase64String(b64); bytes = Convert.FromBase64String(b64);
return true; return true;
} }
catch (Exception e) catch
{ {
bytes = []; bytes = [];
return false; return false;