Files
SocialPub/PrivaPub/Federation/Outbox/OutboxPublisher.cs
T

168 lines
7.0 KiB
C#
Raw Normal View History

using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Post;
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));
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))
.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))
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);
}
}
}