diff --git a/PrivaPub.Tests/Http/MastodonWordSearchTests.cs b/PrivaPub.Tests/Http/MastodonWordSearchTests.cs index 41f8a2a..3954da0 100644 --- a/PrivaPub.Tests/Http/MastodonWordSearchTests.cs +++ b/PrivaPub.Tests/Http/MastodonWordSearchTests.cs @@ -1,6 +1,12 @@ +using MongoDB.Entities; + +using PrivaPub.Federation.Objects; +using PrivaPub.Models.Social; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; +using System.Text.Json.Nodes; + namespace PrivaPub.Tests.Http { // Status search by words, as Mastodon scopes it: the persona's own posts and those it boosted, favourited, bookmarked @@ -47,6 +53,67 @@ namespace PrivaPub.Tests.Http (await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=1&offset=1")).Ok().Body["statuses"]!.AsArray().Select(s => s.Text("id"))); } + // FEP-268d: a remote account's searchableBy outranks its indexable, and a post's own outranks its author's + [Fact] + public async Task A_remote_searchable_by_decides_before_indexable() + { + var token = TestContext.Current.CancellationToken; + await using var peer = await Peer.Start(); + var reader = await _host.Mastodon("reader"); + var keeper = await _host.Mastodon("keeper"); + var word = $"s{Guid.NewGuid():N}"[..12]; + + async Task Author(string name, bool indexable, Func searchableBy) + { + var author = new RemoteActor(peer, name); + var document = author.Document(); + document["indexable"] = indexable; + if (searchableBy != default) + document["searchableBy"] = searchableBy(author); + peer.Serve($"/users/{author.Name}", document.ToJsonString()); + await DB.Default.SaveAsync(new Following { AvatarId = keeper.Id, TargetActorURI = author.Id, State = FollowState.Accepted }, token); + return author; + } + async Task Says(RemoteActor author, string text, JsonArray searchableBy = default) + { + var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}"; + var note = new JsonObject + { + ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = author.Id, ["content"] = $"

{text}

", + ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["published"] = DateTime.UtcNow.ToString("O") + }; + if (searchableBy != default) + note["searchableBy"] = searchableBy; + using var client = _host.Client(); + await client.SendAsync(author.SignedPost("/human-centipede", new JsonObject + { + ["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = author.Id, + ["to"] = new JsonArray(Addressing.Public), ["cc"] = new JsonArray(author.Id + "/followers"), ["object"] = note + }), token); + Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token)); + return (await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token)).ID; + } + + var open = await Says(await Author("open", indexable: false, _ => new JsonArray(Addressing.Public)), $"open {word}"); + var closed = await Says(await Author("closed", indexable: true, _ => new JsonArray()), $"closed {word}"); + var friendly = await Author("friendly", indexable: false, a => new JsonArray(a.Id + "/followers")); + var friends = await Says(friendly, $"friends {word}"); + var quiet = await Author("quiet", indexable: false, default); + var perPost = await Says(quiet, $"per post {word}", new JsonArray(Addressing.Public)); + var plain = await Says(quiet, $"plain {word}"); + + static List Found(JsonNode body) => body["statuses"]!.AsArray().Select(s => s.Text("id")).ToList(); + var before = Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body); + Assert.Contains(open, before); + Assert.Contains(perPost, before); + Assert.DoesNotContain(closed, before); + Assert.DoesNotContain(friends, before); + Assert.DoesNotContain(plain, before); + + await DB.Default.SaveAsync(new Following { AvatarId = reader.Id, TargetActorURI = friendly.Id, State = FollowState.Accepted }, token); + Assert.Contains(friends, Found((await reader.Client.Get($"/api/v2/search?q={word}&type=statuses&limit=40")).Ok().Body)); + } + // a handle asks for its account: no posts sharing its words, and no hashtag made of it [Fact] public async Task A_handle_finds_its_account_and_neither_posts_nor_a_hashtag() diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 81d30f3..69a1172 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -105,15 +105,30 @@ namespace PrivaPub.Api.Mastodon.Controllers mine.UnionWith((await _dbEntities.Posts.Match(p => p.AuthorAccountId == MyId && ids.Contains(p.ReblogOfPostId) && !p.DeletedAt.HasValue).ExecuteAsync(token)) .Select(p => p.ReblogOfPostId)); var authors = candidates.Select(p => p.AuthorAccountId ?? p.GroupUserId).Where(a => a != default).Distinct().ToList(); - var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID) - .Concat((await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID) && f.IsIndexable).ExecuteAsync(token)).Select(f => f.ID)) - .ToHashSet(); + var indexable = (await _dbEntities.Avatars.Match(a => authors.Contains(a.ID) && a.Settings.IsIndexable).ExecuteAsync(token)).Select(a => a.ID).ToHashSet(); + // a remote author's searchableBy (FEP-268d) outranks its indexable, and a post's own outranks both + var remoteAuthors = (await _dbEntities.ForeignAvatars.Match(f => authors.Contains(f.ID)).ExecuteAsync(token)).ToDictionary(f => f.ID); + var stating = remoteAuthors.Values.Where(f => f.SearchableBy != default).Select(f => f.ActorURI) + .Concat(candidates.Where(p => p.SearchableBy != default).Select(p => p.ActorURI)) + .Where(uri => uri != default).Distinct().ToList(); + var followed = stating.Count == 0 + ? new HashSet() + : (await _dbEntities.Followings.Match(f => f.AvatarId == MyId && stating.Contains(f.TargetActorURI) && f.State == Models.Social.FollowState.Accepted) + .ExecuteAsync(token)).Select(f => f.TargetActorURI).ToHashSet(); + bool Searchable(PostEntity post, string author) + { + var remote = remoteAuthors.GetValueOrDefault(author); + var rule = post.SearchableBy ?? remote?.SearchableBy; + if (rule != default) + return Federation.Objects.ObjectShapes.Searchable(rule, remote?.FollowersURL, followed.Contains(post.ActorURI ?? string.Empty)); + return remote?.IsIndexable == true || indexable.Contains(author); + } var found = new List(); foreach (var post in candidates) { var author = post.AuthorAccountId ?? post.GroupUserId; var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId) - || post.Visibility == PostVisibility.Public && indexable.Contains(author); + || post.Visibility == PostVisibility.Public && Searchable(post, author); if (reachable && await VisibilityPolicy.CanSee(post, MyId, token)) found.Add(post); if (found.Count >= offset + limit) diff --git a/PrivaPub/Federation/Actors/ActorDocument.cs b/PrivaPub/Federation/Actors/ActorDocument.cs index b07a1c4..6cea60a 100644 --- a/PrivaPub/Federation/Actors/ActorDocument.cs +++ b/PrivaPub/Federation/Actors/ActorDocument.cs @@ -38,6 +38,7 @@ namespace PrivaPub.Federation.Actors public string MovedTo { get; init; } public string Moderators { get; init; } public List Flairs { get; init; } = new(); + public List SearchableBy { get; init; } public List AlsoKnownAs { get; init; } = new();//the accounts it says it also is (a Move's target names the moved one) public DateTime? Published { get; init; } public List Emojis { get; init; } = new(); @@ -96,6 +97,7 @@ namespace PrivaPub.Federation.Actors MovedTo = RemoteActorService.Text(root, "movedTo"), Flairs = type != "Group" ? new() : ObjectShapes.Flairs(root.TryGetProperty("tag", out var labels) ? JsonNode.Parse(labels.GetRawText()) : default, root.TryGetProperty("lemmy:tagsForPosts", out var older) ? JsonNode.Parse(older.GetRawText()) : default), + SearchableBy = root.TryGetProperty("searchableBy", out var searchable) ? ObjectShapes.SearchableBy(JsonNode.Parse(searchable.GetRawText())) : default, Moderators = type == "Group" ? RemoteActorService.Text(root, "attributedTo") ?? RemoteActorService.Text(root, "moderators") : default, AlsoKnownAs = root.TryGetProperty("alsoKnownAs", out var aliases) ? Uris(aliases) : new(), Published = DateTimeOffset.TryParse(RemoteActorService.Text(root, "published"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var published) diff --git a/PrivaPub/Federation/Actors/RemoteActorService.cs b/PrivaPub/Federation/Actors/RemoteActorService.cs index 0355527..8e2a2d1 100644 --- a/PrivaPub/Federation/Actors/RemoteActorService.cs +++ b/PrivaPub/Federation/Actors/RemoteActorService.cs @@ -236,6 +236,7 @@ namespace PrivaPub.Federation.Actors .Modify(a => a.MovedToURL, Origin.Of(actor.MovedTo) == default ? default : actor.MovedTo) .Modify(a => a.ModeratorsURL, Origin.Same(actor.Moderators, actor.Id) ? actor.Moderators : default) .Modify(a => a.Flairs, actor.Flairs) + .Modify(a => a.SearchableBy, actor.SearchableBy) .Modify(a => a.AlsoKnownAs, actor.AlsoKnownAs) .Modify(a => a.Published, actor.Published) .Modify(a => a.Emojis, actor.Emojis) diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index b520a9f..eef3006 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -39,6 +39,7 @@ namespace PrivaPub.Federation.Inbox post.Event = note.Event ?? post.Event; post.Place = note.Place; post.Flairs = await RemotePosts.Flairs(note, post.AudienceURI, token); + post.SearchableBy = note.SearchableBy; if (note.CommentsEnabled is { } enabled) post.LockedAt = enabled ? null : post.LockedAt ?? DateTime.UtcNow; if (!IsEdit(note, post)) diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 353a3bc..52f946e 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -106,6 +106,7 @@ namespace PrivaPub.Federation.Inbox Excerpt = note.Excerpt, Source = note.Source, Poll = note.Poll, + SearchableBy = note.SearchableBy, QuotePolicy = note.QuotePolicy, ReplyPolicy = note.ReplyPolicy, LikePolicy = note.LikePolicy, diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs index d4e47ea..4ce828e 100644 --- a/PrivaPub/Federation/Objects/NoteParser.cs +++ b/PrivaPub/Federation/Objects/NoteParser.cs @@ -41,6 +41,7 @@ namespace PrivaPub.Federation.Objects public PostSource Source { get; init; } public PostPoll Poll { get; init; } public List Flairs { get; init; } = new(); + public List SearchableBy { get; init; } public InteractionRule QuotePolicy { get; init; } public InteractionRule ReplyPolicy { get; init; } public InteractionRule LikePolicy { get; init; } @@ -121,6 +122,7 @@ namespace PrivaPub.Federation.Objects Source = ObjectShapes.Source(note), Poll = ObjectShapes.Poll(note), Flairs = ObjectShapes.Flairs(note["tag"]), + SearchableBy = ObjectShapes.SearchableBy(note["searchableBy"]), QuotePolicy = ObjectShapes.QuotePolicy(note), ReplyPolicy = ObjectShapes.Policy(note, "canReply"), LikePolicy = ObjectShapes.Policy(note, "canLike"), diff --git a/PrivaPub/Federation/Objects/ObjectShapes.cs b/PrivaPub/Federation/Objects/ObjectShapes.cs index c710d91..9763f49 100644 --- a/PrivaPub/Federation/Objects/ObjectShapes.cs +++ b/PrivaPub/Federation/Objects/ObjectShapes.cs @@ -100,6 +100,18 @@ namespace PrivaPub.Federation.Objects const int MaxFlairs = 20; + // FEP-268d: who may find this in a search (Public, the author's followers, nobody else); null when not stated + public static List SearchableBy(JsonNode node) => node switch + { + null => default, + JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(), + _ when Id(node) is { } single => new List { single }, + _ => new List() + }; + + public static bool Searchable(List searchableBy, string followers, bool viewerFollows) => + searchableBy.Any(Addressing.IsPublic) || viewerFollows && searchableBy.Any(w => w == followers || w.EndsWith("/followers", StringComparison.Ordinal)); + // a post's flairs, or a community's (its `tag`, and PieFed's older `lemmy:tagsForPosts`), in both dialects public static List Flairs(params JsonNode[] lists) => lists.SelectMany(Objects) diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index ac4f201..d542600 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -48,6 +48,8 @@ namespace PrivaPub.Models.Post public PostPoll Poll { get; set; } [BsonIgnoreIfNull] public List Flairs { get; set; } + [BsonIgnoreIfNull] + public List SearchableBy { get; set; }//FEP-268d on the post itself: outranks its author's public List Location { get; set; } = new(); public float RangeKm { get; set; } = 5.0f; [BsonIgnoreIfNull] diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index 1774230..80f4807 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -78,6 +78,7 @@ namespace PrivaPub.Models.User public string OutboxURL { get; set; } public string FeaturedURL { get; set; }//featured: the posts it pins public List Flairs { get; set; } = new();//a community's labels for its posts + public List SearchableBy { get; set; }//FEP-268d, when the account states it: outranks IsIndexable public string ModeratorsURL { get; set; }//a community's attributedTo (Lemmy, PieFed, Mbin): the collection of its moderators public List AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32) public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7a40dad..931985d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -712,7 +712,9 @@ it, raw where it doesn't. - FEP-8fcf followers sync: **done 2026-10-06** (owner decision; sent: the digest of a persona's followers on the receiving server, and a signed roll-call listing only them; honoured: a sender's digest of its followers here, mended from its list; checked live against Mastodon, all four ways a follow can be lost); - - `indexable`/`discoverable`/`searchableBy`; + - `indexable`/`discoverable`/`searchableBy`: **done 2026-10-07** (account search already left out undiscoverable + accounts; status search now reads FEP-268d `searchableBy` on remote accounts and on each post, which outranks + `indexable`: Public, the author's followers (the reader must follow), or nobody else; not emitted by us); - edit history from `formerRepresentations`: **done 2026-10-06** (Pleroma's and Akkoma's earlier versions, for a post met after its edits and for the edits missed in between); - PeerTube reply rules and `ApproveReply`: **done 2026-10-06** (FEP-5624's `canReply`: a reply waits for the author's