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:
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>()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,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
|
||||
@@ -61,7 +60,6 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
}
|
||||
|
||||
await DB.Default.DeleteAsync<PostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
|
||||
await DB.Default.DeleteAsync<DmPostEntity>(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,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
|
||||
|
||||
@@ -15,7 +15,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
|
||||
|
||||
@@ -2,69 +2,83 @@ using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Federation.Outbox;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Group;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
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
|
||||
{
|
||||
public class UpdateHandler : IActivityHandler
|
||||
{
|
||||
const int MaxRevisions = 20;
|
||||
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery)
|
||||
public UpdateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
|
||||
{
|
||||
_dbEntities = dbEntities;
|
||||
_localActors = localActors;
|
||||
_remoteActors = remoteActors;
|
||||
_delivery = delivery;
|
||||
}
|
||||
|
||||
public string Type => "Update";
|
||||
|
||||
public async Task Handle(JsonNode activity, ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
var update = activity;
|
||||
|
||||
var inner = update["object"];
|
||||
var inner = activity["object"];
|
||||
if (Id(inner) == actor.ActorURI)
|
||||
{
|
||||
await _remoteActors.GetActor(actor.ActorURI, refresh: true, token);
|
||||
return;
|
||||
}
|
||||
if (inner is not JsonObject || Id(inner["attributedTo"]) != actor.ActorURI || !Origin.Same(Id(inner), actor.ActorURI))
|
||||
if (inner is not JsonObject || !Origin.Same(Id(inner), actor.ActorURI))
|
||||
return;
|
||||
var note = NoteParser.Parse(inner);
|
||||
if (note == default || note.AttributedTo != actor.ActorURI)
|
||||
return;
|
||||
|
||||
var objectUri = Id(inner);
|
||||
var text = ContentSanitizer.Html(Value(inner, "content"));
|
||||
await DB.Default.Update<PostEntity>()
|
||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
||||
.Modify(p => p.Text, text)
|
||||
.Modify(p => p.ContentHtml, text)
|
||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.Update<DmPostEntity>()
|
||||
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
|
||||
.Modify(p => p.Text, text)
|
||||
.Modify(p => p.ContentHtml, text)
|
||||
.Modify(p => p.UpdateDate, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
return;
|
||||
}
|
||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||
if (post == default || post.DeletedAt.HasValue)
|
||||
return;
|
||||
|
||||
post.Revisions.Add(new PostRevision
|
||||
{
|
||||
Title = post.Title,
|
||||
SpoilerText = post.SpoilerText,
|
||||
ContentHtml = post.ContentHtml,
|
||||
HasContentWarning = post.HasContentWarning,
|
||||
EditedAt = post.EditedAt ?? post.CreationDate
|
||||
});
|
||||
if (post.Revisions.Count > MaxRevisions)
|
||||
post.Revisions.RemoveRange(0, post.Revisions.Count - MaxRevisions);
|
||||
|
||||
var mentions = new List<PostMention>();
|
||||
foreach (var mention in note.Mentions)
|
||||
{
|
||||
var local = await _localActors.FindByUri(mention.ActorURI, token);
|
||||
mentions.Add(new PostMention { ActorURI = mention.ActorURI, Handle = mention.Handle, IsLocal = local != default, AccountId = local?.Id });
|
||||
}
|
||||
|
||||
post.Title = note.Title;
|
||||
post.SpoilerText = note.SpoilerText;
|
||||
post.HasContentWarning = note.Sensitive;
|
||||
post.Text = note.ContentHtml;
|
||||
post.ContentHtml = note.ContentHtml;
|
||||
post.Language = note.Language;
|
||||
post.Tags = note.Tags.ToList();
|
||||
post.Mentions = mentions;
|
||||
post.Media = note.Attachments.ToList();
|
||||
post.EditedAt = note.Updated ?? DateTime.UtcNow;
|
||||
post.UpdateDate = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user