diff --git a/FEDERATION.md b/FEDERATION.md index b4908b1..e8550e1 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -227,7 +227,8 @@ Posts with a location (shown to nearby users of this server) never leave the ser carry is deleted (then 410); a persona's boost id sends a browser to the boosted post. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and `Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom. Neither do `Update`, `Delete`, `EmojiReact`, `QuoteRequest` and its answers, `Flag`, `Ignore` or poll - votes. All of them are always sent with their object embedded. + votes. All of them are always sent with their object embedded. Every boost and every favourite is an activity of its + own (`announce-{boost}`, `like-{favourite}`), so one given again after its `Undo` is new to the server it reaches. - **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag. - **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, starting at an hour and growing to a week. A server can also take a Follow (202) and drop it afterwards, as Pleroma diff --git a/PrivaPub.Tests/Http/FederationGetTests.cs b/PrivaPub.Tests/Http/FederationGetTests.cs index 4799544..23b7f8a 100644 --- a/PrivaPub.Tests/Http/FederationGetTests.cs +++ b/PrivaPub.Tests/Http/FederationGetTests.cs @@ -170,7 +170,7 @@ namespace PrivaPub.Tests.Http var announce = items[0]; Assert.Equal("Announce", announce["type"]!.GetValue()); Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue()); - Assert.Equal($"{persona.ActorUri()}/grunts/announce-{boosted.ID}", announce["id"]!.GetValue()); + Assert.Equal($"{persona.ActorUri()}/grunts/announce-{reblog.ID}", announce["id"]!.GetValue()); Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue()); Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue()); @@ -436,7 +436,7 @@ namespace PrivaPub.Tests.Http string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}"; var create = await _client.Fetch(Path($"create-{post.ID}")); - var announce = await _client.Fetch(Path($"announce-{original.ID}")); + var announce = await _client.Fetch(Path($"announce-{reblog.ID}")); Assert.Equal(HttpStatusCode.OK, create.Status); Assert.Equal(ActivityJson, create.MediaType); @@ -451,7 +451,8 @@ namespace PrivaPub.Tests.Http foreach (var unknown in new[] { $"create-{followersOnly.ID}", $"create-{Guid.NewGuid():N}"[..31], $"announce-{post.ID}", $"like-{post.ID}", "x" }) Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path(unknown))).Status); - Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{original.ID}")).Status); + Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(Path($"announce-{original.ID}"))).Status); + Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{friend.UserName}/grunts/announce-{reblog.ID}")).Status); } [Fact] diff --git a/PrivaPub.Tests/Http/MastodonStatusesTests.cs b/PrivaPub.Tests/Http/MastodonStatusesTests.cs index 3dc81b3..66bdba4 100644 --- a/PrivaPub.Tests/Http/MastodonStatusesTests.cs +++ b/PrivaPub.Tests/Http/MastodonStatusesTests.cs @@ -354,6 +354,37 @@ namespace PrivaPub.Tests.Http Assert.True((await alice.Client.Post($"/api/v1/statuses/{quiet.Text("id")}/favourite")).Ok().Body.Flag("favourited")); } + [Fact] + public async Task A_favourite_or_a_boost_given_again_after_its_undo_is_a_new_activity_that_is_delivered() + { + var since = DateTime.UtcNow.AddSeconds(-1); + var alice = await _host.Mastodon("alice"); + var bob = new RemoteActor(_peer, "bob"); + var remote = await _host.PublicPostFrom(bob, alice); + var path = $"/api/v1/statuses/{remote.ID}"; + + foreach (var (on, off) in new[] { ("favourite", "unfavourite"), ("reblog", "unreblog") }) + { + (await alice.Client.Post($"{path}/{on}")).Ok(); + (await alice.Client.Post($"{path}/{off}")).Ok(); + (await alice.Client.Post($"{path}/{on}")).Ok(); + } + + var sent = await bob.Delivered(since); + foreach (var type in new[] { "Like", "Announce" }) + { + var given = sent.Where(d => d.Type() == type).ToList(); + Assert.Equal(2, given.Count); + Assert.NotEqual(given[0].Text("id"), given[1].Text("id")); + var undo = Assert.Single(sent, d => d.Type() == "Undo" && d["object"].Text("type") == type); + Assert.Equal(given[0].Text("id"), undo["object"].Text("id")); + } + var status = (await alice.Client.Get(path)).Ok().Body; + Assert.True(status.Flag("favourited")); + Assert.True(status.Flag("reblogged")); + Assert.Equal(1, status.Number("reblogs_count")); + } + [Fact] public async Task Bookmarks_are_kept_listed_and_removed() { diff --git a/PrivaPub/Domain/Statuses/QuoteService.cs b/PrivaPub/Domain/Statuses/QuoteService.cs index 9e2a61e..ed9b4ad 100644 --- a/PrivaPub/Domain/Statuses/QuoteService.cs +++ b/PrivaPub/Domain/Statuses/QuoteService.cs @@ -239,7 +239,7 @@ namespace PrivaPub.Domain.Statuses .ExecuteAsync(token); await DB.Default.Update().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token); if (!post.IsLocalOnly) - await _outbox.PublishUpdate(author, post, "quote-approved", token); + await _outbox.PublishUpdate(author, post, $"quote-approved-{DateTime.UtcNow.Ticks}", token); return true; } diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 5ac9e17..fb2f36e 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -1,3 +1,4 @@ +using MongoDB.Bson; using MongoDB.Driver; using MongoDB.Entities; @@ -321,7 +322,7 @@ namespace PrivaPub.Domain.Statuses var update = new JsonObject { ["@context"] = ActivityPubRenderer.Context(), - ["id"] = author.ActivityUri($"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeSeconds()}"), + ["id"] = author.ActivityUri($"update-{post.ID}-{new DateTimeOffset(post.EditedAt.Value).ToUnixTimeMilliseconds()}"), ["type"] = "Update", ["actor"] = author.Uri, ["to"] = note["to"]!.DeepClone(), @@ -378,12 +379,16 @@ namespace PrivaPub.Domain.Statuses if (post == default) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found"); - var activityId = me.ActivityUri($"like-{post.ID}"); + // each favourite is a Like of its own, as Mastodon's are: a favourite after an unfavourite is a new Like, which no + // server (and no delivery queue) takes for the one already undone; an unfavourite undoes the Like it ends + var favourite = default(Favourite); if (on) { + favourite = new Favourite { ID = ObjectId.GenerateNewId().ToString(), AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID }; + favourite.ActivityURI = me.ActivityUri($"like-{favourite.ID}"); try { - await DB.Default.SaveAsync(new Favourite { AccountId = me.Id, ActorURI = me.Uri, PostId = post.ID, ActivityURI = activityId }, token); + await DB.Default.SaveAsync(favourite, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { @@ -396,8 +401,8 @@ namespace PrivaPub.Domain.Statuses } else { - var removed = await DB.Default.DeleteAsync(f => f.AccountId == me.Id && f.PostId == post.ID); - if (removed.DeletedCount == 0) + favourite = await DB.Default.Find().Match(f => f.AccountId == me.Id && f.PostId == post.ID).ExecuteFirstAsync(token); + if (favourite == default || (await DB.Default.DeleteAsync(favourite.ID)).DeletedCount == 0) return new StatusOutcome(post); await DB.Default.Update().MatchID(post.ID).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); post.FavouritesCount--; @@ -408,12 +413,12 @@ namespace PrivaPub.Domain.Statuses var like = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, - ["id"] = activityId, + ["id"] = favourite.ActivityURI ?? me.ActivityUri($"like-{post.ID}"),//a favourite from before every Like had its own id ["type"] = "Like", ["actor"] = me.Uri, ["object"] = post.ObjectURI }; - await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{post.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token); + await _delivery.Enqueue(me, new[] { inbox }, on ? like : Undo(me, like, $"undo-like-{favourite.ID}"), token); } return new StatusOutcome(post); } @@ -429,7 +434,10 @@ namespace PrivaPub.Domain.Statuses return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: This post can't be boosted"); var existing = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); - var announceId = me.ActivityUri($"announce-{original.ID}"); + // each boost is an activity of its own, as Mastodon's are: a boost after an unboost is a new Announce, which no + // server (and no delivery queue) takes for the one already undone; an unboost undoes the boost it ends + var reblogId = existing?.ID ?? ObjectId.GenerateNewId().ToString(); + var announceId = existing?.ActivityURI ?? me.ActivityUri($"announce-{reblogId}"); var announce = new JsonObject { ["@context"] = ActivityPubRenderer.ActivityStreams, @@ -448,6 +456,7 @@ namespace PrivaPub.Domain.Statuses return new StatusOutcome(existing); var reblog = new PostEntity { + ID = reblogId, GroupUserId = me.Id, AuthorAccountId = me.Id, ReblogOfPostId = original.ID, @@ -466,6 +475,14 @@ namespace PrivaPub.Domain.Statuses { return new StatusOutcome(await _dbEntities.Posts.Match(p => p.ObjectURI == announceId).ExecuteFirstAsync(token)); } + // two boosts at once: the first one saved stands, and the other goes + var boosts = await _dbEntities.Posts.Match(p => p.ReblogOfPostId == original.ID && p.AuthorAccountId == me.Id && !p.DeletedAt.HasValue) + .Sort(p => p.ID, Order.Ascending).ExecuteAsync(token); + if (boosts.Count > 1 && boosts[0].ID != reblog.ID) + { + await DB.Default.DeleteAsync(reblog.ID); + return new StatusOutcome(boosts[0]); + } await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token); if (!original.IsFederatedCopy) await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token); @@ -479,7 +496,7 @@ namespace PrivaPub.Domain.Statuses await DB.Default.DeleteAsync(existing.ID); await DB.Default.DeleteAsync(e => e.PostId == existing.ID); await DB.Default.Update().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token); - await _delivery.EnqueueToFollowers(me, Undo(me, announce, $"undo-announce-{original.ID}-{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"), token, + await _delivery.EnqueueToFollowers(me, Undo(me, announce, $"undo-announce-{existing.ID}"), token, await AuthorInbox(original, token) is { } authorInbox ? new[] { authorInbox } : default); return new StatusOutcome(original); }