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
@@ -1,4 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
using MongoDB.Entities;
|
||||
@@ -15,12 +17,15 @@ using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Infrastructure;
|
||||
|
||||
namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
[ApiController, Route("peasants")]
|
||||
public class PeasantsController : ControllerBase
|
||||
public class PeasantsController : ControllerBase, IAsyncActionFilter
|
||||
{
|
||||
const string ActivityContentType = "application/activity+json; charset=utf-8";
|
||||
const int OutboxSize = 20;
|
||||
@@ -29,10 +34,14 @@ namespace PrivaPub.Federation.Controllers
|
||||
readonly IInboxReceiver _inbox;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILogger<PeasantsController> _logger;
|
||||
readonly ISignedFetchAuthorizer _fetches;
|
||||
readonly IOptionsMonitor<FederationOptions> _federation;
|
||||
|
||||
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;
|
||||
_inbox = inbox;
|
||||
_dbEntities = dbEntities;
|
||||
@@ -113,6 +122,34 @@ namespace PrivaPub.Federation.Controllers
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/flock")]
|
||||
public async Task<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")]
|
||||
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)
|
||||
{
|
||||
var (local, post) = await PublicPost(actor, postId, token);
|
||||
if (post == default && await CirclePost(actor, postId, token) is { } circlePost)
|
||||
{
|
||||
var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI);
|
||||
circleNote["@context"] = ActivityPubRenderer.Context();
|
||||
return Activity(circleNote);
|
||||
}
|
||||
if (post == default)
|
||||
return await Tombstone(actor, postId, token) ?? NotFound();
|
||||
if (WantsHtml())
|
||||
@@ -202,6 +245,20 @@ namespace PrivaPub.Federation.Controllers
|
||||
return (local, post);
|
||||
}
|
||||
|
||||
async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
if (local is not { Kind: LocalActorKind.Person })
|
||||
return default;
|
||||
var post = await _dbEntities.Posts
|
||||
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility == PostVisibility.Circle)
|
||||
.ExecuteFirstAsync(token);
|
||||
var circle = post == default ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
|
||||
if (circle == default || !SignedFetchAuthorizer.MayReadCircle(circle, await _fetches.Requester(Request, token)))
|
||||
return default;
|
||||
return (local, _localActors.FromGroup(circle), post);
|
||||
}
|
||||
|
||||
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
@@ -257,6 +314,19 @@ namespace PrivaPub.Federation.Controllers
|
||||
&& !accept.Contains("ld+json", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
|
||||
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
|
||||
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
|
||||
{
|
||||
context.Result = StatusCode(StatusCodes.Status401Unauthorized);
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
IActionResult Answer(InboxResult result)
|
||||
{
|
||||
if (result.Error != default)
|
||||
|
||||
Reference in new issue
Block a user