using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Domain.Timelines; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Moderation; using PrivaPub.Domain.Content; using PrivaPub.Domain.Statuses; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; using PrivaPub.Infrastructure.Ids; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Text.Json.Nodes; using static PrivaPub.Federation.Inbox.ForeignMembers; using static PrivaPub.Federation.Objects.ActivityJson; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Federation.Inbox.Handlers { public class CreateHandler : IActivityHandler { readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IDeliveryService _delivery; readonly IDomainBlocks _domainBlocks; readonly IFanout _fanout; readonly IRemotePosts _remotePosts; readonly IGroupDistributor _groups; readonly IObjectRecords _records; readonly IPollService _polls; readonly ILinkPreviews _previews; readonly IQuoteService _quotes; public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes) { _quotes = quotes; _previews = previews; _records = records; _polls = polls; _groups = groups; _fanout = fanout; _remotePosts = remotePosts; _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; _delivery = delivery; _domainBlocks = domainBlocks; } public string Type => "Create"; public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token) { var node = activity["object"]; var refetched = node is not JsonObject || !Origin.Same(Id(node), author.ActorURI); if (refetched) { using var fetched = await _remoteActors.FetchObject(Id(node), token); node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); } var note = NoteParser.Parse(node); if (note == default || note.AttributedTo != author.ActorURI) return; if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default) { var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token); if (question != default) { await _polls.Receive(question, author, note.Title, Id(activity), token); return; } } if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token) || await DB.Default.Find().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) return; var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList(); var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList(); var visibility = Addressing.Classify(to, cc, author.FollowersURL); var addressed = to.Concat(cc) .Concat(Addresses(activity)) .Concat(note.Mentions.Select(m => m.ActorURI)) .Append(note.Audience) .Where(a => a != default && !Addressing.IsPublic(a) && a != author.FollowersURL) .Distinct(StringComparer.Ordinal) .ToList(); var localTargets = new List(); foreach (var uri in addressed) { var local = await _localActors.FindByUri(uri, token); if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id)) localTargets.Add(local); } var persons = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList(); var parent = string.IsNullOrEmpty(note.InReplyTo) ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true }); if (circle != default) { if (!await IsCircleMember(circle, author.ActorURI, token)) return; visibility = PostVisibility.Circle; } var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted ? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false }) : default); if (group is { IsCircle: false } && !await MayPost(group, author.ActorURI, token)) group = default; var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct; var followed = visibility != PostVisibility.Direct && await _dbEntities.Followings .Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted) .ExecuteAnyAsync(token); if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed) return; if (parent == default && visibility != PostVisibility.Direct) parent = await _remotePosts.Parent(note.InReplyTo, 1, token); DmGroup conversation = default; if (visibility == PostVisibility.Direct) { var participants = persons.Select(p => p.Uri) .Concat(addressed.Where(a => !IsCollection(a))) .Append(author.ActorURI) .ToList(); conversation = await FindOrCreateConversation(participants, Origin.Same(note.Context, author.ActorURI) ? note.Context : default, token); } var post = await _remotePosts.Build(note, author, visibility, to, cc, parent, token); post.ActivityURI = Id(activity); post.GroupId = group?.Id; post.ConversationId = conversation?.ID; try { await DB.Default.SaveAsync(post, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return; } await _records.Record(note, post, ObjectPath.Delivered, refetched, token); await _previews.Wanted(post, token); await _quotes.Resolve(post, note, token); if (parent != default) await DB.Default.Update().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await _fanout.Distribute(post, token); if (conversation != default) await DB.Default.Update().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); if (group is { IsCircle: false }) await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), token); } async Task IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) => await _dbEntities.Groups.Match(g => g.ID == circle.Id && g.Members.Any(m => m.IsForeign && m.AvatarId == actorUri)).ExecuteAnyAsync(token); async Task MayPost(LocalActor community, string actorUri, CancellationToken token) { var entity = await _dbEntities.Groups.MatchID(community.Id).ExecuteFirstAsync(token); return entity?.PostingPolicy switch { PostingPolicy.Anyone => true, PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token), _ => false }; } async Task FindOrCreateConversation(List participantUris, string context, CancellationToken token) { var members = new List(); foreach (var uri in participantUris.Distinct(StringComparer.Ordinal)) { var local = await _localActors.FindByUri(uri, token); var member = local != default && local.Kind == LocalActorKind.Person ? new GroupMember { AvatarId = local.Id } : new GroupMember { AvatarId = uri, IsForeign = true }; if (members.All(m => m.AvatarId != member.AvatarId || m.IsForeign != member.IsForeign)) members.Add(member); } var key = DmGroup.KeyOf(members); var match = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token); if (match != default) return match; var conversation = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key }; await DB.Default.SaveAsync(conversation, token); return conversation; } static bool IsCollection(string uri) => uri.EndsWith("/followers", StringComparison.Ordinal) || uri.EndsWith("/following", StringComparison.Ordinal); static IEnumerable Strings(JsonNode node) => node switch { JsonArray array => array.Select(Id).Where(id => id != default), JsonNode single when Id(single) is { } id => new[] { id }, _ => Enumerable.Empty() }; } }