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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:17:14 +02:00
1 parent 796f06acf4
commit e9529310fe
21 files changed
+777 -192

No files matched your search

@@ -1,3 +1,4 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
@@ -15,7 +16,6 @@ using System.Text.Json.Nodes;
using static PrivaPub.Federation.Inbox.ForeignMembers;
using static PrivaPub.Federation.Objects.ActivityJson;
using DmPostEntity = PrivaPub.Models.Post.DmPost;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox.Handlers
@@ -37,105 +37,129 @@ namespace PrivaPub.Federation.Inbox.Handlers
public string Type => "Create";
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
{
var create = activity;
var author = actor;
var note = create["object"];
if (note is not JsonObject || !Origin.Same(Id(note), author.ActorURI))
var node = activity["object"];
if (node is not JsonObject || !Origin.Same(Id(node), author.ActorURI))
{
using var fetched = await _remoteActors.FetchObject(Id(note), token);
note = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
using var fetched = await _remoteActors.FetchObject(Id(node), token);
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
}
if (note is not JsonObject || Value(note, "type") is not ("Note" or "Article" or "Page" or "Question"))
var note = NoteParser.Parse(node);
if (note == default || note.AttributedTo != author.ActorURI)
return;
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
return;
var objectUri = Id(note);
if (string.IsNullOrEmpty(objectUri) || Id(note["attributedTo"]) != author.ActorURI)
return;
var addressed = Addresses(note).Concat(Addresses(create)).Distinct(StringComparer.Ordinal).ToList();
var isPublic = addressed.Contains(ActivityPubRenderer.Public) || addressed.Contains("as:Public") || addressed.Contains("Public");
var inReplyTo = Id(note["inReplyTo"]);
var to = note.To.Concat(Strings(activity["to"])).Distinct(StringComparer.Ordinal).ToList();
var cc = note.Cc.Concat(Strings(activity["cc"])).Distinct(StringComparer.Ordinal).ToList();
var visibility = Addressing.Classify(to, cc, author.FollowersURL);
var addressed = to.Concat(cc)
.Concat(Addresses(activity))
.Concat(note.Mentions.Select(m => m.ActorURI))
.Append(note.Audience)
.Where(a => a != default && !Addressing.IsPublic(a) && a != author.FollowersURL)
.Distinct(StringComparer.Ordinal)
.ToList();
var localTargets = new List<LocalActor>();
foreach (var uri in addressed.Concat(new[] { Id(note["audience"]) }).Where(u => u != default).Distinct())
foreach (var uri in addressed)
{
var local = await _localActors.FindByUri(uri, token);
if (local is { IsFederated: true } && localTargets.All(l => l.Id != local.Id))
localTargets.Add(local);
}
var persons = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
if (isPublic || addressed.Any(a => a == author.ActorURI + "/followers" || a.EndsWith("/followers")))
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))
group = default;
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal)
return;
var mentions = new List<PostMention>();
foreach (var mention in note.Mentions)
{
var group = localTargets.FirstOrDefault(t => t.Kind == LocalActorKind.Group);
if (group != default && !await IsAcceptedFollower(group, author.ActorURI, token))
group = default;
if (group == default && localTargets.All(t => t.Kind != LocalActorKind.Person))
return;
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return;
var html = ContentSanitizer.Html(Value(note, "content"));
var post = new PostEntity
var local = await _localActors.FindByUri(mention.ActorURI, token);
mentions.Add(new PostMention
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = group?.Id,
Title = Value(note, "summary") ?? Value(note, "name"),
Text = html,
ContentHtml = html,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
IsFederatedCopy = true,
CreationDate = DateTime.TryParse(Value(note, "published"), out var published) ? published.ToUniversalTime() : DateTime.UtcNow
};
ActorURI = mention.ActorURI,
Handle = mention.Handle,
IsLocal = local != default,
AccountId = local?.Id
});
}
DmGroup conversation = default;
if (visibility == PostVisibility.Direct)
{
var participants = persons.Select(p => p.Uri)
.Concat(addressed.Where(a => !IsCollection(a)))
.Append(author.ActorURI)
.ToList();
conversation = await FindOrCreateConversation(participants, Origin.Same(note.Context, author.ActorURI) ? note.Context : default, token);
}
var post = new PostEntity
{
ObjectURI = note.Id,
ActivityURI = Id(activity),
ActorURI = author.ActorURI,
AuthorAccountId = author.ID,
Visibility = visibility,
To = to,
Cc = cc,
Url = note.Url,
ContextURI = note.Context,
QuoteURI = note.QuoteUri,
GroupId = group?.Id,
ConversationId = conversation?.ID,
Title = note.Title,
SpoilerText = note.SpoilerText,
HasContentWarning = note.Sensitive,
Text = note.ContentHtml,
ContentHtml = note.ContentHtml,
ContentFormat = ContentFormat.Html,
Language = note.Language,
Mentions = mentions,
Tags = note.Tags.ToList(),
Media = note.Attachments.ToList(),
InReplyToURI = note.InReplyTo,
AnsweringToPostId = parent?.ID,
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsFederatedCopy = true,
CreationDate = note.Published,
UpdateDate = note.Updated ?? note.Published,
EditedAt = note.Updated
};
try
{
await DB.Default.SaveAsync(post, token);
if (group != default)
{
var announce = ActivityPubRenderer.Announce(group, objectUri, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
}
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return;
}
var recipients = localTargets.Where(t => t.Kind == LocalActorKind.Person).ToList();
if (recipients.Count == 0)
return;
if (await _dbEntities.DmPosts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return;
var participants = recipients.Select(r => r.Uri)
.Concat(addressed.Where(a => a != ActivityPubRenderer.Public && !a.EndsWith("/followers") && !a.EndsWith("/following")))
.Append(author.ActorURI)
.ToList();
var context = Value(note, "context") ?? Value(note, "conversation");
var dmGroup = await FindOrCreateDmGroup(participants, Origin.Same(context, author.ActorURI) ? context : default, token);
var dmHtml = ContentSanitizer.Html(Value(note, "content"));
var dm = new DmPostEntity
if (parent != default)
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
if (conversation != default)
await DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
if (group != default)
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = dmGroup.ID,
Title = Value(note, "summary"),
Text = dmHtml,
ContentHtml = dmHtml,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
AnsweringToPostId = inReplyTo,
IsFederatedCopy = true,
CreationDate = DateTime.TryParse(Value(note, "published"), out var dmPublished) ? dmPublished.ToUniversalTime() : DateTime.UtcNow
};
await DB.Default.SaveAsync(dm, token);
await DB.Default.Update<DmGroup>().MatchID(dmGroup.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
return;
}
var announce = ActivityPubRenderer.Announce(group, note.Id, $"announce-{post.ID}");
await _delivery.EnqueueToFollowers(group, announce, token);
}
}
async Task<DmGroup> FindOrCreateDmGroup(List<string> participantUris, string context, CancellationToken token)
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
{
var members = new List<GroupMember>();
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
@@ -153,17 +177,19 @@ async Task<DmGroup> FindOrCreateDmGroup(List<string> participantUris, string con
if (match != default)
return match;
var dmGroup = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
await DB.Default.SaveAsync(dmGroup, token);
return dmGroup;
var conversation = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
await DB.Default.SaveAsync(conversation, token);
return conversation;
}
async Task<string> LocalPostId(string uri, CancellationToken token)
static bool IsCollection(string uri) =>
uri.EndsWith("/followers", StringComparison.Ordinal) || uri.EndsWith("/following", StringComparison.Ordinal);
static IEnumerable<string> Strings(JsonNode node) => node switch
{
if (string.IsNullOrEmpty(uri) || !uri.StartsWith(_localActors.BaseAddress + "/peasants/", StringComparison.OrdinalIgnoreCase))
return default;
var id = uri[(uri.LastIndexOf('/') + 1)..];
return await _dbEntities.Posts.MatchID(id).ExecuteAnyAsync(token) ? id : default;
}
JsonArray array => array.Select(Id).Where(id => id != default),
JsonNode single when Id(single) is { } id => new[] { id },
_ => Enumerable.Empty<string>()
};
}
}