From e9529310fecd72cb717f44c979c1f33a96d359c0 Mon Sep 17 00:00:00 2001 From: thepra Date: Thu, 1 Oct 2026 11:17:14 +0200 Subject: [PATCH] One Post model for every visibility, read from notes the way peers write them Post gains what federation and the Mastodon API need: Visibility (Public, Unlisted, FollowersOnly, Direct, Circle, LocalGeo), the author's account id, to/cc, the Create's id, url, context, quote, InReplyToURI and the parent's author, a separate SpoilerText next to the title, language, mentions, hashtags, remote attachments (alt text, blurhash, focus, size), reply/favourite/reblog counters, revisions, EditedAt and DeletedAt. Direct messages are Posts with Visibility Direct and a ConversationId; migration _005 copies DmPost rows across with their ids and fills the new fields of existing posts. DmPost is left in place so a rollback still sees the old messages. Inbound: - NoteParser reads Note, Article, Page, Question and media types: content, then contentMap, then _misskey_content; summary as the spoiler and name as the title; Mention and Hashtag tags; attachments; a PeerTube-style list attribution prefers the person over the channel; quote URIs. - Addressing classifies like Mastodon, finding followers-only by the author's own followers URL (now stored on ForeignAvatar), not by a "/followers" suffix. - Create keeps a post when a local persona is addressed or mentioned, when it replies to a local post (the parent's reply count goes up) or when a community it follows is addressed; an unsolicited public post is not stored. Update keeps the previous version as a revision. The outbox and object endpoints serve only Public and Unlisted posts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- PrivaPub.ClientModels/Post/ViewPost.cs | 1 + .../Federation/InboxScenarioTests.cs | 90 +++++++- PrivaPub.Tests/Federation/NoteParserTests.cs | 114 ++++++++++ .../Infrastructure/MigrationTests.cs | 16 ++ PrivaPub/Federation/Actors/ActorDocument.cs | 4 + .../Federation/Actors/RemoteActorService.cs | 2 + .../Controllers/PeasantsController.cs | 25 +-- .../Inbox/Handlers/CreateHandler.cs | 202 ++++++++++-------- .../Inbox/Handlers/DeleteHandler.cs | 2 - .../Inbox/Handlers/FollowHandler.cs | 1 - .../Federation/Inbox/Handlers/UndoHandler.cs | 1 - .../Inbox/Handlers/UpdateHandler.cs | 72 ++++--- PrivaPub/Federation/Objects/Addressing.cs | 22 ++ PrivaPub/Federation/Objects/NoteParser.cs | 179 ++++++++++++++++ .../Rendering/ActivityPubRenderer.cs | 3 +- PrivaPub/Infrastructure/Data/Indexes.cs | 2 + .../_005_direct_posts_join_posts.cs | 67 ++++++ PrivaPub/Models/Post/Post.cs | 54 ++++- PrivaPub/Models/Post/PostMedia.cs | 10 +- PrivaPub/Models/User/Avatar.cs | 2 + PrivaPub/Services/PostsService.cs | 100 +++++---- 21 files changed, 777 insertions(+), 192 deletions(-) create mode 100644 PrivaPub.Tests/Federation/NoteParserTests.cs create mode 100644 PrivaPub/Federation/Objects/Addressing.cs create mode 100644 PrivaPub/Federation/Objects/NoteParser.cs create mode 100644 PrivaPub/Infrastructure/Data/Migrations/_005_direct_posts_join_posts.cs diff --git a/PrivaPub.ClientModels/Post/ViewPost.cs b/PrivaPub.ClientModels/Post/ViewPost.cs index 14da3d4..4174503 100644 --- a/PrivaPub.ClientModels/Post/ViewPost.cs +++ b/PrivaPub.ClientModels/Post/ViewPost.cs @@ -8,6 +8,7 @@ namespace PrivaPub.ClientModels.Post public string AuthorActorURI { get; set; } public string GroupId { get; set; } public string DmGroupId { get; set; } + public string Visibility { get; set; } public string AnsweringToPostId { get; set; } public string Title { get; set; } public string Text { get; set; } diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs index 1ef2ead..d219ce3 100644 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -5,6 +5,7 @@ using MongoDB.Entities; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Objects; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Models.Jobs; using PrivaPub.Federation.Outbox; @@ -50,7 +51,7 @@ namespace PrivaPub.Tests.Federation new UndoHandler(db, _local, remote, delivery), new CreateHandler(db, _local, remote, delivery), new DeleteHandler(db, _local, remote, delivery), - new UpdateHandler(db, _local, remote, delivery) + new UpdateHandler(db, _local, remote) }, NullLogger.Instance); } @@ -119,10 +120,10 @@ namespace PrivaPub.Tests.Federation Assert.Equal(202, first.StatusCode); Assert.Equal(202, injected.StatusCode); - var bobDm = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); - var malloryDm = await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token); - Assert.NotEqual(bobDm.GroupId, malloryDm.GroupId); - var bobConversation = await DB.Default.Find().OneAsync(bobDm.GroupId, token); + var bobDm = await DB.Default.Find().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteSingleAsync(token); + var malloryDm = await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteSingleAsync(token); + Assert.NotEqual(bobDm.ConversationId, malloryDm.ConversationId); + var bobConversation = await DB.Default.Find().OneAsync(bobDm.ConversationId, token); Assert.DoesNotContain(bobConversation.Members, m => m.AvatarId == mallory.Id); } @@ -136,9 +137,9 @@ namespace PrivaPub.Tests.Federation await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)); await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri)); - var dms = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteAsync(token); + var dms = await DB.Default.Find().Match(p => p.ActorURI == bob.Id && p.Visibility == PostVisibility.Direct).ExecuteAsync(token); Assert.Equal(2, dms.Count); - Assert.Single(dms.Select(d => d.GroupId).Distinct()); + Assert.Single(dms.Select(d => d.ConversationId).Distinct()); } [Fact] @@ -166,7 +167,7 @@ namespace PrivaPub.Tests.Federation var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id)); Assert.Equal(400, result.StatusCode); - Assert.False(await DB.Default.Find().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token)); } [Fact] @@ -179,7 +180,78 @@ namespace PrivaPub.Tests.Federation var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, objectOrigin: _peer.B)); Assert.Equal(202, result.StatusCode); - Assert.False(await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(p => p.ActorURI == mallory.Id).ExecuteAnyAsync(token)); + } + + static JsonObject PublicCreate(RemoteActor author, string inReplyTo = default, params string[] cc) + { + var id = $"{Origin(author.Id)}/notes/{Guid.NewGuid():N}"; + var note = new JsonObject + { + ["id"] = id, + ["type"] = "Note", + ["attributedTo"] = author.Id, + ["content"] = "

hello

", + ["to"] = new JsonArray(Addressing.Public), + ["cc"] = new JsonArray(cc.Prepend(author.Id + "/followers").Select(c => (JsonNode)c).ToArray()) + }; + if (inReplyTo != default) + note["inReplyTo"] = inReplyTo; + return new JsonObject + { + ["id"] = $"{Origin(author.Id)}/activities/{Guid.NewGuid():N}", + ["type"] = "Create", + ["actor"] = author.Id, + ["object"] = note + }; + } + + [Fact] + public async Task A_public_reply_to_a_local_post_is_kept_and_counted() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var parent = new Post { GroupUserId = alice.Id, AuthorAccountId = alice.Id, ActorURI = alice.Uri, Text = "hi" }; + parent.ID = (string)parent.GenerateNewID(); + parent.ObjectURI = alice.PostUri(parent.ID); + await DB.Default.SaveAsync(parent, token); + var bob = new RemoteActor(_peer, "bob"); + + await Deliver(bob, "/human-centipede", PublicCreate(bob, parent.ObjectURI)); + + var reply = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); + Assert.Equal(PostVisibility.Public, reply.Visibility); + Assert.Equal(parent.ID, reply.AnsweringToPostId); + Assert.Equal(alice.Id, reply.InReplyToAccountId); + Assert.Equal(1, (await DB.Default.Find().OneAsync(parent.ID, token)).RepliesCount); + } + + [Fact] + public async Task A_public_post_nobody_here_asked_for_is_dropped() + { + var token = TestContext.Current.CancellationToken; + var bob = new RemoteActor(_peer, "bob"); + + await Deliver(bob, "/human-centipede", PublicCreate(bob)); + + Assert.False(await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token)); + } + + [Fact] + public async Task A_mention_is_kept_and_linked_to_the_local_persona() + { + var token = TestContext.Current.CancellationToken; + var alice = await LocalAvatar("alice"); + var bob = new RemoteActor(_peer, "bob"); + var create = PublicCreate(bob, default, alice.Uri); + create["object"]!["tag"] = new JsonArray(new JsonObject { ["type"] = "Mention", ["href"] = alice.Uri, ["name"] = "@" + alice.Handle }); + + await Deliver(bob, "/human-centipede", create); + + var post = await DB.Default.Find().Match(p => p.ActorURI == bob.Id).ExecuteSingleAsync(token); + var mention = Assert.Single(post.Mentions); + Assert.True(mention.IsLocal); + Assert.Equal(alice.Id, mention.AccountId); } [Fact] diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs new file mode 100644 index 0000000..cb626f6 --- /dev/null +++ b/PrivaPub.Tests/Federation/NoteParserTests.cs @@ -0,0 +1,114 @@ +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Post; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Federation +{ + public class NoteParserTests + { + const string MastodonNote = """ + { + "id": "https://m.example/users/alice/statuses/1", + "type": "Note", + "summary": "spoilers", + "inReplyTo": "https://privapub.test/peasants/bob/posts/abc", + "published": "2026-09-30T10:00:00Z", + "url": "https://m.example/@alice/1", + "attributedTo": "https://m.example/users/alice", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://m.example/users/alice/followers", "https://privapub.test/peasants/bob"], + "sensitive": true, + "conversation": "tag:m.example,2026-09-30:objectId=1:objectType=Conversation", + "context": "https://m.example/contexts/1", + "content": "

@bob hi #Fedi

", + "contentMap": { "en": "

hi

" }, + "attachment": [{ + "type": "Document", "mediaType": "image/png", "url": "https://files.m.example/a.png", + "name": "a cat", "blurhash": "UBL_:rOpGG-oBUNG,qRj2so|=eE1w^n4S5NH", "focalPoint": [0.5, -2.0], "width": 640, "height": 480 + }], + "tag": [ + { "type": "Mention", "href": "https://privapub.test/peasants/bob", "name": "@bob@privapub.test" }, + { "type": "Hashtag", "href": "https://m.example/tags/fedi", "name": "#Fedi" }, + { "type": "Emoji", "name": ":blob:", "icon": { "type": "Image", "url": "https://files.m.example/blob.png" } } + ] + } + """; + + [Fact] + public void Reads_a_mastodon_note() + { + var note = NoteParser.Parse(JsonNode.Parse(MastodonNote)); + + Assert.Equal("https://m.example/users/alice", note.AttributedTo); + Assert.Equal("spoilers", note.SpoilerText); + Assert.True(note.Sensitive); + Assert.Equal("en", note.Language); + Assert.Equal("https://m.example/@alice/1", note.Url); + Assert.Equal("https://m.example/contexts/1", note.Context); + Assert.DoesNotContain("