diff --git a/FEDERATION.md b/FEDERATION.md index 08f5b3f..dc11325 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -111,7 +111,8 @@ A group is either a **community** or a **circle**. names that member in `cc`, so servers that keep only posts naming one of their accounts (Mastodon, GoToSocial) keep it; it names no other member. The posts are served only to a signed request from a member, or from the instance actor of a member's server, and that copy names the member (or the members on that server); anyone else gets 404. A - Mastodon member's replies reach only the people they mention. + Mastodon member's replies reach only the people they mention. Its owner lets a request in with an `Accept` of the + Follow, and declines one or takes a member from elsewhere out with a `Reject` of it, as Mastodon removes a follower. - Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post diff --git a/PrivaPub.ClientModels/Group/ViewGroupMember.cs b/PrivaPub.ClientModels/Group/ViewGroupMember.cs new file mode 100644 index 0000000..7bcddc5 --- /dev/null +++ b/PrivaPub.ClientModels/Group/ViewGroupMember.cs @@ -0,0 +1,15 @@ +namespace PrivaPub.ClientModels.Group +{ + // someone in a group, or asking to join it, as its owner and moderators see them + public class ViewGroupMember + { + public string ActorUri { get; set; } + public string Handle { get; set; } + public string Name { get; set; } + public string PictureUrl { get; set; } + public bool IsLocal { get; set; } + public bool IsPending { get; set; } + public string Role { get; set; }//owner, moderator, member + public DateTime Since { get; set; } + } +} diff --git a/PrivaPub.Tests/Http/ClientApiGroupsTests.cs b/PrivaPub.Tests/Http/ClientApiGroupsTests.cs index c46f6a3..4f65da7 100644 --- a/PrivaPub.Tests/Http/ClientApiGroupsTests.cs +++ b/PrivaPub.Tests/Http/ClientApiGroupsTests.cs @@ -202,6 +202,51 @@ namespace PrivaPub.Tests.Http Assert.Equal(circle["url"]!.GetValue(), accept["actor"]!.GetValue()); } + // its owner sees who is in the circle and who asks to join, declines one request and takes members out; nobody else + // sees them + [Fact] + public async Task An_owner_sees_the_members_and_requests_declines_one_and_removes_members() + { + var token = TestContext.Current.CancellationToken; + var (owner, circle, member) = await CircleWithRemoteMember(); + var groupId = circle["id"]!.GetValue(); + var joiner = await NewPersona("joiner"); + using (var joining = _host.As(joiner.Root.Jwt)) + Assert.True((await joining.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = circle["invitationCode"]!.GetValue() })).IsSuccessStatusCode); + var asker = new RemoteActor(_peer, "asker"); + await _host.Follow(asker, _peer.A, circle["userName"]!.GetValue()); + var since = DateTime.UtcNow.AddSeconds(-1); + using var client = _host.As(owner.Root.Jwt); + async Task> Members(HttpClient as_, string avatarId) + { + var response = await as_.GetAsync($"/clientapi/group/members?avatarId={avatarId}&groupId={groupId}", token); + return response.IsSuccessStatusCode ? (await response.JsonItems()).ToList() : null; + } + + var listed = await Members(client, owner.Id); + using var joinerClient = _host.As(joiner.Root.Jwt); + var forJoiner = await Members(joinerClient, joiner.Id); + var declined = await client.PostJson("/clientapi/group/reject", new { avatarId = owner.Id, groupId, memberActorURI = asker.Id }); + var removedRemote = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = member.Id }); + var removedLocal = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = joiner.ActorUri() }); + var removedOwner = await client.PostJson("/clientapi/group/remove", new { avatarId = owner.Id, groupId, memberActorURI = owner.ActorUri() }); + + Assert.Null(forJoiner); + Assert.Equal(4, listed.Count); + Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == owner.ActorUri() && m["role"]!.GetValue() == "owner" && m["isLocal"]!.GetValue()); + Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == joiner.ActorUri() && !m["isPending"]!.GetValue()); + Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == member.Id && !m["isPending"]!.GetValue() && !m["isLocal"]!.GetValue()); + Assert.Contains(listed, m => m!["actorUri"]!.GetValue() == asker.Id && m["isPending"]!.GetValue()); + Assert.True(declined.IsSuccessStatusCode); + Assert.True(removedRemote.IsSuccessStatusCode); + Assert.True(removedLocal.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.BadRequest, removedOwner.StatusCode); + Assert.Equal(new[] { owner.Id }, (await Stored(circle)).Members.Select(m => m.AvatarId)); + Assert.False(await DB.Default.Find().Match(f => f.LocalActorId == groupId && f.ActorURI != owner.ActorUri()).ExecuteAnyAsync(token)); + Assert.Contains(await Jobs.Deliveries(asker.Id + "/inbox", since, token), d => d["type"]!.GetValue() == "Reject"); + Assert.Contains(await Jobs.Deliveries(member.Id + "/inbox", since, token), d => d["type"]!.GetValue() == "Reject"); + } + [Fact] public async Task A_circle_is_never_found() { diff --git a/PrivaPub/Controllers/ClientToServer/GroupController.cs b/PrivaPub/Controllers/ClientToServer/GroupController.cs index 4e4ba12..7ed7b2a 100644 --- a/PrivaPub/Controllers/ClientToServer/GroupController.cs +++ b/PrivaPub/Controllers/ClientToServer/GroupController.cs @@ -48,6 +48,18 @@ namespace PrivaPub.Controllers.ClientToServer public async Task Approve(GroupMembershipForm form, CancellationToken token) => !ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.ApproveMember(User.GetUserId(), form, token)); + [HttpGet, Route("/clientapi/group/members")] + public async Task Members([FromQuery] string avatarId, [FromQuery] string groupId, CancellationToken token) => + Answer(await _groupUsersService.GetMembers(User.GetUserId(), avatarId, groupId, token)); + + [HttpPost, Route("/clientapi/group/reject")] + public async Task Reject(GroupMembershipForm form, CancellationToken token) => + !ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RejectMember(User.GetUserId(), form, token)); + + [HttpPost, Route("/clientapi/group/remove")] + public async Task Remove(GroupMembershipForm form, CancellationToken token) => + !ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RemoveMember(User.GetUserId(), form, token)); + IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."])); IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); diff --git a/PrivaPub/Services/GroupUsersService.cs b/PrivaPub/Services/GroupUsersService.cs index 6ac0c42..1d5d49c 100644 --- a/PrivaPub/Services/GroupUsersService.cs +++ b/PrivaPub/Services/GroupUsersService.cs @@ -25,6 +25,9 @@ namespace PrivaPub.Services Task JoinGroup(string rootUserId, JoinGroupForm form, CancellationToken token); Task LeaveGroup(string rootUserId, GroupMembershipForm form, CancellationToken token); Task ApproveMember(string rootUserId, GroupMembershipForm form, CancellationToken token); + Task GetMembers(string rootUserId, string avatarId, string groupId, CancellationToken token); + Task RejectMember(string rootUserId, GroupMembershipForm form, CancellationToken token); + Task RemoveMember(string rootUserId, GroupMembershipForm form, CancellationToken token); Task GetInvitation(string invitationCode, string invitationPassword, CancellationToken token); } @@ -338,6 +341,148 @@ namespace PrivaPub.Services return result; } + // the group's members and the requests to join it, for its owner and moderators only: members are never shown to + // anyone else (FEDERATION.md, followers' members are never public) + public async Task GetMembers(string rootUserId, string avatarId, string groupId, CancellationToken token) + { + var result = new WebResult(); + try + { + var group = await ManagedGroup(rootUserId, avatarId, groupId, token); + if (group == default) + return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); + + var members = new List(); + foreach (var member in group.Members) + { + if (member.IsForeign) + { + var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == member.AvatarId).ExecuteFirstAsync(token); + members.Add(Remote(member.AvatarId, foreign, member.Role, member.JoinedAt, pending: false)); + continue; + } + if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } local) + members.Add(new ViewGroupMember + { + ActorUri = local.Uri, Handle = local.Handle, Name = local.Name ?? local.UserName, PictureUrl = local.PictureURL, + IsLocal = true, Role = member.Role.ToString().ToLowerInvariant(), Since = member.JoinedAt + }); + } + var asking = await _dbEntities.Followers + .Match(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && !f.IsAccepted) + .Sort(f => f.CreationDate, Order.Ascending) + .ExecuteAsync(token); + foreach (var request in asking.Where(r => members.All(m => m.ActorUri != r.ActorURI))) + { + var foreign = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == request.ActorURI).ExecuteFirstAsync(token); + members.Add(Remote(request.ActorURI, foreign, GroupRole.Member, request.CreationDate, pending: true)); + } + result.Data = members; + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetMembers)}"); + return result.Invalidate(_localizer["Something went wrong."], exception: ex); + } + } + + static ViewGroupMember Remote(string actorUri, Models.User.ForeignAvatar foreign, GroupRole role, DateTime since, bool pending) => new() + { + ActorUri = actorUri, + Handle = foreign == default ? actorUri : $"{foreign.UserName}@{foreign.Domain}", + Name = foreign?.Name ?? foreign?.UserName, + PictureUrl = foreign?.PictureURL, + IsPending = pending, + Role = role.ToString().ToLowerInvariant(), + Since = since + }; + + // a request to join declined: the asker's server is told with a Reject of its Follow + public async Task RejectMember(string rootUserId, GroupMembershipForm form, CancellationToken token) + { + var result = new WebResult(); + try + { + var group = await ManagedGroup(rootUserId, form.AvatarId, form.GroupId, token); + if (group == default) + return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); + var request = await _dbEntities.Followers + .Match(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == form.MemberActorURI && !f.IsAccepted) + .ExecuteFirstAsync(token); + if (request == default) + return result.Invalidate(_localizer["Request not found."], StatusCodes.Status404NotFound); + await DB.Default.DeleteAsync(request.ID); + await RejectFollow(group, request, token); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(RejectMember)}"); + return result.Invalidate(_localizer["Something went wrong."], exception: ex); + } + } + + // a member taken out of the group: a persona here stops following it, someone elsewhere is told with a Reject of + // their Follow (as Mastodon removes a follower). The owner stays. + public async Task RemoveMember(string rootUserId, GroupMembershipForm form, CancellationToken token) + { + var result = new WebResult(); + try + { + var group = await ManagedGroup(rootUserId, form.AvatarId, form.GroupId, token); + if (group == default) + return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound); + var localMember = await _localActors.FindByUri(form.MemberActorURI, token); + var member = group.Members.FirstOrDefault(m => localMember is { Kind: LocalActorKind.Person } + ? !m.IsForeign && m.AvatarId == localMember.Id + : m.IsForeign && m.AvatarId == form.MemberActorURI); + if (member == default) + return result.Invalidate(_localizer["Member not found."], StatusCodes.Status404NotFound); + if (member.Role == GroupRole.Owner) + return result.Invalidate(_localizer["The owner cannot leave the group."]); + + group.Members.Remove(member); + group.UpdatedAt = DateTime.UtcNow; + await DB.Default.SaveAsync(group, token); + var follower = await _dbEntities.Followers + .Match(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == form.MemberActorURI) + .ExecuteFirstAsync(token); + if (follower != default) + await DB.Default.DeleteAsync(follower.ID); + if (localMember is { Kind: LocalActorKind.Person }) + { + var groupUri = _localActors.FromGroup(group).Uri; + await DB.Default.DeleteAsync(f => f.AvatarId == localMember.Id && f.TargetActorURI == groupUri); + } + else if (follower != default) + await RejectFollow(group, follower, token); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(RemoveMember)}"); + return result.Invalidate(_localizer["Something went wrong."], exception: ex); + } + } + + async Task RejectFollow(GroupEntity group, Follower follower, CancellationToken token) + { + if (string.IsNullOrEmpty(follower.InboxURL)) + return; + var actor = _localActors.FromGroup(group); + var follow = new System.Text.Json.Nodes.JsonObject + { + ["id"] = follower.FollowActivityURI, + ["type"] = "Follow", + ["actor"] = follower.ActorURI, + ["object"] = actor.Uri + }; + var reject = ActivityPubRenderer.Accept(actor, follow, $"reject-{follower.ID}-{DateTime.UtcNow.Ticks}"); + reject["type"] = "Reject"; + await _delivery.Enqueue(actor, new[] { follower.InboxURL }, reject, token); + } + async Task ManagedGroup(string rootUserId, string avatarId, string groupId, CancellationToken token) { if (!await OwnsAvatar(rootUserId, avatarId, token)) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c56cd4d..7e026a4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -69,6 +69,9 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati Funkwhale serve it; Funkwhale's answers named after our follow and its deletions of several uploads. - GoToSocial's interaction policies both ways; personas join and leave remote events; third-party replies to a persona's posts passed on to its followers (owner decisions 2026-10-05). + - groups run from the client (2026-10-05): `/clientapi/group/members` shows a group's members and requests to its owner + and moderators only, `reject` declines a request and `remove` takes a member out (a `Reject{Follow}` to a member + elsewhere); decePubClient's Groups page makes, joins, edits and runs them. - wave 2, under way: PieFed, Mbin, NodeBB and Lemmy 0.19 in the pasture with scenarios (2026-10-05). What they showed and was fixed: the instance actor answers at the server's root, where PieFed looks for the inbox it announces to; a community's removal of a post on its own server is believed at once; a followed group's post its own server sends without announcing