diff --git a/ENDPOINTS.md b/ENDPOINTS.md index 4f7fcf5..41ca62a 100644 --- a/ENDPOINTS.md +++ b/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. -- \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs new file mode 100644 index 0000000..1e30549 --- /dev/null +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -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(); + + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task GetMembersAsync(string userRef) + { + var user = await db.ResolveUserAsync(userRef, CurrentToken); + return Ok(await memberRendererService.RenderUserMembersAsync(user, CurrentToken)); + } + + [HttpGet("{memberRef}")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task 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(StatusCodes.Status200OK)] + [Authorize("member.create")] + public async Task 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); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 5dded77..6f6b4e1 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -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(StatusCodes.Status200OK)] public async Task GetMeta() { var now = clock.GetCurrentInstant(); diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 28d7ea8..e1b5702 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -16,16 +16,7 @@ public class UsersController(DatabaseContext db, UserRendererService userRendere [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetUserAsync(string userRef) { - var user = await db.ResolveUserAsync(userRef); - return await GetUserInnerAsync(user); - } - - [HttpGet("@me")] - [Authorize("identify")] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task GetMeAsync() - { - var user = await db.ResolveUserAsync(CurrentUser!.Id); + var user = await db.ResolveUserAsync(userRef, CurrentToken); return await GetUserInnerAsync(user); } diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index 8262e5d..b8f10e9 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -9,8 +9,11 @@ namespace Foxnouns.Backend.Database; public static class DatabaseQueryExtensions { - public static async Task ResolveUserAsync(this DatabaseContext context, string userRef) + public static async Task 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 ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef) + public static async Task 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 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; - } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index b4a1adf..5365480 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -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 _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); diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index 4b0d4b3..4523d16 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -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) { diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs index e151777..962712f 100644 --- a/Foxnouns.Backend/Services/MemberRendererService.cs +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -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> RenderUserMembersAsync(User user, Token? token) + { + var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read"); + var canReadMemberList = !user.ListHidden || canReadHiddenMembers; + + IEnumerable 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 Names, IEnumerable Pronouns); + + public record MemberResponse( + Snowflake Id, + string Name, + string? DisplayName, + string? Bio, + string? AvatarUrl, + string[] Links, + IEnumerable Names, + IEnumerable Pronouns, + IEnumerable Fields, + UserRendererService.PartialUser User, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + bool? Unlisted); } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 2a3754a..c423e59 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -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? 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 + ); } \ No newline at end of file diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index 39d5870..badc19b 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -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;