diff --git a/CLAUDE.md b/CLAUDE.md index 07a60fc..fa428e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/FEDERATION.md b/FEDERATION.md index cf4ce1e..c9d14d1 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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-`, `…/grunts/leave-`). The organiser's server answers `Accept` (Mobilizon at once for an open event) or `Reject`, which the persona sees as diff --git a/PrivaPub.Tests/Federation/FeaturedTests.cs b/PrivaPub.Tests/Federation/FeaturedTests.cs new file mode 100644 index 0000000..a5b41bd --- /dev/null +++ b/PrivaPub.Tests/Federation/FeaturedTests.cs @@ -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"] = $"

{text}

", + ["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> FeaturedBy(RemoteActor actor) => + DB.Default.Find().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().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().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() is "Add" or "Remove").ToList(); + Assert.Equal(new[] { "Add", "Remove" }, sent.Select(a => a["type"]!.GetValue())); + Assert.All(sent, a => Assert.Equal((alice.PostUri(post.ID), alice.Featured), (a["object"]!.GetValue(), a["target"]!.GetValue()))); + } + + [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)); + } + } +} diff --git a/PrivaPub.Tests/Http/MastodonAccountsTests.cs b/PrivaPub.Tests/Http/MastodonAccountsTests.cs index 7191125..832ed6f 100644 --- a/PrivaPub.Tests/Http/MastodonAccountsTests.cs +++ b/PrivaPub.Tests/Http/MastodonAccountsTests.cs @@ -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 = $"

{text}

", 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() { diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 1e03382..2647ce5 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -56,6 +56,7 @@ namespace PrivaPub.Tests.Support Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance, Ledger); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.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.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) => diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs index 2ebafcc..609ea50 100644 --- a/PrivaPub.Tests/Support/RemoteActor.cs +++ b/PrivaPub.Tests/Support/RemoteActor.cs @@ -36,6 +36,7 @@ namespace PrivaPub.Tests.Support ["preferredUsername"] = Name, ["inbox"] = Id + "/inbox", ["followers"] = Id + "/followers", + ["featured"] = Id + "/featured", ["publicKey"] = new JsonObject { ["id"] = KeyId, diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index d9fc892..637bb40 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -154,7 +154,12 @@ namespace PrivaPub.Api.Mastodon.Controllers if (Params.Bool("pinned") == true) { if (local == default) - return Json(Array.Empty()); + { + var featured = (await DB.Default.Find().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().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)); diff --git a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs index 617f176..20e91a6 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StatusesController.cs @@ -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 Unpin(string id, CancellationToken token) { - await DB.Default.DeleteAsync(p => p.AvatarId == MyId && p.PostId == id); + var unpinned = await DB.Default.DeleteAsync(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); } diff --git a/PrivaPub/Federation/Actors/AccountCountsJob.cs b/PrivaPub/Federation/Actors/AccountCountsJob.cs index 4575ef3..2f508d9 100644 --- a/PrivaPub/Federation/Actors/AccountCountsJob.cs +++ b/PrivaPub/Federation/Actors/AccountCountsJob.cs @@ -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; } diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index 221606e..4dfbd72 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -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, diff --git a/PrivaPub/Federation/Actors/FeaturedPosts.cs b/PrivaPub/Federation/Actors/FeaturedPosts.cs new file mode 100644 index 0000000..ead2d6b --- /dev/null +++ b/PrivaPub/Federation/Actors/FeaturedPosts.cs @@ -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(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().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(); + 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(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 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> 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 ItemsOf(JsonElement collection) + { + var items = new List(); + 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; + } + } +} diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index 80580a5..d77a58b 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -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) diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index 5232248..e7041c7 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -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)) as IEnumerable) + ?.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; diff --git a/PrivaPub/Federation/Inbox/Handlers/FeaturedHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FeaturedHandler.cs new file mode 100644 index 0000000..4b8cebc --- /dev/null +++ b/PrivaPub/Federation/Inbox/Handlers/FeaturedHandler.cs @@ -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); + } +} diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs index 73e192a..bc1af60 100644 --- a/PrivaPub/Federation/Outbox/OutboxPublisher.cs +++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs @@ -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); diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index a0d8179..5e674bc 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -121,7 +121,8 @@ namespace PrivaPub.Infrastructure.Data (() => DB.Default.Index().Key(m => m.AvatarId, KeyType.Ascending).Key(m => m.TargetActorURI, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "mute"), (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.Domain, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "domain block"), (() => DB.Default.Index().Key(b => b.AvatarId, KeyType.Ascending).Key(b => b.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "bookmark"), - (() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin") + (() => DB.Default.Index().Key(p => p.AvatarId, KeyType.Ascending).Key(p => p.PostId, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token), "pin"), + (() => DB.Default.Index().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(token, b => b.TargetActorURI); diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 24227a0..5cb150e 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -94,6 +94,9 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton(services => new FeaturedHandler(services.GetRequiredService(), add: true)) + .AddSingleton(services => new FeaturedHandler(services.GetRequiredService(), add: false)) .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Social/Relationships.cs b/PrivaPub/Models/Social/Relationships.cs index fd463ed..e21b167 100644 --- a/PrivaPub/Models/Social/Relationships.cs +++ b/PrivaPub/Models/Social/Relationships.cs @@ -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; } diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index e9cf1b5..6d9c35f 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -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 AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is public string PictureURL { get; set; }//icon diff --git a/docs/INTEROP.md b/docs/INTEROP.md index f48660a..37d5e64 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -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 | — | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7580463..664ff0c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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`; diff --git a/tools/pasture/scenarios/pins.sh b/tools/pasture/scenarios/pins.sh new file mode 100644 index 0000000..9aa4594 --- /dev/null +++ b/tools/pasture/scenarios/pins.sh @@ -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))" diff --git a/tools/pasture/town/check.py b/tools/pasture/town/check.py index cf6ea68..1ee7e19 100644 --- a/tools/pasture/town/check.py +++ b/tools/pasture/town/check.py @@ -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 [])