- A public post that links to a page gets a preview card: from the post's own data first (FEP-8967 Link preview, the object's image, title and summary); otherwise the server reads the page once, 0-60 s after the post arrives, never when someone reads it. OpenGraph and Twitter tags give title, description and image; one LinkPreview per address is cached for 7 days and shared by the whole server, so a fetch never points at a persona. - Lemmy link posts, which carry no title or description, get them filled in. - The page fetch uses the guarded client (public addresses, three redirects, HTML only, first 512 KB). - Federation:FetchLinkPreviews switches page fetching off. - Local public posts get cards too. Checked live: a link to a GoToSocial profile page becomes a card with its title, description and proxied image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
213 lines
8.4 KiB
C#
213 lines
8.4 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;
|
|
|
|
public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery,
|
|
IDomainBlocks domainBlocks, IFanout fanout, IRemotePosts remotePosts, IGroupDistributor groups, IObjectRecords records, IPollService polls, ILinkPreviews previews)
|
|
{
|
|
_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());
|
|
}
|
|
var note = NoteParser.Parse(node);
|
|
if (note == default || note.AttributedTo != author.ActorURI)
|
|
return;
|
|
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);
|
|
return;
|
|
}
|
|
}
|
|
if (await _dbEntities.Posts.Match(p => p.ObjectURI == note.Id).ExecuteAnyAsync(token)
|
|
|| await DB.Default.Find<DeletedObject>().Match(d => d.ObjectURI == note.Id).ExecuteAnyAsync(token))
|
|
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);
|
|
|
|
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)
|
|
{
|
|
if (!await IsCircleMember(circle, author.ActorURI, token))
|
|
return;
|
|
visibility = PostVisibility.Circle;
|
|
}
|
|
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);
|
|
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed)
|
|
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)
|
|
{
|
|
return;
|
|
}
|
|
await _records.Record(note, post, ObjectPath.Delivered, refetched, token);
|
|
await _previews.Wanted(post, token);
|
|
|
|
if (parent != default)
|
|
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 DB.Default.Update<DmGroup>().MatchID(conversation.ID).Modify(g => g.UpdatedAt, DateTime.UtcNow).ExecuteAsync(token);
|
|
if (group is { IsCircle: false })
|
|
await _groups.Announce(group, activity.AsObject(), note.Id, isNewPost: string.IsNullOrEmpty(note.InReplyTo), 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>()
|
|
};
|
|
}
|
|
}
|