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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 02:40:40 +02:00
1 parent 02a05d7a9a
commit ce473f2741
12 files changed
+80 -12

No files matched your search

+3 -1
View File
@@ -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. 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. - **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, - **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 ## Known limitations
@@ -103,7 +103,7 @@ namespace PrivaPub.Tests.Http
var response = await Insert(root, new { userName, name = userName, biography = "testing" }); var response = await Insert(root, new { userName, name = userName, biography = "testing" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("already take", (await response.JsonBody())["errorMessage"]!.GetValue<string>()); Assert.Contains("already taken", (await response.JsonBody())["errorMessage"]!.GetValue<string>());
Assert.Empty(await Listed(root)); Assert.Empty(await Listed(root));
} }
@@ -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"))); 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] [Fact]
public async Task A_remote_authors_followers_only_posts_show_only_to_the_personas_that_follow_them() public async Task A_remote_authors_followers_only_posts_show_only_to_the_personas_that_follow_them()
{ {
+15
View File
@@ -142,6 +142,21 @@ namespace PrivaPub.Tests.Http
Assert.Contains(fromBob, (await alice.Client.Get("/api/v1/timelines/home")).Ok().Ids); 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] [Fact]
public async Task An_unfollowed_account_leaves_the_personas_lists_and_does_not_come_back_with_a_new_follow() public async Task An_unfollowed_account_leaves_the_personas_lists_and_does_not_come_back_with_a_new_follow()
{ {
@@ -134,7 +134,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")] [HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
public async Task<IActionResult> Search([FromServices] AccountSearch search, CancellationToken token) => public async Task<IActionResult> 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] [HttpGet("/api/v1/accounts/{id}"), Scope("read:accounts", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Get(string id, CancellationToken token) public async Task<IActionResult> Get(string id, CancellationToken token)
@@ -21,6 +21,30 @@ namespace PrivaPub.Api.Mastodon.Mappers
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token) => await Find(q, resolve, limit, 0, token); public async Task<List<Account>> 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<List<Account>> 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<Account>();
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 // gone accounts (remote ones that deleted themselves, personas of banned or deleted roots) are never found
public async Task<List<Account>> Find(string q, bool resolve, int limit, int offset, CancellationToken token) public async Task<List<Account>> Find(string q, bool resolve, int limit, int offset, CancellationToken token)
{ {
@@ -227,7 +227,7 @@ namespace PrivaPub.Controllers.ClientToServer
if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase)) if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase))
return Localizer["Your persona's username must differ from your login."]; return Localizer["Your persona's username must differ from your login."];
if (await LocalActors.IsUserNameTaken(avatarUserName, token)) 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; return default;
} }
+5
View File
@@ -105,6 +105,11 @@ namespace PrivaPub.Domain.Social
if (existing.ShowReblogs != showReblogs) if (existing.ShowReblogs != showReblogs)
await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token); await DB.Default.Update<Following>().MatchID(existing.ID).Modify(f => f.ShowReblogs, showReblogs).ExecuteAsync(token);
existing.ShowReblogs = showReblogs; 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; return existing;
} }
@@ -23,7 +23,9 @@ namespace PrivaPub.Federation.Outbox
{ {
public interface IDeliveryService public interface IDeliveryService
{ {
Task Enqueue(LocalActor signer, IEnumerable<string> 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<string> inboxes, JsonObject activity, CancellationToken token, string again = default);
Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default); Task EnqueueToFollowers(LocalActor signer, JsonObject activity, CancellationToken token, IEnumerable<string> extraInboxes = default);
Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token); Task<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token);
} }
@@ -41,7 +43,7 @@ namespace PrivaPub.Federation.Outbox
_queue = queue; _queue = queue;
} }
public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token) public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token, string again = default)
{ {
var body = activity.ToJsonString(); var body = activity.ToJsonString();
var activityId = activity["id"] is JsonValue id && id.TryGetValue<string>(out var text) ? text : default; var activityId = activity["id"] is JsonValue id && id.TryGetValue<string>(out var text) ? text : default;
@@ -54,7 +56,7 @@ namespace PrivaPub.Federation.Outbox
{ {
Kind = JobKind.Deliver, Kind = JobKind.Deliver,
Host = target.uri.Host.ToLowerInvariant(), 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)) Payload = JsonSerializer.Serialize(new DeliveryPayload(signer.Id, signer.Kind, target.inbox, body))
}) })
.ToList(); .ToList();
@@ -52,7 +52,7 @@ namespace PrivaPub.Services.ClientToServer.Private
return result.Invalidate(_localizer["The username may contain only letters, digits and underscores."]); return result.Invalidate(_localizer["The username may contain only letters, digits and underscores."]);
if (await _localActors.IsUserNameTaken(userName, default)) 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)) if (!await IsValidRootUser(form.RootId))
return result.Invalidate(_localizer["You can't do this action because of your account status."]); 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(); newAvatar.ID = (string)newAvatar.GenerateNewID();
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Person, newAvatar.ID, default)) 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 }) if (form.Settings is { IsDefault: false })
Apply(newAvatar.Settings, form.Settings); Apply(newAvatar.Settings, form.Settings);
+2 -2
View File
@@ -104,7 +104,7 @@ namespace PrivaPub.Services
var userName = form.UserName.ToLowerInvariant(); var userName = form.UserName.ToLowerInvariant();
if (await _localActors.IsUserNameTaken(userName, token)) 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 (privateKey, publicKey) = Keys.NewKeyPair();
var group = new GroupEntity var group = new GroupEntity
@@ -126,7 +126,7 @@ namespace PrivaPub.Services
}; };
group.ID = (string)group.GenerateNewID(); group.ID = (string)group.GenerateNewID();
if (!await _localActors.TryReserveUserName(userName, LocalActorKind.Group, group.ID, token)) 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); var actor = _localActors.FromGroup(group);
group.Url = actor.Uri; group.Url = actor.Uri;
group.InboxURL = actor.Inbox; group.InboxURL = actor.Inbox;
+2 -1
View File
@@ -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, - [ ] P9 Reading at volume (owner decision 2026-10-04, back from "Cut"): lists, server-side filters, scheduled posts,
followed hashtags, trends and a directory followed hashtags, trends and a directory
- [x] Lists: CRUD, members (followed accounts only, dropped when the follow ends), `timelines/list` with Mastodon's - [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 ## Intent