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:
1 parent
02a05d7a9a
commit
ce473f2741
12 files changed
+80
-12
No files matched your search
+3
-1
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user