A remote post's canReply, canLike and canAnnounce (with the older always and approvalRequired) are kept beside canQuote and judged for each persona: let in at once when the rule names the public, the persona, the author's followers while it follows the author, or the accounts the author follows while the author follows it; asked first when only the manual list names it; refused (422) otherwise. Asked first, a ReplyRequest, LikeRequest or AnnounceRequest with the interaction as its instrument goes to the author alone, and the interaction waits (privapub.approval: pending). The author's Accept brings an authorization, verified on the author's origin as naming the interaction and the post; the reply then goes out with replyAuthorization, the boost with announceAuthorization, the like with likeAuthorization. A Reject leaves the reply ours alone and takes a like or a boost back. As a third party, a reply a policy does not let in at once is kept only with an authorization that verifies. Clients see the rules as GoToSocial's interaction_policy. Checked live against GoToSocial 0.22.1: the scenario's nine new checks pass (64 in all), a reply and a like approved through its interaction requests and a boost refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
271 lines
11 KiB
C#
271 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);
|
|
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>()
|
|
};
|
|
}
|
|
}
|