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

+60 -1
View File
@@ -322,11 +322,66 @@ namespace PrivaPub.Tests.Http
Assert.Equal(new[] { circle.Uri, circle.Uri + "/flock" }, Strings(asMember.Json["to"]));
Assert.DoesNotContain(ActivityPubRenderer.Public, asMember.Text);
Assert.Equal(HttpStatusCode.OK, asMemberServer.Status);
// a refetch names the member, as the member's copy did; the instance actor sees the members on its server only
Assert.Equal(new[] { member.Id }, Strings(asMember.Json["cc"]));
Assert.Equal(new[] { member.Id }, Strings(asMemberServer.Json["cc"]));
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path)).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(path, Browser)).Status);
foreach (var outsider in new[] { neighbour, stranger, strangerServer })
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(outsider.SignedGet(path))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch($"/peasants/{owner.UserName}/grunts/create-{post.ID}")).Status);
var create = $"/peasants/{owner.UserName}/grunts/create-{post.ID}";
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(create)).Status);
var createAsMember = await _client.Fetch(member.SignedGet(create));
Assert.Equal(HttpStatusCode.OK, createAsMember.Status);
Assert.Equal(post.ObjectURI, createAsMember.Json["object"]!["id"]!.GetValue<string>());
Assert.Equal(new[] { member.Id }, Strings(createAsMember.Json["object"]!["cc"]));
}
[Fact]
public async Task Followers_only_and_direct_posts_are_served_to_signed_fetches_from_those_they_were_for()
{
var token = TestContext.Current.CancellationToken;
var owner = await _host.Persona(await _host.SignUp(), "private");
var follower = new RemoteActor(_peer, "follower");
var followerServer = new RemoteActor(_peer, "instance", type: "Application");
var recipient = new RemoteActor(_peer, "recipient");
var stranger = new RemoteActor(_peer, "stranger", _peer.B);
_peer.WebFinger(recipient);
await DB.Default.SaveAsync(new Follower
{
LocalActorId = owner.Id,
LocalActorKind = LocalActorKind.Person,
ActorURI = follower.Id,
InboxURL = follower.Id + "/inbox"
}, token);
var quiet = await _host.Publish(owner, "for followers", PostVisibility.FollowersOnly);
var direct = await _host.Publish(owner, new StatusDraft { Text = $"@{recipient.Handle()} just you", PlainText = true, Visibility = PostVisibility.Direct });
var quietPath = $"/peasants/{owner.UserName}/scribbles/{quiet.ID}";
var directPath = $"/peasants/{owner.UserName}/scribbles/{direct.ID}";
Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(follower.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.OK, (await _client.Fetch(followerServer.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status);
var asRecipient = await _client.Fetch(recipient.SignedGet(directPath));
Assert.Equal(HttpStatusCode.OK, asRecipient.Status);
Assert.Contains(recipient.Id, Strings(asRecipient.Json["to"]));
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(follower.SignedGet(directPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(directPath)).Status);
// the DM's context is the conversation, for its participants only
var context = PathOf(asRecipient.Json["context"]!.GetValue<string>());
var conversation = await _client.Fetch(recipient.SignedGet(context));
Assert.Equal(HttpStatusCode.OK, conversation.Status);
Assert.Equal(new[] { direct.ObjectURI }, Strings(conversation.Json["orderedItems"]));
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(context))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(context)).Status);
// once deleted, those it was for learn it is gone; everyone else still learns nothing
await _host.Remove(owner, quiet);
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(follower.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(stranger.SignedGet(quietPath))).Status);
Assert.Equal(HttpStatusCode.NotFound, (await _client.Fetch(quietPath)).Status);
}
[Fact]
@@ -463,6 +518,10 @@ namespace PrivaPub.Tests.Http
Assert.Equal("Application", instance.Json["type"]!.GetValue<string>());
foreach (var path in paths[..^1])
Assert.True((await client.Fetch(reader.SignedGet(path))).Status == HttpStatusCode.OK, $"{path} refused a signed GET");
// a browser is not asked for a signature: it is only sent to the public pages
var browser = await client.Fetch($"/peasants/{persona.UserName}", Browser);
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
Assert.Equal(HttpStatusCode.Redirect, (await client.Fetch($"/peasants/{persona.UserName}/scribbles/{post.ID}", Browser)).Status);
}
}
}