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:
1 parent
ce473f2741
commit
b710493701
5 files changed
+64
-14
No files matched your search
+2
-1
@@ -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
|
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
|
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
|
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.
|
- **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,
|
- **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
|
starting at an hour and growing to a week. A server can also take a Follow (202) and drop it afterwards, as Pleroma
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ namespace PrivaPub.Tests.Http
|
|||||||
var announce = items[0];
|
var announce = items[0];
|
||||||
Assert.Equal("Announce", announce["type"]!.GetValue<string>());
|
Assert.Equal("Announce", announce["type"]!.GetValue<string>());
|
||||||
Assert.Equal(reblog.ObjectURI, announce["id"]!.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(boosted.ObjectURI, announce["object"]!.GetValue<string>());
|
||||||
Assert.Equal(persona.ActorUri(), announce["actor"]!.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}";
|
string Path(string id) => $"/peasants/{persona.UserName}/grunts/{id}";
|
||||||
|
|
||||||
var create = await _client.Fetch(Path($"create-{post.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(HttpStatusCode.OK, create.Status);
|
||||||
Assert.Equal(ActivityJson, create.MediaType);
|
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" })
|
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(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]
|
[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"));
|
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]
|
[Fact]
|
||||||
public async Task Bookmarks_are_kept_listed_and_removed()
|
public async Task Bookmarks_are_kept_listed_and_removed()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
.ExecuteAsync(token);
|
.ExecuteAsync(token);
|
||||||
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
|
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
|
||||||
if (!post.IsLocalOnly)
|
if (!post.IsLocalOnly)
|
||||||
await _outbox.PublishUpdate(author, post, "quote-approved", token);
|
await _outbox.PublishUpdate(author, post, $"quote-approved-{DateTime.UtcNow.Ticks}", token);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
|
|
||||||
@@ -321,7 +322,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
var update = new JsonObject
|
var update = new JsonObject
|
||||||
{
|
{
|
||||||
["@context"] = ActivityPubRenderer.Context(),
|
["@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",
|
["type"] = "Update",
|
||||||
["actor"] = author.Uri,
|
["actor"] = author.Uri,
|
||||||
["to"] = note["to"]!.DeepClone(),
|
["to"] = note["to"]!.DeepClone(),
|
||||||
@@ -378,12 +379,16 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
if (post == default)
|
if (post == default)
|
||||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
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)
|
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
|
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)
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||||
{
|
{
|
||||||
@@ -396,8 +401,8 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var removed = await DB.Default.DeleteAsync<Favourite>(f => f.AccountId == me.Id && f.PostId == post.ID);
|
favourite = await DB.Default.Find<Favourite>().Match(f => f.AccountId == me.Id && f.PostId == post.ID).ExecuteFirstAsync(token);
|
||||||
if (removed.DeletedCount == 0)
|
if (favourite == default || (await DB.Default.DeleteAsync<Favourite>(favourite.ID)).DeletedCount == 0)
|
||||||
return new StatusOutcome(post);
|
return new StatusOutcome(post);
|
||||||
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);
|
||||||
post.FavouritesCount--;
|
post.FavouritesCount--;
|
||||||
@@ -408,12 +413,12 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
var like = new JsonObject
|
var like = new JsonObject
|
||||||
{
|
{
|
||||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
["@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",
|
["type"] = "Like",
|
||||||
["actor"] = me.Uri,
|
["actor"] = me.Uri,
|
||||||
["object"] = post.ObjectURI
|
["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);
|
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");
|
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 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
|
var announce = new JsonObject
|
||||||
{
|
{
|
||||||
["@context"] = ActivityPubRenderer.ActivityStreams,
|
["@context"] = ActivityPubRenderer.ActivityStreams,
|
||||||
@@ -448,6 +456,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
return new StatusOutcome(existing);
|
return new StatusOutcome(existing);
|
||||||
var reblog = new PostEntity
|
var reblog = new PostEntity
|
||||||
{
|
{
|
||||||
|
ID = reblogId,
|
||||||
GroupUserId = me.Id,
|
GroupUserId = me.Id,
|
||||||
AuthorAccountId = me.Id,
|
AuthorAccountId = me.Id,
|
||||||
ReblogOfPostId = original.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));
|
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);
|
await DB.Default.Update<PostEntity>().MatchID(original.ID).Modify(b => b.Inc(p => p.ReblogsCount, 1)).ExecuteAsync(token);
|
||||||
if (!original.IsFederatedCopy)
|
if (!original.IsFederatedCopy)
|
||||||
await Notifications.Add(original.GroupUserId, NotificationType.Reblog, me.Id, me.Uri, original.ID, token);
|
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<PostEntity>(existing.ID);
|
||||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == 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 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);
|
await AuthorInbox(original, token) is { } authorInbox ? new[] { authorInbox } : default);
|
||||||
return new StatusOutcome(original);
|
return new StatusOutcome(original);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user