diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index 7d74069..6836f56 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -160,6 +160,63 @@ namespace PrivaPub.Tests.Federation Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode); } + // Funkwhale names its Accept after the Follow it answers (`/accept`), on our origin, not its own + [Fact] + public async Task An_answer_named_after_our_follow_is_taken_and_one_naming_someone_elses_is_not() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var channel = new RemoteActor(_harness.Peer, "channel"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = channel.Id }, token); + var following = await DB.Default.Find().Match(f => f.AvatarId == alice.Id && f.TargetActorURI == channel.Id).ExecuteSingleAsync(token); + + var elsewhere = await _harness.Deliver(channel, "/human-centipede", new JsonObject + { + ["id"] = "https://privapub.test/peasants/somebody/accept", ["type"] = "Accept", ["actor"] = channel.Id, ["object"] = following.FollowActivityURI + }); + Assert.Equal(400, elsewhere.StatusCode); + + var named = await _harness.Deliver(channel, "/human-centipede", new JsonObject + { + ["id"] = following.FollowActivityURI + "/accept", ["type"] = "Accept", ["actor"] = channel.Id, + ["object"] = new JsonObject { ["id"] = following.FollowActivityURI, ["type"] = "Follow", ["actor"] = alice.Uri, ["object"] = channel.Id } + }); + Assert.Equal(202, named.StatusCode); + Assert.Equal(FollowState.Accepted, (await DB.Default.Find().OneAsync(following.ID, token)).State); + } + + // Funkwhale's channel deletes its uploads in one Delete, their ids in a list as the object's id (and the Delete has + // no id of its own) + [Fact] + public async Task A_delete_naming_several_objects_removes_each() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var channel = new RemoteActor(_harness.Peer, "channel"); + await Follows(alice.Id, channel); + var first = PublicNote(channel, "

a first track

"); + var second = PublicNote(channel, "

a second track

"); + await _harness.Deliver(channel, "/human-centipede", Create(channel, first)); + await _harness.Deliver(channel, "/human-centipede", Create(channel, second)); + Task Held(JsonObject note) => DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteAnyAsync(token); + Assert.True(await Held(first) && await Held(second)); + + var result = await _harness.Receiver.Receive(channel.Post(Harness.Host, "/human-centipede", new JsonObject + { + ["type"] = "Delete", ["actor"] = channel.Id, + ["object"] = new JsonObject { ["id"] = new JsonArray(IdOf(first), IdOf(second)), ["type"] = "Audio" } + }), default, token); + Assert.Equal(202, result.StatusCode); + var listed = IdOf(first); + var job = await DB.Default.Find().Match(j => j.Kind == PrivaPub.Models.Jobs.JobKind.ProcessInbox + && j.State == PrivaPub.Models.Jobs.JobState.Pending && j.Payload.Contains(listed)).Sort(j => j.CreatedAt, MongoDB.Entities.Order.Descending) + .ExecuteFirstAsync(token); + await _harness.Processor.Handle(job, token); + + Assert.False(await Held(first)); + Assert.False(await Held(second)); + } + [Fact] public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too() { diff --git a/PrivaPub.Tests/Statistics/DescribeTests.cs b/PrivaPub.Tests/Statistics/DescribeTests.cs index b4de430..95ab23c 100644 --- a/PrivaPub.Tests/Statistics/DescribeTests.cs +++ b/PrivaPub.Tests/Statistics/DescribeTests.cs @@ -59,6 +59,10 @@ namespace PrivaPub.Tests.Statistics Assert.Equal(("https://a.example/2.2", "2.2"), InstanceDocuments.NodeInfoLink(links)); Assert.Equal(default, InstanceDocuments.NodeInfoLink(Json("[]"))); + // Funkwhale 2.0 links its NodeInfo under its swagger schema's URL + Assert.Equal(("https://f.example/api/v2/instance/nodeinfo/2.1", "2.1"), InstanceDocuments.NodeInfoLink(Json(""" + {"links":[{"rel":"https://docs.funkwhale.audio/swagger/schema.yml","href":"https://f.example/api/v2/instance/nodeinfo/2.1"}]} + """))); } [Fact] diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs index 6b041e5..4f9d244 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs @@ -43,11 +43,20 @@ namespace PrivaPub.Federation.Inbox.Handlers public string Type => "Delete"; + const int MaxObjects = 50; + public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { - var delete = activity; + // Funkwhale deletes several uploads at once, naming them in one list as the object's id + var objectUris = activity["object"] is JsonObject { } inner && inner["id"] is JsonArray ids + ? ids.Select(Id).Where(id => id != default).Distinct(StringComparer.Ordinal).Take(MaxObjects).ToList() + : new List { Id(activity["object"]) }; + foreach (var objectUri in objectUris) + await Handle(activity, actor, objectUri, token); + } - var objectUri = Id(delete["object"]); + async Task Handle(JsonNode activity, ForeignAvatar actor, string objectUri, CancellationToken token) + { if (!Origin.Same(objectUri, actor.ActorURI)) { Arrival.Drop("cross-origin"); diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index 3811105..eabed4b 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -222,7 +222,10 @@ namespace PrivaPub.Federation.Inbox async Task ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token) { var activityId = Id(activity); - if (activityId != default && !Origin.Same(activityId, actorUri)) + // (Funkwhale names its answer after the activity it answers: `/accept`) + if (activityId != default && !Origin.Same(activityId, actorUri) + && !(type is "Accept" or "Reject" && Id(activity["object"]) is { } answered && Origin.Same(answered, _localActors.BaseAddress) + && activityId.StartsWith(answered + "/", StringComparison.Ordinal))) return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin", Reason: "id-cross-origin"); var inner = activity["object"]; diff --git a/PrivaPub/Federation/Objects/InstanceDocuments.cs b/PrivaPub/Federation/Objects/InstanceDocuments.cs index 997b234..ba81017 100644 --- a/PrivaPub/Federation/Objects/InstanceDocuments.cs +++ b/PrivaPub/Federation/Objects/InstanceDocuments.cs @@ -38,6 +38,12 @@ namespace PrivaPub.Federation.Objects if (link.ValueKind == JsonValueKind.Object && Text(link, "rel") == schema && Text(link, "href") is { } href && href.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return (href, schema[(schema.LastIndexOf('/') + 1)..]); + // a link to a NodeInfo document under another rel (Funkwhale 2.0 names it after its swagger schema): its path + // says what it is, and its last segment the version + foreach (var link in list.EnumerateArray()) + if (link.ValueKind == JsonValueKind.Object && Text(link, "href") is { } href && href.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + && Uri.TryCreate(href, UriKind.Absolute, out var target) && target.AbsolutePath.Contains("/nodeinfo", StringComparison.OrdinalIgnoreCase)) + return (href, target.Segments[^1].Trim('/') is var last && Schemas.Any(s => s.EndsWith("/" + last, StringComparison.Ordinal)) ? last : "2.0"); return default; }