From fb4949b511aebc27a83db8d33a9912e3c12d6e64 Mon Sep 17 00:00:00 2001 From: thepra Date: Wed, 7 Oct 2026 21:17:36 +0200 Subject: [PATCH] P7: FEP-268d searchableBy decides what search finds A remote account's searchableBy, and a post's own, outrank indexable in status search: Public lets anyone find a public post, the author's followers only those who follow it, anything else nobody but those it already reaches (their own, named, favourited, bookmarked or boosted posts). Read on actors and posts, kept through edits; PrivaPub does not emit it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB --- .../Http/MastodonWordSearchTests.cs | 67 +++++++++++++++++++ .../Mastodon/Controllers/SearchController.cs | 23 +++++-- PrivaPub/Federation/Actors/ActorDocument.cs | 2 + .../Federation/Actors/RemoteActorService.cs | 1 + PrivaPub/Federation/Inbox/RemoteEdits.cs | 1 + PrivaPub/Federation/Inbox/RemotePosts.cs | 1 + PrivaPub/Federation/Objects/NoteParser.cs | 2 + PrivaPub/Federation/Objects/ObjectShapes.cs | 12 ++++ PrivaPub/Models/Post/Post.cs | 2 + PrivaPub/Models/User/Avatar.cs | 1 + docs/ROADMAP.md | 4 +- 11 files changed, 111 insertions(+), 5 deletions(-) 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