Inbound Block was dropped as an unknown type (the pasture's last
Mastodon expected failure, G-0002). Now, as Mastodon does it: the
follows between the blocker and the persona end here too (nothing is
sent back; the blocker already ended its side), the blocker's posts and
notifications are hidden from the persona and kept out of its home, the
persona's posts are no longer addressed to the blocker by mention or
reply, and the relationship says blocked_by. Undo{Block} lifts it. The
block is kept in BlockedBy, unique per persona and blocker.
The Mastodon scenario checks blocked_by instead of expecting a failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
175 lines
7.4 KiB
C#
175 lines
7.4 KiB
C#
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Rendering;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
using PrivaPub.Models.Social;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Outbox
|
|
{
|
|
public interface IOutboxPublisher
|
|
{
|
|
Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token);
|
|
Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token);
|
|
Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token);
|
|
Task PublishProfile(LocalActor actor, CancellationToken token);
|
|
}
|
|
|
|
public class OutboxPublisher : IOutboxPublisher
|
|
{
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IDeliveryService _delivery;
|
|
|
|
public OutboxPublisher(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
|
|
{
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_delivery = delivery;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<string>> Audience(LocalActor author, PostEntity post, CancellationToken token)
|
|
{
|
|
if (post.Visibility == PostVisibility.LocalGeo || post.IsLocalOnly)
|
|
return Array.Empty<string>();
|
|
if (post.Visibility == PostVisibility.Circle)
|
|
return await CircleMembers(post.GroupId, token);
|
|
|
|
var inboxes = new List<string>();
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted or PostVisibility.FollowersOnly)
|
|
inboxes.AddRange(await _delivery.FollowerInboxes(author, token));
|
|
|
|
//nothing is addressed to an account that blocked the author (it would refuse it, and Mastodon counts on that)
|
|
var blockers = (await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == author.Id).ExecuteAsync(token))
|
|
.Select(b => b.ActorURI).ToHashSet(StringComparer.Ordinal);
|
|
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
|
|
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
|
|
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !blockers.Contains(uri))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
foreach (var uri in addressed)
|
|
{
|
|
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == uri).ExecuteFirstAsync(token);
|
|
if (actor != default && !string.IsNullOrEmpty(actor.InboxURL))
|
|
inboxes.Add(actor.InboxURL);
|
|
}
|
|
|
|
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
|
|
{
|
|
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
|
|
if (parentAuthor != default && !string.IsNullOrEmpty(parentAuthor.InboxURL) && !blockers.Contains(parentAuthor.ActorURI))
|
|
inboxes.Add(parentAuthor.InboxURL);
|
|
}
|
|
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.QuotedPostId))
|
|
{
|
|
var quoted = await _dbEntities.Posts.MatchID(post.QuotedPostId).ExecuteFirstAsync(token);
|
|
var quotedAuthor = quoted is { IsFederatedCopy: true }
|
|
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
|
|
: default;
|
|
if (!string.IsNullOrEmpty(quotedAuthor?.InboxURL))
|
|
inboxes.Add(quotedAuthor.InboxURL);
|
|
}
|
|
|
|
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
|
|
}
|
|
|
|
async Task<IReadOnlyList<string>> CircleMembers(string groupId, CancellationToken token) =>
|
|
(await CircleRecipients(groupId, token)).Select(r => r.InboxURL).Distinct(StringComparer.Ordinal).ToList();
|
|
|
|
async Task<IReadOnlyList<ForeignAvatar>> CircleRecipients(string groupId, CancellationToken token)
|
|
{
|
|
var circle = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).ExecuteFirstAsync(token);
|
|
if (circle == default)
|
|
return Array.Empty<ForeignAvatar>();
|
|
var remote = circle.Members.Where(m => m.IsForeign).Select(m => m.AvatarId).ToList();
|
|
if (remote.Count == 0)
|
|
return Array.Empty<ForeignAvatar>();
|
|
return (await _dbEntities.ForeignAvatars.Match(a => remote.Contains(a.ActorURI)).ExecuteAsync(token))
|
|
.Where(a => !string.IsNullOrEmpty(a.InboxURL))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task Publish(LocalActor author, PostEntity post, JsonObject activity, CancellationToken token)
|
|
{
|
|
if (post.Visibility == PostVisibility.Circle)
|
|
{
|
|
foreach (var member in await CircleRecipients(post.GroupId, token))
|
|
await _delivery.Enqueue(author, new[] { member.InboxURL }, Naming(activity, new[] { member }), token);
|
|
return;
|
|
}
|
|
var inboxes = await Audience(author, post, token);
|
|
if (inboxes.Count > 0)
|
|
await _delivery.Enqueue(author, inboxes, activity, token);
|
|
}
|
|
|
|
public async Task PublishUpdate(LocalActor author, PostEntity post, string reason, CancellationToken token)
|
|
{
|
|
var group = string.IsNullOrEmpty(post.GroupId) ? default : await _localActors.FindById(LocalActorKind.Group, post.GroupId, token);
|
|
await Publish(author, post, ActivityPubRenderer.UpdateOf(post, author, group, reason), token);
|
|
}
|
|
|
|
// Mastodon keeps a post only when it names one of its own accounts, and GoToSocial shows a post that is neither
|
|
// public nor for followers only to the accounts it mentions; a circle post names only the circle. So each member's
|
|
// copy, or a member's refetch, also names that member in cc and mentions them, silently, in its tags (owner decision
|
|
// 2026-10-04): it tells each member nothing but that they are in the circle.
|
|
public static JsonObject Naming(JsonObject activityOrObject, IEnumerable<ForeignAvatar> members)
|
|
{
|
|
var named = members.ToList();
|
|
var copy = (JsonObject)activityOrObject.DeepClone();
|
|
if (copy["object"] is JsonObject inner)
|
|
{
|
|
Name(copy, named, mention: false);
|
|
if (inner.ContainsKey("to"))
|
|
Name(inner, named, mention: true);
|
|
}
|
|
else
|
|
Name(copy, named, mention: copy.ContainsKey("attributedTo"));
|
|
return copy;
|
|
}
|
|
|
|
static void Name(JsonObject node, IReadOnlyList<ForeignAvatar> members, bool mention)
|
|
{
|
|
var cc = node["cc"] as JsonArray ?? new JsonArray();
|
|
var tags = node["tag"] as JsonArray ?? new JsonArray();
|
|
foreach (var member in members)
|
|
{
|
|
if (!cc.Any(c => c?.GetValue<string>() == member.ActorURI))
|
|
cc.Add(member.ActorURI);
|
|
if (mention && !tags.Any(t => t?["href"]?.GetValue<string>() == member.ActorURI))
|
|
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = member.ActorURI, ["name"] = Handle(member) });
|
|
}
|
|
node["cc"] = cc;
|
|
if (mention)
|
|
node["tag"] = tags;
|
|
}
|
|
|
|
static string Handle(ForeignAvatar member) =>
|
|
string.IsNullOrEmpty(member.UserName) ? member.ActorURI : $"@{member.UserName}@{new Uri(member.ActorURI).Authority}";
|
|
|
|
public async Task PublishProfile(LocalActor actor, CancellationToken token)
|
|
{
|
|
var document = ActivityPubRenderer.Actor(actor);
|
|
document.Remove("@context");
|
|
var update = new JsonObject
|
|
{
|
|
["@context"] = ActivityPubRenderer.Context(),
|
|
["id"] = actor.ActivityUri($"update-profile-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"),
|
|
["type"] = "Update",
|
|
["actor"] = actor.Uri,
|
|
["to"] = new JsonArray(ActivityPubRenderer.Public),
|
|
["cc"] = new JsonArray(actor.Followers),
|
|
["object"] = document
|
|
};
|
|
await _delivery.EnqueueToFollowers(actor, update, token);
|
|
}
|
|
}
|
|
}
|