Mobilizon's organiser sends the Create, Update and Delete of an event attributed to the group, which announces the Event itself. PrivaPub refused the organiser's activities as misattributed (400) and kept the event through the group's Announce, so an edit was lost and a deletion left the event in place. An object attributed to another account of the actor's own server is now that server's to vouch for: created or edited as the server has it, under the account it is attributed to, and deleted once the server answers 404 or 410. Attributed to an account elsewhere, it is still refused. Checked against Mobilizon 5.2.4 in the pasture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
262 lines
10 KiB
C#
262 lines
10 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;
|
|
|
|
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)
|
|
{
|
|
_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);
|
|
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>()
|
|
};
|
|
}
|
|
}
|