A boost or a favourite given again is sent again

Every boost of a post was announce-{post}, and every favourite like-{post}:
after an Undo, giving it again made the same activity id, which the
delivery queue had already delivered and dropped (and a server that
remembers ids would ignore). Found by decePub's e2e actions against the
pasture, which unboost and boost again. Now each boost and favourite is an
activity of its own, as Mastodon's are, and an Undo names the one it ends;
a concurrent second boost is removed after saving. An edit's Update is
named to the millisecond, a quote approval's to the tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 02:48:35 +02:00
1 parent ce473f2741
commit b710493701
5 files changed
+64 -14

No files matched your search

+2 -1
View File
@@ -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
+4 -3
View File
@@ -170,7 +170,7 @@ namespace PrivaPub.Tests.Http
var announce = items[0];
Assert.Equal("Announce", announce["type"]!.GetValue<string>());
Assert.Equal(reblog.ObjectURI, announce["id"]!.GetValue<string>());
Assert.Equal($"{persona.ActorUri()}/grunts/announce-{boosted.ID}", announce["id"]!.GetValue<string>());
Assert.Equal($"{persona.ActorUri()}/grunts/announce-{reblog.ID}", announce["id"]!.GetValue<string>());
Assert.Equal(boosted.ObjectURI, announce["object"]!.GetValue<string>());
Assert.Equal(persona.ActorUri(), announce["actor"]!.GetValue<string>());
@@ -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]
@@ -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()
{
+1 -1
View File
@@ -239,7 +239,7 @@ namespace PrivaPub.Domain.Statuses
.ExecuteAsync(token);
await DB.Default.Update<PostEntity>().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;
}
+26 -9
View File
@@ -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<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID);
if (removed.DeletedCount == 0)
favourite = await DB.Default.Find<Favourite>().Match(f => f.AccountId == me.Id && f.PostId == post.ID).ExecuteFirstAsync(token);
if (favourite == default || (await DB.Default.DeleteAsync<Favourite>(favourite.ID)).DeletedCount == 0)
return new StatusOutcome(post);
await DB.Default.Update<PostEntity>().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<PostEntity>(reblog.ID);
return new StatusOutcome(boosts[0]);
}
await DB.Default.Update<PostEntity>().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<PostEntity>(existing.ID);
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == existing.ID);
await DB.Default.Update<PostEntity>().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);
}