From a5d9a8944592c868204755eb8af5bef6073b4ea8 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 12:30:57 +0200 Subject: [PATCH] Communities follow FEP-1b12, circles federate to their members only Communities: - a post addressed to a community (to, cc or audience) is accepted according to its posting policy - followers, anyone, or moderators - and GroupDistributor announces the whole activity with `audience` to the community's followers, plus the object for new posts so Mastodon shows them; updates and deletes of community content are announced too; - top-level posts are Pages with a name (the title, or a headline from the text); /flock counts members, /wardens lists moderators; - a Mastodon client posts into a community by mentioning it, or into a remote group, which sets `audience`; - an Announce of an activity from a remote group a persona follows (Lemmy) is followed through: the object is fetched from its own origin, kept with its AudienceURI, and fanned out to the group's local followers; updates are applied in place and deletes checked against the origin. Circles stop being local-only: an undiscoverable Group actor whose follows are all requests the owner approves; posts addressed to the circle and its /flock and delivered to members' own inboxes, never announced, never public; a remote member's post into the circle is accepted from members only. SignedFetchAuthorizer serves circle posts and collections only to a signed request from a member or a member server's instance actor - 404 for anyone else. Circles never surface in search, lookups, mentions, account ids or profile pages. Federation:SecureMode requires a valid signature on every GET under /peasants except the instance actor. Group forms take a posting policy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- CLAUDE.md | 9 +- FEDERATION.md | 20 +- .../Group/InsertGroupForm.cs | 1 + .../Group/UpdateGroupForm.cs | 1 + PrivaPub.ClientModels/Group/ViewGroup.cs | 1 + PrivaPub.Tests/Federation/GroupTests.cs | 198 ++++++++++++++++++ .../Federation/InboxScenarioTests.cs | 17 +- PrivaPub.Tests/Support/Harness.cs | 12 +- PrivaPub.Tests/Support/RemoteActor.cs | 22 +- .../Controllers/AccountsController.cs | 4 +- .../Mastodon/Controllers/SearchController.cs | 2 +- .../Api/Mastodon/Mappers/MastodonMapper.cs | 3 +- PrivaPub/Domain/Content/ContentRenderer.cs | 2 +- PrivaPub/Domain/Social/FollowService.cs | 2 +- PrivaPub/Domain/Statuses/StatusService.cs | 53 ++++- PrivaPub/Domain/Timelines/Fanout.cs | 3 + .../Federation/Actors/LocalActorService.cs | 11 +- .../Controllers/PeasantsController.cs | 74 ++++++- .../Inbox/Handlers/AnnounceHandler.cs | 67 +++++- .../Inbox/Handlers/CreateHandler.cs | 38 +++- .../Inbox/Handlers/DeleteHandler.cs | 7 +- .../Inbox/Handlers/UpdateHandler.cs | 7 +- .../Federation/Outbox/GroupDistributor.cs | 48 +++++ PrivaPub/Federation/Outbox/OutboxPublisher.cs | 23 +- .../Rendering/ActivityPubRenderer.cs | 17 +- .../Signing/SignedFetchAuthorizer.cs | 40 ++++ .../Infrastructure/Http/FederationOptions.cs | 1 + .../Middleware/SocialPubConfigurations.cs | 3 + PrivaPub/Models/Group/Group.cs | 9 + PrivaPub/Models/Post/Post.cs | 1 + PrivaPub/Services/GroupUsersService.cs | 12 ++ PrivaPub/Web/Pages/Pages.cs | 2 +- 32 files changed, 652 insertions(+), 58 deletions(-) create mode 100644 PrivaPub.Tests/Federation/GroupTests.cs create mode 100644 PrivaPub/Federation/Outbox/GroupDistributor.cs create mode 100644 PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs diff --git a/CLAUDE.md b/CLAUDE.md index dd71a04..4663ddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,8 +166,13 @@ cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Produ - the shared inbox is advertised in `endpoints.sharedInbox`. 7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown, `ContentFormat` says what `Text` holds. Remote names are plain text. -8. **Circles never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are - `IsLocalOnly`. Only communities are Group actors. +8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner + approves); its posts are addressed to the circle and its `/flock`, delivered to members' personal inboxes, never + announced, and served only to a signed request from a member or a member's instance actor + (`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages. + **Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts, + for Mastodon), top-level posts are `Page`s with a `name`, posting follows `Group.PostingPolicy`. + Located posts (`LocalGeo`) are the only local-only posts. 9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote `context` decides nothing. DMs are `Post`s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy). 10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`): diff --git a/FEDERATION.md b/FEDERATION.md index 8199645..2b5be0d 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -47,10 +47,18 @@ The names are the project's own and are stable; resolve actors through WebFinger A group is either a **community** or a **circle**. -- A community is a `Group` actor. It accepts `Follow` and re-shares (`Announce`) posts from its followers that address - it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned. -- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never - delivered. +- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts + addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or + moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is + also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A + top-level post is a `Page` with a `name`. Members are counted at `/flock`; moderators are listed at `/wardens`, which + `attributedTo` does not yet point to. A mention of a community posts into it. +- A **circle** is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the + circle and its members collection, delivered to each member's own inbox and never announced. They are served only + to a signed request from a member, or from the instance actor of a member's server; anyone else gets 404. A + Mastodon member's replies reach only the people they mention. +- Announces from **remote** groups (Lemmy communities) are followed through to the activity: the object is fetched + from its own origin, never taken from the announce. ## Activities @@ -85,6 +93,10 @@ tags. A post's title becomes `name` and is also the first, bold line of `content Visibility is expressed in `to`/`cc` the way Mastodon does it: public, unlisted, followers-only and direct. Inbound followers-only posts are recognised by the author's own `followers` collection. +## Local-only posts + +Posts with a location (shown to nearby users of this server) never leave the server, in any form. + ## Security rules a peer will notice - **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The diff --git a/PrivaPub.ClientModels/Group/InsertGroupForm.cs b/PrivaPub.ClientModels/Group/InsertGroupForm.cs index 5a610b7..95e7a35 100644 --- a/PrivaPub.ClientModels/Group/InsertGroupForm.cs +++ b/PrivaPub.ClientModels/Group/InsertGroupForm.cs @@ -25,6 +25,7 @@ namespace PrivaPub.ClientModels.Group public string InvitationPassword { get; set; } public bool IsCommunity { get; set; } + public string PostingPolicy { get; set; }//followers (default), anyone, moderators public bool IsDiscoverable { get; set; } = true; public bool ManuallyApprovesMembers { get; set; } } diff --git a/PrivaPub.ClientModels/Group/UpdateGroupForm.cs b/PrivaPub.ClientModels/Group/UpdateGroupForm.cs index cf3449a..836a532 100644 --- a/PrivaPub.ClientModels/Group/UpdateGroupForm.cs +++ b/PrivaPub.ClientModels/Group/UpdateGroupForm.cs @@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group public bool RemoveInvitationPassword { get; set; } public bool RegenerateInvitationCode { get; set; } public bool? IsDiscoverable { get; set; } + public string PostingPolicy { get; set; } public bool? ManuallyApprovesMembers { get; set; } } } diff --git a/PrivaPub.ClientModels/Group/ViewGroup.cs b/PrivaPub.ClientModels/Group/ViewGroup.cs index 6a7ac35..5896454 100644 --- a/PrivaPub.ClientModels/Group/ViewGroup.cs +++ b/PrivaPub.ClientModels/Group/ViewGroup.cs @@ -9,6 +9,7 @@ namespace PrivaPub.ClientModels.Group public string Url { get; set; } public string Handle { get; set; } public bool IsCommunity { get; set; } + public string PostingPolicy { get; set; } public bool IsDiscoverable { get; set; } public bool ManuallyApprovesMembers { get; set; } public bool IsOwner { get; set; } diff --git a/PrivaPub.Tests/Federation/GroupTests.cs b/PrivaPub.Tests/Federation/GroupTests.cs new file mode 100644 index 0000000..c7283fc --- /dev/null +++ b/PrivaPub.Tests/Federation/GroupTests.cs @@ -0,0 +1,198 @@ +using MongoDB.Entities; + +using PrivaPub.ClientModels.Social; +using PrivaPub.Domain.Statuses; +using PrivaPub.Federation.Objects; +using PrivaPub.Federation.Rendering; +using PrivaPub.Federation.Signing; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Group; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +using GroupEntity = PrivaPub.Models.Group.Group; + +namespace PrivaPub.Tests.Federation +{ + [Trait("Category", "Integration")] + public sealed class GroupTests : 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(); + } + + async Task<(GroupEntity Entity, PrivaPub.Federation.Actors.LocalActor Actor)> Group(GroupKind kind, PostingPolicy policy, string ownerId, params string[] remoteMembers) + { + var (privateKey, publicKey) = PrivaPub.Federation.Actors.Keys.NewKeyPair(); + var group = new GroupEntity + { + UserName = $"{kind}{Guid.NewGuid():N}"[..20].ToLowerInvariant(), + Kind = kind, + PostingPolicy = policy, + PrivateKey = privateKey, + PublicKey = publicKey, + Members = new() { new GroupMember { AvatarId = ownerId, Role = GroupRole.Owner } } + }; + group.Members.AddRange(remoteMembers.Select(m => new GroupMember { AvatarId = m, IsForeign = true })); + await DB.Default.SaveAsync(group); + return (group, _harness.Local.FromGroup(group)); + } + + static string Origin(RemoteActor actor) => new Uri(actor.Id).GetLeftPart(UriPartial.Authority); + + static JsonObject Create(RemoteActor author, IEnumerable to, string audience = default) + { + var note = new JsonObject + { + ["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}", + ["type"] = "Note", + ["attributedTo"] = author.Id, + ["content"] = "

to the group

", + ["to"] = new JsonArray(to.Select(t => (JsonNode)t).ToArray()) + }; + if (audience != default) + note["audience"] = audience; + return new JsonObject { ["id"] = $"{Origin(author)}/activities/{Guid.NewGuid():N}", ["type"] = "Create", ["actor"] = author.Id, ["object"] = note }; + } + + [Fact] + public async Task A_follower_posting_to_a_community_is_announced_activity_and_object() + { + var token = TestContext.Current.CancellationToken; + var (_, owner) = await _harness.Persona("owner"); + var (_, community) = await Group(GroupKind.Community, PostingPolicy.Followers, owner.Id); + var lemmy = new RemoteActor(_harness.Peer, "lemmy"); + var stranger = new RemoteActor(_harness.Peer, "stranger"); + await _harness.FollowedBy(community, lemmy); + + await _harness.Deliver(lemmy, "/human-centipede", Create(lemmy, new[] { community.Uri, Addressing.Public }, community.Uri)); + await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public }, community.Uri)); + + Assert.True(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token)); + var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue() == community.Uri).ToList(); + Assert.Equal(2, announces.Count); + Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue() == "Create" && a["audience"]!.GetValue() == community.Uri); + Assert.Contains(announces, a => a["object"] is JsonValue); + } + + [Fact] + public async Task An_open_community_takes_posts_from_anyone() + { + var token = TestContext.Current.CancellationToken; + var (_, owner) = await _harness.Persona("owner"); + var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, owner.Id); + var stranger = new RemoteActor(_harness.Peer, "stranger"); + + await _harness.Deliver(stranger, "/human-centipede", Create(stranger, new[] { community.Uri, Addressing.Public })); + + Assert.True(await DB.Default.Find().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task Mentioning_a_community_posts_into_it_as_a_titled_page() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var (_, community) = await Group(GroupKind.Community, PostingPolicy.Anyone, alice.Id); + var follower = new RemoteActor(_harness.Peer, "follower"); + await _harness.FollowedBy(community, follower); + + var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = $"@{community.UserName} a new thread", PlainText = true }, token); + + Assert.Equal(community.Id, outcome.Post.GroupId); + var note = ActivityPubRenderer.Note(outcome.Post, alice, community, default); + Assert.Equal("Page", note["type"]!.GetValue()); + Assert.Equal(community.Uri, note["audience"]!.GetValue()); + Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue())); + Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue() == "Announce"); + } + + [Fact] + public async Task A_circle_post_goes_only_to_members_and_only_members_may_read_it() + { + var token = TestContext.Current.CancellationToken; + var (_, alice) = await _harness.Persona("alice"); + var member = new RemoteActor(_harness.Peer, "member"); + var outsider = new RemoteActor(_harness.Peer, "outsider"); + var (circleEntity, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id); + await _harness.Remote.GetActor(member.Id, refresh: false, token); + var follower = new RemoteActor(_harness.Peer, "follower"); + await _harness.FollowedBy(alice, follower); + + var outcome = await _harness.Statuses.Publish(alice, new StatusDraft { Text = "just us", GroupId = circle.Id }, token); + + Assert.Equal(PostVisibility.Circle, outcome.Post.Visibility); + var create = Assert.Single(await _harness.Outgoing(member.Id + "/inbox")); + Assert.Equal(new[] { circle.Uri, circle.Flock }, create["object"]!["to"]!.AsArray().Select(t => t!.GetValue())); + Assert.DoesNotContain(Addressing.Public, create.ToJsonString()); + Assert.Empty(await _harness.Outgoing(follower.SharedInbox)); + Assert.Empty((await _harness.Outgoing(member.SharedInbox)).Where(a => a["type"]!.GetValue() == "Announce")); + + var authorizer = new SignedFetchAuthorizer(_harness.Remote); + var path = new Uri(outcome.Post.ObjectURI).AbsolutePath; + var asMember = await authorizer.Requester(member.Get(Harness.Host, path), token); + var asOutsider = await authorizer.Requester(outsider.Get(Harness.Host, path), token); + Assert.True(SignedFetchAuthorizer.MayReadCircle(circleEntity, asMember)); + Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, asOutsider)); + Assert.False(SignedFetchAuthorizer.MayReadCircle(circleEntity, default)); + Assert.True(circle.IsCircle); + Assert.False(circle.Discoverable); + } + + [Fact] + public async Task Only_circle_members_can_post_into_a_circle() + { + var token = TestContext.Current.CancellationToken; + var (aliceRoot, alice) = await _harness.Persona("alice"); + var member = new RemoteActor(_harness.Peer, "member"); + var outsider = new RemoteActor(_harness.Peer, "outsider"); + var (_, circle) = await Group(GroupKind.Circle, PostingPolicy.Followers, alice.Id, member.Id); + + await _harness.Deliver(member, "/human-centipede", Create(member, new[] { circle.Uri, circle.Flock })); + await _harness.Deliver(outsider, "/human-centipede", Create(outsider, new[] { circle.Uri, circle.Flock })); + + var stored = await DB.Default.Find().Match(p => p.GroupId == circle.Id).ExecuteAsync(token); + Assert.Equal(member.Id, Assert.Single(stored).ActorURI); + Assert.Equal(PostVisibility.Circle, stored[0].Visibility); + Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == stored[0].ID).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_followed_remote_community_announcing_a_post_puts_it_in_home() + { + var token = TestContext.Current.CancellationToken; + var (root, alice) = await _harness.Persona("alice"); + var lemmyCommunity = new RemoteActor(_harness.Peer, "cats", type: "Group"); + var poster = new RemoteActor(_harness.Peer, "poster"); + await _harness.Follows.Follow(root, new FollowForm { AvatarId = alice.Id, Target = lemmyCommunity.Id }, token); + await DB.Default.Update().Match(f => f.AvatarId == alice.Id).Modify(f => f.State, FollowState.Accepted).ExecuteAsync(token); + var create = Create(poster, new[] { lemmyCommunity.Id, Addressing.Public }, lemmyCommunity.Id); + var noteId = create["object"]!["id"]!.GetValue(); + _harness.Peer.Serve(new Uri(noteId).AbsolutePath, create["object"]!.ToJsonString()); + + await _harness.Deliver(lemmyCommunity, "/human-centipede", new JsonObject + { + ["id"] = $"{Origin(lemmyCommunity)}/activities/announce/{Guid.NewGuid():N}", ["type"] = "Announce", ["actor"] = lemmyCommunity.Id, + ["to"] = new JsonArray(Addressing.Public), ["object"] = create + }); + + var post = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token); + Assert.Equal(lemmyCommunity.Id, post.AudienceURI); + Assert.True(await DB.Default.Find().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token)); + } + } +} diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs index b6145d6..9b6733c 100644 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -54,9 +54,9 @@ namespace PrivaPub.Tests.Federation { new FollowHandler(db, _local, remote, delivery), new UndoHandler(db, _local), - new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)), - new DeleteHandler(db, _local, remote, delivery), - new UpdateHandler(db, _local, remote) + new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue), new GroupDistributor(delivery)), + new DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)), + new UpdateHandler(db, _local, remote, new GroupDistributor(delivery)) }, NullLogger.Instance); } @@ -323,7 +323,7 @@ namespace PrivaPub.Tests.Federation } [Fact] - public async Task A_circle_is_not_a_federated_actor() + public async Task A_circle_only_takes_follow_requests() { var token = TestContext.Current.CancellationToken; var (privateKey, publicKey) = Keys.NewKeyPair(); @@ -339,8 +339,13 @@ namespace PrivaPub.Tests.Federation ["object"] = actor.Uri }; - Assert.False(actor.IsFederated); - Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode); + Assert.True(actor.IsCircle); + Assert.True(actor.ManuallyApprovesFollowers); + Assert.False(actor.Discoverable); + Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode); + var request = await DB.Default.Find().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token); + Assert.False(request.IsAccepted); + Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id); } } } diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 42824b0..202f51a 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -41,6 +41,7 @@ namespace PrivaPub.Tests.Support Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db); Delivery = new DeliveryService(Db, Queue); Fanout = new Fanout(Db); + Groups = new GroupDistributor(Delivery); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance); Processor = new InboxProcessor(Remote, new IActivityHandler[] @@ -50,10 +51,10 @@ namespace PrivaPub.Tests.Support new RejectHandler(Db, Local), new UndoHandler(Db, Local), new LikeHandler(Db), - new AnnounceHandler(Db, Local, RemotePosts, Fanout), - new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts), - new DeleteHandler(Db, Local, Remote, Delivery), - new UpdateHandler(Db, Local, Remote), + new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote), + new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups), + new DeleteHandler(Db, Local, Remote, Delivery, Groups), + new UpdateHandler(Db, Local, Remote, Groups), new FlagHandler(Db, Local) }, NullLogger.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); @@ -61,7 +62,7 @@ namespace PrivaPub.Tests.Support Outbox = new OutboxPublisher(Db, Local, Delivery); Media = new MediaService(new StaticOptions(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), Local, default, NullLogger.Instance); - Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media); + Statuses = new StatusService(Db, Local, Remote, Delivery, Content, Outbox, Fanout, Media, Groups); Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer(), NullLogger.Instance); Timelines = new TimelineService(Db, new KeyLocalizer()); Relationships = new RelationshipService(Db, Follows, Delivery); @@ -82,6 +83,7 @@ namespace PrivaPub.Tests.Support public PostsService Posts { get; } public StatusService Statuses { get; } public MediaService Media { get; } + public GroupDistributor Groups { get; } public Fanout Fanout { get; } public RemotePosts RemotePosts { get; } public TimelineService Timelines { get; } diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs index 991eaef..ef87494 100644 --- a/PrivaPub.Tests/Support/RemoteActor.cs +++ b/PrivaPub.Tests/Support/RemoteActor.cs @@ -14,13 +14,16 @@ namespace PrivaPub.Tests.Support { readonly RSA _key = RSA.Create(2048); - public RemoteActor(Peer peer, string name, string origin = default) + public RemoteActor(Peer peer, string name, string origin = default, string type = "Person") { Name = $"{name}{Guid.NewGuid():N}"[..20]; Id = $"{origin ?? peer.A}/users/{Name}"; + Type = type; peer.Serve($"/users/{Name}", Document().ToJsonString()); } + public string Type { get; } + public string Name { get; } public string Id { get; } public string KeyId => Id + "#main-key"; @@ -29,7 +32,7 @@ namespace PrivaPub.Tests.Support public JsonObject Document() => new() { ["id"] = Id, - ["type"] = "Person", + ["type"] = Type, ["preferredUsername"] = Name, ["inbox"] = Id + "/inbox", ["followers"] = Id + "/followers", @@ -41,6 +44,21 @@ namespace PrivaPub.Tests.Support } }; + public HttpRequest Get(string host, string path) + { + var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); + var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}"; + var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + var context = new DefaultHttpContext(); + context.Request.Method = "GET"; + context.Request.Host = new HostString(host); + context.Request.Path = path; + context.Features.Get().RawTarget = path; + context.Request.Headers["Date"] = date; + context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\""; + return context.Request; + } + public HttpRequest Post(string host, string path, JsonNode activity) { var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 25d8cbd..fac50f5 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -114,7 +114,7 @@ namespace PrivaPub.Api.Mastodon.Controllers if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) { var local = await _localActors.FindByUserName(parts[0], token); - return local is { IsFederated: true, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError(); + return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? Json(await _mapper.Local(local, false, token)) : NotFoundError(); } var userName = parts[0]; var domain = parts[1].ToLowerInvariant(); @@ -365,7 +365,7 @@ namespace PrivaPub.Api.Mastodon.Controllers if (avatar is { DeletionAt: null }) return (_localActors.FromAvatar(avatar), default); var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token); - if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true } community) + if (group is { DeletionAt: null } && _localActors.FromGroup(group) is { IsFederated: true, IsCircle: false } community) return (community, default); return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token)); } diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index cf319d4..c6d9ad6 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -56,7 +56,7 @@ namespace PrivaPub.Api.Mastodon.Controllers { var local = await _localActors.FindByUri(q, token); var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default; - if (local is { IsFederated: true }) + if (local is { IsFederated: true, IsCircle: false }) results.Accounts.Add(await _mapper.Local(local, false, token)); else if (foreign != default) results.Accounts.Add(_mapper.Foreign(foreign)); diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index b9193a0..1149f3d 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -6,6 +6,7 @@ using PrivaPub.Domain.Media; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Models.Federation; +using PrivaPub.Models.Group; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; @@ -124,7 +125,7 @@ namespace PrivaPub.Api.Mastodon.Mappers foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)) accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token); - foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue).ExecuteAsync(token)) + foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token)) accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token); foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token)) accounts[foreign.ID] = Foreign(foreign); diff --git a/PrivaPub/Domain/Content/ContentRenderer.cs b/PrivaPub/Domain/Content/ContentRenderer.cs index 30b784c..66b65e7 100644 --- a/PrivaPub/Domain/Content/ContentRenderer.cs +++ b/PrivaPub/Domain/Content/ContentRenderer.cs @@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) { var local = await _localActors.FindByUserName(parts[0], token); - return local is { IsFederated: true, Kind: not LocalActorKind.Application } + return local is { IsFederated: true, IsCircle: false, Kind: not LocalActorKind.Application } ? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox) : default; } diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index 6d575f9..b8a6dc6 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -95,7 +95,7 @@ namespace PrivaPub.Domain.Social var (local, remote) = await ResolveTarget(target, token); if (local == default && remote == default) return default; - if (local != default && (local.Id == follower.Id || !local.IsFederated || local.Kind == LocalActorKind.Application)) + if (local != default && (local.Id == follower.Id || !local.IsFederated || local.IsCircle || local.Kind == LocalActorKind.Application)) return default; var targetUri = local?.Uri ?? remote.ActorURI; diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 733609f..92255c3 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -68,11 +68,13 @@ namespace PrivaPub.Domain.Statuses readonly IOutboxPublisher _outbox; readonly IFanout _fanout; readonly IMediaService _media; + readonly IGroupDistributor _groups; public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, - IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media) + IContentRenderer content, IOutboxPublisher outbox, IFanout fanout, IMediaService media, IGroupDistributor groups) { _media = media; + _groups = groups; _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; @@ -94,7 +96,7 @@ namespace PrivaPub.Domain.Statuses if (!string.IsNullOrEmpty(draft.GroupId)) { var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token); - if (groupEntity == default || groupEntity.DeletionAt.HasValue || !groupEntity.Members.Any(m => !m.IsForeign && m.AvatarId == author.Id)) + if (groupEntity == default || groupEntity.DeletionAt.HasValue || !await MayPost(groupEntity, author, token)) return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found"); group = _localActors.FromGroup(groupEntity); } @@ -111,9 +113,28 @@ namespace PrivaPub.Domain.Statuses return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A located post needs a valid position and no group or recipients"); var rendered = draft.PlainText ? await _content.PlainText(draft.Text ?? string.Empty, token) : await _content.Markdown(draft.Text ?? string.Empty, token); - var isLocalOnly = group is { IsFederated: false } || located; + string audienceUri = default; + if (group == default && !located && draft.Visibility is PostVisibility.Public or PostVisibility.Unlisted) + foreach (var mention in rendered.Mentions) + { + if (mention.IsLocal) + { + var mentioned = await _dbEntities.Groups.MatchID(mention.AccountId).ExecuteFirstAsync(token); + if (mentioned is { DeletionAt: null, Kind: GroupKind.Community } && await MayPost(mentioned, author, token)) + { + group = _localActors.FromGroup(mentioned); + break; + } + } + else if (await _dbEntities.ForeignAvatars.MatchID(mention.AccountId).ExecuteFirstAsync(token) is { AvatarType: Models.User.AvatarType.Group } remoteGroup) + { + audienceUri = remoteGroup.ActorURI; + break; + } + } + var isLocalOnly = located; var visibility = located ? PostVisibility.LocalGeo - : isLocalOnly ? PostVisibility.Circle + : group is { IsCircle: true } ? PostVisibility.Circle : draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility; var post = new PostEntity { @@ -131,6 +152,7 @@ namespace PrivaPub.Domain.Statuses Mentions = rendered.Mentions.Select(ToMention).ToList(), Tags = rendered.Tags.ToList(), Media = media.Select(ToPostMedia).ToList(), + AudienceURI = audienceUri, AnsweringToPostId = parent?.ID, InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default), InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, @@ -180,8 +202,8 @@ namespace PrivaPub.Domain.Statuses await _fanout.Distribute(post, token); if (create != default) await _outbox.Publish(author, post, create, token); - if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted) - await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token); + if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted) + await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token); return new StatusOutcome(post); } @@ -247,6 +269,8 @@ namespace PrivaPub.Domain.Statuses ["object"] = note }; await _outbox.Publish(author, post, update, token); + if (group is { IsCircle: false }) + await _groups.Announce(group, update, post.ObjectURI, isNewPost: false, token); } return new StatusOutcome(post); } @@ -277,6 +301,9 @@ namespace PrivaPub.Domain.Statuses var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}", new JsonArray(post.To.Select(t => (JsonNode)t).ToArray()), new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray())); await _delivery.Enqueue(author, audience, delete, token); + var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); + if (group is { IsCircle: false }) + await _groups.Announce(group, delete, post.ObjectURI, isNewPost: false, token); } return new StatusOutcome(post); } @@ -494,6 +521,20 @@ namespace PrivaPub.Domain.Statuses return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL; } + async Task MayPost(Models.Group.Group group, LocalActor author, CancellationToken token) + { + var member = group.Members.FirstOrDefault(m => !m.IsForeign && m.AvatarId == author.Id); + if (group.Kind == GroupKind.Circle) + return member != default; + return group.PostingPolicy switch + { + PostingPolicy.Anyone => true, + PostingPolicy.Moderators => member?.Role is GroupRole.Owner or GroupRole.Moderator, + _ => member != default || await _dbEntities.Followings + .Match(f => f.AvatarId == author.Id && f.TargetAccountId == group.ID && f.State == FollowState.Accepted).ExecuteAnyAsync(token) + }; + } + async Task> Media(LocalActor author, IReadOnlyList ids, string postId, CancellationToken token) { if (ids == default || ids.Count == 0) diff --git a/PrivaPub/Domain/Timelines/Fanout.cs b/PrivaPub/Domain/Timelines/Fanout.cs index ecc077c..f50d186 100644 --- a/PrivaPub/Domain/Timelines/Fanout.cs +++ b/PrivaPub/Domain/Timelines/Fanout.cs @@ -40,6 +40,9 @@ namespace PrivaPub.Domain.Timelines foreach (var following in followers) if (await Shows(following, post, token)) recipients.Add(following.AvatarId); + if (!string.IsNullOrEmpty(post.AudienceURI)) + foreach (var member in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.AudienceURI && f.State == FollowState.Accepted).ExecuteAsync(token)) + recipients.Add(member.AvatarId); break; case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId): var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token); diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 64f609e..cbdebdc 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -29,6 +29,8 @@ namespace PrivaPub.Federation.Actors public bool Discoverable { get; init; } = true; public bool ManuallyApprovesFollowers { get; init; } public bool IsFederated { get; init; } = true; + public bool IsCircle { get; init; } + public bool PostingRestrictedToModerators { get; init; } public bool IsBot { get; init; } public bool Indexable { get; init; } public AvatarSettings Settings { get; init; } = new(); @@ -44,6 +46,8 @@ namespace PrivaPub.Federation.Actors public string Following => $"{Uri}/stalking"; public string Featured => $"{Uri}/trophies"; public string FeaturedTags => $"{Uri}/tattoos"; + public string Flock => $"{Uri}/flock"; + public string Wardens => $"{Uri}/wardens"; public string SharedInbox => $"{BaseAddress}/human-centipede"; public string Domain => new Uri(BaseAddress).Authority; public string Handle => $"{UserName}@{Domain}"; @@ -219,9 +223,10 @@ namespace PrivaPub.Federation.Actors ThumbnailURL = group.ThumbnailURL, PrivateKeyPem = group.PrivateKey, PublicKeyPem = group.PublicKey, - Discoverable = group.IsDiscoverable, - ManuallyApprovesFollowers = group.ManuallyApprovesMembers, - IsFederated = group.Kind == GroupKind.Community, + Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable, + ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers, + IsCircle = group.Kind == GroupKind.Circle, + PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators, Published = group.CreationDate, BaseAddress = BaseAddress }; diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 0da8fe9..cd49983 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; using Microsoft.AspNetCore.RateLimiting; using MongoDB.Entities; @@ -15,12 +17,15 @@ using PrivaPub.Domain.Privacy; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Rendering; using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Signing; +using PrivaPub.Infrastructure.Http; +using PrivaPub.Models.Group; using PrivaPub.Infrastructure; namespace PrivaPub.Federation.Controllers { [ApiController, Route("peasants")] - public class PeasantsController : ControllerBase + public class PeasantsController : ControllerBase, IAsyncActionFilter { const string ActivityContentType = "application/activity+json; charset=utf-8"; const int OutboxSize = 20; @@ -29,10 +34,14 @@ namespace PrivaPub.Federation.Controllers readonly IInboxReceiver _inbox; readonly DbEntities _dbEntities; readonly ILogger _logger; + readonly ISignedFetchAuthorizer _fetches; + readonly IOptionsMonitor _federation; public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities, - ILogger logger) + ILogger logger, ISignedFetchAuthorizer fetches, IOptionsMonitor federation) { + _fetches = fetches; + _federation = federation; _localActors = localActors; _inbox = inbox; _dbEntities = dbEntities; @@ -113,6 +122,34 @@ namespace PrivaPub.Federation.Controllers return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default)); } + [HttpGet, Route("{actor}/flock")] + public async Task Members(string actor, CancellationToken token) + { + var local = await _localActors.FindByUserName(actor, token); + if (local is not { IsFederated: true, Kind: LocalActorKind.Group }) + return NotFound(); + var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token); + if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token))) + return NotFound(); + return Activity(ActivityPubRenderer.OrderedCollection(local.Flock, group.Members.Count, default)); + } + + [HttpGet, Route("{actor}/wardens")] + public async Task Moderators(string actor, CancellationToken token) + { + var local = await _localActors.FindByUserName(actor, token); + if (local is not { IsFederated: true, Kind: LocalActorKind.Group }) + return NotFound(); + var group = await _dbEntities.Groups.MatchID(local.Id).ExecuteFirstAsync(token); + if (local.IsCircle && !SignedFetchAuthorizer.MayReadCircle(group, await _fetches.Requester(Request, token))) + return NotFound(); + var moderators = new List(); + foreach (var member in group.Members.Where(m => !m.IsForeign && m.Role is GroupRole.Owner or GroupRole.Moderator)) + if (await _localActors.FindById(LocalActorKind.Person, member.AvatarId, token) is { } moderator) + moderators.Add(moderator.Uri); + return Activity(ActivityPubRenderer.OrderedCollection(local.Wardens, moderators.Count, moderators)); + } + [HttpGet, Route("{actor}/trophies")] public async Task Featured(string actor, CancellationToken token) { @@ -145,6 +182,12 @@ namespace PrivaPub.Federation.Controllers public async Task Post(string actor, string postId, CancellationToken token) { var (local, post) = await PublicPost(actor, postId, token); + if (post == default && await CirclePost(actor, postId, token) is { } circlePost) + { + var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI); + circleNote["@context"] = ActivityPubRenderer.Context(); + return Activity(circleNote); + } if (post == default) return await Tombstone(actor, postId, token) ?? NotFound(); if (WantsHtml()) @@ -202,6 +245,20 @@ namespace PrivaPub.Federation.Controllers return (local, post); } + async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token) + { + var local = await _localActors.FindByUserName(actor, token); + if (local is not { Kind: LocalActorKind.Person }) + return default; + var post = await _dbEntities.Posts + .Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility == PostVisibility.Circle) + .ExecuteFirstAsync(token); + var circle = post == default ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token); + if (circle == default || !SignedFetchAuthorizer.MayReadCircle(circle, await _fetches.Requester(Request, token))) + return default; + return (local, _localActors.FromGroup(circle), post); + } + async Task Tombstone(string actor, string postId, CancellationToken token) { var local = await _localActors.FindByUserName(actor, token); @@ -257,6 +314,19 @@ namespace PrivaPub.Federation.Controllers && !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase); } + [NonAction] + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) + && !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase) + && await _fetches.Requester(Request, HttpContext.RequestAborted) == default) + { + context.Result = StatusCode(StatusCodes.Status401Unauthorized); + return; + } + await next(); + } + IActionResult Answer(InboxResult result) { if (result.Error != default) diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index 823d1b9..e230b6e 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -26,9 +26,11 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly ILocalActorService _localActors; readonly IRemotePosts _remotePosts; readonly IFanout _fanout; + readonly IRemoteActorService _remoteActors; - public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout) + public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors) { + _remoteActors = remoteActors; _dbEntities = dbEntities; _localActors = localActors; _remotePosts = remotePosts; @@ -41,7 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers { 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") + { + await GroupActivity(inner, actor, token); return; + } var objectUri = Id(inner); var announceId = Id(activity); if (objectUri == default || announceId == default) @@ -96,6 +101,66 @@ namespace PrivaPub.Federation.Inbox.Handlers await _fanout.Distribute(reblog, token); } + async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token) + { + if (group.AvatarType != AvatarType.Group + || !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token)) + return; + var objectUri = Id(inner["object"]); + switch (Value(inner, "type")) + { + case "Create" when objectUri != default: + if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token)) + return; + var post = await _remotePosts.StoreContext(objectUri, 0, token); + if (post == default) + return; + await DB.Default.Update().MatchID(post.ID).Modify(p => p.AudienceURI, group.ActorURI).ExecuteAsync(token); + post.AudienceURI = group.ActorURI; + await _fanout.Distribute(post, token); + break; + case "Update" when objectUri != default: + var stored = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI && !p.DeletedAt.HasValue) + .ExecuteFirstAsync(token); + if (stored == default) + return; + using (var fetched = await _remoteActors.FetchObject(objectUri, token)) + { + var note = fetched == default ? default : NoteParser.Parse(System.Text.Json.Nodes.JsonNode.Parse(fetched.Root.GetRawText())); + if (note == default || note.AttributedTo != stored.ActorURI) + return; + stored.Revisions.Add(new PostRevision + { + Title = stored.Title, + SpoilerText = stored.SpoilerText, + ContentHtml = stored.ContentHtml, + HasContentWarning = stored.HasContentWarning, + EditedAt = stored.EditedAt ?? stored.CreationDate + }); + stored.Title = note.Title; + stored.SpoilerText = note.SpoilerText; + stored.HasContentWarning = note.Sensitive; + stored.Text = note.ContentHtml; + stored.ContentHtml = note.ContentHtml; + stored.Tags = note.Tags.ToList(); + stored.Media = note.Attachments.ToList(); + stored.EditedAt = note.Updated ?? DateTime.UtcNow; + await DB.Default.SaveAsync(stored, token); + } + break; + case "Delete" when objectUri != default: + var deleted = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.AudienceURI == group.ActorURI).ExecuteFirstAsync(token); + if (deleted == default) + return; + using (var check = await _remoteActors.FetchObject(objectUri, token)) + if (check != default && Value(System.Text.Json.Nodes.JsonNode.Parse(check.Root.GetRawText()), "type") != "Tombstone") + return; + await DB.Default.DeleteAsync(deleted.ID); + await DB.Default.DeleteAsync(e => e.PostId == deleted.ID); + break; + } + } + static List Strings(JsonNode node) => node switch { JsonArray array => array.Select(Id).Where(id => id != default).ToList(), diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index 729c463..f2bb30b 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -33,10 +33,12 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly IDomainBlocks _domainBlocks; readonly IFanout _fanout; readonly IRemotePosts _remotePosts; + readonly IGroupDistributor _groups; public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, - IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts) + IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups) { + _groups = groups; _fanout = fanout; _remotePosts = remotePosts; _dbEntities = dbEntities; @@ -85,10 +87,17 @@ namespace PrivaPub.Federation.Inbox.Handlers var parent = string.IsNullOrEmpty(note.InReplyTo) ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token); - var group = visibility is PostVisibility.Public or PostVisibility.Unlisted - ? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group) - : default; - if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token)) + var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true }); + if (circle != default) + { + if (!await IsCircleMember(circle, author.ActorURI, token)) + return; + visibility = PostVisibility.Circle; + } + var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted + ? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false }) + : default); + if (group is { IsCircle: false } && !await MayPost(group, author.ActorURI, token)) group = default; var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct; @@ -128,11 +137,22 @@ namespace PrivaPub.Federation.Inbox.Handlers await _fanout.Distribute(post, token); if (conversation != default) await DB.Default.Update().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token); - if (group != default) + if (group is { IsCircle: false }) + await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), token); + } + + async Task IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) => + await _dbEntities.Groups.Match(g => g.ID == circle.Id && g.Members.Any(m => m.IsForeign && m.AvatarId == actorUri)).ExecuteAnyAsync(token); + + async Task MayPost(LocalActor community, string actorUri, CancellationToken token) + { + var entity = await _dbEntities.Groups.MatchID(community.Id).ExecuteFirstAsync(token); + return entity?.PostingPolicy switch { - var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}"); - await _delivery.EnqueueToFollowers(group, announce, token); - } + PostingPolicy.Anyone => true, + PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token), + _ => false + }; } async Task FindOrCreateConversation(List participantUris, string context, CancellationToken token) diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs index b43d064..ff723c0 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs @@ -26,9 +26,12 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IDeliveryService _delivery; + readonly IGroupDistributor _groups; - public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery) + public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, + IGroupDistributor groups) { + _groups = groups; _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; @@ -67,6 +70,8 @@ namespace PrivaPub.Federation.Inbox.Handlers return; await DB.Default.DeleteAsync(post.ID); await DB.Default.DeleteAsync(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); + if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) + await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); await DB.Default.DeleteAsync(p => p.ReblogOfPostId == post.ID); if (!string.IsNullOrEmpty(post.AnsweringToPostId)) await DB.Default.Update().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token); diff --git a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs index 6c6b73a..0084111 100644 --- a/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/UpdateHandler.cs @@ -2,6 +2,7 @@ using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Objects; +using PrivaPub.Federation.Outbox; using PrivaPub.Models.Post; using PrivaPub.Models.User; using PrivaPub.StaticServices; @@ -21,9 +22,11 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; + readonly IGroupDistributor _groups; - public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors) + public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups) { + _groups = groups; _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; @@ -79,6 +82,8 @@ namespace PrivaPub.Federation.Inbox.Handlers post.EditedAt = note.Updated ?? DateTime.UtcNow; post.UpdateDate = DateTime.UtcNow; await DB.Default.SaveAsync(post, token); + if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community) + await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token); } } } diff --git a/PrivaPub/Federation/Outbox/GroupDistributor.cs b/PrivaPub/Federation/Outbox/GroupDistributor.cs new file mode 100644 index 0000000..01dd7c6 --- /dev/null +++ b/PrivaPub/Federation/Outbox/GroupDistributor.cs @@ -0,0 +1,48 @@ +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Rendering; + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace PrivaPub.Federation.Outbox +{ + public interface IGroupDistributor + { + Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token); + } + + public class GroupDistributor : IGroupDistributor + { + readonly IDeliveryService _delivery; + + public GroupDistributor(IDeliveryService delivery) + { + _delivery = delivery; + } + + public async Task Announce(LocalActor group, JsonObject activity, string objectUri, bool isNewPost, CancellationToken token) + { + if (group is not { IsCircle: false, Kind: Models.Federation.LocalActorKind.Group }) + return; + var embedded = (JsonObject)activity.DeepClone(); + embedded.Remove("@context"); + embedded["audience"] ??= group.Uri; + var key = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(activity["id"]?.GetValue() ?? embedded.ToJsonString())))[..24]; + var announce = new JsonObject + { + ["@context"] = ActivityPubRenderer.Context(), + ["id"] = group.ActivityUri($"announce-{key}"), + ["type"] = "Announce", + ["actor"] = group.Uri, + ["to"] = new JsonArray(ActivityPubRenderer.Public), + ["cc"] = new JsonArray(group.Followers), + ["audience"] = group.Uri, + ["object"] = embedded + }; + await _delivery.EnqueueToFollowers(group, announce, token); + if (isNewPost && !string.IsNullOrEmpty(objectUri)) + await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token); + } + } +} diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs index 774c7b6..e22c917 100644 --- a/PrivaPub/Federation/Outbox/OutboxPublisher.cs +++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs @@ -32,8 +32,10 @@ namespace PrivaPub.Federation.Outbox public async Task> Audience(LocalActor author, PostEntity post, CancellationToken token) { - if (post.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo || post.IsLocalOnly) + if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly) return Array.Empty(); + if (post.Visibility == PostVisibility.Circle) + return await CircleMembers(post.GroupId, token); var inboxes = new List(); if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly) @@ -58,16 +60,21 @@ namespace PrivaPub.Federation.Outbox inboxes.Add(parentAuthor.InboxURL); } - if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted) - { - var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token); - if (group is { IsFederated: true }) - inboxes.AddRange(await _delivery.FollowerInboxes(group, token)); - } - return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); } + async Task> CircleMembers(string groupId, CancellationToken token) + { + var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token); + if (circle == default) + return Array.Empty(); + var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList(); + if (remote.Count == 0) + return Array.Empty(); + return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token)) + .Select(a => a.InboxURL).Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); + } + public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token) { var inboxes = await Audience(author, post, token); diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs index e1fa56e..e6d7712 100644 --- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs +++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs @@ -109,17 +109,25 @@ namespace PrivaPub.Federation.Rendering var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray(); var (to, cc) = post.Visibility switch { + PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)), PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())), PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)), PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()), _ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray())) }; - if (group != default) + if (group is { IsCircle: false }) cc.Add(group.Uri); var note = NoteBody(post, author, to, cc, inReplyTo); if (group != default) note["audience"] = group.Uri; + else if (!string.IsNullOrEmpty(post.AudienceURI)) + note["audience"] = post.AudienceURI; + if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo)) + { + note["type"] = "Page"; + note["name"] = post.Title ?? Headline(post); + } return note; } @@ -141,6 +149,13 @@ namespace PrivaPub.Federation.Rendering return note; } + static string Headline(PostEntity post) + { + var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " "); + text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim(); + return text.Length <= 100 ? text : text[..97] + "..."; + } + static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo) { var content = post.ContentHtml ?? Html(post.Text); diff --git a/PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs b/PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs new file mode 100644 index 0000000..80a4612 --- /dev/null +++ b/PrivaPub/Federation/Signing/SignedFetchAuthorizer.cs @@ -0,0 +1,40 @@ +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Objects; +using PrivaPub.Models.User; + +using GroupEntity = PrivaPub.Models.Group.Group; + +namespace PrivaPub.Federation.Signing +{ + public interface ISignedFetchAuthorizer + { + Task Requester(HttpRequest request, CancellationToken token); + } + + public class SignedFetchAuthorizer : ISignedFetchAuthorizer + { + readonly IRemoteActorService _remoteActors; + + public SignedFetchAuthorizer(IRemoteActorService remoteActors) + { + _remoteActors = remoteActors; + } + + public async Task Requester(HttpRequest request, CancellationToken token) + { + var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString()); + if (parameters == default || HttpSignatures.CheckRequest(request, parameters, body: default) != default) + return default; + var signingString = HttpSignatures.SigningString(request, parameters); + var actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); + if (actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature)) + return actor; + actor = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); + return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default; + } + + public static bool MayReadCircle(GroupEntity circle, ForeignAvatar requester) => + requester != default && circle.Members.Any(m => m.IsForeign + && (m.AvatarId == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(m.AvatarId, requester.ActorURI))); + } +} diff --git a/PrivaPub/Infrastructure/Http/FederationOptions.cs b/PrivaPub/Infrastructure/Http/FederationOptions.cs index c68a23c..cb68e48 100644 --- a/PrivaPub/Infrastructure/Http/FederationOptions.cs +++ b/PrivaPub/Infrastructure/Http/FederationOptions.cs @@ -4,5 +4,6 @@ namespace PrivaPub.Infrastructure.Http { public bool AllowPrivateNetworks { get; set; } public bool AllowPlainHttp { get; set; } + public bool SecureMode { get; set; } } } diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 216c1e4..5843c77 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -15,6 +15,7 @@ using PrivaPub.Services.ClientToServer.Private; using PrivaPub.Services.ClientToServer.Public; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; +using PrivaPub.Federation.Signing; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Inbox.Handlers; @@ -65,6 +66,8 @@ namespace PrivaPub.Middleware .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Group/Group.cs b/PrivaPub/Models/Group/Group.cs index f32194a..c6197d5 100644 --- a/PrivaPub/Models/Group/Group.cs +++ b/PrivaPub/Models/Group/Group.cs @@ -20,6 +20,8 @@ namespace PrivaPub.Models.Group public string PublicKey { get; set; } public GroupKind Kind { get; set; } + public PostingPolicy PostingPolicy { get; set; } + public List Rules { get; set; } = new(); public bool IsDiscoverable { get; set; } = true; public bool ManuallyApprovesMembers { get; set; } public string OwnerAvatarId { get; set; } @@ -49,6 +51,13 @@ namespace PrivaPub.Models.Group Community } + public enum PostingPolicy + { + Followers, + Anyone, + Moderators + } + public enum GroupRole { Member, diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index 7bed0d3..5a94fd9 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -12,6 +12,7 @@ namespace PrivaPub.Models.Post public string InReplyToAccountId { get; set; } public string GroupId { get; set; } public string ConversationId { get; set; }//DmGroup.ID of a direct post + public string AudienceURI { get; set; }//a remote community the post was made in public string ReblogOfPostId { get; set; } public PostVisibility Visibility { get; set; } public string Title { get; set; } diff --git a/PrivaPub/Services/GroupUsersService.cs b/PrivaPub/Services/GroupUsersService.cs index d926bbb..0efed5a 100644 --- a/PrivaPub/Services/GroupUsersService.cs +++ b/PrivaPub/Services/GroupUsersService.cs @@ -116,6 +116,7 @@ namespace PrivaPub.Services PrivateKey = privateKey, PublicKey = publicKey, Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle, + PostingPolicy = Policy(form.PostingPolicy) ?? PostingPolicy.Followers, IsDiscoverable = form.IsDiscoverable, ManuallyApprovesMembers = form.ManuallyApprovesMembers, OwnerAvatarId = form.AvatarId, @@ -157,6 +158,8 @@ namespace PrivaPub.Services group.Description = form.Description; if (form.IsDiscoverable.HasValue) group.IsDiscoverable = form.IsDiscoverable.Value; + if (Policy(form.PostingPolicy) is { } policy) + group.PostingPolicy = policy; if (form.ManuallyApprovesMembers.HasValue) group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value; if (form.RemoveInvitationPassword) @@ -316,6 +319,14 @@ namespace PrivaPub.Services !string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId) && await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token); + static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch + { + "anyone" => PostingPolicy.Anyone, + "moderators" => PostingPolicy.Moderators, + "followers" => PostingPolicy.Followers, + _ => (PostingPolicy?)null + }; + static string NewInvitationCode() => $"{Guid.NewGuid():N}{Guid.NewGuid():N}"; ViewGroup ToView(GroupEntity group, string avatarId) @@ -331,6 +342,7 @@ namespace PrivaPub.Services Url = actor.Uri, Handle = actor.Handle, IsCommunity = group.Kind == GroupKind.Community, + PostingPolicy = group.PostingPolicy.ToString().ToLowerInvariant(), IsDiscoverable = group.IsDiscoverable, ManuallyApprovesMembers = group.ManuallyApprovesMembers, IsOwner = group.OwnerAvatarId == avatarId, diff --git a/PrivaPub/Web/Pages/Pages.cs b/PrivaPub/Web/Pages/Pages.cs index 930ea38..7f66d97 100644 --- a/PrivaPub/Web/Pages/Pages.cs +++ b/PrivaPub/Web/Pages/Pages.cs @@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages public async Task OnGetAsync(string user, CancellationToken token) { Actor = await _localActors.FindByUserName(user, token); - if (Actor is not { IsFederated: true } || Actor.Kind == LocalActorKind.Application) + if (Actor is not { IsFederated: true, IsCircle: false } || Actor.Kind == LocalActorKind.Application) return NotFound(); if (WantsActivityJson()) return Redirect(Actor.Uri);