Owner decision 2026-10-04: fix the mismatches and every other mismatch of the same kind.
- One counting rule (Domain/Privacy/Counted), Mastodon's. It is used for a persona's statuses_count, its outbox
totalItems, NodeInfo localPosts and the instance status_count, which used to count four different things. It
counts every post that is neither deleted nor a DM, boosts included, and circle and located posts too (owner
decision). A group's count includes its remote members' posts.
- Users. Personas of banned or deleted roots no longer count, and are not found in search. NodeInfo now gives
activeMonth and activeHalfyear, and the v2 instance gives active_month instead of a constant 0.
- replies_count counts only public and unlisted replies, so it no longer tells anyone that a private reply exists.
Migration _012 recounts it.
- A remote account that deletes itself takes everything out of every count (GoneActors): its likes, downvotes,
reactions and poll votes go and their counters come back, as do its boosts', replies' and quotes' counts, and its
notifications. Lookups, account lists, search and favourited_by no longer show it. Migration _012 applies this to
accounts already gone.
- Deleting a post also deletes its pins and the local boosts of it.
- /stalking gives the same total as following_count. Members are still never listed, and hide_collections is now
always true, since the setting never did anything.
- Joining a community by invitation is following it, so /flock and /groupies agree; leaving unfollows.
- Search. Anyone may search, as on Mastodon; resolve and offset need a sign-in, offset pages, and deleted accounts
are never found.
- notifications/unread_count counts what the list shows, and the owner's follower and following lists page with
Link.
- The instance API advertises what is enforced:
- max_characters, now enforced with a 422;
- max_pinned_statuses = MaxPins;
- the media types and limits MediaService and MediaOptions accept;
- PollService's limits;
- the configured languages;
- no streaming URL until streaming exists.
domain_count counts the servers we have exchanged with; which ones stays unpublished (peers is empty).
- Routes Mastodon answers now answer instead of 404:
- directory, tags/{name}, timelines/link and identity_proofs;
- instance/languages, translation_languages, domain_blocks and privacy_policy;
- the v1 and v2 notification policy, and notification requests.
Also, from phase 3: a recovered password ends /clientapi sessions through a per-root SessionStamp claim instead of
comparing the JWT's whole-second nbf with the change time. That comparison let a token issued in the same second
survive, which made a test flaky.
671 tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
66 lines
2.8 KiB
C#
66 lines
2.8 KiB
C#
using PrivaPub.Api.Mastodon.Entities;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.StaticServices;
|
|
|
|
namespace PrivaPub.Api.Mastodon.Mappers
|
|
{
|
|
public class AccountSearch
|
|
{
|
|
readonly MastodonMapper _mapper;
|
|
readonly DbEntities _dbEntities;
|
|
readonly ILocalActorService _localActors;
|
|
readonly IRemoteActorService _remoteActors;
|
|
|
|
public AccountSearch(MastodonMapper mapper, DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors)
|
|
{
|
|
_mapper = mapper;
|
|
_dbEntities = dbEntities;
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
}
|
|
|
|
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token) => await Find(q, resolve, limit, 0, token);
|
|
|
|
// 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)
|
|
{
|
|
q = q?.Trim().TrimStart('@');
|
|
if (string.IsNullOrEmpty(q))
|
|
return new List<Account>();
|
|
var results = new List<Account>();
|
|
var parts = q.Split('@');
|
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
|
if (parts.Length == 2 && !parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(parts[0]))
|
|
{
|
|
var userName = parts[0];
|
|
var domain = parts[1].ToLowerInvariant();
|
|
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
|
if (known == default && resolve)
|
|
{
|
|
var actorUri = await _remoteActors.ResolveHandle(q, token);
|
|
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
|
|
}
|
|
if (known is { DeletionAt: null } && offset == 0)
|
|
results.Add(_mapper.Foreign(known));
|
|
return results;
|
|
}
|
|
|
|
var prefix = parts[0].ToLowerInvariant();
|
|
var barred = await Domain.Privacy.Counted.BarredPersonas(token);
|
|
var locals = await _dbEntities.Avatars
|
|
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID))
|
|
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
|
|
.Limit(offset + limit).ExecuteAsync(token);
|
|
foreach (var avatar in locals.Skip(offset))
|
|
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
|
|
var skipRemote = Math.Max(0, offset - locals.Count);
|
|
if (results.Count < limit)
|
|
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue)
|
|
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
|
|
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
|
|
results.Add(_mapper.Foreign(foreign));
|
|
return results;
|
|
}
|
|
}
|
|
}
|