Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8d353f9a | ||
|
|
e2f61ede56 | ||
|
|
8f25bf056d | ||
|
|
123ba39520 |
No files matched your search
@@ -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
|
||||
@@ -223,7 +230,9 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
||||
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
|
||||
`UseMastodonErrorBodies`. `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and
|
||||
with a junk token.
|
||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
|
||||
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
|
||||
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
|
||||
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus`): object type, title, excerpt,
|
||||
cover, the author's source, link, video, audio and event details, and up/down votes. Every media URL in it goes
|
||||
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
|
||||
@@ -256,8 +265,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
|
||||
|
||||
+42
-19
@@ -12,17 +12,29 @@ PrivaPub is an ActivityPub server written in C#. This document follows
|
||||
|
||||
## Tested against
|
||||
|
||||
- **GoToSocial 0.22.1, end to end.** It runs in a private network on the workstation (`tools/pasture/`) and is driven
|
||||
through its own client API. Checked both ways:
|
||||
- follows, including to a locked account;
|
||||
- public posts with a content warning;
|
||||
- replies with notifications;
|
||||
- likes and boosts;
|
||||
End to end, in a private network on the workstation (`tools/pasture/`), each peer driven through its own client API
|
||||
and every run starting clean, with signed fetches required (as privapub.thepra.dev runs):
|
||||
- **GoToSocial 0.22.1**
|
||||
- **Mastodon 4.7.3**
|
||||
- **Misskey 2026.10**
|
||||
- **Sharkey 2025.4.7**
|
||||
- **Akkoma 3.20.1**
|
||||
- **Lemmy 1.0.0-beta.2**
|
||||
|
||||
Checked both ways, where the peer has the feature:
|
||||
- follows, locked accounts included;
|
||||
- every visibility, content warnings, replies and their notifications;
|
||||
- likes, boosts and emoji reactions with their undos;
|
||||
- direct messages;
|
||||
- edits and deletes;
|
||||
- unfollow.
|
||||
- **Mastodon, Misskey, Lemmy and PeerTube, by unit tests only.** The tests feed the parser documents written in each
|
||||
server's shape. No live exchange with any of them has run yet.
|
||||
- polls;
|
||||
- quotes;
|
||||
- images with alt text;
|
||||
- edits with history and deletes;
|
||||
- communities and circles;
|
||||
- blocks and unfollows.
|
||||
|
||||
`docs/INTEROP.md` has each peer's evidence and what is still expected to fail. PeerTube, PieFed, Mbin and the others are
|
||||
covered by unit tests written in their documents' shape.
|
||||
|
||||
## Supported FEPs
|
||||
|
||||
@@ -30,11 +42,16 @@ PrivaPub is an ActivityPub server written in C#. This document follows
|
||||
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
||||
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md)
|
||||
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
|
||||
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md)
|
||||
(`QuoteAuthorization` at `/parrot-licences/{id}`; checked with Mastodon both ways)
|
||||
- [FEP-9967: Polls](https://codeberg.org/fediverse/fep/src/branch/main/fep/9967/fep-9967.md)
|
||||
- [FEP-c0e0: Emoji reactions](https://codeberg.org/fediverse/fep/src/branch/main/fep/c0e0/fep-c0e0.md) (`EmojiReact`, and Misskey's
|
||||
`Like` with content)
|
||||
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
|
||||
|
||||
Planned (see `docs/ROADMAP.md`, phases P5 to P8, and the per-platform notes in `docs/INTEROP.md`): FEP-044f (quotes),
|
||||
FEP-9967 (polls), FEP-c0e0 (emoji reactions), FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move),
|
||||
FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-8967 (link attachments), FEP-521a and FEP-8b32 (keys
|
||||
and integrity proofs), FEP-ae0c (relays).
|
||||
Planned (see `docs/ROADMAP.md`, phases P7 and P8, and the per-platform notes in `docs/INTEROP.md`): FEP-9098 (custom
|
||||
emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8fcf (followers synchronisation), FEP-8967 (link
|
||||
attachments), FEP-521a and FEP-8b32 (keys and integrity proofs), FEP-ae0c (relays).
|
||||
|
||||
## Actors
|
||||
|
||||
@@ -58,7 +75,8 @@ The names are the project's own and are stable; resolve actors through WebFinger
|
||||
|
||||
- The key is `{actor}#main-key`, RSA 2048, served as SPKI PEM with `owner` set to the actor.
|
||||
- `published` on an actor is a whole day, chosen at random up to two weeks before the account was made, so two
|
||||
personas made on the same day do not share a date. `indexable` is `false`.
|
||||
personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and
|
||||
`discoverable` is `true` unless it turns that off (the deploy's own persona, @thepra, is undiscoverable).
|
||||
- The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key
|
||||
is used to read another server's content.
|
||||
|
||||
@@ -204,9 +222,12 @@ Posts with a location (shown to nearby users of this server) never leave the ser
|
||||
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
||||
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
|
||||
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
|
||||
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and
|
||||
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always
|
||||
sent with their object embedded.
|
||||
- **Activity ids.** `Create` and `Announce` ids dereference, a community's announces included, until the post they
|
||||
carry is deleted (then 410); a persona's boost id sends a browser to the boosted post. `Follow`, `Like`, `Block` and
|
||||
the `Accept`, `Reject` and `Undo` that answer them do not, because serving them would reveal who follows, likes and
|
||||
blocks whom. Neither do `Update`, `Delete`, `EmojiReact`, `QuoteRequest` and its answers, `Flag`, `Ignore` or poll
|
||||
votes. All of them are always sent with their object embedded.
|
||||
- **Hashtags.** A post's `Hashtag` links go to `/tags/{tag}`, a public page of this server's public posts with that tag.
|
||||
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
||||
starting at an hour and growing to a week.
|
||||
|
||||
@@ -215,5 +236,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.
|
||||
@@ -195,11 +195,13 @@ namespace PrivaPub.Tests.Federation
|
||||
entity.Members.Add(new GroupMember { AvatarId = bob.Id });
|
||||
await DB.Default.SaveAsync(entity, token);
|
||||
var outsider = new RemoteActor(_harness.Peer, "asked");
|
||||
await _harness.Remote.GetActor(outsider.Id, refresh: false, token);
|
||||
var outsiderAccount = await _harness.Remote.GetActor(outsider.Id, refresh: false, token);
|
||||
var asksFirst = new Post
|
||||
{
|
||||
ObjectURI = $"{Origin(outsider)}/notes/{Guid.NewGuid():N}",
|
||||
ActorURI = outsider.Id,
|
||||
GroupUserId = outsiderAccount.ID,
|
||||
AuthorAccountId = outsiderAccount.ID,
|
||||
IsFederatedCopy = true,
|
||||
Visibility = PostVisibility.Public,
|
||||
ContentHtml = "<p>ask me first</p>",
|
||||
|
||||
@@ -142,7 +142,9 @@ namespace PrivaPub.Tests.Http
|
||||
Assert.Equal(ActivityJson, outbox.MediaType);
|
||||
Assert.Equal("OrderedCollection", outbox.Json["type"]!.GetValue<string>());
|
||||
Assert.Equal(persona.ActorUri() + "/anus", outbox.Json["id"]!.GetValue<string>());
|
||||
Assert.Equal(22, outbox.Json["totalItems"]!.GetValue<int>());
|
||||
// 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<int>());
|
||||
Assert.Null(outbox.Json["orderedItems"]);
|
||||
var first = outbox.Json["first"]!.GetValue<string>();
|
||||
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<string>());
|
||||
Assert.Equal(0, following.Json["totalItems"]!.GetValue<int>());
|
||||
Assert.Equal(1, following.Json["totalItems"]!.GetValue<int>());//counted, as following_count says, never listed
|
||||
Assert.DoesNotContain(friend.UserName, following.Text);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>()));
|
||||
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"));
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
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<string>(), 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<string>(), 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<Favourite>().Match(f => f.ActorURI == fan.Id).ExecuteAnyAsync(token));
|
||||
Assert.False(await DB.Default.Find<Notification>().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<string>();
|
||||
using var client = _host.As(joinerRoot.Jwt);
|
||||
|
||||
var joined = await client.PostJson("/clientapi/group/join", new { avatarId = joiner.Id, invitationCode = group["invitationCode"]!.GetValue<string>() });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
|
||||
Assert.Equal(1, (await _client.Fetch($"/peasants/{groupName}/groupies")).Json["totalItems"]!.GetValue<int>());
|
||||
Assert.True(await DB.Default.Find<Following>().Match(f => f.AvatarId == joiner.Id && f.State == FollowState.Accepted).ExecuteAnyAsync(token));
|
||||
}
|
||||
|
||||
public static TheoryData<string> 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 Likes_of_one_post_come_as_one_group_of_notifications()
|
||||
{
|
||||
var author = await _host.Mastodon("grouped");
|
||||
var first = await _host.Mastodon("fanone");
|
||||
var second = await _host.Mastodon("fantwo");
|
||||
var post = await author.Status("like me twice");
|
||||
var id = post.Text("id");
|
||||
await first.Client.Post($"/api/v1/statuses/{id}/favourite");
|
||||
await second.Client.Post($"/api/v1/statuses/{id}/favourite");
|
||||
|
||||
var list = (await author.Client.Get("/api/v2/notifications")).Ok().Body;
|
||||
var group = Assert.Single(list["notification_groups"]!.AsArray(), g => g!.Text("type") == "favourite")!;
|
||||
var key = group.Text("group_key");
|
||||
|
||||
Assert.Equal($"grouped-favourite-{id}", key);
|
||||
Assert.Equal(2, group.Number("notifications_count"));
|
||||
Assert.Equal(2, group["sample_account_ids"]!.AsArray().Count);
|
||||
Assert.Equal(id, group.Text("status_id"));
|
||||
Assert.Contains(list["statuses"]!.AsArray(), s => s!.Text("id") == id);
|
||||
Assert.True((await author.Client.Get("/api/v2/notifications/unread_count")).Ok().Body.Number("count") >= 1);
|
||||
Assert.Equal(key, Assert.Single((await author.Client.Get($"/api/v2/notifications/{key}")).Ok().Body["notification_groups"]!.AsArray())!.Text("group_key"));
|
||||
Assert.Equal(2, (await author.Client.Get($"/api/v2/notifications/{key}/accounts")).Ok().Array.Count);
|
||||
Assert.Equal(HttpStatusCode.OK, (await author.Client.Post($"/api/v2/notifications/{key}/dismiss")).Status);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await author.Client.Get($"/api/v2/notifications/{key}")).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hashtag_link_opens_a_page_of_public_posts_with_that_tag()
|
||||
{
|
||||
var author = await _host.Mastodon("tagger");
|
||||
var tag = $"tag{Guid.NewGuid():N}"[..16];
|
||||
await author.Status($"about #{tag}");
|
||||
await author.Status($"quietly about #{tag}", ("visibility", "private"));
|
||||
|
||||
var page = await _client.Fetch($"/tags/{tag}", Browser);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, page.Status);
|
||||
Assert.Contains("about", page.Text);
|
||||
Assert.DoesNotContain("quietly", page.Text);
|
||||
Assert.Contains("default-src 'none'", page.Response.Headers.GetValues("Content-Security-Policy").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_communitys_announces_resolve_while_the_post_is_shown()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var owner = await _host.Persona(await _host.SignUp(), "announcer");
|
||||
var community = await _host.FederatedGroup(owner, community: true);
|
||||
var post = await _host.Publish(owner, new PrivaPub.Domain.Statuses.StatusDraft { Text = "into the community", PlainText = true, GroupId = community.Id });
|
||||
var kept = await DB.Default.Find<GroupAnnouncement>().Match(a => a.GroupId == community.Id && a.ObjectURI == post.ObjectURI).ExecuteAsync(token);
|
||||
Assert.Equal(2, kept.Count);//the activity, and the object for Mastodon
|
||||
|
||||
foreach (var announcement in kept)
|
||||
{
|
||||
var fetched = await _client.Fetch(PathOf(announcement.ActivityURI));
|
||||
Assert.Equal(HttpStatusCode.OK, fetched.Status);
|
||||
Assert.Equal("Announce", fetched.Json["type"]!.GetValue<string>());
|
||||
Assert.Equal(announcement.ActivityURI, fetched.Json["id"]!.GetValue<string>());
|
||||
}
|
||||
var listed = await _client.Fetch($"/peasants/{community.UserName}/grunts/announce-{post.ID}");
|
||||
Assert.Equal(HttpStatusCode.OK, listed.Status);
|
||||
|
||||
await _host.Remove(owner, post);
|
||||
Assert.Equal(HttpStatusCode.Gone, (await _client.Fetch(PathOf(kept[0].ActivityURI))).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_boost_points_at_the_boosted_posts_page()
|
||||
{
|
||||
var author = await _host.Mastodon("boosted");
|
||||
var booster = await _host.Mastodon("booster");
|
||||
var post = await author.Status("boost me");
|
||||
|
||||
var boost = (await booster.Client.Post($"/api/v1/statuses/{post.Text("id")}/reblog")).Ok().Body;
|
||||
|
||||
Assert.Equal(post.Text("url"), boost.Text("url"));
|
||||
var browser = await _client.Fetch(PathOf(boost.Text("uri")), Browser);
|
||||
Assert.Equal(HttpStatusCode.Redirect, browser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_accounts_counts_are_what_its_server_publishes()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var reader = await _host.Mastodon("counter");
|
||||
var popular = new RemoteActor(_peer, "popular");
|
||||
_peer.Serve(new Uri(popular.Id).AbsolutePath + "/followers",
|
||||
$$"""{"@context":"https://www.w3.org/ns/activitystreams","id":"{{popular.Id}}/followers","type":"OrderedCollection","totalItems":42}""");
|
||||
var remote = _host.Get<PrivaPub.Federation.Actors.IRemoteActorService>();
|
||||
var account = await remote.GetActor(popular.Id, refresh: true, token);
|
||||
|
||||
await _host.Run(j => j.Kind == PrivaPub.Models.Jobs.JobKind.CountAccount && j.Payload == popular.Id, token);
|
||||
|
||||
var shown = (await reader.Client.Get($"/api/v1/accounts/{account.ID}")).Ok().Body;
|
||||
Assert.Equal(42, shown.Number("followers_count"));
|
||||
Assert.Equal(0, shown.Number("following_count"));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,8 +179,10 @@ namespace PrivaPub.Tests.Http
|
||||
return (await client.Fetch("/nodeinfo/2.1", "application/json")).Json["usage"]!["localPosts"]!.GetValue<long>();
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
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>());
|
||||
}
|
||||
}
|
||||
@@ -177,8 +177,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> List(CancellationToken token)
|
||||
Find<NotificationEntity, NotificationEntity> Filtered()
|
||||
{
|
||||
var types = Params.List("types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
||||
var excluded = Params.List("exclude_types").Select(Parse).Where(t => t.HasValue).Select(t => t.Value).ToList();
|
||||
@@ -189,11 +188,132 @@ namespace PrivaPub.Api.Mastodon.Controllers
|
||||
query.Match(n => !excluded.Contains(n.Type));
|
||||
if (Params.Get("account_id") is { } accountId)
|
||||
query.Match(n => n.FromAccountId == accountId);
|
||||
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(query, n => n.ID, token);
|
||||
return query;
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> List(CancellationToken token)
|
||||
{
|
||||
var notifications = await Page.From(Params, Limit(15, 30)).Fetch(Filtered(), n => n.ID, token);
|
||||
Link("/api/v1/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
||||
return Json(await Map(notifications, token));
|
||||
}
|
||||
|
||||
// Grouped notifications (Mastodon 4.3): api_versions.mastodon says we speak that API, so clients call it. Likes and
|
||||
// boosts of one post group together, as do follows within an hour; everything else stands alone.
|
||||
static readonly HashSet<string> DefaultGrouped = new() { "favourite", "reblog", "follow" };
|
||||
|
||||
HashSet<string> Grouped() => Params.List("grouped_types") is { Count: > 0 } chosen ? chosen.ToHashSet() : DefaultGrouped;
|
||||
|
||||
static string GroupKey(NotificationEntity n, ISet<string> grouped) =>
|
||||
!grouped.Contains(Names[n.Type]) ? $"ungrouped-{n.ID}"
|
||||
: n.Type == NotificationType.Follow ? $"grouped-follow-{n.CreatedAt:yyyyMMddHH}"
|
||||
: string.IsNullOrEmpty(n.PostId) ? $"ungrouped-{n.ID}"
|
||||
: $"grouped-{Names[n.Type]}-{n.PostId}";
|
||||
|
||||
// the notifications a group key stands for
|
||||
System.Linq.Expressions.Expression<Func<NotificationEntity, bool>> InGroup(string key)
|
||||
{
|
||||
var me = MyId;
|
||||
if (key.StartsWith("ungrouped-", StringComparison.Ordinal))
|
||||
{
|
||||
var id = key["ungrouped-".Length..];
|
||||
return n => n.AvatarId == me && n.ID == id;
|
||||
}
|
||||
if (key.StartsWith("grouped-follow-", StringComparison.Ordinal)
|
||||
&& DateTime.TryParseExact(key["grouped-follow-".Length..], "yyyyMMddHH", System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var hour))
|
||||
{
|
||||
var end = hour.AddHours(1);
|
||||
return n => n.AvatarId == me && n.Type == NotificationType.Follow && n.CreatedAt >= hour && n.CreatedAt < end;
|
||||
}
|
||||
if (key.Split('-') is ["grouped", var name, var postId] && Parse(name) is { } type)
|
||||
return n => n.AvatarId == me && n.Type == type && n.PostId == postId;
|
||||
return default;
|
||||
}
|
||||
|
||||
async Task<List<NotificationEntity>> Members(string key, CancellationToken token) =>
|
||||
InGroup(key) is { } filter
|
||||
? await _dbEntities.Notifications.Match(filter).Sort(n => n.ID, Order.Descending).Limit(80).ExecuteAsync(token)
|
||||
: new List<NotificationEntity>();
|
||||
|
||||
async Task<object> Groups(List<NotificationEntity> page, ISet<string> grouped, CancellationToken token)
|
||||
{
|
||||
var mapped = (await Map(page, token)).ToDictionary(n => n.Id);
|
||||
var shown = page.Where(n => mapped.ContainsKey(n.ID)).ToList();
|
||||
var groups = new List<object>();
|
||||
foreach (var group in shown.GroupBy(n => GroupKey(n, grouped)))
|
||||
{
|
||||
var newest = mapped[group.First().ID];
|
||||
var total = group.Key.StartsWith("ungrouped-", StringComparison.Ordinal) ? 1 : await DB.Default.CountAsync(InGroup(group.Key), token);
|
||||
groups.Add(new
|
||||
{
|
||||
group_key = group.Key,
|
||||
notifications_count = Math.Max(total, group.Count()),
|
||||
type = newest.Type,
|
||||
most_recent_notification_id = newest.Id,
|
||||
page_min_id = group.Last().ID,
|
||||
page_max_id = group.First().ID,
|
||||
latest_page_notification_at = newest.CreatedAt,
|
||||
sample_account_ids = group.Select(n => mapped[n.ID].Account.Id).Distinct().Take(8).ToList(),
|
||||
status_id = newest.Status?.Id,
|
||||
emoji = newest.Emoji,
|
||||
emoji_url = newest.EmojiUrl
|
||||
});
|
||||
}
|
||||
return new
|
||||
{
|
||||
accounts = mapped.Values.Select(n => n.Account).DistinctBy(a => a.Id).ToList(),
|
||||
statuses = mapped.Values.Where(n => n.Status != default).Select(n => n.Status).DistinctBy(s => s.Id).ToList(),
|
||||
notification_groups = groups
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupedList(CancellationToken token)
|
||||
{
|
||||
var notifications = await Page.From(Params, Limit(40, 80)).Fetch(Filtered(), n => n.ID, token);
|
||||
Link("/api/v2/notifications", notifications.LastOrDefault()?.ID, notifications.FirstOrDefault()?.ID);
|
||||
return Json(await Groups(notifications, Grouped(), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/unread_count"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupedUnreadCount(CancellationToken token)
|
||||
{
|
||||
var unread = await Filtered().Match(n => !n.IsRead).Sort(n => n.ID, Order.Descending)
|
||||
.Limit(Math.Clamp(Params.Int("limit") ?? 100, 1, 1000)).ExecuteAsync(token);
|
||||
var shown = (await Map(unread, token)).Select(n => n.Id).ToHashSet();
|
||||
var grouped = Grouped();
|
||||
return Json(new { count = unread.Where(n => shown.Contains(n.ID)).Select(n => GroupKey(n, grouped)).Distinct().Count() });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/{groupKey}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Group(string groupKey, CancellationToken token)
|
||||
{
|
||||
var members = await Members(groupKey, token);
|
||||
if (members.Count == 0)
|
||||
return NotFoundError();
|
||||
return Json(await Groups(members, new HashSet<string>(DefaultGrouped.Append(Names[members[0].Type])), token));
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/notifications/{groupKey}/accounts"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> GroupAccounts(string groupKey, CancellationToken token)
|
||||
{
|
||||
var members = await Members(groupKey, token);
|
||||
if (members.Count == 0)
|
||||
return NotFoundError();
|
||||
return Json((await Map(members, token)).Select(n => n.Account).DistinctBy(a => a.Id).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/v2/notifications/{groupKey}/dismiss"), Scope("write:notifications")]
|
||||
public async Task<IActionResult> DismissGroup(string groupKey, CancellationToken token)
|
||||
{
|
||||
if (InGroup(groupKey) is not { } filter)
|
||||
return NotFoundError();
|
||||
await DB.Default.DeleteAsync(filter);
|
||||
return Json(new { });
|
||||
}
|
||||
|
||||
[HttpGet("/api/v1/notifications/{id}"), Scope("read:notifications")]
|
||||
public async Task<IActionResult> Get(string id, CancellationToken token)
|
||||
{
|
||||
@@ -216,9 +336,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,23 +34,30 @@ 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))
|
||||
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),
|
||||
@@ -124,7 +120,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
Avatar = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||
AvatarStatic = Proxied(foreign.PictureURL) ?? MissingAvatar,
|
||||
Header = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||||
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader
|
||||
HeaderStatic = Proxied(foreign.ThumbnailURL) ?? MissingHeader,
|
||||
FollowersCount = foreign.FollowersCount ?? 0,//as its server publishes them (AccountCountsJob); 0 while unknown
|
||||
FollowingCount = foreign.FollowingCount ?? 0,
|
||||
StatusesCount = foreign.StatusesCount ?? 0
|
||||
};
|
||||
|
||||
public async Task<Account> Account(string id, CancellationToken token) =>
|
||||
@@ -141,7 +140,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;
|
||||
}
|
||||
@@ -202,8 +201,8 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
|
||||
Status Map(PostEntity post)
|
||||
{
|
||||
if (!accounts.TryGetValue(AuthorOf(post), out var account))
|
||||
return default;
|
||||
if (AuthorOf(post) is not { } author || !accounts.TryGetValue(author, out var account))
|
||||
return default;//a post whose author we cannot show is left out, never a 500
|
||||
var status = new Status
|
||||
{
|
||||
Id = post.ID,
|
||||
@@ -283,6 +282,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
|
||||
continue;
|
||||
Quote(inner, original);
|
||||
status.Reblog = inner;
|
||||
status.Url = inner.Url;//a boost has no page of its own; /grunts would answer JSON to a browser
|
||||
status.Content = string.Empty;
|
||||
status.Reblogged = reblogged.Contains(original.ID);
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Objects;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
using PrivaPub.Infrastructure.Jobs;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
// A remote account's follower, following and post counts, as its own server publishes them (the totalItems of its
|
||||
// followers, following and outbox collections). Read when the account is fetched, at most once a day, never when
|
||||
// someone looks at it; a hidden or missing collection leaves its count unknown. Mastodon and GoToSocial do the same.
|
||||
public class AccountCountsJob : IJobHandler
|
||||
{
|
||||
readonly IRemoteActorService _remoteActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public AccountCountsJob(IRemoteActorService remoteActors, DbEntities dbEntities)
|
||||
{
|
||||
_remoteActors = remoteActors;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public JobKind Kind => JobKind.CountAccount;
|
||||
public int Concurrency => 2;
|
||||
public int MaxAttempts => 2;
|
||||
public int PerHostLimit => 1;
|
||||
|
||||
public static string DedupeKey(string actorUri, DateTime day) => $"count|{actorUri}|{day:yyyyMMdd}";
|
||||
|
||||
public async Task<JobOutcome> Handle(Job job, CancellationToken token)
|
||||
{
|
||||
var actor = await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == job.Payload && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
if (actor == default)
|
||||
return JobOutcome.Done;
|
||||
await DB.Default.Update<ForeignAvatar>().MatchID(actor.ID)
|
||||
.Modify(a => a.FollowersCount, await Total(actor, actor.FollowersURL, token))
|
||||
.Modify(a => a.FollowingCount, await Total(actor, actor.FollowingURL, token))
|
||||
.Modify(a => a.StatusesCount, await Total(actor, actor.OutboxURL, token))
|
||||
.Modify(a => a.CountedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync(token);
|
||||
return JobOutcome.Done;
|
||||
}
|
||||
|
||||
// only from the actor's own server, as everything we believe about it
|
||||
async Task<int?> Total(ForeignAvatar actor, string collection, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(collection) || !Origin.Same(collection, actor.ActorURI))
|
||||
return default;
|
||||
using var scope = HttpScope.For("collection");
|
||||
using var fetched = await _remoteActors.FetchObject(collection, token);
|
||||
return fetched != default && fetched.Root.ValueKind == System.Text.Json.JsonValueKind.Object
|
||||
&& fetched.Root.TryGetProperty("totalItems", out var total) && total.TryGetInt32(out var count) && count >= 0
|
||||
? count
|
||||
: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,12 @@ namespace PrivaPub.Federation.Actors
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly IOptionsMonitor<FederationOptions> _options;
|
||||
|
||||
readonly Infrastructure.Jobs.IJobQueue _jobs;
|
||||
|
||||
public RemoteActorService(IFederationHttp http, ILocalActorService localActors, IMemoryCache cache, DbEntities dbEntities,
|
||||
IOptionsMonitor<FederationOptions> options = default)
|
||||
IOptionsMonitor<FederationOptions> options = default, Infrastructure.Jobs.IJobQueue jobs = default)
|
||||
{
|
||||
_jobs = jobs;
|
||||
_http = http;
|
||||
_localActors = localActors;
|
||||
_cache = cache;
|
||||
@@ -89,7 +92,16 @@ namespace PrivaPub.Federation.Actors
|
||||
return cached;
|
||||
|
||||
var key = cached == default ? default : actor.Key(cached.PublicKeyId);
|
||||
return await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token);
|
||||
return await Counted(await Upsert(actor, key ?? actor.Keys.FirstOrDefault(), token), token);
|
||||
}
|
||||
|
||||
// its counts are read once a day, after it was fetched (AccountCountsJob)
|
||||
async Task<ForeignAvatar> Counted(ForeignAvatar stored, CancellationToken token)
|
||||
{
|
||||
if (_jobs != default && stored is { DeletionAt: null } && Uri.TryCreate(stored.ActorURI, UriKind.Absolute, out var uri))
|
||||
await _jobs.Enqueue(Models.Jobs.JobKind.CountAccount, stored.ActorURI, uri.Host.ToLowerInvariant(),
|
||||
AccountCountsJob.DedupeKey(stored.ActorURI, DateTime.UtcNow), token);
|
||||
return stored;
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token)
|
||||
@@ -121,7 +133,7 @@ namespace PrivaPub.Federation.Actors
|
||||
var key = actor?.Key(keyId);
|
||||
if (key == default)
|
||||
return default;
|
||||
return await Upsert(actor, key, token);
|
||||
return await Counted(await Upsert(actor, key, token), token);
|
||||
}
|
||||
|
||||
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -102,7 +101,7 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
if (local.Kind == LocalActorKind.Group)
|
||||
{
|
||||
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}"));
|
||||
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}", post.CreationDate));
|
||||
continue;
|
||||
}
|
||||
if (post.ReblogOfPostId != default)
|
||||
@@ -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")]
|
||||
@@ -253,8 +258,12 @@ namespace PrivaPub.Federation.Controllers
|
||||
{
|
||||
var booster = await _localActors.FindByUserName(actor, token);
|
||||
var uri = booster?.ActivityUri(activityId);
|
||||
if (booster is { Kind: LocalActorKind.Group, IsCircle: false })
|
||||
return await GroupAnnounce(booster, activityId, uri, token);
|
||||
var reblog = uri == default ? default : await _dbEntities.Posts.Match(p => p.ObjectURI == uri && !p.DeletedAt.HasValue).ExecuteFirstAsync(token);
|
||||
var rendered = reblog == default ? default : await AnnounceFor(reblog, token);
|
||||
if (rendered != default && WantsHtml() && await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token) is { } boosted)
|
||||
return Redirect(boosted.Url ?? boosted.ObjectURI);//a boost has no page of its own: the boosted post's
|
||||
return rendered == default ? NotFound() : Activity(rendered);
|
||||
}
|
||||
if (!activityId.StartsWith("create-", StringComparison.Ordinal))
|
||||
@@ -381,6 +390,24 @@ namespace PrivaPub.Federation.Controllers
|
||||
return new ContentResult { Content = tombstone.ToJsonString(), ContentType = ActivityContentType, StatusCode = StatusCodes.Status410Gone };
|
||||
}
|
||||
|
||||
// A community's announce: one GroupDistributor sent (kept as sent), or one its outbox lists (announce-{postId}),
|
||||
// while the post it is about is still shown; 410 once that post is gone.
|
||||
async Task<IActionResult> GroupAnnounce(LocalActor group, string activityId, string uri, CancellationToken token)
|
||||
{
|
||||
if (await DB.Default.Find<GroupAnnouncement>().Match(a => a.ActivityURI == uri && a.GroupId == group.Id).ExecuteFirstAsync(token) is { } kept)
|
||||
{
|
||||
var about = await _dbEntities.Posts.Match(p => p.ObjectURI == kept.ObjectURI).ExecuteFirstAsync(token);
|
||||
if (!VisibilityPolicy.Shown(about) || about.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
|
||||
return StatusCode(StatusCodes.Status410Gone);
|
||||
var body = JsonNode.Parse(kept.Body)!.AsObject();
|
||||
body["@context"] = ActivityPubRenderer.Context();
|
||||
return Activity(body);
|
||||
}
|
||||
var postId = activityId["announce-".Length..];
|
||||
var post = await _dbEntities.Posts.Match(p => p.ID == postId && p.GroupId == group.Id).Match(VisibilityPolicy.IsPublic).ExecuteFirstAsync(token);
|
||||
return post == default ? NotFound() : Activity(ActivityPubRenderer.Announce(group, post.ObjectURI, activityId, post.CreationDate));
|
||||
}
|
||||
|
||||
async Task<JsonObject> AnnounceFor(PostEntity reblog, CancellationToken token)
|
||||
{
|
||||
var original = await _dbEntities.Posts.MatchID(reblog.ReblogOfPostId).ExecuteFirstAsync(token);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Rendering;
|
||||
using PrivaPub.Models.Federation;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -14,6 +17,26 @@ namespace PrivaPub.Federation.Outbox
|
||||
|
||||
public class GroupDistributor : IGroupDistributor
|
||||
{
|
||||
// so the announce's id resolves (PeasantsController.Grunt); a repeat of the same announce keeps the first
|
||||
static async Task Keep(LocalActor group, JsonObject announce, string objectUri, CancellationToken token)
|
||||
{
|
||||
var body = (JsonObject)announce.DeepClone();
|
||||
body.Remove("@context");
|
||||
try
|
||||
{
|
||||
await DB.Default.SaveAsync(new GroupAnnouncement
|
||||
{
|
||||
GroupId = group.Id,
|
||||
ActivityURI = body["id"]!.GetValue<string>(),
|
||||
ObjectURI = objectUri,
|
||||
Body = body.ToJsonString()
|
||||
}, token);
|
||||
}
|
||||
catch (MongoDB.Driver.MongoWriteException ex) when (ex.WriteError?.Category == MongoDB.Driver.ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
readonly IDeliveryService _delivery;
|
||||
|
||||
public GroupDistributor(IDeliveryService delivery)
|
||||
@@ -43,8 +66,13 @@ namespace PrivaPub.Federation.Outbox
|
||||
// Every follower, the author's own server included: Lemmy 1.0 keeps its user's post pending until the community
|
||||
// announces it back, and clears that before it answers the echo 400 ("Object is not remote").
|
||||
await _delivery.EnqueueToFollowers(group, announce, token);
|
||||
await Keep(group, announce, objectUri, token);
|
||||
if (isNewPost && !string.IsNullOrEmpty(objectUri))
|
||||
await _delivery.EnqueueToFollowers(group, ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}"), token);
|
||||
{
|
||||
var boost = ActivityPubRenderer.Announce(group, objectUri, $"announce-object-{key}");
|
||||
await _delivery.EnqueueToFollowers(group, boost, token);
|
||||
await Keep(group, boost, objectUri, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,13 +357,13 @@ namespace PrivaPub.Federation.Rendering
|
||||
["object"] = note
|
||||
};
|
||||
|
||||
public static JsonObject Announce(LocalActor group, string objectUri, string activityId) => new()
|
||||
public static JsonObject Announce(LocalActor group, string objectUri, string activityId, DateTime? published = default) => new()
|
||||
{
|
||||
["@context"] = ActivityStreams,
|
||||
["id"] = group.ActivityUri(activityId),
|
||||
["type"] = "Announce",
|
||||
["actor"] = group.Uri,
|
||||
["published"] = Timestamp(DateTime.UtcNow),
|
||||
["published"] = Timestamp(published ?? DateTime.UtcNow),
|
||||
["to"] = new JsonArray(Public),
|
||||
["cc"] = new JsonArray(group.Followers),
|
||||
["object"] = objectUri
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace PrivaPub.Infrastructure.Data
|
||||
await Plain<Group>(token, g => g.InvitationCode);
|
||||
|
||||
await Plain<DmGroup>(token, g => g.ParticipantsKey);
|
||||
await Unique<GroupAnnouncement>(a => a.ActivityURI, Builders<GroupAnnouncement>.Filter.Type(a => a.ActivityURI, BsonType.String), token);
|
||||
|
||||
await Plain<Delivery>(token, d => d.DeliveredAt, d => d.AbandonedAt, d => d.NextAttemptAt);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ namespace PrivaPub.Middleware
|
||||
.AddSingleton<IQuoteService, QuoteService>()
|
||||
.AddSingleton<IJobHandler, PollRefreshJob>()
|
||||
.AddSingleton<IJobHandler, RecoveryJob>()
|
||||
.AddSingleton<IJobHandler, Federation.Actors.AccountCountsJob>()
|
||||
.AddSingleton<IJobHandler, PollCloseJob>()
|
||||
.AddSingleton<InstanceDescriber>()
|
||||
.AddSingleton<IJobHandler>(services => services.GetRequiredService<InstanceDescriber>())
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
namespace PrivaPub.Models.Federation
|
||||
{
|
||||
// An Announce a community sent (GroupDistributor), kept so its id resolves as both docs promise: announces are public,
|
||||
// unlike follows, likes and blocks. Served only while the post it is about is still shown.
|
||||
public class GroupAnnouncement : Entity
|
||||
{
|
||||
public string GroupId { get; set; }
|
||||
public string ActivityURI { get; set; }
|
||||
public string ObjectURI { get; set; }//the post it is about
|
||||
public string Body { get; set; }//the Announce as sent, without @context
|
||||
public DateTime PublishedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,8 @@ namespace PrivaPub.Models.Jobs
|
||||
RollupDay,
|
||||
CrawlPlan,
|
||||
CrawlInstance,
|
||||
SendRecovery
|
||||
SendRecovery,
|
||||
CountAccount
|
||||
}
|
||||
|
||||
public enum JobState
|
||||
|
||||
@@ -87,6 +87,11 @@ 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 int? FollowersCount { get; set; }//its own collections' totalItems, read at most daily (AccountCountsJob)
|
||||
public int? FollowingCount { get; set; }
|
||||
public int? StatusesCount { get; set; }
|
||||
public DateTime? CountedAt { get; set; }
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -92,6 +92,49 @@ namespace PrivaPub.Web.Pages
|
||||
}
|
||||
}
|
||||
|
||||
// The hashtag links our posts carry (/tags/{tag}): this server's public posts with that tag, as a page like a profile's.
|
||||
public class TagModel : PublicPageModel
|
||||
{
|
||||
const int PageSize = 20;
|
||||
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly DbEntities _dbEntities;
|
||||
|
||||
public TagModel(ILocalActorService localActors, DbEntities dbEntities)
|
||||
{
|
||||
_localActors = localActors;
|
||||
_dbEntities = dbEntities;
|
||||
}
|
||||
|
||||
public string Tag { get; private set; }
|
||||
public IReadOnlyList<PostView> Posts { get; private set; } = Array.Empty<PostView>();
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(string tag, CancellationToken token)
|
||||
{
|
||||
Tag = tag?.TrimStart('#').ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(Tag) || Tag.Length > 100)
|
||||
return NotFound();
|
||||
Harden();
|
||||
var posts = await _dbEntities.Posts
|
||||
.Match(p => p.Tags.Contains(Tag) && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.Visibility == PostVisibility.Public)
|
||||
.Match(VisibilityPolicy.IsPublic)
|
||||
.Sort(p => p.ID, Order.Descending)
|
||||
.Limit(PageSize)
|
||||
.ExecuteAsync(token);
|
||||
var authors = new Dictionary<string, LocalActor>();
|
||||
var views = new List<PostView>();
|
||||
foreach (var post in posts)
|
||||
{
|
||||
if (!authors.TryGetValue(post.GroupUserId, out var author))
|
||||
authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
|
||||
if (author is { IsFederated: true })
|
||||
views.Add(PostView.From(post, author));
|
||||
}
|
||||
Posts = views;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
|
||||
public class StatusModel : PublicPageModel
|
||||
{
|
||||
readonly ILocalActorService _localActors;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
@page "/tags/{tag}"
|
||||
@model PrivaPub.Web.Pages.TagModel
|
||||
@{
|
||||
ViewData["Title"] = $"#{Model.Tag}";
|
||||
}
|
||||
<header>
|
||||
<h1>#@Model.Tag</h1>
|
||||
<div class="meta">Public posts on this server with this tag.</div>
|
||||
</header>
|
||||
@foreach (var post in Model.Posts)
|
||||
{
|
||||
<partial name="_Post" model="post" />
|
||||
}
|
||||
@if (Model.Posts.Count == 0)
|
||||
{
|
||||
<p class="meta">Nothing public with this tag yet.</p>
|
||||
}
|
||||
+19
-6
@@ -35,11 +35,21 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
||||
- v1.18.0, deployed and verified 2026-10-04: geolocation that updates itself (DB-IP Lite 2026-10 loaded), the deploy
|
||||
signing in as @thepra (undiscoverable), the crawler on (1010 servers known within the hour), sign-up by invitation
|
||||
with one registrations switch;
|
||||
- v1.19.0, 2026-10-04: circle posts reach Mastodon and GoToSocial members (each copy names and mentions its member),
|
||||
followers-only, direct and circle posts served to signed refetches from those they were for, SecureMode on (all six
|
||||
pasture peers pass under it), and account privacy (sign-in and recovery say nothing, recovery codes hashed for an
|
||||
hour, a recovered password ends every session, a deleted root's personas and groups are deleted everywhere);
|
||||
- still to come: one answer everywhere (the mismatch sweep).
|
||||
- v1.19.1, deployed and verified 2026-10-04 (v1.19.0's deploy stopped at a flaky test, fixed in v1.19.1):
|
||||
- circle posts reach Mastodon and GoToSocial members, each copy naming and mentioning its member;
|
||||
- followers-only, direct and circle posts are served to signed refetches from those they were for;
|
||||
- SecureMode is on: a persona answers 401 unsigned, a browser is redirected, and all six pasture peers pass;
|
||||
- account privacy: sign-in and recovery say nothing, recovery codes are hashed for an hour, a recovered password
|
||||
ends every session, and a deleted root's personas and groups are deleted everywhere;
|
||||
- v1.20.0, 2026-10-04: one answer everywhere:
|
||||
- one counting rule for posts and users;
|
||||
- gone accounts out of every count;
|
||||
- replies_count only for public replies;
|
||||
- search anyone may use;
|
||||
- the instance API advertising what is enforced;
|
||||
- Mastodon routes that answer, grouped notifications;
|
||||
- community announce ids that resolve, a hashtag page;
|
||||
- remote accounts' real counts.
|
||||
- [ ] P7 Threads, communities, moderation, the social graph
|
||||
- [ ] P8 Signatures, discovery, the long tail
|
||||
|
||||
@@ -504,7 +514,10 @@ it, raw where it doesn't.
|
||||
so Mastodon 4.7 retries instead of switching to RFC 9421.
|
||||
- **Deliberately not done: dereferenceable `Follow`, `Like`, `Block`, `Accept`, `Reject` and `Undo` ids.** Serving them
|
||||
would publish who follows, likes and blocks whom, which our collections deliberately hide. Every one of them is sent
|
||||
with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference.
|
||||
with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference, a community's
|
||||
announces included (kept since 2026-10-04 while the post is shown, 410 after). Update, Delete, EmojiReact,
|
||||
QuoteRequest and its answers, Flag, Ignore and poll votes do not either: like Mastodon's, they are sent whole and
|
||||
never fetched.
|
||||
|
||||
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
|
||||
- **Custom emoji** on posts, names, fields and poll options, proxied (done in v1.10.0), together with fuller remote
|
||||
|
||||
@@ -57,7 +57,8 @@ until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id/context" | j
|
||||
echo " likes, boosts and reactions"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/favourite"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/reblog"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "alice_akkoma's like and boost count on Akkoma" || ko "like or boost not counted on Akkoma"
|
||||
# Akkoma counts them from its own job queue, which can lag behind under the load of a six-peer run: two minutes
|
||||
until_true 60 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "1 1" ]' && ok "alice_akkoma's like and boost count on Akkoma" || ko "like or boost not counted on Akkoma"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unfavourite"
|
||||
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$a_post_on_p/unreblog"
|
||||
until_true 30 '[ "$(acurl -H "$KH" "$AK/api/v1/statuses/$a_post_id" | j "print(d[\"favourites_count\"], d[\"reblogs_count\"])")" = "0 0" ]' && ok "alice_akkoma's unlike and unboost reach Akkoma" || ko "undo of like or boost not applied on Akkoma"
|
||||
|
||||
Reference in new issue
Block a user