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

@@ -134,7 +134,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/accounts/search"), Scope("read:accounts")]
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]
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);
// 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
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))
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;
}
+5
View File
@@ -105,6 +105,11 @@ namespace PrivaPub.Domain.Social
if (existing.ShowReblogs != showReblogs)
await DB.Default.Update<Following>().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;
}
@@ -23,7 +23,9 @@ namespace PrivaPub.Federation.Outbox
{
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<IReadOnlyList<string>> FollowerInboxes(LocalActor actor, CancellationToken token);
}
@@ -41,7 +43,7 @@ namespace PrivaPub.Federation.Outbox
_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 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,
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();
@@ -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);
+2 -2
View File
@@ -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;