using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Federation.Inbox; using PrivaPub.Federation.Objects; using PrivaPub.Infrastructure.Http; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PrivaPub.StaticServices; using System.Text.Json; using System.Text.Json.Nodes; using static PrivaPub.Federation.Objects.ActivityJson; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Federation.Actors { public interface IFeaturedPosts { Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token); Task Sync(ForeignAvatar owner, CancellationToken token); } // What an account elsewhere features (pins): its `featured` collection, read with its counts at most once a day, and // the Add and Remove it sends when it pins or unpins. An account features only its own posts; a community (Lemmy's, // inside its announce) features posts made in it. Shown first on its profile here, as Mastodon shows them. public class FeaturedPosts : IFeaturedPosts { public const int MaxFeatured = 20; readonly DbEntities _dbEntities; readonly IRemoteActorService _remoteActors; readonly IRemotePosts _remotePosts; public FeaturedPosts(DbEntities dbEntities, IRemoteActorService remoteActors, IRemotePosts remotePosts) { _dbEntities = dbEntities; _remoteActors = remoteActors; _remotePosts = remotePosts; } public async Task Receive(JsonNode activity, ForeignAvatar owner, bool add, CancellationToken token) { if (string.IsNullOrEmpty(owner.FeaturedURL) || Id(activity["target"]) != owner.FeaturedURL) { Arrival.Drop("unknown-target"); return; } var objectUri = Id(activity["object"]); if (objectUri == default) { Arrival.Drop("unparseable"); return; } if (!add) { var removed = await DB.Default.DeleteAsync(f => f.ActorURI == owner.ActorURI && f.ObjectURI == objectUri); if (removed is { IsAcknowledged: true, DeletedCount: > 0 }) Arrival.Accept("unfeatured"); else Arrival.Drop("unknown-object"); return; } var post = await Featurable(owner, objectUri, token); if (post == default) { Arrival.Drop("unknown-object"); return; } Arrival.About(post.ObjectType ?? "Note", post.Visibility, post.CreationDate); var first = await DB.Default.Find().Match(f => f.ActorURI == owner.ActorURI).Sort(f => f.Position, Order.Ascending).ExecuteFirstAsync(token); try { await DB.Default.SaveAsync(new RemoteFeatured { ActorURI = owner.ActorURI, ObjectURI = objectUri, PostId = post.ID, Position = (first?.Position ?? 0) - 1 }, token); Arrival.Accept("featured"); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { Arrival.Drop("duplicate"); } } public async Task Sync(ForeignAvatar owner, CancellationToken token) { if (string.IsNullOrEmpty(owner.FeaturedURL) || !Origin.Same(owner.FeaturedURL, owner.ActorURI)) return; var uris = await Items(owner, token); if (uris == default) return; var featured = new List(); foreach (var uri in uris.Distinct(StringComparer.Ordinal).Take(MaxFeatured)) if (await Featurable(owner, uri, token) is { } post) featured.Add(new RemoteFeatured { ActorURI = owner.ActorURI, ObjectURI = uri, PostId = post.ID, Position = featured.Count }); await DB.Default.DeleteAsync(f => f.ActorURI == owner.ActorURI); if (featured.Count > 0) await DB.Default.SaveAsync(featured, token); } // the post, held or fetched from its own server, when the owner may feature it async Task Featurable(ForeignAvatar owner, string objectUri, CancellationToken token) { var isGroup = owner.AvatarType is AvatarType.Group; if (!isGroup && !Origin.Same(objectUri, owner.ActorURI)) return default; var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token) ?? await _remotePosts.StoreContext(objectUri, 0, token); return post switch { null => default, { ReblogOfPostId: not null } => default, _ when isGroup => post.AudienceURI == owner.ActorURI ? post : default, _ => post.ActorURI == owner.ActorURI ? post : default }; } // the collection's items, in its order, from its first page when it pages: links or embedded objects alike async Task> Items(ForeignAvatar owner, CancellationToken token) { using var scope = HttpScope.For("collection"); using var collection = await _remoteActors.FetchObject(owner.FeaturedURL, token); if (collection == default || collection.Root.ValueKind != JsonValueKind.Object) return default; var items = ItemsOf(collection.Root); if (items.Count > 0 || !collection.Root.TryGetProperty("first", out var first)) return items; if (first.ValueKind == JsonValueKind.Object) return ItemsOf(first); var page = first.ValueKind == JsonValueKind.String ? first.GetString() : default; if (page == default || !Origin.Same(page, owner.ActorURI)) return items; using var fetched = await _remoteActors.FetchObject(page, token); return fetched == default || fetched.Root.ValueKind != JsonValueKind.Object ? items : ItemsOf(fetched.Root); } static List ItemsOf(JsonElement collection) { var items = new List(); foreach (var name in new[] { "orderedItems", "items" }) if (collection.TryGetProperty(name, out var list) && list.ValueKind == JsonValueKind.Array) foreach (var item in list.EnumerateArray()) if (item.ValueKind == JsonValueKind.String) items.Add(item.GetString()); else if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String) items.Add(id.GetString()); return items; } } }