feat(backend): add member GET endpoints, POST /users/@me/members endpoint
This commit is contained in:
parent
16f230b97d
commit
e7ec0e6661
10 changed files with 152 additions and 55 deletions
14
ENDPOINTS.md
14
ENDPOINTS.md
|
@ -3,6 +3,8 @@
|
|||
## Scopes
|
||||
|
||||
- `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.update`: can update the user's profile.
|
||||
**cannot** update anything locked behind `user.read_privileged`
|
||||
|
@ -12,15 +14,16 @@
|
|||
|
||||
## Meta
|
||||
|
||||
- [ ] GET `/meta`: gets stats and server information
|
||||
- [x] GET `/meta`: gets stats and server information
|
||||
|
||||
## Users
|
||||
|
||||
- [ ] GET `/users/{userRef}`: views current user.
|
||||
- [x] GET `/users/{userRef}`: views current user.
|
||||
`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.
|
||||
`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
|
||||
- [ ] POST `/users/@me/export`: queues new data export. `*` required
|
||||
- [ ] GET `/users/@me/export`: gets latest data export. `*` required
|
||||
|
@ -32,14 +35,13 @@
|
|||
|
||||
## 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,
|
||||
and it is not the authenticated user (or the token doesn't have the `member.read` scope)
|
||||
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.
|
||||
- [ ] POST `/users/@me/members`: creates a new member. `member.create` required
|
||||
- [ ] PATCH `/users/@me/members/{memberRef}`: edits 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.
|
||||
-
|
68
Foxnouns.Backend/Controllers/MembersController.cs
Normal file
68
Foxnouns.Backend/Controllers/MembersController.cs
Normal 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);
|
||||
}
|
|
@ -11,7 +11,7 @@ public class MetaController(DatabaseContext db, IClock clock) : ApiControllerBas
|
|||
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MetaResponse))]
|
||||
[ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMeta()
|
||||
{
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
|
|
@ -16,16 +16,7 @@ public class UsersController(DatabaseContext db, UserRendererService userRendere
|
|||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetUserAsync(string userRef)
|
||||
{
|
||||
var user = await db.ResolveUserAsync(userRef);
|
||||
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);
|
||||
var user = await db.ResolveUserAsync(userRef, CurrentToken);
|
||||
return await GetUserInnerAsync(user);
|
||||
}
|
||||
|
||||
|
|
|
@ -9,8 +9,11 @@ namespace Foxnouns.Backend.Database;
|
|||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -92,33 +95,4 @@ public static class DatabaseQueryExtensions
|
|||
await context.SaveChangesAsync();
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ using NodaTime;
|
|||
|
||||
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();
|
||||
|
||||
|
@ -140,7 +140,7 @@ public class AuthService(ILogger logger, IClock clock, DatabaseContext db, ISnow
|
|||
|
||||
private static (string, byte[]) GenerateToken()
|
||||
{
|
||||
var token = AuthUtils.RandomToken(48);
|
||||
var token = AuthUtils.RandomToken();
|
||||
var hash = SHA512.HashData(Convert.FromBase64String(token));
|
||||
|
||||
return (token, hash);
|
||||
|
|
|
@ -10,7 +10,7 @@ namespace Foxnouns.Backend.Services;
|
|||
public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
|
|
@ -1,16 +1,50 @@
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Foxnouns.Backend.Services;
|
||||
|
||||
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,
|
||||
member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns);
|
||||
|
||||
private string? AvatarUrlFor(Member member) =>
|
||||
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(
|
||||
Snowflake Id,
|
||||
string Name,
|
||||
|
@ -19,4 +53,18 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
string? AvatarUrl,
|
||||
IEnumerable<FieldEntry> Names,
|
||||
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);
|
||||
}
|
|
@ -16,6 +16,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
{
|
||||
var isSelfUser = selfUser?.Id == user.Id;
|
||||
var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser;
|
||||
var tokenHidden = token.HasScope("user.read_hidden") && isSelfUser;
|
||||
var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser;
|
||||
|
||||
renderMembers = renderMembers &&
|
||||
|
@ -45,10 +46,14 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
a.RemoteUsername, a.FediverseApplication?.Domain
|
||||
))
|
||||
: 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) =>
|
||||
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)]
|
||||
IEnumerable<AuthenticationMethodResponse>? AuthMethods,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
bool? MemberListHidden,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
Instant? LastActive
|
||||
);
|
||||
|
||||
|
@ -81,4 +88,11 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
string? FediverseInstance
|
||||
);
|
||||
|
||||
public record PartialUser(
|
||||
Snowflake Id,
|
||||
string Username,
|
||||
string? DisplayName,
|
||||
string? AvatarUrl
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ public static class AuthUtils
|
|||
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
|
||||
|
||||
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"];
|
||||
|
||||
|
@ -73,7 +73,7 @@ public static class AuthUtils
|
|||
bytes = Convert.FromBase64String(b64);
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
catch
|
||||
{
|
||||
bytes = [];
|
||||
return false;
|
||||
|
|
Loading…
Reference in a new issue