Pins across servers, both ways

An account elsewhere that pins or unpins one of its posts (Add or Remove on its `featured`) now shows those pins on its
profile here (`pinned=true`), in its order and its public posts only; a community's announce of a moderator's Add does
the same for the community. The `featured` collection itself is read with the account's counts, at most once a day, so
pins made before PrivaPub ever saw an account show too. Any other target (Smithereen's wall, a community's moderators)
is dropped. A persona's pin and unpin go to the post's audience as Add and Remove on /trophies, as Mastodon sends them.

Checked live against Mastodon (scenarios/pins.sh, 8 checks). The town's checker learnt three peer rules from the
village: Misskey and Sharkey keep a forwarded reply only with its author's LD signature (only Mastodon signs), they
count no renote by a bot, and Mastodon never sees a Lemmy vote on a post in a community. The village of 2026-10-05
checks clean, 2454 of 2454.

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 22:48:57 +02:00
1 parent 5f150b7a59
commit 1c7d1ece9c
23 files changed
+510 -19

No files matched your search

+2
View File
@@ -559,6 +559,8 @@ tools/pasture/run.sh down # removes e
`connection`) and its API is v3 (`/api/v3`, `sort=New`, `resolve_object` answering views). It takes private messages
only as `ChatMessage`, which PrivaPub sends it, a first message too (invariant 17). `scenarios/lemmy19.sh`, 30
checks.
- **Pins (`scenarios/pins.sh`, needs mastodon):** a Mastodon account pins and unpins while alice follows it, alice pins
and unpins while it follows her, and a fresh account's earlier pin shows once PrivaPub resolves it. 8 checks.
- **Smithereen (1.0.3):** its image on the shared MySQL (database `smithereen`, its schema from the image's commit),
with imgproxy and a file server behind Caddy as `smithereen.test` (`/i` and `/s`), trusting the CA through a JDK
store with it added (`JAVA_TOOL_OPTIONS`). MySQL takes its stored functions only with
+6 -3
View File
@@ -149,6 +149,7 @@ Received:
| `Delete` | deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late `Create` cannot bring it back |
| `Flag` | becomes a report for this server's moderators |
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. Any other target (Smithereen's wall, a community's moderators) is dropped |
| `Block` of a persona | the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says `blocked_by`; `Undo{Block}` lifts it |
Sent: `Follow`, `Undo{Follow}`, `Create{Note}`, `Create{Question}` and poll votes, `EmojiReact` and its `Undo`, `Update{Note}`, `Update{Person}`, `Delete{Tombstone}`, `Accept{Follow}`,
@@ -157,7 +158,8 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
Uploaded files have all metadata removed.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`.
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
- **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it
followed); an unblock sends `Undo{Block}`.
- **Reports** are sent as `Flag` by the instance actor, never by the reporting account.
@@ -169,8 +171,9 @@ persona's posts passed on to its followers. A deleted post answers 410 with a `T
to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent
and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its `Update` and
`Delete` follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public
or unlisted. A server that receives it checks it as it checks any forwarded activity (Mastodon by the LD signature
or by reading the reply from its origin).
or unlisted. A server that receives it checks it as it checks any forwarded activity: Mastodon, Misskey and Sharkey
keep it only when its author LD-signed it (Mastodon does; Misskey 2026.10 and Akkoma do not), others read the reply
from its origin.
- **Events** (owner decision 2026-10-05). A persona joins another server's event with a `Join` and leaves it with a
`Leave`, both sent to the event's organiser only (`…/grunts/join-<id>`, `…/grunts/leave-<id>`). The organiser's
server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as
+143
View File
@@ -0,0 +1,143 @@
using MongoDB.Entities;
using PrivaPub.Models.Post;
using PrivaPub.Models.Social;
using PrivaPub.Tests.Support;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Federation
{
// pins across servers: an account elsewhere features its own posts (Add and Remove on its `featured` collection, and
// the collection itself), a community features posts made in it, and a persona's pin is told to its followers
[Trait("Category", "Integration")]
public sealed class FeaturedTests : IAsyncLifetime
{
Harness _harness;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_harness = await Harness.Start();
}
public async ValueTask DisposeAsync()
{
if (_harness != default)
await _harness.DisposeAsync();
}
static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority);
// a public note of the actor's, served where its id points
string Note(RemoteActor author, string text, string audience = default)
{
var path = $"/notes/{Guid.NewGuid():N}";
var note = new JsonObject
{
["id"] = Origin(author) + path, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"<p>{text}</p>",
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"), ["published"] = DateTime.UtcNow.ToString("O")
};
if (audience != default)
note["audience"] = audience;
_harness.Peer.Serve(path, note.ToJsonString());
return Origin(author) + path;
}
JsonObject Featuring(RemoteActor actor, string type, string objectUri, string target = default) => new()
{
["id"] = $"{actor.Id}#{type}-{Guid.NewGuid():N}", ["type"] = type, ["actor"] = actor.Id, ["object"] = objectUri,
["target"] = target ?? actor.Id + "/featured"
};
Task<List<RemoteFeatured>> FeaturedBy(RemoteActor actor) =>
DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == actor.Id).Sort(f => f.Position, Order.Ascending).ExecuteAsync(TestContext.Current.CancellationToken);
[Fact]
public async Task An_account_features_its_own_post_and_takes_it_back_but_nothing_else()
{
var bob = new RemoteActor(_harness.Peer, "bob");
var carol = new RemoteActor(_harness.Peer, "carol", _harness.Peer.B);
var mine = Note(bob, "pinned");
var theirs = Note(carol, "not bob's");
var older = Note(bob, "pinned before");
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", older));
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", mine));
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", theirs));
// Smithereen's wall is a collection of the actor's too, but no pin
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Add", Note(bob, "on the wall"), bob.Id + "/wall"));
Assert.Equal(new[] { mine, older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
var post = await DB.Default.Find<Post>().Match(p => p.ObjectURI == mine).ExecuteSingleAsync(TestContext.Current.CancellationToken);
Assert.Equal(post.ID, (await FeaturedBy(bob))[0].PostId);
await _harness.Deliver(bob, "/human-centipede", Featuring(bob, "Remove", mine));
Assert.Equal(new[] { older }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
}
[Fact]
public async Task The_featured_collection_is_read_in_its_order_from_the_accounts_own_server()
{
var token = TestContext.Current.CancellationToken;
var bob = new RemoteActor(_harness.Peer, "bob");
var first = Note(bob, "first pin");
var second = Note(bob, "second pin");
_harness.Peer.Serve(new Uri(bob.Id).AbsolutePath + "/featured", new JsonObject
{
["id"] = bob.Id + "/featured", ["type"] = "OrderedCollection", ["totalItems"] = 2,
["orderedItems"] = new JsonArray(new JsonObject { ["id"] = first, ["type"] = "Note" }, second)
}.ToJsonString());
var account = await _harness.Remote.GetActor(bob.Id, refresh: false, token);
await _harness.Featured.Sync(account, token);
Assert.Equal(new[] { first, second }, (await FeaturedBy(bob)).Select(f => f.ObjectURI));
}
[Fact]
public async Task A_personas_pin_and_unpin_reach_its_followers_as_add_and_remove()
{
var token = TestContext.Current.CancellationToken;
var (root, alice) = await _harness.Persona("alice");
var follower = new RemoteActor(_harness.Peer, "fan");
await _harness.FollowedBy(alice, follower);
await _harness.Posts.InsertPost(root, new PrivaPub.ClientModels.Post.InsertPostForm { AvatarId = alice.Id, Text = "worth a pin" }, token);
var post = await DB.Default.Find<Post>().Match(p => p.GroupUserId == alice.Id).ExecuteFirstAsync(token);
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: true, token);
await _harness.Outbox.PublishFeatured(alice.Id, post, featured: false, token);
var sent = (await _harness.Outgoing(follower.SharedInbox)).Concat(await _harness.Outgoing(follower.Id + "/inbox"))
.Where(a => a["type"]!.GetValue<string>() is "Add" or "Remove").ToList();
Assert.Equal(new[] { "Add", "Remove" }, sent.Select(a => a["type"]!.GetValue<string>()));
Assert.All(sent, a => Assert.Equal((alice.PostUri(post.ID), alice.Featured), (a["object"]!.GetValue<string>(), a["target"]!.GetValue<string>())));
}
[Fact]
public async Task A_community_features_a_post_made_in_it()
{
var token = TestContext.Current.CancellationToken;
var (_, alice) = await _harness.Persona("alice");
var community = new RemoteActor(_harness.Peer, "books", type: "Group");
var member = new RemoteActor(_harness.Peer, "reader", _harness.Peer.B);
var account = await _harness.Remote.GetActor(community.Id, refresh: false, token);
await DB.Default.SaveAsync(new Following { AvatarId = alice.Id, TargetActorURI = community.Id, TargetAccountId = account.ID, State = FollowState.Accepted }, token);
var post = Note(member, "a thread in the community", community.Id);
await _harness.Deliver(community, "/human-centipede", new JsonObject
{
["id"] = $"{community.Id}#announce-{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = community.Id,
["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public"),
["object"] = new JsonObject
{
["id"] = $"{Origin(member)}/activities/{Guid.NewGuid():N}", ["type"] = "Add", ["actor"] = member.Id, ["object"] = post,
["target"] = community.Id + "/featured"
}
});
Assert.Equal(new[] { post }, (await FeaturedBy(community)).Select(f => f.ObjectURI));
}
}
}
@@ -183,6 +183,33 @@ namespace PrivaPub.Tests.Http
Assert.Contains(alice.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={alice.UserName}")).Ok().Ids);
}
// an account elsewhere: what it features, in its order, its public posts only
[Fact]
public async Task A_remote_accounts_pinned_statuses_are_what_it_features()
{
var alice = await _host.Mastodon("alice");
var (bob, bobId) = await Remote();
Post Held(string text, PostVisibility visibility) => new()
{
ObjectURI = $"{bob.Id}/statuses/{Guid.NewGuid():N}", ActorURI = bob.Id, AuthorAccountId = bobId, IsFederatedCopy = true,
Visibility = visibility, ContentHtml = $"<p>{text}</p>", CreationDate = DateTime.UtcNow, UpdateDate = DateTime.UtcNow
};
var first = Held("first pin", PostVisibility.Public);
var second = Held("second pin", PostVisibility.Unlisted);
var hidden = Held("for followers", PostVisibility.FollowersOnly);
await DB.Default.SaveAsync(new[] { first, second, hidden }, Token);
await DB.Default.SaveAsync(new[]
{
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = second.ObjectURI, PostId = second.ID, Position = 1 },
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = hidden.ObjectURI, PostId = hidden.ID, Position = 2 },
new RemoteFeatured { ActorURI = bob.Id, ObjectURI = first.ObjectURI, PostId = first.ID, Position = 0 }
}, Token);
var pinned = (await alice.Client.Get($"/api/v1/accounts/{bobId}/statuses?pinned=true")).Ok();
Assert.Equal(new[] { first.ID, second.ID }, pinned.Ids);
}
[Fact]
public async Task Account_statuses_filter_pins_replies_reblogs_media_and_tags_and_page_both_ways()
{
+5 -1
View File
@@ -56,6 +56,7 @@ namespace PrivaPub.Tests.Support
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
Relationships = new RelationshipService(Db, Follows, Delivery);
Featured = new FeaturedPosts(Db, Remote, RemotePosts);
Handlers = new IActivityHandler[]
{
new FollowHandler(Db, Local, Remote, Delivery),
@@ -74,7 +75,9 @@ namespace PrivaPub.Tests.Support
new FlagHandler(Db, Local),
new BlockHandler(Db, Local),
new LockHandler(Db),
new MoveHandler(Remote, Db, Local, Follows, Relationships)
new MoveHandler(Remote, Db, Local, Follows, Relationships),
new FeaturedHandler(Featured, add: true),
new FeaturedHandler(Featured, add: false)
};
((AnnounceHandler)Handlers.First(h => h is AnnounceHandler)).Relays = Handlers;
Processor = new InboxProcessor(Remote, Handlers, NullLogger<InboxProcessor>.Instance, Ledger, Local);
@@ -114,6 +117,7 @@ namespace PrivaPub.Tests.Support
public Participations Participations { get; }
public TimelineService Timelines { get; }
public RelationshipService Relationships { get; }
public FeaturedPosts Featured { get; }
public ReportService Reports { get; }
public async Task FollowedBy(LocalActor local, RemoteActor follower) =>
+1
View File
@@ -36,6 +36,7 @@ namespace PrivaPub.Tests.Support
["preferredUsername"] = Name,
["inbox"] = Id + "/inbox",
["followers"] = Id + "/followers",
["featured"] = Id + "/featured",
["publicKey"] = new JsonObject
{
["id"] = KeyId,
@@ -154,7 +154,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (Params.Bool("pinned") == true)
{
if (local == default)
return Json(Array.Empty<Status>());
{
var featured = (await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == remote.ActorURI).Sort(f => f.Position, Order.Ascending)
.Limit(Federation.Actors.FeaturedPosts.MaxFeatured).ExecuteAsync(token)).Select(f => f.PostId).ToList();
var featuredPosts = await _dbEntities.Posts.Match(p => featured.Contains(p.ID) && !p.DeletedAt.HasValue).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(featured.Select(id => featuredPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
}
var pinIds = (await DB.Default.Find<Pin>().Match(p => p.AvatarId == local.Id).Sort(p => p.ID, Order.Descending).ExecuteAsync(token)).Select(p => p.PostId).ToList();
var pinnedPosts = await _dbEntities.Posts.Match(p => pinIds.Contains(p.ID)).Match(VisibilityPolicy.IsPublic).ExecuteAsync(token);
return Json(await _mapper.Statuses(pinIds.Select(id => pinnedPosts.FirstOrDefault(p => p.ID == id)).Where(p => p != default).ToList(), MyId, token));
@@ -405,6 +405,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
try
{
await DB.Default.SaveAsync(new Models.Social.Pin { AvatarId = MyId, PostId = post.ID }, token);
await _outbox.PublishFeatured(MyId, post, featured: true, token);
}
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
{
@@ -415,7 +416,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpPost("/api/v1/statuses/{id}/unpin"), Scope("write:accounts")]
public async Task<IActionResult> Unpin(string id, CancellationToken token)
{
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
var unpinned = await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.AvatarId == MyId && p.PostId == id);
if (unpinned is { IsAcknowledged: true, DeletedCount: > 0 } && await _dbEntities.Posts.MatchID(id).ExecuteFirstAsync(token) is { } post)
await _outbox.PublishFeatured(MyId, post, featured: false, token);
return await Unchanged(id, token);
}
@@ -10,17 +10,20 @@ using PrivaPub.StaticServices;
namespace PrivaPub.Federation.Actors
{
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
// followers, following and outbox collections). Read when the account is fetched, at most once a day, never when
// someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and GoToSocial do the same.
// followers, following and outbox collections), and the posts it features. Read when the account is fetched, at most
// once a day, never when someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and
// GoToSocial do the same.
public class AccountCountsJob : IJobHandler
{
readonly IRemoteActorService _remoteActors;
readonly DbEntities _dbEntities;
readonly IFeaturedPosts _featured;
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities)
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default)
{
_remoteActors = remoteActors;
_dbEntities = dbEntities;
_featured = featured;
}
public JobKind Kind => JobKind.CountAccount;
@@ -41,6 +44,8 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
.Modify(a => a.CountedAt, DateTime.UtcNow)
.ExecuteAsync(token);
if (_featured != default)
await _featured.Sync(actor, token);
return JobOutcome.Done;
}
@@ -23,6 +23,7 @@ namespace PrivaPub.Federation.Actors
public string Outbox { get; init; }
public string Followers { get; init; }
public string Following { get; init; }
public string Featured { get; init; }
public string SharedInbox { get; init; }
public string Icon { get; init; }
public bool Discoverable { get; init; } = true;
@@ -73,6 +74,7 @@ namespace PrivaPub.Federation.Actors
Outbox = RemoteActorService.Text(root, "outbox"),
Followers = RemoteActorService.Text(root, "followers"),
Following = RemoteActorService.Text(root, "following"),
Featured = RemoteActorService.Text(root, "featured"),
SharedInbox = root.TryGetProperty("endpoints", out var endpoints) ? RemoteActorService.Text(endpoints, "sharedInbox") : default,
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
+156
View File
@@ -0,0 +1,156 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Actors
{
public interface IFeaturedPosts
{
Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token);
Task Sync(ForeignAvatar owner, CancellationToken token);
}
// What an account elsewhere features (pins): its `featured` collection, read with its counts at most once a day, and
// the Add and Remove it sends when it pins or unpins. An account features only its own posts; a community (Lemmy's,
// inside its announce) features posts made in it. Shown first on its profile here, as Mastodon shows them.
public class FeaturedPosts : IFeaturedPosts
{
public const int MaxFeatured = 20;
readonly DbEntities _dbEntities;
readonly IRemoteActorService _remoteActors;
readonly IRemotePosts _remotePosts;
public FeaturedPosts(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts)
{
_dbEntities = dbEntities;
_remoteActors = remoteActors;
_remotePosts = remotePosts;
}
public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token)
{
if (string.IsNullOrEmpty(owner.FeaturedURL) || Id(activity["target"]) != owner.FeaturedURL)
{
Arrival.Drop("unknown-target");
return;
}
var objectUri = Id(activity["object"]);
if (objectUri == default)
{
Arrival.Drop("unparseable");
return;
}
if (!add)
{
var removed = await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI && f.ObjectURI == objectUri);
if (removed is { IsAcknowledged: true, DeletedCount: > 0 })
Arrival.Accept("unfeatured");
else
Arrival.Drop("unknown-object");
return;
}
var post = await Featurable(owner, objectUri, token);
if (post == default)
{
Arrival.Drop("unknown-object");
return;
}
Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate);
var first = await DB.Default.Find<RemoteFeatured>().Match(f => f.ActorURI == owner.ActorURI).Sort(f => f.Position, Order.Ascending).ExecuteFirstAsync(token);
try
{
await DB.Default.SaveAsync(new RemoteFeatured
{
ActorURI = owner.ActorURI,
ObjectURI = objectUri,
PostId = post.ID,
Position = (first?.Position ?? 0) - 1
}, token);
Arrival.Accept("featured");
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
Arrival.Drop("duplicate");
}
}
public async Task Sync(ForeignAvatar owner, CancellationToken token)
{
if (string.IsNullOrEmpty(owner.FeaturedURL) || !Origin.Same(owner.FeaturedURL, owner.ActorURI))
return;
var uris = await Items(owner, token);
if (uris == default)
return;
var featured = new List<RemoteFeatured>();
foreach (var uri in uris.Distinct(StringComparer.Ordinal).Take(MaxFeatured))
if (await Featurable(owner, uri, token) is { } post)
featured.Add(new RemoteFeatured { ActorURI = owner.ActorURI, ObjectURI = uri, PostId = post.ID, Position = featured.Count });
await DB.Default.DeleteAsync<RemoteFeatured>(f => f.ActorURI == owner.ActorURI);
if (featured.Count > 0)
await DB.Default.SaveAsync(featured, token);
}
// the post, held or fetched from its own server, when the owner may feature it
async Task<PostEntity> Featurable(ForeignAvatar owner, string objectUri, CancellationToken token)
{
var isGroup = owner.AvatarType is AvatarType.Group;
if (!isGroup && !Origin.Same(objectUri, owner.ActorURI))
return default;
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token)
?? await _remotePosts.StoreContext(objectUri, 0, token);
return post switch
{
null => default,
{ ReblogOfPostId: not null } => default,
_ when isGroup => post.AudienceURI == owner.ActorURI ? post : default,
_ => post.ActorURI == owner.ActorURI ? post : default
};
}
// the collection's items, in its order, from its first page when it pages: links or embedded objects alike
async Task<List<string>> Items(ForeignAvatar owner, CancellationToken token)
{
using var scope = HttpScope.For("collection");
using var collection = await _remoteActors.FetchObject(owner.FeaturedURL, token);
if (collection == default || collection.Root.ValueKind != JsonValueKind.Object)
return default;
var items = ItemsOf(collection.Root);
if (items.Count > 0 || !collection.Root.TryGetProperty("first", out var first))
return items;
if (first.ValueKind == JsonValueKind.Object)
return ItemsOf(first);
var page = first.ValueKind == JsonValueKind.String ? first.GetString() : default;
if (page == default || !Origin.Same(page, owner.ActorURI))
return items;
using var fetched = await _remoteActors.FetchObject(page, token);
return fetched == default || fetched.Root.ValueKind != JsonValueKind.Object ? items : ItemsOf(fetched.Root);
}
static List<string> ItemsOf(JsonElement collection)
{
var items = new List<string>();
foreach (var name in new[] { "orderedItems", "items" })
if (collection.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array)
foreach (var item in list.EnumerateArray())
if (item.ValueKind == JsonValueKind.String)
items.Add(item.GetString());
else if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
items.Add(id.GetString());
return items;
}
}
}
@@ -204,6 +204,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.OutboxURL, actor.Outbox)
.Modify(a => a.FollowersURL, actor.Followers)
.Modify(a => a.FollowingURL, actor.Following)
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.ThumbnailURL, actor.Header)
@@ -228,6 +228,15 @@ namespace PrivaPub.Federation.Inbox.Handlers
case "Like" or "Dislike" or "Undo":
await Relayed(inner, group, token);
break;
// a moderator features a post in the community, or no longer: the community's own pins
case "Add" or "Remove" when Id(inner["target"]) is { } target && target == group.FeaturedURL:
var featured = (Relays ?? _services?.GetService(typeof(IEnumerable<IActivityHandler>)) as IEnumerable<IActivityHandler>)
?.FirstOrDefault(h => h.Type == Value(inner, "type"));
if (featured == default)
Arrival.Drop("unsupported");
else
await featured.Handle(inner, group, token);
break;
default:
Arrival.Drop("unsupported");
break;
@@ -0,0 +1,26 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Models.User;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Inbox.Handlers
{
// An account pins one of its posts (Add to its `featured` collection) or unpins it (Remove), as Mastodon and Akkoma
// send it; inside a community's announce, the community features a post made in it. Any other target (Smithereen's
// wall, a community's moderators) is not ours to keep.
public class FeaturedHandler : IActivityHandler
{
readonly IFeaturedPosts _featured;
readonly bool _add;
public FeaturedHandler(IFeaturedPosts featured, bool add)
{
_featured = featured;
_add = add;
}
public string Type => _add ? "Add" : "Remove";
public Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) => _featured.Receive(activity, actor, _add, token);
}
}
@@ -20,6 +20,7 @@ namespace PrivaPub.Federation.Outbox
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token);
Task PublishProfile(LocalActor actor, CancellationToken token);
Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token);
}
public class OutboxPublisher : IOutboxPublisher
@@ -110,6 +111,26 @@ namespace PrivaPub.Federation.Outbox
await _delivery.Enqueue(author, inboxes, activity, token);
}
// a persona pins its post or unpins it: Add or Remove on its featured collection (/trophies, which serves the same
// pins), told to the post's audience as Mastodon tells it
public async Task PublishFeatured(string personaId, PostEntity post, bool featured, CancellationToken token)
{
var author = await _localActors.FindById(LocalActorKind.Person, personaId, token);
if (author is not { IsFederated: true } || post.IsLocalOnly)
return;
await Publish(author, post, new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = author.ActivityUri($"{(featured ? "feature" : "unfeature")}-{post.ID}-{DateTime.UtcNow.Ticks}"),
["type"] = featured ? "Add" : "Remove",
["actor"] = author.Uri,
["object"] = author.PostUri(post.ID),
["target"] = author.Featured,
["to"] = new JsonArray(Objects.Addressing.Public),
["cc"] = new JsonArray(author.Followers)
}, token);
}
public async Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
+2 -1
View File
@@ -121,7 +121,8 @@ namespace PrivaPub.Infrastructure.Data
(() => DB.Default.Index<Mute>().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"),
(() => DB.Default.Index<AccountDomainBlock>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"),
(() => DB.Default.Index<Bookmark>().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"),
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin")
(() => DB.Default.Index<Pin>().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"),
(() => DB.Default.Index<RemoteFeatured>().Key(f => f.ActorURI, KeyType.Ascending).Key(f => f.ObjectURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "remote-featured")
})
await pair.Item1();
await Plain<Block>(token, b => b.TargetActorURI);
@@ -94,6 +94,9 @@ namespace PrivaPub.Middleware
.AddSingleton<IActivityHandler, FlagHandler>()
.AddSingleton<IActivityHandler, BlockHandler>()
.AddSingleton<IActivityHandler, LockHandler>()
.AddSingleton<Federation.Actors.IFeaturedPosts, Federation.Actors.FeaturedPosts>()
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: true))
.AddSingleton<IActivityHandler>(services => new FeaturedHandler(services.GetRequiredService<Federation.Actors.IFeaturedPosts>(), add: false))
.AddSingleton<IActivityHandler, MoveHandler>()
.AddSingleton<IActivityHandler, CreateHandler>()
.AddSingleton<IActivityHandler, DeleteHandler>()
+11
View File
@@ -21,6 +21,17 @@ namespace PrivaPub.Models.Social
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
//a post an account elsewhere features (its `featured` collection, Add and Remove), shown first on its profile here;
//the lowest Position first
public class RemoteFeatured : Entity
{
public string ActorURI { get; set; }
public string ObjectURI { get; set; }
public string PostId { get; set; }
public int Position { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public class Mute : Entity
{
public string AvatarId { get; set; }
+1
View File
@@ -75,6 +75,7 @@ namespace PrivaPub.Models.User
public string SharedInboxURL { get; set; }
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins
public string MovedToURL { get; set; }
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
public string PictureURL { get; set; }//icon
+1 -1
View File
@@ -145,7 +145,7 @@ Priorities, used throughout:
| Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account); move each persona's follow, lists, mutes and blocks (done 2026-10-05) | P1 | `Account.moved` |
| Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` |
| Inbound `Block`: stop delivering, hide (**done** 2026-10-04) | P2 | `relationship.blocked_by` |
| `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` |
| `Add`/`Remove` featured (pins, tags): pins both ways and the collection read daily, done 2026-10-05; featured tags open | P2 | `GET /accounts/:id/statuses?pinned=true` |
| Remote like and boost totals | P2 | counts |
| Read the thread's `context` collection to complete a thread (**done** 2026-10-05, `FetchReplies`) | P2 | `/statuses/:id/context` |
| Publish `context` and a paged `replies` | P2 | — |
+2 -1
View File
@@ -664,7 +664,8 @@ it, raw where it doesn't.
- inbound `Move` with Mastodon's checks: **done 2026-10-05** (the old account shows `moved`, the personas' follows,
lists, mutes and blocks move to the new one, owner decision; checked live against GoToSocial);
- re-run WebFinger on a rename;
- inbound `Block`, plus `Add`/`Remove` of pins;
- inbound `Block`; `Add`/`Remove` of pins: **done 2026-10-05** (both ways, a community's pins too, the `featured`
collection read with an account's counts; checked live against Mastodon);
- FEP-8fcf followers sync;
- `indexable`/`discoverable`/`searchableBy`;
- edit history from `formerRepresentations`;
+54
View File
@@ -0,0 +1,54 @@
# Pinned posts across servers (featured, FEP-e232's Add and Remove): a Mastodon account pins and unpins while alice
# follows it; alice pins and unpins while it follows her; an account that pinned before PrivaPub ever saw it shows its
# pins once resolved (its featured collection, read with its counts). Needs the mastodon peer.
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
p_pinned() { curl -s -H "$PH" "$P/api/v1/accounts/$1/statuses?pinned=true" | j "print(' '.join(s['uri'] for s in d))"; }
m_pinned() { mcurl -H "$MH" "$M/api/v1/accounts/$1/statuses?pinned=true" | j "print(' '.join(s['uri'] for s in d))"; }
echo "pins"
PT=$(privapub_token alice_pins)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_pins" || { ko "PrivaPub token for alice_pins"; return 1; }
run=$(date +%s)
mastodon_user pinner
MT=$(mastodon_token pinner)
MH="Authorization: Bearer $MT"
[ -n "$MT" ] && ok "Mastodon token for pinner" || { ko "Mastodon token for pinner"; return 1; }
echo " following each other"
pinner_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=pinner@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pinner_on_p/follow"
alice_on_m=$(mcurl -H "$MH" "$M/api/v2/search?q=@alice_pins@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$alice_on_m/follow"
until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pinner_on_p" | j "print(d[0][\"following\"] and d[0][\"followed_by\"])")" = "True" ]' \
&& ok "alice and pinner follow each other" || ko "the follows between alice and pinner never took"
echo " pinner pins"
m_post=$(mcurl -X POST -H "$MH" "$M/api/v1/statuses" -d "status=a Mastodon post worth pinning $run&visibility=public")
m_post_id=$(echo "$m_post" | j "print(d['id'])"); m_post_uri=$(echo "$m_post" | j "print(d['uri'])")
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses/$m_post_id/pin"
until_true 45 '[ "$(p_pinned $pinner_on_p)" = "$m_post_uri" ]' && ok "pinner's pin shows on its profile here" || ko "pinner's pin never arrived ($(p_pinned $pinner_on_p))"
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/statuses/$m_post_id/unpin"
until_true 45 '[ -z "$(p_pinned $pinner_on_p)" ]' && ok "and its unpin takes it off" || ko "pinner's unpin never arrived"
echo " alice pins"
p_post=$(curl -s -X POST -H "$PH" "$P/api/v1/statuses" -d "status=a PrivaPub post worth pinning $run&visibility=public")
p_post_id=$(echo "$p_post" | j "print(d['id'])"); p_post_uri=$(echo "$p_post" | j "print(d['uri'])")
until_true 45 '[ "$(mcurl -H "$MH" "$M/api/v1/accounts/$alice_on_m/statuses?limit=5" | j "print(any(s[\"uri\"] == \"$p_post_uri\" for s in d))")" = "True" ]'
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$p_post_id/pin"
until_true 45 '[ "$(m_pinned $alice_on_m)" = "$p_post_uri" ]' && ok "alice's pin shows on her profile on Mastodon" || ko "alice's pin never reached Mastodon ($(m_pinned $alice_on_m))"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$p_post_id/unpin"
until_true 45 '[ -z "$(m_pinned $alice_on_m)" ]' && ok "and her unpin takes it off" || ko "alice's unpin never reached Mastodon"
echo " pins made before"
# a fresh account pins first; PrivaPub reads its featured collection when it first fetches it
early="early$run"
mastodon_user "$early"
ET=$(mastodon_token "$early")
e_post=$(mcurl -X POST -H "Authorization: Bearer $ET" "$M/api/v1/statuses" -d "status=pinned before anyone looked $run&visibility=public")
mcurl -o /dev/null -X POST -H "Authorization: Bearer $ET" "$M/api/v1/statuses/$(echo "$e_post" | j "print(d['id'])")/pin"
early_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=$early@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
until_true 45 '[ "$(p_pinned $early_on_p)" = "$(echo "$e_post" | j "print(d[\"uri\"])")" ]' \
&& ok "an account's earlier pin shows once it is resolved" || ko "the earlier pin never showed ($(p_pinned $early_on_p))"
+19 -7
View File
@@ -37,10 +37,17 @@ NEEDS_PICTURE = {"pixelfed"}
# others there (nobody relays a followers-only reply), nor anything under it
THREAD_BOUND = {"friendica"}
# Mastodon takes an activity another server passes on only with its author's LD signature (or a FEP-8b32 proof) and
# drops it otherwise (ActivityPub::ProcessActivityService); of the platforms here only Mastodon and Misskey and its forks
# sign their Creates that way, so another's reply PrivaPub passes on never lands there
NEEDS_LD_SIGNATURE = {"mastodon"}
LD_SIGNS = {"mastodon", "misskey", "sharkey"}
# drops it otherwise (ActivityPub::ProcessActivityService); Misskey and Sharkey skip it the same way ("http-signature
# verification failed and no LD-Signature"). Of the platforms here only Mastodon signs its Creates that way: Misskey
# 2026.10's carry no signature (seen in the village of 2026-10-05), so another's reply PrivaPub passes on lands there
# only when Mastodon wrote it
NEEDS_LD_SIGNATURE = {"mastodon", "misskey", "sharkey"}
LD_SIGNS = {"mastodon"}
# Misskey and Sharkey count no renote by a bot (NoteCreateService: `!user.isBot` before incRenoteCount)
BOT_RENOTES_UNCOUNTED = {"misskey", "sharkey"}
# a Lemmy account's vote goes to the community alone, which announces it; Mastodon takes no Announce of a Like, so a
# Lemmy vote on a Mastodon post in a community never counts there
GROUP_VOTES_UNSEEN = {"mastodon"}
class World:
@@ -256,6 +263,7 @@ class Sweep:
# -- counts on the object's own server, and PrivaPub's exact counts
def counts(self, stored):
likes, reacts, boosts, votes = defaultdict(int), defaultdict(lambda: defaultdict(int)), defaultdict(int), defaultdict(lambda: defaultdict(int))
bot_boosts = defaultdict(int)
for f in self.w.interactions:
ref = f["ref"]
if f["verb"] == "like":
@@ -269,6 +277,8 @@ class Sweep:
reacts[ref]["❤️" if f["emoji"] in HEARTS else f["emoji"]] += 1
elif f["verb"] == "boost":
boosts[ref] += 1
if self.w.planner.accounts[f["actor"]]["bot"]:
bot_boosts[ref] += 1
elif f["verb"] == "vote":
for c in f["choices"]:
votes[ref][c] += 1
@@ -293,12 +303,14 @@ class Sweep:
self.add(f"count.vote.{v}", "privapub", "privapub", row.votes == want, ref, want, row.votes)
elif origin != "lemmy":
# elsewhere: at least the plain likes that every platform sends as Like
plain = sum(1 for f in self.w.interactions if f["ref"] == ref and f["verb"] == "like")
plain = sum(1 for f in self.w.interactions if f["ref"] == ref and f["verb"] == "like"
and not (origin in GROUP_VOTES_UNSEEN and o.get("group") and self.w.platform(f["actor"]) == "lemmy"))
if plain:
total = (row.likes or 0)
self.add(f"count.like.{v}", "*", origin, total >= plain, ref, f">={plain}", total)
if boosts[ref]:
self.add(f"count.boost.{v}", "*", origin, (row.boosts or 0) >= boosts[ref], ref, f">={boosts[ref]}", row.boosts)
counted = boosts[ref] - (bot_boosts[ref] if origin in BOT_RENOTES_UNCOUNTED else 0)
if counted:
self.add(f"count.boost.{v}", "*", origin, (row.boosts or 0) >= counted, ref, f">={counted}", row.boosts)
if votes[ref]:
want = sum(votes[ref].values())
got = sum(row.votes or [])