using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Domain.Statuses; using PrivaPub.Domain.Social; using PrivaPub.Domain.Timelines; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Objects; using PrivaPub.Infrastructure.Ids; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Globalization; using System.Text.Json.Nodes; using static PrivaPub.Federation.Objects.ActivityJson; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Federation.Inbox.Handlers { public class AnnounceHandler : IActivityHandler { readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IRemotePosts _remotePosts; readonly IFanout _fanout; readonly IRemoteActorService _remoteActors; readonly IObjectRecords _records; readonly IQuoteService _quotes; public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors, IObjectRecords records, IQuoteService quotes) { _records = records; _quotes = quotes; _remoteActors = remoteActors; _dbEntities = dbEntities; _localActors = localActors; _remotePosts = remotePosts; _fanout = fanout; } public string Type => "Announce"; public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { var inner = activity["object"]; if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block") { await GroupActivity(inner, actor, token); return; } var objectUri = Id(inner); var announceId = Id(activity); if (objectUri == default || announceId == default) { Arrival.Drop("unparseable"); return; } if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token)) { Arrival.Drop("duplicate"); return; } var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase); var original = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token); if (original is not { IsFederatedCopy: false } && !followed) { Arrival.Drop("not-followed"); return; } if (original == default && !isLocal) original = await _remotePosts.StoreContext(objectUri, 0, token); if (original == default) { Arrival.Drop("fetch-failed"); return; } Arrival.About(original.ObjectType ?? "Note", original.Visibility, original.CreationDate); if (original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) { Arrival.Drop("not-public"); return; } var to = Strings(activity["to"]); var cc = Strings(activity["cc"]); var published = DateTimeOffset.TryParse(Value(activity, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var at) ? at.UtcDateTime : DateTime.UtcNow; var reblog = new PostEntity { ID = PrivacyIds.Arrived(published), ObjectURI = announceId, ActivityURI = announceId, ActorURI = actor.ActorURI, AuthorAccountId = actor.ID, ReblogOfPostId = original.ID, Visibility = Addressing.Classify(to, cc, actor.FollowersURL), To = to, Cc = cc, IsFederatedCopy = true, CreationDate = published, UpdateDate = published }; if (reblog.Visibility == PostVisibility.Direct) { Arrival.Drop("not-public"); return; } try { await DB.Default.SaveAsync(reblog, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { Arrival.Drop("duplicate"); return; } Arrival.Accept("stored"); Arrival.About(local: original.IsFederatedCopy ? default : await _localActors.FindById(Models.Federation.LocalActorKind.Person, original.GroupUserId, token)); await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); if (!original.IsFederatedCopy) await Notifications.Add(original.GroupUserId, NotificationType.Reblog, actor.ID, actor.ActorURI, original.ID, token); await _fanout.Distribute(reblog, token); } async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token) { Arrival.About(Value(inner, "type")); if (group.AvatarType != AvatarType.Group || !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token)) { Arrival.Drop("not-followed"); return; } var objectUri = Id(inner["object"]); switch (Value(inner, "type")) { case "Create" when objectUri != default: if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) { Arrival.Drop("duplicate"); return; } var post = await _remotePosts.StoreContext(objectUri, 0, token); if (post == default) { Arrival.Drop("fetch-failed"); return; } Arrival.Accept("stored"); Arrival.About(visibility: post.Visibility); await DB.Default.Update().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token); post.AudienceURI = group.ActorURI; await _fanout.Distribute(post, token); break; case "Update" when objectUri != default: var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue) .ExecuteFirstAsync(token); if (stored == default) { Arrival.Drop("unknown-object"); return; } Arrival.About(visibility: stored.Visibility, created: stored.CreationDate); using (var fetched = await _remoteActors.FetchObject(objectUri, token)) { var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText())); if (note == default || note.AttributedTo != stored.ActorURI) { Arrival.Drop(note == default ? "fetch-failed" : "misattributed"); return; } Arrival.Accept(await RemoteEdits.Apply(stored, note, Id(inner), _localActors, _records, _quotes, token) ? "edit" : "refresh"); } break; case "Delete" when objectUri != default: var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token); if (deleted == default) { Arrival.Drop("unknown-object"); return; } Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate); using (var check = await _remoteActors.FetchObject(objectUri, token)) if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") { Arrival.Drop("not-deleted"); return; } Arrival.Accept("removed"); await RemoteDeletes.Remove(deleted, objectUri, token); break; default: Arrival.Drop("unsupported"); break; } } static List Strings(JsonNode node) => node switch { JsonArray array => array.Select(Id).Where(id => id != default).ToList(), JsonNode single when Id(single) is { } id => new List { id }, _ => new List() }; } }