Two owner decisions of 2026-10-05, recorded in the roadmap.
Replies passed on ("the fediverse is broken without"): a public or unlisted
reply from another server to a persona's public, unlisted or followers-only
post goes on to the persona's followers as its author's server sent it, as
Mastodon forwards it, never to the replier's own server, never for a
local-only or group post; its edit and deletion follow. Only an activity its
own actor delivered is passed on (Arrival.Raw), so nothing forwarded is
forwarded again. The town checks it as relay.reply cells (specs/relay-five:
882 checks pass); Mastodon takes a passed-on activity only with an LD
signature, which GoToSocial and Akkoma don't add, and the checker knows it.
Events: a persona joins another server's event with a Join and leaves it with
a Leave, both to the organiser only, through
POST /api/privapub/v1/statuses/:id/join|leave; the organiser's Accept or
Reject is routed by our join id and shows as privapub.event.participation.
Events by invitation or taken on another site are refused before anything
is sent. Mobilizon's scenario joins and leaves an event (28 checks) and keeps
one for decePubClient's e2e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
272 lines
11 KiB
C#
272 lines
11 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Domain.Timelines;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Moderation;
|
|
using PrivaPub.Domain.Content;
|
|
using PrivaPub.Domain.Statuses;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Federation.Outbox;
|
|
using PrivaPub.Infrastructure.Ids;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Group;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
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 PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Inbox.Handlers
|
|
{
|
|
public class CreateHandler : IActivityHandler
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IRemoteActorService _remoteActors;
|
|
readonly IDeliveryService _delivery;
|
|
readonly IDomainBlocks _domainBlocks;
|
|
readonly IFanout _fanout;
|
|
readonly IRemotePosts _remotePosts;
|
|
readonly IGroupDistributor _groups;
|
|
readonly IObjectRecords _records;
|
|
readonly IPollService _polls;
|
|
readonly ILinkPreviews _previews;
|
|
readonly IQuoteService _quotes;
|
|
readonly IInteractionApprovals _approvals;
|
|
|
|
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
|
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews, IQuoteService quotes,
|
|
IInteractionApprovals approvals = default)
|
|
{
|
|
_approvals = approvals;
|
|
_quotes = quotes;
|
|
_previews = previews;
|
|
_records = records;
|
|
_polls = polls;
|
|
_groups = groups;
|
|
_fanout = fanout;
|
|
_remotePosts = remotePosts;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
_delivery = delivery;
|
|
_domainBlocks = domainBlocks;
|
|
}
|
|
|
|
public string Type => "Create";
|
|
|
|
public async Task Handle(JsonNode activity, ForeignAvatar author, CancellationToken token)
|
|
{
|
|
var node = activity["object"];
|
|
var refetched = node is not JsonObject || !Origin.Same(Id(node), author.ActorURI);
|
|
if (refetched)
|
|
{
|
|
using var fetched = await _remoteActors.FetchObject(Id(node), token);
|
|
node = fetched == default ? default : JsonNode.Parse(fetched.Root.GetRawText());
|
|
if (node == default)
|
|
Arrival.Drop("fetch-failed");
|
|
}
|
|
var note = NoteParser.Parse(node);
|
|
// made by another account of the author's own server (Mobilizon's organiser creates the group's event): taken
|
|
// as that server has it, under the account it is attributed to
|
|
if (note != default && note.AttributedTo != author.ActorURI && Origin.Same(note.AttributedTo, author.ActorURI))
|
|
{
|
|
if (!refetched)
|
|
{
|
|
using var fetched = await _remoteActors.FetchObject(note.Id, token);
|
|
note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
|
|
refetched = true;
|
|
}
|
|
author = note == default ? default : await _remoteActors.GetActor(note.AttributedTo, refresh: false, token);
|
|
if (author == default)
|
|
{
|
|
Arrival.Drop(note == default ? "fetch-failed" : "author-unavailable");
|
|
return;
|
|
}
|
|
}
|
|
if (note == default || note.AttributedTo != author.ActorURI)
|
|
{
|
|
Arrival.Drop(note == default ? "unparseable" : "misattributed");
|
|
return;
|
|
}
|
|
Arrival.About(note.Type);
|
|
if (note.Title != default && note.InReplyTo != default && ObjectShapes.Text(note.ContentHtml, 1) == default)
|
|
{
|
|
var question = await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && p.Poll != null).ExecuteFirstAsync(token);
|
|
if (question != default)
|
|
{
|
|
await _polls.Receive(question, author, note.Title, Id(activity), token);
|
|
Arrival.Accept("poll-vote");
|
|
Arrival.About(visibility: question.Visibility);
|
|
return;
|
|
}
|
|
}
|
|
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token))
|
|
{
|
|
Arrival.Drop("duplicate");
|
|
return;
|
|
}
|
|
if (await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
|
|
{
|
|
Arrival.Drop("deleted");
|
|
return;
|
|
}
|
|
|
|
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);
|
|
Arrival.About(visibility: visibility);
|
|
|
|
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)
|
|
{
|
|
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();
|
|
|
|
var parent = string.IsNullOrEmpty(note.InReplyTo)
|
|
? default
|
|
: await _dbEntities.Posts.Match(p => p.ObjectURI == note.InReplyTo && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
|
// a reply its parent's author has not let in (GoToSocial's canReply), which a third party must not show
|
|
if (_approvals != default && parent != default && !await _approvals.MayReply(note, author, parent, token))
|
|
{
|
|
Arrival.Drop("reply-not-authorized");
|
|
return;
|
|
}
|
|
var circle = localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: true });
|
|
if (circle != default)
|
|
{
|
|
visibility = PostVisibility.Circle;
|
|
Arrival.About(visibility: visibility);
|
|
if (!await IsCircleMember(circle, author.ActorURI, token))
|
|
{
|
|
Arrival.Drop("not-addressed");
|
|
return;
|
|
}
|
|
}
|
|
var group = circle ?? (visibility is PostVisibility.Public or PostVisibility.Unlisted
|
|
? localTargets.FirstOrDefault(t => t is { Kind: LocalActorKind.Group, IsCircle: false })
|
|
: default);
|
|
if (group is { IsCircle: false } && !await MayPost(group, author.ActorURI, token))
|
|
group = default;
|
|
|
|
var repliesToLocal = parent is { IsFederatedCopy: false } && visibility != PostVisibility.Direct;
|
|
var followed = visibility != PostVisibility.Direct && await _dbEntities.Followings
|
|
.Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
|
|
.ExecuteAnyAsync(token);
|
|
// a reply in the thread of someone followed here, as Mastodon keeps them: what the thread's server forwards
|
|
var repliesToFollowed = !followed && parent is { IsFederatedCopy: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted
|
|
&& await _dbEntities.Followings.Match(f => f.TargetActorURI == parent.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
|
|
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed)
|
|
{
|
|
Arrival.Drop("not-addressed");
|
|
return;
|
|
}
|
|
if (parent == default && visibility != PostVisibility.Direct)
|
|
parent = await _remotePosts.Parent(note.InReplyTo, 1, token);
|
|
|
|
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 = await _remotePosts.Build(note, author, visibility, to, cc, parent, token);
|
|
post.ActivityURI = Id(activity);
|
|
post.GroupId = group?.Id;
|
|
post.ConversationId = conversation?.ID;
|
|
try
|
|
{
|
|
await DB.Default.SaveAsync(post, token);
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
Arrival.Drop("duplicate");
|
|
return;
|
|
}
|
|
Arrival.Accept("stored");
|
|
Arrival.About(local: group ?? persons.FirstOrDefault());
|
|
await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
|
|
await _previews.Wanted(post, token);
|
|
await _quotes.Resolve(post, note, token);
|
|
|
|
if (parent != default && Domain.Privacy.Counted.Reply(post))
|
|
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
|
await _fanout.Distribute(post, token);
|
|
await ReplyRelay.Pass(post, _dbEntities, _localActors, _delivery, token);
|
|
if (conversation != default)
|
|
await Domain.Statuses.ConversationStates.Posted(conversation.ID, post.ID, default, token);
|
|
if (group is { IsCircle: false })
|
|
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: true, token);
|
|
}
|
|
|
|
async Task<bool> IsCircleMember(LocalActor circle, string actorUri, CancellationToken token) =>
|
|
await _dbEntities.Groups.Match(g => g.ID == circle.Id && g.Members.Any(m => m.IsForeign && m.AvatarId == actorUri)).ExecuteAnyAsync(token);
|
|
|
|
async Task<bool> MayPost(LocalActor community, string actorUri, CancellationToken token)
|
|
{
|
|
var entity = await _dbEntities.Groups.MatchID(community.Id).ExecuteFirstAsync(token);
|
|
return entity?.PostingPolicy switch
|
|
{
|
|
PostingPolicy.Anyone => true,
|
|
PostingPolicy.Followers => await IsAcceptedFollower(community, actorUri, token),
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
async Task<DmGroup> FindOrCreateConversation(List<string> participantUris, string context, CancellationToken token)
|
|
{
|
|
var members = new List<GroupMember>();
|
|
foreach (var uri in participantUris.Distinct(StringComparer.Ordinal))
|
|
{
|
|
var local = await _localActors.FindByUri(uri, token);
|
|
var member = local != default && local.Kind == LocalActorKind.Person
|
|
? new GroupMember { AvatarId = local.Id }
|
|
: new GroupMember { AvatarId = uri, IsForeign = true };
|
|
if (members.All(m => m.AvatarId != member.AvatarId || m.IsForeign != member.IsForeign))
|
|
members.Add(member);
|
|
}
|
|
|
|
var key = DmGroup.KeyOf(members);
|
|
var match = await _dbEntities.DmGroups.Match(g => g.ParticipantsKey == key && !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
if (match != default)
|
|
return match;
|
|
|
|
var conversation = new DmGroup { Members = members, ConversationURI = context, ParticipantsKey = key };
|
|
await DB.Default.SaveAsync(conversation, token);
|
|
return conversation;
|
|
}
|
|
|
|
static bool IsCollection(string uri) =>
|
|
uri.EndsWith("/followers", StringComparison.Ordinal) || uri.EndsWith("/following", StringComparison.Ordinal);
|
|
|
|
static IEnumerable<string> Strings(JsonNode node) => node switch
|
|
{
|
|
JsonArray array => array.Select(Id).Where(id => id != default),
|
|
JsonNode single when Id(single) is { } id => new[] { id },
|
|
_ => Enumerable.Empty<string>()
|
|
};
|
|
}
|
|
}
|