Files
SocialPub/PrivaPub/Services/GroupUsersService.cs
T
thepraandClaude Opus 5.5 3ca29603ed The media proxy is bounded
Anyone could mint signed proxy URLs (a remote account changes its icon, an anonymous lookup returns the URL), and each
anonymous request held up to 40 MB in memory; the cache grew without bound between hourly trims; two clients asking
for the same new file downloaded it twice and wrote over each other in place, so a reader could get half a file with a
7-day cache header; a file over the limit was downloaded twice on every request; a failed fetch, a 404, was cached by
browsers for a week; cached media of a server suspended later were still served, and RejectMedia skipped avatars,
emoji, covers, video variants, link cards and remote edits; /clientapi/group/members returned remote pictures raw.

Now a download is shared by everyone asking at once, streamed into a .part file and renamed into place
(FederationHttp.DownloadMedia copies bounded, never into memory), at most eight at a time; a file too big to cache is
remembered for an hour and only streamed, a failure for five minutes; the cache's size is counted as it grows and
trimmed as soon as it passes the cap; a cached file is opened before it is answered; browsers may cache only a
success; nothing of a suspended server, or of one whose media are rejected, is proxied (everything remote a client
sees goes through the proxy, so that covers every kind), and blocking one purges its cache; the proxy has its own rate
limit per client address; group members' pictures are proxied; the proxy's key is loaded once, the oldest if two
were made. This changes what PrivaPub serves its clients, not what it sends to other servers.

Tests: clients asking at once share one download, a failure isn't cached by browsers, an over-limit file is fetched
three times for two requests instead of four, a blocked server's media are refused and its cache purged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 10:54:33 +02:00

540 lines
22 KiB
C#

using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Group;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using GroupEntity = PrivaPub.Models.Group.Group;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Outbox;
namespace PrivaPub.Services
{
public interface IGroupUsersService
{
Task<WebResult> GetGroup(string actor, CancellationToken token);
Task<WebResult> GetGroups(string rootUserId, string avatarId, CancellationToken token);
Task<WebResult> InsertGroup(string rootUserId, InsertGroupForm form, CancellationToken token);
Task<WebResult> UpdateGroup(string rootUserId, UpdateGroupForm form, CancellationToken token);
Task<WebResult> JoinGroup(string rootUserId, JoinGroupForm form, CancellationToken token);
Task<WebResult> LeaveGroup(string rootUserId, GroupMembershipForm form, CancellationToken token);
Task<WebResult> ApproveMember(string rootUserId, GroupMembershipForm form, CancellationToken token);
Task<WebResult> GetMembers(string rootUserId, string avatarId, string groupId, CancellationToken token);
Task<WebResult> RejectMember(string rootUserId, GroupMembershipForm form, CancellationToken token);
Task<WebResult> RemoveMember(string rootUserId, GroupMembershipForm form, CancellationToken token);
Task<WebResult> GetInvitation(string invitationCode, string invitationPassword, CancellationToken token);
}
public class GroupUsersService : IGroupUsersService
{
readonly DbEntities _dbEntities;
readonly IPasswordHasher _passwordHasher;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly Domain.Media.IMediaProxy _proxy;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<GroupUsersService> _logger;
public GroupUsersService(DbEntities dbEntities,
IPasswordHasher passwordHasher,
ILocalActorService localActors,
IDeliveryService delivery,
IStringLocalizer<GenericRes> localizer,
ILogger<GroupUsersService> logger,
Domain.Media.IMediaProxy proxy)
{
_proxy = proxy;
_dbEntities = dbEntities;
_passwordHasher = passwordHasher;
_localActors = localActors;
_delivery = delivery;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> GetGroup(string actor, CancellationToken token)
{
var result = new WebResult();
try
{
var userName = actor?.ToLowerInvariant();
var group = await _dbEntities.Groups
.Match(g => g.UserName == userName && !g.DeletionAt.HasValue)
.ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Group '{0}' not found.", actor], StatusCodes.Status404NotFound);
result.Data = group;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroup)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> GetGroups(string rootUserId, string avatarId, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, avatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var groups = await _dbEntities.Groups
.Match(g => !g.DeletionAt.HasValue && g.Members.Any(m => !m.IsForeign && m.AvatarId == avatarId))
.ExecuteAsync(token);
result.Data = groups.Select(g => ToView(g, avatarId)).ToList();
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(GetGroups)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> InsertGroup(string rootUserId, InsertGroupForm form, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, form.AvatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var userName = form.UserName.ToLowerInvariant();
if (await _localActors.IsUserNameTaken(userName, token))
return result.Invalidate(_localizer["The username '{0}' is already taken.", userName]);
var (privateKey, publicKey) = Keys.NewKeyPair();
var group = new GroupEntity
{
UserName = userName,
Name = form.Name,
Description = form.Description,
Domain = _localActors.BaseAddress,
PrivateKey = privateKey,
PublicKey = publicKey,
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
PostingPolicy = Policy(form.PostingPolicy) ?? PostingPolicy.Followers,
IsDiscoverable = form.IsDiscoverable,
ManuallyApprovesMembers = form.ManuallyApprovesMembers,
OwnerAvatarId = form.AvatarId,
InvitationCode = NewInvitationCode(),
HashedInvitationPassword = string.IsNullOrEmpty(form.InvitationPassword) ? default : _passwordHasher.Hash(form.InvitationPassword),
Members = new() { new GroupMember { AvatarId = form.AvatarId, Role = GroupRole.Owner } }
};
group.ID = (string)group.GenerateNewID();
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Group, group.ID, token))
return result.Invalidate(_localizer["The username '{0}' is already taken.", userName]);
var actor = _localActors.FromGroup(group);
group.Url = actor.Uri;
group.InboxURL = actor.Inbox;
group.OutboxURL = actor.Outbox;
await DB.Default.SaveAsync(group, token);
result.Data = ToView(group, form.AvatarId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(InsertGroup)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> UpdateGroup(string rootUserId, UpdateGroupForm 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);
if (form.Name != default)
group.Name = form.Name;
if (form.Description != default)
group.Description = form.Description;
if (form.IsDiscoverable.HasValue)
group.IsDiscoverable = form.IsDiscoverable.Value;
if (Policy(form.PostingPolicy) is { } policy)
group.PostingPolicy = policy;
if (form.ManuallyApprovesMembers.HasValue)
group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value;
if (form.RemoveInvitationPassword)
group.HashedInvitationPassword = default;
else if (!string.IsNullOrEmpty(form.InvitationPassword))
group.HashedInvitationPassword = _passwordHasher.Hash(form.InvitationPassword);
if (form.RegenerateInvitationCode)
group.InvitationCode = NewInvitationCode();
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
result.Data = ToView(group, form.AvatarId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(UpdateGroup)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> JoinGroup(string rootUserId, JoinGroupForm form, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, form.AvatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var group = await _dbEntities.Groups
.Match(g => g.InvitationCode == form.InvitationCode && !g.DeletionAt.HasValue)
.ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Invalid invitation."], StatusCodes.Status404NotFound);
if (!string.IsNullOrEmpty(group.HashedInvitationPassword))
{
var (verified, _) = string.IsNullOrEmpty(form.InvitationPassword)
? (false, false)
: _passwordHasher.Check(group.HashedInvitationPassword, form.InvitationPassword);
if (!verified)
return result.Invalidate(_localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
}
if (!group.Members.Any(m => !m.IsForeign && m.AvatarId == form.AvatarId))
{
group.Members.Add(new GroupMember { AvatarId = form.AvatarId });
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
}
await FollowAsMember(group, form.AvatarId, token);
result.Data = ToView(group, form.AvatarId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(JoinGroup)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
// Joining by invitation is following, as a local follow of the group would be: the member counts among its
// followers (so /groupies and /flock agree) and gets its posts in the home timeline.
async Task FollowAsMember(GroupEntity group, string avatarId, CancellationToken token)
{
var persona = await _localActors.FindById(LocalActorKind.Person, avatarId, token);
var target = _localActors.FromGroup(group);
if (persona == default)
return;
await DB.Default.Update<Follower>()
.Match(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == persona.Uri)
.Modify(f => f.InboxURL, persona.Inbox)
.Modify(f => f.SharedInboxURL, persona.SharedInbox)
.Modify(f => f.IsAccepted, true)
.Modify(b => b.SetOnInsert(f => f.CreationDate, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
await DB.Default.Update<Models.Social.Following>()
.Match(f => f.AvatarId == persona.Id && f.TargetActorURI == target.Uri)
.Modify(f => f.TargetAccountId, group.ID)
.Modify(f => f.TargetIsLocal, true)
.Modify(f => f.TargetInboxURL, target.Inbox)
.Modify(f => f.State, Models.Social.FollowState.Accepted)
.Modify(b => b.SetOnInsert(f => f.CreatedAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
}
public async Task<WebResult> LeaveGroup(string rootUserId, GroupMembershipForm form, CancellationToken token)
{
var result = new WebResult();
try
{
if (!await OwnsAvatar(rootUserId, form.AvatarId, token))
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
var group = await _dbEntities.Groups.MatchID(form.GroupId).ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound);
if (group.OwnerAvatarId == form.AvatarId)
return result.Invalidate(_localizer["The owner cannot leave the group."]);
group.Members.RemoveAll(m => !m.IsForeign && m.AvatarId == form.AvatarId);
group.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(group, token);
// leaving is unfollowing, as joining was following
var groupUri = _localActors.FromGroup(group).Uri;
var personaUri = (await _localActors.FindById(LocalActorKind.Person, form.AvatarId, token))?.Uri;
await DB.Default.DeleteAsync<Models.Social.Following>(f => f.AvatarId == form.AvatarId && f.TargetActorURI == groupUri);
if (personaUri != default)
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == personaUri);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(LeaveGroup)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> ApproveMember(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 follower = await _dbEntities.Followers
.Match(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == form.MemberActorURI)
.ExecuteFirstAsync(token);
if (follower == default)
return result.Invalidate(_localizer["Request not found."], StatusCodes.Status404NotFound);
follower.IsAccepted = true;
await DB.Default.SaveAsync(follower, token);
if (!group.Members.Any(m => m.IsForeign && m.AvatarId == follower.ActorURI))
{
group.Members.Add(new GroupMember { AvatarId = follower.ActorURI, IsForeign = true });
await DB.Default.SaveAsync(group, token);
}
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 accept = ActivityPubRenderer.Accept(actor, follow, $"accept-{follower.ID}-{DateTime.UtcNow.Ticks}");
await _delivery.Enqueue(actor, new[] { follower.InboxURL }, accept, token);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(ApproveMember)}");
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> GetInvitation(string invitationCode, string invitationPassword, CancellationToken token)
{
var result = new WebResult();
var group = string.IsNullOrEmpty(invitationCode)
? default
: await _dbEntities.Groups.Match(g => g.InvitationCode == invitationCode && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (group == default)
return result.Invalidate(_localizer["Invalid invitation."], StatusCodes.Status404NotFound);
if (!string.IsNullOrEmpty(group.HashedInvitationPassword)
&& (string.IsNullOrEmpty(invitationPassword) || !_passwordHasher.Check(group.HashedInvitationPassword, invitationPassword).verified))
return result.Invalidate(_localizer["Invalid password."], StatusCodes.Status406NotAcceptable);
result.Data = group;
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<WebResult> 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<ViewGroupMember>();
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);
}
}
// a remote member's picture through the media proxy, as everywhere else a client sees remote media
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 = _proxy.Wrap(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<WebResult> 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<Follower>(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<WebResult> 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>(follower.ID);
if (localMember is { Kind: LocalActorKind.Person })
{
var groupUri = _localActors.FromGroup(group).Uri;
await DB.Default.DeleteAsync<Models.Social.Following>(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<GroupEntity> ManagedGroup(string rootUserId, string avatarId, string groupId, CancellationToken token)
{
if (!await OwnsAvatar(rootUserId, avatarId, token))
return default;
var group = await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (group == default || group.DeletionAt.HasValue)
return default;
var role = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == avatarId)?.Role;
return role is GroupRole.Owner or GroupRole.Moderator ? group : default;
}
async Task<bool> OwnsAvatar(string rootUserId, string avatarId, CancellationToken token) =>
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch
{
"anyone" => PostingPolicy.Anyone,
"moderators" => PostingPolicy.Moderators,
"followers" => PostingPolicy.Followers,
_ => (PostingPolicy?)null
};
static string NewInvitationCode() => $"{Guid.NewGuid():N}{Guid.NewGuid():N}";
ViewGroup ToView(GroupEntity group, string avatarId)
{
var actor = _localActors.FromGroup(group);
var isManager = group.Members.Any(m => !m.IsForeign && m.AvatarId == avatarId && m.Role is GroupRole.Owner or GroupRole.Moderator);
return new ViewGroup
{
Id = group.ID,
UserName = group.UserName,
Name = group.Name,
Description = group.Description,
Url = actor.Uri,
Handle = actor.Handle,
IsCommunity = group.Kind == GroupKind.Community,
PostingPolicy = group.PostingPolicy.ToString().ToLowerInvariant(),
IsDiscoverable = group.IsDiscoverable,
ManuallyApprovesMembers = group.ManuallyApprovesMembers,
IsOwner = group.OwnerAvatarId == avatarId,
InvitationCode = isManager ? group.InvitationCode : default,
IsPasswordRequired = !string.IsNullOrEmpty(group.HashedInvitationPassword),
MembersCount = group.Members.Count,
CreationDate = group.CreationDate
};
}
}
}