diff --git a/FEDERATION.md b/FEDERATION.md index 4132e64..17886c3 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -167,7 +167,12 @@ A group is either a **community** or a **circle**. and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken from the post's own server only. A lock (`Announce{Lock}`, or `commentsEnabled: false` on the post) refuses replies, ours included, until `Undo{Lock}`. A ban of a persona (`Announce{Block}` with the community as `target`) shows as - `blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. + `blocked_by` on the community and refuses the persona's posts and replies there until the `Undo`. A post moved to + another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes + there with its thread; a move into a community this server hosts is not taken. +- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is + their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in + turn. A feed cannot be followed from here yet; its communities can. ## Activities diff --git a/PrivaPub.Tests/Federation/InboxGapTests.cs b/PrivaPub.Tests/Federation/InboxGapTests.cs index 6836f56..39a85e4 100644 --- a/PrivaPub.Tests/Federation/InboxGapTests.cs +++ b/PrivaPub.Tests/Federation/InboxGapTests.cs @@ -412,6 +412,48 @@ namespace PrivaPub.Tests.Federation Assert.Equal(0, (await DB.Default.Find().OneAsync(reply.ID, token)).FavouritesCount); } + // PieFed moves a post to another community, and the community it leaves relays the Move: the post's thread goes with it + [Fact] + public async Task A_post_moved_to_another_community_takes_its_thread_along() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var community = new RemoteActor(_harness.Peer, "cats", type: "Group"); + var other = new RemoteActor(_harness.Peer, "dogs", type: "Group"); + var poster = new RemoteActor(_harness.Peer, "poster"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = community.Id }, token); + await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + async Task Announced(JsonObject note) + { + note["audience"] = community.Id; + _harness.Peer.Serve(new Uri(IdOf(note)).AbsolutePath, note.ToJsonString()); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", Create(poster, note))); + return await DB.Default.Find().Match(p => p.ObjectURI == IdOf(note)).ExecuteSingleAsync(token); + } + var page = PublicNote(poster, "

misplaced

", community.Id); + page["type"] = "Page"; + page["name"] = "misplaced"; + var post = await Announced(page); + var comment = PublicNote(poster, "

a comment

", community.Id); + comment["inReplyTo"] = IdOf(page); + var answer = await Announced(comment); + var move = new JsonObject + { + ["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = community.Id, ["target"] = other.Id + }; + + // only the community it leaves speaks for it + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", new JsonObject + { + ["id"] = NewId(poster, "moves"), ["type"] = "Move", ["actor"] = poster.Id, ["object"] = post.ObjectURI, ["origin"] = other.Id, ["target"] = community.Id + })); + Assert.Equal(("dropped", "unsupported"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + await _harness.Deliver(community, "/human-centipede", Activity(community, "Announce", move)); + Assert.Equal(("accepted", "moved"), (Processed("Announce").Outcome, Processed("Announce").Reason)); + Assert.Equal(other.Id, (await DB.Default.Find().OneAsync(post.ID, token)).AudienceURI); + Assert.Equal(other.Id, (await DB.Default.Find().OneAsync(answer.ID, token)).AudienceURI); + } + // Lemmy's moderators lock a community's post and ban members, and the community relays both inside its Announce // (G-0006): a locked post takes no reply, a banned persona posts nothing there, until the Undo [Fact] diff --git a/PrivaPub.Tests/Http/MastodonFeedTests.cs b/PrivaPub.Tests/Http/MastodonFeedTests.cs new file mode 100644 index 0000000..3ea369e --- /dev/null +++ b/PrivaPub.Tests/Http/MastodonFeedTests.cs @@ -0,0 +1,67 @@ +using PrivaPub.Federation.Actors; +using PrivaPub.Models.Jobs; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Http +{ + // Lemmy 1.0's multi-communities and PieFed's feeds are `Feed` actors whose `following` is their communities + [Trait("Category", "Integration")] + public sealed class MastodonFeedTests : IAsyncLifetime + { + PrivaPubHost _host; + Peer _peer; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + _peer = await Peer.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_peer != default) + await _peer.DisposeAsync(); + } + + [Fact] + public async Task A_feed_shows_as_an_account_whose_following_is_its_communities() + { + var token = TestContext.Current.CancellationToken; + var reader = await _host.Mastodon("feeder"); + var cats = new RemoteActor(_peer, "cats", type: "Group"); + var dogs = new RemoteActor(_peer, "dogs", type: "Group"); + var name = $"feed{Guid.NewGuid():N}"[..12]; + var feed = $"{_peer.A}/m/{name}"; + // as Lemmy serves one: the instance's inbox and key, its text in `description` + _peer.Serve($"/m/{name}", new JsonObject + { + ["@context"] = new JsonArray("https://join-lemmy.org/context.json", "https://www.w3.org/ns/activitystreams"), + ["type"] = "Feed", ["id"] = feed, ["inbox"] = $"{_peer.A}/inbox", + ["publicKey"] = new JsonObject { ["id"] = $"{_peer.A}/#main-key", ["owner"] = $"{_peer.A}/", ["publicKeyPem"] = "-----BEGIN PUBLIC KEY-----\n-----END PUBLIC KEY-----\n" }, + ["following"] = $"{feed}/following", ["preferredUsername"] = name, ["name"] = "Pets", ["description"] = "every pet", + ["attributedTo"] = $"{_peer.A}/users/owner" + }.ToJsonString()); + _peer.Serve($"/m/{name}/following", new JsonObject + { + ["type"] = "Collection", ["id"] = $"{feed}/following", ["totalItems"] = 2, ["items"] = new JsonArray(cats.Id, dogs.Id) + }.ToJsonString()); + + var account = await _host.Get().GetActor(feed, refresh: true, token); + Assert.NotNull(account); + await _host.Run(j => j.Kind == JobKind.CountAccount && j.Payload == feed, token); + + var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body; + Assert.Equal(("Pets", 2), (shown.Text("display_name"), shown.Number("following_count"))); + Assert.Contains("every pet", shown.Text("note")); + var communities = (await reader.Client.Get($"/api/v1/accounts/{account.ID}/following")).Ok().Array; + Assert.Equal(new[] { cats.Id, dogs.Id }, communities.Select(c => c.Text("uri"))); + Assert.All(communities, c => Assert.True(c!["group"]!.GetValue())); + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await reader.Client.Post($"/api/v1/accounts/{account.ID}/follow")).Status); + } + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 5577713..f1e565d 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -253,7 +253,10 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/accounts/{id}/following"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Following(string id, CancellationToken token) { - var (local, _) = await Find(id, token); + var (local, remote) = await Find(id, token); + // a feed's following is what it is: its communities, as its server lists them + if (remote is { AvatarType: AvatarType.Feed }) + return Json(await AccountsFor(remote.FeedCommunities.Take(Limit(40, 80)), token)); if (local == default || local.Id != MyId) return Json(Array.Empty()); var following = await Page.From(Params, Limit(40, 80)) @@ -269,6 +272,8 @@ namespace PrivaPub.Api.Mastodon.Controllers var (local, remote) = await Find(id, token); if (local == default && remote == default) return NotFoundError(); + if (remote is { AvatarType: AvatarType.Feed }) + return Error(StatusCodes.Status422UnprocessableEntity, "A feed is followed through its communities"); var following = await _follows.FollowAs(Me, local?.Uri ?? remote.ActorURI, Params.Bool("reblogs") != false, token); return following == default ? Error(StatusCodes.Status403Forbidden, "This action is not allowed") : Json(await Relationship(id, token)); } diff --git a/PrivaPub/Federation/Actors/AccountCountsJob.cs b/PrivaPub/Federation/Actors/AccountCountsJob.cs index 2f508d9..0d001a1 100644 --- a/PrivaPub/Federation/Actors/AccountCountsJob.cs +++ b/PrivaPub/Federation/Actors/AccountCountsJob.cs @@ -12,18 +12,23 @@ 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), 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. + // GoToSocial do the same. A feed's following is its communities: they are kept, and read in turn, so its list shows. public class AccountCountsJob : IJobHandler { readonly IRemoteActorService _remoteActors; readonly DbEntities _dbEntities; readonly IFeaturedPosts _featured; + readonly ILocalActorService _localActors; - public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default) + public const int MaxFeedCommunities = 50; + + public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities, IFeaturedPosts featured = default, + ILocalActorService localActors = default) { _remoteActors = remoteActors; _dbEntities = dbEntities; _featured = featured; + _localActors = localActors; } public JobKind Kind => JobKind.CountAccount; @@ -38,28 +43,52 @@ namespace PrivaPub.Federation.Actors var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token); if (actor == default) return JobOutcome.Done; - await DB.Default.Update().MatchID(actor.ID) - .Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token)) - .Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token)) - .Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token)) - .Modify(a => a.CountedAt, DateTime.UtcNow) - .ExecuteAsync(token); + var feed = actor.AvatarType == AvatarType.Feed; + var (following, communities) = await Read(actor, actor.FollowingURL, feed, token); + var update = DB.Default.Update().MatchID(actor.ID) + .Modify(a => a.FollowersCount, (await Read(actor, actor.FollowersURL, false, token)).Total) + .Modify(a => a.FollowingCount, following) + .Modify(a => a.StatusesCount, (await Read(actor, actor.OutboxURL, false, token)).Total) + .Modify(a => a.CountedAt, DateTime.UtcNow); + if (feed) + update = update.Modify(a => a.FeedCommunities, communities); + await update.ExecuteAsync(token); + if (feed) + foreach (var community in communities) + if (_localActors == default || !community.StartsWith(_localActors.BaseAddress + "/", StringComparison.Ordinal)) + await _remoteActors.GetActor(community, refresh: false, token); if (_featured != default) await _featured.Sync(actor, token); return JobOutcome.Done; } - // only from the actor's own server, as everything we believe about it - async Task Total(ForeignAvatar actor, string collection, CancellationToken token) + // only from the actor's own server, as everything we believe about it: its total, and the ids it lists when asked + // (inline, or on its first page) + async Task<(int? Total, List Items)> Read(ForeignAvatar actor, string collection, bool items, CancellationToken token) { + var listed = new List(); if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI)) - return default; + return (default, listed); using var scope = HttpScope.For("collection"); using var fetched = await _remoteActors.FetchObject(collection, token); - return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object - && fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0 - ? count - : default; + if (fetched == default || fetched.Root.ValueKind != System.Text.Json.JsonValueKind.Object) + return (default, listed); + int? total = fetched.Root.TryGetProperty("totalItems", out var counted) && counted.TryGetInt32(out var count) && count >= 0 ? count : default; + if (!items) + return (total, listed); + var document = System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText()); + var page = document["orderedItems"] ?? document["items"] ?? document["first"]?["orderedItems"] ?? document["first"]?["items"]; + if (page == default && ActivityJson.Id(document["first"]) is { } first && Origin.Same(first, actor.ActorURI)) + { + using var next = await _remoteActors.FetchObject(first, token); + var nextPage = next == default ? default : System.Text.Json.Nodes.JsonNode.Parse(next.Root.GetRawText()); + page = nextPage?["orderedItems"] ?? nextPage?["items"]; + } + if (page is System.Text.Json.Nodes.JsonArray array) + listed = array.Select(ActivityJson.Id) + .Where(id => Uri.TryCreate(id, UriKind.Absolute, out var uri) && uri.Scheme is "https" or "http") + .Distinct(StringComparer.Ordinal).Take(MaxFeedCommunities).ToList(); + return (total, listed); } } } diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index 6cea60a..6532753 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -11,7 +11,7 @@ namespace PrivaPub.Federation.Actors public sealed class ActorDocument { - static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization" }; + static readonly string[] ActorTypes = { "Person", "Service", "Application", "Group", "Organization", "Feed" }; public string Id { get; init; } public string Type { get; init; } @@ -74,7 +74,8 @@ namespace PrivaPub.Federation.Actors Features = ActorFeatures.Detect(root), PreferredUsername = RemoteActorService.Text(root, "preferredUsername"), Name = RemoteActorService.Text(root, "name"), - Summary = RemoteActorService.Text(root, "summary"), + // (Lemmy's feed keeps its text in `description`) + Summary = RemoteActorService.Text(root, "summary") ?? (type == "Feed" ? RemoteActorService.Text(root, "description") : default), Url = RemoteActorService.Text(root, "url") ?? id, Inbox = Origin.Same(inbox, id) ? inbox : default, Outbox = RemoteActorService.Text(root, "outbox"), diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index 224308d..246e927 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -59,7 +59,7 @@ namespace PrivaPub.Federation.Inbox.Handlers public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token) { var inner = activity["object"]; - if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock") + if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block" or "Lock" or "Move") { await GroupActivity(inner, actor, token); return; @@ -242,6 +242,9 @@ namespace PrivaPub.Federation.Inbox.Handlers case "Lock" when objectUri != default: await Lock(objectUri, group, true, token); break; + case "Move" when objectUri != default && Id(inner["origin"]) == group.ActorURI: + await Move(objectUri, Id(inner["target"]), group, token); + break; case "Undo" when Value(inner["object"], "type") == "Lock": await Lock(Id(inner["object"]?["object"]), group, false, token); break; @@ -305,6 +308,42 @@ namespace PrivaPub.Federation.Inbox.Handlers Arrival.Accept(locked ? "locked" : "unlocked"); } + // A post moved to another community (PieFed's Move{object: post, origin, target}), relayed by the community it leaves, + // which vouches for what is done to its own posts as for a lock; its thread goes with it. A move into a community we + // host is not taken: it would be ours to announce. + async Task Move(string objectUri, string targetUri, ForeignAvatar group, CancellationToken token) + { + var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue) + .ExecuteFirstAsync(token); + if (post == default) + { + Arrival.Drop("unknown-object"); + return; + } + Arrival.About(visibility: post.Visibility, created: post.CreationDate); + var target = targetUri == default || targetUri == group.ActorURI || await _localActors.FindByUri(targetUri, token) != default + ? default + : await _remoteActors.GetActor(targetUri, refresh: false, token); + if (target is not { AvatarType: AvatarType.Group }) + { + Arrival.Drop("unknown-target"); + return; + } + var thread = new List { post.ID }; + var level = thread.ToList(); + while (level.Count > 0 && thread.Count < MaxMoved) + { + var parents = level; + level = (await _dbEntities.Posts.Match(p => parents.Contains(p.AnsweringToPostId) && p.AudienceURI == group.ActorURI) + .Project(p => new PostEntity { ID = p.ID }).ExecuteAsync(token)).Select(p => p.ID).ToList(); + thread.AddRange(level); + } + await DB.Default.Update().Match(p => thread.Contains(p.ID)).Modify(p => p.AudienceURI, target.ActorURI).ExecuteAsync(token); + Arrival.Accept("moved"); + } + + const int MaxMoved = 1000; + // A community bans one of our personas (Lemmy's Block with the community as its target): kept as the community // blocking the persona, so its relationship says blocked_by and nothing of the persona's is posted there until the // Undo. A ban of someone from another server is no business of ours. diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index 80f4807..fd92ffa 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -78,6 +78,7 @@ namespace PrivaPub.Models.User public string OutboxURL { get; set; } public string FeaturedURL { get; set; }//featured: the posts it pins public List Flairs { get; set; } = new();//a community's labels for its posts + public List FeedCommunities { get; set; } = new();//a feed's communities, its `following` (Lemmy's multi-communities, PieFed's feeds) public List SearchableBy { get; set; }//FEP-268d, when the account states it: outranks IsIndexable public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators public List AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32) @@ -152,7 +153,8 @@ namespace PrivaPub.Models.User Group, Organization, Person, - Service + Service, + Feed } public enum AvatarAccountState diff --git a/docs/INTEROP.md b/docs/INTEROP.md index d2e1e71..1988d72 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -492,7 +492,11 @@ on a Page: pins live in `featured`, locks in `Lock`. - **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0. - **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post thread**. Group threads by the root post, never by comparing `context` strings. -- **Feeds:** multi-communities are 1.0's `type: Feed` actors. +- **Feeds:** multi-communities are 1.0's `type: Feed` actors at `/m/`, with no WebFinger handle: the instance's + shared inbox and the **instance's key** (`owner` is the instance, not the feed), text in `description` (not `summary`), + the creator in `attributedTo`, and `following`, an unpaged `Collection` of the community ids with `totalItems`. + Following one stays on the follower's server, which subscribes to each community itself (checked 2026-10-07 on + 1.0.0-beta.2). **Expects** - **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with @@ -595,8 +599,12 @@ What it showed: - **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s; - flairs in two dialects; - comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities; - - `Move{object: post}` between communities; - - `Feed` actors; + - `Move{object: post, origin, target}` between communities, by the post's author or a moderator of the origin, relayed + as the origin community's `Announce` (only from the web UI; its API has no move); + - `Feed` actors at `/f/` with their own key, inbox, `followers`, `moderators` (the owner) and `following` (the + communities, a `Collection` with `totalItems`); PieFed's handle is `~name@host`. Following one is a `Follow` to the + feed, after which its owner's server sends `Add`/`Remove` of communities, signed by the feed; each member also + subscribes to every community as themselves (`feed_auto_follow`). Its API creates feeds (`POST /api/alpha/feed`); - emoji Likes and `EmojiReact` count as upvotes. - **How PieFed formats what it sends us:** - It **chooses the format by our NodeInfo software name**, so keep that accurate. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d89bf79..3e679de 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -696,7 +696,10 @@ it, raw where it doesn't. server or its moderators collection, kept as `ForeignAvatar.ModeratorsURL`; a resolution is kept on our report); - votes in and out; link posts; flairs (**done 2026-10-07**: Lemmy 1.0's `CommunityPostTag` and PieFed's `lemmy:CommunityTag`, on posts and communities, described from the community's list, as `privapub.flairs`); `Feed` - actors; community polls; post `Move`; + actors (**read 2026-10-07**: Lemmy 1.0's and PieFed's feeds resolve as accounts whose `/following` is their + communities, kept from the feed's `following` once a day; following one is refused for now, **owner decision + pending** on how); community polls; post `Move` (**done 2026-10-07**: PieFed's, relayed by the community the post + leaves, moves the post and its thread; checked live); - the outbound shape Lemmy requires; - communities we host announce to the author's own instance too: **done 2026-10-06** (its server's shared inbox, whether anyone there follows the community or not: Lemmy keeps its user's post pending until it is announced back);