diff --git a/PrivaPub.Tests/Statistics/HandlerVerdictTests.cs b/PrivaPub.Tests/Statistics/HandlerVerdictTests.cs new file mode 100644 index 0000000..9366dfe --- /dev/null +++ b/PrivaPub.Tests/Statistics/HandlerVerdictTests.cs @@ -0,0 +1,211 @@ +using MongoDB.Entities; + +using PrivaPub.Domain.Statuses; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Group; +using PrivaPub.Models.Post; +using PrivaPub.Models.Statistics; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +using GroupEntity = PrivaPub.Models.Group.Group; + +namespace PrivaPub.Tests.Statistics +{ + public class ArrivalVerdictTests + { + [Fact] + public void The_first_decision_wins_and_a_circle_has_no_kind() + { + var verdict = new ArrivalVerdict(); + + verdict.Drop("not-addressed"); + verdict.Accept("stored"); + verdict.About("Note", PostVisibility.Circle, DateTime.UtcNow.AddMinutes(-2), new LocalActor { Kind = LocalActorKind.Group, IsCircle = true }); + + Assert.Equal(("dropped", "not-addressed"), (verdict.Outcome, verdict.Reason)); + Assert.Equal("private", verdict.Audience); + Assert.Null(verdict.LocalKind); + Assert.InRange(verdict.AgeSeconds!.Value, 110, 130); + } + + [Theory] + [InlineData(PostVisibility.Public, "public")] + [InlineData(PostVisibility.Unlisted, "unlisted")] + [InlineData(PostVisibility.FollowersOnly, "private")] + [InlineData(PostVisibility.Direct, "private")] + [InlineData(PostVisibility.Circle, "private")] + [InlineData(PostVisibility.LocalGeo, "private")] + public void Every_visibility_but_public_and_unlisted_is_private(PostVisibility visibility, string audience) => + Assert.Equal(audience, ArrivalVerdict.AudienceOf(visibility)); + + [Fact] + public void Outside_an_arrival_nothing_happens() + { + Arrival.Current = default; + Arrival.Drop("x"); + Arrival.About("Note"); + } + } + + [Trait("Category", "Integration")] + public sealed class HandlerVerdictTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + InteractionEvent Processed(string activityType) => + _harness.Ledger.Of("in").Last(e => e.Activity == activityType); + + JsonObject Create(RemoteActor author, IEnumerable to, JsonObject extra = default) + { + var origin = new Uri(author.Id).GetLeftPart(UriPartial.Authority); + var note = new JsonObject + { + ["id"] = $"{origin}/notes/{Guid.NewGuid():N}", + ["type"] = "Note", + ["attributedTo"] = author.Id, + ["content"] = "

hello

", + ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) + }; + foreach (var (key, value) in extra ?? new JsonObject()) + note[key] = value?.DeepClone(); + return new JsonObject { ["id"] = $"{origin}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note }; + } + + [Fact] + public async Task A_direct_message_is_stored_private_and_without_a_local_kind() + { + var (_, alice) = await _harness.Persona("alice"); + var bob = new RemoteActor(_harness.Peer, "bob"); + + await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { alice.Uri })); + + var e = Processed("Create"); + Assert.Equal(("accepted", "stored", "private"), (e.Outcome, e.Reason, e.Audience)); + Assert.Equal("Note", e.Object); + Assert.Null(e.LocalKind); + Assert.Equal("127.0.0.1", e.Host); + Assert.NotNull(e.Attempt); + Assert.NotNull(e.WaitMs); + Assert.Equal(bob.Id, _harness.Ledger.Events.Last(x => x.Event == e).ActorUri); + } + + [Fact] + public async Task A_public_reply_to_a_persona_names_its_kind_and_its_features() + { + var (_, alice) = await _harness.Persona("alice"); + var bob = new RemoteActor(_harness.Peer, "bob"); + var parent = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "first" }, TestContext.Current.CancellationToken); + + await _harness.Deliver(bob, "/human-centipede", Create(bob, new[] { Addressing.Public, alice.Uri }, + new JsonObject { ["inReplyTo"] = parent.Post.ObjectURI, ["interactionPolicy"] = new JsonObject(), ["quoteUrl"] = "https://elsewhere.example/1" })); + + var e = Processed("Create"); + Assert.Equal(("accepted", "public", "person"), (e.Outcome, e.Audience, e.LocalKind)); + Assert.Contains("interaction-policy", e.Features); + Assert.Contains("legacy-quote", e.Features); + } + + [Fact] + public async Task Unwanted_things_are_dropped_with_a_reason() + { + var stranger = new RemoteActor(_harness.Peer, "stranger"); + + await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { Addressing.Public })); + var unaddressed = Processed("Create"); + await _harness.Deliver(stranger, "/human-centipede", new JsonObject + { + ["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/likes/" + Guid.NewGuid().ToString("N"), + ["type"] = "Like", + ["actor"] = stranger.Id, + ["object"] = $"{Harness.Base}/peasants/x/scribbles/{Guid.NewGuid():N}" + }); + var like = Processed("Like"); + await _harness.Deliver(stranger, "/human-centipede", new JsonObject + { + ["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/x/" + Guid.NewGuid().ToString("N"), + ["type"] = "Bite", + ["actor"] = stranger.Id, + ["object"] = stranger.Id + }); + var bite = Processed("Bite"); + await _harness.Deliver(stranger, "/human-centipede", new JsonObject + { + ["id"] = new Uri(stranger.Id).GetLeftPart(UriPartial.Authority) + "/undo/" + Guid.NewGuid().ToString("N"), + ["type"] = "Undo", + ["actor"] = stranger.Id, + ["object"] = new JsonObject { ["id"] = stranger.Id + "/likes/never", ["type"] = "Like", ["actor"] = stranger.Id, ["object"] = "https://nowhere.example/1" } + }); + var undo = Processed("Undo"); + + Assert.Equal(("dropped", "not-addressed", "public"), (unaddressed.Outcome, unaddressed.Reason, unaddressed.Audience)); + Assert.Equal(("dropped", "unknown-object"), (like.Outcome, like.Reason)); + Assert.Equal(("dropped", "unknown-type"), (bite.Outcome, bite.Reason)); + Assert.Equal(("dropped", "unknown-object", "Like"), (undo.Outcome, undo.Reason, undo.Object)); + } + + [Fact] + public async Task A_stranger_posting_into_a_circle_never_reveals_the_circle() + { + var (_, owner) = await _harness.Persona("owner"); + var (privateKey, publicKey) = Keys.NewKeyPair(); + var entity = new GroupEntity + { + UserName = $"circle{Guid.NewGuid():N}"[..20], + Kind = GroupKind.Circle, + PostingPolicy = PostingPolicy.Followers, + PrivateKey = privateKey, + PublicKey = publicKey, + Members = new() { new GroupMember { AvatarId = owner.Id, Role = GroupRole.Owner } } + }; + await DB.Default.SaveAsync(entity); + var circle = _harness.Local.FromGroup(entity); + var outsider = new RemoteActor(_harness.Peer, "outsider"); + + await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock })); + + var e = Processed("Create"); + Assert.Equal(("dropped", "not-addressed", "private"), (e.Outcome, e.Reason, e.Audience)); + Assert.Null(e.LocalKind); + Assert.DoesNotContain(_harness.Ledger.Events, x => System.Text.Json.JsonSerializer.Serialize(x.Event).Contains("circle", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task A_follow_is_accepted_and_its_undo_is_recorded() + { + var (_, alice) = await _harness.Persona("alice"); + var bob = new RemoteActor(_harness.Peer, "bob"); + var follow = new JsonObject { ["id"] = bob.Id + "/follows/" + Guid.NewGuid().ToString("N"), ["type"] = "Follow", ["actor"] = bob.Id, ["object"] = alice.Uri }; + + await _harness.Deliver(bob, "/human-centipede", follow); + var followed = Processed("Follow"); + await _harness.Deliver(bob, "/human-centipede", new JsonObject + { + ["id"] = bob.Id + "/undo/" + Guid.NewGuid().ToString("N"), + ["type"] = "Undo", + ["actor"] = bob.Id, + ["object"] = follow.DeepClone() + }); + var undone = Processed("Undo"); + + Assert.Equal(("accepted", "auto-accepted", "none"), (followed.Outcome, followed.Reason, followed.Audience)); + Assert.Equal(("accepted", "undone", "Follow"), (undone.Outcome, undone.Reason, undone.Object)); + } + } +} diff --git a/PrivaPub.Tests/Statistics/InteractionsTests.cs b/PrivaPub.Tests/Statistics/InteractionsTests.cs index e0f685b..877d69b 100644 --- a/PrivaPub.Tests/Statistics/InteractionsTests.cs +++ b/PrivaPub.Tests/Statistics/InteractionsTests.cs @@ -33,7 +33,7 @@ namespace PrivaPub.Tests.Statistics Audience = "everyone", Inbox = "front-door", Signature = "cavage:rsa-sha256", - Features = new() { "fep-044f", "Bad Feature", "fep-044f" } + Features = new() { "fep-044f", "Bad Feature", "fep-044f", "source:text/markdown" } }); Assert.Equal("mastodon.social", e.Host); @@ -45,7 +45,7 @@ namespace PrivaPub.Tests.Statistics Assert.Null(e.Audience); Assert.Null(e.Inbox); Assert.Equal("cavage:rsa-sha256", e.Signature); - Assert.Equal(new[] { "fep-044f", "other" }, e.Features); + Assert.Equal(new[] { "fep-044f", "other", "source:text/markdown" }, e.Features); Assert.Equal("-", Interactions.Host("evil host/with path")); Assert.Equal("-", Interactions.Host(default)); Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x")); diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index faca9f5..13ad357 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -69,7 +69,7 @@ namespace PrivaPub.Tests.Support new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), new FlagHandler(Db, Local) }; - Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance); + Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance, Ledger); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); Content = new ContentRenderer(Local, Remote); Media = new MediaService(new StaticOptions(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), diff --git a/PrivaPub/Domain/Social/Reactions.cs b/PrivaPub/Domain/Social/Reactions.cs index 3439902..109adb8 100644 --- a/PrivaPub/Domain/Social/Reactions.cs +++ b/PrivaPub/Domain/Social/Reactions.cs @@ -2,6 +2,7 @@ using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; @@ -43,7 +44,10 @@ namespace PrivaPub.Domain.Social public async Task Receive(PostEntity post, ForeignAvatar actor, string content, JsonNode tags, string activityId, CancellationToken token) { if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji) + { + Arrival.Drop(LikeHandler.MaySee(post, actor) ? "unparseable" : "not-visible"); return; + } try { await DB.Default.SaveAsync(new Reaction @@ -53,8 +57,10 @@ namespace PrivaPub.Domain.Social } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { + Arrival.Drop("duplicate"); return; } + Arrival.Accept("reaction"); if (!post.IsFederatedCopy) await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url); } diff --git a/PrivaPub/Domain/Statuses/QuoteService.cs b/PrivaPub/Domain/Statuses/QuoteService.cs index 9d03f1f..62a33c9 100644 --- a/PrivaPub/Domain/Statuses/QuoteService.cs +++ b/PrivaPub/Domain/Statuses/QuoteService.cs @@ -107,11 +107,18 @@ namespace PrivaPub.Domain.Statuses var quotingUri = Id(instrument); if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI) || instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI) + { + Arrival.Drop("misattributed"); return; + } var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token); if (author == default || string.IsNullOrEmpty(actor.InboxURL)) + { + Arrival.Drop("unknown-object"); return; + } + Arrival.About("Note", quoted.Visibility, quoted.CreationDate); var answer = new JsonObject { ["@context"] = ActivityPubRenderer.Context(), @@ -121,12 +128,14 @@ namespace PrivaPub.Domain.Statuses }; if (await MayQuote(quoted, actor.ActorURI, token)) { + Arrival.Accept("quote-granted"); answer["type"] = "Accept"; answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token); answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}"); } else { + Arrival.Reject("quote-refused"); answer["type"] = "Reject"; answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}"); } diff --git a/PrivaPub/Federation/Inbox/Arrival.cs b/PrivaPub/Federation/Inbox/Arrival.cs index a18d815..2771a65 100644 --- a/PrivaPub/Federation/Inbox/Arrival.cs +++ b/PrivaPub/Federation/Inbox/Arrival.cs @@ -1,3 +1,8 @@ +using PrivaPub.Federation.Actors; +using PrivaPub.Infrastructure.Statistics; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Post; + namespace PrivaPub.Federation.Inbox { public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm, @@ -10,5 +15,64 @@ namespace PrivaPub.Federation.Inbox get => current.Value; set => current.Value = value; } + + public ArrivalVerdict Verdict { get; } = new(); + + public static void Drop(string reason) => Current?.Verdict.Drop(reason); + + public static void Reject(string reason) => Current?.Verdict.Reject(reason); + + public static void Accept(string reason) => Current?.Verdict.Accept(reason); + + public static void About(string objectType = default, PostVisibility? visibility = default, DateTime? created = default, LocalActor local = default) => + Current?.Verdict.About(objectType, visibility, created, local); + } + + public sealed class ArrivalVerdict + { + public string Outcome { get; private set; } + public string Reason { get; private set; } + public string ObjectType { get; private set; } + public string Audience { get; private set; } + public string LocalKind { get; private set; } + public long? AgeSeconds { get; private set; } + + public void Drop(string reason) => Decide(Interactions.Dropped, reason); + + public void Reject(string reason) => Decide(Interactions.Rejected, reason); + + public void Accept(string reason) => Decide(Interactions.Accepted, reason); + + void Decide(string outcome, string reason) + { + if (Outcome != default) + return; + Outcome = outcome; + Reason = reason; + } + + public void About(string objectType, PostVisibility? visibility, DateTime? created, LocalActor local) + { + ObjectType = objectType ?? ObjectType; + if (visibility is { } known) + Audience = AudienceOf(known); + if (created is { } at) + AgeSeconds = Math.Max(0, (long)(DateTime.UtcNow - at).TotalSeconds); + if (local != default) + LocalKind = local.Kind switch + { + LocalActorKind.Person => "person", + LocalActorKind.Group when !local.IsCircle => "group", + LocalActorKind.Application => "application", + _ => default + }; + } + + public static string AudienceOf(PostVisibility visibility) => visibility switch + { + PostVisibility.Public => Interactions.Public, + PostVisibility.Unlisted => Interactions.Unlisted, + _ => Interactions.Private + }; } } diff --git a/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs index 6f71b86..5109df9 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AcceptHandler.cs @@ -30,10 +30,18 @@ namespace PrivaPub.Federation.Inbox.Handlers public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token)) + { + Arrival.Accept("quote-answer"); return; + } var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); if (following == default) + { + Arrival.Drop("unknown-object"); return; + } + Arrival.About("Follow"); + Arrival.Accept("follow-answer"); await Apply(following, token); } diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index a420110..5aa881e 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -50,19 +50,37 @@ namespace PrivaPub.Federation.Inbox.Handlers 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 || original.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted)) + 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"]); @@ -85,15 +103,21 @@ namespace PrivaPub.Federation.Inbox.Handlers 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) @@ -103,18 +127,30 @@ namespace PrivaPub.Federation.Inbox.Handlers 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); @@ -123,12 +159,20 @@ namespace PrivaPub.Federation.Inbox.Handlers 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("edit"); stored.Revisions.Add(new PostRevision { Title = stored.Title, @@ -151,13 +195,24 @@ namespace PrivaPub.Federation.Inbox.Handlers 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 DB.Default.DeleteAsync(deleted.ID); await DB.Default.DeleteAsync(e => e.PostId == deleted.ID); break; + default: + Arrival.Drop("unsupported"); + break; } } diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index 1ee978c..cb6b3c4 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -68,26 +68,42 @@ namespace PrivaPub.Federation.Inbox.Handlers { using var fetched = await _remoteActors.FetchObject(Id(node), token); node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); + if (node == default) + Arrival.Drop("fetch-failed"); } var note = NoteParser.Parse(node); if (note == default || note.AttributedTo != author.ActorURI) + { + Arrival.Drop(note == default ? "unparseable" : "misattributed"); return; + } + Arrival.About(note.Type); 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); + Arrival.Accept("poll-vote"); + Arrival.About(visibility: question.Visibility); 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)) + if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)) + { + Arrival.Drop("duplicate"); return; + } + if (await DB.Default.Find().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) + { + Arrival.Drop("deleted"); + 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); + Arrival.About(visibility: visibility); var addressed = to.Concat(cc) .Concat(Addresses(activity)) @@ -111,9 +127,13 @@ namespace PrivaPub.Federation.Inbox.Handlers 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; + Arrival.About(visibility: visibility); + if (!await IsCircleMember(circle, author.ActorURI, token)) + { + Arrival.Drop("not-addressed"); + return; + } } var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted ? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false }) @@ -126,7 +146,10 @@ namespace PrivaPub.Federation.Inbox.Handlers .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) + { + Arrival.Drop("not-addressed"); return; + } if (parent == default && visibility != PostVisibility.Direct) parent = await _remotePosts.Parent(note.InReplyTo, 1, token); @@ -150,8 +173,11 @@ namespace PrivaPub.Federation.Inbox.Handlers } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { + Arrival.Drop("duplicate"); return; } + Arrival.Accept("stored"); + Arrival.About(local: group ?? persons.FirstOrDefault()); await _records.Record(note, post, ObjectPath.Delivered, refetched, token); await _previews.Wanted(post, token); await _quotes.Resolve(post, note, token); diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs index b2f2d98..929c427 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs @@ -49,10 +49,15 @@ namespace PrivaPub.Federation.Inbox.Handlers var objectUri = Id(delete["object"]); if (!Origin.Same(objectUri, actor.ActorURI)) + { + Arrival.Drop("cross-origin"); return; + } if (objectUri == actor.ActorURI) { + Arrival.Accept("actor-delete"); + Arrival.About(actor.AvatarType.ToString(), created: actor.Published); actor.DeletionAt = DateTime.UtcNow; actor.AccountState = AvatarAccountState.Deleted; await DB.Default.SaveAsync(actor, token); @@ -76,9 +81,12 @@ namespace PrivaPub.Federation.Inbox.Handlers var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); if (post == default) { + Arrival.Accept("tombstone-only"); await _quotes.Revoke(objectUri, token); return; } + Arrival.Accept("removed"); + Arrival.About(post.ObjectType, post.Visibility, post.CreationDate); await DB.Default.DeleteAsync(post.ID); await DB.Default.DeleteAsync(r => r.PostId == post.ID || r.ObjectURI == objectUri); await DB.Default.DeleteAsync(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); diff --git a/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs index 1d29290..dcb269b 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DislikeHandler.cs @@ -31,7 +31,11 @@ namespace PrivaPub.Federation.Inbox.Handlers ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); if (post == default || !LikeHandler.MaySee(post, actor)) + { + Arrival.Drop(post == default ? "unknown-object" : "not-visible"); return; + } + Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate); try { @@ -39,8 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { + Arrival.Drop("duplicate"); return; } + Arrival.Accept("stored"); await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); } } diff --git a/PrivaPub/Federation/Inbox/Handlers/EmojiReactHandler.cs b/PrivaPub/Federation/Inbox/Handlers/EmojiReactHandler.cs index 42e4f8b..852ed09 100644 --- a/PrivaPub/Federation/Inbox/Handlers/EmojiReactHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/EmojiReactHandler.cs @@ -27,8 +27,13 @@ namespace PrivaPub.Federation.Inbox.Handlers var post = objectUri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); - if (post != default) - await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token); + if (post == default) + { + Arrival.Drop("unknown-object"); + return; + } + Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate); + await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token); } } } diff --git a/PrivaPub/Federation/Inbox/Handlers/FlagHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FlagHandler.cs index acf35cf..831ee46 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FlagHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FlagHandler.cs @@ -50,7 +50,11 @@ namespace PrivaPub.Federation.Inbox.Handlers if (target == default && posts.Count > 0) target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token); if (target == default) + { + Arrival.Drop("unknown-object"); return; + } + Arrival.Accept("reported"); await DB.Default.SaveAsync(new Report { diff --git a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs index 4eec123..6135582 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs @@ -45,7 +45,10 @@ namespace PrivaPub.Federation.Inbox.Handlers var target = await _localActors.FindByUri(Id(follow["object"]), token); if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) + { + Arrival.Drop("unknown-recipient"); return; + } if (target.Kind == LocalActorKind.Person && await DB.Default.Find().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token)) { @@ -58,6 +61,7 @@ namespace PrivaPub.Federation.Inbox.Handlers ["object"] = follow.DeepClone() }; await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token); + Arrival.Reject("blocked"); return; } @@ -75,6 +79,7 @@ namespace PrivaPub.Federation.Inbox.Handlers record.FollowActivityURI = Id(follow); record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers; await DB.Default.SaveAsync(record, token); + Arrival.Accept(record.IsAccepted ? "auto-accepted" : "pending"); if (target.Kind == LocalActorKind.Person) await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest, diff --git a/PrivaPub/Federation/Inbox/Handlers/JoinHandler.cs b/PrivaPub/Federation/Inbox/Handlers/JoinHandler.cs index 0ae0fd7..9414284 100644 --- a/PrivaPub/Federation/Inbox/Handlers/JoinHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/JoinHandler.cs @@ -30,11 +30,19 @@ namespace PrivaPub.Federation.Inbox.Handlers { var objectUri = Id(activity["object"]); if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) + { + Arrival.Drop("unknown-object"); return; + } var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token); if (owner == default || string.IsNullOrEmpty(actor.InboxURL)) + { + Arrival.Drop("unknown-object"); return; + } + Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate); + Arrival.Reject("ignored"); await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, diff --git a/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs index dccc912..08171af 100644 --- a/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/LikeHandler.cs @@ -35,7 +35,11 @@ namespace PrivaPub.Federation.Inbox.Handlers ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); if (post == default || !MaySee(post, actor)) + { + Arrival.Drop(post == default ? "unknown-object" : "not-visible"); return; + } + Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate); var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content"); if (!Reactions.IsHeart(content)) { @@ -49,8 +53,10 @@ namespace PrivaPub.Federation.Inbox.Handlers } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { + Arrival.Drop("duplicate"); return; } + Arrival.Accept("stored"); await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); if (!post.IsFederatedCopy) await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token); diff --git a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs index 7de10a3..1362b60 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UndoHandler.cs @@ -37,20 +37,26 @@ namespace PrivaPub.Federation.Inbox.Handlers var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerId = Id(inner); + Arrival.About(innerType); + var undone = false; if (innerType is null or "Follow") - await UndoFollow(inner, innerId, actor, token); + undone |= await UndoFollow(inner, innerId, actor, token); if (innerType is null or "Like") - await UndoLike(inner, innerId, actor, token); + undone |= await UndoLike(inner, innerId, actor, token); if (innerType is null or "Announce") - await UndoAnnounce(innerId, actor, token); + undone |= await UndoAnnounce(innerId, actor, token); if (innerType is null or "Dislike") - await UndoDislike(inner, innerId, actor, token); + undone |= await UndoDislike(inner, innerId, actor, token); if (innerType is null or "Like" or "EmojiReact") - await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default, + undone |= await _reactions.Withdraw(actor, innerId, inner is JsonObject ? Id(inner["object"]) : default, inner is JsonObject ? Value(inner, "_misskey_reaction") ?? Value(inner, "content") : default, token); + if (undone) + Arrival.Accept("undone"); + else + Arrival.Drop("unknown-object"); } - async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) + async Task UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) { var downvote = innerId == default ? default @@ -63,16 +69,18 @@ namespace PrivaPub.Federation.Inbox.Handlers : await DB.Default.Find().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); } if (downvote == default) - return; + return false; await DB.Default.DeleteAsync(downvote.ID); await DB.Default.Update().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token); + return true; } - async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) + async Task UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) { var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token); var targetUri = inner is JsonObject ? Id(inner["object"]) : default; var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token); + var undone = false; foreach (var follower in followers) { var matchesActivity = innerId != default && follower.FollowActivityURI == innerId; @@ -83,10 +91,12 @@ namespace PrivaPub.Federation.Inbox.Handlers await DB.Default.DeleteAsync(follower.ID); if (follower.LocalActorKind == LocalActorKind.Group) await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token); + undone = true; } + return undone; } - async Task UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) + async Task UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) { var favourite = innerId == default ? default @@ -99,23 +109,25 @@ namespace PrivaPub.Federation.Inbox.Handlers : await _dbEntities.Favourites.Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); } if (favourite == default) - return; + return false; await DB.Default.DeleteAsync(favourite.ID); await DB.Default.Update().MatchID(favourite.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); + return true; } - async Task UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token) + async Task UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token) { if (innerId == default) - return; + return false; var reblog = await _dbEntities.Posts .Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null) .ExecuteFirstAsync(token); if (reblog == default) - return; + return false; await DB.Default.DeleteAsync(reblog.ID); await DB.Default.DeleteAsync(e => e.PostId == reblog.ID); await DB.Default.Update().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); + return true; } } } diff --git a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs index a248392..b4f1131 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs @@ -46,17 +46,29 @@ namespace PrivaPub.Federation.Inbox.Handlers if (Id(inner) == actor.ActorURI) { await _remoteActors.GetActor(actor.ActorURI, refresh: true, token); + Arrival.Accept("actor-refresh"); + Arrival.About(actor.AvatarType.ToString()); return; } if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI)) + { + Arrival.Drop("cross-origin"); return; + } var note = NoteParser.Parse(inner); if (note == default || note.AttributedTo != actor.ActorURI) + { + Arrival.Drop(note == default ? "unparseable" : "misattributed"); return; + } var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); if (post == default || post.DeletedAt.HasValue) + { + Arrival.Drop("unknown-object"); return; + } + Arrival.About(note.Type, post.Visibility, post.CreationDate); post.Poll = note.Poll ?? post.Poll; post.QuotePolicy = note.QuotePolicy; @@ -65,12 +77,14 @@ namespace PrivaPub.Federation.Inbox.Handlers post.Event = note.Event ?? post.Event; if (!IsEdit(note, post)) { + Arrival.Accept("refresh"); await DB.Default.SaveAsync(post, token); await _records.Revise(note, Id(activity), token); await Requote(post, note, token); return; } + Arrival.Accept("edit"); post.Revisions.Add(new PostRevision { Title = post.Title, diff --git a/PrivaPub/Federation/Inbox/InboxProcessor.cs b/PrivaPub/Federation/Inbox/InboxProcessor.cs index 267c046..280ff7d 100644 --- a/PrivaPub/Federation/Inbox/InboxProcessor.cs +++ b/PrivaPub/Federation/Inbox/InboxProcessor.cs @@ -1,8 +1,12 @@ using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Infrastructure.Statistics; using PrivaPub.Models.Jobs; +using PrivaPub.Models.Statistics; using PrivaPub.Models.User; +using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; @@ -21,12 +25,15 @@ namespace PrivaPub.Federation.Inbox readonly IRemoteActorService _remoteActors; readonly IReadOnlyDictionary _handlers; readonly ILogger _logger; + readonly IInteractionLedger _ledger; - public InboxProcessor(IRemoteActorService remoteActors, IEnumerable handlers, ILogger logger) + public InboxProcessor(IRemoteActorService remoteActors, IEnumerable handlers, ILogger logger, + IInteractionLedger ledger = default) { _remoteActors = remoteActors; _handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal); _logger = logger; + _ledger = ledger; } public JobKind Kind => JobKind.ProcessInbox; @@ -36,28 +43,86 @@ namespace PrivaPub.Federation.Inbox public async Task Handle(Job job, CancellationToken token) { + var started = Stopwatch.GetTimestamp(); var payload = JsonSerializer.Deserialize(job.Payload); var activity = payload == default ? default : JsonNode.Parse(payload.Activity); var type = Value(activity, "type"); if (type == default || !_handlers.TryGetValue(type, out var handler)) + { + Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Dropped, "unknown-type"); return JobOutcome.Done; + } var actor = await _remoteActors.GetActor(payload.ActorURI, refresh: false, token); if (actor == default) + { + Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable"); return JobOutcome.Retry("the actor could not be loaded"); + } - Arrival.Current = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm, + var arrival = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm, payload.SignedHeaders ?? Array.Empty(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString()); + Arrival.Current = arrival; try { await handler.Handle(activity, actor, token); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Record(job, payload, activity, type, started, arrival.Verdict, Interactions.Failed, ex.GetType().Name.ToLowerInvariant()); + throw; + } finally { Arrival.Current = default; } + Record(job, payload, activity, type, started, arrival.Verdict, default, default); _logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI); return JobOutcome.Done; } + + void Record(Job job, InboxPayload payload, JsonNode activity, string type, long started, ArrivalVerdict verdict, string outcome, string reason) + { + if (_ledger == default || payload == default) + return; + var embedded = activity?["object"] as JsonObject; + _ledger.Record(new InteractionEvent + { + Channel = Interactions.In, + Host = Interactions.HostOf(payload.ActorURI), + Activity = type, + Object = verdict.ObjectType ?? Value(embedded, "type"), + Outcome = outcome ?? verdict.Outcome ?? Interactions.Accepted, + Reason = reason ?? verdict.Reason, + LatencyMs = (int)Stopwatch.GetElapsedTime(started).TotalMilliseconds, + WaitMs = (int)Math.Min(int.MaxValue, Math.Max(0, (DateTime.UtcNow - (payload.ReceivedAt ?? job.CreatedAt)).TotalMilliseconds)), + Attempt = job.Attempts, + Audience = verdict.Audience ?? Audience(activity), + LocalKind = verdict.LocalKind, + AgeSeconds = verdict.AgeSeconds, + Inbox = payload.Inbox, + Signature = payload.Algorithm == default ? default : "cavage:" + payload.Algorithm, + Features = type is "Create" or "Update" && embedded != default ? ObjectFeatures.Detect(embedded) : default + }, payload.ActorURI); + } + + static string Audience(JsonNode activity) + { + var source = activity?["to"] != default || activity?["cc"] != default ? activity : activity?["object"] as JsonObject; + var to = Addresses(source?["to"]); + var cc = Addresses(source?["cc"]); + if (to.Any(Addressing.IsPublic)) + return Interactions.Public; + if (cc.Any(Addressing.IsPublic)) + return Interactions.Unlisted; + return to.Count + cc.Count == 0 ? Interactions.None : Interactions.Private; + } + + static List Addresses(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() + }; } } diff --git a/PrivaPub/Infrastructure/Statistics/Interactions.cs b/PrivaPub/Infrastructure/Statistics/Interactions.cs index 15fc0cd..534025b 100644 --- a/PrivaPub/Infrastructure/Statistics/Interactions.cs +++ b/PrivaPub/Infrastructure/Statistics/Interactions.cs @@ -53,7 +53,7 @@ namespace PrivaPub.Infrastructure.Statistics e.LocalKind = e.Audience is Public or Unlisted && e.LocalKind != default && LocalKinds.Contains(e.LocalKind) ? e.LocalKind : default; e.Inbox = e.Inbox != default && Inboxes.Contains(e.Inbox) ? e.Inbox : default; e.Signature = e.Signature == default ? default : SignatureName().IsMatch(e.Signature) ? e.Signature : Other; - e.Features = e.Features?.Select(Word).Where(f => f != default).Distinct().Take(32).ToList(); + e.Features = e.Features?.Select(Feature).Where(f => f != default).Distinct().Take(32).ToList(); if (e.Features?.Count == 0) e.Features = default; return e; @@ -86,6 +86,9 @@ namespace PrivaPub.Infrastructure.Statistics static string Word(string value) => value == default ? default : KebabWord().IsMatch(value) ? value : Other; + static string Feature(string value) => + value == default ? default : FeatureName().IsMatch(value) ? value : Other; + [GeneratedRegex("^[A-Za-z][A-Za-z0-9_-]{0,31}$")] private static partial Regex TypeWord(); @@ -95,6 +98,9 @@ namespace PrivaPub.Infrastructure.Statistics [GeneratedRegex("^[a-z0-9][a-z0-9:-]{0,39}$")] private static partial Regex SignatureName(); + [GeneratedRegex("^[a-z0-9][a-z0-9:/+.-]{0,63}$")] + private static partial Regex FeatureName(); + [GeneratedRegex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")] private static partial Regex HostName(); }