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:
thepraandClaude Opus 5.5 committed 2026-10-01 12:30:57 +02:00
1 parent 0ccbcd558f
commit a5d9a89445
32 files changed
+652 -58

No files matched your search

@@ -29,6 +29,8 @@ namespace PrivaPub.Federation.Actors
public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; }
public bool IsFederated { get; init; } = true;
public bool IsCircle { get; init; }
public bool PostingRestrictedToModerators { get; init; }
public bool IsBot { get; init; }
public bool Indexable { get; init; }
public AvatarSettings Settings { get; init; } = new();
@@ -44,6 +46,8 @@ namespace PrivaPub.Federation.Actors
public string Following => $"{Uri}/stalking";
public string Featured => $"{Uri}/trophies";
public string FeaturedTags => $"{Uri}/tattoos";
public string Flock => $"{Uri}/flock";
public string Wardens => $"{Uri}/wardens";
public string SharedInbox => $"{BaseAddress}/human-centipede";
public string Domain => new Uri(BaseAddress).Authority;
public string Handle => $"{UserName}@{Domain}";
@@ -219,9 +223,10 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = group.ThumbnailURL,
PrivateKeyPem = group.PrivateKey,
PublicKeyPem = group.PublicKey,
Discoverable = group.IsDiscoverable,
ManuallyApprovesFollowers = group.ManuallyApprovesMembers,
IsFederated = group.Kind == GroupKind.Community,
Discoverable = group.Kind == GroupKind.Community && group.IsDiscoverable,
ManuallyApprovesFollowers = group.Kind == GroupKind.Circle || group.ManuallyApprovesMembers,
IsCircle = group.Kind == GroupKind.Circle,
PostingRestrictedToModerators = group.PostingPolicy == PostingPolicy.Moderators,
Published = group.CreationDate,
BaseAddress = BaseAddress
};
@@ -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)
@@ -26,9 +26,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemotePosts _remotePosts;
readonly IFanout _fanout;
readonly IRemoteActorService _remoteActors;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout)
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors)
{
_remoteActors = remoteActors;
_dbEntities = dbEntities;
_localActors = localActors;
_remotePosts = remotePosts;
@@ -41,7 +43,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
{
var inner = activity["object"];
if (inner is JsonObject && Value(inner, "type") is "Create" or "Update" or "Delete" or "Like" or "Dislike" or "Undo" or "Add" or "Remove" or "Block")
{
await GroupActivity(inner, actor, token);
return;
}
var objectUri = Id(inner);
var announceId = Id(activity);
if (objectUri == default || announceId == default)
@@ -96,6 +101,66 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(reblog, token);
}
async Task GroupActivity(JsonNode inner, ForeignAvatar group, CancellationToken token)
{
if (group.AvatarType != AvatarType.Group
|| !await _dbEntities.Followings.Match(f => f.TargetActorURI == group.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token))
return;
var objectUri = Id(inner["object"]);
switch (Value(inner, "type"))
{
case "Create" when objectUri != default:
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return;
var post = await _remotePosts.StoreContext(objectUri, 0, token);
if (post == default)
return;
await DB.Default.Update<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
{
JsonArray array => array.Select(Id).Where(id => id != default).ToList(),
@@ -33,10 +33,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly IDomainBlocks _domainBlocks;
readonly IFanout _fanout;
readonly IRemotePosts _remotePosts;
readonly IGroupDistributor _groups;
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts)
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups)
{
_groups = groups;
_fanout = fanout;
_remotePosts = remotePosts;
_dbEntities = dbEntities;
@@ -85,10 +87,17 @@ namespace PrivaPub.Federation.Inbox.Handlers
var parent = string.IsNullOrEmpty(note.InReplyTo)
? default
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
var group = visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group)
: default;
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
if (circle != default)
{
if (!await IsCircleMember(circle, author.ActorURI, token))
return;
visibility = PostVisibility.Circle;
}
var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false })
: default);
if (group is { IsCircle: false } && !await MayPost(group, author.ActorURI, token))
group = default;
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
@@ -128,11 +137,22 @@ namespace PrivaPub.Federation.Inbox.Handlers
await _fanout.Distribute(post, token);
if (conversation != default)
await DB.Default.Update<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);
}
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
{
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
}
PostingPolicy.Anyone => true,
PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token),
_ => false
};
}
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
@@ -26,9 +26,12 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IDeliveryService _delivery;
readonly IGroupDistributor _groups;
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
public DeleteHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
IGroupDistributor groups)
{
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -67,6 +70,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
return;
await DB.Default.DeleteAsync<PostEntity>(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);
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
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.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
@@ -21,9 +22,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IRemoteActorService _remoteActors;
readonly IGroupDistributor _groups;
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IGroupDistributor groups)
{
_groups = groups;
_dbEntities = dbEntities;
_localActors = localActors;
_remoteActors = remoteActors;
@@ -79,6 +82,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
post.EditedAt = note.Updated ?? DateTime.UtcNow;
post.UpdateDate = DateTime.UtcNow;
await DB.Default.SaveAsync(post, token);
if (!string.IsNullOrEmpty(post.GroupId) && await _localActors.FindById(Models.Federation.LocalActorKind.Group, post.GroupId, token) is { IsCircle: false } community)
await _groups.Announce(community, activity.AsObject(), post.ObjectURI, isNewPost: false, token);
}
}
}
@@ -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);
}
}
}
+15 -8
View File
@@ -32,8 +32,10 @@ namespace PrivaPub.Federation.Outbox
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>();
if (post.Visibility == PostVisibility.Circle)
return await CircleMembers(post.GroupId, token);
var inboxes = new List<string>();
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
@@ -58,16 +60,21 @@ namespace PrivaPub.Federation.Outbox
inboxes.Add(parentAuthor.InboxURL);
}
if (!string.IsNullOrEmpty(post.GroupId) && post.Visibility is PostVisibility.Public or PostVisibility.Unlisted)
{
var group = await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
if (group is { IsFederated: true })
inboxes.AddRange(await _delivery.FollowerInboxes(group, token));
}
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
async Task<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)
{
var inboxes = await Audience(author, post, token);
@@ -109,17 +109,25 @@ namespace PrivaPub.Federation.Rendering
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
var (to, cc) = post.Visibility switch
{
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions)),
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
};
if (group != default)
if (group is { IsCircle: false })
cc.Add(group.Uri);
var note = NoteBody(post, author, to, cc, inReplyTo);
if (group != default)
note["audience"] = group.Uri;
else if (!string.IsNullOrEmpty(post.AudienceURI))
note["audience"] = post.AudienceURI;
if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo))
{
note["type"] = "Page";
note["name"] = post.Title ?? Headline(post);
}
return note;
}
@@ -141,6 +149,13 @@ namespace PrivaPub.Federation.Rendering
return note;
}
static string Headline(PostEntity post)
{
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim();
return text.Length <= 100 ? text : text[..97] + "...";
}
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
{
var content = post.ContentHtml ?? Html(post.Text);
@@ -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)));
}
}