Everything on, phase 4a: one answer everywhere for counts, search, collections and the instance API

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-04 03:48:40 +02:00
1 parent 123ba39520
commit 8f25bf056d
28 files changed
+607 -78

No files matched your search

@@ -71,8 +71,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
avatar.Settings.IsDiscoverable = discoverable;
if (Params.Bool("indexable") is { } indexable)
avatar.Settings.IsIndexable = indexable;
if (Params.Bool("hide_collections") is { } hide)
avatar.Settings.HideCollections = hide;
// hide_collections stays true: who follows whom is never listed here, only counted (FEDERATION.md)
if (Params.Get("source[privacy]") is { } privacy && privacy is "public" or "unlisted" or "private")
avatar.Settings.DefaultVisibility = privacy;
if (Params.Bool("source[sensitive]") is { } sensitive)
@@ -120,7 +119,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
}
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token);
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
}
@@ -197,8 +196,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>());
var followers = await _dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var followers = await Page.From(Params, Limit(40, 80))
.Fetch(_dbEntities.Followers.Match(f => f.LocalActorId == local.Id && f.LocalActorKind == local.Kind && f.IsAccepted), f => f.ID, token);
Link($"/api/v1/accounts/{id}/followers", followers.LastOrDefault()?.ID, followers.FirstOrDefault()?.ID);
return Json(await AccountsFor(followers.Select(f => f.ActorURI), token));
}
@@ -208,8 +208,9 @@ namespace PrivaPub.Api.Mastodon.Controllers
var (local, _) = await Find(id, token);
if (local == default || local.Id != MyId)
return Json(Array.Empty<Account>());
var following = await _dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted)
.Sort(f => f.ID, Order.Descending).Limit(Limit(40, 80)).ExecuteAsync(token);
var following = await Page.From(Params, Limit(40, 80))
.Fetch(_dbEntities.Followings.Match(f => f.AvatarId == local.Id && f.State == FollowState.Accepted), f => f.ID, token);
Link($"/api/v1/accounts/{id}/following", following.LastOrDefault()?.ID, following.FirstOrDefault()?.ID);
var accounts = await _mapper.Accounts(following.Select(f => f.TargetAccountId), token);
return Json(following.Select(f => accounts.GetValueOrDefault(f.TargetAccountId)).Where(a => a != default).ToList());
}
@@ -30,28 +30,50 @@ namespace PrivaPub.Api.Mastodon.Controllers
bool Open => _registrations.CurrentValue.IsOpen;
// the languages this server is set up for (AppConfiguration:SupportedLanguages), "en" when none are named
public static IReadOnlyList<string> LanguageCodes(IServiceProvider services)
{
var configured = services.GetRequiredService<Microsoft.Extensions.Options.IOptionsMonitor<Models.AppConfiguration>>().CurrentValue.SupportedLanguages;
var codes = (configured ?? new List<string>()).Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Split('-')[0].ToLowerInvariant()).Distinct().ToList();
return codes.Count == 0 ? new[] { "en" } : codes;
}
string Domain => new Uri(_localActors.BaseAddress).Authority;
static object Statuses => new { max_characters = 5000, max_media_attachments = 4, characters_reserved_per_url = 23 };
// every limit is what the server enforces, read from where it is enforced
static object Statuses => new { max_characters = PrivaPub.Domain.Statuses.StatusService.MaxCharacters, max_media_attachments = 4, characters_reserved_per_url = 23 };
static object MediaAttachments => new
object MediaAttachments
{
supported_mime_types = new[] { "image/jpeg", "image/png", "image/gif", "image/webp", "video/mp4", "video/webm", "audio/mpeg", "audio/ogg" },
image_size_limit = 16 * 1024 * 1024,
image_matrix_limit = 16_777_216,
video_size_limit = 99 * 1024 * 1024,
video_frame_rate_limit = 60,
video_matrix_limit = 2_304_000
};
get
{
var media = HttpContext.RequestServices.GetRequiredService<IOptionsMonitor<PrivaPub.Domain.Media.MediaOptions>>().CurrentValue;
return new
{
supported_mime_types = PrivaPub.Domain.Media.MediaService.SupportedTypes,
image_size_limit = media.MaxImageBytes,
image_matrix_limit = media.MaxImageSide * media.MaxImageSide,
video_size_limit = media.MaxVideoBytes,
video_frame_rate_limit = 60,
video_matrix_limit = 2_304_000
};
}
}
static object Polls => new { max_options = 4, max_characters_per_option = 50, min_expiration = 300, max_expiration = 2_629_746 };
static object Polls => new
{
max_options = PrivaPub.Domain.Statuses.PollService.MaxOptions,
max_characters_per_option = PrivaPub.Domain.Statuses.PollService.MaxOptionLength,
min_expiration = PrivaPub.Domain.Statuses.PollService.MinExpiration,
max_expiration = PrivaPub.Domain.Statuses.PollService.MaxExpiration
};
[HttpGet("/api/v1/instance"), AllowAnonymous]
public async Task<IActionResult> V1(CancellationToken token)
{
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
var statuses = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token);
var domains = (await DB.Default.Find<ForeignAvatar, string>().Project(a => a.Domain).ExecuteAsync(token)).Distinct().Count();
var users = await Counted.Users(token);
var statuses = await Counted.LocalPosts(token);
var domains = await KnownServers(token);
return Json(new
{
uri = Domain,
@@ -60,10 +82,10 @@ namespace PrivaPub.Api.Mastodon.Controllers
description = Description,
email = string.Empty,
version = Version,
urls = new { streaming_api = $"wss://{Domain}" },
urls = new { },//no streaming API yet, so none is advertised
stats = new { user_count = users, status_count = statuses, domain_count = domains },
thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png",
languages = new[] { "en" },
languages = LanguageCodes(HttpContext.RequestServices),
registrations = Open,
approval_required = false,
invites_enabled = true,//a group invitation always creates an account
@@ -73,8 +95,14 @@ namespace PrivaPub.Api.Mastodon.Controllers
});
}
// the servers we have exchanged activities with, as the statistics count them. Which ones is not published
// (/instance/peers stays empty, as on a Mastodon with its peers API off): on a small server it would say who its
// personas talk to.
static async Task<long> KnownServers(CancellationToken token) =>
await DB.Default.CountAsync<Models.Jobs.RemoteInstance>(i => i.Seen == "touched", token);
[HttpGet("/api/v2/instance"), AllowAnonymous]
public IActionResult V2() => Json(new
public async Task<IActionResult> V2(CancellationToken token) => Json(new
{
domain = Domain,
title = "PrivaPub",
@@ -82,13 +110,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
api_versions = new { mastodon = 7 },
source_url = "https://git.thepra.dev/thepra/SocialPub",
description = Description,
usage = new { users = new { active_month = 0 } },
usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } },
thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" },
languages = new[] { "en" },
languages = LanguageCodes(HttpContext.RequestServices),
configuration = new
{
urls = new { streaming = $"wss://{Domain}" },
accounts = new { max_featured_tags = 0, max_pinned_statuses = 0 },
urls = new { },//no streaming API yet, so none is advertised
accounts = new { max_featured_tags = 0, max_pinned_statuses = StatusesController.MaxPins },
statuses = Statuses,
media_attachments = MediaAttachments,
polls = Polls,
@@ -35,11 +35,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
_search = search;
}
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false)]
// Anyone may search, as on Mastodon; only resolving (which fetches from other servers) and paging need a sign-in.
[HttpGet("/api/v2/search"), Scope("read:search", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Search(CancellationToken token)
{
var q = Params.Get("q")?.Trim();
var type = Params.Get("type");
var offset = Params.Int("offset") ?? 0;
if (MyId == default && (Params.Bool("resolve") == true || offset > 0))
return Error(StatusCodes.Status401Unauthorized, "Search queries that resolve uris or use offset require the read:search scope");
var resolve = Params.Bool("resolve") == true && MyId != default;
var results = new SearchResults();
if (string.IsNullOrEmpty(q))
@@ -66,7 +70,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (type is null or "accounts")
{
results.Accounts = await _search.Find(q, resolve, Limit(), token);
results.Accounts = await _search.Find(q, resolve, Limit(), Math.Max(0, offset), token);
}
if (type is null or "hashtags" && q.TrimStart('#') is { Length: > 0 } tag)
results.Hashtags.Add(new { name = tag.ToLowerInvariant(), url = $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag.ToLowerInvariant())}", history = Array.Empty<object>() });
@@ -133,5 +137,67 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/push/subscription"), Scope("push")]
public IActionResult PushSubscription() => Error(StatusCodes.Status404NotFound, "Record not found");
// Routes Mastodon answers that we have nothing behind: an empty or default answer lets clients degrade, where a
// 404 breaks some of them (Mastodon invariant 5)
[HttpGet("/api/v1/directory"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Directory() => Json(Array.Empty<object>());
[HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult LinkTimeline() => Json(Array.Empty<object>());
[HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult IdentityProofs(string id) => Json(Array.Empty<object>());
[HttpGet("/api/v1/tags/{name}"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Tag(string name)
{
var tag = name?.TrimStart('#').ToLowerInvariant() ?? string.Empty;
var baseAddress = HttpContext.RequestServices.GetRequiredService<Federation.Actors.ILocalActorService>().BaseAddress;
return Json(new { name = tag, url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty<object>(), following = false });
}
[HttpGet("/api/v1/instance/languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Languages() => Json(InstanceController.LanguageCodes(HttpContext.RequestServices)
.Select(code => new { code, name = System.Globalization.CultureInfo.GetCultureInfo(code).EnglishName }));
[HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult TranslationLanguages() => Json(new { });
[HttpGet("/api/v1/instance/domain_blocks"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult InstanceDomainBlocks() => Json(Array.Empty<object>());
[HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult PrivacyPolicy() => Json(new
{
updated_at = "2026-10-04T00:00:00.000Z",
content = "<p>One private login owns several public personas, and nobody but this server's admin can tell they belong "
+ "together. What a persona posts is shared as its visibility says; a located post never leaves this server. "
+ "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.</p>"
});
[HttpGet("/api/v1/notifications/policy"), Scope("read:notifications")]
public IActionResult NotificationPolicyV1() => Json(new
{
filter_not_following = false,
filter_not_followers = false,
filter_new_accounts = false,
filter_private_mentions = false,
summary = new { pending_requests_count = 0, pending_notifications_count = 0 }
});
[HttpGet("/api/v2/notifications/policy"), Scope("read:notifications")]
public IActionResult NotificationPolicyV2() => Json(new
{
for_not_following = "accept",
for_not_followers = "accept",
for_new_accounts = "accept",
for_private_mentions = "accept",
for_limited_accounts = "accept",
summary = new { pending_requests_count = 0, pending_notifications_count = 0 }
});
[HttpGet("/api/v1/notifications/requests"), Scope("read:notifications")]
public IActionResult NotificationRequests() => Json(Array.Empty<object>());
}
}
@@ -216,9 +216,15 @@ namespace PrivaPub.Api.Mastodon.Controllers
return Json(new { });
}
// counts what the list would show (Map drops notifications from gone accounts and about hidden posts), at most
// `limit` of them, as Mastodon does
[HttpGet("/api/v1/notifications/unread_count"), Scope("read:notifications")]
public async Task<IActionResult> UnreadCount(CancellationToken token) =>
Json(new { count = await DB.Default.CountAsync<NotificationEntity>(n => n.AvatarId == MyId && !n.IsRead, token) });
public async Task<IActionResult> UnreadCount(CancellationToken token)
{
var unread = await DB.Default.Find<NotificationEntity>().Match(n => n.AvatarId == MyId && !n.IsRead)
.Sort(n => n.ID, Order.Descending).Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
return Json(new { count = (await Map(unread, token)).Count });
}
async Task<List<Entities.Notification>> Map(List<NotificationEntity> notifications, CancellationToken token)
{
+18 -8
View File
@@ -19,7 +19,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
_remoteActors = remoteActors;
}
public async Task<List<Account>> Find(string q, bool resolve, int limit, CancellationToken token)
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))
@@ -31,24 +34,31 @@ namespace PrivaPub.Api.Mastodon.Mappers
{
var userName = parts[0];
var domain = parts[1].ToLowerInvariant();
var known = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain).ExecuteFirstAsync(token);
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 != default)
if (known is { DeletionAt: null } && offset == 0)
results.Add(_mapper.Foreign(known));
return results;
}
var prefix = parts[0].ToLowerInvariant();
foreach (var avatar in await _dbEntities.Avatars.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false)
.Limit(limit).ExecuteAsync(token))
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));
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable)
.Limit(Math.Max(0, limit - results.Count)).ExecuteAsync(token))
results.Add(_mapper.Foreign(foreign));
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;
}
}
@@ -50,15 +50,11 @@ namespace PrivaPub.Api.Mastodon.Mappers
? await DB.Default.CountAsync<Following>(f => f.AvatarId == actor.Id && f.State == FollowState.Accepted, token)
: 0;
var latest = await _dbEntities.Posts
.Match(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id) && !p.IsFederatedCopy
&& !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null)
.Match(Counted.By(actor))
.Sort(p => p.ID, Order.Descending)
.Limit(1)
.ExecuteFirstAsync(token);
var statuses = latest == default
? 0
: await DB.Default.CountAsync<PostEntity>(p => (actor.Kind == LocalActorKind.Group ? p.GroupId == actor.Id : p.GroupUserId == actor.Id)
&& !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct && p.ReblogOfPostId == null, token);
var statuses = latest == default ? 0 : await Counted.PostsBy(actor, token);
var account = new Account
{
@@ -71,7 +67,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Discoverable = actor.Discoverable,
Indexable = actor.Indexable,
Noindex = !actor.Indexable,
HideCollections = actor.Settings.HideCollections,
HideCollections = true,//follower and following lists are never shown to others, only their counts
Group = actor.Kind == LocalActorKind.Group,
CreatedAt = MastodonJson.Day(actor.Published),
Note = ActivityPubRenderer.Html(actor.Summary),
@@ -141,7 +137,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID)).ExecuteAsync(token))
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token))
accounts[foreign.ID] = Foreign(foreign);
return accounts;
}