Infrastructure/Data/Indexes runs at every start, after the migrations: unique on Post/DmPost ObjectURI, ForeignAvatar ActorURI, the Follower triple, RootToAvatar, RootUser UserName and ReservedName; plain indexes on the lookups the services actually make (PublicKeyId, author and group post listings, ParticipantsKey, the delivery queue). Migration _001 runs first and removes the duplicates the races could already have left (keeping the newest actor row, the oldest post, the accepted follower), then fills ReservedName from every avatar and group. ReservedName is one username space for personas, groups and the instance: a name is reserved by an insert the unique index arbitrates, before the avatar or group is saved, so two simultaneous sign-ups cannot both get it. A short list of names (admin, support, abuse, postmaster, ...) is never available. EntityMaps.Warm touches every entity's collection one at a time before anything else runs. MongoDB.Entities maps the Entity base class on first touch, and two types mapped at once throw "An item with the same key has already been added" and stay broken for the life of the process; the parallel test run hit it, and the delivery worker racing a request could have too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
345 lines
13 KiB
C#
345 lines
13 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> 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<GenericRes> _localizer;
|
|
readonly ILogger<GroupUsersService> _logger;
|
|
|
|
public GroupUsersService(DbEntities dbEntities,
|
|
IPasswordHasher passwordHasher,
|
|
ILocalActorService localActors,
|
|
IDeliveryService delivery,
|
|
IStringLocalizer<GenericRes> localizer,
|
|
ILogger<GroupUsersService> logger)
|
|
{
|
|
_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 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,
|
|
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
|
|
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 take.", 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 (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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
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;
|
|
}
|
|
|
|
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 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,
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|