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.Services.Federation; using PrivaPub.StaticServices; using GroupEntity = PrivaPub.Models.Group.Group; namespace PrivaPub.Services { public interface IGroupUsersService { Task GetGroup(string actor, CancellationToken token); Task GetGroups(string rootUserId, string avatarId, CancellationToken token); Task InsertGroup(string rootUserId, InsertGroupForm form, CancellationToken token); Task UpdateGroup(string rootUserId, UpdateGroupForm form, CancellationToken token); 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 GetInvitation(string invitationCode, string invitationPassword, CancellationToken token); } public class GroupUsersService : IGroupUsersService { readonly DbEntities _dbEntities; readonly IPasswordHasher _passwordHasher; readonly ILocalActorService _localActors; readonly IDeliveryService _delivery; readonly IStringLocalizer _localizer; readonly ILogger _logger; public GroupUsersService(DbEntities dbEntities, IPasswordHasher passwordHasher, ILocalActorService localActors, IDeliveryService delivery, IStringLocalizer localizer, ILogger logger) { _dbEntities = dbEntities; _passwordHasher = passwordHasher; _localActors = localActors; _delivery = delivery; _localizer = localizer; _logger = logger; } public async Task 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["Error: {0}", ex.Message], exception: ex); } } public async Task 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["Error: {0}", ex.Message], exception: ex); } } public async Task 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 take.", 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, 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 } } }; 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["Error: {0}", ex.Message], exception: ex); } } public async Task 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 (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["Error: {0}", ex.Message], exception: ex); } } public async Task 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); } result.Data = ToView(group, form.AvatarId); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(JoinGroup)}"); return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); } } public async Task 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); return result; } catch (Exception ex) { _logger.LogError(ex, $"{nameof(GroupUsersService)}.{nameof(LeaveGroup)}"); return result.Invalidate(_localizer["Error: {0}", ex.Message], exception: ex); } } public async Task 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["Error: {0}", ex.Message], exception: ex); } } public async Task 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; } async Task 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 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 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, 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 }; } } }