Funkwhale's answers, deletions and NodeInfo are understood
Three shapes Funkwhale 2.0 sends, each of which lost something: - its Accept is named after the Follow it answers, on our origin (`…#follows/<uuid>/accept`), and was refused as off its actor's origin, so no follow of a channel completed: an Accept or Reject whose id extends the id of the activity it answers, on our origin, is now taken; - a channel deletes its uploads in one Delete without an id, their ids in a list as the object's id: each is deleted; - its NodeInfo discovery names the document under its swagger schema's URL: a link whose path names NodeInfo is taken when no rel is known. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
3866558d1d
commit
9f0b25e92b
5 files changed
+82
-3
No files matched your search
@@ -160,6 +160,63 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode);
|
Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Funkwhale names its Accept after the Follow it answers (`<our Follow's id>/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<Following>().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<Following>().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, "<p>a first track</p>");
|
||||||
|
var second = PublicNote(channel, "<p>a second track</p>");
|
||||||
|
await _harness.Deliver(channel, "/human-centipede", Create(channel, first));
|
||||||
|
await _harness.Deliver(channel, "/human-centipede", Create(channel, second));
|
||||||
|
Task<bool> Held(JsonObject note) => DB.Default.Find<Post>().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<PrivaPub.Models.Jobs.Job>().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]
|
[Fact]
|
||||||
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
|
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ namespace PrivaPub.Tests.Statistics
|
|||||||
|
|
||||||
Assert.Equal(("https://a.example/2.2", "2.2"), InstanceDocuments.NodeInfoLink(links));
|
Assert.Equal(("https://a.example/2.2", "2.2"), InstanceDocuments.NodeInfoLink(links));
|
||||||
Assert.Equal(default, InstanceDocuments.NodeInfoLink(Json("[]")));
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -43,11 +43,20 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
|
|
||||||
public string Type => "Delete";
|
public string Type => "Delete";
|
||||||
|
|
||||||
|
const int MaxObjects = 50;
|
||||||
|
|
||||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
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<string> { 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))
|
if (!Origin.Same(objectUri, actor.ActorURI))
|
||||||
{
|
{
|
||||||
Arrival.Drop("cross-origin");
|
Arrival.Drop("cross-origin");
|
||||||
|
|||||||
@@ -222,7 +222,10 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
|
async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
|
||||||
{
|
{
|
||||||
var activityId = Id(activity);
|
var activityId = Id(activity);
|
||||||
if (activityId != default && !Origin.Same(activityId, actorUri))
|
// (Funkwhale names its answer after the activity it answers: `<our Follow's id>/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");
|
return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin", Reason: "id-cross-origin");
|
||||||
|
|
||||||
var inner = activity["object"];
|
var inner = activity["object"];
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ namespace PrivaPub.Federation.Objects
|
|||||||
if (link.ValueKind == JsonValueKind.Object && Text(link, "rel") == schema && Text(link, "href") is { } href
|
if (link.ValueKind == JsonValueKind.Object && Text(link, "rel") == schema && Text(link, "href") is { } href
|
||||||
&& href.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
&& href.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||||
return (href, schema[(schema.LastIndexOf('/') + 1)..]);
|
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;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user