diff --git a/CLAUDE.md b/CLAUDE.md index 5a43816..0f90b19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,13 +201,20 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. 10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job`s (`Infrastructure/Jobs`): leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance`. The inbox answers 202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI`, job `DedupeKey`). -11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and +11. **Every number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one + answer everywhere): a persona's `statuses_count`, its outbox `totalItems`, NodeInfo `localPosts` and the instance + `status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too); + users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a + remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count + (`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always + true). +12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision). -12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or +13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is fetched as context. Followers-only is detected by the author's stored `followers` URL. -13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and +14. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone). ## Mastodon client API invariants @@ -256,8 +263,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. gets "That username and password do not match." after the same hashing (`RootUsersService.Decoy`, a constant-time comparison), and only a correct password learns of a ban. Every recovery request answers the same sentence and queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an - hour. A recovered password ends every session of the root (`RootSessions`: `CredentialsChangedAt` for `/clientapi` - JWTs, OpenIddict revocation for each persona). + hour. A recovered password ends every session of the root (`RootSessions`: a new `SessionStamp`, which every `/clientapi` + JWT carries, and OpenIddict revocation for each persona). - **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete` with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer diff --git a/FEDERATION.md b/FEDERATION.md index 026ca3c..895e928 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -215,5 +215,7 @@ Posts with a location (shown to nearby users of this server) never leave the ser - Our own posts carry no custom emoji: emoji are received and shown, not offered. - Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer. -- Collections expose counts, not members. +- Collections expose counts, not members: `/groupies` and `/stalking` give the same totals as the Mastodon API's + follower and following counts, and the outbox's `totalItems` is the persona's post count, though only public posts + are listed. `/api/v1/instance/peers` is empty: which servers this one talks to is not published. - Only `rsa-sha256`-style keys are verified. RFC 9421 signatures are planned. diff --git a/PrivaPub.Tests/Http/FederationGetTests.cs b/PrivaPub.Tests/Http/FederationGetTests.cs index f0c6ad9..4799544 100644 --- a/PrivaPub.Tests/Http/FederationGetTests.cs +++ b/PrivaPub.Tests/Http/FederationGetTests.cs @@ -142,7 +142,9 @@ namespace PrivaPub.Tests.Http Assert.Equal(ActivityJson, outbox.MediaType); Assert.Equal("OrderedCollection", outbox.Json["type"]!.GetValue()); Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue()); - Assert.Equal(22, outbox.Json["totalItems"]!.GetValue()); + // statuses_count, as Mastodon counts it: everything but the DM, the deleted post and the copy of a remote post, + // the followers-only and located posts and the boost included; only the public posts are listed + Assert.Equal(24, outbox.Json["totalItems"]!.GetValue()); Assert.Null(outbox.Json["orderedItems"]); var first = outbox.Json["first"]!.GetValue(); Assert.Equal(persona.ActorUri() + "/anus?page=true", first); @@ -221,7 +223,7 @@ namespace PrivaPub.Tests.Http Assert.DoesNotContain(pending, followers.Text); Assert.Equal(HttpStatusCode.OK, following.Status); Assert.Equal(persona.ActorUri() + "/stalking", following.Json["id"]!.GetValue()); - Assert.Equal(0, following.Json["totalItems"]!.GetValue()); + Assert.Equal(1, following.Json["totalItems"]!.GetValue());//counted, as following_count says, never listed Assert.DoesNotContain(friend.UserName, following.Text); } diff --git a/PrivaPub.Tests/Http/MastodonInstanceTests.cs b/PrivaPub.Tests/Http/MastodonInstanceTests.cs index 706d0e6..059df74 100644 --- a/PrivaPub.Tests/Http/MastodonInstanceTests.cs +++ b/PrivaPub.Tests/Http/MastodonInstanceTests.cs @@ -41,7 +41,9 @@ namespace PrivaPub.Tests.Http var v2 = (await anonymous.Get("/api/v2/instance")).Ok(); Assert.Equal(InstanceController.Version, v2.Body.Text("version")); Assert.Equal(PrivaPubHost.Host, v2.Body.Text("domain")); - Assert.Equal($"wss://{PrivaPubHost.Host}", v2.Body["configuration"]!["urls"].Text("streaming")); + Assert.Null(v2.Body["configuration"]!["urls"]!["streaming"]);//nothing streams yet, so nothing is advertised + Assert.Equal(PrivaPub.Api.Mastodon.Controllers.StatusesController.MaxPins, v2.Body["configuration"]!["accounts"].Number("max_pinned_statuses")); + Assert.Contains("image/avif", v2.Body["configuration"]!["media_attachments"]!["supported_mime_types"]!.AsArray().Select(t => t!.GetValue())); Assert.True(v2.Body["registrations"].Flag("enabled")); Assert.True((await anonymous.Get("/nodeinfo/2.1")).Ok().Body.Flag("openRegistrations")); Assert.False(v2.Body["configuration"]!["translation"].Flag("enabled")); diff --git a/PrivaPub.Tests/Http/OneAnswerTests.cs b/PrivaPub.Tests/Http/OneAnswerTests.cs new file mode 100644 index 0000000..6ff8c6b --- /dev/null +++ b/PrivaPub.Tests/Http/OneAnswerTests.cs @@ -0,0 +1,164 @@ +using MongoDB.Entities; + +using PrivaPub.Models.Federation; +using PrivaPub.Models.Post; +using PrivaPub.Models.Social; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Text.Json.Nodes; + +using static PrivaPub.Tests.Support.Host.FederationHelpers; + +using PostEntity = PrivaPub.Models.Post.Post; + +namespace PrivaPub.Tests.Http +{ + // Two views of the same fact give one answer (owner decision 2026-10-04: fix the mismatches). + [Trait("Category", "Integration")] + public sealed class OneAnswerTests : IAsyncLifetime + { + PrivaPubHost _host; + HttpClient _client; + Peer _peer; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + _client = _host.Client(); + _peer = await Peer.Start(); + } + + public async ValueTask DisposeAsync() + { + _client?.Dispose(); + if (_peer != default) + await _peer.DisposeAsync(); + } + + [Fact] + public async Task Anyone_may_search_and_only_a_signed_in_reader_resolves_or_pages() + { + var reader = await _host.Mastodon("searcher"); + + Assert.Equal(HttpStatusCode.OK, (await _client.Get("/api/v2/search?q=somebody")).Status); + Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&resolve=true")).Status); + Assert.Equal(HttpStatusCode.Unauthorized, (await _client.Get("/api/v2/search?q=somebody&offset=5")).Status); + Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v2/search?q=somebody&offset=5")).Status); + } + + [Fact] + public async Task A_remote_account_that_deletes_itself_leaves_nothing_in_the_counts() + { + var token = TestContext.Current.CancellationToken; + var author = await _host.Mastodon("liked"); + var post = await author.Status("like me"); + var fan = new RemoteActor(_peer, "fickle"); + var like = new JsonObject + { + ["id"] = $"{fan.Id}#likes/{Guid.NewGuid():N}", + ["type"] = "Like", + ["actor"] = fan.Id, + ["object"] = post.Text("uri") + }; + Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", like), token)).StatusCode); + await _host.RunInbox(like["id"]!.GetValue(), token); + Assert.Equal(1, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count")); + + var delete = new JsonObject + { + ["id"] = $"{fan.Id}#delete", + ["type"] = "Delete", + ["actor"] = fan.Id, + ["object"] = fan.Id, + ["to"] = new JsonArray("https://www.w3.org/ns/activitystreams#Public") + }; + Assert.Equal(HttpStatusCode.Accepted, (await _client.SendAsync(fan.SignedPost("/human-centipede", delete), token)).StatusCode); + await _host.RunInbox(delete["id"]!.GetValue(), token); + + Assert.Equal(0, (await author.Client.Get($"/api/v1/statuses/{post.Text("id")}")).Ok().Body.Number("favourites_count")); + Assert.Empty((await author.Client.Get($"/api/v1/statuses/{post.Text("id")}/favourited_by")).Ok().Array); + Assert.False(await DB.Default.Find().Match(f => f.ActorURI == fan.Id).ExecuteAnyAsync(token)); + Assert.False(await DB.Default.Find().Match(n => n.FromActorURI == fan.Id).ExecuteAnyAsync(token)); + Assert.Equal(0, (await author.Client.Get("/api/v1/notifications/unread_count")).Ok().Body.Number("count")); + } + + [Fact] + public async Task Only_public_and_unlisted_replies_are_counted() + { + var author = await _host.Mastodon("threadstart"); + var replier = await _host.Mastodon("replier"); + var root = await author.Status("say something"); + var id = root.Text("id"); + + await replier.Status("in public", ("in_reply_to_id", id)); + await replier.Status("quietly", ("in_reply_to_id", id), ("visibility", "unlisted")); + await replier.Status("for my followers", ("in_reply_to_id", id), ("visibility", "private")); + await replier.Status($"@{author.UserName} just you", ("in_reply_to_id", id), ("visibility", "direct")); + + Assert.Equal(2, (await author.Client.Get($"/api/v1/statuses/{id}")).Ok().Body.Number("replies_count")); + } + + [Fact] + public async Task A_status_longer_than_advertised_is_refused() + { + var author = await _host.Mastodon("wordy"); + var limit = (await _client.Get("/api/v2/instance")).Ok().Body["configuration"]!["statuses"].Number("max_characters"); + + var refused = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit + 1))); + var accepted = await author.Client.Post("/api/v1/statuses", ("status", new string('a', limit))); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, refused.Status); + Assert.Equal(HttpStatusCode.OK, accepted.Status); + } + + [Fact] + public async Task Joining_a_community_by_invitation_is_following_it() + { + var token = TestContext.Current.CancellationToken; + var owner = await _host.Persona(await _host.SignUp(), "commowner"); + var joinerRoot = await _host.SignUp("joiner"); + var joiner = await _host.Persona(joinerRoot, "joiner"); + var group = await _host.Group(owner, community: true); + var groupName = group["userName"]!.GetValue(); + using var client = _host.As(joinerRoot.Jwt); + + var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = group["invitationCode"]!.GetValue() }); + + Assert.Equal(HttpStatusCode.OK, joined.StatusCode); + Assert.Equal(1, (await _client.Fetch($"/peasants/{groupName}/groupies")).Json["totalItems"]!.GetValue()); + Assert.True(await DB.Default.Find().Match(f => f.AvatarId == joiner.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token)); + } + + public static TheoryData AnsweredRoutes() => new() + { + "/api/v1/directory", + "/api/v1/tags/cats", + "/api/v1/instance/languages", + "/api/v1/instance/translation_languages", + "/api/v1/instance/domain_blocks", + "/api/v1/instance/privacy_policy", + "/api/v1/timelines/link?url=https%3A%2F%2Fexample.org%2F", + "/api/v1/accounts/000000000000000000000000/identity_proofs" + }; + + [Theory] + [MemberData(nameof(AnsweredRoutes))] + public async Task Mastodon_routes_we_have_nothing_behind_answer_instead_of_404(string path) + { + Assert.Equal(HttpStatusCode.OK, (await _client.Get(path)).Status); + } + + [Fact] + public async Task Notification_policy_and_requests_answer_a_signed_in_reader() + { + var reader = await _host.Mastodon("policy"); + + Assert.Equal(HttpStatusCode.OK, (await reader.Client.Get("/api/v1/notifications/policy")).Status); + Assert.Equal("accept", (await reader.Client.Get("/api/v2/notifications/policy")).Ok().Body.Text("for_not_following")); + Assert.Empty((await reader.Client.Get("/api/v1/notifications/requests")).Ok().Array); + } + } +} diff --git a/PrivaPub.Tests/Http/WellKnownTests.cs b/PrivaPub.Tests/Http/WellKnownTests.cs index 529bee0..286f627 100644 --- a/PrivaPub.Tests/Http/WellKnownTests.cs +++ b/PrivaPub.Tests/Http/WellKnownTests.cs @@ -179,8 +179,10 @@ namespace PrivaPub.Tests.Http return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue(); } + // localPosts is Mastodon's: the sum of statuses_count, every local post but DMs and deleted ones, boosts included, + // and circle and located posts too (owner decision 2026-10-04) [Fact] - public async Task Local_posts_count_only_what_anyone_may_see() + public async Task Local_posts_count_as_mastodon_counts_statuses() { var persona = await _host.Persona(await _host.SignUp(), "usage"); var friend = await _host.Persona(await _host.SignUp(), "usagefriend"); @@ -197,10 +199,10 @@ namespace PrivaPub.Tests.Http await _host.Reblog(friend, shown); var circle = await _host.FederatedGroup(persona, community: false); await _host.Publish(persona, new StatusDraft { Text = "circle only", PlainText = true, GroupId = circle.Id }); - Assert.Equal(start + 2, await LocalPosts()); + Assert.Equal(start + 6, await LocalPosts());//followers-only, located, the boost and the circle post; not the DM nor the copy await _host.Remove(persona, shown); - Assert.Equal(start + 1, await LocalPosts()); + Assert.Equal(start + 4, await LocalPosts());//the post and the boost of it } } } diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index 400b99e..158777b 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -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()); - 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()); - 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()); } diff --git a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs index 51fd012..eb08e5f 100644 --- a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs @@ -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 LanguageCodes(IServiceProvider services) + { + var configured = services.GetRequiredService>().CurrentValue.SupportedLanguages; + var codes = (configured ?? new List()).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>().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 V1(CancellationToken token) { - var users = await DB.Default.CountAsync(a => !a.DeletionAt.HasValue, token); - var statuses = await DB.Default.CountAsync(f => f.Where(p => !p.IsFederatedCopy) & f.Where(VisibilityPolicy.IsPublic), token); - var domains = (await DB.Default.Find().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 KnownServers(CancellationToken token) => + await DB.Default.CountAsync(i => i.Seen == "touched", token); + [HttpGet("/api/v2/instance"), AllowAnonymous] - public IActionResult V2() => Json(new + public async Task 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, diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index c6d9ad6..a026bff 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -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 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() }); @@ -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()); + + [HttpGet("/api/v1/timelines/link"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult LinkTimeline() => Json(Array.Empty()); + + [HttpGet("/api/v1/accounts/{id}/identity_proofs"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult IdentityProofs(string id) => Json(Array.Empty()); + + [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().BaseAddress; + return Json(new { name = tag, url = $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}", history = Array.Empty(), 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()); + + [HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous] + public IActionResult PrivacyPolicy() => Json(new + { + updated_at = "2026-10-04T00:00:00.000Z", + content = "

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.

" + }); + + [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()); } } diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs index 5d91849..6168666 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs @@ -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 UnreadCount(CancellationToken token) => - Json(new { count = await DB.Default.CountAsync(n => n.AvatarId == MyId && !n.IsRead, token) }); + public async Task UnreadCount(CancellationToken token) + { + var unread = await DB.Default.Find().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> Map(List notifications, CancellationToken token) { diff --git a/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs index fd9bfbe..b4575d6 100644 --- a/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs +++ b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs @@ -19,7 +19,10 @@ namespace PrivaPub.Api.Mastodon.Mappers _remoteActors = remoteActors; } - public async Task> Find(string q, bool resolve, int limit, CancellationToken token) + public async Task> 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> 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; } } diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 479f76e..0502abe 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -50,15 +50,11 @@ namespace PrivaPub.Api.Mastodon.Mappers ? await DB.Default.CountAsync(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(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; } diff --git a/PrivaPub/Domain/Media/MediaService.cs b/PrivaPub/Domain/Media/MediaService.cs index 444fe2a..ee9bff3 100644 --- a/PrivaPub/Domain/Media/MediaService.cs +++ b/PrivaPub/Domain/Media/MediaService.cs @@ -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 SupportedTypes => ImageTypes.Concat(AvTypes.Keys).ToList(); + readonly IOptionsMonitor _options; readonly ILocalActorService _localActors; readonly IWebHostEnvironment _environment; diff --git a/PrivaPub/Domain/Privacy/Counted.cs b/PrivaPub/Domain/Privacy/Counted.cs new file mode 100644 index 0000000..ffb0b7c --- /dev/null +++ b/PrivaPub/Domain/Privacy/Counted.cs @@ -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> 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> 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 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> BarredPersonas(CancellationToken token) + { + var roots = (await DB.Default.Find().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() + : (await DB.Default.Find().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList(); + } + + public static async Task Users(CancellationToken token) + { + var barred = await BarredPersonas(token); + return await DB.Default.CountAsync(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 ActiveUsers(int days, CancellationToken token) + { + var barred = await BarredPersonas(token); + var since = DateTime.UtcNow.AddDays(-days); + var authors = await DB.Default.Find() + .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(a => distinct.Contains(a.ID) && !a.DeletionAt.HasValue, token); + } + + public static async Task LocalPosts(CancellationToken token) + { + var barred = await BarredPersonas(token); + return await DB.Default.CountAsync(f => f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token); + } + } +} diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index cc1e6e1..4269977 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -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 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().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()) .ExecuteAsync(token); await DB.Default.DeleteAsync(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().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().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, -1)).ExecuteAsync(token); + await DB.Default.DeleteAsync(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}", diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index b224781..ddee4e3 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -84,9 +84,8 @@ namespace PrivaPub.Federation.Controllers if (!page) { - var total = local.Kind == LocalActorKind.Group - ? await DB.Default.CountAsync(f => f.Where(p => p.GroupId == local.Id) & f.Where(VisibilityPolicy.IsPublic), token) - : await DB.Default.CountAsync(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 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(f => f.AvatarId == local.Id && f.State == FollowState.Accepted, token) + : 0; + return Activity(ActivityPubRenderer.OrderedCollection(local.Following, (int)following, default)); } [HttpGet, Route("{actor}/flock")] diff --git a/PrivaPub/Federation/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index 0a8c452..1645a02 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -94,8 +94,8 @@ namespace PrivaPub.Federation.Controllers [HttpGet, Route("/nodeinfo/{version:regex(^2\\.[[01]]$)}")] public async Task NodeInfo(string version, CancellationToken token) { - var users = await DB.Default.CountAsync(a => !a.DeletionAt.HasValue, token); - var posts = await DB.Default.CountAsync(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 diff --git a/PrivaPub/Federation/Inbox/GoneActors.cs b/PrivaPub/Federation/Inbox/GoneActors.cs new file mode 100644 index 0000000..421570e --- /dev/null +++ b/PrivaPub/Federation/Inbox/GoneActors.cs @@ -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().Match(f => f.ActorURI == uri).ExecuteAsync(token)) + { + await DB.Default.Update().MatchID(like.PostId).Modify(b => b.Inc(p => p.FavouritesCount, -1)).ExecuteAsync(token); + await DB.Default.DeleteAsync(like.ID); + } + foreach (var down in await DB.Default.Find().Match(d => d.ActorURI == uri).ExecuteAsync(token)) + { + await DB.Default.Update().MatchID(down.PostId).Modify(b => b.Inc(p => p.DownvotesCount, -1)).ExecuteAsync(token); + await DB.Default.DeleteAsync(down.ID); + } + await DB.Default.DeleteAsync(r => r.ActorURI == uri); + foreach (var ballot in (await DB.Default.Find().Match(v => v.ActorURI == uri).ExecuteAsync(token)).GroupBy(v => v.PostId)) + { + var poll = await DB.Default.Find().MatchID(ballot.Key).ExecuteFirstAsync(token); + if (poll?.Poll != default) + { + var update = DB.Default.Update().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(v => v.PostId == ballot.Key && v.ActorURI == uri); + } + + foreach (var post in await DB.Default.Find().Match(p => p.ActorURI == uri && !p.DeletedAt.HasValue).ExecuteAsync(token)) + { + if (!string.IsNullOrEmpty(post.ReblogOfPostId)) + await DB.Default.Update().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().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().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token); + } + + await DB.Default.DeleteAsync(n => n.FromActorURI == uri); + await DB.Default.Update().MatchID(actor.ID).Modify(a => a.ForgottenAt, DateTime.UtcNow).ExecuteAsync(token); + } + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index cb6b3c4..0a3f7d9 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -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().MatchID(parent.ID).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token); await _fanout.Distribute(post, token); if (conversation != default) diff --git a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs index 6e75dce..96292d2 100644 --- a/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/DeleteHandler.cs @@ -71,6 +71,7 @@ namespace PrivaPub.Federation.Inbox.Handlers await DB.Default.DeleteAsync(f => f.TargetActorURI == actor.ActorURI); await DB.Default.DeleteAsync(e => e.AuthorAccountId == actor.ID); await DB.Default.Update().Match(p => p.ActorURI == actor.ActorURI).Modify(p => p.AuthorGone, true).ExecuteAsync(token); + await GoneActors.Forget(actor, token); return; } diff --git a/PrivaPub/Federation/Inbox/RemoteEdits.cs b/PrivaPub/Federation/Inbox/RemoteEdits.cs index 0326cf9..25585a0 100644 --- a/PrivaPub/Federation/Inbox/RemoteEdits.cs +++ b/PrivaPub/Federation/Inbox/RemoteEdits.cs @@ -104,7 +104,7 @@ namespace PrivaPub.Federation.Inbox await DB.Default.DeleteAsync(r => r.PostId == post.ID || r.ObjectURI == objectUri); await DB.Default.DeleteAsync(e => e.PostId == post.ID || e.ReblogOfPostId == post.ID); await DB.Default.DeleteAsync(p => p.ReblogOfPostId == post.ID); - if (!string.IsNullOrEmpty(post.AnsweringToPostId)) + if (!string.IsNullOrEmpty(post.AnsweringToPostId) && Domain.Privacy.Counted.Reply(post)) await DB.Default.Update().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().MatchID(post.QuotedPostId).Modify(b => b.Inc(p => p.QuotesCount, -1)).ExecuteAsync(token); diff --git a/PrivaPub/Infrastructure/Data/Migrations/_012_counts_follow_one_rule.cs b/PrivaPub/Infrastructure/Data/Migrations/_012_counts_follow_one_rule.cs new file mode 100644 index 0000000..8195c87 --- /dev/null +++ b/PrivaPub/Infrastructure/Data/Migrations/_012_counts_follow_one_rule.cs @@ -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() + .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().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().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().MatchID(id).Modify(p => p.RepliesCount, count).ExecuteAsync(); + + foreach (var gone in await DB.Default.Find().Match(a => a.DeletionAt != null && a.ForgottenAt == null).ExecuteAsync()) + await GoneActors.Forget(gone, CancellationToken.None); + } + } +} diff --git a/PrivaPub/Models/User/Avatar.cs b/PrivaPub/Models/User/Avatar.cs index e32dfdf..287f340 100644 --- a/PrivaPub/Models/User/Avatar.cs +++ b/PrivaPub/Models/User/Avatar.cs @@ -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 diff --git a/PrivaPub/Models/User/RootUser.cs b/PrivaPub/Models/User/RootUser.cs index 19ebd2e..174424a 100644 --- a/PrivaPub/Models/User/RootUser.cs +++ b/PrivaPub/Models/User/RootUser.cs @@ -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 diff --git a/PrivaPub/Services/GroupUsersService.cs b/PrivaPub/Services/GroupUsersService.cs index 0efed5a..7c4e9c9 100644 --- a/PrivaPub/Services/GroupUsersService.cs +++ b/PrivaPub/Services/GroupUsersService.cs @@ -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() + .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() + .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 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(f => f.AvatarId == form.AvatarId && f.TargetActorURI == groupUri); + if (personaUri != default) + await DB.Default.DeleteAsync(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group && f.ActorURI == personaUri); return result; } catch (Exception ex) diff --git a/PrivaPub/Services/JwtEvents.cs b/PrivaPub/Services/JwtEvents.cs index ee7c1c6..37c4a47 100644 --- a/PrivaPub/Services/JwtEvents.cs +++ b/PrivaPub/Services/JwtEvents.cs @@ -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) diff --git a/PrivaPub/Services/RootSessions.cs b/PrivaPub/Services/RootSessions.cs index eab0d2b..c40ccac 100644 --- a/PrivaPub/Services/RootSessions.cs +++ b/PrivaPub/Services/RootSessions.cs @@ -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().MatchID(rootId).Modify(u => u.CredentialsChangedAt, DateTime.UtcNow).ExecuteAsync(token); + await DB.Default.Update().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); diff --git a/PrivaPub/StaticServices/AuthTokenManager.cs b/PrivaPub/StaticServices/AuthTokenManager.cs index 5d4e1aa..dd185d4 100644 --- a/PrivaPub/StaticServices/AuthTokenManager.cs +++ b/PrivaPub/StaticServices/AuthTokenManager.cs @@ -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 { 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) );