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
+652
-58
No files matched your search
@@ -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<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)
|
||||
{
|
||||
if (ids == default || ids.Count == 0)
|
||||
|
||||
Reference in new issue
Block a user