using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; using PrivaPub.Models.Group; using PrivaPub.Models.Post; 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 DmPostEntity = PrivaPub.Models.Post.DmPost; 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; public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery) { _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; _delivery = delivery; } public string Type => "Create"; public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { var create = activity; var author = actor; var note = create["object"]; if (note is not JsonObject || !Origin.Same(Id(note), author.ActorURI)) { using var fetched = await _remoteActors.FetchObject(Id(note), token); note = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); } if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question")) return; var objectUri = Id(note); if (string.IsNullOrEmpty(objectUri) || Id(note["attributedTo"]) != author.ActorURI) return; var addressed = Addresses(note).Concat(Addresses(create)).Distinct(StringComparer.Ordinal).ToList(); var isPublic = addressed.Contains(ActivityPubRenderer.Public) || addressed.Contains("as:Public") || addressed.Contains("Public"); var inReplyTo = Id(note["inReplyTo"]); var localTargets = new List(); foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct()) { var local = await _localActors.FindByUri(uri, token); if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id)) localTargets.Add(local); } if (isPublic || addressed.Any(a => a == author.ActorURI + "/followers" || a.EndsWith("/followers"))) { var group = localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group); if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token)) group = default; if (group == default && localTargets.All(t => t.Kind != LocalActorKind.Person)) return; if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) return; var html = ContentSanitizer.Html(Value(note, "content")); var post = new PostEntity { ObjectURI = objectUri, ActorURI = author.ActorURI, GroupId = group?.Id, Title = Value(note, "summary") ?? Value(note, "name"), Text = html, ContentHtml = html, ContentFormat = ContentFormat.Html, HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue(out var s) && s, AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo, IsFederatedCopy = true, CreationDate = DateTime.TryParse(Value(note, "published"), out var published) ? published.ToUniversalTime() : DateTime.UtcNow }; await DB.Default.SaveAsync(post, token); if (group != default) { var announce = ActivityPubRenderer.Announce(group, objectUri, $"announce-{post.ID}"); await _delivery.EnqueueToFollowers(group, announce, token); } return; } var recipients = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList(); if (recipients.Count == 0) return; if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) return; var participants = recipients.Select(r => r.Uri) .Concat(addressed.Where(a => a != ActivityPubRenderer.Public && !a.EndsWith("/followers") && !a.EndsWith("/following"))) .Append(author.ActorURI) .ToList(); var context = Value(note, "context") ?? Value(note, "conversation"); var dmGroup = await FindOrCreateDmGroup(participants, Origin.Same(context, author.ActorURI) ? context : default, token); var dmHtml = ContentSanitizer.Html(Value(note, "content")); var dm = new DmPostEntity { ObjectURI = objectUri, ActorURI = author.ActorURI, GroupId = dmGroup.ID, Title = Value(note, "summary"), Text = dmHtml, ContentHtml = dmHtml, ContentFormat = ContentFormat.Html, HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue(out var ds) && ds, AnsweringToPostId = inReplyTo, IsFederatedCopy = true, CreationDate = DateTime.TryParse(Value(note, "published"), out var dmPublished) ? dmPublished.ToUniversalTime() : DateTime.UtcNow }; await DB.Default.SaveAsync(dm, token); await DB.Default.Update().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); return; } async Task FindOrCreateDmGroup(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 dmGroup = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key }; await DB.Default.SaveAsync(dmGroup, token); return dmGroup; } async Task LocalPostId(string uri, CancellationToken token) { if (string.IsNullOrEmpty(uri) || !uri.StartsWith(_localActors.BaseAddress + "/peasants/", StringComparison.OrdinalIgnoreCase)) return default; var id = uri[(uri.LastIndexOf('/') + 1)..]; return await _dbEntities.Posts.MatchID(id).ExecuteAnyAsync(token) ? id : default; } } }