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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
0ccbcd558f
commit
a5d9a89445
32 files changed
+651
-57
No files matched your search
@@ -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`.
|
- the shared inbox is advertised in `endpoints.sharedInbox`.
|
||||||
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
|
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.
|
`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
|
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
|
||||||
`IsLocalOnly`. Only communities are Group actors.
|
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
|
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).
|
`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`):
|
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`):
|
||||||
|
|||||||
+16
-4
@@ -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 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
|
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
|
||||||
it. Full FEP-1b12 behaviour (announcing activities, `audience`, moderation) is planned.
|
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
|
||||||
- A circle is private and does not federate yet: its actor, collections and WebFinger answer 404, and its posts are never
|
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers. A new post is
|
||||||
delivered.
|
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
|
## 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
|
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.
|
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
|
## Security rules a peer will notice
|
||||||
|
|
||||||
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
|
- **Signatures.** Inbox POSTs must be signed over `(request-target)`, `host`, `digest` and `date` (or `(created)`). The
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
|
|
||||||
public string InvitationPassword { get; set; }
|
public string InvitationPassword { get; set; }
|
||||||
public bool IsCommunity { get; set; }
|
public bool IsCommunity { get; set; }
|
||||||
|
public string PostingPolicy { get; set; }//followers (default), anyone, moderators
|
||||||
public bool IsDiscoverable { get; set; } = true;
|
public bool IsDiscoverable { get; set; } = true;
|
||||||
public bool ManuallyApprovesMembers { get; set; }
|
public bool ManuallyApprovesMembers { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
public bool RemoveInvitationPassword { get; set; }
|
public bool RemoveInvitationPassword { get; set; }
|
||||||
public bool RegenerateInvitationCode { get; set; }
|
public bool RegenerateInvitationCode { get; set; }
|
||||||
public bool? IsDiscoverable { get; set; }
|
public bool? IsDiscoverable { get; set; }
|
||||||
|
public string PostingPolicy { get; set; }
|
||||||
public bool? ManuallyApprovesMembers { get; set; }
|
public bool? ManuallyApprovesMembers { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ namespace PrivaPub.ClientModels.Group
|
|||||||
public string Url { get; set; }
|
public string Url { get; set; }
|
||||||
public string Handle { get; set; }
|
public string Handle { get; set; }
|
||||||
public bool IsCommunity { get; set; }
|
public bool IsCommunity { get; set; }
|
||||||
|
public string PostingPolicy { get; set; }
|
||||||
public bool IsDiscoverable { get; set; }
|
public bool IsDiscoverable { get; set; }
|
||||||
public bool ManuallyApprovesMembers { get; set; }
|
public bool ManuallyApprovesMembers { get; set; }
|
||||||
public bool IsOwner { get; set; }
|
public bool IsOwner { get; set; }
|
||||||
|
|||||||
@@ -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<string> to, string audience = default)
|
||||||
|
{
|
||||||
|
var note = new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = $"{Origin(author)}/notes/{Guid.NewGuid():N}",
|
||||||
|
["type"] = "Note",
|
||||||
|
["attributedTo"] = author.Id,
|
||||||
|
["content"] = "<p>to the group</p>",
|
||||||
|
["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<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == lemmy.Id).ExecuteAnyAsync(token));
|
||||||
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.GroupId == community.Id && p.ActorURI == stranger.Id).ExecuteAnyAsync(token));
|
||||||
|
var announces = (await _harness.Outgoing(lemmy.SharedInbox)).Where(a => a["actor"]!.GetValue<string>() == community.Uri).ToList();
|
||||||
|
Assert.Equal(2, announces.Count);
|
||||||
|
Assert.Contains(announces, a => a["object"] is JsonObject inner && inner["type"]!.GetValue<string>() == "Create" && a["audience"]!.GetValue<string>() == 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<Post>().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<string>());
|
||||||
|
Assert.Equal(community.Uri, note["audience"]!.GetValue<string>());
|
||||||
|
Assert.False(string.IsNullOrEmpty(note["name"]!.GetValue<string>()));
|
||||||
|
Assert.Contains(await _harness.Outgoing(follower.SharedInbox), a => a["type"]!.GetValue<string>() == "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<string>()));
|
||||||
|
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<string>() == "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<Post>().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<TimelineEntry>().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<Following>().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<string>();
|
||||||
|
_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<Post>().Match(p => p.ObjectURI == noteId).ExecuteSingleAsync(token);
|
||||||
|
Assert.Equal(lemmyCommunity.Id, post.AudienceURI);
|
||||||
|
Assert.True(await DB.Default.Find<TimelineEntry>().Match(e => e.AvatarId == alice.Id && e.PostId == post.ID).ExecuteAnyAsync(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,9 +54,9 @@ namespace PrivaPub.Tests.Federation
|
|||||||
{
|
{
|
||||||
new FollowHandler(db, _local, remote, delivery),
|
new FollowHandler(db, _local, remote, delivery),
|
||||||
new UndoHandler(db, _local),
|
new UndoHandler(db, _local),
|
||||||
new CreateHandler(db, _local, remote, delivery, _blocks, new Fanout(db), new RemotePosts(db, _local, remote, _blocks, queue)),
|
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 DeleteHandler(db, _local, remote, delivery, new GroupDistributor(delivery)),
|
||||||
new UpdateHandler(db, _local, remote)
|
new UpdateHandler(db, _local, remote, new GroupDistributor(delivery))
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ namespace PrivaPub.Tests.Federation
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 token = TestContext.Current.CancellationToken;
|
||||||
var (privateKey, publicKey) = Keys.NewKeyPair();
|
var (privateKey, publicKey) = Keys.NewKeyPair();
|
||||||
@@ -339,8 +339,13 @@ namespace PrivaPub.Tests.Federation
|
|||||||
["object"] = actor.Uri
|
["object"] = actor.Uri
|
||||||
};
|
};
|
||||||
|
|
||||||
Assert.False(actor.IsFederated);
|
Assert.True(actor.IsCircle);
|
||||||
Assert.Equal(404, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
Assert.True(actor.ManuallyApprovesFollowers);
|
||||||
|
Assert.False(actor.Discoverable);
|
||||||
|
Assert.Equal(202, (await Deliver(bob, "/human-centipede", follow)).StatusCode);
|
||||||
|
var request = await DB.Default.Find<Follower>().Match(f => f.LocalActorId == circle.ID).ExecuteSingleAsync(token);
|
||||||
|
Assert.False(request.IsAccepted);
|
||||||
|
Assert.DoesNotContain(circle.Members, m => m.AvatarId == bob.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
Remote = new RemoteActorService(Peer.Http(cache), Local, cache, Db);
|
||||||
Delivery = new DeliveryService(Db, Queue);
|
Delivery = new DeliveryService(Db, Queue);
|
||||||
Fanout = new Fanout(Db);
|
Fanout = new Fanout(Db);
|
||||||
|
Groups = new GroupDistributor(Delivery);
|
||||||
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
|
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue);
|
||||||
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance);
|
||||||
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
Processor = new InboxProcessor(Remote, new IActivityHandler[]
|
||||||
@@ -50,10 +51,10 @@ namespace PrivaPub.Tests.Support
|
|||||||
new RejectHandler(Db, Local),
|
new RejectHandler(Db, Local),
|
||||||
new UndoHandler(Db, Local),
|
new UndoHandler(Db, Local),
|
||||||
new LikeHandler(Db),
|
new LikeHandler(Db),
|
||||||
new AnnounceHandler(Db, Local, RemotePosts, Fanout),
|
new AnnounceHandler(Db, Local, RemotePosts, Fanout, Remote),
|
||||||
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts),
|
new CreateHandler(Db, Local, Remote, Delivery, new NoBlocks(), Fanout, RemotePosts, Groups),
|
||||||
new DeleteHandler(Db, Local, Remote, Delivery),
|
new DeleteHandler(Db, Local, Remote, Delivery, Groups),
|
||||||
new UpdateHandler(Db, Local, Remote),
|
new UpdateHandler(Db, Local, Remote, Groups),
|
||||||
new FlagHandler(Db, Local)
|
new FlagHandler(Db, Local)
|
||||||
}, NullLogger<InboxProcessor>.Instance);
|
}, NullLogger<InboxProcessor>.Instance);
|
||||||
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer<GenericRes>(), NullLogger<FollowService>.Instance);
|
||||||
@@ -61,7 +62,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
Outbox = new OutboxPublisher(Db, Local, Delivery);
|
||||||
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
Media = new MediaService(new StaticOptions<MediaOptions>(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }),
|
||||||
Local, default, NullLogger<MediaService>.Instance);
|
Local, default, NullLogger<MediaService>.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<GenericRes>(), NullLogger<PostsService>.Instance);
|
Posts = new PostsService(Db, Local, Statuses, new KeyLocalizer<GenericRes>(), NullLogger<PostsService>.Instance);
|
||||||
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
Timelines = new TimelineService(Db, new KeyLocalizer<GenericRes>());
|
||||||
Relationships = new RelationshipService(Db, Follows, Delivery);
|
Relationships = new RelationshipService(Db, Follows, Delivery);
|
||||||
@@ -82,6 +83,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public PostsService Posts { get; }
|
public PostsService Posts { get; }
|
||||||
public StatusService Statuses { get; }
|
public StatusService Statuses { get; }
|
||||||
public MediaService Media { get; }
|
public MediaService Media { get; }
|
||||||
|
public GroupDistributor Groups { get; }
|
||||||
public Fanout Fanout { get; }
|
public Fanout Fanout { get; }
|
||||||
public RemotePosts RemotePosts { get; }
|
public RemotePosts RemotePosts { get; }
|
||||||
public TimelineService Timelines { get; }
|
public TimelineService Timelines { get; }
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ namespace PrivaPub.Tests.Support
|
|||||||
{
|
{
|
||||||
readonly RSA _key = RSA.Create(2048);
|
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];
|
Name = $"{name}{Guid.NewGuid():N}"[..20];
|
||||||
Id = $"{origin ?? peer.A}/users/{Name}";
|
Id = $"{origin ?? peer.A}/users/{Name}";
|
||||||
|
Type = type;
|
||||||
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
peer.Serve($"/users/{Name}", Document().ToJsonString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string Type { get; }
|
||||||
|
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
public string KeyId => Id + "#main-key";
|
public string KeyId => Id + "#main-key";
|
||||||
@@ -29,7 +32,7 @@ namespace PrivaPub.Tests.Support
|
|||||||
public JsonObject Document() => new()
|
public JsonObject Document() => new()
|
||||||
{
|
{
|
||||||
["id"] = Id,
|
["id"] = Id,
|
||||||
["type"] = "Person",
|
["type"] = Type,
|
||||||
["preferredUsername"] = Name,
|
["preferredUsername"] = Name,
|
||||||
["inbox"] = Id + "/inbox",
|
["inbox"] = Id + "/inbox",
|
||||||
["followers"] = Id + "/followers",
|
["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<IHttpRequestFeature>().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)
|
public HttpRequest Post(string host, string path, JsonNode activity)
|
||||||
{
|
{
|
||||||
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
var body = Encoding.UTF8.GetBytes(activity.ToJsonString());
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var local = await _localActors.FindByUserName(parts[0], token);
|
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 userName = parts[0];
|
||||||
var domain = parts[1].ToLowerInvariant();
|
var domain = parts[1].ToLowerInvariant();
|
||||||
@@ -365,7 +365,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
if (avatar is { DeletionAt: null })
|
if (avatar is { DeletionAt: null })
|
||||||
return (_localActors.FromAvatar(avatar), default);
|
return (_localActors.FromAvatar(avatar), default);
|
||||||
var group = await _dbEntities.Groups.MatchID(id).ExecuteFirstAsync(token);
|
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 (community, default);
|
||||||
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token));
|
return (default, await _dbEntities.ForeignAvatars.MatchID(id).ExecuteFirstAsync(token));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
|||||||
{
|
{
|
||||||
var local = await _localActors.FindByUri(q, token);
|
var local = await _localActors.FindByUri(q, token);
|
||||||
var foreign = local == default ? (resolve ? await _remoteActors.GetActor(q, refresh: false, token) : default) : default;
|
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));
|
results.Accounts.Add(await _mapper.Local(local, false, token));
|
||||||
else if (foreign != default)
|
else if (foreign != default)
|
||||||
results.Accounts.Add(_mapper.Foreign(foreign));
|
results.Accounts.Add(_mapper.Foreign(foreign));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using PrivaPub.Domain.Media;
|
|||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Rendering;
|
using PrivaPub.Federation.Rendering;
|
||||||
using PrivaPub.Models.Federation;
|
using PrivaPub.Models.Federation;
|
||||||
|
using PrivaPub.Models.Group;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.Social;
|
using PrivaPub.Models.Social;
|
||||||
using PrivaPub.Models.User;
|
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))
|
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);
|
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);
|
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))
|
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
|
||||||
accounts[foreign.ID] = Foreign(foreign);
|
accounts[foreign.ID] = Foreign(foreign);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ namespace PrivaPub.Domain.Content
|
|||||||
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var local = await _localActors.FindByUserName(parts[0], token);
|
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)
|
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
|
||||||
: default;
|
: default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ namespace PrivaPub.Domain.Social
|
|||||||
var (local, remote) = await ResolveTarget(target, token);
|
var (local, remote) = await ResolveTarget(target, token);
|
||||||
if (local == default && remote == default)
|
if (local == default && remote == default)
|
||||||
return 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;
|
return default;
|
||||||
|
|
||||||
var targetUri = local?.Uri ?? remote.ActorURI;
|
var targetUri = local?.Uri ?? remote.ActorURI;
|
||||||
|
|||||||
@@ -68,11 +68,13 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
readonly IOutboxPublisher _outbox;
|
readonly IOutboxPublisher _outbox;
|
||||||
readonly IFanout _fanout;
|
readonly IFanout _fanout;
|
||||||
readonly IMediaService _media;
|
readonly IMediaService _media;
|
||||||
|
readonly IGroupDistributor _groups;
|
||||||
|
|
||||||
public StatusService(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
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;
|
_media = media;
|
||||||
|
_groups = groups;
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
@@ -94,7 +96,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
if (!string.IsNullOrEmpty(draft.GroupId))
|
if (!string.IsNullOrEmpty(draft.GroupId))
|
||||||
{
|
{
|
||||||
var groupEntity = await _dbEntities.Groups.MatchID(draft.GroupId).ExecuteFirstAsync(token);
|
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");
|
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Group not found");
|
||||||
group = _localActors.FromGroup(groupEntity);
|
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");
|
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 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
|
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;
|
: draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
|
||||||
var post = new PostEntity
|
var post = new PostEntity
|
||||||
{
|
{
|
||||||
@@ -131,6 +152,7 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
Mentions = rendered.Mentions.Select(ToMention).ToList(),
|
Mentions = rendered.Mentions.Select(ToMention).ToList(),
|
||||||
Tags = rendered.Tags.ToList(),
|
Tags = rendered.Tags.ToList(),
|
||||||
Media = media.Select(ToPostMedia).ToList(),
|
Media = media.Select(ToPostMedia).ToList(),
|
||||||
|
AudienceURI = audienceUri,
|
||||||
AnsweringToPostId = parent?.ID,
|
AnsweringToPostId = parent?.ID,
|
||||||
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
|
InReplyToURI = parent?.ObjectURI ?? (IsRemoteUri(draft.InReplyTo) ? draft.InReplyTo : default),
|
||||||
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
|
||||||
@@ -180,8 +202,8 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
await _fanout.Distribute(post, token);
|
await _fanout.Distribute(post, token);
|
||||||
if (create != default)
|
if (create != default)
|
||||||
await _outbox.Publish(author, post, create, token);
|
await _outbox.Publish(author, post, create, token);
|
||||||
if (group is { IsFederated: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
if (create != default && group is { IsCircle: false } && visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
||||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, post.ObjectURI, $"announce-{post.ID}"), token);
|
await _groups.Announce(group, create, post.ObjectURI, isNewPost: string.IsNullOrEmpty(post.InReplyToURI), token);
|
||||||
return new StatusOutcome(post);
|
return new StatusOutcome(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +269,8 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
["object"] = note
|
["object"] = note
|
||||||
};
|
};
|
||||||
await _outbox.Publish(author, post, update, token);
|
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);
|
return new StatusOutcome(post);
|
||||||
}
|
}
|
||||||
@@ -277,6 +301,9 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
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()));
|
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);
|
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);
|
return new StatusOutcome(post);
|
||||||
}
|
}
|
||||||
@@ -494,6 +521,20 @@ namespace PrivaPub.Domain.Statuses
|
|||||||
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
|
return (await _dbEntities.ForeignAvatars.MatchID(post.AuthorAccountId).ExecuteFirstAsync(token))?.InboxURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task<bool> 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<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token)
|
async Task<List<MediaAttachment>> Media(LocalActor author, IReadOnlyList<string> ids, string postId, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (ids == default || ids.Count == 0)
|
if (ids == default || ids.Count == 0)
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ namespace PrivaPub.Domain.Timelines
|
|||||||
foreach (var following in followers)
|
foreach (var following in followers)
|
||||||
if (await Shows(following, post, token))
|
if (await Shows(following, post, token))
|
||||||
recipients.Add(following.AvatarId);
|
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;
|
break;
|
||||||
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
|
case PostVisibility.Direct when !string.IsNullOrEmpty(post.ConversationId):
|
||||||
var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token);
|
var conversation = await _dbEntities.DmGroups.MatchID(post.ConversationId).ExecuteFirstAsync(token);
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public bool Discoverable { get; init; } = true;
|
public bool Discoverable { get; init; } = true;
|
||||||
public bool ManuallyApprovesFollowers { get; init; }
|
public bool ManuallyApprovesFollowers { get; init; }
|
||||||
public bool IsFederated { get; init; } = true;
|
public bool IsFederated { get; init; } = true;
|
||||||
|
public bool IsCircle { get; init; }
|
||||||
|
public bool PostingRestrictedToModerators { get; init; }
|
||||||
public bool IsBot { get; init; }
|
public bool IsBot { get; init; }
|
||||||
public bool Indexable { get; init; }
|
public bool Indexable { get; init; }
|
||||||
public AvatarSettings Settings { get; init; } = new();
|
public AvatarSettings Settings { get; init; } = new();
|
||||||
@@ -44,6 +46,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
public string Following => $"{Uri}/stalking";
|
public string Following => $"{Uri}/stalking";
|
||||||
public string Featured => $"{Uri}/trophies";
|
public string Featured => $"{Uri}/trophies";
|
||||||
public string FeaturedTags => $"{Uri}/tattoos";
|
public string FeaturedTags => $"{Uri}/tattoos";
|
||||||
|
public string Flock => $"{Uri}/flock";
|
||||||
|
public string Wardens => $"{Uri}/wardens";
|
||||||
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
public string SharedInbox => $"{BaseAddress}/human-centipede";
|
||||||
public string Domain => new Uri(BaseAddress).Authority;
|
public string Domain => new Uri(BaseAddress).Authority;
|
||||||
public string Handle => $"{UserName}@{Domain}";
|
public string Handle => $"{UserName}@{Domain}";
|
||||||
@@ -219,9 +223,10 @@ namespace PrivaPub.Federation.Actors
|
|||||||
ThumbnailURL = group.ThumbnailURL,
|
ThumbnailURL = group.ThumbnailURL,
|
||||||
PrivateKeyPem = group.PrivateKey,
|
PrivateKeyPem = group.PrivateKey,
|
||||||
PublicKeyPem = group.PublicKey,
|
PublicKeyPem = group.PublicKey,
|
||||||
Discoverable = group.IsDiscoverable,
|
Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
|
||||||
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
|
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
|
||||||
IsFederated = group.Kind == GroupKind.Community,
|
IsCircle = group.Kind == GroupKind.Circle,
|
||||||
|
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
|
||||||
Published = group.CreationDate,
|
Published = group.CreationDate,
|
||||||
BaseAddress = BaseAddress
|
BaseAddress = BaseAddress
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
using MongoDB.Entities;
|
using MongoDB.Entities;
|
||||||
@@ -15,12 +17,15 @@ using PrivaPub.Domain.Privacy;
|
|||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Rendering;
|
using PrivaPub.Federation.Rendering;
|
||||||
using PrivaPub.Federation.Inbox;
|
using PrivaPub.Federation.Inbox;
|
||||||
|
using PrivaPub.Federation.Signing;
|
||||||
|
using PrivaPub.Infrastructure.Http;
|
||||||
|
using PrivaPub.Models.Group;
|
||||||
using PrivaPub.Infrastructure;
|
using PrivaPub.Infrastructure;
|
||||||
|
|
||||||
namespace PrivaPub.Federation.Controllers
|
namespace PrivaPub.Federation.Controllers
|
||||||
{
|
{
|
||||||
[ApiController, Route("peasants")]
|
[ApiController, Route("peasants")]
|
||||||
public class PeasantsController : ControllerBase
|
public class PeasantsController : ControllerBase, IAsyncActionFilter
|
||||||
{
|
{
|
||||||
const string ActivityContentType = "application/activity+json; charset=utf-8";
|
const string ActivityContentType = "application/activity+json; charset=utf-8";
|
||||||
const int OutboxSize = 20;
|
const int OutboxSize = 20;
|
||||||
@@ -29,10 +34,14 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
readonly IInboxReceiver _inbox;
|
readonly IInboxReceiver _inbox;
|
||||||
readonly DbEntities _dbEntities;
|
readonly DbEntities _dbEntities;
|
||||||
readonly ILogger<PeasantsController> _logger;
|
readonly ILogger<PeasantsController> _logger;
|
||||||
|
readonly ISignedFetchAuthorizer _fetches;
|
||||||
|
readonly IOptionsMonitor<FederationOptions> _federation;
|
||||||
|
|
||||||
public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities,
|
public PeasantsController(ILocalActorService localActors, IInboxReceiver inbox, DbEntities dbEntities,
|
||||||
ILogger<PeasantsController> logger)
|
ILogger<PeasantsController> logger, ISignedFetchAuthorizer fetches, IOptionsMonitor<FederationOptions> federation)
|
||||||
{
|
{
|
||||||
|
_fetches = fetches;
|
||||||
|
_federation = federation;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_inbox = inbox;
|
_inbox = inbox;
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
@@ -113,6 +122,34 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
|
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet, Route("{actor}/flock")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<JsonNode>();
|
||||||
|
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")]
|
[HttpGet, Route("{actor}/trophies")]
|
||||||
public async Task<IActionResult> Featured(string actor, CancellationToken token)
|
public async Task<IActionResult> Featured(string actor, CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -145,6 +182,12 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
|
public async Task<IActionResult> Post(string actor, string postId, CancellationToken token)
|
||||||
{
|
{
|
||||||
var (local, post) = await PublicPost(actor, postId, 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)
|
if (post == default)
|
||||||
return await Tombstone(actor, postId, token) ?? NotFound();
|
return await Tombstone(actor, postId, token) ?? NotFound();
|
||||||
if (WantsHtml())
|
if (WantsHtml())
|
||||||
@@ -202,6 +245,20 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
return (local, post);
|
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<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
|
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
|
||||||
{
|
{
|
||||||
var local = await _localActors.FindByUserName(actor, token);
|
var local = await _localActors.FindByUserName(actor, token);
|
||||||
@@ -257,6 +314,19 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
|
&& !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)
|
IActionResult Answer(InboxResult result)
|
||||||
{
|
{
|
||||||
if (result.Error != default)
|
if (result.Error != default)
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
readonly IRemotePosts _remotePosts;
|
readonly IRemotePosts _remotePosts;
|
||||||
readonly IFanout _fanout;
|
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;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_remotePosts = remotePosts;
|
_remotePosts = remotePosts;
|
||||||
@@ -41,7 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
{
|
{
|
||||||
var inner = activity["object"];
|
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")
|
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;
|
return;
|
||||||
|
}
|
||||||
var objectUri = Id(inner);
|
var objectUri = Id(inner);
|
||||||
var announceId = Id(activity);
|
var announceId = Id(activity);
|
||||||
if (objectUri == default || announceId == default)
|
if (objectUri == default || announceId == default)
|
||||||
@@ -96,6 +101,66 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
await _fanout.Distribute(reblog, token);
|
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<PostEntity>().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<PostEntity>(deleted.ID);
|
||||||
|
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == deleted.ID);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static List<string> Strings(JsonNode node) => node switch
|
static List<string> Strings(JsonNode node) => node switch
|
||||||
{
|
{
|
||||||
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
|
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
readonly IDomainBlocks _domainBlocks;
|
readonly IDomainBlocks _domainBlocks;
|
||||||
readonly IFanout _fanout;
|
readonly IFanout _fanout;
|
||||||
readonly IRemotePosts _remotePosts;
|
readonly IRemotePosts _remotePosts;
|
||||||
|
readonly IGroupDistributor _groups;
|
||||||
|
|
||||||
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
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;
|
_fanout = fanout;
|
||||||
_remotePosts = remotePosts;
|
_remotePosts = remotePosts;
|
||||||
_dbEntities = dbEntities;
|
_dbEntities = dbEntities;
|
||||||
@@ -85,10 +87,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
var parent = string.IsNullOrEmpty(note.InReplyTo)
|
var parent = string.IsNullOrEmpty(note.InReplyTo)
|
||||||
? default
|
? default
|
||||||
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||||
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted
|
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
|
||||||
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group)
|
if (circle != default)
|
||||||
: default;
|
{
|
||||||
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
|
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;
|
group = default;
|
||||||
|
|
||||||
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
|
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
|
||||||
@@ -128,11 +137,22 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
await _fanout.Distribute(post, token);
|
await _fanout.Distribute(post, token);
|
||||||
if (conversation != default)
|
if (conversation != default)
|
||||||
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
await DB.Default.Update<DmGroup>().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);
|
||||||
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
|
|
||||||
await _delivery.EnqueueToFollowers(group, announce, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task<bool> 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<bool> MayPost(LocalActor community, string actorUri, CancellationToken token)
|
||||||
|
{
|
||||||
|
var entity = await _dbEntities.Groups.MatchID(community.Id).ExecuteFirstAsync(token);
|
||||||
|
return entity?.PostingPolicy switch
|
||||||
|
{
|
||||||
|
PostingPolicy.Anyone => true,
|
||||||
|
PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token),
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
|
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
readonly IRemoteActorService _remoteActors;
|
readonly IRemoteActorService _remoteActors;
|
||||||
readonly IDeliveryService _delivery;
|
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;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
@@ -67,6 +70,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
return;
|
return;
|
||||||
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
await DB.Default.DeleteAsync<PostEntity>(post.ID);
|
||||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
await DB.Default.DeleteAsync<TimelineEntry>(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<PostEntity>(p => p.ReblogOfPostId == post.ID);
|
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
|
||||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
|
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||||
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using MongoDB.Entities;
|
|||||||
|
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Objects;
|
using PrivaPub.Federation.Objects;
|
||||||
|
using PrivaPub.Federation.Outbox;
|
||||||
using PrivaPub.Models.Post;
|
using PrivaPub.Models.Post;
|
||||||
using PrivaPub.Models.User;
|
using PrivaPub.Models.User;
|
||||||
using PrivaPub.StaticServices;
|
using PrivaPub.StaticServices;
|
||||||
@@ -21,9 +22,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
readonly DbEntities _dbEntities;
|
readonly DbEntities _dbEntities;
|
||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
readonly IRemoteActorService _remoteActors;
|
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;
|
_dbEntities = dbEntities;
|
||||||
_localActors = localActors;
|
_localActors = localActors;
|
||||||
_remoteActors = remoteActors;
|
_remoteActors = remoteActors;
|
||||||
@@ -79,6 +82,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
||||||
post.UpdateDate = DateTime.UtcNow;
|
post.UpdateDate = DateTime.UtcNow;
|
||||||
await DB.Default.SaveAsync(post, token);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<string>() ?? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,8 +32,10 @@ namespace PrivaPub.Federation.Outbox
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
|
public async Task<IReadOnlyList<string>> 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<string>();
|
return Array.Empty<string>();
|
||||||
|
if (post.Visibility == PostVisibility.Circle)
|
||||||
|
return await CircleMembers(post.GroupId, token);
|
||||||
|
|
||||||
var inboxes = new List<string>();
|
var inboxes = new List<string>();
|
||||||
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
||||||
@@ -58,14 +60,19 @@ namespace PrivaPub.Federation.Outbox
|
|||||||
inboxes.Add(parentAuthor.InboxURL);
|
inboxes.Add(parentAuthor.InboxURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
|
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
||||||
{
|
|
||||||
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<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token)
|
||||||
|
{
|
||||||
|
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
||||||
|
if (circle == default)
|
||||||
|
return Array.Empty<string>();
|
||||||
|
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
|
||||||
|
if (remote.Count == 0)
|
||||||
|
return Array.Empty<string>();
|
||||||
|
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)
|
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
|
||||||
|
|||||||
@@ -109,17 +109,25 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
|
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
|
||||||
var (to, cc) = post.Visibility switch
|
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.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
|
||||||
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
|
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
|
||||||
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
|
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
|
||||||
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
|
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
|
||||||
};
|
};
|
||||||
if (group != default)
|
if (group is { IsCircle: false })
|
||||||
cc.Add(group.Uri);
|
cc.Add(group.Uri);
|
||||||
|
|
||||||
var note = NoteBody(post, author, to, cc, inReplyTo);
|
var note = NoteBody(post, author, to, cc, inReplyTo);
|
||||||
if (group != default)
|
if (group != default)
|
||||||
note["audience"] = group.Uri;
|
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;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +149,13 @@ namespace PrivaPub.Federation.Rendering
|
|||||||
return note;
|
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)
|
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
|
||||||
{
|
{
|
||||||
var content = post.ContentHtml ?? Html(post.Text);
|
var content = post.ContentHtml ?? Html(post.Text);
|
||||||
|
|||||||
@@ -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<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
|
||||||
|
{
|
||||||
|
readonly IRemoteActorService _remoteActors;
|
||||||
|
|
||||||
|
public SignedFetchAuthorizer(IRemoteActorService remoteActors)
|
||||||
|
{
|
||||||
|
_remoteActors = remoteActors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ForeignAvatar> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
{
|
{
|
||||||
public bool AllowPrivateNetworks { get; set; }
|
public bool AllowPrivateNetworks { get; set; }
|
||||||
public bool AllowPlainHttp { get; set; }
|
public bool AllowPlainHttp { get; set; }
|
||||||
|
public bool SecureMode { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,7 @@ using PrivaPub.Services.ClientToServer.Private;
|
|||||||
using PrivaPub.Services.ClientToServer.Public;
|
using PrivaPub.Services.ClientToServer.Public;
|
||||||
using PrivaPub.Federation.Actors;
|
using PrivaPub.Federation.Actors;
|
||||||
using PrivaPub.Federation.Outbox;
|
using PrivaPub.Federation.Outbox;
|
||||||
|
using PrivaPub.Federation.Signing;
|
||||||
using PrivaPub.Federation.Inbox;
|
using PrivaPub.Federation.Inbox;
|
||||||
using PrivaPub.Federation.Moderation;
|
using PrivaPub.Federation.Moderation;
|
||||||
using PrivaPub.Federation.Inbox.Handlers;
|
using PrivaPub.Federation.Inbox.Handlers;
|
||||||
@@ -65,6 +66,8 @@ namespace PrivaPub.Middleware
|
|||||||
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
.AddSingleton<IRemoteActorService, RemoteActorService>()
|
||||||
.AddSingleton<IDeliveryService, DeliveryService>()
|
.AddSingleton<IDeliveryService, DeliveryService>()
|
||||||
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
|
.AddSingleton<IOutboxPublisher, OutboxPublisher>()
|
||||||
|
.AddSingleton<IGroupDistributor, GroupDistributor>()
|
||||||
|
.AddSingleton<ISignedFetchAuthorizer, SignedFetchAuthorizer>()
|
||||||
.AddSingleton<IFanout, Fanout>()
|
.AddSingleton<IFanout, Fanout>()
|
||||||
.AddSingleton<IInboxReceiver, InboxReceiver>()
|
.AddSingleton<IInboxReceiver, InboxReceiver>()
|
||||||
.AddSingleton<IActivityHandler, FollowHandler>()
|
.AddSingleton<IActivityHandler, FollowHandler>()
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ namespace PrivaPub.Models.Group
|
|||||||
public string PublicKey { get; set; }
|
public string PublicKey { get; set; }
|
||||||
|
|
||||||
public GroupKind Kind { get; set; }
|
public GroupKind Kind { get; set; }
|
||||||
|
public PostingPolicy PostingPolicy { get; set; }
|
||||||
|
public List<string> Rules { get; set; } = new();
|
||||||
public bool IsDiscoverable { get; set; } = true;
|
public bool IsDiscoverable { get; set; } = true;
|
||||||
public bool ManuallyApprovesMembers { get; set; }
|
public bool ManuallyApprovesMembers { get; set; }
|
||||||
public string OwnerAvatarId { get; set; }
|
public string OwnerAvatarId { get; set; }
|
||||||
@@ -49,6 +51,13 @@ namespace PrivaPub.Models.Group
|
|||||||
Community
|
Community
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum PostingPolicy
|
||||||
|
{
|
||||||
|
Followers,
|
||||||
|
Anyone,
|
||||||
|
Moderators
|
||||||
|
}
|
||||||
|
|
||||||
public enum GroupRole
|
public enum GroupRole
|
||||||
{
|
{
|
||||||
Member,
|
Member,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace PrivaPub.Models.Post
|
|||||||
public string InReplyToAccountId { get; set; }
|
public string InReplyToAccountId { get; set; }
|
||||||
public string GroupId { get; set; }
|
public string GroupId { get; set; }
|
||||||
public string ConversationId { get; set; }//DmGroup.ID of a direct post
|
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 string ReblogOfPostId { get; set; }
|
||||||
public PostVisibility Visibility { get; set; }
|
public PostVisibility Visibility { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ namespace PrivaPub.Services
|
|||||||
PrivateKey = privateKey,
|
PrivateKey = privateKey,
|
||||||
PublicKey = publicKey,
|
PublicKey = publicKey,
|
||||||
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
|
Kind = form.IsCommunity ? GroupKind.Community : GroupKind.Circle,
|
||||||
|
PostingPolicy = Policy(form.PostingPolicy) ?? PostingPolicy.Followers,
|
||||||
IsDiscoverable = form.IsDiscoverable,
|
IsDiscoverable = form.IsDiscoverable,
|
||||||
ManuallyApprovesMembers = form.ManuallyApprovesMembers,
|
ManuallyApprovesMembers = form.ManuallyApprovesMembers,
|
||||||
OwnerAvatarId = form.AvatarId,
|
OwnerAvatarId = form.AvatarId,
|
||||||
@@ -157,6 +158,8 @@ namespace PrivaPub.Services
|
|||||||
group.Description = form.Description;
|
group.Description = form.Description;
|
||||||
if (form.IsDiscoverable.HasValue)
|
if (form.IsDiscoverable.HasValue)
|
||||||
group.IsDiscoverable = form.IsDiscoverable.Value;
|
group.IsDiscoverable = form.IsDiscoverable.Value;
|
||||||
|
if (Policy(form.PostingPolicy) is { } policy)
|
||||||
|
group.PostingPolicy = policy;
|
||||||
if (form.ManuallyApprovesMembers.HasValue)
|
if (form.ManuallyApprovesMembers.HasValue)
|
||||||
group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value;
|
group.ManuallyApprovesMembers = form.ManuallyApprovesMembers.Value;
|
||||||
if (form.RemoveInvitationPassword)
|
if (form.RemoveInvitationPassword)
|
||||||
@@ -316,6 +319,14 @@ namespace PrivaPub.Services
|
|||||||
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
|
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId)
|
||||||
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
|
&& 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}";
|
static string NewInvitationCode() => $"{Guid.NewGuid():N}{Guid.NewGuid():N}";
|
||||||
|
|
||||||
ViewGroup ToView(GroupEntity group, string avatarId)
|
ViewGroup ToView(GroupEntity group, string avatarId)
|
||||||
@@ -331,6 +342,7 @@ namespace PrivaPub.Services
|
|||||||
Url = actor.Uri,
|
Url = actor.Uri,
|
||||||
Handle = actor.Handle,
|
Handle = actor.Handle,
|
||||||
IsCommunity = group.Kind == GroupKind.Community,
|
IsCommunity = group.Kind == GroupKind.Community,
|
||||||
|
PostingPolicy = group.PostingPolicy.ToString().ToLowerInvariant(),
|
||||||
IsDiscoverable = group.IsDiscoverable,
|
IsDiscoverable = group.IsDiscoverable,
|
||||||
ManuallyApprovesMembers = group.ManuallyApprovesMembers,
|
ManuallyApprovesMembers = group.ManuallyApprovesMembers,
|
||||||
IsOwner = group.OwnerAvatarId == avatarId,
|
IsOwner = group.OwnerAvatarId == avatarId,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages
|
|||||||
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
|
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
|
||||||
{
|
{
|
||||||
Actor = await _localActors.FindByUserName(user, 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();
|
return NotFound();
|
||||||
if (WantsActivityJson())
|
if (WantsActivityJson())
|
||||||
return Redirect(Actor.Uri);
|
return Redirect(Actor.Uri);
|
||||||
|
|||||||
Reference in new issue
Block a user