From ce473f27414729f12e53895612add1157ccdb1d5 Mon Sep 17 00:00:00 2001 From: thepra Date: Mon, 5 Oct 2026 02:40:40 +0200 Subject: [PATCH] Follow requests asked again, and followed accounts found by name A server can take a Follow with 202 and drop it afterwards, as Pleroma does while it cannot fetch our actor; the request then stayed pending for good. Following again now sends an unanswered request once more, the same activity, at most once an hour (a delivery's `again` key). accounts/search takes following=true: only accounts the persona follows, by the start of their name, display name or server, never resolved; a client fills a list with it. "already take" becomes "already taken". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- FEDERATION.md | 4 +++- PrivaPub.Tests/Http/ClientApiPersonasTests.cs | 2 +- PrivaPub.Tests/Http/MastodonAccountsTests.cs | 17 +++++++++++++ PrivaPub.Tests/Http/MastodonListsTests.cs | 15 ++++++++++++ .../Controllers/AccountsController.cs | 4 +++- .../Api/Mastodon/Mappers/AccountSearch.cs | 24 +++++++++++++++++++ .../ClientToServer/RootUserController.cs | 2 +- PrivaPub/Domain/Social/FollowService.cs | 5 ++++ PrivaPub/Federation/Outbox/DeliveryService.cs | 8 ++++--- .../Private/IPrivateAvatarUsersService.cs | 4 ++-- PrivaPub/Services/GroupUsersService.cs | 4 ++-- docs/ROADMAP.md | 3 ++- 12 files changed, 80 insertions(+), 12 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 0fdb703..b4908b1 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -230,7 +230,9 @@ Posts with a location (shown to nearby users of this server) never leave the ser votes. All of them are always sent with their object embedded. - **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag. - **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, - starting at an hour and growing to a week. + starting at an hour and growing to a week. A server can also take a Follow (202) and drop it afterwards, as Pleroma + does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the + persona follows once more, at most once an hour. ## Known limitations diff --git a/PrivaPub.Tests/Http/ClientApiPersonasTests.cs b/PrivaPub.Tests/Http/ClientApiPersonasTests.cs index 1cf1168..af9b9a1 100644 --- a/PrivaPub.Tests/Http/ClientApiPersonasTests.cs +++ b/PrivaPub.Tests/Http/ClientApiPersonasTests.cs @@ -103,7 +103,7 @@ namespace PrivaPub.Tests.Http var response = await Insert(root, new { userName, name = userName, biography = "testing" }); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Contains("already take", (await response.JsonBody())["errorMessage"]!.GetValue()); + Assert.Contains("already taken", (await response.JsonBody())["errorMessage"]!.GetValue()); Assert.Empty(await Listed(root)); } diff --git a/PrivaPub.Tests/Http/MastodonAccountsTests.cs b/PrivaPub.Tests/Http/MastodonAccountsTests.cs index 73c2a1c..7191125 100644 --- a/PrivaPub.Tests/Http/MastodonAccountsTests.cs +++ b/PrivaPub.Tests/Http/MastodonAccountsTests.cs @@ -493,6 +493,23 @@ namespace PrivaPub.Tests.Http Assert.All(answer.Array, r => Assert.False(r.Flag("following") || r.Flag("blocking") || r.Flag("muting") || r.Flag("followed_by"))); } + [Fact] + public async Task Following_again_sends_an_unanswered_request_once_more_and_no_more_than_hourly() + { + var since = DateTime.UtcNow.AddSeconds(-1); + var alice = await _host.Mastodon("alice"); + var (bob, bobId) = await Remote(); + Assert.True((await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok().Body.Flag("requested")); + var first = Assert.Single(await bob.Delivered(since), d => d.Type() == "Follow"); + + Assert.True((await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok().Body.Flag("requested")); + (await alice.Client.Post($"/api/v1/accounts/{bobId}/follow")).Ok(); + + var follows = (await bob.Delivered(since)).Where(d => d.Type() == "Follow").ToList(); + Assert.Equal(2, follows.Count); + Assert.All(follows, f => Assert.Equal(first.Text("id"), f.Text("id"))); + } + [Fact] public async Task A_remote_authors_followers_only_posts_show_only_to_the_personas_that_follow_them() { diff --git a/PrivaPub.Tests/Http/MastodonListsTests.cs b/PrivaPub.Tests/Http/MastodonListsTests.cs index 00f133d..2d5557d 100644 --- a/PrivaPub.Tests/Http/MastodonListsTests.cs +++ b/PrivaPub.Tests/Http/MastodonListsTests.cs @@ -142,6 +142,21 @@ namespace PrivaPub.Tests.Http Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); } + [Fact] + public async Task Account_search_with_following_finds_only_followed_accounts() + { + var alice = await _host.Mastodon("alice"); + var prefix = $"ls{Guid.NewGuid():N}"[..10]; + var followed = await _host.Mastodon(prefix + "a"); + var stranger = await _host.Mastodon(prefix + "b"); + await Follow(alice, followed); + + var found = (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Ids.ToList(); + Assert.Equal(new[] { followed.Id }, found); + Assert.Contains(stranger.Id, (await alice.Client.Get($"/api/v1/accounts/search?q={prefix}")).Ok().Ids); + Assert.Empty((await stranger.Client.Get($"/api/v1/accounts/search?q={prefix}&following=true")).Ok().Array); + } + [Fact] public async Task An_unfollowed_account_leaves_the_personas_lists_and_does_not_come_back_with_a_new_follow() { diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 420c4a9..d9fc892 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -134,7 +134,9 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/accounts/search"), Scope("read:accounts")] public async Task Search([FromServices] AccountSearch search, CancellationToken token) => - Json(await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token)); + Json(Params.Bool("following") == true && MyId != default + ? await search.Followed(MyId, Params.Get("q"), Limit(), token) + : await search.Find(Params.Get("q"), Params.Bool("resolve") == true && MyId != default, Limit(), token)); [HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task Get(string id, CancellationToken token) diff --git a/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs index b4575d6..fa02018 100644 --- a/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs +++ b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs @@ -21,6 +21,30 @@ namespace PrivaPub.Api.Mastodon.Mappers public async Task> Find(string q, bool resolve, int limit, CancellationToken token) => await Find(q, resolve, limit, 0, token); + // following=true: only accounts the persona follows, matched by the start of their name, display name or server + // (what a client offers when adding to a list), read from what PrivaPub holds and never resolved + public async Task> Followed(string viewerId, string q, int limit, CancellationToken token) + { + q = q?.Trim().TrimStart('@') ?? string.Empty; + var ids = (await _dbEntities.Followings.Match(f => f.AvatarId == viewerId && f.State == Models.Social.FollowState.Accepted).ExecuteAsync(token)) + .Select(f => f.TargetAccountId).Where(id => !string.IsNullOrEmpty(id)).ToList(); + if (ids.Count == 0) + return new List(); + var parts = q.Split('@'); + var localDomain = new Uri(_localActors.BaseAddress).Authority; + bool Matches(string userName, string name, string domain) => + userName?.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase) == true + && (parts.Length < 2 || domain?.StartsWith(parts[1], StringComparison.OrdinalIgnoreCase) == true) + || parts.Length == 1 && name?.Contains(q, StringComparison.OrdinalIgnoreCase) == true; + var found = (await _dbEntities.Avatars.Match(a => ids.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)) + .Where(a => Matches(a.UserName, a.Name, localDomain)).Select(a => (a.UserName, a.ID)) + .Concat((await _dbEntities.ForeignAvatars.Match(f => ids.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token)) + .Where(f => Matches(f.UserName, f.Name, f.Domain)).Select(f => (f.UserName, f.ID))) + .OrderBy(a => a.UserName, StringComparer.OrdinalIgnoreCase).Take(limit).Select(a => a.ID).ToList(); + var accounts = await _mapper.Accounts(found, token); + return found.Where(accounts.ContainsKey).Select(id => accounts[id]).ToList(); + } + // gone accounts (remote ones that deleted themselves, personas of banned or deleted roots) are never found public async Task> Find(string q, bool resolve, int limit, int offset, CancellationToken token) { diff --git a/PrivaPub/Controllers/ClientToServer/RootUserController.cs b/PrivaPub/Controllers/ClientToServer/RootUserController.cs index 40bc99a..e4f12b5 100644 --- a/PrivaPub/Controllers/ClientToServer/RootUserController.cs +++ b/PrivaPub/Controllers/ClientToServer/RootUserController.cs @@ -227,7 +227,7 @@ namespace PrivaPub.Controllers.ClientToServer if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase)) return Localizer["Your persona's username must differ from your login."]; if (await LocalActors.IsUserNameTaken(avatarUserName, token)) - return Localizer["The username '{0}' is already take.", avatarUserName]; + return Localizer["The username '{0}' is already taken.", avatarUserName]; return default; } diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index 1ffc28b..1b587ee 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -105,6 +105,11 @@ namespace PrivaPub.Domain.Social if (existing.ShowReblogs != showReblogs) await DB.Default.Update().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token); existing.ShowReblogs = showReblogs; + // a request still unanswered is sent again, at most once an hour: a server can take a Follow (202) and + // drop it later, as one that cannot yet read our actor does; one that holds it already ignores the copy + if (existing.State == FollowState.Requested && remote != default) + await _delivery.Enqueue(follower, new[] { remote.InboxURL }, FollowActivity(follower, existing), token, + again: "again-" + DateTime.UtcNow.ToString("yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture)); return existing; } diff --git a/PrivaPub/Federation/Outbox/DeliveryService.cs b/PrivaPub/Federation/Outbox/DeliveryService.cs index c29ebcc..7040608 100644 --- a/PrivaPub/Federation/Outbox/DeliveryService.cs +++ b/PrivaPub/Federation/Outbox/DeliveryService.cs @@ -23,7 +23,9 @@ namespace PrivaPub.Federation.Outbox { public interface IDeliveryService { - Task Enqueue(LocalActor signer, IEnumerable inboxes, JsonObject activity, CancellationToken token); + // again: a delivery made once more on purpose, which an earlier one of the same activity does not stop; deliveries + // with the same again are made once + Task Enqueue(LocalActor signer, IEnumerable inboxes, JsonObject activity, CancellationToken token, string again = default); Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable extraInboxes = default); Task> FollowerInboxes(LocalActor actor, CancellationToken token); } @@ -41,7 +43,7 @@ namespace PrivaPub.Federation.Outbox _queue = queue; } - public async Task Enqueue(LocalActor signer, IEnumerable inboxes, JsonObject activity, CancellationToken token) + public async Task Enqueue(LocalActor signer, IEnumerable inboxes, JsonObject activity, CancellationToken token, string again = default) { var body = activity.ToJsonString(); var activityId = activity["id"] is JsonValue id && id.TryGetValue(out var text) ? text : default; @@ -54,7 +56,7 @@ namespace PrivaPub.Federation.Outbox { Kind = JobKind.Deliver, Host = target.uri.Host.ToLowerInvariant(), - DedupeKey = activityId == default ? default : $"{activityId}|{target.inbox}", + DedupeKey = activityId == default ? default : again == default ? $"{activityId}|{target.inbox}" : $"{activityId}|{target.inbox}|{again}", Payload = JsonSerializer.Serialize(new DeliveryPayload(signer.Id, signer.Kind, target.inbox, body)) }) .ToList(); diff --git a/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs b/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs index 8601d1d..54328ad 100644 --- a/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs +++ b/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs @@ -52,7 +52,7 @@ namespace PrivaPub.Services.ClientToServer.Private return result.Invalidate(_localizer["The username may contain only letters, digits and underscores."]); if (await _localActors.IsUserNameTaken(userName, default)) - return result.Invalidate(_localizer["The username '{0}' is already take.", userName]); + return result.Invalidate(_localizer["The username '{0}' is already taken.", userName]); if (!await IsValidRootUser(form.RootId)) return result.Invalidate(_localizer["You can't do this action because of your account status."]); @@ -71,7 +71,7 @@ namespace PrivaPub.Services.ClientToServer.Private }; newAvatar.ID = (string)newAvatar.GenerateNewID(); if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Person, newAvatar.ID, default)) - return result.Invalidate(_localizer["The username '{0}' is already take.", userName]); + return result.Invalidate(_localizer["The username '{0}' is already taken.", userName]); if (form.Settings is { IsDefault: false }) Apply(newAvatar.Settings, form.Settings); diff --git a/PrivaPub/Services/GroupUsersService.cs b/PrivaPub/Services/GroupUsersService.cs index 7c4e9c9..6ac0c42 100644 --- a/PrivaPub/Services/GroupUsersService.cs +++ b/PrivaPub/Services/GroupUsersService.cs @@ -104,7 +104,7 @@ namespace PrivaPub.Services var userName = form.UserName.ToLowerInvariant(); if (await _localActors.IsUserNameTaken(userName, token)) - return result.Invalidate(_localizer["The username '{0}' is already take.", userName]); + return result.Invalidate(_localizer["The username '{0}' is already taken.", userName]); var (privateKey, publicKey) = Keys.NewKeyPair(); var group = new GroupEntity @@ -126,7 +126,7 @@ namespace PrivaPub.Services }; group.ID = (string)group.GenerateNewID(); if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Group, group.ID, token)) - return result.Invalidate(_localizer["The username '{0}' is already take.", userName]); + return result.Invalidate(_localizer["The username '{0}' is already taken.", userName]); var actor = _localActors.FromGroup(group); group.Url = actor.Uri; group.InboxURL = actor.Inbox; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cbbded1..bd6fd69 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -64,7 +64,8 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - [ ] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts, followed hashtags, trends and a directory - [x] Lists: CRUD, members (followed accounts only, dropped when the follow ends), `timelines/list` with Mastodon's - replies policies, exclusive lists kept out of home. Lists are the persona's own and never federate. + replies policies, exclusive lists kept out of home, and `accounts/search?following=true` to fill them. Lists are the + persona's own and never federate. ## Intent