Everything on, phase 2: circle posts for everyone, private posts on signed refetch, browsers past SecureMode

Circles (owner decision 2026-10-04: fix them for compatibility):
- Mastodon 4.7 and GoToSocial drop a post that names none of their accounts, and a circle post named only the circle
  and its /flock. OutboxPublisher.Publish now sends each member a copy that also names that member in `cc`, on the
  activity and on the object, and names no other member. The Create, every Update (edit, poll, quote approval, policy,
  through the new PublishUpdate) and the Delete (StatusService.Remove now uses Publish) all go that way.
- UpdateOf renders with the post's group, so an Update keeps a circle post's `audience` and a community post's `Page`
  and title.
- A reply to a circle post stays in the circle, whichever client wrote it.
- A circle post can no longer quote a post that needs permission: asking would show the circle post to its author.

Posts that are not public, on refetch (SignedFetchAuthorizer.MayRead):
- Followers-only, direct and circle posts are served to a signed request from someone they were for, or from the
  instance actor of a server where one of them lives. That is a follower or an addressed account, an addressed
  account, or a member. Everyone else still gets 404.
- Once deleted they answer those readers 410. Mastodon deletes its copy when a refetch answers 404.
- A circle refetch names the requesting member, or the members on the requesting server, as the delivered copy did.
- /grunts/create-{id} serves the same.
- /peasants/{name}/whispers/{id}, a DM's `context`, was never routed. It is now the conversation's posts, for its
  participants only.

SecureMode lets browsers through to the redirect to the public page, instead of answering them 401.

653 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 03:01:14 +02:00
1 parent 5f56681c01
commit fcd35f5043
14 files changed
+339 -46

No files matched your search

@@ -273,7 +273,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.LocalQuotePolicy, policy).ExecuteAsync(token);
post.LocalQuotePolicy = policy;
if (!post.IsLocalOnly)
await _outbox.Publish(Me, post, ActivityPubRenderer.UpdateOf(post, Me, $"policy-{DateTime.UtcNow.Ticks}"), token);
await _outbox.PublishUpdate(Me, post, $"policy-{DateTime.UtcNow.Ticks}", token);
return Json(await _mapper.Status(post, MyId, token));
}
+1 -1
View File
@@ -233,7 +233,7 @@ namespace PrivaPub.Domain.Statuses
return JobOutcome.Done;
await Closing(post, author, token);
if (!post.IsLocalOnly)
await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, $"poll-{DateTime.UtcNow.Ticks}"), token);
await _outbox.PublishUpdate(author, post, $"poll-{DateTime.UtcNow.Ticks}", token);
return JobOutcome.Done;
}
+1 -1
View File
@@ -239,7 +239,7 @@ namespace PrivaPub.Domain.Statuses
.ExecuteAsync(token);
await DB.Default.Update<PostEntity>().MatchID(quoted.ID).Modify(b => b.Inc(p => p.QuotesCount, 1)).ExecuteAsync(token);
if (!post.IsLocalOnly)
await _outbox.Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, "quote-approved"), token);
await _outbox.PublishUpdate(author, post, "quote-approved", token);
return true;
}
+9 -1
View File
@@ -119,6 +119,11 @@ namespace PrivaPub.Domain.Statuses
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
if (parent != default && !await VisibilityPolicy.CanSee(parent, author.Id, token))
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
// a reply in a circle stays in the circle, whichever client wrote it: Mastodon clients know nothing of groups
if (group == default && parent is { Visibility: PostVisibility.Circle } && !string.IsNullOrEmpty(parent.GroupId)
&& await _dbEntities.Groups.MatchID(parent.GroupId).ExecuteFirstAsync(token) is { DeletionAt: null } parentCircle
&& await MayPost(parentCircle, author, token))
group = _localActors.FromGroup(parentCircle);
PostEntity quoted = default;
var quotePermission = QuotePermission.Denied;
@@ -161,6 +166,9 @@ namespace PrivaPub.Domain.Statuses
var visibility = located ? PostVisibility.LocalGeo
: group is { IsCircle: true } ? PostVisibility.Circle
: draft.Visibility is PostVisibility.Circle or PostVisibility.LocalGeo ? PostVisibility.Public : draft.Visibility;
// asking a quoted post's author for permission would show them the circle post
if (visibility == PostVisibility.Circle && quoted != default && quotePermission != QuotePermission.Granted)
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: A circle post can only quote a post that needs no permission");
var post = new PostEntity
{
GroupUserId = author.Id,
@@ -340,7 +348,7 @@ 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);
await _outbox.Publish(author, post, delete, token);//the post in hand still has its group and mentions
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);
@@ -17,6 +17,7 @@ using PrivaPub.Domain.Privacy;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Models.Group;
@@ -185,11 +186,13 @@ 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)
if (post == default && await SignedPost(actor, postId, token) is { } signed)
{
var circleNote = ActivityPubRenderer.Note(circlePost.Post, circlePost.Author, circlePost.Circle, circlePost.Post.InReplyToURI);
circleNote["@context"] = ActivityPubRenderer.Context();
return Activity(circleNote);
if (signed.Post.DeletedAt.HasValue)
return TombstoneOf(signed.Author, signed.Post);
var signedNote = (JsonObject)signed.Note.DeepClone();
signedNote["@context"] = ActivityPubRenderer.Context();
return Activity(signedNote);
}
if (post == default)
return await Tombstone(actor, postId, token) ?? NotFound();
@@ -243,7 +246,36 @@ namespace PrivaPub.Federation.Controllers
if (!activityId.StartsWith("create-", StringComparison.Ordinal))
return NotFound();
var (local, post) = await PublicPost(actor, activityId["create-".Length..], token);
return post == default ? NotFound() : Activity(await CreateFor(post, local, token));
if (post != default)
return Activity(await CreateFor(post, local, token));
return await SignedPost(actor, activityId["create-".Length..], token) is { Post.DeletedAt: null } signed
? Activity(ActivityPubRenderer.Create(signed.Author, (JsonObject)signed.Note.DeepClone(), activityId))
: NotFound();
}
// A DM's `context`: the conversation's posts, for its participants (or their servers' instance actors) only.
[HttpGet, Route("{actor}/whispers/{conversationId}")]
public async Task<IActionResult> Whispers(string actor, string conversationId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true, Kind: LocalActorKind.Person })
return NotFound();
var uri = local.ConversationUri(conversationId);
var conversation = await _dbEntities.DmGroups.Match(g => g.ID == conversationId && g.ConversationURI == uri && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (conversation == default || !SignedFetchAuthorizer.MayReadConversation(conversation, await _fetches.Requester(Request, token)))
return NotFound();
var posts = await _dbEntities.Posts
.Match(p => p.ConversationId == conversationId && p.Visibility == PostVisibility.Direct && !p.DeletedAt.HasValue)
.Sort(p => p.CreationDate, Order.Ascending)
.ExecuteAsync(token);
return Activity(new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
["id"] = uri,
["type"] = "OrderedCollection",
["totalItems"] = posts.Count,
["orderedItems"] = new JsonArray(posts.Select(p => (JsonNode)p.ObjectURI).ToArray())
});
}
[HttpPost, Route("{actor}/mouth"), EnableRateLimiting(RateLimiting.Inbox)]
@@ -275,18 +307,35 @@ namespace PrivaPub.Federation.Controllers
return (local, post);
}
async Task<(LocalActor Author, LocalActor Circle, PostEntity Post)?> CirclePost(string actor, string postId, CancellationToken token)
// A followers-only, direct or circle post (deleted ones included), for a signed request from someone it was for
// (SignedFetchAuthorizer.MayRead). It is rendered as it was delivered: a circle post also names, in cc, the requesting
// member, or the members on the requesting instance actor's server.
async Task<(LocalActor Author, PostEntity Post, JsonObject Note)?> SignedPost(string actor, string postId, CancellationToken token)
{
var local = await _localActors.FindByUserName(actor, token);
if (local is not { Kind: LocalActorKind.Person })
if (local is not { IsFederated: true, 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)
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null
&& (p.Visibility == PostVisibility.FollowersOnly || p.Visibility == PostVisibility.Direct || 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)))
if (post == default)
return default;
return (local, _localActors.FromGroup(circle), post);
var requester = await _fetches.Requester(Request, token);
if (!await _fetches.MayRead(post, requester, token))
return default;
if (post.Visibility == PostVisibility.Circle)
{
var circle = await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
var note = ActivityPubRenderer.Note(post, local, _localActors.FromGroup(circle), post.InReplyToURI);
return (local, post, OutboxPublisher.Naming(note, SignedFetchAuthorizer.CircleReaders(circle, requester)));
}
var rendered = post.Visibility == PostVisibility.Direct
? ActivityPubRenderer.DirectNote(post, local, Array.Empty<(string, string)>(), post.ContextURI)
: ActivityPubRenderer.Note(post, local, default, post.InReplyToURI);
rendered["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
rendered["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
return (local, post, rendered);
}
async Task<IActionResult> Tombstone(string actor, string postId, CancellationToken token)
@@ -298,8 +347,11 @@ namespace PrivaPub.Federation.Controllers
.Match(p => p.ID == postId && p.GroupUserId == local.Id && !p.IsFederatedCopy && p.DeletedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
.ExecuteFirstAsync(token);
if (deleted == default)
return default;
return deleted == default ? default : TombstoneOf(local, deleted);
}
ContentResult TombstoneOf(LocalActor local, PostEntity deleted)
{
var tombstone = new JsonObject
{
["@context"] = ActivityPubRenderer.ActivityStreams,
@@ -349,7 +401,9 @@ namespace PrivaPub.Federation.Controllers
{
if (HttpMethods.IsGet(Request.Method))
Response.Headers.Vary = "Accept";
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method)
// SecureMode asks every reader of ActivityPub documents for a signature, except for the instance actor, whose key
// peers need first, and except for browsers, which only get redirected to the public pages
if (_federation.CurrentValue.SecureMode && HttpMethods.IsGet(Request.Method) && !WantsHtml()
&& !string.Equals(context.RouteData.Values["actor"] as string, LocalActorService.InstanceUserName, StringComparison.OrdinalIgnoreCase)
&& await _fetches.Requester(Request, HttpContext.RequestAborted) == default)
{
+43 -4
View File
@@ -14,6 +14,7 @@ namespace PrivaPub.Federation.Outbox
{
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token);
Task PublishProfile(LocalActor actor, CancellationToken token);
}
@@ -73,25 +74,63 @@ namespace PrivaPub.Federation.Outbox
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
}
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token)
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
(await CircleRecipients(groupId, token)).Select(r => r.Inbox).Distinct(StringComparer.Ordinal).ToList();
async Task<IReadOnlyList<(string Member, string Inbox)>> CircleRecipients(string groupId, CancellationToken token)
{
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
if (circle == default)
return Array.Empty<string>();
return Array.Empty<(string, string)>();
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
if (remote.Count == 0)
return Array.Empty<string>();
return Array.Empty<(string, 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();
.Where(a => !string.IsNullOrEmpty(a.InboxURL))
.Select(a => (a.ActorURI, a.InboxURL))
.ToList();
}
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
{
if (post.Visibility == PostVisibility.Circle)
{
foreach (var (member, inbox) in await CircleRecipients(post.GroupId, token))
await _delivery.Enqueue(author, new[] { inbox }, Naming(activity, new[] { member }), token);
return;
}
var inboxes = await Audience(author, post, token);
if (inboxes.Count > 0)
await _delivery.Enqueue(author, inboxes, activity, token);
}
public async Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token)
{
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
await Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, group, reason), token);
}
// Mastodon and GoToSocial keep a post only when it names one of their own accounts, and a circle post names only the
// circle. So each member's copy, or a member's refetch, also names that member in cc (owner decision 2026-10-04):
// it tells each member nothing but that they are in the circle.
public static JsonObject Naming(JsonObject activityOrObject, IEnumerable<string> members)
{
var copy = (JsonObject)activityOrObject.DeepClone();
Name(copy, members);
if (copy["object"] is JsonObject inner && inner.ContainsKey("to"))
Name(inner, members);
return copy;
}
static void Name(JsonObject node, IEnumerable<string> members)
{
var cc = node["cc"] as JsonArray ?? new JsonArray();
foreach (var member in members)
if (!cc.Any(c => c?.GetValue<string>() == member))
cc.Add(member);
node["cc"] = cc;
}
public async Task PublishProfile(LocalActor actor, CancellationToken token)
{
var document = ActivityPubRenderer.Actor(actor);
@@ -178,11 +178,11 @@ namespace PrivaPub.Federation.Rendering
return note;
}
public static JsonObject UpdateOf(PostEntity post, LocalActor author, string reason)
public static JsonObject UpdateOf(PostEntity post, LocalActor author, LocalActor group, string reason)
{
var note = post.Visibility == PostVisibility.Direct
? DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
: Note(post, author, default, post.InReplyToURI);
: Note(post, author, group, post.InReplyToURI);
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
return new JsonObject
@@ -1,23 +1,34 @@
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Signing
{
public interface ISignedFetchAuthorizer
{
Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token);
Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token);
}
// Who may refetch a post that is not public (owner decision 2026-10-04). A server that received a followers-only post,
// a DM or a circle post refetches it, signed by the account it was for or by its instance actor, and Mastodon deletes its
// copy when the refetch answers 404. So the post is served to a signed request from someone it was for, or from the
// instance actor of a server where someone it was for lives; everyone else still gets 404.
public class SignedFetchAuthorizer : ISignedFetchAuthorizer
{
readonly IRemoteActorService _remoteActors;
readonly DbEntities _dbEntities;
public SignedFetchAuthorizer(IRemoteActorService remoteActors)
public SignedFetchAuthorizer(IRemoteActorService remoteActors, DbEntities dbEntities)
{
_remoteActors = remoteActors;
_dbEntities = dbEntities;
}
public async Task<ForeignAvatar> Requester(HttpRequest request, CancellationToken token)
@@ -33,8 +44,48 @@ namespace PrivaPub.Federation.Signing
return actor != default && HttpSignatures.Verify(actor.PublicKey, signingString, parameters.Signature) ? actor : default;
}
public async Task<bool> MayRead(PostEntity post, ForeignAvatar requester, CancellationToken token)
{
if (post == default || requester == default)
return false;
switch (post.Visibility)
{
case PostVisibility.Public or PostVisibility.Unlisted:
return true;
case PostVisibility.Circle:
var circle = string.IsNullOrEmpty(post.GroupId) ? default : await _dbEntities.Groups.MatchID(post.GroupId).ExecuteFirstAsync(token);
return circle != default && MayReadCircle(circle, requester);
case PostVisibility.Direct:
return Addressed(post).Any(uri => Is(requester, uri));
case PostVisibility.FollowersOnly:
if (Addressed(post).Any(uri => Is(requester, uri)))
return true;
var followers = await _dbEntities.Followers
.Match(f => f.LocalActorId == post.GroupUserId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted)
.ExecuteAsync(token);
return followers.Any(f => Is(requester, f.ActorURI));
default:
return false;
}
}
static IEnumerable<string> Addressed(PostEntity post) =>
post.To.Concat(post.Cc).Concat(post.Mentions.Select(m => m.ActorURI)).Where(uri => !string.IsNullOrEmpty(uri));
// the account itself, or the instance actor of the server it lives on
static bool Is(ForeignAvatar requester, string actorUri) =>
actorUri == requester.ActorURI || requester.AvatarType == AvatarType.Application && Origin.Same(actorUri, requester.ActorURI);
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)));
requester != default && circle.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId));
public static bool MayReadConversation(Models.Group.DmGroup conversation, ForeignAvatar requester) =>
requester != default && conversation.Members.Any(m => m.IsForeign && Is(requester, m.AvatarId));
// the members a refetch names in cc: the requesting member, or the members on an instance actor's server
public static IReadOnlyList<string> CircleReaders(GroupEntity circle, ForeignAvatar requester) =>
requester == default
? Array.Empty<string>()
: circle.Members.Where(m => m.IsForeign && Is(requester, m.AvatarId)).Select(m => m.AvatarId).ToList();
}
}