M3: what each handler did with an activity

Arrival carries a verdict that handlers set with one line before an existing return
(Arrival.Drop, Reject, Accept, About), so no handler signature changes. InboxProcessor
records it as an 'in' event, with the time taken, the wait since the inbox accepted it, the
attempt, the audience (from the post's visibility, or else the activity's addressing), the
local actor kind, the object's age for updates, deletes and reactions, and the FEP features
of a delivered object. It also records types nothing handles (unknown-type), actors that
cannot be loaded (deferred) and handler failures.

Drop reasons: fetch-failed, unparseable, misattributed, duplicate, deleted, not-addressed,
not-followed, not-public, not-visible, not-deleted, unknown-object, unknown-recipient,
cross-origin, unsupported. Accepted sub-reasons: stored, poll-vote, edit, refresh,
actor-refresh, actor-delete, removed, tombstone-only, auto-accepted, pending,
follow-answer, quote-answer, quote-granted, undone, reaction, reported. Rejected: blocked,
ignored, quote-refused.

A circle's traffic is private and kindless, and a stranger posting into one is just
not-addressed, so the event store cannot reveal a circle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:04:42 +02:00
1 parent 4fa53f63bd
commit ac7cbd136b
20 files changed
+543 -25

No files matched your search

@@ -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<string> 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"] = "<p>hello</p>",
["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));
}
}
}
@@ -33,7 +33,7 @@ namespace PrivaPub.Tests.Statistics
Audience = "everyone", Audience = "everyone",
Inbox = "front-door", Inbox = "front-door",
Signature = "cavage:rsa-sha256", 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); Assert.Equal("mastodon.social", e.Host);
@@ -45,7 +45,7 @@ namespace PrivaPub.Tests.Statistics
Assert.Null(e.Audience); Assert.Null(e.Audience);
Assert.Null(e.Inbox); Assert.Null(e.Inbox);
Assert.Equal("cavage:rsa-sha256", e.Signature); 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("evil host/with path"));
Assert.Equal("-", Interactions.Host(default)); Assert.Equal("-", Interactions.Host(default));
Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x")); Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x"));
+1 -1
View File
@@ -69,7 +69,7 @@ namespace PrivaPub.Tests.Support
new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes),
new FlagHandler(Db, Local) new FlagHandler(Db, Local)
}; };
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance); Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Content = new ContentRenderer(Local, Remote); Content = new ContentRenderer(Local, Remote);
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
+6
View File
@@ -2,6 +2,7 @@ using MongoDB.Driver;
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox; 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) 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) if (!LikeHandler.MaySee(post, actor) || Normalise(content, ObjectShapes.Emojis(tags)) is not { } emoji)
{
Arrival.Drop(LikeHandler.MaySee(post, actor) ? "unparseable" : "not-visible");
return; return;
}
try try
{ {
await DB.Default.SaveAsync(new Reaction await DB.Default.SaveAsync(new Reaction
@@ -53,8 +57,10 @@ namespace PrivaPub.Domain.Social
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("reaction");
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url); await Notifications.Add(post.GroupUserId, NotificationType.Reaction, actor.ID, actor.ActorURI, post.ID, token, emoji.Name, emoji.Url);
} }
+9
View File
@@ -107,11 +107,18 @@ namespace PrivaPub.Domain.Statuses
var quotingUri = Id(instrument); var quotingUri = Id(instrument);
if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI) if (objectUri == default || quotingUri == default || !Origin.Same(quotingUri, actor.ActorURI)
|| instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI) || instrument is JsonObject embedded && Id(embedded["attributedTo"]) is { } by && by != actor.ActorURI)
{
Arrival.Drop("misattributed");
return; return;
}
var quoted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); 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); var author = quoted == default ? default : await _localActors.FindById(LocalActorKind.Person, quoted.GroupUserId, token);
if (author == default || string.IsNullOrEmpty(actor.InboxURL)) if (author == default || string.IsNullOrEmpty(actor.InboxURL))
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About("Note", quoted.Visibility, quoted.CreationDate);
var answer = new JsonObject var answer = new JsonObject
{ {
["@context"] = ActivityPubRenderer.Context(), ["@context"] = ActivityPubRenderer.Context(),
@@ -121,12 +128,14 @@ namespace PrivaPub.Domain.Statuses
}; };
if (await MayQuote(quoted, actor.ActorURI, token)) if (await MayQuote(quoted, actor.ActorURI, token))
{ {
Arrival.Accept("quote-granted");
answer["type"] = "Accept"; answer["type"] = "Accept";
answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token); answer["result"] = await Grant(author, quoted, quotingUri, actor.ActorURI, token);
answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}"); answer["id"] = author.ActivityUri($"accept-quote-{Guid.NewGuid():N}");
} }
else else
{ {
Arrival.Reject("quote-refused");
answer["type"] = "Reject"; answer["type"] = "Reject";
answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}"); answer["id"] = author.ActivityUri($"reject-quote-{Guid.NewGuid():N}");
} }
+64
View File
@@ -1,3 +1,8 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
namespace PrivaPub.Federation.Inbox namespace PrivaPub.Federation.Inbox
{ {
public sealed record Arrival(string ActivityId, string ActivityType, string ActorURI, string Inbox, string KeyId, string Algorithm, 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; get => current.Value;
set => current.Value = 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
};
} }
} }
@@ -30,10 +30,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
{ {
if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token)) if (await _quotes.Answered(activity, actor, accepted: Type == "Accept", token))
{
Arrival.Accept("quote-answer");
return; return;
}
var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token); var following = await FindFollowing(activity["object"], actor, _dbEntities, _localActors, token);
if (following == default) if (following == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About("Follow");
Arrival.Accept("follow-answer");
await Apply(following, token); await Apply(following, token);
} }
@@ -50,19 +50,37 @@ namespace PrivaPub.Federation.Inbox.Handlers
var objectUri = Id(inner); var objectUri = Id(inner);
var announceId = Id(activity); var announceId = Id(activity);
if (objectUri == default || announceId == default) if (objectUri == default || announceId == default)
{
Arrival.Drop("unparseable");
return; return;
}
if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteAnyAsync(token))
{
Arrival.Drop("duplicate");
return; return;
}
var isLocal = objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase); 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 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); var followed = await _dbEntities.Followings.Match(f => f.TargetActorURI == actor.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (original is not { IsFederatedCopy: false } && !followed) if (original is not { IsFederatedCopy: false } && !followed)
{
Arrival.Drop("not-followed");
return; return;
}
if (original == default && !isLocal) if (original == default && !isLocal)
original = await _remotePosts.StoreContext(objectUri, 0, token); 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; 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 to = Strings(activity["to"]);
var cc = Strings(activity["cc"]); var cc = Strings(activity["cc"]);
@@ -85,15 +103,21 @@ namespace PrivaPub.Federation.Inbox.Handlers
UpdateDate = published UpdateDate = published
}; };
if (reblog.Visibility == PostVisibility.Direct) if (reblog.Visibility == PostVisibility.Direct)
{
Arrival.Drop("not-public");
return; return;
}
try try
{ {
await DB.Default.SaveAsync(reblog, token); await DB.Default.SaveAsync(reblog, token);
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
Arrival.About(local: original.IsFederatedCopy ? default : await _localActors.FindById(Models.Federation.LocalActorKind.Person, original.GroupUserId, token));
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
if (!original.IsFederatedCopy) if (!original.IsFederatedCopy)
@@ -103,18 +127,30 @@ namespace PrivaPub.Federation.Inbox.Handlers
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token) async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{ {
Arrival.About(Value(inner, "type"));
if (group.AvatarType != AvatarType.Group if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token)) || !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
{
Arrival.Drop("not-followed");
return; return;
}
var objectUri = Id(inner["object"]); var objectUri = Id(inner["object"]);
switch (Value(inner, "type")) switch (Value(inner, "type"))
{ {
case "Create" when objectUri != default: case "Create" when objectUri != default:
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
{
Arrival.Drop("duplicate");
return; return;
}
var post = await _remotePosts.StoreContext(objectUri, 0, token); var post = await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default) if (post == default)
{
Arrival.Drop("fetch-failed");
return; return;
}
Arrival.Accept("stored");
Arrival.About(visibility: post.Visibility);
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token);
post.AudienceURI = group.ActorURI; post.AudienceURI = group.ActorURI;
await _fanout.Distribute(post, token); 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) var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (stored == default) if (stored == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(visibility: stored.Visibility, created: stored.CreationDate);
using (var fetched = await _remoteActors.FetchObject(objectUri, token)) using (var fetched = await _remoteActors.FetchObject(objectUri, token))
{ {
var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText())); var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()));
if (note == default || note.AttributedTo != stored.ActorURI) if (note == default || note.AttributedTo != stored.ActorURI)
{
Arrival.Drop(note == default ? "fetch-failed" : "misattributed");
return; return;
}
Arrival.Accept("edit");
stored.Revisions.Add(new PostRevision stored.Revisions.Add(new PostRevision
{ {
Title = stored.Title, Title = stored.Title,
@@ -151,13 +195,24 @@ namespace PrivaPub.Federation.Inbox.Handlers
case "Delete" when objectUri != default: case "Delete" when objectUri != default:
var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token); var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token);
if (deleted == default) if (deleted == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(visibility: deleted.Visibility, created: deleted.CreationDate);
using (var check = await _remoteActors.FetchObject(objectUri, token)) using (var check = await _remoteActors.FetchObject(objectUri, token))
if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone")
{
Arrival.Drop("not-deleted");
return; return;
}
Arrival.Accept("removed");
await DB.Default.DeleteAsync<PostEntity>(deleted.ID); await DB.Default.DeleteAsync<PostEntity>(deleted.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID);
break; break;
default:
Arrival.Drop("unsupported");
break;
} }
} }
@@ -68,26 +68,42 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
using var fetched = await _remoteActors.FetchObject(Id(node), token); using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText()); node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
if (node == default)
Arrival.Drop("fetch-failed");
} }
var note = NoteParser.Parse(node); var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI) if (note == default || note.AttributedTo != author.ActorURI)
{
Arrival.Drop(note == default ? "unparseable" : "misattributed");
return; return;
}
Arrival.About(note.Type);
if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default) 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); var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token);
if (question != default) if (question != default)
{ {
await _polls.Receive(question, author, note.Title, Id(activity), token); await _polls.Receive(question, author, note.Title, Id(activity), token);
Arrival.Accept("poll-vote");
Arrival.About(visibility: question.Visibility);
return; return;
} }
} }
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token) if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token)) {
Arrival.Drop("duplicate");
return; return;
}
if (await DB.Default.Find<DeletedObject>().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 to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
var cc = note.Cc.Concat(Strings(activity["cc"])).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 visibility = Addressing.Classify(to, cc, author.FollowersURL);
Arrival.About(visibility: visibility);
var addressed = to.Concat(cc) var addressed = to.Concat(cc)
.Concat(Addresses(activity)) .Concat(Addresses(activity))
@@ -111,9 +127,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true }); var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
if (circle != default) if (circle != default)
{ {
if (!await IsCircleMember(circle, author.ActorURI, token))
return;
visibility = PostVisibility.Circle; 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 var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false }) ? 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) .Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token); .ExecuteAnyAsync(token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed) if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed)
{
Arrival.Drop("not-addressed");
return; return;
}
if (parent == default && visibility != PostVisibility.Direct) if (parent == default && visibility != PostVisibility.Direct)
parent = await _remotePosts.Parent(note.InReplyTo, 1, token); 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) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
Arrival.About(local: group ?? persons.FirstOrDefault());
await _records.Record(note, post, ObjectPath.Delivered, refetched, token); await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
await _previews.Wanted(post, token); await _previews.Wanted(post, token);
await _quotes.Resolve(post, note, token); await _quotes.Resolve(post, note, token);
@@ -49,10 +49,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
var objectUri = Id(delete["object"]); var objectUri = Id(delete["object"]);
if (!Origin.Same(objectUri, actor.ActorURI)) if (!Origin.Same(objectUri, actor.ActorURI))
{
Arrival.Drop("cross-origin");
return; return;
}
if (objectUri == actor.ActorURI) if (objectUri == actor.ActorURI)
{ {
Arrival.Accept("actor-delete");
Arrival.About(actor.AvatarType.ToString(), created: actor.Published);
actor.DeletionAt = DateTime.UtcNow; actor.DeletionAt = DateTime.UtcNow;
actor.AccountState = AvatarAccountState.Deleted; actor.AccountState = AvatarAccountState.Deleted;
await DB.Default.SaveAsync(actor, token); 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); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default) if (post == default)
{ {
Arrival.Accept("tombstone-only");
await _quotes.Revoke(objectUri, token); await _quotes.Revoke(objectUri, token);
return; return;
} }
Arrival.Accept("removed");
Arrival.About(post.ObjectType, post.Visibility, post.CreationDate);
await DB.Default.DeleteAsync<PostEntity>(post.ID); await DB.Default.DeleteAsync<PostEntity>(post.ID);
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri); await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
@@ -31,7 +31,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !LikeHandler.MaySee(post, actor)) if (post == default || !LikeHandler.MaySee(post, actor))
{
Arrival.Drop(post == default ? "unknown-object" : "not-visible");
return; return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
try try
{ {
@@ -39,8 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.DownvotesCount, 1)).ExecuteAsync(token);
} }
} }
@@ -27,7 +27,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
var post = objectUri == default var post = objectUri == default
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post != default) 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); await _reactions.Receive(post, actor, Value(activity, "content"), activity["tag"], Id(activity), token);
} }
} }
@@ -50,7 +50,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (target == default && posts.Count > 0) if (target == default && posts.Count > 0)
target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token); target = await _localActors.FindById(LocalActorKind.Person, posts[0].GroupUserId, token);
if (target == default) if (target == default)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.Accept("reported");
await DB.Default.SaveAsync(new Report await DB.Default.SaveAsync(new Report
{ {
@@ -45,7 +45,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
var target = await _localActors.FindByUri(Id(follow["object"]), token); var target = await _localActors.FindByUri(Id(follow["object"]), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application)
{
Arrival.Drop("unknown-recipient");
return; return;
}
if (target.Kind == LocalActorKind.Person if (target.Kind == LocalActorKind.Person
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token)) && await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
{ {
@@ -58,6 +61,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
["object"] = follow.DeepClone() ["object"] = follow.DeepClone()
}; };
await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token); await _delivery.Enqueue(target, new[] { follower.InboxURL }, reject, token);
Arrival.Reject("blocked");
return; return;
} }
@@ -75,6 +79,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
record.FollowActivityURI = Id(follow); record.FollowActivityURI = Id(follow);
record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers; record.IsAccepted = existing?.IsAccepted == true || !target.ManuallyApprovesFollowers;
await DB.Default.SaveAsync(record, token); await DB.Default.SaveAsync(record, token);
Arrival.Accept(record.IsAccepted ? "auto-accepted" : "pending");
if (target.Kind == LocalActorKind.Person) if (target.Kind == LocalActorKind.Person)
await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest, await Notifications.Add(target.Id, record.IsAccepted ? NotificationType.Follow : NotificationType.FollowRequest,
@@ -30,11 +30,19 @@ namespace PrivaPub.Federation.Inbox.Handlers
{ {
var objectUri = Id(activity["object"]); var objectUri = Id(activity["object"]);
if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) if (objectUri == default || !objectUri.StartsWith(_localActors.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
{
Arrival.Drop("unknown-object");
return; return;
}
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.IsFederatedCopy && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); 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); var owner = post == default ? default : await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (owner == default || string.IsNullOrEmpty(actor.InboxURL)) if (owner == default || string.IsNullOrEmpty(actor.InboxURL))
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
Arrival.Reject("ignored");
await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject await _delivery.Enqueue(owner, new[] { actor.InboxURL }, new JsonObject
{ {
["@context"] = ActivityPubRenderer.ActivityStreams, ["@context"] = ActivityPubRenderer.ActivityStreams,
@@ -35,7 +35,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
? default ? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token); : await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue && p.ReblogOfPostId == null).ExecuteFirstAsync(token);
if (post == default || !MaySee(post, actor)) if (post == default || !MaySee(post, actor))
{
Arrival.Drop(post == default ? "unknown-object" : "not-visible");
return; return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content"); var content = Value(activity, "_misskey_reaction") ?? Value(activity, "content");
if (!Reactions.IsHeart(content)) if (!Reactions.IsHeart(content))
{ {
@@ -49,8 +53,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
} }
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{ {
Arrival.Drop("duplicate");
return; return;
} }
Arrival.Accept("stored");
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, 1)).ExecuteAsync(token);
if (!post.IsFederatedCopy) if (!post.IsFederatedCopy)
await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token); await Notifications.Add(post.GroupUserId, NotificationType.Favourite, actor.ID, actor.ActorURI, post.ID, token);
@@ -37,20 +37,26 @@ namespace PrivaPub.Federation.Inbox.Handlers
var innerType = inner is JsonObject ? Value(inner, "type") : default; var innerType = inner is JsonObject ? Value(inner, "type") : default;
var innerId = Id(inner); var innerId = Id(inner);
Arrival.About(innerType);
var undone = false;
if (innerType is null or "Follow") 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") 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") if (innerType is null or "Announce")
await UndoAnnounce(innerId, actor, token); undone |= await UndoAnnounce(innerId, actor, token);
if (innerType is null or "Dislike") 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") 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); 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<bool> UndoDislike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{ {
var downvote = innerId == default var downvote = innerId == default
? default ? default
@@ -63,16 +69,18 @@ namespace PrivaPub.Federation.Inbox.Handlers
: await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); : await DB.Default.Find<Downvote>().Match(d => d.PostId == post.ID && d.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
} }
if (downvote == default) if (downvote == default)
return; return false;
await DB.Default.DeleteAsync<Downvote>(downvote.ID); await DB.Default.DeleteAsync<Downvote>(downvote.ID);
await DB.Default.Update<PostEntity>().MatchID(downvote.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().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<bool> UndoFollow(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{ {
var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token); var followers = await _dbEntities.Followers.Match(f => f.ActorURI == actor.ActorURI).ExecuteAsync(token);
var targetUri = inner is JsonObject ? Id(inner["object"]) : default; var targetUri = inner is JsonObject ? Id(inner["object"]) : default;
var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token); var target = targetUri == default ? default : await _localActors.FindByUri(targetUri, token);
var undone = false;
foreach (var follower in followers) foreach (var follower in followers)
{ {
var matchesActivity = innerId != default && follower.FollowActivityURI == innerId; var matchesActivity = innerId != default && follower.FollowActivityURI == innerId;
@@ -83,10 +91,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
await DB.Default.DeleteAsync<Follower>(follower.ID); await DB.Default.DeleteAsync<Follower>(follower.ID);
if (follower.LocalActorKind == LocalActorKind.Group) if (follower.LocalActorKind == LocalActorKind.Group)
await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token); await RemoveForeignMember(follower.LocalActorId, actor.ActorURI, token);
undone = true;
} }
return undone;
} }
async Task UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token) async Task<bool> UndoLike(JsonNode inner, string innerId, ForeignAvatar actor, CancellationToken token)
{ {
var favourite = innerId == default var favourite = innerId == default
? 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); : await _dbEntities.Favourites.Match(f => f.PostId == post.ID && f.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
} }
if (favourite == default) if (favourite == default)
return; return false;
await DB.Default.DeleteAsync<Favourite>(favourite.ID); await DB.Default.DeleteAsync<Favourite>(favourite.ID);
await DB.Default.Update<PostEntity>().MatchID(favourite.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().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<bool> UndoAnnounce(string innerId, ForeignAvatar actor, CancellationToken token)
{ {
if (innerId == default) if (innerId == default)
return; return false;
var reblog = await _dbEntities.Posts var reblog = await _dbEntities.Posts
.Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null) .Match(p => p.ObjectURI == innerId && p.ActorURI == actor.ActorURI && p.ReblogOfPostId != null)
.ExecuteFirstAsync(token); .ExecuteFirstAsync(token);
if (reblog == default) if (reblog == default)
return; return false;
await DB.Default.DeleteAsync<PostEntity>(reblog.ID); await DB.Default.DeleteAsync<PostEntity>(reblog.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == reblog.ID); await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == reblog.ID);
await DB.Default.Update<PostEntity>().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); await DB.Default.Update<PostEntity>().MatchID(reblog.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
return true;
} }
} }
} }
@@ -46,17 +46,29 @@ namespace PrivaPub.Federation.Inbox.Handlers
if (Id(inner) == actor.ActorURI) if (Id(inner) == actor.ActorURI)
{ {
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token); await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
Arrival.Accept("actor-refresh");
Arrival.About(actor.AvatarType.ToString());
return; return;
} }
if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI)) if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI))
{
Arrival.Drop("cross-origin");
return; return;
}
var note = NoteParser.Parse(inner); var note = NoteParser.Parse(inner);
if (note == default || note.AttributedTo != actor.ActorURI) if (note == default || note.AttributedTo != actor.ActorURI)
{
Arrival.Drop(note == default ? "unparseable" : "misattributed");
return; return;
}
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token); var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue) if (post == default || post.DeletedAt.HasValue)
{
Arrival.Drop("unknown-object");
return; return;
}
Arrival.About(note.Type, post.Visibility, post.CreationDate);
post.Poll = note.Poll ?? post.Poll; post.Poll = note.Poll ?? post.Poll;
post.QuotePolicy = note.QuotePolicy; post.QuotePolicy = note.QuotePolicy;
@@ -65,12 +77,14 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.Event = note.Event ?? post.Event; post.Event = note.Event ?? post.Event;
if (!IsEdit(note, post)) if (!IsEdit(note, post))
{ {
Arrival.Accept("refresh");
await DB.Default.SaveAsync(post, token); await DB.Default.SaveAsync(post, token);
await _records.Revise(note, Id(activity), token); await _records.Revise(note, Id(activity), token);
await Requote(post, note, token); await Requote(post, note, token);
return; return;
} }
Arrival.Accept("edit");
post.Revisions.Add(new PostRevision post.Revisions.Add(new PostRevision
{ {
Title = post.Title, Title = post.Title,
+67 -2
View File
@@ -1,8 +1,12 @@
using PrivaPub.Federation.Actors; using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -21,12 +25,15 @@ namespace PrivaPub.Federation.Inbox
readonly IRemoteActorService _remoteActors; readonly IRemoteActorService _remoteActors;
readonly IReadOnlyDictionary<string, IActivityHandler> _handlers; readonly IReadOnlyDictionary<string, IActivityHandler> _handlers;
readonly ILogger<InboxProcessor> _logger; readonly ILogger<InboxProcessor> _logger;
readonly IInteractionLedger _ledger;
public InboxProcessor(IRemoteActorService remoteActors, IEnumerable<IActivityHandler> handlers, ILogger<InboxProcessor> logger) public InboxProcessor(IRemoteActorService remoteActors, IEnumerable<IActivityHandler> handlers, ILogger<InboxProcessor> logger,
IInteractionLedger ledger = default)
{ {
_remoteActors = remoteActors; _remoteActors = remoteActors;
_handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal); _handlers = handlers.ToDictionary(h => h.Type, StringComparer.Ordinal);
_logger = logger; _logger = logger;
_ledger = ledger;
} }
public JobKind Kind => JobKind.ProcessInbox; public JobKind Kind => JobKind.ProcessInbox;
@@ -36,28 +43,86 @@ namespace PrivaPub.Federation.Inbox
public async Task<JobOutcome> Handle(Job job, CancellationToken token) public async Task<JobOutcome> Handle(Job job, CancellationToken token)
{ {
var started = Stopwatch.GetTimestamp();
var payload = JsonSerializer.Deserialize<InboxPayload>(job.Payload); var payload = JsonSerializer.Deserialize<InboxPayload>(job.Payload);
var activity = payload == default ? default : JsonNode.Parse(payload.Activity); var activity = payload == default ? default : JsonNode.Parse(payload.Activity);
var type = Value(activity, "type"); var type = Value(activity, "type");
if (type == default || !_handlers.TryGetValue(type, out var handler)) if (type == default || !_handlers.TryGetValue(type, out var handler))
{
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Dropped, "unknown-type");
return JobOutcome.Done; return JobOutcome.Done;
}
var actor = await _remoteActors.GetActor(payload.ActorURI, refresh: false, token); var actor = await _remoteActors.GetActor(payload.ActorURI, refresh: false, token);
if (actor == default) if (actor == default)
{
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable");
return JobOutcome.Retry("the actor could not be loaded"); 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<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString()); payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString());
Arrival.Current = arrival;
try try
{ {
await handler.Handle(activity, actor, token); 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 finally
{ {
Arrival.Current = default; 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); _logger.LogInformation("Processed {Type} {Id} from {Actor}", type, Id(activity), actor.ActorURI);
return JobOutcome.Done; 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<string> Addresses(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
JsonNode single when Id(single) is { } id => new List<string> { id },
_ => new List<string>()
};
} }
} }
@@ -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.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.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.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) if (e.Features?.Count == 0)
e.Features = default; e.Features = default;
return e; return e;
@@ -86,6 +86,9 @@ namespace PrivaPub.Infrastructure.Statistics
static string Word(string value) => static string Word(string value) =>
value == default ? default : KebabWord().IsMatch(value) ? value : Other; 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}$")] [GeneratedRegex("^[A-Za-z][A-Za-z0-9_-]{0,31}$")]
private static partial Regex TypeWord(); private static partial Regex TypeWord();
@@ -95,6 +98,9 @@ namespace PrivaPub.Infrastructure.Statistics
[GeneratedRegex("^[a-z0-9][a-z0-9:-]{0,39}$")] [GeneratedRegex("^[a-z0-9][a-z0-9:-]{0,39}$")]
private static partial Regex SignatureName(); 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])?$")] [GeneratedRegex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")]
private static partial Regex HostName(); private static partial Regex HostName();
} }