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:
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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ namespace PrivaPub.Domain.Media
|
||||
["audio/mp4"] = "m4a", ["audio/x-m4a"] = "m4a", ["audio/flac"] = "flac", ["audio/webm"] = "webm"
|
||||
};
|
||||
|
||||
// what an upload may be, as the instance API advertises it
|
||||
public static IReadOnlyList<string> SupportedTypes => ImageTypes.Concat(AvTypes.Keys).ToList();
|
||||
|
||||
readonly IOptionsMonitor<MediaOptions> _options;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly IWebHostEnvironment _environment;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using System.Linq.Expressions;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Domain.Privacy
|
||||
{
|
||||
// What the numbers count, in one place, so a persona's statuses_count, its outbox's totalItems, NodeInfo and the
|
||||
// instance API agree (they used to count four different things).
|
||||
public static class Counted
|
||||
{
|
||||
// Mastodon's statuses_count: every post that is neither deleted nor a direct message, reblogs included. Circle and
|
||||
// located posts count too (owner decision 2026-10-04: "a good balance for the fediverse to work").
|
||||
public static readonly Expression<Func<PostEntity, bool>> Post = p => !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct;
|
||||
|
||||
// a persona's own posts, or every post in a group, its remote members' included
|
||||
public static Expression<Func<PostEntity, bool>> By(LocalActor actor)
|
||||
{
|
||||
var id = actor.Id;
|
||||
return actor.Kind == LocalActorKind.Group
|
||||
? p => p.GroupId == id && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct
|
||||
: p => p.GroupUserId == id && !p.IsFederatedCopy && !p.DeletedAt.HasValue && p.Visibility != PostVisibility.Direct;
|
||||
}
|
||||
|
||||
// replies_count counts only public and unlisted replies, as Mastodon does: a count must not tell anyone that a
|
||||
// followers-only, direct or circle reply exists
|
||||
public static bool Reply(PostEntity reply) => reply.Visibility is PostVisibility.Public or PostVisibility.Unlisted;
|
||||
|
||||
public static async Task<long> PostsBy(LocalActor actor, CancellationToken token) =>
|
||||
await DB.Default.CountAsync(By(actor), token);
|
||||
|
||||
// Personas that do not count as this server's users, and are not found in search: those of a banned or deleted root.
|
||||
public static async Task<List<string>> BarredPersonas(CancellationToken token)
|
||||
{
|
||||
var roots = (await DB.Default.Find<RootUser>().Match(u => u.IsBanned || u.DeletedAt != null).Project(u => u.Include(x => x.ID)).ExecuteAsync(token))
|
||||
.Select(u => u.ID).ToList();
|
||||
return roots.Count == 0
|
||||
? new List<string>()
|
||||
: (await DB.Default.Find<RootToAvatar>().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
|
||||
}
|
||||
|
||||
public static async Task<long> Users(CancellationToken token)
|
||||
{
|
||||
var barred = await BarredPersonas(token);
|
||||
return await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue && !barred.Contains(a.ID), token);
|
||||
}
|
||||
|
||||
// users who posted within the last days, as NodeInfo's activeMonth and activeHalfyear and the instance API mean it
|
||||
public static async Task<long> ActiveUsers(int days, CancellationToken token)
|
||||
{
|
||||
var barred = await BarredPersonas(token);
|
||||
var since = DateTime.UtcNow.AddDays(-days);
|
||||
var authors = await DB.Default.Find<PostEntity, string>()
|
||||
.Match(p => !p.IsFederatedCopy && p.CreationDate >= since && !barred.Contains(p.GroupUserId))
|
||||
.Project(p => p.GroupUserId)
|
||||
.ExecuteAsync(token);
|
||||
var distinct = authors.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList();
|
||||
return distinct.Count == 0 ? 0 : await DB.Default.CountAsync<Avatar>(a => distinct.Contains(a.ID) && !a.DeletionAt.HasValue, token);
|
||||
}
|
||||
|
||||
public static async Task<long> LocalPosts(CancellationToken token)
|
||||
{
|
||||
var barred = await BarredPersonas(token);
|
||||
return await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,8 +95,17 @@ namespace PrivaPub.Domain.Statuses
|
||||
_fanout = fanout;
|
||||
}
|
||||
|
||||
public const int MaxCharacters = 5000;//what the instance API advertises as max_characters, and enforced here
|
||||
|
||||
static StatusOutcome TooLong(StatusDraft draft) =>
|
||||
(draft.Text?.Length ?? 0) > MaxCharacters
|
||||
? StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, $"Validation failed: Text character limit of {MaxCharacters} exceeded")
|
||||
: default;
|
||||
|
||||
public async Task<StatusOutcome> Publish(LocalActor author, StatusDraft draft, CancellationToken token)
|
||||
{
|
||||
if (TooLong(draft) is { } tooLong)
|
||||
return tooLong;
|
||||
var media = await Media(author, draft.MediaIds, default, token);
|
||||
if (media == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
@@ -239,7 +248,7 @@ namespace PrivaPub.Domain.Statuses
|
||||
|
||||
await DB.Default.SaveAsync(post, token);
|
||||
await Attach(media, post.ID, token);
|
||||
if (parent != default)
|
||||
if (parent != default && Counted.Reply(post))
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
await _fanout.Distribute(post, token);
|
||||
await _polls.Scheduled(post, token);
|
||||
@@ -260,6 +269,8 @@ namespace PrivaPub.Domain.Statuses
|
||||
var post = await Own(author, postId, token);
|
||||
if (post == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status404NotFound, "Record not found");
|
||||
if (TooLong(draft) is { } tooLong)
|
||||
return tooLong;
|
||||
var media = draft.MediaIds == default ? default : await Media(author, draft.MediaIds, post.ID, token);
|
||||
if (draft.MediaIds != default && media == default)
|
||||
return StatusOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: Media attachments are invalid");
|
||||
@@ -342,8 +353,12 @@ namespace PrivaPub.Domain.Statuses
|
||||
.Modify(p => p.Revisions, new List<PostRevision>())
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||
// boosts of it end with it, as on Mastodon, so no count keeps them
|
||||
await DB.Default.Update<PostEntity>().Match(p => p.ReblogOfPostId == post.ID && !p.DeletedAt.HasValue)
|
||||
.Modify(p => p.DeletedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId) && Counted.Reply(post))
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<Models.Social.Pin>(p => p.PostId == post.ID);//a deleted post no longer takes a pin
|
||||
if (audience.Count > 0)
|
||||
{
|
||||
var delete = ActivityPubRenderer.Delete(author, post.ObjectURI, $"delete-{post.ID}",
|
||||
|
||||
@@ -84,9 +84,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
|
||||
if (!page)
|
||||
{
|
||||
var total = local.Kind == LocalActorKind.Group
|
||||
? await DB.Default.CountAsync<PostEntity>(f => f.Where(p => p.GroupId == local.Id) & f.Where(VisibilityPolicy.IsPublic), token)
|
||||
: await DB.Default.CountAsync<PostEntity>(f => f.Where(p => p.GroupUserId == local.Id && !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token);
|
||||
// as Mastodon's: statuses_count, though only public posts are listed
|
||||
var total = await Counted.PostsBy(local, token);
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Outbox, (int)total, default, $"{local.Outbox}?page=true"));
|
||||
}
|
||||
|
||||
@@ -137,7 +136,13 @@ namespace PrivaPub.Federation.Controllers
|
||||
public async Task<IActionResult> Following(string actor, CancellationToken token)
|
||||
{
|
||||
var local = await _localActors.FindByUserName(actor, token);
|
||||
return local is not { IsFederated: true } ? NotFound() : Activity(ActivityPubRenderer.OrderedCollection(local.Following, 0, default));
|
||||
if (local is not { IsFederated: true })
|
||||
return NotFound();
|
||||
// the count, as following_count says it; who is followed is never listed (FEDERATION.md)
|
||||
var following = local.Kind == LocalActorKind.Person
|
||||
? await DB.Default.CountAsync<Following>(f => f.AvatarId == local.Id && f.State == FollowState.Accepted, token)
|
||||
: 0;
|
||||
return Activity(ActivityPubRenderer.OrderedCollection(local.Following, (int)following, default));
|
||||
}
|
||||
|
||||
[HttpGet, Route("{actor}/flock")]
|
||||
|
||||
@@ -94,8 +94,8 @@ namespace PrivaPub.Federation.Controllers
|
||||
[HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")]
|
||||
public async Task<IActionResult> NodeInfo(string version, CancellationToken token)
|
||||
{
|
||||
var users = await DB.Default.CountAsync<Avatar>(a => !a.DeletionAt.HasValue, token);
|
||||
var posts = await DB.Default.CountAsync<PostEntity>(f => f.Where(p => !p.IsFederatedCopy && p.ReblogOfPostId == null) & f.Where(VisibilityPolicy.IsPublic), token);
|
||||
var users = await Counted.Users(token);
|
||||
var posts = await Counted.LocalPosts(token);
|
||||
var software = new JsonObject { ["name"] = "privapub", ["version"] = BuildInfo.Ref };
|
||||
if (version == "2.1")
|
||||
{
|
||||
@@ -111,7 +111,12 @@ namespace PrivaPub.Federation.Controllers
|
||||
["openRegistrations"] = _registrations.CurrentValue.IsOpen,
|
||||
["usage"] = new JsonObject
|
||||
{
|
||||
["users"] = new JsonObject { ["total"] = users },
|
||||
["users"] = new JsonObject
|
||||
{
|
||||
["total"] = users,
|
||||
["activeMonth"] = await Counted.ActiveUsers(30, token),
|
||||
["activeHalfyear"] = await Counted.ActiveUsers(180, token)
|
||||
},
|
||||
["localPosts"] = posts
|
||||
},
|
||||
["metadata"] = new JsonObject
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Federation.Inbox
|
||||
{
|
||||
// A remote account that deleted itself keeps its posts, hidden (owner decision 2026-10-03), but leaves nothing in our
|
||||
// numbers: its likes, downvotes, reactions and poll votes go, and the counters they had moved come back, as do the
|
||||
// reblog, reply and quote counts its own posts had added. Its notifications go too. ForgottenAt makes it run once.
|
||||
public static class GoneActors
|
||||
{
|
||||
public static async Task Forget(ForeignAvatar actor, CancellationToken token)
|
||||
{
|
||||
if (actor == default || actor.ForgottenAt.HasValue)
|
||||
return;
|
||||
var uri = actor.ActorURI;
|
||||
|
||||
foreach (var like in await DB.Default.Find<Favourite>().Match(f => f.ActorURI == uri).ExecuteAsync(token))
|
||||
{
|
||||
await DB.Default.Update<PostEntity>().MatchID(like.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<Favourite>(like.ID);
|
||||
}
|
||||
foreach (var down in await DB.Default.Find<Downvote>().Match(d => d.ActorURI == uri).ExecuteAsync(token))
|
||||
{
|
||||
await DB.Default.Update<PostEntity>().MatchID(down.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token);
|
||||
await DB.Default.DeleteAsync<Downvote>(down.ID);
|
||||
}
|
||||
await DB.Default.DeleteAsync<Reaction>(r => r.ActorURI == uri);
|
||||
foreach (var ballot in (await DB.Default.Find<PollVote>().Match(v => v.ActorURI == uri).ExecuteAsync(token)).GroupBy(v => v.PostId))
|
||||
{
|
||||
var poll = await DB.Default.Find<PostEntity>().MatchID(ballot.Key).ExecuteFirstAsync(token);
|
||||
if (poll?.Poll != default)
|
||||
{
|
||||
var update = DB.Default.Update<PostEntity>().MatchID(poll.ID);
|
||||
foreach (var vote in ballot.Where(v => v.Choice >= 0 && v.Choice < poll.Poll.Options.Count))
|
||||
update = update.Modify(b => b.Inc($"{nameof(PostEntity.Poll)}.{nameof(PostPoll.Options)}.{vote.Choice}.{nameof(PollOption.Votes)}", -1));
|
||||
if (poll.Poll.VotersCount.HasValue)
|
||||
update = update.Modify(b => b.Inc($"{nameof(PostEntity.Poll)}.{nameof(PostPoll.VotersCount)}", -1));
|
||||
await update.ExecuteAsync(token);
|
||||
}
|
||||
await DB.Default.DeleteAsync<PollVote>(v => v.PostId == ballot.Key && v.ActorURI == uri);
|
||||
}
|
||||
|
||||
foreach (var post in await DB.Default.Find<PostEntity>().Match(p => p.ActorURI == uri && !p.DeletedAt.HasValue).ExecuteAsync(token))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(post.ReblogOfPostId))
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ReblogOfPostId).Modify(b => b.Inc(p => p.ReblogsCount, -1)).ExecuteAsync(token);
|
||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId) && Counted.Reply(post))
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
||||
if (!string.IsNullOrEmpty(post.QuotedPostId) && post.QuoteState == QuoteState.Accepted)
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
|
||||
}
|
||||
|
||||
await DB.Default.DeleteAsync<Notification>(n => n.FromActorURI == uri);
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID).Modify(a => a.ForgottenAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
await _previews.Wanted(post, token);
|
||||
await _quotes.Resolve(post, note, token);
|
||||
|
||||
if (parent != default)
|
||||
if (parent != default && Domain.Privacy.Counted.Reply(post))
|
||||
await DB.Default.Update<PostEntity>().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
|
||||
await _fanout.Distribute(post, token);
|
||||
if (conversation != default)
|
||||
|
||||
@@ -71,6 +71,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
||||
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == actor.ActorURI);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.AuthorAccountId == actor.ID);
|
||||
await DB.Default.Update<PostEntity>().Match(p => p.ActorURI == actor.ActorURI).Modify(p => p.AuthorGone, true).ExecuteAsync(token);
|
||||
await GoneActors.Forget(actor, token);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ namespace PrivaPub.Federation.Inbox
|
||||
await DB.Default.DeleteAsync<ObjectRecord>(r => r.PostId == post.ID || r.ObjectURI == objectUri);
|
||||
await DB.Default.DeleteAsync<TimelineEntry>(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID);
|
||||
await DB.Default.DeleteAsync<PostEntity>(p => p.ReblogOfPostId == post.ID);
|
||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId))
|
||||
if (!string.IsNullOrEmpty(post.AnsweringToPostId) && Domain.Privacy.Counted.Reply(post))
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token);
|
||||
if (post.QuoteState == QuoteState.Accepted && !string.IsNullOrEmpty(post.QuotedPostId))
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Inbox;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Models.User;
|
||||
|
||||
using PostEntity = PrivaPub.Models.Post.Post;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Data.Migrations
|
||||
{
|
||||
// replies_count now counts only public and unlisted replies, and accounts that deleted themselves leave nothing in the
|
||||
// counts (GoneActors). Both are recounted once for what was stored before.
|
||||
public class _012_counts_follow_one_rule : IMigration
|
||||
{
|
||||
public async Task UpgradeAsync()
|
||||
{
|
||||
var counted = (await DB.Default.Find<PostEntity, string>()
|
||||
.Match(p => p.AnsweringToPostId != null && !p.DeletedAt.HasValue && !p.AuthorGone
|
||||
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted))
|
||||
.Project(p => p.AnsweringToPostId)
|
||||
.ExecuteAsync())
|
||||
.GroupBy(id => id)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var stale = await DB.Default.Find<PostEntity>().Match(p => p.RepliesCount > 0).Project(p => p.Include(x => x.ID).Include(x => x.RepliesCount)).ExecuteAsync();
|
||||
foreach (var post in stale.Where(p => counted.GetValueOrDefault(p.ID) != p.RepliesCount))
|
||||
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.RepliesCount, counted.GetValueOrDefault(post.ID)).ExecuteAsync();
|
||||
foreach (var (id, count) in counted.Where(c => stale.All(p => p.ID != c.Key)))
|
||||
await DB.Default.Update<PostEntity>().MatchID(id).Modify(p => p.RepliesCount, count).ExecuteAsync();
|
||||
|
||||
foreach (var gone in await DB.Default.Find<ForeignAvatar>().Match(a => a.DeletionAt != null && a.ForgottenAt == null).ExecuteAsync())
|
||||
await GoneActors.Forget(gone, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ namespace PrivaPub.Models.User
|
||||
public DateTime? SuspendedAt { get; set; }
|
||||
public DateTime? BannedAt { get; set; }
|
||||
public DateTime? DeletionAt { get; set; }
|
||||
public DateTime? ForgottenAt { get; set; }//its likes, votes, boosts and replies were taken out of our counts (GoneActors)
|
||||
}
|
||||
|
||||
public class AvatarSettings
|
||||
|
||||
@@ -23,7 +23,8 @@ namespace PrivaPub.Models.User
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? CredentialsChangedAt { get; set; }//a /clientapi token issued before this is refused (JwtEvents)
|
||||
public DateTime? CredentialsChangedAt { get; set; }//when RootSessions last ended every session
|
||||
public string SessionStamp { get; set; }//carried by every /clientapi token; a new one ends the tokens issued before
|
||||
}
|
||||
|
||||
public class ContactItem
|
||||
|
||||
@@ -210,6 +210,7 @@ namespace PrivaPub.Services
|
||||
group.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(group, token);
|
||||
}
|
||||
await FollowAsMember(group, form.AvatarId, token);
|
||||
|
||||
result.Data = ToView(group, form.AvatarId);
|
||||
return result;
|
||||
@@ -221,6 +222,33 @@ namespace PrivaPub.Services
|
||||
}
|
||||
}
|
||||
|
||||
// Joining by invitation is following, as a local follow of the group would be: the member counts among its
|
||||
// followers (so /groupies and /flock agree) and gets its posts in the home timeline.
|
||||
async Task FollowAsMember(GroupEntity group, string avatarId, CancellationToken token)
|
||||
{
|
||||
var persona = await _localActors.FindById(LocalActorKind.Person, avatarId, token);
|
||||
var target = _localActors.FromGroup(group);
|
||||
if (persona == default)
|
||||
return;
|
||||
await DB.Default.Update<Follower>()
|
||||
.Match(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == persona.Uri)
|
||||
.Modify(f => f.InboxURL, persona.Inbox)
|
||||
.Modify(f => f.SharedInboxURL, persona.SharedInbox)
|
||||
.Modify(f => f.IsAccepted, true)
|
||||
.Modify(b => b.SetOnInsert(f => f.CreationDate, DateTime.UtcNow))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
await DB.Default.Update<Models.Social.Following>()
|
||||
.Match(f => f.AvatarId == persona.Id && f.TargetActorURI == target.Uri)
|
||||
.Modify(f => f.TargetAccountId, group.ID)
|
||||
.Modify(f => f.TargetIsLocal, true)
|
||||
.Modify(f => f.TargetInboxURL, target.Inbox)
|
||||
.Modify(f => f.State, Models.Social.FollowState.Accepted)
|
||||
.Modify(b => b.SetOnInsert(f => f.CreatedAt, DateTime.UtcNow))
|
||||
.Option(o => o.IsUpsert = true)
|
||||
.ExecuteAsync(token);
|
||||
}
|
||||
|
||||
public async Task<WebResult> LeaveGroup(string rootUserId, GroupMembershipForm form, CancellationToken token)
|
||||
{
|
||||
var result = new WebResult();
|
||||
@@ -238,6 +266,12 @@ namespace PrivaPub.Services
|
||||
group.Members.RemoveAll(m => !m.IsForeign && m.AvatarId == form.AvatarId);
|
||||
group.UpdatedAt = DateTime.UtcNow;
|
||||
await DB.Default.SaveAsync(group, token);
|
||||
// leaving is unfollowing, as joining was following
|
||||
var groupUri = _localActors.FromGroup(group).Uri;
|
||||
var personaUri = (await _localActors.FindById(LocalActorKind.Person, form.AvatarId, token))?.Uri;
|
||||
await DB.Default.DeleteAsync<Models.Social.Following>(f => f.AvatarId == form.AvatarId && f.TargetActorURI == groupUri);
|
||||
if (personaUri != default)
|
||||
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == personaUri);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -33,10 +33,10 @@ namespace PrivaPub.Services
|
||||
context.Fail("The account can no longer be used.");
|
||||
return;
|
||||
}
|
||||
// a password recovery ends every session made before it (RootSessions)
|
||||
if (root.CredentialsChangedAt is { } changed && context.SecurityToken.ValidFrom < changed.AddSeconds(-1))
|
||||
// a password recovery ends every session made before it (RootSessions gives the root a new stamp)
|
||||
if ((root.SessionStamp ?? string.Empty) != (context.Principal.FindFirst(AuthTokenManager.SessionStamp)?.Value ?? string.Empty))
|
||||
{
|
||||
context.Fail("The account's password was changed.");
|
||||
context.Fail("The account's sessions were ended.");
|
||||
return;
|
||||
}
|
||||
if (context.Principal.Identity is not ClaimsIdentity identity)
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace PrivaPub.Services
|
||||
Task Revoke(string rootId, CancellationToken token);
|
||||
}
|
||||
|
||||
// Ends everything a root is signed in with: its /clientapi tokens, through CredentialsChangedAt (checked by JwtEvents),
|
||||
// Ends everything a root is signed in with: its /clientapi tokens, through a new SessionStamp (checked by JwtEvents),
|
||||
// and the Mastodon API tokens and authorizations of each of its personas. Used when its password is recovered and when
|
||||
// it is deleted.
|
||||
public class RootSessions : IRootSessions
|
||||
@@ -30,7 +30,10 @@ namespace PrivaPub.Services
|
||||
|
||||
public async Task Revoke(string rootId, CancellationToken token)
|
||||
{
|
||||
await DB.Default.Update<RootUser>().MatchID(rootId).Modify(u => u.CredentialsChangedAt, DateTime.UtcNow).ExecuteAsync(token);
|
||||
await DB.Default.Update<RootUser>().MatchID(rootId)
|
||||
.Modify(u => u.CredentialsChangedAt, DateTime.UtcNow)
|
||||
.Modify(u => u.SessionStamp, Guid.NewGuid().ToString("N"))
|
||||
.ExecuteAsync(token);
|
||||
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId).ExecuteAsync(token))
|
||||
{
|
||||
await _tokens.RevokeBySubjectAsync(link.AvatarId, token);
|
||||
|
||||
@@ -12,6 +12,9 @@ namespace PrivaPub.StaticServices
|
||||
{
|
||||
public class AuthTokenManager
|
||||
{
|
||||
public const string SessionStamp = "ss";
|
||||
const string SessionStampClaim = SessionStamp;//the root's RootUser.SessionStamp when issued; JwtEvents refuses a stale one
|
||||
|
||||
readonly IConfiguration Configuration;
|
||||
|
||||
public AuthTokenManager(IConfiguration configuration)
|
||||
@@ -35,7 +38,8 @@ namespace PrivaPub.StaticServices
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.UserData, user.ID),
|
||||
new(ClaimTypes.Name, user.UserName)
|
||||
new(ClaimTypes.Name, user.UserName),
|
||||
new(SessionStampClaim, user.SessionStamp ?? string.Empty)
|
||||
};
|
||||
|
||||
claims.Add(new(Policies.IsUser, $"{user.Policies.Contains(Policies.IsUser)}".ToLower()));
|
||||
@@ -44,7 +48,7 @@ namespace PrivaPub.StaticServices
|
||||
|
||||
var token = new JwtSecurityToken(issuer: Configuration["AppConfiguration:Jwt:Issuer"], audience: Configuration["AppConfiguration:Jwt:Audience"],
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,//its issue time, which JwtEvents compares with RootUser.CredentialsChangedAt
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiration,
|
||||
signingCredentials: new(securityKey, SecurityAlgorithms.HmacSha512)
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user